diff --git a/package.json b/package.json index 6e247ff..4e02585 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "vite_react_shadcn_ts", "private": true, - "version": "1.0.4", + "version": "1.0.6", "type": "module", "scripts": { "dev": "vite", diff --git a/src/components/three/ModelViewer.tsx b/src/components/three/ModelViewer.tsx index fb536b6..8af14cd 100644 --- a/src/components/three/ModelViewer.tsx +++ b/src/components/three/ModelViewer.tsx @@ -59,6 +59,15 @@ export function findElementRoot(obj: THREE.Object3D | null): THREE.Object3D | nu return fallback; } +export function findModelId(obj: THREE.Object3D | null): string | undefined { + let cur: THREE.Object3D | null = obj; + while (cur) { + if (cur.userData?.modelId) return cur.userData.modelId as string; + cur = cur.parent; + } + return undefined; +} + function hasIfcAncestor(obj: THREE.Object3D): boolean { let cur = obj.parent; while (cur) { @@ -574,7 +583,9 @@ function SmartSnapHandler() { } } if (bestHole) { - setSnapPoint(bestHole); + // Find the measurement we matched + const matched = st.measurements.find(m => m.pointA.x === bestHole.x && m.pointA.y === bestHole.y && m.pointA.z === bestHole.z); + setSnapPoint({ ...bestHole, modelId: matched?.modelId }); return; } @@ -585,7 +596,8 @@ function SmartSnapHandler() { 10 ); if (snap) { - setSnapPoint({ x: snap.x, y: snap.y, z: snap.z }); + const hitModelId = findModelId(hit.object); + setSnapPoint({ x: snap.x, y: snap.y, z: snap.z, modelId: hitModelId }); } else { setSnapPoint(null); } @@ -653,11 +665,13 @@ function HoverDetector() { // 1) Try circle fit from edge vertices around the hit (catches holes on flat faces) const circ = detectCircularEdgeAtPoint(hit.object, hit.point, camera, canvasSize, 50); + const hitModelId = findModelId(hit.object); if (circ) { setHoverInfo({ type: 'hole', position: [circ.center.x, circ.center.y, circ.center.z], value: circ.diameterMM, + modelId: hitModelId, }); } else if (hit.faceIndex !== undefined && (() => { const hole = detectHoleAtFace(hit.object, hit.faceIndex); @@ -666,6 +680,7 @@ function HoverDetector() { type: 'hole', position: [hole.center.x, hole.center.y, hole.center.z], value: hole.diameterMM, + modelId: hitModelId, }); return true; } @@ -680,6 +695,7 @@ function HoverDetector() { type: 'edge', position: [edge.midpoint.x, edge.midpoint.y, edge.midpoint.z], value: edge.lengthMM, + modelId: hitModelId, endpoints: { a: { x: edge.a.x, y: edge.a.y, z: edge.a.z }, b: { x: edge.b.x, y: edge.b.y, z: edge.b.z }, @@ -763,7 +779,7 @@ function MeasureClickHandler() { // Prefer snap (vertex snap, or hole-center snap from SmartSnapHandler) if (st.snapPoint) { - st.addMeasurePoint({ x: st.snapPoint.x, y: st.snapPoint.y, z: st.snapPoint.z }); + st.addMeasurePoint({ x: st.snapPoint.x, y: st.snapPoint.y, z: st.snapPoint.z, modelId: st.snapPoint.modelId }); return; } @@ -782,7 +798,8 @@ function MeasureClickHandler() { return true; }); if (hit) { - st.addMeasurePoint({ x: hit.point.x, y: hit.point.y, z: hit.point.z }); + const hitModelId = findModelId(hit.object); + st.addMeasurePoint({ x: hit.point.x, y: hit.point.y, z: hit.point.z, modelId: hitModelId }); } }; diff --git a/src/components/three/XRControllerMeasure.tsx b/src/components/three/XRControllerMeasure.tsx index de52e40..398c6a6 100644 --- a/src/components/three/XRControllerMeasure.tsx +++ b/src/components/three/XRControllerMeasure.tsx @@ -26,6 +26,31 @@ function isPickableModelMesh(o: THREE.Object3D): boolean { return false; } +function findModelId(obj: THREE.Object3D): string | undefined { + let cur: THREE.Object3D | null = obj; + while (cur) { + if (cur.userData?.modelId) return cur.userData.modelId as string; + cur = cur.parent; + } + return undefined; +} + +function triggerHaptic(session: XRSession | null, handedness: 'left' | 'right', intensity = 0.5, duration = 40) { + if (!session) return; + try { + for (const source of session.inputSources) { + if (source.handedness === handedness) { + const gp = source.gamepad; + if (gp && gp.hapticActuators && gp.hapticActuators.length > 0) { + gp.hapticActuators[0].pulse(intensity, duration).catch(() => {}); + } + } + } + } catch (e) { + console.warn('[XR][Haptic] erro ao vibrar controle:', e); + } +} + /** * Right-controller trigger driven measurement for AR. * @@ -65,6 +90,7 @@ export function XRControllerMeasure() { const dwellPos = useRef(new THREE.Vector3(Infinity, Infinity, Infinity)); const dwellStart = useRef(0); const dwellFired = useRef(false); + const lastSnapKind = useRef(null); // Throttling: raycast + snap analysis are heavy on dense IFC models and // running them every frame at 72fps can stall the Quest right after @@ -177,6 +203,12 @@ export function XRControllerMeasure() { } } + // ── Haptic feedback on snap state changes ──────────────────────── + if (snappedPoint && snapKind !== 'surface' && lastSnapKind.current !== snapKind) { + triggerHaptic(session, 'right', 0.4, 15); + } + lastSnapKind.current = snappedPoint ? snapKind : null; + // ── Dwell detection (1 s) to auto-register hovered hole/edge ───── const measureModeNow = useModelStore.getState().measureMode; const nowT = performance.now(); @@ -189,15 +221,18 @@ export function XRControllerMeasure() { } else if (!dwellFired.current && nowT - dwellStart.current > 1000) { dwellFired.current = true; if (measureModeNow) { + const hitModelId = hit ? findModelId(hit.object) : undefined; useModelStore.getState().registerHoverMeasurement({ type: hoverDetected.kind, value: hoverDetected.value, position: [hoverDetected.position.x, hoverDetected.position.y, hoverDetected.position.z], + modelId: hitModelId, endpoints: hoverDetected.endpoints && { a: { x: hoverDetected.endpoints.a.x, y: hoverDetected.endpoints.a.y, z: hoverDetected.endpoints.a.z }, b: { x: hoverDetected.endpoints.b.x, y: hoverDetected.endpoints.b.y, z: hoverDetected.endpoints.b.z }, }, }); + triggerHaptic(session, 'right', 0.6, 50); } } } else { @@ -270,10 +305,13 @@ export function XRControllerMeasure() { } } } else if (snappedPoint) { + const hitModelId = hit ? findModelId(hit.object) : undefined; sendRemoteLog('info', 'Trigger físico pressionado no AR com snapPoint ativo', snappedPoint); st.addMeasurePoint({ x: snappedPoint.x, y: snappedPoint.y, z: snappedPoint.z, + modelId: hitModelId, }); + triggerHaptic(session, 'right', 0.8, 25); } } else if (trigState.current && trigVal < TRIG_OFF) { trigState.current = false; @@ -285,6 +323,7 @@ export function XRControllerMeasure() { if (!aState.current && aVal > BTN_ON) { aState.current = true; useModelStore.getState().undoLastMeasurement(); + triggerHaptic(session, 'right', 0.5, 30); } else if (aState.current && aVal < TRIG_OFF) { aState.current = false; } @@ -295,6 +334,7 @@ export function XRControllerMeasure() { if (!bState.current && bVal > BTN_ON) { bState.current = true; useModelStore.getState().clearMeasurements(); + triggerHaptic(session, 'right', 0.7, 60); } else if (bState.current && bVal < TRIG_OFF) { bState.current = false; } diff --git a/src/components/three/XRGrabbable.tsx b/src/components/three/XRGrabbable.tsx index 69acba7..88a08ef 100644 --- a/src/components/three/XRGrabbable.tsx +++ b/src/components/three/XRGrabbable.tsx @@ -1,9 +1,25 @@ import { useRef, ReactNode, useEffect } from 'react'; -import { useFrame } from '@react-three/fiber'; +import { useFrame, useThree } from '@react-three/fiber'; import * as THREE from 'three'; import { useControllerGrab, ControllerGrabSnapshot } from '@/hooks/useControllerGrab'; import { useModelStore } from '@/stores/useModelStore'; +function triggerHaptic(session: XRSession | null, handedness: 'left' | 'right', intensity = 0.5, duration = 40) { + if (!session) return; + try { + for (const source of session.inputSources) { + if (source.handedness === handedness) { + const gp = source.gamepad; + if (gp && gp.hapticActuators && gp.hapticActuators.length > 0) { + gp.hapticActuators[0].pulse(intensity, duration).catch(() => {}); + } + } + } + } catch (e) { + console.warn('[XR][Haptic] erro ao vibrar controle no grab:', e); + } +} + interface XRGrabbableProps { /** * Quando true (padrão), o grip duplo aplica ZOOM uniforme além da rotação. @@ -70,9 +86,12 @@ export function XRGrabbable({ allowScale = true, lockedActive = false, onGrabSta single.current = null; }, [scaleResetNonce]); - useFrame(() => { + const { gl } = useThree(); + + useFrame((_state, dt) => { const group = groupRef.current; if (!group) return; + const session = gl.xr.getSession(); const snap: ControllerGrabSnapshot = grab.current; const L = snap.left; const R = snap.right; @@ -95,6 +114,33 @@ export function XRGrabbable({ allowScale = true, lockedActive = false, onGrabSta haloRef.current.visible = maxGrip > 0.05; } + const commitGrabTransforms = () => { + const activeId = useModelStore.getState().activeModelId; + if (!activeId) return; + const active = useModelStore.getState().models.find(m => m.id === activeId); + if (!active) return; + + const ft = { ...active.fineTuning }; + ft.posX += group.position.x; + ft.posY += group.position.y; + ft.posZ += group.position.z; + + ft.rotX += (group.rotation.x * 180) / Math.PI; + ft.rotY += (group.rotation.y * 180) / Math.PI; + ft.rotZ += (group.rotation.z * 180) / Math.PI; + + ft.scale = (ft.scale ?? 1) * group.scale.x; + + useModelStore.getState().setFineTuning(ft); + + group.position.set(0, 0, 0); + group.rotation.set(0, 0, 0); + group.scale.set(1, 1, 1); + + triggerHaptic(session, 'left', 0.25, 12); + triggerHaptic(session, 'right', 0.25, 12); + }; + // ─── Two-hand mode (orbit + zoom, sempre) ──────────────────── if (lActive && rActive) { _tmpVecL.setFromMatrixPosition(L.gripWorld); @@ -116,12 +162,13 @@ export function XRGrabbable({ allowScale = true, lockedActive = false, onGrabSta }; single.current = null; if (!everGrabbed.current) { everGrabbed.current = true; onGrabStart?.(); } + triggerHaptic(session, 'left', 0.5, 30); + triggerHaptic(session, 'right', 0.5, 30); console.log('[XR][grab] ◆ TWO-HAND start (orbit+zoom)'); } const d = dual.current; const deltaQuat = new THREE.Quaternion().setFromUnitVectors(d.startAxis, axisNow); - // ZOOM só quando allowScale=true. Caso contrário, mantém escala 1× (só orbita). const scaleRatio = allowScale ? THREE.MathUtils.clamp(distNow / d.startDist, 0.1, 10) : 1; @@ -137,10 +184,27 @@ export function XRGrabbable({ allowScale = true, lockedActive = false, onGrabSta .multiply(tToOrigin) .multiply(d.startGroupWorld); - applyWorldMatrixToLocal(group, m); + // Decompose target world matrix + const targetPos = new THREE.Vector3(); + const targetQuat = new THREE.Quaternion(); + const targetScale = new THREE.Vector3(); + + let parentLocalMatrix = m; + if (group.parent) { + group.parent.updateMatrixWorld(); + parentLocalMatrix = new THREE.Matrix4().copy(group.parent.matrixWorld).invert().multiply(m); + } + parentLocalMatrix.decompose(targetPos, targetQuat, targetScale); + + // Apply smooth interpolation (anti-jitter dampening) + const t = Math.min(1.0, dt * 18); + group.position.lerp(targetPos, t); + group.quaternion.slerp(targetQuat, t); + group.scale.lerp(targetScale, t); return; } else if (dual.current) { console.log('[XR][grab] ◆ TWO-HAND end'); + commitGrabTransforms(); dual.current = null; } @@ -156,6 +220,7 @@ export function XRGrabbable({ allowScale = true, lockedActive = false, onGrabSta const offsetWorld = new THREE.Vector3().subVectors(groupWorldPos, ctrlPos); single.current = { hand: activeHand, offsetWorld }; if (!everGrabbed.current) { everGrabbed.current = true; onGrabStart?.(); } + triggerHaptic(session, activeHand, 0.45, 25); console.log(`[XR][grab] ● ONE-HAND start (${activeHand}) — pan only`); } @@ -168,8 +233,13 @@ export function XRGrabbable({ allowScale = true, lockedActive = false, onGrabSta const invParent = new THREE.Matrix4().copy(group.parent.matrixWorld).invert(); targetWorldPos.applyMatrix4(invParent); } - group.position.copy(targetWorldPos); + + // Amortecimento suave da translação + const t = Math.min(1.0, dt * 18); + group.position.lerp(targetWorldPos, t); } else if (single.current) { + console.log('[XR][grab] ● ONE-HAND end'); + commitGrabTransforms(); single.current = null; } }); diff --git a/src/components/three/XRSceneComponents.tsx b/src/components/three/XRSceneComponents.tsx index 0ff069b..9b5669a 100644 --- a/src/components/three/XRSceneComponents.tsx +++ b/src/components/three/XRSceneComponents.tsx @@ -1,6 +1,6 @@ import { useEffect, useRef, useMemo, useState, useCallback } from 'react'; import { useFrame, useThree } from '@react-three/fiber'; -import { Grid } from '@react-three/drei'; +import { Grid, Billboard } from '@react-three/drei'; import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js'; import * as THREE from 'three'; import { useModelStore, type SceneModel } from '@/stores/useModelStore'; @@ -8,6 +8,15 @@ import { findNearestVertex } from './SmartMeasure'; import { registerModelLocalGroup, unregisterModelLocalGroup } from '@/lib/modelTransforms'; import { parseIFCtoThree } from '@/lib/convertIFC'; +function findModelId(obj: THREE.Object3D): string | undefined { + let cur: THREE.Object3D | null = obj; + while (cur) { + if (cur.userData?.modelId) return cur.userData.modelId as string; + cur = cur.parent; + } + return undefined; +} + // ─── XRModel ─────────────────────────────────────────── export function XRModel({ sceneModel }: { sceneModel: SceneModel }) { const [rawScene, setRawScene] = useState(null); @@ -282,15 +291,17 @@ function XRMeasurementText({ text, color }: { text: string; color: string }) { }, [text, color]); return ( - - - - + + + + + + ); } @@ -444,7 +455,8 @@ export function XRSnapHandler() { if (hit && hit.object instanceof THREE.Mesh) { const canvas = gl.domElement; const snap = findNearestVertex(hit.object, hit.point, camera, { width: canvas.clientWidth, height: canvas.clientHeight }, 10); - setSnapPoint(snap ? { x: snap.x, y: snap.y, z: snap.z } : null); + const hitModelId = findModelId(hit.object); + setSnapPoint(snap ? { x: snap.x, y: snap.y, z: snap.z, modelId: hitModelId } : null); } else { setSnapPoint(null); } @@ -455,7 +467,7 @@ export function XRSnapHandler() { if (gl.xr.isPresenting) return; const onClick = (e: MouseEvent) => { if (snapPoint) { - addMeasurePoint({ x: snapPoint.x, y: snapPoint.y, z: snapPoint.z }); + addMeasurePoint({ x: snapPoint.x, y: snapPoint.y, z: snapPoint.z, modelId: snapPoint.modelId }); return; } const rect = gl.domElement.getBoundingClientRect(); @@ -464,7 +476,10 @@ export function XRSnapHandler() { raycaster.setFromCamera(mouse, camera); const intersects = raycaster.intersectObjects(scene.children, true); const hit = intersects.find(i => i.object instanceof THREE.Mesh && !i.object.userData.__edgeLine); - if (hit) addMeasurePoint({ x: hit.point.x, y: hit.point.y, z: hit.point.z }); + if (hit) { + const hitModelId = findModelId(hit.object); + addMeasurePoint({ x: hit.point.x, y: hit.point.y, z: hit.point.z, modelId: hitModelId }); + } }; gl.domElement.addEventListener('click', onClick); return () => gl.domElement.removeEventListener('click', onClick); diff --git a/src/pages/Index.tsx b/src/pages/Index.tsx index c020dcc..c6dfc87 100644 --- a/src/pages/Index.tsx +++ b/src/pages/Index.tsx @@ -327,7 +327,7 @@ const Index = () => { {/* Footer */}

- TrackSteelXR v1.05 — Q.C. Inspection + TrackSteelXR v1.06 — Q.C. Inspection

{showLogs && ( diff --git a/src/stores/useModelStore.ts b/src/stores/useModelStore.ts index 6f06f1a..81d1d86 100644 --- a/src/stores/useModelStore.ts +++ b/src/stores/useModelStore.ts @@ -133,6 +133,7 @@ export interface MeasurePoint { x: number; y: number; z: number; + modelId?: string; } export interface Measurement { @@ -155,6 +156,7 @@ export interface HoverInfo { value: number; // mm (diameter or length) /** For edges: the two endpoints in world coords. */ endpoints?: { a: MeasurePoint; b: MeasurePoint }; + modelId?: string; } export interface ChecklistItem { @@ -564,7 +566,7 @@ export const useModelStore = create((set, get) => ({ const distanceWorldMM = Math.sqrt(dx * dx + dy * dy + dz * dz); const factor = state.scaleRatio?.factor ?? 1; const distanceMM = distanceWorldMM / factor; - const modelId = state.activeModelId ?? undefined; + const modelId = a.modelId ?? b.modelId ?? state.activeModelId ?? undefined; const aConv = modelId ? worldToModelLocal(modelId, a) : null; const bConv = modelId ? worldToModelLocal(modelId, b) : null; const attached = !!(aConv && bConv); @@ -602,7 +604,7 @@ export const useModelStore = create((set, get) => ({ return { measurements: state.measurements.slice(0, -1) }; }), registerHoverMeasurement: (info) => set((state) => { - const modelId = state.activeModelId ?? undefined; + const modelId = info.modelId ?? state.activeModelId ?? undefined; const toLocal = (p: MeasurePoint): { point: MeasurePoint; attached: boolean } => { const conv = modelId ? worldToModelLocal(modelId, p) : null; return conv ? { point: conv, attached: true } : { point: p, attached: false };