Revision control
++ // Copyright (C) 2026 The Qt Company Ltd.
++ // SPDX-License-Identifier: LicenseRef-Qt-Commercial OR GPL-3.0-only
++
++ import QtCore
++ import QtQuick
++ import QtTest
++ import QtQuick3D
++ import QtQuick3D.Physics
++ import QtQuick3D.Physics.TestUtils
++
++ // A body with no physicsMaterial of its own is simulated with the default material
++ // properties. The test cases below check that such bodies are not affected by the materials
++ // of other bodies, and that the physicsMaterial property can be changed while the
++ // simulation is running. Most of them slide two cubes across a floor and compare how far
++ // they travel, which is decided by the friction of the cube and of the floor. The character
++ // controller cases instead drop a ball on its capsule and measure how high it bounces.
++
++ Item {
++ width: 640
++ height: 480
++ visible: true
++
++ // Set by the first phase of the test cases below, so that the second phase only starts
++ // once the material has actually been changed
++ property bool materialAssigned: false
++ property bool materialSwitched: false
++ property bool materialUnset: false
++ property bool characterMaterialSwitched: false
++ property bool characterMaterialUnset: false
++
++ // How high the ball bounced off the character, before and after its material was unset
++ property bool ballReachedCharacter: false
++ property real ballApexAfterBounce: -999
++ property bool ballReachedCharacterAgain: false
++ property real ballApexAfterUnset: -999
++
++ PhysicsWorld {
++ gravity: Qt.vector3d(0, -9.81, 0)
++ running: true
++ forceDebugDraw: true
++ typicalLength: 1
++ typicalSpeed: 10
++ minimumTimestep: 15
++ maximumTimestep: 15
++ scene: viewport.scene
++ }
++
++ View3D {
++ id: viewport
++ anchors.fill: parent
++
++ environment: SceneEnvironment {
++ clearColor: "#151a3f"
++ backgroundMode: SceneEnvironment.Color
++ }
++
++ Node {
++ id: scene
++
++ // The groups below are laid out in two columns of three, so that the whole scene
++ // fits in the window when the test is run with the debug draw visible
++ PerspectiveCamera {
++ position: Qt.vector3d(0, 58, 95)
++ eulerRotation: Qt.vector3d(-40, 0, 0)
++ clipFar: 500
++ clipNear: 0.01
++ }
++
++ DirectionalLight {
++ eulerRotation.x: -45
++ eulerRotation.y: 45
++ }
++
++ PhysicsMaterial {
++ id: slipperyMaterial
++ staticFriction: 0
++ dynamicFriction: 0
++ }
++
++ PhysicsMaterial {
++ id: stickyMaterial
++ staticFriction: 1
++ dynamicFriction: 1
++ }
++
++ PhysicsMaterial {
++ id: bouncyMaterial
++ restitution: 1
++ }
++
++ // A body with no physicsMaterial is simulated with the default properties
++ Node {
++ id: defaultNode
++ x: -24
++
++ StaticRigidBody {
++ position: Qt.vector3d(0, -1, 0)
++ collisionShapes: BoxShape { extents: Qt.vector3d(4, 1, 4) }
++ }
++
++ TestCube {
++ id: defaultCube
++ y: 2
++ color: "yellow"
++ }
++ }
++
++ // Assigning a material to a single body must not change the friction of the
++ // bodies that have no material of their own
++ Node {
++ id: assignNode
++ x: -24
++ z: 26
++
++ StaticRigidBody {
++ position: Qt.vector3d(0, -1, 0)
++ collisionShapes: BoxShape { extents: Qt.vector3d(36, 1, 20) }
++ }
++
++ TestCube {
++ id: assignControlCube
++ x: -16
++ z: -3
++ color: "green"
++ }
++
++ TestCube {
++ id: assignCube
++ x: -16
++ z: 3
++ color: "red"
++ }
++ }
++
++ // Switching from one material to another while the simulation is running
++ Node {
++ id: switchNode
++ x: 24
++ z: 26
++
++ StaticRigidBody {
++ position: Qt.vector3d(0, -1, 0)
++ collisionShapes: BoxShape { extents: Qt.vector3d(36, 1, 20) }
++ }
++
++ TestCube {
++ id: switchControlCube
++ x: -16
++ z: -3
++ color: "blue"
++ physicsMaterial: slipperyMaterial
++ }
++
++ TestCube {
++ id: switchCube
++ x: -16
++ z: 3
++ color: "orange"
++ physicsMaterial: slipperyMaterial
++ }
++ }
++
++ // Bodies using the same material share it, so editing it affects all of them
++ Node {
++ id: sharedNode
++ x: -24
++ z: 52
++
++ PhysicsMaterial {
++ id: editedMaterial
++ staticFriction: 0
++ dynamicFriction: 0
++ }
++
++ StaticRigidBody {
++ position: Qt.vector3d(0, -1, 0)
++ collisionShapes: BoxShape { extents: Qt.vector3d(36, 1, 20) }
++ }
++
++ TestCube {
++ id: sharedCube1
++ x: -16
++ z: -3
++ color: "white"
++ physicsMaterial: editedMaterial
++ }
++
++ TestCube {
++ id: sharedCube2
++ x: -16
++ z: 3
++ color: "black"
++ physicsMaterial: editedMaterial
++ }
++
++ TestCube {
++ id: sharedControlCube
++ x: -16
++ z: 9
++ color: "grey"
++ physicsMaterial: slipperyMaterial
++ }
++ }
++
++ // Setting physicsMaterial back to null must bring the body back to the default
++ // properties
++ Node {
++ id: unsetNode
++ x: 24
++ z: 52
++
++ StaticRigidBody {
++ position: Qt.vector3d(0, -1, 0)
++ collisionShapes: BoxShape { extents: Qt.vector3d(36, 1, 20) }
++ }
++
++ TestCube {
++ id: unsetControlCube
++ x: -16
++ z: -3
++ color: "magenta"
++ physicsMaterial: slipperyMaterial
++ }
++
++ TestCube {
++ id: unsetCube
++ x: -16
++ z: 3
++ color: "cyan"
++ physicsMaterial: slipperyMaterial
++ }
++ }
++
++ // The material of a CharacterController must reach the shape of the controller,
++ // so that the bodies bouncing off it do so according to the new material
++ Node {
++ id: characterNode
++ x: 24
++
++ StaticRigidBody {
++ position: Qt.vector3d(0, -1, 0)
++ collisionShapes: BoxShape { extents: Qt.vector3d(20, 1, 20) }
++ }
++
++ CharacterController {
++ id: character
++ position: Qt.vector3d(0, 0.5, 0)
++ gravity: Qt.vector3d(0, 0, 0)
++ movement: Qt.vector3d(0, 0, 0)
++ collisionShapes: CapsuleShape { diameter: 1; height: 1 }
++ physicsMaterial: PhysicsMaterial { restitution: 0 }
++ }
++
++ DynamicRigidBody {
++ id: characterBall
++ position: Qt.vector3d(0, 12, 0)
++ massMode: DynamicRigidBody.CustomDensity
++ density: 1000
++ angularAxisLock: DynamicRigidBody.LockX | DynamicRigidBody.LockY
++ | DynamicRigidBody.LockZ
++ collisionShapes: SphereShape { diameter: 0.5 }
++ Model {
++ source: "#Sphere"
++ scale: Qt.vector3d(0.5, 0.5, 0.5).times(0.01)
++ materials: PrincipledMaterial { baseColor: "yellow" }
++ }
++ }
++ }
++
++ // A body that keeps falling, used to drive the per-step checks
++ DynamicRigidBody {
++ id: stepDriver
++ // Falls freely next to the groups above, so that it never hits anything
++ position: Qt.vector3d(0, 40, -25)
++ collisionShapes: SphereShape { diameter: 2 }
++ property int simulationSteps: 0
++ onPositionChanged: {
++ // Only do the checks every other step, since we have no guarantees on signal emission order
++ if (simulationSteps % 2) {
++ defaultCube.checkStable()
++ assignControlCube.checkStable()
++ assignCube.checkStable()
++ switchControlCube.checkStable()
++ switchCube.checkStable()
++ sharedCube1.checkStable()
++ sharedCube2.checkStable()
++ sharedControlCube.checkStable()
++ unsetControlCube.checkStable()
++ unsetCube.checkStable()
++ // Only track the apex from the moment the ball is about to land on
++ // the capsule, so that the height it is dropped from is not counted
++ if (characterBall.y < 2) {
++ if (characterMaterialUnset)
++ ballReachedCharacterAgain = true
++ else
++ ballReachedCharacter = true
++ }
++ if (characterMaterialUnset) {
++ if (ballReachedCharacterAgain)
++ ballApexAfterUnset = Math.max(ballApexAfterUnset,
++ characterBall.y)
++ } else if (ballReachedCharacter) {
++ ballApexAfterBounce = Math.max(ballApexAfterBounce, characterBall.y)
++ }
++ }
++ // Send the cubes sliding once the bodies have been created
++ if (simulationSteps === 4) {
++ const velocity = Qt.vector3d(15, 0, 0)
++ assignControlCube.setLinearVelocity(velocity)
++ assignCube.setLinearVelocity(velocity)
++ switchControlCube.setLinearVelocity(velocity)
++ switchCube.setLinearVelocity(velocity)
++ sharedCube1.setLinearVelocity(velocity)
++ sharedCube2.setLinearVelocity(velocity)
++ sharedControlCube.setLinearVelocity(velocity)
++ unsetControlCube.setLinearVelocity(velocity)
++ unsetCube.setLinearVelocity(velocity)
++ }
++ simulationSteps++
++ }
++ }
++ }
++ }
++
++ // The test functions of a TestCase are run in alphabetical order, so they are numbered
++ // where the order matters.
++
++ PhysicsTestCase {
++ name: "DefaultMaterial"
++ goalReached: defaultCube.stable
++ function test_1_body_without_material_is_simulated() {
++ // Came to rest on top of the floor
++ fuzzyCompare(defaultCube.y, 0, 0.01)
++ }
++ function test_2_material_has_default_values() {
++ const material = defaultCube.physicsMaterial
++ verify(material !== null)
++ fuzzyCompare(material.staticFriction, 0.5, 0.001)
++ fuzzyCompare(material.dynamicFriction, 0.5, 0.001)
++ fuzzyCompare(material.restitution, 0.5, 0.001)
++ }
++ }
++
++ PhysicsTestCase {
++ name: "AssignMaterial"
++ // Both cubes are sliding with the default friction
++ goalReached: assignControlCube.x > -13 && assignCube.x > -13
++ function test_1_bodies_slide_alike_without_materials() {
++ verify(!assignControlCube.stable)
++ verify(!assignCube.stable)
++ fuzzyCompare(assignCube.x, assignControlCube.x, 0.5)
++ }
++ function test_2_assign_material_to_a_single_body() {
++ assignCube.physicsMaterial = slipperyMaterial
++ materialAssigned = true
++ }
++ }
++
++ PhysicsTestCase {
++ name: "AssignMaterial2"
++ // The control cube keeps the default friction, so it stops first
++ goalReached: materialAssigned && assignControlCube.stable
++ function test_default_material_was_not_modified() {
++ // The default material is shared by all bodies that have no material, so
++ // assigning one to assignCube must not make assignControlCube slippery too
++ verify(!assignCube.stable)
++ verify(assignCube.x > assignControlCube.x + 2)
++ }
++ }
++
++ PhysicsTestCase {
++ name: "SwitchMaterial"
++ // Both cubes are sliding with the same slippery material
++ goalReached: switchControlCube.x > -13 && switchCube.x > -13
++ function test_1_bodies_slide_alike_with_the_same_material() {
++ verify(!switchControlCube.stable)
++ verify(!switchCube.stable)
++ fuzzyCompare(switchCube.x, switchControlCube.x, 0.5)
++ }
++ function test_2_switch_material() {
++ switchCube.physicsMaterial = stickyMaterial
++ materialSwitched = true
++ }
++ }
++
++ PhysicsTestCase {
++ name: "SwitchMaterial2"
++ // The sticky material has a higher friction, so that cube stops first
++ goalReached: materialSwitched && switchCube.stable
++ function test_switched_material_is_used() {
++ // The control cube still references the slippery material, which must not
++ // have been affected by the switch
++ verify(!switchControlCube.stable)
++ verify(switchControlCube.x > switchCube.x + 2)
++ }
++ }
++
++ PhysicsTestCase {
++ name: "CharacterControllerMaterial"
++ // The ball is still well above the capsule of the character
++ goalReached: characterBall.y < 6
++ function test_switch_the_character_material() {
++ character.physicsMaterial = bouncyMaterial
++ characterMaterialSwitched = true
++ }
++ }
++
++ PhysicsTestCase {
++ // The ball bounces back up high above the capsule, which it only does if the new
++ // material reached the shape of the character controller. Times out otherwise.
++ name: "CharacterControllerMaterial2"
++ goalReached: characterMaterialSwitched && ballApexAfterBounce > 4.5
++ function test_unset_the_character_material() {
++ // Drop the ball again, with the character back to the default properties
++ character.physicsMaterial = null
++ characterBall.reset(Qt.vector3d(0, 12, 0), Qt.vector3d(0, 0, 0))
++ characterMaterialUnset = true
++ }
++ }
++
++ PhysicsTestCase {
++ // The ball has bounced off the character again and is on its way down
++ name: "CharacterControllerMaterial3"
++ goalReached: ballApexAfterUnset > 2
++ && characterBall.y < ballApexAfterUnset - 0.5
++ function test_character_uses_default_properties_again() {
++ // The default restitution is lower than that of the material it had, so the ball
++ // does not come anywhere near as high as it did before
++ verify(ballApexAfterUnset < 5.5)
++ }
++ }
++
++ PhysicsTestCase {
++ name: "SharedMaterial"
++ // All three cubes are sliding with a friction of 0
++ goalReached: sharedCube1.x > -13 && sharedCube2.x > -13 && sharedControlCube.x > -13
++ function test_1_bodies_slide_alike() {
++ verify(!sharedCube1.stable)
++ verify(!sharedCube2.stable)
++ fuzzyCompare(sharedCube1.x, sharedCube2.x, 0.5)
++ }
++ function test_2_edit_shared_material() {
++ editedMaterial.staticFriction = 1
++ editedMaterial.dynamicFriction = 1
++ }
++ }
++
++ PhysicsTestCase {
++ name: "SharedMaterial2"
++ // The edit brings both cubes using the material to a halt
++ goalReached: sharedCube1.stable && sharedCube2.stable
++ function test_edit_reaches_every_body_using_the_material() {
++ fuzzyCompare(sharedCube1.x, sharedCube2.x, 0.5)
++ // The cube using another material is unaffected and slides further
++ verify(!sharedControlCube.stable)
++ verify(sharedControlCube.x > sharedCube1.x + 2)
++ }
++ }
++
++ PhysicsTestCase {
++ name: "UnsetMaterial"
++ // Both cubes are sliding with their own slippery material
++ goalReached: unsetControlCube.x > -13 && unsetCube.x > -13
++ function test_1_bodies_slide_alike_with_the_same_material() {
++ verify(!unsetControlCube.stable)
++ verify(!unsetCube.stable)
++ fuzzyCompare(unsetCube.x, unsetControlCube.x, 0.5)
++ }
++ function test_2_unset_material() {
++ unsetCube.physicsMaterial = null
++ materialUnset = true
++ }
++ }
++
++ PhysicsTestCase {
++ name: "UnsetMaterial2"
++ // Back to the default friction, which is higher, so that cube stops first
++ goalReached: materialUnset && unsetCube.stable
++ function test_body_uses_default_properties_again() {
++ // The control cube keeps the slippery material and slides further
++ verify(!unsetControlCube.stable)
++ verify(unsetControlCube.x > unsetCube.x + 2)
++ }
++ }
++ }