Files
SteelXR/src/components/three/ModelViewer.tsx
T
gpt-engineer-app[bot] ce8b92a27b Changes
Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
2026-05-21 14:52:52 +00:00

1053 lines
38 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}
/** Walk up the parent chain until we find a node tagged as `ifcElement`,
* or the model root. Returns null if neither found. */
export function findElementRoot(obj: THREE.Object3D | null): THREE.Object3D | null {
let cur: THREE.Object3D | null = obj;
while (cur) {
if (cur.userData?.ifcElement) return cur;
cur = cur.parent;
}
return null;
}
/** 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) => {
const o = i.object;
if (!(o instanceof THREE.Mesh)) return false;
if (o.userData.__edgeLine) return false;
if (o.geometry instanceof THREE.SphereGeometry) return false;
if (o.geometry instanceof THREE.RingGeometry) return false;
if (o.geometry instanceof THREE.PlaneGeometry) return false;
return true;
});
if (!hit) return;
// Walk up to find ifcElement node AND the GLBModel root (carrying modelId)
let cur: THREE.Object3D | null = hit.object;
let element: THREE.Object3D | null = null;
let modelId: string | null = null;
while (cur) {
if (!element && cur.userData?.ifcElement) element = cur;
if (!modelId && cur.userData?.modelId) modelId = cur.userData.modelId as string;
cur = cur.parent;
}
// Non-IFC fallback: pick the mesh itself
if (!element) element = hit.object;
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;
// For each ifcElement descendant inside this model root
root.traverse((el) => {
if (!el.userData?.ifcElement) 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;
const mats = Array.isArray(m.material) ? m.material : [m.material];
mats.forEach((mat) => {
if (!(mat instanceof THREE.MeshStandardMaterial)) return;
if (selected) {
if (m.userData.__origEmissive === undefined) {
m.userData.__origEmissive = mat.emissive.getHex();
m.userData.__origEmissiveIntensity = mat.emissiveIntensity;
}
mat.emissive.set('#f59e0b');
mat.emissiveIntensity = 0.6;
} else if (m.userData.__origEmissive !== undefined) {
mat.emissive.setHex(m.userData.__origEmissive as number);
mat.emissiveIntensity = (m.userData.__origEmissiveIntensity as number) ?? 0;
delete m.userData.__origEmissive;
delete m.userData.__origEmissiveIntensity;
}
mat.needsUpdate = true;
});
});
});
});
}, [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 />
<OrbitControls
makeDefault
enableDamping
dampingFactor={0.1}
minDistance={0.05}
maxDistance={50}
enabled={!positionMode && !selectionMode}
/>
</Canvas>
);
}