Anclou cotas ao frame local

X-Lovable-Edit-ID: edt-b7c3c060-0262-4347-980e-8a1ab2da97b9
Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
This commit is contained in:
gpt-engineer-app[bot]
2026-05-21 20:51:42 +00:00
3 changed files with 175 additions and 66 deletions
+107 -56
View File
@@ -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,12 +210,40 @@ 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) */}
<group position={[-modelInfo.center.x, -modelInfo.center.y, -modelInfo.center.z]}>
{/* 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. */}
<ModelLocalFrame modelId={sceneModel.id} center={modelInfo.center}>
<primitive object={scene} />
<ModelMeasurements modelId={sceneModel.id} />
</ModelLocalFrame>
</group>
</group>
</group>
);
}
/** 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<THREE.Group>(null);
useEffect(() => {
const g = ref.current;
if (!g) return;
registerModelLocalGroup(modelId, g);
return () => unregisterModelLocalGroup(modelId, g);
}, [modelId]);
return (
<group ref={ref} position={[-center.x, -center.y, -center.z]}>
{children}
</group>
);
}
@@ -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<THREE.Mesh>(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 (
<mesh ref={ref} position={position}>
@@ -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 (
<group>
<PointMarker position={a} color="#f59e0b" />
<MeasurementLabel position={a} text={m.label ?? `Ø ${m.distanceMM.toFixed(1)}`} variant="accent" />
</group>
);
}
if (m.kind === 'edge') {
return (
<group>
<PointMarker position={a} color="#22c55e" />
<PointMarker position={b} color="#22c55e" />
<Line points={[a, b]} color="#22c55e" lineWidth={2} depthTest={false} />
<MeasurementLabel position={mid} text={m.label ?? `${m.distanceMM.toFixed(1)} mm`} variant="success" />
</group>
);
}
return (
<group>
<PointMarker position={a} color="#22c55e" />
<PointMarker position={b} color="#22c55e" />
<Line points={[a, b]} color="#22c55e" lineWidth={2} depthTest={false} />
<MeasurementLabel position={mid} text={`${m.distanceMM.toFixed(1)} mm`} variant="success" />
</group>
);
}
/** 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) => (
<MeasurementGraphic key={m.id} m={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 && (
<PointMarker position={[measurePoints[0].x, measurePoints[0].y, measurePoints[0].z]} color="#e8a838" />
)}
{/* 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 (
<group key={m.id}>
<PointMarker position={a} color="#f59e0b" />
<MeasurementLabel position={a} text={m.label ?? `Ø ${m.distanceMM.toFixed(1)}`} variant="accent" />
</group>
);
}
if (m.kind === 'edge') {
return (
<group key={m.id}>
<PointMarker position={a} color="#22c55e" />
<PointMarker position={b} color="#22c55e" />
<Line points={[a, b]} color="#22c55e" lineWidth={2} depthTest={false} />
<MeasurementLabel position={mid} text={m.label ?? `${m.distanceMM.toFixed(1)} mm`} variant="success" />
</group>
);
}
return (
<group key={m.id}>
<PointMarker position={a} color="#22c55e" />
<PointMarker position={b} color="#22c55e" />
<Line
points={[a, b]}
color="#22c55e"
lineWidth={2}
depthTest={false}
/>
<MeasurementLabel position={mid} text={`${m.distanceMM.toFixed(1)} mm`} variant="success" />
</group>
);
})}
{/* Legacy world-frame measurements (no modelId) */}
{measurements.filter((m) => !m.modelId).map((m) => (
<MeasurementGraphic key={m.id} m={m} />
))}
</>
);
}
+35
View File
@@ -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<string, THREE.Object3D>();
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 };
}
+32 -9
View File
@@ -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<ModelStore>((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<ModelStore>((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 };