From 0abe4fb753ae59e22266a99e7f7150cbda3abc77 Mon Sep 17 00:00:00 2001 From: felipedse Date: Sun, 15 Mar 2026 21:13:30 -0300 Subject: [PATCH 1/8] Implement grip button functionality for calibration and update UI text for improved user instructions --- index.html | 30 ++++++++++++++++++++++++++++-- 1 file changed, 28 insertions(+), 2 deletions(-) diff --git a/index.html b/index.html index 8967293..1332078 100644 --- a/index.html +++ b/index.html @@ -101,7 +101,7 @@ currentColor.x * smoothOpacity + (1.0 - smoothOpacity) * sobelColor.x, currentColor.y * smoothOpacity + (1.0 - smoothOpacity) * sobelColor.y, currentColor.z * smoothOpacity + (1.0 - smoothOpacity) * sobelColor.z, - currentColor.w * min(opacity, smoothOpacity) + (1.0 - smoothOpacity) * sobelColor.w + max(smoothOpacity, (1.0 - smoothOpacity) * sobelColor.w) ); } @@ -127,6 +127,9 @@ let currentScale = 1.0; let previousDidPressHideImage = false; let previousDidPressHideInstructions = false; + let calibrationOffsetY = 0; + let isCalibrated = false; + let previousDidPressGrip = false; init(); animate(); @@ -279,7 +282,19 @@ new ThreeMeshUI.Text({ - content: "\n\nHold trigger: ", + content: "\n\nGrip (on surface): ", + fontSize: 0.025, + fontColor: new THREE.Color(0xffff00) + }), + + new ThreeMeshUI.Text({ + content: "Calibrate offset", + fontSize: 0.025, + fontColor: new THREE.Color(0xffffff) + }), + + new ThreeMeshUI.Text({ + content: "\nHold trigger: ", fontSize: 0.025, fontColor: new THREE.Color(0xffff00) }), @@ -441,6 +456,7 @@ const vertical = gamepad.axes[3]; // Primary thumbstick Y const triggerButton = gamepad.buttons[0]; + const gripButton = gamepad.buttons[1]; const axButton = gamepad.buttons[4]; const byButton = gamepad.buttons[5]; @@ -460,8 +476,18 @@ // if a disconnection happens during the session. const controllerToUse = renderer.xr.getController(i); + // Calibrate: press grip while controller is resting on the surface + if (gripButton.pressed && !previousDidPressGrip) { + calibrationOffsetY = controllerToUse.position.y; + isCalibrated = true; + } + previousDidPressGrip = gripButton.pressed; + if (triggerButton.pressed) { const offsetPosition = controllerToUse.localToWorld(new THREE.Vector3(0, 0, 0.045)); + if (isCalibrated) { + offsetPosition.y -= (controllerToUse.position.y - calibrationOffsetY); + } group.position.copy(offsetPosition); group.setRotationFromQuaternion(controllerToUse.quaternion); } From f30bd02ea26966f62a1bd561f1081e22883b90b0 Mon Sep 17 00:00:00 2001 From: felipedse Date: Sun, 15 Mar 2026 21:16:48 -0300 Subject: [PATCH 2/8] Update .gitignore to include .wrangler --- .gitignore | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 496ee2c..ee4fac0 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ -.DS_Store \ No newline at end of file +.DS_Store +.wrangler \ No newline at end of file From c441c958fbc7cb1fa942c41166e7a8241fc86281 Mon Sep 17 00:00:00 2001 From: felipedse Date: Sun, 15 Mar 2026 21:57:51 -0300 Subject: [PATCH 3/8] Refactor intensity calculation and enhance calibration functionality; update UI text for clarity --- index.html | 72 ++++++++++++++++++++++++++++++++++++++---------------- 1 file changed, 51 insertions(+), 21 deletions(-) diff --git a/index.html b/index.html index 1332078..e05e827 100644 --- a/index.html +++ b/index.html @@ -69,12 +69,13 @@ varying vec2 vUv; float intensity(in vec4 color) { - return sqrt((color.x*color.x)+(color.y*color.y)+(color.z*color.z)); + float raw = sqrt((color.x*color.x)+(color.y*color.y)+(color.z*color.z)) / 1.732; + return pow(raw, 0.4); } void main() { vec2 uv = vUv; - + ivec2 imageSize = textureSize(colorTexture, 0); vec2 step = vec2(stepSize/float(imageSize.x), stepSize/float(imageSize.y)); @@ -86,16 +87,16 @@ float topRight = intensity(texture2D(colorTexture, uv + vec2(step.x, step.y))); float right = intensity(texture2D(colorTexture, uv + vec2(step.x, 0))); float bottomRight = intensity(texture2D(colorTexture, uv + vec2(step.x, -step.y))); - + float horizontal = topLeft + 2.0 * left + bottomLeft - topRight - 2.0 * right - bottomRight; float vertical = -topLeft - 2.0 * top - topRight + bottomLeft + 2.0 * bottom + bottomRight; - float sobelResult = sqrt(horizontal * horizontal + vertical * vertical); + float sobelResult = clamp(sqrt(horizontal * horizontal + vertical * vertical) * 2.5, 0.0, 1.0); vec4 sobelColor = vec4(1.0 - sobelResult, 1.0 - sobelResult, 1.0 - sobelResult, sobelResult); vec4 currentColor = texture2D(colorTexture, uv); - float smoothOpacity = smoothstep(0.0, 0.3, opacity); + float smoothOpacity = smoothstep(0.0, 0.8, opacity); gl_FragColor = vec4( currentColor.x * smoothOpacity + (1.0 - smoothOpacity) * sobelColor.x, @@ -127,9 +128,9 @@ let currentScale = 1.0; let previousDidPressHideImage = false; let previousDidPressHideInstructions = false; - let calibrationOffsetY = 0; + let surfaceY = 0; let isCalibrated = false; - let previousDidPressGrip = false; + let didPressGripPrev = false; init(); animate(); @@ -288,7 +289,19 @@ }), new ThreeMeshUI.Text({ - content: "Calibrate offset", + content: "Set surface plane", + fontSize: 0.025, + fontColor: new THREE.Color(0xffffff) + }), + + new ThreeMeshUI.Text({ + content: "\nGrip + analog up/down: ", + fontSize: 0.025, + fontColor: new THREE.Color(0xffff00) + }), + + new ThreeMeshUI.Text({ + content: "Adjust height", fontSize: 0.025, fontColor: new THREE.Color(0xffffff) }), @@ -300,7 +313,7 @@ }), new ThreeMeshUI.Text({ - content: "Reposition image", + content: "Move image on surface", fontSize: 0.025, fontColor: new THREE.Color(0xffffff) }), @@ -460,10 +473,19 @@ const axButton = gamepad.buttons[4]; const byButton = gamepad.buttons[5]; - if (vertical > 0.7) { - incrementScale(-1.0 * elapsedTime); - } else if (vertical < -0.7) { - incrementScale(1.0 * elapsedTime); + if (gripButton.pressed && isCalibrated) { + // Grip held: thumbstick vertical adjusts surface height + if (vertical > 0.7) { + surfaceY -= 0.05 * elapsedTime; + } else if (vertical < -0.7) { + surfaceY += 0.05 * elapsedTime; + } + } else { + if (vertical > 0.7) { + incrementScale(-1.0 * elapsedTime); + } else if (vertical < -0.7) { + incrementScale(1.0 * elapsedTime); + } } if (horizontal > 0.7) { @@ -476,20 +498,28 @@ // if a disconnection happens during the session. const controllerToUse = renderer.xr.getController(i); - // Calibrate: press grip while controller is resting on the surface - if (gripButton.pressed && !previousDidPressGrip) { - calibrationOffsetY = controllerToUse.position.y; + // Calibrate: press grip while controller rests on the drawing surface + if (gripButton.pressed && !didPressGripPrev) { + surfaceY = controllerToUse.position.y; isCalibrated = true; } - previousDidPressGrip = gripButton.pressed; + didPressGripPrev = gripButton.pressed; if (triggerButton.pressed) { - const offsetPosition = controllerToUse.localToWorld(new THREE.Vector3(0, 0, 0.045)); if (isCalibrated) { - offsetPosition.y -= (controllerToUse.position.y - calibrationOffsetY); + // Project controller XZ onto the calibrated surface plane + const worldPos = controllerToUse.localToWorld(new THREE.Vector3(0, 0, 0)); + group.position.set(worldPos.x, surfaceY, worldPos.z); + + // Lay image flat on surface, rotated by controller yaw + const euler = new THREE.Euler().setFromQuaternion(controllerToUse.quaternion, 'YXZ'); + group.rotation.set(-Math.PI / 2, euler.y, 0); + } else { + // Original behavior before calibration + const offsetPosition = controllerToUse.localToWorld(new THREE.Vector3(0, 0, 0.045)); + group.position.copy(offsetPosition); + group.setRotationFromQuaternion(controllerToUse.quaternion); } - group.position.copy(offsetPosition); - group.setRotationFromQuaternion(controllerToUse.quaternion); } if (axButton.pressed) { From 5501f338d89b3b5d3080207dbf61dc79b79f8523 Mon Sep 17 00:00:00 2001 From: felipedse Date: Sun, 15 Mar 2026 21:58:55 -0300 Subject: [PATCH 4/8] Update .gitignore to include deploy.md --- .gitignore | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index ee4fac0..467ed02 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ .DS_Store -.wrangler \ No newline at end of file +.wrangler +deploy.md \ No newline at end of file From b635f8199fa44ce24449809534fcb96fa0245969 Mon Sep 17 00:00:00 2001 From: felipedse Date: Sun, 15 Mar 2026 22:42:40 -0300 Subject: [PATCH 5/8] Enhance image blending and opacity control in fragment shader; improve surface height adjustment and calibration logic --- index.html | 28 ++++++++++++++++------------ 1 file changed, 16 insertions(+), 12 deletions(-) diff --git a/index.html b/index.html index e05e827..92dd1c5 100644 --- a/index.html +++ b/index.html @@ -93,17 +93,19 @@ float sobelResult = clamp(sqrt(horizontal * horizontal + vertical * vertical) * 2.5, 0.0, 1.0); - vec4 sobelColor = vec4(1.0 - sobelResult, 1.0 - sobelResult, 1.0 - sobelResult, sobelResult); + vec3 sobelRGB = vec3(1.0 - sobelResult); vec4 currentColor = texture2D(colorTexture, uv); - float smoothOpacity = smoothstep(0.0, 0.8, opacity); + // Two-zone opacity control: + // opacity 0.0-0.4: visibility fades from transparent to edges-only + // opacity 0.4-0.9: blend from edges-only to full original image + float visibility = smoothstep(0.0, 0.4, opacity); + float imageBlend = smoothstep(0.4, 0.9, opacity); - gl_FragColor = vec4( - currentColor.x * smoothOpacity + (1.0 - smoothOpacity) * sobelColor.x, - currentColor.y * smoothOpacity + (1.0 - smoothOpacity) * sobelColor.y, - currentColor.z * smoothOpacity + (1.0 - smoothOpacity) * sobelColor.z, - max(smoothOpacity, (1.0 - smoothOpacity) * sobelColor.w) - ); + vec3 rgb = mix(sobelRGB, currentColor.rgb, imageBlend); + float alpha = mix(sobelResult, 1.0, imageBlend) * visibility; + + gl_FragColor = vec4(rgb, alpha); } @@ -476,9 +478,9 @@ if (gripButton.pressed && isCalibrated) { // Grip held: thumbstick vertical adjusts surface height if (vertical > 0.7) { - surfaceY -= 0.05 * elapsedTime; + surfaceY -= 0.5 * elapsedTime; } else if (vertical < -0.7) { - surfaceY += 0.05 * elapsedTime; + surfaceY += 0.5 * elapsedTime; } } else { if (vertical > 0.7) { @@ -500,7 +502,7 @@ // Calibrate: press grip while controller rests on the drawing surface if (gripButton.pressed && !didPressGripPrev) { - surfaceY = controllerToUse.position.y; + surfaceY = controllerToUse.localToWorld(new THREE.Vector3(0, 0, 0)).y; isCalibrated = true; } didPressGripPrev = gripButton.pressed; @@ -512,7 +514,9 @@ group.position.set(worldPos.x, surfaceY, worldPos.z); // Lay image flat on surface, rotated by controller yaw - const euler = new THREE.Euler().setFromQuaternion(controllerToUse.quaternion, 'YXZ'); + const worldQuat = new THREE.Quaternion(); + controllerToUse.getWorldQuaternion(worldQuat); + const euler = new THREE.Euler().setFromQuaternion(worldQuat, 'YXZ'); group.rotation.set(-Math.PI / 2, euler.y, 0); } else { // Original behavior before calibration From 1ebf8797b5c23bd1d4c0adde82f163ddd7e58a7e Mon Sep 17 00:00:00 2001 From: felipedse Date: Sun, 15 Mar 2026 23:15:44 -0300 Subject: [PATCH 6/8] Add hit-test functionality for improved surface detection; update UI text for clarity and enhance calibration logic --- index.html | 64 +++++++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 58 insertions(+), 6 deletions(-) diff --git a/index.html b/index.html index 92dd1c5..64c9e15 100644 --- a/index.html +++ b/index.html @@ -133,6 +133,9 @@ let surfaceY = 0; let isCalibrated = false; let didPressGripPrev = false; + let hitTestSource = null; + let hitTestSourceRequested = false; + const GRIP_TO_SURFACE_OFFSET = 0.04; // ~4cm from grip center to controller bottom init(); animate(); @@ -291,7 +294,7 @@ }), new ThreeMeshUI.Text({ - content: "Set surface plane", + content: "Detect surface plane", fontSize: 0.025, fontColor: new THREE.Color(0xffffff) }), @@ -374,7 +377,9 @@ url, function (texture) { - document.body.appendChild(ARButton.createButton(renderer)); + document.body.appendChild(ARButton.createButton(renderer, { + optionalFeatures: ['hit-test'] + })); let originalWidth = texture.image.width; let originalHeight = texture.image.height; @@ -441,13 +446,13 @@ renderer.setAnimationLoop(loop); } - function loop() { - updateInput(); + function loop(timestamp, frame) { + updateInput(frame); ThreeMeshUI.update(); renderer.render(scene, camera); } - function updateInput() { + function updateInput(frame) { const xrSession = renderer.xr.getSession(); const elapsedTime = clock.getDelta(); @@ -458,6 +463,34 @@ return; } + // Set up hit-test source once (downward ray from first controller's grip) + if (!hitTestSourceRequested && frame) { + for (let i = 0; i < xrSession.inputSources.length; i++) { + const inputSource = xrSession.inputSources[i]; + if (inputSource.gripSpace) { + const offsetRay = new XRRay( + { x: 0, y: 0, z: 0, w: 1.0 }, + { x: 0, y: -1.0, z: 0, w: 0.0 } + ); + xrSession.requestHitTestSource({ + space: inputSource.gripSpace, + offsetRay: offsetRay + }).then((source) => { + hitTestSource = source; + }).catch(() => { + // Hit-test not available, will use offset fallback + }); + + xrSession.addEventListener('end', () => { + hitTestSourceRequested = false; + hitTestSource = null; + }); + hitTestSourceRequested = true; + break; + } + } + } + for (let i = 0; i < xrSession.inputSources.length; i++) { const input = xrSession.inputSources[i]; const gamepad = input.gamepad; @@ -502,7 +535,26 @@ // Calibrate: press grip while controller rests on the drawing surface if (gripButton.pressed && !didPressGripPrev) { - surfaceY = controllerToUse.localToWorld(new THREE.Vector3(0, 0, 0)).y; + let calibratedFromHitTest = false; + + // Try hit-test first for accurate surface detection + if (hitTestSource && frame) { + const referenceSpace = renderer.xr.getReferenceSpace(); + const hitResults = frame.getHitTestResults(hitTestSource); + if (hitResults.length > 0) { + const hitPose = hitResults[0].getPose(referenceSpace); + if (hitPose) { + surfaceY = hitPose.transform.position.y; + calibratedFromHitTest = true; + } + } + } + + // Fallback: grip world position minus offset for controller thickness + if (!calibratedFromHitTest) { + surfaceY = controllerToUse.localToWorld(new THREE.Vector3(0, 0, 0)).y - GRIP_TO_SURFACE_OFFSET; + } + isCalibrated = true; } didPressGripPrev = gripButton.pressed; From bcd1777b0fb7748ef49e51ad2c3f54eeb18cff72 Mon Sep 17 00:00:00 2001 From: felipedse Date: Mon, 16 Mar 2026 00:00:48 -0300 Subject: [PATCH 7/8] Refactor grip handling and input logic; streamline UI text for improved user experience and remove unused variables --- calibration.md | 91 +++++++++++++++++++ index.html | 233 ++++++++++++++++++++++--------------------------- 2 files changed, 196 insertions(+), 128 deletions(-) create mode 100644 calibration.md diff --git a/calibration.md b/calibration.md new file mode 100644 index 0000000..792ad5a --- /dev/null +++ b/calibration.md @@ -0,0 +1,91 @@ +# Posicionamento de Imagem em WebXR + +## Abordagem: Two-Handed Grab + +O posicionamento da imagem e feito segurando ambos os grips simultaneamente. Isso permite mover, rotacionar (3 eixos) e escalar a imagem livremente no espaco 3D, sem etapa de calibracao. + +### Como Funciona + +1. **Inicio do grab**: Ao pressionar ambos os grips, calcula-se uma matriz de referencia ("grab space") a partir das posicoes e orientacoes dos dois controles, e armazena-se a transformacao relativa da imagem nesse espaco (`grabLocalMatrix`). + +2. **Durante o grab**: A cada frame, recalcula-se a matriz de referencia com as novas posicoes/orientacoes e aplica-se a transformacao relativa armazenada. + +3. **Fim do grab**: Ao soltar qualquer grip, a imagem permanece onde foi deixada. + +### Calculo da Grab Matrix + +A matriz e construida a partir de: + +- **Posicao**: ponto medio entre os dois controles +- **Rotacao**: frame de coordenadas derivado de: + - Eixo X: direcao do controle 1 ao controle 2 + - Eixo Y: "up" medio dos dois controles (via `getWorldQuaternion`) + - Eixo Z: produto vetorial para completar o frame ortonormal +- **Escala**: tratada separadamente via razao de distancia entre controles + +```javascript +function computeGrabMatrix(c1Pos, c2Pos, c1Quat, c2Quat) { + const midpoint = c1Pos.clone().add(c2Pos).multiplyScalar(0.5); + const direction = c2Pos.clone().sub(c1Pos).normalize(); + + // Media do "up" real dos controles para rotacao 3-eixos + const up1 = new THREE.Vector3(0, 1, 0).applyQuaternion(c1Quat); + const up2 = new THREE.Vector3(0, 1, 0).applyQuaternion(c2Quat); + const avgUp = up1.add(up2).normalize(); + + let forward = new THREE.Vector3().crossVectors(avgUp, direction); + forward.normalize(); + const correctedUp = new THREE.Vector3().crossVectors(direction, forward).normalize(); + + const rotMatrix = new THREE.Matrix4().makeBasis(direction, correctedUp, forward); + const quaternion = new THREE.Quaternion().setFromRotationMatrix(rotMatrix); + + return new THREE.Matrix4().compose(midpoint, quaternion, new THREE.Vector3(1, 1, 1)); +} +``` + +### Graus de Liberdade + +| Gesto | Efeito | +|-------|--------| +| Mover ambas as maos juntas | Translacao (XYZ) | +| Girar as maos como um volante | Rotacao no eixo entre controles | +| Inclinar ambas as maos frente/tras | Pitch | +| Inclinar ambas as maos lateralmente | Roll | +| Afastar/aproximar as maos | Escala (pinch-to-zoom) | + +### Escala + +A escala e calculada pela razao entre a distancia atual e a distancia inicial dos controles no momento do grab: + +```javascript +currentScale = grabStartScale * (newDistance / grabStartDistance); +``` + +Limitada ao intervalo `[0.01, 4.0]`. + +## Controles Adicionais + +| Controle | Acao | +|----------|------| +| Analogico horizontal | Opacidade (+/-) | +| Analogico vertical | Escala (+/-) | +| A ou X | Mostrar/esconder imagem | +| B ou Y | Mostrar/esconder instrucoes | + +Os controles de analogico sao desabilitados durante o grab para evitar conflitos. + +## Armadilhas Comuns + +### controller.position vs localToWorld + +Em Three.js WebXR, o controller tem `matrixAutoUpdate = false` e a pose XR e aplicada direto na `matrix`/`matrixWorld`. Usar `.position` pode retornar (0,0,0) ou valor defasado. **Sempre usar:** + +```javascript +// Posicao world correta +const worldPos = controller.localToWorld(new THREE.Vector3(0, 0, 0)); + +// Quaternion world correto +const worldQuat = new THREE.Quaternion(); +controller.getWorldQuaternion(worldQuat); +``` diff --git a/index.html b/index.html index 64c9e15..2d92a9f 100644 --- a/index.html +++ b/index.html @@ -130,12 +130,10 @@ let currentScale = 1.0; let previousDidPressHideImage = false; let previousDidPressHideInstructions = false; - let surfaceY = 0; - let isCalibrated = false; - let didPressGripPrev = false; - let hitTestSource = null; - let hitTestSourceRequested = false; - const GRIP_TO_SURFACE_OFFSET = 0.04; // ~4cm from grip center to controller bottom + let isGrabbing = false; + let grabLocalMatrix = null; + let grabStartDistance = 0; + let grabStartScale = 1.0; init(); animate(); @@ -288,37 +286,13 @@ new ThreeMeshUI.Text({ - content: "\n\nGrip (on surface): ", + content: "\n\nBoth grips: ", fontSize: 0.025, fontColor: new THREE.Color(0xffff00) }), new ThreeMeshUI.Text({ - content: "Detect surface plane", - fontSize: 0.025, - fontColor: new THREE.Color(0xffffff) - }), - - new ThreeMeshUI.Text({ - content: "\nGrip + analog up/down: ", - fontSize: 0.025, - fontColor: new THREE.Color(0xffff00) - }), - - new ThreeMeshUI.Text({ - content: "Adjust height", - fontSize: 0.025, - fontColor: new THREE.Color(0xffffff) - }), - - new ThreeMeshUI.Text({ - content: "\nHold trigger: ", - fontSize: 0.025, - fontColor: new THREE.Color(0xffff00) - }), - - new ThreeMeshUI.Text({ - content: "Move image on surface", + content: "Grab and move image", fontSize: 0.025, fontColor: new THREE.Color(0xffffff) }), @@ -377,9 +351,7 @@ url, function (texture) { - document.body.appendChild(ARButton.createButton(renderer, { - optionalFeatures: ['hit-test'] - })); + document.body.appendChild(ARButton.createButton(renderer)); let originalWidth = texture.image.width; let originalHeight = texture.image.height; @@ -452,6 +424,33 @@ renderer.render(scene, camera); } + function computeGrabMatrix(c1Pos, c2Pos, c1Quat, c2Quat) { + const midpoint = c1Pos.clone().add(c2Pos).multiplyScalar(0.5); + const direction = c2Pos.clone().sub(c1Pos).normalize(); + + // Average "up" from both controllers for full 3-axis rotation + const up1 = new THREE.Vector3(0, 1, 0).applyQuaternion(c1Quat); + const up2 = new THREE.Vector3(0, 1, 0).applyQuaternion(c2Quat); + const avgUp = up1.add(up2).normalize(); + + let forward = new THREE.Vector3().crossVectors(avgUp, direction); + if (forward.lengthSq() < 0.001) { + forward.set(0, 0, 1); + } + forward.normalize(); + + const correctedUp = new THREE.Vector3().crossVectors(direction, forward).normalize(); + + const rotMatrix = new THREE.Matrix4().makeBasis(direction, correctedUp, forward); + const quaternion = new THREE.Quaternion().setFromRotationMatrix(rotMatrix); + + return new THREE.Matrix4().compose( + midpoint, + quaternion, + new THREE.Vector3(1, 1, 1) + ); + } + function updateInput(frame) { const xrSession = renderer.xr.getSession(); const elapsedTime = clock.getDelta(); @@ -463,121 +462,54 @@ return; } - // Set up hit-test source once (downward ray from first controller's grip) - if (!hitTestSourceRequested && frame) { - for (let i = 0; i < xrSession.inputSources.length; i++) { - const inputSource = xrSession.inputSources[i]; - if (inputSource.gripSpace) { - const offsetRay = new XRRay( - { x: 0, y: 0, z: 0, w: 1.0 }, - { x: 0, y: -1.0, z: 0, w: 0.0 } - ); - xrSession.requestHitTestSource({ - space: inputSource.gripSpace, - offsetRay: offsetRay - }).then((source) => { - hitTestSource = source; - }).catch(() => { - // Hit-test not available, will use offset fallback - }); - - xrSession.addEventListener('end', () => { - hitTestSourceRequested = false; - hitTestSource = null; - }); - hitTestSourceRequested = true; - break; - } - } - } + let bothGripsPressed = true; + let controllerPositions = []; + let controllerQuaternions = []; + let validControllerCount = 0; for (let i = 0; i < xrSession.inputSources.length; i++) { const input = xrSession.inputSources[i]; - const gamepad = input.gamepad; + const gamepad = input.gamepad; if ( - gamepad != null && + gamepad != null && gamepad.mapping == "xr-standard" && gamepad.axes.length >= 4 ) { - const horizontal = gamepad.axes[2]; // Primary thumbstick X - const vertical = gamepad.axes[3]; // Primary thumbstick Y + validControllerCount++; + + const horizontal = gamepad.axes[2]; + const vertical = gamepad.axes[3]; - const triggerButton = gamepad.buttons[0]; const gripButton = gamepad.buttons[1]; const axButton = gamepad.buttons[4]; const byButton = gamepad.buttons[5]; - if (gripButton.pressed && isCalibrated) { - // Grip held: thumbstick vertical adjusts surface height - if (vertical > 0.7) { - surfaceY -= 0.5 * elapsedTime; - } else if (vertical < -0.7) { - surfaceY += 0.5 * elapsedTime; - } - } else { + if (!gripButton.pressed) { + bothGripsPressed = false; + } + + // Thumbstick controls (only when not grabbing) + if (!isGrabbing) { if (vertical > 0.7) { incrementScale(-1.0 * elapsedTime); } else if (vertical < -0.7) { incrementScale(1.0 * elapsedTime); } - } - - if (horizontal > 0.7) { - incrementOpacity(1.0 * elapsedTime); - } else if (horizontal < -0.7) { - incrementOpacity(-1.0 * elapsedTime); - } - - // FIXME: This index won't match the correct controller - // if a disconnection happens during the session. - const controllerToUse = renderer.xr.getController(i); - - // Calibrate: press grip while controller rests on the drawing surface - if (gripButton.pressed && !didPressGripPrev) { - let calibratedFromHitTest = false; - - // Try hit-test first for accurate surface detection - if (hitTestSource && frame) { - const referenceSpace = renderer.xr.getReferenceSpace(); - const hitResults = frame.getHitTestResults(hitTestSource); - if (hitResults.length > 0) { - const hitPose = hitResults[0].getPose(referenceSpace); - if (hitPose) { - surfaceY = hitPose.transform.position.y; - calibratedFromHitTest = true; - } - } - } - // Fallback: grip world position minus offset for controller thickness - if (!calibratedFromHitTest) { - surfaceY = controllerToUse.localToWorld(new THREE.Vector3(0, 0, 0)).y - GRIP_TO_SURFACE_OFFSET; - } - - isCalibrated = true; - } - didPressGripPrev = gripButton.pressed; - - if (triggerButton.pressed) { - if (isCalibrated) { - // Project controller XZ onto the calibrated surface plane - const worldPos = controllerToUse.localToWorld(new THREE.Vector3(0, 0, 0)); - group.position.set(worldPos.x, surfaceY, worldPos.z); - - // Lay image flat on surface, rotated by controller yaw - const worldQuat = new THREE.Quaternion(); - controllerToUse.getWorldQuaternion(worldQuat); - const euler = new THREE.Euler().setFromQuaternion(worldQuat, 'YXZ'); - group.rotation.set(-Math.PI / 2, euler.y, 0); - } else { - // Original behavior before calibration - const offsetPosition = controllerToUse.localToWorld(new THREE.Vector3(0, 0, 0.045)); - group.position.copy(offsetPosition); - group.setRotationFromQuaternion(controllerToUse.quaternion); + if (horizontal > 0.7) { + incrementOpacity(1.0 * elapsedTime); + } else if (horizontal < -0.7) { + incrementOpacity(-1.0 * elapsedTime); } } + const controllerObj = renderer.xr.getController(i); + controllerPositions.push(controllerObj.localToWorld(new THREE.Vector3(0, 0, 0))); + const worldQuat = new THREE.Quaternion(); + controllerObj.getWorldQuaternion(worldQuat); + controllerQuaternions.push(worldQuat); + if (axButton.pressed) { didPressHideImage = true; } @@ -588,6 +520,51 @@ } } + bothGripsPressed = bothGripsPressed && validControllerCount >= 2 && controllerPositions.length >= 2; + + if (bothGripsPressed) { + const c1 = controllerPositions[0]; + const c2 = controllerPositions[1]; + + if (!isGrabbing) { + // Grab start + isGrabbing = true; + grabStartDistance = c1.distanceTo(c2); + grabStartScale = currentScale; + + const grabMatrix = computeGrabMatrix(c1, c2, controllerQuaternions[0], controllerQuaternions[1]); + const grabMatrixInverse = grabMatrix.clone().invert(); + const objectMatrix = new THREE.Matrix4().compose( + group.position, + group.quaternion, + new THREE.Vector3(1, 1, 1) + ); + grabLocalMatrix = grabMatrixInverse.multiply(objectMatrix); + } else { + // Grab update + const newGrabMatrix = computeGrabMatrix(c1, c2, controllerQuaternions[0], controllerQuaternions[1]); + const newObjectMatrix = newGrabMatrix.clone().multiply(grabLocalMatrix); + + const pos = new THREE.Vector3(); + const quat = new THREE.Quaternion(); + const scl = new THREE.Vector3(); + newObjectMatrix.decompose(pos, quat, scl); + + group.position.copy(pos); + group.quaternion.copy(quat); + + // Scale from controller distance + const newDistance = c1.distanceTo(c2); + if (grabStartDistance > 0.01) { + currentScale = grabStartScale * (newDistance / grabStartDistance); + currentScale = Math.min(Math.max(currentScale, 0.01), 4.0); + group.scale.setScalar(currentScale); + } + } + } else { + isGrabbing = false; + } + const shouldChangeInstructionsVisibility = (!didPressHideInstructions && previousDidPressHideInstructions); previousDidPressHideInstructions = didPressHideInstructions; From c1711cfbf9d2b4bd2229f9a7210e02fca8a16555 Mon Sep 17 00:00:00 2001 From: felipedse Date: Mon, 16 Mar 2026 08:03:37 -0300 Subject: [PATCH 8/8] Refactor grab state management and enhance hit-test reticle; update UI text for improved clarity and user interaction --- index.html | 423 ++++++++++++++++++++++++++++++++++++++--------------- 1 file changed, 307 insertions(+), 116 deletions(-) diff --git a/index.html b/index.html index 2d92a9f..123204d 100644 --- a/index.html +++ b/index.html @@ -96,9 +96,6 @@ vec3 sobelRGB = vec3(1.0 - sobelResult); vec4 currentColor = texture2D(colorTexture, uv); - // Two-zone opacity control: - // opacity 0.0-0.4: visibility fades from transparent to edges-only - // opacity 0.4-0.9: blend from edges-only to full original image float visibility = smoothstep(0.0, 0.4, opacity); float imageBlend = smoothstep(0.4, 0.9, opacity); @@ -130,10 +127,28 @@ let currentScale = 1.0; let previousDidPressHideImage = false; let previousDidPressHideInstructions = false; - let isGrabbing = false; - let grabLocalMatrix = null; - let grabStartDistance = 0; - let grabStartScale = 1.0; + + // ============================================================ + // DELTA-BASED GRAB STATE + // Instead of computing absolute rotation each frame, we track + // the CHANGE between frames and apply it to the object. + // ============================================================ + + // Two-hand grab state + let isTwoHandGrabbing = false; + let prevGrabMidpoint = null; + let prevGrabQuat = null; + let prevGrabDistance = 0; + + // Single-hand grab state (trigger on either controller) + let singleGrabControllerIndex = -1; + let prevSingleGrabPos = null; + let prevSingleGrabQuat = null; + + // Hit-test state + let hitTestSource = null; + let hitTestReticle = null; + let hitTestPose = null; init(); animate(); @@ -153,7 +168,6 @@ clock = new THREE.Clock(); // Renderer - renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true }); renderer.setPixelRatio(window.devicePixelRatio); renderer.setSize(window.innerWidth, window.innerHeight); @@ -164,23 +178,32 @@ container.appendChild(renderer.domElement); // Controllers - controller1 = renderer.xr.getController(0); scene.add(controller1); controller2 = renderer.xr.getController(1); scene.add(controller2); - // UI + // Hit-test reticle (green ring on detected surfaces) + const reticleGeometry = new THREE.RingGeometry(0.04, 0.05, 32).rotateX(-Math.PI / 2); + const reticleMaterial = new THREE.MeshBasicMaterial({ + color: 0x00ff88, + transparent: true, + opacity: 0.6, + side: THREE.DoubleSide + }); + hitTestReticle = new THREE.Mesh(reticleGeometry, reticleMaterial); + hitTestReticle.visible = false; + hitTestReticle.matrixAutoUpdate = false; + scene.add(hitTestReticle); + // UI makeInstructionsUI(); // Load image - const length = 0.25; group = new THREE.Group(); group.position.set(0, 0, -0.5); - scene.add(group); const url = new URL(window.location); @@ -199,16 +222,13 @@ } // Resize event - window.addEventListener('resize', onWindowResize); // URL Input - const inputLink = document.getElementById("urlInputLink"); inputLink.onclick = changeImageUrl; // Send to Quest - const sendToQuestLink = document.getElementById("urlToQuestLink"); sendToQuestLink.onclick = sendUrlToQuest; } @@ -217,7 +237,7 @@ const textContainer = new ThreeMeshUI.Block({ borderRadius: 0.05, width: 0.45, - height: 0.60, + height: 0.68, padding: 0.05, textAlign: 'left', fontKerning: 'none', @@ -235,86 +255,81 @@ fontSize: 0.04, fontColor: new THREE.Color(0xffff00) }), - new ThreeMeshUI.Text({ content: "\nAnalog right: ", fontSize: 0.025, fontColor: new THREE.Color(0xffff00) }), - new ThreeMeshUI.Text({ content: "Increase opacity", fontSize: 0.025, fontColor: new THREE.Color(0xffffff) }), - new ThreeMeshUI.Text({ content: "\nAnalog left: ", fontSize: 0.025, fontColor: new THREE.Color(0xffff00) }), - new ThreeMeshUI.Text({ content: "Decrease opacity", fontSize: 0.025, fontColor: new THREE.Color(0xffffff) }), - new ThreeMeshUI.Text({ content: "\n\nAnalog up: ", fontSize: 0.025, fontColor: new THREE.Color(0xffff00) }), - new ThreeMeshUI.Text({ content: "Increase size", fontSize: 0.025, fontColor: new THREE.Color(0xffffff) }), - new ThreeMeshUI.Text({ content: "\nAnalog down: ", fontSize: 0.025, fontColor: new THREE.Color(0xffff00) }), - new ThreeMeshUI.Text({ content: "Decrease size", fontSize: 0.025, fontColor: new THREE.Color(0xffffff) }), - - new ThreeMeshUI.Text({ - content: "\n\nBoth grips: ", + content: "\n\nTrigger: ", fontSize: 0.025, fontColor: new THREE.Color(0xffff00) }), - new ThreeMeshUI.Text({ - content: "Grab and move image", + content: "Grab with one hand", + fontSize: 0.025, + fontColor: new THREE.Color(0xffffff) + }), + new ThreeMeshUI.Text({ + content: "\nBoth grips: ", + fontSize: 0.025, + fontColor: new THREE.Color(0xffff00) + }), + new ThreeMeshUI.Text({ + content: "Grab + rotate + scale", fontSize: 0.025, fontColor: new THREE.Color(0xffffff) }), - new ThreeMeshUI.Text({ content: "\n\nA or X: ", fontSize: 0.025, fontColor: new THREE.Color(0xffff00) }), - new ThreeMeshUI.Text({ - content: "Show/Hide image", + content: "Snap to surface / Show-Hide", fontSize: 0.025, fontColor: new THREE.Color(0xffffff) }), - new ThreeMeshUI.Text({ content: "\nB or Y: ", fontSize: 0.025, fontColor: new THREE.Color(0xffff00) }), - new ThreeMeshUI.Text({ content: "Show/Hide instructions", fontSize: 0.025, @@ -344,14 +359,35 @@ } function loadTexture(container, length, url) { - const loader = new THREE.TextureLoader(); loader.load( url, - function (texture) { - document.body.appendChild(ARButton.createButton(renderer)); + // Request hit-test as optional feature + const sessionInit = { + requiredFeatures: ['local-floor'], + optionalFeatures: ['hit-test'] + }; + + const arButton = ARButton.createButton(renderer, sessionInit); + document.body.appendChild(arButton); + + // Setup hit-test when XR session starts + renderer.xr.addEventListener('sessionstart', async () => { + const session = renderer.xr.getSession(); + try { + const viewerSpace = await session.requestReferenceSpace('viewer'); + hitTestSource = await session.requestHitTestSource({ space: viewerSpace }); + } catch (e) { + console.log('Hit-test not available:', e); + hitTestSource = null; + } + }); + + renderer.xr.addEventListener('sessionend', () => { + hitTestSource = null; + }); let originalWidth = texture.image.width; let originalHeight = texture.image.height; @@ -361,7 +397,6 @@ let height = (originalHeight/max) * length; const geometry = new THREE.PlaneGeometry(width, height); - texture.generateMipmaps = false; const sobelMaterial = new THREE.ShaderMaterial({ @@ -376,12 +411,9 @@ }); imagePlane = new THREE.Mesh(geometry, sobelMaterial); - container.add(imagePlane); }, - undefined, - function (err) { alert("Unable to load image, try a different URL.\n\nCross-Origin Resource Sharing (CORS) policies might prevent the app from loading images from certain websites."); } @@ -391,7 +423,6 @@ function incrementOpacity(delta) { currentOpacity += delta; currentOpacity = Math.min(Math.max(currentOpacity, 0.0), 1.0); - if (imagePlane != null) { imagePlane.material.uniforms["opacity"].value = currentOpacity; } @@ -399,10 +430,9 @@ function incrementScale(delta) { currentScale += delta; - currentScale = Math.min(Math.max(currentScale, 0.0), 4.0); - + currentScale = Math.min(Math.max(currentScale, 0.01), 4.0); if (group != null) { - group.scale.set(currentScale, currentScale, currentScale); + group.scale.setScalar(currentScale); } } @@ -412,45 +442,216 @@ renderer.setSize( window.innerWidth, window.innerHeight ); } - // - function animate() { renderer.setAnimationLoop(loop); } function loop(timestamp, frame) { + updateHitTest(frame); updateInput(frame); ThreeMeshUI.update(); renderer.render(scene, camera); } - function computeGrabMatrix(c1Pos, c2Pos, c1Quat, c2Quat) { - const midpoint = c1Pos.clone().add(c2Pos).multiplyScalar(0.5); - const direction = c2Pos.clone().sub(c1Pos).normalize(); + // ============================================================ + // HIT-TEST: detect real surfaces and show reticle + // ============================================================ - // Average "up" from both controllers for full 3-axis rotation - const up1 = new THREE.Vector3(0, 1, 0).applyQuaternion(c1Quat); - const up2 = new THREE.Vector3(0, 1, 0).applyQuaternion(c2Quat); - const avgUp = up1.add(up2).normalize(); + function updateHitTest(frame) { + if (!frame || !hitTestSource) { + hitTestReticle.visible = false; + return; + } - let forward = new THREE.Vector3().crossVectors(avgUp, direction); - if (forward.lengthSq() < 0.001) { - forward.set(0, 0, 1); + const refSpace = renderer.xr.getReferenceSpace(); + const results = frame.getHitTestResults(hitTestSource); + + if (results.length > 0) { + const hit = results[0]; + const pose = hit.getPose(refSpace); + if (pose) { + hitTestReticle.visible = true; + hitTestReticle.matrix.fromArray(pose.transform.matrix); + hitTestPose = pose; + } else { + hitTestReticle.visible = false; + hitTestPose = null; + } + } else { + hitTestReticle.visible = false; + hitTestPose = null; } - forward.normalize(); + } - const correctedUp = new THREE.Vector3().crossVectors(direction, forward).normalize(); + // ============================================================ + // SNAP TO SURFACE: place image flat on detected surface + // ============================================================ - const rotMatrix = new THREE.Matrix4().makeBasis(direction, correctedUp, forward); - const quaternion = new THREE.Quaternion().setFromRotationMatrix(rotMatrix); + function snapToSurface() { + if (!hitTestPose || !group) return false; - return new THREE.Matrix4().compose( - midpoint, - quaternion, - new THREE.Vector3(1, 1, 1) + const matrix = new THREE.Matrix4().fromArray(hitTestPose.transform.matrix); + const pos = new THREE.Vector3(); + const quat = new THREE.Quaternion(); + const scl = new THREE.Vector3(); + matrix.decompose(pos, quat, scl); + + // Offset slightly above the surface to prevent z-fighting + pos.y += 0.002; + group.position.copy(pos); + group.quaternion.copy(quat); + + // Lay the plane flat on the surface. + // Hit-test Y = surface normal, so rotate -90deg around X + const layFlat = new THREE.Quaternion().setFromAxisAngle( + new THREE.Vector3(1, 0, 0), -Math.PI / 2 ); + group.quaternion.multiply(layFlat); + + return true; + } + + // ============================================================ + // HELPER: Get controller world position and quaternion + // In WebXR, controller.position can be stale. Always use these. + // ============================================================ + + function getControllerWorldPos(index) { + const c = renderer.xr.getController(index); + return c.localToWorld(new THREE.Vector3(0, 0, 0)); + } + + function getControllerWorldQuat(index) { + const c = renderer.xr.getController(index); + const q = new THREE.Quaternion(); + c.getWorldQuaternion(q); + return q; + } + + // ============================================================ + // DELTA-BASED TWO-HAND GRAB + // + // Key insight: instead of computing "where should the image be" + // from scratch each frame, we compute "how did the hands change + // since last frame" and apply that delta to the current pose. + // + // This captures ALL rotations faithfully (pitch, yaw, roll) + // because we use the average quaternion of both controllers + // directly via SLERP, rather than trying to construct rotation + // from axis vectors (which was the root cause of the old bug). + // ============================================================ + + function computeTwoHandPose(c1Pos, c2Pos, c1Quat, c2Quat) { + const midpoint = c1Pos.clone().add(c2Pos).multiplyScalar(0.5); + + // SLERP at 0.5 = average orientation of both hands. + // Directly captures actual tilt/pitch/roll. + const avgQuat = c1Quat.clone().slerp(c2Quat, 0.5); + + const distance = c1Pos.distanceTo(c2Pos); + + return { midpoint, quat: avgQuat, distance }; } + function updateTwoHandGrab(c1Pos, c2Pos, c1Quat, c2Quat) { + const current = computeTwoHandPose(c1Pos, c2Pos, c1Quat, c2Quat); + + if (!isTwoHandGrabbing) { + // === GRAB START === + isTwoHandGrabbing = true; + prevGrabMidpoint = current.midpoint.clone(); + prevGrabQuat = current.quat.clone(); + prevGrabDistance = current.distance; + return; + } + + // === GRAB UPDATE: compute delta and apply === + + // 1. TRANSLATION DELTA + const deltaPos = current.midpoint.clone().sub(prevGrabMidpoint); + group.position.add(deltaPos); + + // 2. ROTATION DELTA + // deltaQuat = current * inverse(previous) + // = "how much did the hands rotate since last frame" + const prevQuatInv = prevGrabQuat.clone().invert(); + const deltaQuat = current.quat.clone().multiply(prevQuatInv); + + // Apply rotation around the grab midpoint (natural pivot) + const pivot = current.midpoint; + const objToPivot = group.position.clone().sub(pivot); + objToPivot.applyQuaternion(deltaQuat); + group.position.copy(pivot).add(objToPivot); + group.quaternion.premultiply(deltaQuat); + + // 3. SCALE DELTA (pinch-to-zoom) + if (prevGrabDistance > 0.01 && current.distance > 0.01) { + const scaleRatio = current.distance / prevGrabDistance; + currentScale *= scaleRatio; + currentScale = Math.min(Math.max(currentScale, 0.01), 4.0); + group.scale.setScalar(currentScale); + } + + // Store for next frame + prevGrabMidpoint = current.midpoint.clone(); + prevGrabQuat = current.quat.clone(); + prevGrabDistance = current.distance; + } + + function endTwoHandGrab() { + isTwoHandGrabbing = false; + prevGrabMidpoint = null; + prevGrabQuat = null; + prevGrabDistance = 0; + } + + // ============================================================ + // SINGLE-HAND GRAB (trigger button) + // + // Press trigger with one hand to grab and move/rotate the image. + // Uses the same delta technique — tracks the controller's + // quaternion change frame-to-frame and applies it directly. + // ============================================================ + + function updateSingleHandGrab(controllerIndex) { + const pos = getControllerWorldPos(controllerIndex); + const quat = getControllerWorldQuat(controllerIndex); + + if (singleGrabControllerIndex !== controllerIndex) { + // === GRAB START === + singleGrabControllerIndex = controllerIndex; + prevSingleGrabPos = pos.clone(); + prevSingleGrabQuat = quat.clone(); + return; + } + + // === GRAB UPDATE === + const deltaPos = pos.clone().sub(prevSingleGrabPos); + group.position.add(deltaPos); + + const prevQuatInv = prevSingleGrabQuat.clone().invert(); + const deltaQuat = quat.clone().multiply(prevQuatInv); + + const pivot = pos; + const objToPivot = group.position.clone().sub(pivot); + objToPivot.applyQuaternion(deltaQuat); + group.position.copy(pivot).add(objToPivot); + group.quaternion.premultiply(deltaQuat); + + prevSingleGrabPos = pos.clone(); + prevSingleGrabQuat = quat.clone(); + } + + function endSingleHandGrab() { + singleGrabControllerIndex = -1; + prevSingleGrabPos = null; + prevSingleGrabQuat = null; + } + + // ============================================================ + // INPUT PROCESSING + // ============================================================ + function updateInput(frame) { const xrSession = renderer.xr.getSession(); const elapsedTime = clock.getDelta(); @@ -466,6 +667,7 @@ let controllerPositions = []; let controllerQuaternions = []; let validControllerCount = 0; + let triggerControllerIndex = -1; for (let i = 0; i < xrSession.inputSources.length; i++) { const input = xrSession.inputSources[i]; @@ -481,6 +683,7 @@ const horizontal = gamepad.axes[2]; const vertical = gamepad.axes[3]; + const triggerButton = gamepad.buttons[0]; const gripButton = gamepad.buttons[1]; const axButton = gamepad.buttons[4]; const byButton = gamepad.buttons[5]; @@ -489,8 +692,13 @@ bothGripsPressed = false; } + // Track trigger for single-hand grab + if (triggerButton.pressed) { + triggerControllerIndex = i; + } + // Thumbstick controls (only when not grabbing) - if (!isGrabbing) { + if (!isTwoHandGrabbing && singleGrabControllerIndex < 0) { if (vertical > 0.7) { incrementScale(-1.0 * elapsedTime); } else if (vertical < -0.7) { @@ -504,11 +712,8 @@ } } - const controllerObj = renderer.xr.getController(i); - controllerPositions.push(controllerObj.localToWorld(new THREE.Vector3(0, 0, 0))); - const worldQuat = new THREE.Quaternion(); - controllerObj.getWorldQuaternion(worldQuat); - controllerQuaternions.push(worldQuat); + controllerPositions.push(getControllerWorldPos(i)); + controllerQuaternions.push(getControllerWorldQuat(i)); if (axButton.pressed) { didPressHideImage = true; @@ -522,62 +727,48 @@ bothGripsPressed = bothGripsPressed && validControllerCount >= 2 && controllerPositions.length >= 2; + // === PRIORITY: Two-hand grab > Single-hand grab === + if (bothGripsPressed) { - const c1 = controllerPositions[0]; - const c2 = controllerPositions[1]; - - if (!isGrabbing) { - // Grab start - isGrabbing = true; - grabStartDistance = c1.distanceTo(c2); - grabStartScale = currentScale; - - const grabMatrix = computeGrabMatrix(c1, c2, controllerQuaternions[0], controllerQuaternions[1]); - const grabMatrixInverse = grabMatrix.clone().invert(); - const objectMatrix = new THREE.Matrix4().compose( - group.position, - group.quaternion, - new THREE.Vector3(1, 1, 1) - ); - grabLocalMatrix = grabMatrixInverse.multiply(objectMatrix); - } else { - // Grab update - const newGrabMatrix = computeGrabMatrix(c1, c2, controllerQuaternions[0], controllerQuaternions[1]); - const newObjectMatrix = newGrabMatrix.clone().multiply(grabLocalMatrix); - - const pos = new THREE.Vector3(); - const quat = new THREE.Quaternion(); - const scl = new THREE.Vector3(); - newObjectMatrix.decompose(pos, quat, scl); - - group.position.copy(pos); - group.quaternion.copy(quat); - - // Scale from controller distance - const newDistance = c1.distanceTo(c2); - if (grabStartDistance > 0.01) { - currentScale = grabStartScale * (newDistance / grabStartDistance); - currentScale = Math.min(Math.max(currentScale, 0.01), 4.0); - group.scale.setScalar(currentScale); - } + if (singleGrabControllerIndex >= 0) { + endSingleHandGrab(); } + + updateTwoHandGrab( + controllerPositions[0], controllerPositions[1], + controllerQuaternions[0], controllerQuaternions[1] + ); } else { - isGrabbing = false; + if (isTwoHandGrabbing) { + endTwoHandGrab(); + } + + if (triggerControllerIndex >= 0) { + updateSingleHandGrab(triggerControllerIndex); + } else if (singleGrabControllerIndex >= 0) { + endSingleHandGrab(); + } + } + + // === A/X: snap to surface if available, else toggle visibility === + + const shouldHandleAX = (!didPressHideImage && previousDidPressHideImage); + previousDidPressHideImage = didPressHideImage; + + if (shouldHandleAX) { + if (!snapToSurface()) { + imagePlane.visible = !imagePlane.visible; + } } + // === B/Y: toggle instructions === + const shouldChangeInstructionsVisibility = (!didPressHideInstructions && previousDidPressHideInstructions); previousDidPressHideInstructions = didPressHideInstructions; if (shouldChangeInstructionsVisibility) { instructionsPlane.visible = !instructionsPlane.visible; } - - const shouldChangeImageVisibility = (!didPressHideImage && previousDidPressHideImage); - previousDidPressHideImage = didPressHideImage; - - if (shouldChangeImageVisibility) { - imagePlane.visible = !imagePlane.visible; - } }