Revision control
Copy as Markdown
// Copyright (C) 2026 The Qt Company Ltd.
// SPDX-License-Identifier: LicenseRef-Qt-Commercial OR GPL-3.0-only
import QtQuick
import QtTest
import QtQuick3D
import QtQuick3D.Physics
import QtQuick3D.Physics.TestUtils
Item {
id: root
width: 640
height: 480
visible: true
// Latched, since the tests go on to move the rays off their targets and the timeout
// guard checks the goal again after they have run.
property bool raysReady: false
readonly property bool raysHitting: ray.isColliding && childRay.isColliding
onRaysHittingChanged: raysReady = raysReady || raysHitting;
// Draws a ray as a thin bar from its origin to where it ends, green while it is hitting
// something and red while it is not. The bodies come from forceDebugDraw, which does not
// know about rays.
component RayVisual: Model {
required property Raycaster raycaster
readonly property real length: raycaster.isColliding ? raycaster.hit.distance
: raycaster.maxDistance
source: "#Cube"
z: -length / 2
scale: Qt.vector3d(0.02, 0.02, length / 100)
materials: PrincipledMaterial {
baseColor: raycaster.isColliding ? "#24a148" : "#da1e28"
lighting: PrincipledMaterial.NoLighting
}
}
// Marks where a ray currently ends. Belongs to the scene rather than to the ray, since
// the hit position is in scene coordinates.
component HitMarker: Model {
required property Raycaster raycaster
visible: raycaster.isColliding
position: raycaster.hit.position
source: "#Sphere"
scale: Qt.vector3d(0.08, 0.08, 0.08)
materials: PrincipledMaterial {
baseColor: "#f1c21b"
lighting: PrincipledMaterial.NoLighting
}
}
PhysicsWorld {
gravity: Qt.vector3d(0, 0, 0)
minimumTimestep: 15
maximumTimestep: 15
forceDebugDraw: true
scene: viewport.scene
}
View3D {
id: viewport
anchors.fill: parent
environment: SceneEnvironment {
backgroundMode: SceneEnvironment.Color
clearColor: "#101418"
}
// High and slightly to one side, so all the rays are in frame and each is seen along
// its length rather than end on.
PerspectiveCamera {
position: Qt.vector3d(11, 64, 114)
eulerRotation: Qt.vector3d(-25, -10, 0)
clipFar: 1000
}
DirectionalLight {
eulerRotation: Qt.vector3d(-40, 30, 0)
}
Raycaster {
id: ray
maxDistance: 100
RayVisual {
raycaster: ray
}
}
StaticRigidBody {
id: target
z: -50
collisionShapes: BoxShape {
id: targetShape
extents: Qt.vector3d(10, 10, 10)
}
}
DynamicRigidBody {
id: parentBody
x: 50
isKinematic: true
kinematicPosition: position
collisionShapes: BoxShape {
extents: Qt.vector3d(10, 10, 10)
}
Raycaster {
id: childRay
maxDistance: 100
RayVisual {
raycaster: childRay
}
}
}
StaticRigidBody {
id: childTarget
x: 50
z: -50
collisionShapes: BoxShape {
extents: Qt.vector3d(10, 10, 10)
}
}
// A ray at its own body's origin, which is how one is normally mounted. A raycast does
// report the shape it starts inside, at distance 0, so without the exclusion this ray
// sees its own body and nothing past it.
DynamicRigidBody {
id: mountBody
x: -45
isKinematic: true
kinematicPosition: position
collisionShapes: BoxShape {
extents: Qt.vector3d(10, 10, 10)
}
Raycaster {
id: mountedRay
maxDistance: 100
RayVisual {
raycaster: mountedRay
}
}
}
StaticRigidBody {
id: behindMount
x: -45
z: -50
collisionShapes: BoxShape {
extents: Qt.vector3d(10, 10, 10)
}
}
StaticRigidBody {
id: outerBody
x: 105
collisionShapes: BoxShape {
extents: Qt.vector3d(30, 30, 30)
}
StaticRigidBody {
id: innerBody
collisionShapes: BoxShape {
extents: Qt.vector3d(10, 10, 10)
}
Raycaster {
id: nestedRay
maxDistance: 100
RayVisual {
raycaster: nestedRay
}
}
}
}
StaticRigidBody {
id: behindNested
x: 105
z: -50
collisionShapes: BoxShape {
extents: Qt.vector3d(10, 10, 10)
}
}
HitMarker {
raycaster: ray
}
HitMarker {
raycaster: childRay
}
HitMarker {
raycaster: mountedRay
}
HitMarker {
raycaster: nestedRay
}
}
SignalSpy {
id: enteredSpy
target: ray
signalName: "bodyEntered"
}
SignalSpy {
id: exitedSpy
target: ray
signalName: "bodyExited"
}
PhysicsTestCase {
name: "Raycaster"
// Both rays reaching their target is what the rest builds on, so wait for that rather
// than for the window, which never arrives on a headless run and leaves the whole case
// hanging.
goalReached: root.raysReady
function test_1_properties() {
compare(ray.excludeParentBody, true);
compare(ray.includeStatic, true);
compare(ray.includeDynamic, true);
tryCompare(ray, "isColliding", true);
enteredSpy.clear();
exitedSpy.clear();
ray.direction = Qt.vector3d(0, 0, 0);
compare(ray.direction, Qt.vector3d(0, 0, 0));
compare(ray.isColliding, false);
compare(ray.hit.body, null);
compare(exitedSpy.count, 1);
compare(exitedSpy.signalArguments[0][0], target);
ray.maxDistance = 0;
compare(ray.maxDistance, 0);
ignoreWarning("Raycaster: maxDistance must be finite and non-negative, ignoring.");
ray.maxDistance = -1;
compare(ray.maxDistance, 0);
ray.direction = Qt.vector3d(0, 0, -1);
ray.maxDistance = 100;
}
function test_2_raycast() {
tryCompare(ray, "isColliding", true);
compare(ray.hit.body, target);
compare(ray.hit.shape, targetShape);
verify(ray.hit.distance > 0);
verify(ray.hit.distance < ray.maxDistance);
fuzzyCompare(ray.hit.position.z, -45, 0.01);
compare(ray.hit.normal, Qt.vector3d(0, 0, 1));
tryCompare(childRay, "isColliding", true);
compare(childRay.hit.body, childTarget);
enteredSpy.clear();
exitedSpy.clear();
target.x = 100;
tryCompare(ray, "isColliding", false);
compare(ray.hit.body, null);
compare(ray.hit.shape, null);
compare(ray.hit.position, Qt.vector3d(0, 0, 0));
compare(ray.hit.normal, Qt.vector3d(0, 0, 0));
compare(ray.hit.distance, 0);
compare(exitedSpy.count, 1);
compare(exitedSpy.signalArguments[0][0], target);
target.x = 0;
tryCompare(ray, "isColliding", true);
compare(enteredSpy.count, 1);
compare(enteredSpy.signalArguments[0][0], target);
}
function test_3_filtersAndInactiveStates() {
ray.includeStatic = false;
tryCompare(ray, "isColliding", false);
ray.includeDynamic = false;
compare(ray.isColliding, false);
compare(ray.hit.body, null);
ray.includeStatic = true;
tryCompare(ray, "isColliding", true);
enteredSpy.clear();
exitedSpy.clear();
ray.enabled = false;
compare(ray.isColliding, false);
compare(ray.hit.body, null);
compare(exitedSpy.count, 1);
compare(exitedSpy.signalArguments[0][0], target);
ray.enabled = true;
tryCompare(ray, "isColliding", true);
ray.includeDynamic = true;
}
function test_4_excludeParentBody() {
// Excluding, the body the ray sits in is passed over for the one behind.
compare(mountedRay.excludeParentBody, true);
tryCompare(mountedRay, "isColliding", true);
compare(mountedRay.hit.body, behindMount);
fuzzyCompare(mountedRay.hit.distance, 45, 0.01);
// Not excluding, the ray stops dead on the shape it starts inside.
mountedRay.excludeParentBody = false;
tryVerify(() => mountedRay.hit.body === mountBody);
fuzzyCompare(mountedRay.hit.distance, 0, 0.01);
mountedRay.excludeParentBody = true;
tryVerify(() => mountedRay.hit.body === behindMount);
}
function test_5_excludesEveryAncestorBody() {
// The ray is inside two nested bodies, so passing over only the nearest would
// leave the outer one reported at distance 0.
tryCompare(nestedRay, "isColliding", true);
compare(nestedRay.hit.body, behindNested);
fuzzyCompare(nestedRay.hit.distance, 45, 0.01);
}
function test_6_excludedBodies() {
// Without the exclusion the ray stops on the body it sits in.
mountedRay.excludeParentBody = false;
tryVerify(() => mountedRay.hit.body === mountBody);
// Naming that body explicitly does the same job as the ancestor walk, and works
// for bodies the ray is not mounted on.
mountedRay.excludedBodies = [mountBody];
tryVerify(() => mountedRay.hit.body === behindMount);
fuzzyCompare(mountedRay.hit.distance, 45, 0.01);
// Excluding both leaves nothing for the ray to report.
mountedRay.excludedBodies = [mountBody, behindMount];
tryCompare(mountedRay, "isColliding", false);
mountedRay.excludedBodies = [];
mountedRay.excludeParentBody = true;
tryVerify(() => mountedRay.hit.body === behindMount);
}
}
}