Revision control

++ // Copyright (C) 2026 The Qt Company Ltd.
++ // SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
++ import QtQuick
++ import QtQuick.Controls
++ import QtQuick.Layouts
++ import QtQuick3D
++ import QtQuick3D.Helpers
++ import QtQuick3D.Physics
++
++ Window {
++ id: window
++ width: 1280
++ height: 720
++ visible: true
++ title: qsTr("Qt Quick 3D Physics - Queries")
++
++ property int currentMode: 0
++ property real playerYaw: 0
++ property real cameraPitch: -15
++
++ property bool keyW: false
++ property bool keyS: false
++ property bool keyA: false
++ property bool keyD: false
++ property bool keySpace: false
++
++ readonly property var modeDetails: [
++ { name: "1. Single Probe", type: "PROBE", desc: "Closest body along a ray (singleRaycastQuery)" },
++ { name: "2. Piercing Probe", type: "PROBE", desc: "Every body along a ray (multiRaycastQuery)" },
++ { name: "3. Volume Probe", type: "PROBE", desc: "Closest body along a sweep (singleSweepQuery)" },
++ { name: "4. Piercing Volume Probe", type: "PROBE", desc: "Every body along a sweep (multiSweepQuery)" },
++ { name: "5. Radial Pulse", type: "PROBE", desc: "Every body in a volume (multiOverlapQuery)" },
++ { name: "6. Line-of-Sight Scanner", type: "SCANNER", desc: "Is the ray blocked? (testRaycastQuery)" },
++ { name: "7. Clearance Scanner", type: "SCANNER", desc: "Is the corridor clear? (testSweepQuery)" },
++ { name: "8. Occupancy Scanner", type: "SCANNER", desc: "Is the volume occupied? (testOverlapQuery)" }
++ ]
++
++ Timer {
++ id: autoFireTimer
++ interval: 150
++ repeat: true
++ onTriggered: {
++ if (window.keySpace) {
++ window.runQuery();
++ } else {
++ autoFireTimer.stop();
++ }
++ }
++ }
++
++ function updateShootingState() {
++ if (keySpace) {
++ if (!autoFireTimer.running) {
++ runQuery();
++ autoFireTimer.start();
++ }
++ } else {
++ autoFireTimer.stop();
++ }
++ }
++
++ Item {
++ id: inputController
++ anchors.fill: parent
++ focus: true
++
++ Component.onCompleted: inputController.forceActiveFocus()
++
++ Keys.onPressed: (event) => {
++ let k = event.key;
++ let t = event.text.toLowerCase();
++
++ if (k === Qt.Key_W || t === "w") window.keyW = true;
++ if (k === Qt.Key_S || t === "s") window.keyS = true;
++ if (k === Qt.Key_A || t === "a") window.keyA = true;
++ if (k === Qt.Key_D || t === "d") window.keyD = true;
++
++ if (k === Qt.Key_Space && !event.isAutoRepeat) {
++ window.keySpace = true;
++ window.updateShootingState();
++ }
++
++ if (k >= Qt.Key_1 && k <= Qt.Key_8) {
++ window.currentMode = k - Qt.Key_1;
++ }
++ }
++
++ Keys.onReleased: (event) => {
++ let k = event.key;
++ let t = event.text.toLowerCase();
++
++ if (k === Qt.Key_W || t === "w") window.keyW = false;
++ if (k === Qt.Key_S || t === "s") window.keyS = false;
++ if (k === Qt.Key_A || t === "a") window.keyA = false;
++ if (k === Qt.Key_D || t === "d") window.keyD = false;
++
++ if (k === Qt.Key_Space) {
++ window.keySpace = false;
++ window.updateShootingState();
++ }
++ }
++ }
++
++ View3D {
++ id: view
++ anchors.fill: parent
++
++ //! [physics-world-setup]
++ PhysicsWorld {
++ id: physicsWorld
++ scene: view.scene
++ running: true
++ gravity: Qt.vector3d(0, -981, 0)
++ }
++
++ // Shapes used for Sweep and Overlap queries
++ SphereShape { id: querySphereShape; diameter: 200 }
++ BoxShape { id: queryBoxShape; extents: Qt.vector3d(200, 200, 200) }
++ //! [physics-world-setup]
++
++ MouseArea {
++ anchors.fill: parent
++ acceptedButtons: Qt.LeftButton | Qt.RightButton
++ property point lastPos: Qt.point(0, 0)
++
++ onPressed: (mouse) => {
++ inputController.forceActiveFocus();
++ lastPos = Qt.point(mouse.x, mouse.y);
++ }
++
++ onPositionChanged: (mouse) => {
++ if (pressedButtons & Qt.RightButton || pressedButtons & Qt.LeftButton) {
++ let dx = mouse.x - lastPos.x;
++ let dy = mouse.y - lastPos.y;
++
++ window.playerYaw -= dx * 0.25;
++ window.cameraPitch = Math.max(-60, Math.min(30, window.cameraPitch - dy * 0.25));
++ lastPos = Qt.point(mouse.x, mouse.y);
++ }
++ }
++
++ onWheel: (wheel) => {
++ if (wheel.angleDelta.y > 0) {
++ window.currentMode = (window.currentMode + 1) % window.modeDetails.length;
++ } else {
++ window.currentMode = (window.currentMode - 1 + window.modeDetails.length)
++ % window.modeDetails.length;
++ }
++ }
++ }
++
++ environment: SceneEnvironment {
++ clearColor: "#0f111a"
++ backgroundMode: SceneEnvironment.Color
++ }
++
++ CharacterController {
++ id: player
++ position: Qt.vector3d(0, 150, 1200)
++ eulerRotation.y: window.playerYaw
++ gravity: physicsWorld.gravity
++
++ collisionShapes: CapsuleShape {
++ diameter: 80
++ height: 180
++ }
++
++ Model {
++ source: "#Cylinder"
++ scale: Qt.vector3d(0.8, 1.8, 0.8)
++ materials: [ PrincipledMaterial { baseColor: "#00d2d3" } ]
++ }
++
++ Node {
++ id: cameraRig
++ eulerRotation.x: window.cameraPitch
++
++ PerspectiveCamera {
++ id: camera
++ position: Qt.vector3d(0, 200, 500)
++ eulerRotation.x: -10
++ clipNear: 1
++ }
++ }
++ }
++
++ DirectionalLight {
++ eulerRotation: Qt.vector3d(-45, 30, 0)
++ brightness: 1.5
++ }
++
++ StaticRigidBody {
++ position: Qt.vector3d(0, -50, 0)
++ collisionShapes: BoxShape { extents: Qt.vector3d(6000, 100, 6000) }
++ Model {
++ source: "#Cube"
++ scale: Qt.vector3d(60, 1, 60)
++ materials: [ PrincipledMaterial { baseColor: "#1e272e" } ]
++ }
++ }
++
++ component ArenaWall : StaticRigidBody {
++ property vector3d wallSize: Qt.vector3d(100, 100, 100)
++ collisionShapes: BoxShape { extents: wallSize }
++ Model {
++ source: "#Cube"
++ scale: Qt.vector3d(wallSize.x * 0.01, wallSize.y * 0.01, wallSize.z * 0.01)
++ materials: [ PrincipledMaterial { baseColor: "#34495e" } ]
++ }
++ }
++
++ ArenaWall { position: Qt.vector3d(0, 300, -3000); wallSize: Qt.vector3d(6000, 600, 200) }
++ ArenaWall { position: Qt.vector3d(0, 300, 3000); wallSize: Qt.vector3d(6000, 600, 200) }
++ ArenaWall { position: Qt.vector3d(-3000, 300, 0); wallSize: Qt.vector3d(200, 600, 6000) }
++ ArenaWall { position: Qt.vector3d(3000, 300, 0); wallSize: Qt.vector3d(200, 600, 6000) }
++
++ component DynamicCube : DynamicRigidBody {
++ id: cubeRoot
++ property color cubeColor: "#e67e22"
++ collisionShapes: BoxShape { extents: Qt.vector3d(100, 100, 100) }
++ Model {
++ source: "#Cube"
++ scale: Qt.vector3d(1, 1, 1)
++ materials: [ PrincipledMaterial { baseColor: cubeRoot.cubeColor } ]
++ }
++ }
++
++ component DynamicSphere : DynamicRigidBody {
++ id: sphereRoot
++ property color sphereColor: "#1abc9c"
++ collisionShapes: SphereShape { diameter: 100 }
++ Model {
++ source: "#Sphere"
++ scale: Qt.vector3d(1, 1, 1)
++ materials: [ PrincipledMaterial { baseColor: sphereRoot.sphereColor } ]
++ }
++ }
++
++ // --- MASSIVE DYNAMIC OBJECTS GENERATOR (500+ BODIES) ---
++ Component { id: dynamicCubeComp; DynamicCube {} }
++ Component { id: dynamicSphereComp; DynamicSphere {} }
++
++ Component.onCompleted: {
++ let colors = ["#e74c3c", "#e67e22", "#f1c40f", "#2ecc71", "#3498db", "#9b59b6", "#1abc9c", "#e84393"];
++
++ // Generate a huge wall of dynamic objects (18 x 7 x 4 grid = ~500 objects)
++ for (let x = -1000; x <= 1000; x += 120) {
++ for (let y = 50; y <= 850; y += 120) {
++ for (let z = -1000; z >= -1400; z -= 120) {
++ let isSphere = (Math.random() > 0.5);
++ let comp = isSphere ? dynamicSphereComp : dynamicCubeComp;
++ let randomColor = colors[Math.floor(Math.random() * colors.length)];
++
++ comp.createObject(view.scene, {
++ "position": Qt.vector3d(x + (Math.random() * 5), y, z + (Math.random() * 5)),
++ "cubeColor": randomColor,
++ "sphereColor": randomColor
++ });
++ }
++ }
++ }
++ }
++
++ Node {
++ id: tracerBeam
++ visible: false
++ Model {
++ id: tracerMesh
++ source: "#Cylinder"
++ scale: Qt.vector3d(1, 1, 1)
++ materials: [
++ PrincipledMaterial {
++ id: tracerMat
++ baseColor: "#f1c40f"
++ opacity: 0.5
++ alphaMode: PrincipledMaterial.Blend
++ lighting: PrincipledMaterial.NoLighting
++ }
++ ]
++ }
++ }
++ Timer { id: tracerTimer; interval: 150; onTriggered: tracerBeam.visible = false }
++
++ Model {
++ id: volumeGhost
++ visible: false
++ materials: [
++ PrincipledMaterial {
++ id: ghostMat
++ baseColor: "#3300ff00"
++ alphaMode: PrincipledMaterial.Blend
++ opacity: 0.4
++ lighting: PrincipledMaterial.NoLighting
++ }
++ ]
++ }
++ }
++
++ FrameAnimation {
++ running: true
++ onTriggered: {
++ let speed = 800;
++ let moveX = 0;
++ let moveZ = 0;
++
++ if (window.keyW) moveZ -= 1;
++ if (window.keyS) moveZ += 1;
++ if (window.keyA) moveX -= 1;
++ if (window.keyD) moveX += 1;
++
++ let len = Math.sqrt(moveX * moveX + moveZ * moveZ);
++ if (len > 0) {
++ moveX /= len;
++ moveZ /= len;
++ player.movement = Qt.vector3d(moveX * speed, 0, moveZ * speed);
++ } else {
++ player.movement = Qt.vector3d(0, 0, 0);
++ }
++
++ let rad = player.eulerRotation.y * Math.PI / 180.0;
++ let forwardWorld = Qt.vector3d(-Math.sin(rad), 0, -Math.cos(rad)).normalized();
++
++ window.updateScannerState(forwardWorld);
++ }
++ }
++
++ //! [probe-origin]
++ // The player's own collider is an ordinary hit candidate, so a query starting at
++ // the character's center would hit its own capsule at distance 0. Start the query
++ // slightly above and in front of the capsule instead.
++ function probeOrigin(forwardDir) {
++ return player.position.plus(Qt.vector3d(0, 50, 0)).plus(forwardDir.times(150));
++ }
++ //! [probe-origin]
++
++ // Only a DynamicRigidBody can take an impulse. The queries here also return the
++ // static arena and the player's own character controller.
++ function pushBody(body, impulse) {
++ if (body instanceof DynamicRigidBody)
++ body.applyCentralImpulse(impulse);
++ }
++
++ function runQuery() {
++ if (currentMode >= 5) {
++ statusText.text = "Scanner mode: modes 1-5 are triggered manually";
++ return;
++ }
++
++ let rad = player.eulerRotation.y * Math.PI / 180.0;
++ let pitchRad = cameraRig.eulerRotation.x * Math.PI / 180.0;
++
++ let forwardDir = Qt.vector3d(
++ -Math.sin(rad) * Math.cos(pitchRad),
++ Math.sin(pitchRad),
++ -Math.cos(rad) * Math.cos(pitchRad)
++ ).normalized();
++
++ let origin = window.probeOrigin(forwardDir);
++ let maxDist = 3000;
++
++ switch (currentMode) {
++
++ //! [mode-single-raycast]
++ case 0: // singleRaycastQuery
++ let hit1 = physicsWorld.singleRaycastQuery(origin, forwardDir, maxDist, true, true);
++
++ // hit.body === null is the miss test; distance is 0 on a miss.
++ let dist1 = hit1.body ? hit1.distance : maxDist;
++ renderTracer(origin, forwardDir, dist1, "#f1c40f", 10, 10, false);
++
++ if (hit1.body) {
++ window.pushBody(hit1.body, forwardDir.times(250000));
++ statusText.text = "singleRaycastQuery: hit at " + Math.round(hit1.distance) + " cm";
++ } else {
++ statusText.text = "singleRaycastQuery: missed";
++ }
++ break;
++ //! [mode-single-raycast]
++
++ //! [mode-multi-raycast]
++ case 1: // multiRaycastQuery
++ let hits2 = physicsWorld.multiRaycastQuery(origin, forwardDir, maxDist, true, true);
++ renderTracer(origin, forwardDir, maxDist, "#3498db", 10, 10, false);
++
++ for (let i = 0; i < hits2.length; ++i)
++ window.pushBody(hits2[i].body, forwardDir.times(200000));
++
++ statusText.text = "multiRaycastQuery: hit count " + hits2.length;
++ break;
++ //! [mode-multi-raycast]
++
++ //! [mode-single-sweep]
++ case 2: // singleSweepQuery
++ querySphereShape.position = origin;
++ let hit3 = physicsWorld.singleSweepQuery(querySphereShape, forwardDir, maxDist, true, true);
++
++ // A sweep that starts already overlapping a body reports a negative
++ // distance, so test body rather than distance to detect a miss.
++ let dist3 = hit3.body ? Math.max(hit3.distance, 0) : maxDist;
++
++ // Cylinder matching the swept SphereShape diameter
++ renderTracer(origin, forwardDir, dist3, "#e74c3c", 200, 200, false);
++
++ if (hit3.body) {
++ window.pushBody(hit3.body, forwardDir.times(300000));
++ statusText.text = "singleSweepQuery: hit at " + Math.round(hit3.distance) + " cm";
++ } else {
++ statusText.text = "singleSweepQuery: missed";
++ }
++ break;
++ //! [mode-single-sweep]
++
++ //! [mode-multi-sweep]
++ case 3: // multiSweepQuery
++ queryBoxShape.position = origin;
++ let hits4 = physicsWorld.multiSweepQuery(queryBoxShape, forwardDir, maxDist, true, true);
++
++ // Translucent box corridor along the sweep trajectory
++ renderTracer(origin, forwardDir, maxDist, "#9b59b6", 200, 200, true);
++
++ for (let j = 0; j < hits4.length; ++j)
++ window.pushBody(hits4[j].body, forwardDir.times(250000));
++
++ statusText.text = "multiSweepQuery: hit count " + hits4.length;
++ break;
++ //! [mode-multi-sweep]
++
++ //! [mode-multi-overlap]
++ case 4: // multiOverlapQuery
++ let pulseCenter = origin.plus(forwardDir.times(600));
++ querySphereShape.position = pulseCenter;
++
++ let overlapHits = physicsWorld.multiOverlapQuery(querySphereShape, true, true);
++
++ volumeGhost.source = "#Sphere";
++ volumeGhost.position = pulseCenter;
++ volumeGhost.scale = Qt.vector3d(2, 2, 2);
++ ghostMat.baseColor = "#e74c3c";
++ volumeGhost.visible = true;
++
++ for (let k = 0; k < overlapHits.length; ++k) {
++ let body5 = overlapHits[k].body;
++ if (!body5)
++ continue;
++ let pushDir = body5.position.minus(pulseCenter);
++ pushDir = pushDir.length() > 0 ? pushDir.normalized() : Qt.vector3d(0, 1, 0);
++ window.pushBody(body5, pushDir.times(350000).plus(Qt.vector3d(0, 100000, 0)));
++ }
++ statusText.text = "multiOverlapQuery: hit count " + overlapHits.length;
++ break;
++ //! [mode-multi-overlap]
++ }
++ }
++
++ function updateScannerState(forwardDir) {
++ if (currentMode < 5) {
++ if (currentMode !== 4) volumeGhost.visible = false;
++ return;
++ }
++
++ let origin = window.probeOrigin(forwardDir);
++ let scanDist = 1200;
++
++ switch (currentMode) {
++
++ //! [mode-test-raycast]
++ case 5: // testRaycastQuery
++ let isBlocked5 = physicsWorld.testRaycastQuery(origin, forwardDir, scanDist, true, true);
++ renderTracer(origin, forwardDir, scanDist, isBlocked5 ? "#ff2d55" : "#00ff66", 10, 10, false);
++
++ statusText.text = isBlocked5 ? "testRaycastQuery: blocked" : "testRaycastQuery: path clear";
++ statusText.color = isBlocked5 ? "#ff2d55" : "#00ff66";
++ break;
++ //! [mode-test-raycast]
++
++ //! [mode-test-sweep]
++ case 6: // testSweepQuery
++ querySphereShape.position = origin;
++ let isBlocked6 = physicsWorld.testSweepQuery(querySphereShape, forwardDir, scanDist, true, true);
++
++ volumeGhost.source = "#Sphere";
++ volumeGhost.position = origin.plus(forwardDir.times(scanDist));
++ volumeGhost.scale = Qt.vector3d(2, 2, 2);
++ ghostMat.baseColor = isBlocked6 ? "#ff2d55" : "#00ff66";
++ volumeGhost.visible = true;
++
++ statusText.text = isBlocked6 ? "testSweepQuery: corridor blocked" : "testSweepQuery: corridor clear";
++ statusText.color = isBlocked6 ? "#ff2d55" : "#00ff66";
++ break;
++ //! [mode-test-sweep]
++
++ //! [mode-test-overlap]
++ case 7: // testOverlapQuery
++ let checkZone = origin.plus(forwardDir.times(500));
++ querySphereShape.position = checkZone;
++ let isOccupied = physicsWorld.testOverlapQuery(querySphereShape, true, true);
++
++ volumeGhost.source = "#Sphere";
++ volumeGhost.position = checkZone;
++ volumeGhost.scale = Qt.vector3d(2, 2, 2);
++ ghostMat.baseColor = isOccupied ? "#ff2d55" : "#00ff66";
++ volumeGhost.visible = true;
++
++ statusText.text = isOccupied ? "testOverlapQuery: volume occupied" : "testOverlapQuery: volume empty";
++ statusText.color = isOccupied ? "#ff2d55" : "#00ff66";
++ break;
++ //! [mode-test-overlap]
++ }
++ }
++
++ function renderTracer(origin, forwardDir, dist, colorHex, thicknessX, thicknessY, isBox) {
++ thicknessX = thicknessX !== undefined ? thicknessX : 10;
++ thicknessY = thicknessY !== undefined ? thicknessY : 10;
++ isBox = isBox !== undefined ? isBox : false;
++
++ tracerMesh.source = isBox ? "#Cube" : "#Cylinder";
++ tracerBeam.position = origin.plus(forwardDir.times(dist / 2));
++ tracerBeam.eulerRotation = Qt.vector3d(cameraRig.eulerRotation.x, player.eulerRotation.y, 0);
++
++ tracerMesh.scale = Qt.vector3d(thicknessX * 0.01, thicknessY * 0.01, dist * 0.01);
++ tracerMat.baseColor = colorHex;
++ tracerMat.opacity = (thicknessX > 50) ? 0.35 : 0.8;
++
++ tracerBeam.visible = true;
++ tracerTimer.restart();
++ }
++
++ ColumnLayout {
++ anchors.left: parent.left
++ anchors.top: parent.top
++ anchors.margins: 15
++ spacing: 10
++
++ Rectangle {
++ implicitWidth: 420
++ implicitHeight: 200
++ color: "#d910141d"
++ radius: 8
++ border.color: "#34495e"
++ border.width: 1
++
++ ColumnLayout {
++ anchors.fill: parent
++ anchors.margins: 12
++
++ Text {
++ text: "PHYSICS SCENE QUERIES"
++ color: "white"
++ font.bold: true
++ font.pixelSize: 14
++ }
++
++ ComboBox {
++ id: modeSelect
++ Layout.fillWidth: true
++ model: window.modeDetails.map(item => "[" + item.type + "] " + item.name)
++ currentIndex: window.currentMode
++ focusPolicy: Qt.NoFocus
++ onActivated: (index) => {
++ window.currentMode = index;
++ inputController.forceActiveFocus();
++ }
++ }
++
++ Text {
++ text: window.modeDetails[window.currentMode].desc
++ color: "#f1c40f"
++ font.pixelSize: 12
++ font.italic: true
++ }
++
++ Text {
++ text: "Controls:"
++ + "\n- WASD: Move character"
++ + "\n- Hold Mouse Drag: Rotate camera"
++ + "\n- Spacebar: Run the selected query"
++ + "\n- Keys 1-8: Switch query modes"
++ color: "#bdc3c7"
++ font.pixelSize: 11
++ }
++
++ Rectangle {
++ Layout.fillWidth: true
++ implicitHeight: 34
++ color: "#000000"
++ radius: 4
++
++ Text {
++ id: statusText
++ anchors.centerIn: parent
++ color: "#00ff66"
++ font.pixelSize: 11
++ font.bold: true
++ }
++ }
++ }
++ }
++ }
++ }