Revision control
++ // Copyright (C) 2026 The Qt Company Ltd.
++ // SPDX-License-Identifier: LicenseRef-Qt-Commercial OR GPL-3.0-only
++
++ // Two ways a body ends up on a trigger's list and never comes off it again,
++ // which leaves collisionCount counting a body that is not inside any more.
++ //
++ // A body deleted while inside is the first. Nothing took it off the list, so
++ // the count kept it and the list kept a pointer to an object that no longer
++ // exists. Nothing is reported for such a body either, so bodyExited has to
++ // stay silent while the count still drops.
++ //
++ // A body that turns sendTriggerReports off while inside is the second. The
++ // flag was read again as the body left, so one that had stopped asking was
++ // never taken off the list it was put on when it did ask.
++ //
++ // No rendering is needed for either, so scene is a plain Node rather than a
++ // View3D.
++
++ import QtQuick
++ import QtTest
++ import QtQuick3D
++ import QtQuick3D.Physics
++ import QtQuick3D.Physics.TestUtils
++
++ Item {
++ id: root
++ width: 64
++ height: 64
++
++ PhysicsWorld {
++ id: world
++ // Off, so a body stays where it is put and only the test moves it.
++ gravity: Qt.vector3d(0, 0, 0)
++ running: true
++ typicalLength: 100
++ typicalSpeed: 1000
++ minimumTimestep: 15
++ maximumTimestep: 15
++ scene: bodies
++
++ property int frames: 0
++ onFrameDone: world.frames++
++ }
++
++ component AfterFrames: Timer {
++ id: gate
++ property int frames: 30
++ property int startedAt: 0
++ signal reached()
++
++ interval: 16
++ repeat: true
++ onRunningChanged: if (running) gate.startedAt = world.frames
++ onTriggered: {
++ if (world.frames - gate.startedAt < gate.frames)
++ return;
++ gate.running = false;
++ gate.reached();
++ }
++ }
++
++ component Region: TriggerBody {
++ collisionShapes: BoxShape {
++ extents: Qt.vector3d(400, 400, 400)
++ }
++ }
++
++ component Occupant: DynamicRigidBody {
++ sendTriggerReports: true
++ collisionShapes: BoxShape {
++ extents: Qt.vector3d(40, 40, 40)
++ }
++ }
++
++ Node {
++ id: bodies
++
++ // ---- a body deleted while inside ----
++
++ Region {
++ id: outlived
++ position: Qt.vector3d(0, 0, 0)
++
++ property int exits: 0
++ onBodyExited: (body) => outlived.exits++
++ }
++
++ Occupant {
++ id: doomed
++ position: Qt.vector3d(0, 0, 0)
++ }
++
++ QtObject {
++ id: deletion
++ property int countInside: -1
++ property int countAfter: -1
++ property int exitsAfter: -1
++ }
++
++ AfterFrames {
++ running: true
++ onReached: {
++ deletion.countInside = outlived.collisionCount;
++ doomed.destroy();
++ deletedGate.running = true;
++ }
++ }
++
++ AfterFrames {
++ id: deletedGate
++ onReached: {
++ deletion.countAfter = outlived.collisionCount;
++ deletion.exitsAfter = outlived.exits;
++ }
++ }
++
++ // ---- a body that stops asking while it is inside ----
++
++ Region {
++ id: forsaken
++ position: Qt.vector3d(2000, 0, 0)
++ }
++
++ Occupant {
++ id: quitter
++ position: Qt.vector3d(2000, 0, 0)
++ }
++
++ QtObject {
++ id: quit
++ property int countInside: -1
++ property int countAfter: -1
++ }
++
++ AfterFrames {
++ running: true
++ onReached: {
++ quit.countInside = forsaken.collisionCount;
++ quitter.sendTriggerReports = false;
++ // Out of the region it is no longer asking to be told about.
++ quitter.reset(Qt.vector3d(4000, 0, 0), Qt.vector3d(0, 0, 0));
++ quitGate.running = true;
++ }
++ }
++
++ AfterFrames {
++ id: quitGate
++ onReached: quit.countAfter = forsaken.collisionCount
++ }
++ }
++
++ PhysicsTestCase {
++ name: "a body deleted while inside stops being counted"
++ timeoutSecs: 10
++ goalReached: deletion.countInside === 1 && deletion.countAfter === 0
++ }
++
++ PhysicsTestCase {
++ name: "a body deleted while inside is not reported as having left"
++ timeoutSecs: 10
++ goalReached: deletion.countAfter === 0 && deletion.exitsAfter === 0
++ }
++
++ PhysicsTestCase {
++ name: "a body that stopped asking is still taken off the list"
++ timeoutSecs: 10
++ goalReached: quit.countInside === 1 && quit.countAfter === 0
++ }
++ }