diff --git a/src/components/three/ModelViewer.tsx b/src/components/three/ModelViewer.tsx index 046c96c..f1363ab 100644 --- a/src/components/three/ModelViewer.tsx +++ b/src/components/three/ModelViewer.tsx @@ -454,10 +454,11 @@ function MeasureClickHandler() { function GridLayer() { const showGrid = useModelStore((s) => s.showGrid); + const gridY = useModelStore((s) => s.gridY); if (!showGrid) return null; return ( s.models); + const { scene } = useThree(); + useEffect(() => { + const auto = useModelStore.getState().gridAutoFollow; + if (!auto) return; + // Defer to next tick so models have rendered/positioned + const id = setTimeout(() => { + if (!useModelStore.getState().gridAutoFollow) return; + const box = new THREE.Box3(); + let has = false; + scene.traverse((obj) => { + if (obj instanceof THREE.Mesh && obj.geometry) { + // Skip helpers (markers, rings) + if (obj.geometry instanceof THREE.SphereGeometry) return; + if (obj.geometry instanceof THREE.RingGeometry) return; + if (obj.userData.__edgeLine) return; + obj.updateWorldMatrix(true, false); + const b = new THREE.Box3().setFromObject(obj); + if (isFinite(b.min.y)) { + if (!has) { box.copy(b); has = true; } + else box.union(b); + } + } + }); + if (has) { + useModelStore.setState({ gridY: box.min.y - 0.005 }); + } + }, 100); + return () => clearTimeout(id); + }, [models, scene]); + return null; +} + export function ModelViewerCanvas({ url }: ModelViewerProps) { return ( + setScaleRatio(preset)} /> ); })} + + {/* Grid floor controls */} + + + ); +} + +function GridFloorRow() { + const { camera } = useThree(); + const gridY = useModelStore((s) => s.gridY); + const gridAutoFollow = useModelStore((s) => s.gridAutoFollow); + const setGridAutoFollow = useModelStore((s) => s.setGridAutoFollow); + const setGridY = useModelStore((s) => s.setGridY); + const nudgeGridY = useModelStore((s) => s.nudgeGridY); + const placeAtFloor = () => { + // Headset eye Y minus avg standing eye height (1.6 m) + const floor = camera.position.y - 1.6; + setGridY(floor); + }; + return ( + + + Grid Y {(gridY * 1000).toFixed(0)}mm {gridAutoFollow ? '(auto)' : '(travado)'} + + setGridAutoFollow(true)} /> + setGridAutoFollow(false)} /> + nudgeGridY(-0.01)} /> + nudgeGridY(0.01)} /> + ); } diff --git a/src/pages/XRSession.tsx b/src/pages/XRSession.tsx index 7abd1b7..0c69b64 100644 --- a/src/pages/XRSession.tsx +++ b/src/pages/XRSession.tsx @@ -359,10 +359,11 @@ function XRSnapHandler() { // ─── XR Grid ─────────────────────────────────────────── function XRGrid() { const showGrid = useModelStore((s) => s.showGrid); + const gridY = useModelStore((s) => s.gridY); if (!showGrid) return null; return ( s.models); + const { scene } = useThree(); + useEffect(() => { + if (!useModelStore.getState().gridAutoFollow) return; + const id = setTimeout(() => { + if (!useModelStore.getState().gridAutoFollow) return; + const box = new THREE.Box3(); + let has = false; + scene.traverse((obj) => { + if (obj instanceof THREE.Mesh && obj.geometry) { + if (obj.geometry instanceof THREE.SphereGeometry) return; + if (obj.geometry instanceof THREE.RingGeometry) return; + if (obj.userData.__edgeLine) return; + obj.updateWorldMatrix(true, false); + const b = new THREE.Box3().setFromObject(obj); + if (isFinite(b.min.y)) { + if (!has) { box.copy(b); has = true; } + else box.union(b); + } + } + }); + if (has) useModelStore.setState({ gridY: box.min.y - 0.005 }); + }, 150); + return () => clearTimeout(id); + }, [models, scene]); + return null; +} + // ImageTrackingAnchor removed — replaced by XRHitTestPlacement // ─── XRSession Page ──────────────────────────────────── @@ -585,6 +616,7 @@ const XRSession = () => { + diff --git a/src/stores/useModelStore.ts b/src/stores/useModelStore.ts index c4f771e..c9ce865 100644 --- a/src/stores/useModelStore.ts +++ b/src/stores/useModelStore.ts @@ -133,6 +133,15 @@ interface ModelStore { showGrid: boolean; setShowGrid: (show: boolean) => void; + /** World Y position of the grid plane (meters). */ + gridY: number; + setGridY: (y: number) => void; + /** When true, grid follows the bottom of the loaded model automatically. */ + gridAutoFollow: boolean; + setGridAutoFollow: (b: boolean) => void; + /** Nudge grid by delta meters and disable auto-follow. */ + nudgeGridY: (delta: number) => void; + wireframeColor: string; setWireframeColor: (color: string) => void; @@ -308,6 +317,12 @@ export const useModelStore = create((set, get) => ({ showGrid: true, setShowGrid: (showGrid) => set({ showGrid }), + gridY: -0.09, + setGridY: (gridY) => set({ gridY, gridAutoFollow: false }), + gridAutoFollow: true, + setGridAutoFollow: (gridAutoFollow) => set({ gridAutoFollow }), + nudgeGridY: (delta) => set((s) => ({ gridY: s.gridY + delta, gridAutoFollow: false })), + wireframeColor: '#8899aa', setWireframeColor: (wireframeColor) => set({ wireframeColor }),