Revision control

++ // Copyright (C) 2026 The Qt Company Ltd.
++ // SPDX-License-Identifier: LicenseRef-Qt-Commercial OR GPL-3.0-only
++
++ // Takes the scene away from the world from inside a trigger report, while the
++ // batch of reports that carries it is still on the stack. Moving the world
++ // deregisters every body at once, which is what deleting one does to the
++ // trigger callback's end of things, and it lands while the callback is still
++ // running rather than whenever the object a delete was asked for is deferred
++ // to. Deregistering from there took the lock the callback held, on the thread
++ // that already held it, so the application hung. Reaching the next frame is
++ // the assertion.
++ //
++ // Three bodies cross the boundary together, so the report the world is moved
++ // from is one of a batch and the pairs behind it are about bodies the move has
++ // taken out. Only the first is reported: the rest are asked about one at a
++ // time and skipped.
++ //
++ // No rendering is needed for any of this, 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
++
++ // The frame the world was moved on, set by the first report to arrive.
++ property int frameAtMove: -1
++ property int reports: 0
++
++ PhysicsWorld {
++ id: world
++ gravity: Qt.vector3d(0, -981, 0)
++ running: true
++ typicalLength: 100
++ typicalSpeed: 1000
++ minimumTimestep: 15
++ maximumTimestep: 15
++ scene: fallingScene
++
++ property int frameCount: 0
++ onFrameDone: world.frameCount++
++ }
++
++ Node {
++ id: fallingScene
++
++ TriggerBody {
++ id: gate
++ collisionShapes: BoxShape {
++ extents: Qt.vector3d(400, 100, 100)
++ }
++
++ onBodyEntered: (body) => {
++ root.reports++;
++ if (root.frameAtMove < 0) {
++ root.frameAtMove = world.frameCount;
++ world.scene = elsewhere;
++ }
++ }
++ }
++
++ // Side by side and let go of from the same height, so they reach the
++ // trigger on the same step and the pairs that say so arrive together.
++ Repeater3D {
++ model: 3
++ DynamicRigidBody {
++ required property int index
++ position: Qt.vector3d(index * 120 - 120, 200, 0)
++ sendTriggerReports: true
++ collisionShapes: BoxShape {
++ extents: Qt.vector3d(50, 50, 50)
++ }
++ }
++ }
++ }
++
++ // What the world is moved to. It holds no bodies, so the move leaves every
++ // body without a world rather than handing it to a new one.
++ Node {
++ id: elsewhere
++ }
++
++ PhysicsTestCase {
++ name: "moving the world from a trigger report reaches the next frame"
++ timeoutSecs: 10
++ goalReached: root.frameAtMove >= 0 && world.frameCount > root.frameAtMove + 1
++ }
++
++ PhysicsTestCase {
++ name: "a body the move took out is not reported"
++ timeoutSecs: 10
++ goalReached: root.frameAtMove >= 0 && world.frameCount > root.frameAtMove + 1
++ && root.reports === 1
++ }
++ }