From 379f3f96e61a22d766f04ff5b5fa46a40687f2a8 Mon Sep 17 00:00:00 2001 From: "gpt-engineer-app[bot]" <159125892+gpt-engineer-app[bot]@users.noreply.github.com> Date: Fri, 27 Feb 2026 01:13:10 +0000 Subject: [PATCH] Changes --- src/components/MeasurementsList.tsx | 37 ++++++++ src/components/ViewerControls.tsx | 35 +++++++- src/components/three/ModelViewer.tsx | 124 ++++++++++++++++++++++++--- src/pages/Viewer.tsx | 5 ++ src/stores/useModelStore.ts | 48 +++++++++++ 5 files changed, 235 insertions(+), 14 deletions(-) create mode 100644 src/components/MeasurementsList.tsx diff --git a/src/components/MeasurementsList.tsx b/src/components/MeasurementsList.tsx new file mode 100644 index 0000000..e5d45a7 --- /dev/null +++ b/src/components/MeasurementsList.tsx @@ -0,0 +1,37 @@ +import { Ruler, X } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { useModelStore } from '@/stores/useModelStore'; + +export function MeasurementsList() { + const { measurements, removeMeasurement } = useModelStore(); + + if (measurements.length === 0) return null; + + return ( +
+

+ Medições +

+
+ {measurements.map((m, i) => ( +
+
+ + + M{i + 1}: {m.distanceMM.toFixed(1)} mm + +
+ +
+ ))} +
+
+ ); +} diff --git a/src/components/ViewerControls.tsx b/src/components/ViewerControls.tsx index 74817dc..1a5b455 100644 --- a/src/components/ViewerControls.tsx +++ b/src/components/ViewerControls.tsx @@ -1,10 +1,10 @@ -import { Eye, Grid3X3 } from 'lucide-react'; +import { Eye, Grid3X3, Ruler, Trash2 } from 'lucide-react'; import { Slider } from '@/components/ui/slider'; import { Button } from '@/components/ui/button'; import { useModelStore } from '@/stores/useModelStore'; export function ViewerControls() { - const { opacity, setOpacity, renderMode, setRenderMode } = useModelStore(); + const { opacity, setOpacity, renderMode, setRenderMode, measureMode, setMeasureMode, measurements, clearMeasurements, measurePoints } = useModelStore(); return (
@@ -38,6 +38,37 @@ export function ViewerControls() { {renderMode === 'wireframe' ? 'Wireframe' : 'Sólido'} + + {/* Measure tool */} +
+ + + {measurements.length > 0 && ( + + )} +
); } diff --git a/src/components/three/ModelViewer.tsx b/src/components/three/ModelViewer.tsx index b9e73ab..a534692 100644 --- a/src/components/three/ModelViewer.tsx +++ b/src/components/three/ModelViewer.tsx @@ -1,6 +1,6 @@ -import { Suspense, useRef, useMemo, useEffect } from 'react'; -import { Canvas, useFrame } from '@react-three/fiber'; -import { OrbitControls, useGLTF, Grid, Html } from '@react-three/drei'; +import { Suspense, useRef, useMemo, useEffect, useCallback } from 'react'; +import { Canvas, useThree, useFrame } from '@react-three/fiber'; +import { OrbitControls, useGLTF, Grid, Html, Line } from '@react-three/drei'; import { Loader2 } from 'lucide-react'; import * as THREE from 'three'; import { useModelStore } from '@/stores/useModelStore'; @@ -37,15 +37,12 @@ function GLBModel({ url }: { url: string }) { return { size, center }; }, [scene]); - // Apply opacity, wireframe, and checklist color feedback useEffect(() => { - // Determine overall inspection color const hasRejected = checklist.some(i => i.status === 'rejected'); const allApproved = checklist.every(i => i.status === 'approved'); scene.traverse((child) => { if (child instanceof THREE.Mesh) { - // Clone material to avoid shared mutations if (Array.isArray(child.material)) { child.material = child.material.map(m => m.clone()); } else { @@ -58,13 +55,12 @@ function GLBModel({ url }: { url: string }) { mat.opacity = opacity; mat.wireframe = renderMode === 'wireframe'; mat.needsUpdate = true; - // Color feedback if (hasRejected) { - mat.color.setHSL(0, 0.7, 0.5); // red + mat.color.setHSL(0, 0.7, 0.5); } else if (allApproved) { - mat.color.setHSL(0.38, 0.7, 0.45); // green + mat.color.setHSL(0.38, 0.7, 0.45); } else { - mat.color.set(0x8899aa); // default steel + mat.color.set(0x8899aa); } } }); @@ -89,6 +85,109 @@ function GLBModel({ url }: { url: string }) { ); } +/** Sphere marker at a 3D point */ +function PointMarker({ position, color = '#e8a838' }: { position: [number, number, number]; color?: string }) { + return ( + + + + + ); +} + +/** Renders all measurements and pending points */ +function MeasurementOverlay() { + const measurements = useModelStore((s) => s.measurements); + const measurePoints = useModelStore((s) => s.measurePoints); + + return ( + <> + {/* Pending first point */} + {measurePoints.length === 1 && ( + + )} + + {/* Completed measurements */} + {measurements.map((m) => { + const a: [number, number, number] = [m.pointA.x, m.pointA.y, m.pointA.z]; + const b: [number, number, number] = [m.pointB.x, m.pointB.y, m.pointB.z]; + const mid: [number, number, number] = [ + (a[0] + b[0]) / 2, + (a[1] + b[1]) / 2, + (a[2] + b[2]) / 2, + ]; + + return ( + + + + + +
+ + {m.distanceMM.toFixed(1)} mm + +
+ +
+ ); + })} + + ); +} + +/** Raycasting click handler for measurement mode */ +function MeasureClickHandler() { + const { camera, scene, gl } = useThree(); + const measureMode = useModelStore((s) => s.measureMode); + const addMeasurePoint = useModelStore((s) => s.addMeasurePoint); + const raycaster = useMemo(() => new THREE.Raycaster(), []); + const mouse = useMemo(() => new THREE.Vector2(), []); + + const handleClick = useCallback((event: MouseEvent) => { + if (!measureMode) return; + + const rect = gl.domElement.getBoundingClientRect(); + mouse.x = ((event.clientX - rect.left) / rect.width) * 2 - 1; + mouse.y = -((event.clientY - rect.top) / rect.height) * 2 + 1; + + raycaster.setFromCamera(mouse, camera); + const intersects = raycaster.intersectObjects(scene.children, true); + + // Filter out measurement markers and grid + const hit = intersects.find(i => { + const obj = i.object; + if (obj instanceof THREE.GridHelper) return false; + if (obj instanceof THREE.Mesh) { + if (obj.geometry instanceof THREE.SphereGeometry) return false; + } + return true; + }); + + if (hit) { + addMeasurePoint({ x: hit.point.x, y: hit.point.y, z: hit.point.z }); + } + }, [measureMode, addMeasurePoint, camera, scene, gl, raycaster, mouse]); + + useEffect(() => { + const canvas = gl.domElement; + canvas.addEventListener('click', handleClick); + return () => canvas.removeEventListener('click', handleClick); + }, [gl, handleClick]); + + // Change cursor when in measure mode + useEffect(() => { + gl.domElement.style.cursor = measureMode ? 'crosshair' : 'grab'; + return () => { gl.domElement.style.cursor = 'grab'; }; + }, [measureMode, gl]); + + return null; +} + export function ModelViewerCanvas({ url }: ModelViewerProps) { return ( { - // Target 72fps for Quest 3 compatibility gl.setPixelRatio(Math.min(window.devicePixelRatio, 1.5)); - // Continuous render for smooth interaction const loop = () => { invalidate(); requestAnimationFrame(loop); }; loop(); }} @@ -112,6 +209,9 @@ export function ModelViewerCanvas({ url }: ModelViewerProps) { + + + { + + + +
diff --git a/src/stores/useModelStore.ts b/src/stores/useModelStore.ts index 322058c..72373fa 100644 --- a/src/stores/useModelStore.ts +++ b/src/stores/useModelStore.ts @@ -17,6 +17,19 @@ type AnchorMode = 'tracking' | 'manual' | 'fine-tuning'; type InspectionResult = 'pending' | 'approved' | 'rejected'; type RenderMode = 'solid' | 'wireframe'; +export interface MeasurePoint { + x: number; + y: number; + z: number; +} + +export interface Measurement { + id: string; + pointA: MeasurePoint; + pointB: MeasurePoint; + distanceMM: number; +} + export interface ChecklistItem { id: string; label: string; @@ -60,6 +73,14 @@ interface ModelStore { setChecklistItemNote: (id: string, note: string) => void; setChecklistItemAudio: (id: string, audioUrl: string | null) => void; resetChecklist: () => void; + + measureMode: boolean; + setMeasureMode: (on: boolean) => void; + measurePoints: MeasurePoint[]; + addMeasurePoint: (p: MeasurePoint) => void; + measurements: Measurement[]; + clearMeasurements: () => void; + removeMeasurement: (id: string) => void; } const defaultFineTuning: FineTuning = { posX: 0, posY: 0, posZ: 0, rotY: 0 }; @@ -104,4 +125,31 @@ export const useModelStore = create((set) => ({ ), })), resetChecklist: () => set({ checklist: defaultChecklist.map(i => ({ ...i })) }), + + measureMode: false, + setMeasureMode: (measureMode) => set({ measureMode, measurePoints: [] }), + measurePoints: [], + addMeasurePoint: (p) => set((state) => { + const points = [...state.measurePoints, p]; + if (points.length === 2) { + const [a, b] = points; + const dx = (a.x - b.x) * 1000; + const dy = (a.y - b.y) * 1000; + const dz = (a.z - b.z) * 1000; + const distanceMM = Math.sqrt(dx * dx + dy * dy + dz * dz); + const measurement: Measurement = { + id: `m_${Date.now()}`, + pointA: a, + pointB: b, + distanceMM, + }; + return { measurePoints: [], measurements: [...state.measurements, measurement] }; + } + return { measurePoints: points }; + }), + measurements: [], + clearMeasurements: () => set({ measurements: [], measurePoints: [] }), + removeMeasurement: (id) => set((state) => ({ + measurements: state.measurements.filter(m => m.id !== id), + })), }));