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:
@@ -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 { Canvas, useThree, useFrame } from '@react-three/fiber';
|
||||||
import { OrbitControls, useGLTF, Grid, Html, Line } from '@react-three/drei';
|
import { OrbitControls, useGLTF, Grid, Html, Line } from '@react-three/drei';
|
||||||
import { Loader2 } from 'lucide-react';
|
import { Loader2 } from 'lucide-react';
|
||||||
import * as THREE from 'three';
|
import * as THREE from 'three';
|
||||||
import { useModelStore, type SceneModel } from '@/stores/useModelStore';
|
import { useModelStore, type SceneModel } from '@/stores/useModelStore';
|
||||||
import { findNearestVertex, detectHoleAtFace, findNearestEdgeSegment, detectCircularEdgeAtPoint } from './SmartMeasure';
|
import { findNearestVertex, detectHoleAtFace, findNearestEdgeSegment, detectCircularEdgeAtPoint } from './SmartMeasure';
|
||||||
|
import { registerModelLocalGroup, unregisterModelLocalGroup } from '@/lib/modelTransforms';
|
||||||
|
|
||||||
interface ModelViewerProps {
|
interface ModelViewerProps {
|
||||||
url?: string; // legacy, ignored — uses store.models
|
url?: string; // legacy, ignored — uses store.models
|
||||||
@@ -209,16 +210,44 @@ function GLBModel({ sceneModel, isActive }: { sceneModel: SceneModel; isActive:
|
|||||||
rotation={[rotXRad, rotYRad, rotZRad]}
|
rotation={[rotXRad, rotYRad, rotZRad]}
|
||||||
scale={[s, s, s]}
|
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).
|
||||||
<group position={[-modelInfo.center.x, -modelInfo.center.y, -modelInfo.center.z]}>
|
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} />
|
<primitive object={scene} />
|
||||||
</group>
|
<ModelMeasurements modelId={sceneModel.id} />
|
||||||
|
</ModelLocalFrame>
|
||||||
</group>
|
</group>
|
||||||
</group>
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function SceneModels() {
|
function SceneModels() {
|
||||||
const models = useModelStore((s) => s.models);
|
const models = useModelStore((s) => s.models);
|
||||||
const activeId = useModelStore((s) => s.activeModelId);
|
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 }) {
|
function PointMarker({ position, color = '#e8a838' }: { position: [number, number, number]; color?: string }) {
|
||||||
const ref = useRef<THREE.Mesh>(null);
|
const ref = useRef<THREE.Mesh>(null);
|
||||||
const { camera, size } = useThree();
|
const { camera, size } = useThree();
|
||||||
|
const tmpVec = useMemo(() => new THREE.Vector3(), []);
|
||||||
|
const worldPos = useMemo(() => new THREE.Vector3(), []);
|
||||||
useFrame(() => {
|
useFrame(() => {
|
||||||
if (!ref.current) return;
|
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 perspCam = camera as THREE.PerspectiveCamera;
|
||||||
const fov = (perspCam.fov ?? 50) * (Math.PI / 180);
|
const fov = (perspCam.fov ?? 50) * (Math.PI / 180);
|
||||||
const worldPerPixel = (2 * dist * Math.tan(fov / 2)) / size.height;
|
const worldPerPixel = (2 * dist * Math.tan(fov / 2)) / size.height;
|
||||||
// Target: ~6 px radius (radius = 1 in base geometry → scale = 6*wpp)
|
// Target: ~6 px radius (radius = 1 in base geometry → scale = 6*wpp).
|
||||||
const s = Math.max(0.0005, 6 * worldPerPixel);
|
const targetWorld = Math.max(0.0005, 6 * worldPerPixel);
|
||||||
ref.current.scale.setScalar(s);
|
// 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 (
|
return (
|
||||||
<mesh ref={ref} position={position}>
|
<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() {
|
function MeasurementOverlay() {
|
||||||
const measurements = useModelStore((s) => s.measurements);
|
const measurements = useModelStore((s) => s.measurements);
|
||||||
const measurePoints = useModelStore((s) => s.measurePoints);
|
const measurePoints = useModelStore((s) => s.measurePoints);
|
||||||
const snapPoint = useModelStore((s) => s.snapPoint);
|
const snapPoint = useModelStore((s) => s.snapPoint);
|
||||||
const hoverInfo = useModelStore((s) => s.hoverInfo);
|
const hoverInfo = useModelStore((s) => s.hoverInfo);
|
||||||
const measureMode = useModelStore((s) => s.measureMode);
|
const measureMode = useModelStore((s) => s.measureMode);
|
||||||
const scaleRatio = useModelStore((s) => s.scaleRatio);
|
|
||||||
const factor = scaleRatio?.factor ?? 1;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -345,55 +436,15 @@ function MeasurementOverlay() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Pending first point */}
|
{/* Pending first point (always in world coords) */}
|
||||||
{measurePoints.length === 1 && (
|
{measurePoints.length === 1 && (
|
||||||
<PointMarker position={[measurePoints[0].x, measurePoints[0].y, measurePoints[0].z]} color="#e8a838" />
|
<PointMarker position={[measurePoints[0].x, measurePoints[0].y, measurePoints[0].z]} color="#e8a838" />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Completed measurements */}
|
{/* Legacy world-frame measurements (no modelId) */}
|
||||||
{measurements.map((m) => {
|
{measurements.filter((m) => !m.modelId).map((m) => (
|
||||||
const a: [number, number, number] = [m.pointA.x, m.pointA.y, m.pointA.z];
|
<MeasurementGraphic key={m.id} m={m} />
|
||||||
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>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
|
import { worldToModelLocal } from '@/lib/modelTransforms';
|
||||||
|
|
||||||
// ── Persistência de placement (fineTuning + escala) e flags WebXR ─────
|
// ── Persistência de placement (fineTuning + escala) e flags WebXR ─────
|
||||||
const PLACEMENT_KEY = 'tsxr_placements_v1';
|
const PLACEMENT_KEY = 'tsxr_placements_v1';
|
||||||
@@ -142,6 +143,9 @@ export interface Measurement {
|
|||||||
kind?: 'distance' | 'hole' | 'edge';
|
kind?: 'distance' | 'hole' | 'edge';
|
||||||
/** Optional pre-formatted label (e.g. "Ø 12.5"). When absent, UI formats distanceMM. */
|
/** Optional pre-formatted label (e.g. "Ø 12.5"). When absent, UI formats distanceMM. */
|
||||||
label?: string;
|
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 {
|
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 distanceWorldMM = Math.sqrt(dx * dx + dy * dy + dz * dz);
|
||||||
const factor = state.scaleRatio?.factor ?? 1;
|
const factor = state.scaleRatio?.factor ?? 1;
|
||||||
const distanceMM = distanceWorldMM / factor;
|
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 = {
|
const measurement: Measurement = {
|
||||||
id: `m_${Date.now()}`,
|
id: `m_${Date.now()}`,
|
||||||
pointA: a,
|
pointA: attached ? aConv! : a,
|
||||||
pointB: b,
|
pointB: attached ? bConv! : b,
|
||||||
distanceMM,
|
distanceMM,
|
||||||
|
modelId: attached ? modelId : undefined,
|
||||||
};
|
};
|
||||||
return { measurePoints: [], measurements: [...state.measurements, measurement] };
|
return { measurePoints: [], measurements: [...state.measurements, measurement] };
|
||||||
}
|
}
|
||||||
@@ -583,27 +593,40 @@ export const useModelStore = create<ModelStore>((set, get) => ({
|
|||||||
return { measurements: state.measurements.slice(0, -1) };
|
return { measurements: state.measurements.slice(0, -1) };
|
||||||
}),
|
}),
|
||||||
registerHoverMeasurement: (info) => set((state) => {
|
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 measurement: Measurement;
|
||||||
|
let attached = false;
|
||||||
if (info.type === 'hole') {
|
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 = {
|
measurement = {
|
||||||
id: `m_${Date.now()}`,
|
id: `m_${Date.now()}`,
|
||||||
pointA: c,
|
pointA: c.point,
|
||||||
pointB: c,
|
pointB: c.point,
|
||||||
distanceMM: info.value,
|
distanceMM: info.value,
|
||||||
kind: 'hole',
|
kind: 'hole',
|
||||||
label: `Ø ${info.value.toFixed(1)}`,
|
label: `Ø ${info.value.toFixed(1)}`,
|
||||||
|
modelId: attached ? modelId : undefined,
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
const a = info.endpoints?.a ?? { x: info.position[0], y: info.position[1], z: info.position[2] };
|
const aw = info.endpoints?.a ?? { x: info.position[0], y: info.position[1], z: info.position[2] };
|
||||||
const b = info.endpoints?.b ?? a;
|
const bw = info.endpoints?.b ?? aw;
|
||||||
|
const a = toLocal(aw);
|
||||||
|
const b = toLocal(bw);
|
||||||
|
attached = a.attached && b.attached;
|
||||||
measurement = {
|
measurement = {
|
||||||
id: `m_${Date.now()}`,
|
id: `m_${Date.now()}`,
|
||||||
pointA: a,
|
pointA: attached ? a.point : aw,
|
||||||
pointB: b,
|
pointB: attached ? b.point : bw,
|
||||||
distanceMM: info.value,
|
distanceMM: info.value,
|
||||||
kind: 'edge',
|
kind: 'edge',
|
||||||
label: `${info.value.toFixed(1)} mm`,
|
label: `${info.value.toFixed(1)} mm`,
|
||||||
|
modelId: attached ? modelId : undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return { measurements: [...state.measurements, measurement], hoverInfo: null };
|
return { measurements: [...state.measurements, measurement], hoverInfo: null };
|
||||||
|
|||||||
Reference in New Issue
Block a user