a46451f8d0
Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
1063 lines
38 KiB
TypeScript
1063 lines
38 KiB
TypeScript
import { Suspense, useRef, useMemo, useEffect } 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';
|
||
|
||
interface ModelViewerProps {
|
||
url?: string; // legacy, ignored — uses store.models
|
||
}
|
||
|
||
function isPickableModelMesh(obj: THREE.Object3D): obj is THREE.Mesh {
|
||
if (!(obj instanceof THREE.Mesh)) return false;
|
||
if (obj.userData.__edgeLine) return false;
|
||
if (obj.geometry instanceof THREE.SphereGeometry) return false;
|
||
if (obj.geometry instanceof THREE.RingGeometry) return false;
|
||
if (obj.geometry instanceof THREE.PlaneGeometry) return false;
|
||
return true;
|
||
}
|
||
|
||
/** Walk up the parent chain until we find a node tagged as `ifcElement`.
|
||
* Falls back to the clicked mesh so older/demo GLBs without IFC metadata
|
||
* still support selection/highlight. */
|
||
export function findElementRoot(obj: THREE.Object3D | null): THREE.Object3D | null {
|
||
const fallback = isPickableModelMesh(obj as THREE.Object3D) ? obj : null;
|
||
let cur: THREE.Object3D | null = obj;
|
||
while (cur) {
|
||
if (cur.userData?.ifcElement) return cur;
|
||
cur = cur.parent;
|
||
}
|
||
return fallback;
|
||
}
|
||
|
||
function hasIfcAncestor(obj: THREE.Object3D): boolean {
|
||
let cur = obj.parent;
|
||
while (cur) {
|
||
if (cur.userData?.ifcElement) return true;
|
||
cur = cur.parent;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
/** Stable key for an element across reloads: prefers ifcId, falls back to name. */
|
||
export function elementKey(modelId: string, el: THREE.Object3D): string {
|
||
const id = el.userData?.ifcId ?? el.name ?? el.uuid;
|
||
return `${modelId}:${id}`;
|
||
}
|
||
|
||
/** Globally accessible ref to the active R3F scene (set by SceneRefCapture). */
|
||
export const sceneRef: { current: THREE.Scene | null } = { current: null };
|
||
|
||
function SceneRefCapture() {
|
||
const { scene } = useThree();
|
||
useEffect(() => {
|
||
sceneRef.current = scene;
|
||
return () => { if (sceneRef.current === scene) sceneRef.current = null; };
|
||
}, [scene]);
|
||
return null;
|
||
}
|
||
|
||
|
||
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 — scaled per-frame to keep constant screen size */
|
||
function PointMarker({ position, color = '#e8a838' }: { position: [number, number, number]; color?: string }) {
|
||
const ref = useRef<THREE.Mesh>(null);
|
||
const { camera, size } = useThree();
|
||
useFrame(() => {
|
||
if (!ref.current) return;
|
||
const dist = camera.position.distanceTo(ref.current.position);
|
||
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);
|
||
});
|
||
return (
|
||
<mesh ref={ref} position={position}>
|
||
<sphereGeometry args={[1, 16, 16]} />
|
||
<meshBasicMaterial color={color} depthTest={false} transparent opacity={0.95} />
|
||
</mesh>
|
||
);
|
||
}
|
||
|
||
/** Snap ring indicator — scaled per-frame */
|
||
function SnapRing({ position, color = '#eab308' }: { position: [number, number, number]; color?: string }) {
|
||
const ref = useRef<THREE.Mesh>(null);
|
||
const { camera, size } = useThree();
|
||
useFrame(() => {
|
||
if (!ref.current) return;
|
||
ref.current.lookAt(camera.position);
|
||
const dist = camera.position.distanceTo(ref.current.position);
|
||
const perspCam = camera as THREE.PerspectiveCamera;
|
||
const fov = (perspCam.fov ?? 50) * (Math.PI / 180);
|
||
const worldPerPixel = (2 * dist * Math.tan(fov / 2)) / size.height;
|
||
const s = Math.max(0.0008, 10 * worldPerPixel);
|
||
ref.current.scale.setScalar(s);
|
||
});
|
||
return (
|
||
<mesh ref={ref} position={position}>
|
||
<ringGeometry args={[0.7, 1, 28]} />
|
||
<meshBasicMaterial color={color} side={THREE.DoubleSide} depthTest={false} transparent opacity={0.9} />
|
||
</mesh>
|
||
);
|
||
}
|
||
|
||
/** Computes a distanceFactor that keeps an Html label roughly constant on screen */
|
||
function useLabelDistanceFactor(position: [number, number, number]): number {
|
||
const { camera, size } = useThree();
|
||
const v = useMemo(() => new THREE.Vector3(), []);
|
||
const ref = useRef(1);
|
||
useFrame(() => {
|
||
v.set(position[0], position[1], position[2]);
|
||
const dist = camera.position.distanceTo(v);
|
||
const perspCam = camera as THREE.PerspectiveCamera;
|
||
const fov = (perspCam.fov ?? 50) * (Math.PI / 180);
|
||
const worldPerPixel = (2 * dist * Math.tan(fov / 2)) / size.height;
|
||
// distanceFactor scales the html so it stays ~140 px tall at this distance
|
||
ref.current = Math.max(0.05, worldPerPixel * 140);
|
||
});
|
||
return ref.current;
|
||
}
|
||
|
||
function MeasurementLabel({ position, text, variant, fixed = false }: { position: [number, number, number]; text: string; variant: 'success' | 'primary' | 'accent'; fixed?: boolean }) {
|
||
const dfDynamic = useLabelDistanceFactor(position);
|
||
const df = fixed ? dfDynamic : 1.5;
|
||
const borderClass = variant === 'success'
|
||
? 'border-success/60'
|
||
: variant === 'accent'
|
||
? 'border-amber-400/70'
|
||
: 'border-primary/60';
|
||
const textClass = variant === 'success'
|
||
? 'text-success'
|
||
: variant === 'accent'
|
||
? 'text-amber-400'
|
||
: 'text-primary';
|
||
return (
|
||
<Html position={position} center distanceFactor={df} zIndexRange={[100, 0]} style={{ pointerEvents: 'none' }}>
|
||
<div className={`rounded bg-card/95 border ${borderClass} px-2 py-0.5 shadow-lg backdrop-blur-sm`}>
|
||
<span className={`font-mono text-[11px] font-bold ${textClass} whitespace-nowrap`}>
|
||
{text}
|
||
</span>
|
||
</div>
|
||
</Html>
|
||
);
|
||
}
|
||
|
||
/** 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);
|
||
const scaleRatio = useModelStore((s) => s.scaleRatio);
|
||
const factor = scaleRatio?.factor ?? 1;
|
||
|
||
return (
|
||
<>
|
||
{/* Snap indicator */}
|
||
{measureMode && snapPoint && (
|
||
<SnapRing position={[snapPoint.x, snapPoint.y, snapPoint.z]} />
|
||
)}
|
||
|
||
{/* Hover auto-detect tooltip (works both inside and outside measure mode) */}
|
||
{hoverInfo && (
|
||
<MeasurementLabel
|
||
position={hoverInfo.position}
|
||
text={hoverInfo.type === 'hole'
|
||
? `Ø ${hoverInfo.value.toFixed(1)}`
|
||
: `${hoverInfo.value.toFixed(1)} mm`}
|
||
variant={hoverInfo.type === 'hole' ? 'accent' : 'primary'}
|
||
fixed
|
||
/>
|
||
)}
|
||
|
||
{/* 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,
|
||
];
|
||
|
||
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>
|
||
);
|
||
})}
|
||
</>
|
||
);
|
||
}
|
||
|
||
|
||
/** 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;
|
||
}
|
||
|
||
const st = useModelStore.getState();
|
||
|
||
// Highest priority: a live hover label pointing at a hole → snap to its center
|
||
if (st.hoverInfo?.type === 'hole') {
|
||
const [hx, hy, hz] = st.hoverInfo.position;
|
||
setSnapPoint({ x: hx, y: hy, z: hz });
|
||
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;
|
||
});
|
||
|
||
const canvas = gl.domElement;
|
||
const canvasW = canvas.clientWidth;
|
||
const canvasH = canvas.clientHeight;
|
||
|
||
// Cursor in screen px (mouse is in NDC [-1, 1])
|
||
const cursorX = (mouse.x * 0.5 + 0.5) * canvasW;
|
||
const cursorY = (-mouse.y * 0.5 + 0.5) * canvasH;
|
||
|
||
// Snap to centers of saved hole measurements within 20 px on screen
|
||
const holeSnapPxThreshold = 20;
|
||
let bestHoleDist = Infinity;
|
||
let bestHole: { x: number; y: number; z: number } | null = null;
|
||
const proj = new THREE.Vector3();
|
||
for (const m of st.measurements) {
|
||
if (m.kind !== 'hole') continue;
|
||
proj.set(m.pointA.x, m.pointA.y, m.pointA.z).project(camera);
|
||
const sx = (proj.x * 0.5 + 0.5) * canvasW;
|
||
const sy = (-proj.y * 0.5 + 0.5) * canvasH;
|
||
const d = Math.hypot(sx - cursorX, sy - cursorY);
|
||
if (d < bestHoleDist && d <= holeSnapPxThreshold) {
|
||
bestHoleDist = d;
|
||
bestHole = { x: m.pointA.x, y: m.pointA.y, z: m.pointA.z };
|
||
}
|
||
}
|
||
if (bestHole) {
|
||
setSnapPoint(bestHole);
|
||
return;
|
||
}
|
||
|
||
if (hit && hit.object instanceof THREE.Mesh) {
|
||
const snap = findNearestVertex(
|
||
hit.object, hit.point, camera,
|
||
{ width: canvasW, height: canvasH },
|
||
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.
|
||
* Runs in BOTH view mode and measure mode. Outside measure mode the label
|
||
* auto-hides after 4s; in measure mode it persists until the user clicks
|
||
* (registering it as a saved measurement) or moves the mouse significantly. */
|
||
function HoverDetector() {
|
||
const { camera, scene, gl } = useThree();
|
||
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 hideTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||
const lastHitKey = useRef('');
|
||
|
||
useEffect(() => {
|
||
const clearTimers = () => {
|
||
if (hoverTimer.current) { clearTimeout(hoverTimer.current); hoverTimer.current = null; }
|
||
if (hideTimer.current) { clearTimeout(hideTimer.current); hideTimer.current = null; }
|
||
};
|
||
|
||
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);
|
||
clearTimers();
|
||
return;
|
||
}
|
||
|
||
// Stability check – same approximate position for debounce (~3 mm)
|
||
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);
|
||
clearTimers();
|
||
|
||
hoverTimer.current = setTimeout(() => {
|
||
if (!(hit.object instanceof THREE.Mesh)) return;
|
||
const canvas = gl.domElement;
|
||
const canvasSize = { width: canvas.clientWidth, height: canvas.clientHeight };
|
||
|
||
// 1) Try circle fit from edge vertices around the hit (catches holes on flat faces)
|
||
const circ = detectCircularEdgeAtPoint(hit.object, hit.point, camera, canvasSize, 50);
|
||
if (circ) {
|
||
setHoverInfo({
|
||
type: 'hole',
|
||
position: [circ.center.x, circ.center.y, circ.center.z],
|
||
value: circ.diameterMM,
|
||
});
|
||
} else 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 true;
|
||
}
|
||
return false;
|
||
})()) {
|
||
// handled inside IIFE
|
||
} else {
|
||
// 3) Edge length
|
||
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,
|
||
endpoints: {
|
||
a: { x: edge.a.x, y: edge.a.y, z: edge.a.z },
|
||
b: { x: edge.b.x, y: edge.b.y, z: edge.b.z },
|
||
},
|
||
});
|
||
} else {
|
||
setHoverInfo(null);
|
||
return;
|
||
}
|
||
}
|
||
|
||
// Outside measure mode, auto-hide after 4 s
|
||
const inMeasure = useModelStore.getState().measureMode;
|
||
if (!inMeasure) {
|
||
hideTimer.current = setTimeout(() => setHoverInfo(null), 4000);
|
||
}
|
||
}, 1000);
|
||
};
|
||
|
||
const onLeave = () => {
|
||
lastHitKey.current = '';
|
||
setHoverInfo(null);
|
||
clearTimers();
|
||
};
|
||
|
||
gl.domElement.addEventListener('pointermove', onMove);
|
||
gl.domElement.addEventListener('pointerleave', onLeave);
|
||
return () => {
|
||
gl.domElement.removeEventListener('pointermove', onMove);
|
||
gl.domElement.removeEventListener('pointerleave', onLeave);
|
||
clearTimers();
|
||
};
|
||
}, [camera, scene, gl, raycaster, mouse, setHoverInfo]);
|
||
|
||
return null;
|
||
}
|
||
|
||
/** Pointer handler for measurement mode – uses snap point when available.
|
||
* Uses pointerdown/up with a drag gate so accidental drags don't add points. */
|
||
function MeasureClickHandler() {
|
||
const { camera, scene, gl } = useThree();
|
||
const measureMode = useModelStore((s) => s.measureMode);
|
||
const raycaster = useMemo(() => new THREE.Raycaster(), []);
|
||
const mouse = useMemo(() => new THREE.Vector2(), []);
|
||
|
||
useEffect(() => {
|
||
if (!measureMode) {
|
||
gl.domElement.style.cursor = 'grab';
|
||
return;
|
||
}
|
||
gl.domElement.style.cursor = 'crosshair';
|
||
|
||
const canvas = gl.domElement;
|
||
let downX = 0, downY = 0, downBtn = 0;
|
||
let armed = false;
|
||
|
||
const onDown = (e: PointerEvent) => {
|
||
if (e.button !== 0) { armed = false; return; }
|
||
downX = e.clientX;
|
||
downY = e.clientY;
|
||
downBtn = e.button;
|
||
armed = true;
|
||
};
|
||
|
||
const onUp = (e: PointerEvent) => {
|
||
if (!armed || e.button !== downBtn) return;
|
||
armed = false;
|
||
const dx = e.clientX - downX;
|
||
const dy = e.clientY - downY;
|
||
if (Math.hypot(dx, dy) > 4) return; // it was a drag, not a click
|
||
|
||
const st = useModelStore.getState();
|
||
if (!st.measureMode) return;
|
||
|
||
// If a hover label is currently shown (hole diameter or edge length),
|
||
// a click registers it as a saved measurement.
|
||
if (st.hoverInfo) {
|
||
st.registerHoverMeasurement(st.hoverInfo);
|
||
return;
|
||
}
|
||
|
||
// Prefer snap (vertex snap, or hole-center snap from SmartSnapHandler)
|
||
if (st.snapPoint) {
|
||
st.addMeasurePoint({ x: st.snapPoint.x, y: st.snapPoint.y, z: st.snapPoint.z });
|
||
return;
|
||
}
|
||
|
||
const rect = canvas.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.Mesh)) return false;
|
||
if (obj.userData.__edgeLine) return false;
|
||
if (obj.geometry instanceof THREE.SphereGeometry) return false;
|
||
if (obj.geometry instanceof THREE.RingGeometry) return false;
|
||
if (obj.geometry instanceof THREE.PlaneGeometry) return false;
|
||
return true;
|
||
});
|
||
if (hit) {
|
||
st.addMeasurePoint({ x: hit.point.x, y: hit.point.y, z: hit.point.z });
|
||
}
|
||
};
|
||
|
||
const onKey = (e: KeyboardEvent) => {
|
||
const st = useModelStore.getState();
|
||
if (!st.measureMode) return;
|
||
if (e.key === 'Escape') {
|
||
if (st.measurePoints.length > 0) st.undoLastMeasurePoint();
|
||
} else if ((e.key === 'z' || e.key === 'Z') && !e.ctrlKey && !e.metaKey) {
|
||
st.undoLastMeasurement();
|
||
}
|
||
};
|
||
|
||
canvas.addEventListener('pointerdown', onDown);
|
||
canvas.addEventListener('pointerup', onUp);
|
||
window.addEventListener('keydown', onKey);
|
||
return () => {
|
||
canvas.removeEventListener('pointerdown', onDown);
|
||
canvas.removeEventListener('pointerup', onUp);
|
||
window.removeEventListener('keydown', onKey);
|
||
gl.domElement.style.cursor = 'grab';
|
||
};
|
||
}, [measureMode, camera, scene, gl, raycaster, mouse]);
|
||
|
||
return null;
|
||
}
|
||
|
||
|
||
/** Pointer handler for element-selection mode. Clicks an IFC element, sub-mesh
|
||
* of a non-IFC model, or any pickable mesh group. Toggles selection in store. */
|
||
function SelectionHandler() {
|
||
const { camera, scene, gl } = useThree();
|
||
const selectionMode = useModelStore((s) => s.selectionMode);
|
||
const raycaster = useMemo(() => new THREE.Raycaster(), []);
|
||
const mouse = useMemo(() => new THREE.Vector2(), []);
|
||
|
||
useEffect(() => {
|
||
if (!selectionMode) return;
|
||
const prevCursor = gl.domElement.style.cursor;
|
||
gl.domElement.style.cursor = 'cell';
|
||
const canvas = gl.domElement;
|
||
let downX = 0, downY = 0, armed = false;
|
||
|
||
const onDown = (e: PointerEvent) => {
|
||
if (e.button !== 0) { armed = false; return; }
|
||
downX = e.clientX; downY = e.clientY; armed = true;
|
||
};
|
||
const onUp = (e: PointerEvent) => {
|
||
if (!armed) return;
|
||
armed = false;
|
||
if (Math.hypot(e.clientX - downX, e.clientY - downY) > 4) return;
|
||
|
||
const rect = canvas.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) => isPickableModelMesh(i.object));
|
||
if (!hit) return;
|
||
|
||
const element = findElementRoot(hit.object);
|
||
let cur: THREE.Object3D | null = hit.object;
|
||
let modelId: string | null = null;
|
||
while (cur) {
|
||
if (!modelId && cur.userData?.modelId) modelId = cur.userData.modelId as string;
|
||
cur = cur.parent;
|
||
}
|
||
if (!modelId) modelId = useModelStore.getState().activeModelId;
|
||
if (!modelId || !element) return;
|
||
const key = elementKey(modelId, element);
|
||
useModelStore.getState().toggleElementSelection(key);
|
||
};
|
||
|
||
canvas.addEventListener('pointerdown', onDown);
|
||
canvas.addEventListener('pointerup', onUp);
|
||
return () => {
|
||
canvas.removeEventListener('pointerdown', onDown);
|
||
canvas.removeEventListener('pointerup', onUp);
|
||
gl.domElement.style.cursor = prevCursor;
|
||
};
|
||
}, [selectionMode, camera, scene, gl, raycaster, mouse]);
|
||
|
||
return null;
|
||
}
|
||
|
||
/** Walks the scene each time visibility/selection state changes and applies
|
||
* per-element visibility + emissive highlight for selected elements. */
|
||
function VisibilityApplier() {
|
||
const { scene } = useThree();
|
||
const nonce = useModelStore((s) => s.visibilityNonce);
|
||
const models = useModelStore((s) => s.models);
|
||
const opacity = useModelStore((s) => s.opacity);
|
||
const renderMode = useModelStore((s) => s.renderMode);
|
||
const checklist = useModelStore((s) => s.checklist);
|
||
|
||
useEffect(() => {
|
||
const st = useModelStore.getState();
|
||
const { hiddenElementKeys, isolatedElementKeys, selectedElementKeys } = st;
|
||
|
||
scene.traverse((root) => {
|
||
const modelId = root.userData?.modelId as string | undefined;
|
||
if (!modelId) return;
|
||
root.traverse((el) => {
|
||
if (!el.userData?.ifcElement && !(isPickableModelMesh(el) && !hasIfcAncestor(el))) return;
|
||
const key = elementKey(modelId, el);
|
||
const hidden = hiddenElementKeys.has(key);
|
||
const isolated = isolatedElementKeys ? !isolatedElementKeys.has(key) : false;
|
||
el.visible = !(hidden || isolated);
|
||
|
||
const selected = selectedElementKeys.has(key);
|
||
el.traverse((m) => {
|
||
if (!(m instanceof THREE.Mesh)) return;
|
||
if (selected) {
|
||
if (!m.userData.__origMaterial) {
|
||
m.userData.__origMaterial = m.material;
|
||
const src = Array.isArray(m.material) ? m.material[0] : m.material;
|
||
const cloned = (src as THREE.Material).clone() as THREE.MeshStandardMaterial;
|
||
if (cloned instanceof THREE.MeshStandardMaterial) {
|
||
cloned.color.set('#f59e0b');
|
||
cloned.emissive.set('#f59e0b');
|
||
cloned.emissiveIntensity = 0.8;
|
||
cloned.needsUpdate = true;
|
||
}
|
||
m.material = cloned;
|
||
}
|
||
} else if (m.userData.__origMaterial) {
|
||
// Dispose the cloned highlight material
|
||
const cur = Array.isArray(m.material) ? m.material[0] : m.material;
|
||
(cur as THREE.Material)?.dispose?.();
|
||
m.material = m.userData.__origMaterial as THREE.Material;
|
||
delete m.userData.__origMaterial;
|
||
}
|
||
});
|
||
});
|
||
});
|
||
}, [scene, nonce, models, opacity, renderMode, checklist]);
|
||
|
||
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);
|
||
const selectionMode = useModelStore((s) => s.selectionMode);
|
||
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 />
|
||
<SelectionHandler />
|
||
<VisibilityApplier />
|
||
<SceneRefCapture />
|
||
|
||
<OrbitControls
|
||
makeDefault
|
||
enableDamping
|
||
dampingFactor={0.1}
|
||
minDistance={0.05}
|
||
maxDistance={50}
|
||
enabled={!positionMode}
|
||
/>
|
||
|
||
</Canvas>
|
||
);
|
||
}
|