diff --git a/src/components/three/ModelViewer.tsx b/src/components/three/ModelViewer.tsx index 62cc5e0..494e899 100644 --- a/src/components/three/ModelViewer.tsx +++ b/src/components/three/ModelViewer.tsx @@ -1,10 +1,11 @@ -import { Suspense, useRef, useMemo, useEffect } from 'react'; +import { Suspense, useRef, useMemo, useEffect, type ReactNode } 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, type SceneModel } from '@/stores/useModelStore'; import { findNearestVertex, detectHoleAtFace, findNearestEdgeSegment, detectCircularEdgeAtPoint } from './SmartMeasure'; +import { registerModelLocalGroup, unregisterModelLocalGroup } from '@/lib/modelTransforms'; interface ModelViewerProps { url?: string; // legacy, ignored — uses store.models @@ -209,16 +210,44 @@ function GLBModel({ sceneModel, isActive }: { sceneModel: SceneModel; isActive: rotation={[rotXRad, rotYRad, rotZRad]} scale={[s, s, s]} > - {/* Shift geometry so its center sits at the parent's origin (pivot = center) */} - + {/* Shift geometry so its center sits at the parent's origin (pivot = center). + This is the local frame where measurement points are stored, so measurements + follow the model when posX/Y/Z or rotX/Y/Z change in "Posicionar" mode. */} + - + + ); } +/** Innermost local frame of a model. Registers itself in the global model + * registry so the store can convert world points to this group's local frame. */ +function ModelLocalFrame({ + modelId, + center, + children, +}: { + modelId: string; + center: THREE.Vector3; + children: ReactNode; +}) { + const ref = useRef(null); + useEffect(() => { + const g = ref.current; + if (!g) return; + registerModelLocalGroup(modelId, g); + return () => unregisterModelLocalGroup(modelId, g); + }, [modelId]); + return ( + + {children} + + ); +} + function SceneModels() { const models = useModelStore((s) => s.models); const activeId = useModelStore((s) => s.activeModelId); @@ -231,19 +260,26 @@ function SceneModels() { ); } -/** Sphere marker at a 3D point — scaled per-frame to keep constant screen size */ +/** Sphere marker at a 3D point — scaled per-frame to keep constant screen size. + * Compensates for any cumulative parent world scale so it stays the same screen + * size whether mounted in world space or inside a transformed model group. */ function PointMarker({ position, color = '#e8a838' }: { position: [number, number, number]; color?: string }) { const ref = useRef(null); const { camera, size } = useThree(); + const tmpVec = useMemo(() => new THREE.Vector3(), []); + const worldPos = useMemo(() => new THREE.Vector3(), []); useFrame(() => { if (!ref.current) return; - const dist = camera.position.distanceTo(ref.current.position); + ref.current.getWorldPosition(worldPos); + const dist = camera.position.distanceTo(worldPos); const perspCam = camera as THREE.PerspectiveCamera; const fov = (perspCam.fov ?? 50) * (Math.PI / 180); const worldPerPixel = (2 * dist * Math.tan(fov / 2)) / size.height; - // Target: ~6 px radius (radius = 1 in base geometry → scale = 6*wpp) - const s = Math.max(0.0005, 6 * worldPerPixel); - ref.current.scale.setScalar(s); + // Target: ~6 px radius (radius = 1 in base geometry → scale = 6*wpp). + const targetWorld = Math.max(0.0005, 6 * worldPerPixel); + // Divide by parent world scale so the final world size matches `targetWorld`. + const parentScale = ref.current.parent ? ref.current.parent.getWorldScale(tmpVec).x || 1 : 1; + ref.current.scale.setScalar(targetWorld / parentScale); }); return ( @@ -316,15 +352,70 @@ function MeasurementLabel({ position, text, variant, fixed = false }: { position ); } -/** Renders all measurements, snap point, and hover info */ +/** Renders a single measurement's geometry + label. + * Coordinates are interpreted in whichever frame the component is mounted in. */ +function MeasurementGraphic({ m }: { m: import('@/stores/useModelStore').Measurement }) { + 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, + ]; + + if (m.kind === 'hole') { + return ( + + + + + ); + } + + if (m.kind === 'edge') { + return ( + + + + + + + ); + } + + return ( + + + + + + + ); +} + +/** Renders measurements attached to a given model, in that model's local frame. + * Mounted inside GLBModel so they follow the model when it is repositioned. */ +function ModelMeasurements({ modelId }: { modelId: string }) { + const measurements = useModelStore((s) => s.measurements); + const attached = measurements.filter((m) => m.modelId === modelId); + if (attached.length === 0) return null; + return ( + <> + {attached.map((m) => ( + + ))} + + ); +} + +/** Renders snap indicator, hover tooltip, pending point and any *legacy* + * world-frame measurements that aren't attached to a specific model. */ function MeasurementOverlay() { const measurements = useModelStore((s) => s.measurements); const measurePoints = useModelStore((s) => s.measurePoints); const snapPoint = useModelStore((s) => s.snapPoint); const hoverInfo = useModelStore((s) => s.hoverInfo); const measureMode = useModelStore((s) => s.measureMode); - const scaleRatio = useModelStore((s) => s.scaleRatio); - const factor = scaleRatio?.factor ?? 1; return ( <> @@ -345,55 +436,15 @@ function MeasurementOverlay() { /> )} - {/* Pending first point */} + {/* Pending first point (always in world coords) */} {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, - ]; - - if (m.kind === 'hole') { - return ( - - - - - ); - } - - if (m.kind === 'edge') { - return ( - - - - - - - ); - } - - return ( - - - - - - - ); - })} + {/* Legacy world-frame measurements (no modelId) */} + {measurements.filter((m) => !m.modelId).map((m) => ( + + ))} ); } diff --git a/src/lib/modelTransforms.ts b/src/lib/modelTransforms.ts new file mode 100644 index 0000000..58d8807 --- /dev/null +++ b/src/lib/modelTransforms.ts @@ -0,0 +1,35 @@ +import * as THREE from 'three'; + +/** + * Registry of the innermost local-frame group of each model in the scene. + * Measurements are stored in this local frame so they follow the model + * when the user repositions/rotates it via the "Posicionar" tool. + */ +const localGroups = new Map(); + +export function registerModelLocalGroup(modelId: string, obj: THREE.Object3D) { + localGroups.set(modelId, obj); +} + +export function unregisterModelLocalGroup(modelId: string, obj?: THREE.Object3D) { + // Only delete if the registered object matches (avoid race between unmount/mount of the same id) + const current = localGroups.get(modelId); + if (!obj || current === obj) localGroups.delete(modelId); +} + +export function getModelLocalGroup(modelId: string | null | undefined): THREE.Object3D | undefined { + if (!modelId) return undefined; + return localGroups.get(modelId); +} + +export function worldToModelLocal( + modelId: string | null | undefined, + p: { x: number; y: number; z: number }, +): { x: number; y: number; z: number } | null { + const g = getModelLocalGroup(modelId); + if (!g) return null; + g.updateWorldMatrix(true, false); + const v = new THREE.Vector3(p.x, p.y, p.z); + g.worldToLocal(v); + return { x: v.x, y: v.y, z: v.z }; +} diff --git a/src/stores/useModelStore.ts b/src/stores/useModelStore.ts index 6bb5177..3a15175 100644 --- a/src/stores/useModelStore.ts +++ b/src/stores/useModelStore.ts @@ -1,4 +1,5 @@ import { create } from 'zustand'; +import { worldToModelLocal } from '@/lib/modelTransforms'; // ── Persistência de placement (fineTuning + escala) e flags WebXR ───── const PLACEMENT_KEY = 'tsxr_placements_v1'; @@ -142,6 +143,9 @@ export interface Measurement { kind?: 'distance' | 'hole' | 'edge'; /** Optional pre-formatted label (e.g. "Ø 12.5"). When absent, UI formats distanceMM. */ label?: string; + /** Model the measurement belongs to. When set, pointA/pointB are stored in that + * model's local frame so the measurement follows the model when repositioned. */ + modelId?: string; } export interface HoverInfo { @@ -558,11 +562,17 @@ 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; + // Convert world points to active model's local frame so they follow the model. + const modelId = state.activeModelId ?? undefined; + const aConv = modelId ? worldToModelLocal(modelId, a) : null; + const bConv = modelId ? worldToModelLocal(modelId, b) : null; + const attached = !!(aConv && bConv); const measurement: Measurement = { id: `m_${Date.now()}`, - pointA: a, - pointB: b, + pointA: attached ? aConv! : a, + pointB: attached ? bConv! : b, distanceMM, + modelId: attached ? modelId : undefined, }; return { measurePoints: [], measurements: [...state.measurements, measurement] }; } @@ -583,27 +593,40 @@ export const useModelStore = create((set, get) => ({ return { measurements: state.measurements.slice(0, -1) }; }), registerHoverMeasurement: (info) => set((state) => { + const 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 }; + }; let measurement: Measurement; + let attached = false; if (info.type === 'hole') { - const c: MeasurePoint = { x: info.position[0], y: info.position[1], z: info.position[2] }; + const cw: MeasurePoint = { x: info.position[0], y: info.position[1], z: info.position[2] }; + const c = toLocal(cw); + attached = c.attached; measurement = { id: `m_${Date.now()}`, - pointA: c, - pointB: c, + pointA: c.point, + pointB: c.point, distanceMM: info.value, kind: 'hole', label: `Ø ${info.value.toFixed(1)}`, + modelId: attached ? modelId : undefined, }; } else { - const a = info.endpoints?.a ?? { x: info.position[0], y: info.position[1], z: info.position[2] }; - const b = info.endpoints?.b ?? a; + const aw = info.endpoints?.a ?? { x: info.position[0], y: info.position[1], z: info.position[2] }; + const bw = info.endpoints?.b ?? aw; + const a = toLocal(aw); + const b = toLocal(bw); + attached = a.attached && b.attached; measurement = { id: `m_${Date.now()}`, - pointA: a, - pointB: b, + pointA: attached ? a.point : aw, + pointB: attached ? b.point : bw, distanceMM: info.value, kind: 'edge', label: `${info.value.toFixed(1)} mm`, + modelId: attached ? modelId : undefined, }; } return { measurements: [...state.measurements, measurement], hoverInfo: null };