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 };