36aa0c802e
Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
714 lines
25 KiB
TypeScript
714 lines
25 KiB
TypeScript
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, type SceneModel } from '@/stores/useModelStore';
|
||
import { findNearestVertex, detectHoleAtFace, findNearestEdgeSegment } from './SmartMeasure';
|
||
|
||
interface ModelViewerProps {
|
||
url?: string; // legacy, ignored — uses store.models
|
||
}
|
||
|
||
function LoadingFallback() {
|
||
return (
|
||
<Html center>
|
||
<div className="flex items-center gap-2 rounded-lg bg-card px-4 py-2 text-foreground shadow-lg">
|
||
<Loader2 className="h-4 w-4 animate-spin text-primary" />
|
||
<span className="font-mono text-sm">Carregando modelo…</span>
|
||
</div>
|
||
</Html>
|
||
);
|
||
}
|
||
|
||
function GLBModel({ sceneModel, isActive }: { sceneModel: SceneModel; isActive: boolean }) {
|
||
const { scene: rawScene } = useGLTF(sceneModel.url);
|
||
// Clone scene per instance so multiple GLBModels with same url don't share materials/transforms
|
||
const scene = useMemo(() => rawScene.clone(true), [rawScene]);
|
||
const ref = useRef<THREE.Group>(null);
|
||
const opacity = useModelStore((s) => s.opacity);
|
||
const renderMode = useModelStore((s) => s.renderMode);
|
||
const wireframeColor = useModelStore((s) => s.wireframeColor);
|
||
const wireframeThickness = useModelStore((s) => s.wireframeThickness);
|
||
const edgeThresholdAngle = useModelStore((s) => s.edgeThresholdAngle);
|
||
const checklist = useModelStore((s) => s.checklist);
|
||
const setActive = useModelStore((s) => s.setActiveModel);
|
||
const fineTuning = sceneModel.fineTuning;
|
||
|
||
const modelInfo = useMemo(() => {
|
||
const box = new THREE.Box3().setFromObject(scene);
|
||
const size = new THREE.Vector3();
|
||
const center = new THREE.Vector3();
|
||
box.getSize(size);
|
||
box.getCenter(center);
|
||
return { size, center };
|
||
}, [scene]);
|
||
|
||
const originalColors = useRef<Map<THREE.Material, THREE.Color>>(new Map());
|
||
|
||
useEffect(() => {
|
||
const hasRejected = checklist.some(i => i.status === 'rejected');
|
||
const allApproved = checklist.every(i => i.status === 'approved');
|
||
|
||
scene.traverse((child) => {
|
||
if (child instanceof THREE.Mesh) {
|
||
if (Array.isArray(child.material)) {
|
||
child.material = child.material.map(m => m.clone());
|
||
} else {
|
||
child.material = child.material.clone();
|
||
}
|
||
|
||
const toRemove: THREE.Object3D[] = [];
|
||
child.children.forEach(c => {
|
||
if (c.userData.__edgeLine) toRemove.push(c);
|
||
});
|
||
toRemove.forEach(c => {
|
||
if (c instanceof THREE.LineSegments) {
|
||
c.geometry.dispose();
|
||
(c.material as THREE.Material).dispose();
|
||
}
|
||
child.remove(c);
|
||
});
|
||
|
||
const materials = Array.isArray(child.material) ? child.material : [child.material];
|
||
materials.forEach((mat: THREE.Material) => {
|
||
if (mat instanceof THREE.MeshStandardMaterial) {
|
||
if (!originalColors.current.has(mat)) {
|
||
originalColors.current.set(mat, mat.color.clone());
|
||
}
|
||
|
||
if (renderMode === 'edges') {
|
||
mat.visible = false;
|
||
} else {
|
||
mat.visible = true;
|
||
mat.transparent = opacity < 1;
|
||
mat.opacity = opacity;
|
||
mat.wireframe = renderMode === 'wireframe';
|
||
if (renderMode === 'wireframe') {
|
||
mat.wireframeLinewidth = wireframeThickness;
|
||
mat.color.set(wireframeColor);
|
||
} else if (hasRejected) {
|
||
mat.color.setHSL(0, 0.7, 0.5);
|
||
} else if (allApproved) {
|
||
mat.color.setHSL(0.38, 0.7, 0.45);
|
||
} else {
|
||
// Tint with the per-model color (subtle)
|
||
mat.color.set(sceneModel.color);
|
||
}
|
||
}
|
||
mat.needsUpdate = true;
|
||
}
|
||
});
|
||
|
||
if (renderMode === 'edges' && child.geometry) {
|
||
const edgesGeo = new THREE.EdgesGeometry(child.geometry, edgeThresholdAngle);
|
||
const lineMat = new THREE.LineBasicMaterial({
|
||
color: wireframeColor,
|
||
linewidth: wireframeThickness,
|
||
});
|
||
const lineSegments = new THREE.LineSegments(edgesGeo, lineMat);
|
||
lineSegments.userData.__edgeLine = true;
|
||
child.add(lineSegments);
|
||
}
|
||
}
|
||
});
|
||
}, [scene, opacity, renderMode, checklist, wireframeColor, wireframeThickness, edgeThresholdAngle, sceneModel.color]);
|
||
|
||
const rotXRad = (fineTuning.rotX * Math.PI) / 180;
|
||
const rotYRad = (fineTuning.rotY * Math.PI) / 180;
|
||
const rotZRad = (fineTuning.rotZ * Math.PI) / 180;
|
||
const s = fineTuning.scale ?? 1;
|
||
const scaleRatio = useModelStore((st) => st.scaleRatio);
|
||
const renderFactor = scaleRatio?.factor ?? 1;
|
||
|
||
if (!sceneModel.visible) return null;
|
||
|
||
// Determine dominant local axis of the model (longest bbox side) for axial roll
|
||
const dominantAxis: 'x' | 'y' | 'z' =
|
||
modelInfo.size.x >= modelInfo.size.y && modelInfo.size.x >= modelInfo.size.z
|
||
? 'x'
|
||
: modelInfo.size.y >= modelInfo.size.z
|
||
? 'y'
|
||
: 'z';
|
||
|
||
return (
|
||
<group
|
||
scale={[renderFactor, renderFactor, renderFactor]}
|
||
onClick={(e) => { e.stopPropagation(); setActive(sceneModel.id); }}
|
||
userData={{ modelId: sceneModel.id, dominantAxis }}
|
||
>
|
||
{/* Translation */}
|
||
<group position={[fineTuning.posX, fineTuning.posY, fineTuning.posZ]}>
|
||
{/* Rotation + scale around the geometry center */}
|
||
<group
|
||
ref={ref}
|
||
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]}>
|
||
<primitive object={scene} />
|
||
</group>
|
||
</group>
|
||
</group>
|
||
</group>
|
||
);
|
||
}
|
||
|
||
function SceneModels() {
|
||
const models = useModelStore((s) => s.models);
|
||
const activeId = useModelStore((s) => s.activeModelId);
|
||
return (
|
||
<>
|
||
{models.map((m) => (
|
||
<GLBModel key={m.id} sceneModel={m} isActive={m.id === activeId} />
|
||
))}
|
||
</>
|
||
);
|
||
}
|
||
|
||
/** Sphere marker at a 3D point */
|
||
function PointMarker({ position, color = '#e8a838' }: { position: [number, number, number]; color?: string }) {
|
||
return (
|
||
<mesh position={position}>
|
||
<sphereGeometry args={[0.003, 16, 16]} />
|
||
<meshBasicMaterial color={color} />
|
||
</mesh>
|
||
);
|
||
}
|
||
|
||
/** Snap ring indicator */
|
||
function SnapRing({ position }: { position: [number, number, number] }) {
|
||
return (
|
||
<mesh position={position} rotation={[-Math.PI / 2, 0, 0]}>
|
||
<ringGeometry args={[0.003, 0.005, 24]} />
|
||
<meshBasicMaterial color="#eab308" side={THREE.DoubleSide} />
|
||
</mesh>
|
||
);
|
||
}
|
||
|
||
/** Renders all measurements, snap point, and hover info */
|
||
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);
|
||
|
||
return (
|
||
<>
|
||
{/* Snap indicator */}
|
||
{measureMode && snapPoint && (
|
||
<SnapRing position={[snapPoint.x, snapPoint.y, snapPoint.z]} />
|
||
)}
|
||
|
||
{/* Hover auto-detect tooltip */}
|
||
{hoverInfo && (
|
||
<Html position={hoverInfo.position} center distanceFactor={1} style={{ pointerEvents: 'none' }}>
|
||
<div className="rounded bg-card/95 border border-primary/50 px-2 py-0.5 shadow-lg backdrop-blur-sm">
|
||
<span className="font-mono text-[11px] font-bold text-primary whitespace-nowrap">
|
||
{hoverInfo.type === 'hole' ? '⌀ ' : ''}
|
||
{hoverInfo.value.toFixed(1)} mm
|
||
</span>
|
||
</div>
|
||
</Html>
|
||
)}
|
||
|
||
{/* Pending first point */}
|
||
{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,
|
||
];
|
||
|
||
return (
|
||
<group key={m.id}>
|
||
<PointMarker position={a} color="#22c55e" />
|
||
<PointMarker position={b} color="#22c55e" />
|
||
<Line
|
||
points={[a, b]}
|
||
color="#22c55e"
|
||
lineWidth={2}
|
||
/>
|
||
<Html position={mid} center distanceFactor={1} style={{ pointerEvents: 'none' }}>
|
||
<div className="rounded bg-card/95 border border-success/50 px-2 py-0.5 shadow-lg backdrop-blur-sm">
|
||
<span className="font-mono text-[11px] font-bold text-success whitespace-nowrap">
|
||
{m.distanceMM.toFixed(1)} mm
|
||
</span>
|
||
</div>
|
||
</Html>
|
||
</group>
|
||
);
|
||
})}
|
||
</>
|
||
);
|
||
}
|
||
|
||
/** Smart vertex snap on pointer move */
|
||
function SmartSnapHandler() {
|
||
const { camera, scene, gl } = useThree();
|
||
const measureMode = useModelStore((s) => s.measureMode);
|
||
const setSnapPoint = useModelStore((s) => s.setSnapPoint);
|
||
const raycaster = useMemo(() => new THREE.Raycaster(), []);
|
||
const mouse = useMemo(() => new THREE.Vector2(), []);
|
||
const mouseRef = useRef({ x: 0, y: 0 });
|
||
|
||
useEffect(() => {
|
||
const onMove = (e: MouseEvent) => {
|
||
const rect = gl.domElement.getBoundingClientRect();
|
||
mouseRef.current.x = e.clientX - rect.left;
|
||
mouseRef.current.y = e.clientY - rect.top;
|
||
mouse.x = (mouseRef.current.x / rect.width) * 2 - 1;
|
||
mouse.y = -(mouseRef.current.y / rect.height) * 2 + 1;
|
||
};
|
||
gl.domElement.addEventListener('pointermove', onMove);
|
||
return () => gl.domElement.removeEventListener('pointermove', onMove);
|
||
}, [gl, mouse]);
|
||
|
||
useFrame(() => {
|
||
if (!measureMode) {
|
||
setSnapPoint(null);
|
||
return;
|
||
}
|
||
|
||
raycaster.setFromCamera(mouse, camera);
|
||
const intersects = raycaster.intersectObjects(scene.children, true);
|
||
const hit = intersects.find(i => {
|
||
const obj = i.object;
|
||
if (obj instanceof THREE.GridHelper) return false;
|
||
if (obj instanceof THREE.Mesh && obj.geometry instanceof THREE.SphereGeometry) return false;
|
||
if (obj instanceof THREE.Mesh && obj.geometry instanceof THREE.RingGeometry) return false;
|
||
if (obj.userData.__edgeLine) return false;
|
||
return obj instanceof THREE.Mesh;
|
||
});
|
||
|
||
if (hit && hit.object instanceof THREE.Mesh) {
|
||
const canvas = gl.domElement;
|
||
const snap = findNearestVertex(
|
||
hit.object, hit.point, camera,
|
||
{ width: canvas.clientWidth, height: canvas.clientHeight },
|
||
10
|
||
);
|
||
if (snap) {
|
||
setSnapPoint({ x: snap.x, y: snap.y, z: snap.z });
|
||
} else {
|
||
setSnapPoint(null);
|
||
}
|
||
} else {
|
||
setSnapPoint(null);
|
||
}
|
||
});
|
||
|
||
return null;
|
||
}
|
||
|
||
/** Hover detector for auto-detect hole diameter and edge length */
|
||
function HoverDetector() {
|
||
const { camera, scene, gl } = useThree();
|
||
const measureMode = useModelStore((s) => s.measureMode);
|
||
const setHoverInfo = useModelStore((s) => s.setHoverInfo);
|
||
const raycaster = useMemo(() => new THREE.Raycaster(), []);
|
||
const mouse = useMemo(() => new THREE.Vector2(), []);
|
||
const hoverTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||
const lastHitKey = useRef('');
|
||
|
||
useEffect(() => {
|
||
if (measureMode) {
|
||
setHoverInfo(null);
|
||
if (hoverTimer.current) clearTimeout(hoverTimer.current);
|
||
return;
|
||
}
|
||
|
||
const onMove = (e: MouseEvent) => {
|
||
const rect = gl.domElement.getBoundingClientRect();
|
||
mouse.x = ((e.clientX - rect.left) / rect.width) * 2 - 1;
|
||
mouse.y = -((e.clientY - rect.top) / rect.height) * 2 + 1;
|
||
|
||
raycaster.setFromCamera(mouse, camera);
|
||
const intersects = raycaster.intersectObjects(scene.children, true);
|
||
const hit = intersects.find(i => {
|
||
const obj = i.object;
|
||
if (obj instanceof THREE.GridHelper) return false;
|
||
if (obj instanceof THREE.Mesh && obj.geometry instanceof THREE.SphereGeometry) return false;
|
||
if (obj instanceof THREE.Mesh && obj.geometry instanceof THREE.RingGeometry) return false;
|
||
if (obj.userData.__edgeLine) return false;
|
||
return obj instanceof THREE.Mesh;
|
||
});
|
||
|
||
if (!hit || !(hit.object instanceof THREE.Mesh)) {
|
||
lastHitKey.current = '';
|
||
setHoverInfo(null);
|
||
if (hoverTimer.current) clearTimeout(hoverTimer.current);
|
||
return;
|
||
}
|
||
|
||
// Stability check – same approximate position for debounce
|
||
const key = `${hit.point.x.toFixed(3)},${hit.point.y.toFixed(3)},${hit.point.z.toFixed(3)}`;
|
||
if (key === lastHitKey.current) return; // timer already running
|
||
lastHitKey.current = key;
|
||
setHoverInfo(null);
|
||
|
||
if (hoverTimer.current) clearTimeout(hoverTimer.current);
|
||
hoverTimer.current = setTimeout(() => {
|
||
if (!(hit.object instanceof THREE.Mesh)) return;
|
||
const canvas = gl.domElement;
|
||
const canvasSize = { width: canvas.clientWidth, height: canvas.clientHeight };
|
||
|
||
// Try hole detection first
|
||
if (hit.faceIndex !== undefined) {
|
||
const hole = detectHoleAtFace(hit.object, hit.faceIndex);
|
||
if (hole) {
|
||
setHoverInfo({
|
||
type: 'hole',
|
||
position: [hole.center.x, hole.center.y, hole.center.z],
|
||
value: hole.diameterMM,
|
||
});
|
||
return;
|
||
}
|
||
}
|
||
|
||
// Try edge detection
|
||
const edge = findNearestEdgeSegment(hit.object, hit.point, camera, canvasSize, 15);
|
||
if (edge) {
|
||
setHoverInfo({
|
||
type: 'edge',
|
||
position: [edge.midpoint.x, edge.midpoint.y, edge.midpoint.z],
|
||
value: edge.lengthMM,
|
||
});
|
||
return;
|
||
}
|
||
|
||
setHoverInfo(null);
|
||
}, 1000);
|
||
};
|
||
|
||
const onLeave = () => {
|
||
lastHitKey.current = '';
|
||
setHoverInfo(null);
|
||
if (hoverTimer.current) clearTimeout(hoverTimer.current);
|
||
};
|
||
|
||
gl.domElement.addEventListener('pointermove', onMove);
|
||
gl.domElement.addEventListener('pointerleave', onLeave);
|
||
return () => {
|
||
gl.domElement.removeEventListener('pointermove', onMove);
|
||
gl.domElement.removeEventListener('pointerleave', onLeave);
|
||
if (hoverTimer.current) clearTimeout(hoverTimer.current);
|
||
};
|
||
}, [measureMode, camera, scene, gl, raycaster, mouse, setHoverInfo]);
|
||
|
||
return null;
|
||
}
|
||
|
||
/** Raycasting click handler for measurement mode – uses snap point when available */
|
||
function MeasureClickHandler() {
|
||
const { camera, scene, gl } = useThree();
|
||
const measureMode = useModelStore((s) => s.measureMode);
|
||
const addMeasurePoint = useModelStore((s) => s.addMeasurePoint);
|
||
const snapPoint = useModelStore((s) => s.snapPoint);
|
||
const raycaster = useMemo(() => new THREE.Raycaster(), []);
|
||
const mouse = useMemo(() => new THREE.Vector2(), []);
|
||
|
||
const handleClick = useCallback((event: MouseEvent) => {
|
||
if (!measureMode) return;
|
||
|
||
// Use snap point if available
|
||
if (snapPoint) {
|
||
addMeasurePoint({ x: snapPoint.x, y: snapPoint.y, z: snapPoint.z });
|
||
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);
|
||
|
||
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;
|
||
if (obj.geometry instanceof THREE.RingGeometry) return false;
|
||
}
|
||
return true;
|
||
});
|
||
|
||
if (hit) {
|
||
addMeasurePoint({ x: hit.point.x, y: hit.point.y, z: hit.point.z });
|
||
}
|
||
}, [measureMode, addMeasurePoint, snapPoint, 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;
|
||
}
|
||
|
||
function GridLayer() {
|
||
const showGrid = useModelStore((s) => s.showGrid);
|
||
const gridY = useModelStore((s) => s.gridY);
|
||
if (!showGrid) return null;
|
||
return (
|
||
<Grid
|
||
position={[0, gridY, 0]}
|
||
infiniteGrid
|
||
cellSize={0.01}
|
||
sectionSize={0.1}
|
||
cellThickness={0.5}
|
||
sectionThickness={1}
|
||
cellColor="#334155"
|
||
sectionColor="#475569"
|
||
fadeDistance={5}
|
||
fadeStrength={1}
|
||
/>
|
||
);
|
||
}
|
||
|
||
/** Computes min Y of all visible models and updates store gridY when auto-follow is on. */
|
||
function GridAutoFollower() {
|
||
const models = useModelStore((s) => s.models);
|
||
const { scene } = useThree();
|
||
useEffect(() => {
|
||
const auto = useModelStore.getState().gridAutoFollow;
|
||
if (!auto) return;
|
||
// Defer to next tick so models have rendered/positioned
|
||
const id = setTimeout(() => {
|
||
if (!useModelStore.getState().gridAutoFollow) return;
|
||
const box = new THREE.Box3();
|
||
let has = false;
|
||
scene.traverse((obj) => {
|
||
if (obj instanceof THREE.Mesh && obj.geometry) {
|
||
// Skip helpers (markers, rings)
|
||
if (obj.geometry instanceof THREE.SphereGeometry) return;
|
||
if (obj.geometry instanceof THREE.RingGeometry) return;
|
||
if (obj.userData.__edgeLine) return;
|
||
obj.updateWorldMatrix(true, false);
|
||
const b = new THREE.Box3().setFromObject(obj);
|
||
if (isFinite(b.min.y)) {
|
||
if (!has) { box.copy(b); has = true; }
|
||
else box.union(b);
|
||
}
|
||
}
|
||
});
|
||
if (has) {
|
||
useModelStore.setState({ gridY: box.min.y - 0.005 });
|
||
}
|
||
}, 100);
|
||
return () => clearTimeout(id);
|
||
}, [models, scene]);
|
||
return null;
|
||
}
|
||
|
||
/** Mouse drag handler for desktop "Posicionar" mode: translates/rotates the active model. */
|
||
function PositionDragHandler() {
|
||
const { camera, gl, scene } = useThree();
|
||
const positionMode = useModelStore((s) => s.positionMode);
|
||
const activeId = useModelStore((s) => s.activeModelId);
|
||
|
||
useEffect(() => {
|
||
if (!positionMode) {
|
||
gl.domElement.style.cursor = 'grab';
|
||
return;
|
||
}
|
||
gl.domElement.style.cursor = 'move';
|
||
|
||
const canvas = gl.domElement;
|
||
let dragging = false;
|
||
let button = 0;
|
||
let shiftKey = false;
|
||
let lastX = 0;
|
||
let lastY = 0;
|
||
let pixelsPerWorldUnit = 1;
|
||
|
||
const computePixelScale = () => {
|
||
const st = useModelStore.getState();
|
||
const active = st.models.find((m) => m.id === st.activeModelId);
|
||
if (!active) return;
|
||
// Find the model's group position (approx world): use renderFactor + fineTuning pos
|
||
const factor = st.scaleRatio?.factor ?? 1;
|
||
const wp = new THREE.Vector3(
|
||
active.fineTuning.posX * factor,
|
||
active.fineTuning.posY * factor,
|
||
active.fineTuning.posZ * factor,
|
||
);
|
||
const dist = camera.position.distanceTo(wp);
|
||
const perspCam = camera as THREE.PerspectiveCamera;
|
||
const fov = (perspCam.fov ?? 50) * (Math.PI / 180);
|
||
const worldPerPixel = (2 * dist * Math.tan(fov / 2)) / canvas.clientHeight;
|
||
pixelsPerWorldUnit = worldPerPixel; // world units per pixel
|
||
};
|
||
|
||
const onDown = (e: PointerEvent) => {
|
||
const st = useModelStore.getState();
|
||
if (!st.positionMode || !st.activeModelId) return;
|
||
dragging = true;
|
||
button = e.button;
|
||
shiftKey = e.shiftKey;
|
||
lastX = e.clientX;
|
||
lastY = e.clientY;
|
||
computePixelScale();
|
||
(e.target as Element).setPointerCapture?.(e.pointerId);
|
||
e.preventDefault();
|
||
};
|
||
|
||
const onMove = (e: PointerEvent) => {
|
||
if (!dragging) return;
|
||
const st = useModelStore.getState();
|
||
const active = st.models.find((m) => m.id === st.activeModelId);
|
||
if (!active) return;
|
||
|
||
const dx = e.clientX - lastX;
|
||
const dy = e.clientY - lastY;
|
||
lastX = e.clientX;
|
||
lastY = e.clientY;
|
||
shiftKey = e.shiftKey;
|
||
|
||
const ft = active.fineTuning;
|
||
const factor = st.scaleRatio?.factor ?? 1;
|
||
|
||
if (button === 2) {
|
||
// Right button: rotate
|
||
const sens = 0.4; // deg per pixel
|
||
if (shiftKey) {
|
||
useModelStore.getState().setFineTuning({ rotZ: ft.rotZ + dx * sens });
|
||
} else {
|
||
useModelStore.getState().setFineTuning({
|
||
rotY: ft.rotY + dx * sens,
|
||
rotX: ft.rotX + dy * sens,
|
||
});
|
||
}
|
||
} else if (button === 1) {
|
||
// Middle button (wheel): roll around the piece's own longitudinal axis
|
||
const sens = 0.5;
|
||
let dominant: 'x' | 'y' | 'z' = 'x';
|
||
scene.traverse((o) => {
|
||
if (o.userData?.modelId === active.id && o.userData?.dominantAxis) {
|
||
dominant = o.userData.dominantAxis;
|
||
}
|
||
});
|
||
const delta = dx * sens;
|
||
if (dominant === 'x') {
|
||
useModelStore.getState().setFineTuning({ rotX: ft.rotX + delta });
|
||
} else if (dominant === 'y') {
|
||
useModelStore.getState().setFineTuning({ rotY: ft.rotY + delta });
|
||
} else {
|
||
useModelStore.getState().setFineTuning({ rotZ: ft.rotZ + delta });
|
||
}
|
||
} else if (button === 0 && shiftKey) {
|
||
// Shift+left: depth (Z in camera space)
|
||
const worldDelta = dy * pixelsPerWorldUnit;
|
||
const camDir = new THREE.Vector3();
|
||
camera.getWorldDirection(camDir);
|
||
const move = camDir.multiplyScalar(worldDelta).divideScalar(factor);
|
||
useModelStore.getState().setFineTuning({
|
||
posX: ft.posX + move.x,
|
||
posY: ft.posY + move.y,
|
||
posZ: ft.posZ + move.z,
|
||
});
|
||
} else {
|
||
// Left: translate in camera plane
|
||
const right = new THREE.Vector3();
|
||
const up = new THREE.Vector3();
|
||
camera.matrixWorld.extractBasis(right, up, new THREE.Vector3());
|
||
const worldDX = dx * pixelsPerWorldUnit;
|
||
const worldDY = -dy * pixelsPerWorldUnit;
|
||
const move = right.multiplyScalar(worldDX).add(up.multiplyScalar(worldDY)).divideScalar(factor);
|
||
useModelStore.getState().setFineTuning({
|
||
posX: ft.posX + move.x,
|
||
posY: ft.posY + move.y,
|
||
posZ: ft.posZ + move.z,
|
||
});
|
||
}
|
||
};
|
||
|
||
const onUp = (e: PointerEvent) => {
|
||
dragging = false;
|
||
(e.target as Element).releasePointerCapture?.(e.pointerId);
|
||
};
|
||
|
||
const onContext = (e: MouseEvent) => e.preventDefault();
|
||
|
||
canvas.addEventListener('pointerdown', onDown);
|
||
canvas.addEventListener('pointermove', onMove);
|
||
canvas.addEventListener('pointerup', onUp);
|
||
canvas.addEventListener('pointercancel', onUp);
|
||
canvas.addEventListener('contextmenu', onContext);
|
||
return () => {
|
||
canvas.removeEventListener('pointerdown', onDown);
|
||
canvas.removeEventListener('pointermove', onMove);
|
||
canvas.removeEventListener('pointerup', onUp);
|
||
canvas.removeEventListener('pointercancel', onUp);
|
||
canvas.removeEventListener('contextmenu', onContext);
|
||
gl.domElement.style.cursor = 'grab';
|
||
};
|
||
}, [positionMode, activeId, camera, gl, scene]);
|
||
|
||
return null;
|
||
}
|
||
|
||
|
||
|
||
export function ModelViewerCanvas({ url }: ModelViewerProps) {
|
||
const positionMode = useModelStore((s) => s.positionMode);
|
||
const measureMode = useModelStore((s) => s.measureMode);
|
||
return (
|
||
<Canvas
|
||
camera={{ position: [2, 2, 2], fov: 50, near: 0.001, far: 1000 }}
|
||
gl={{ antialias: true, alpha: true, powerPreference: 'high-performance', preserveDrawingBuffer: true }}
|
||
frameloop="always"
|
||
className="!bg-background"
|
||
onCreated={({ gl }) => {
|
||
const isApple = /Mac|iPhone|iPad/.test(navigator.platform);
|
||
const maxRatio = isApple ? 2 : 1.5;
|
||
gl.setPixelRatio(Math.min(window.devicePixelRatio, maxRatio));
|
||
}}
|
||
>
|
||
<ambientLight intensity={0.6} />
|
||
<directionalLight position={[5, 10, 5]} intensity={1} castShadow />
|
||
<directionalLight position={[-5, 5, -5]} intensity={0.3} />
|
||
|
||
<Suspense fallback={<LoadingFallback />}>
|
||
<SceneModels />
|
||
</Suspense>
|
||
|
||
<MeasurementOverlay />
|
||
<MeasureClickHandler />
|
||
<SmartSnapHandler />
|
||
<HoverDetector />
|
||
|
||
<GridLayer />
|
||
<GridAutoFollower />
|
||
|
||
<PositionDragHandler />
|
||
|
||
<OrbitControls
|
||
makeDefault
|
||
enableDamping
|
||
dampingFactor={0.1}
|
||
minDistance={0.05}
|
||
maxDistance={50}
|
||
enabled={!positionMode && !measureMode}
|
||
/>
|
||
</Canvas>
|
||
);
|
||
}
|