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

1117 lines
40 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';
import { registerModelLocalGroup, unregisterModelLocalGroup } from '@/lib/modelTransforms';
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 selectionMode = useModelStore((s) => s.selectionMode);
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();
if ((e.delta ?? 0) > 4) return;
if (selectionMode) {
const element = findElementRoot(e.object);
if (element) {
useModelStore.getState().toggleElementSelection(elementKey(sceneModel.id, element));
}
return;
}
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).
This is the local frame where measurement points are stored, so measurements
follow the model when posX/Y/Z or rotX/Y/Z change in "Posicionar" mode. */}
<ModelLocalFrame modelId={sceneModel.id} center={modelInfo.center}>
<primitive object={scene} />
<ModelMeasurements modelId={sceneModel.id} />
</ModelLocalFrame>
</group>
</group>
</group>
);
}
/** Innermost local frame of a model. Registers itself in the global model
* registry so the store can convert world points to this group's local frame. */
function ModelLocalFrame({
modelId,
center,
children,
}: {
modelId: string;
center: THREE.Vector3;
children: React.ReactNode;
}) {
const ref = useRef<THREE.Group>(null);
useEffect(() => {
const g = ref.current;
if (!g) return;
registerModelLocalGroup(modelId, g);
return () => unregisterModelLocalGroup(modelId, g);
}, [modelId]);
return (
<group ref={ref} position={[-center.x, -center.y, -center.z]}>
{children}
</group>
);
}
function SceneModels() {
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.
* Compensates for any cumulative parent world scale so it stays the same screen
* size whether mounted in world space or inside a transformed model group. */
function PointMarker({ position, color = '#e8a838' }: { position: [number, number, number]; color?: string }) {
const ref = useRef<THREE.Mesh>(null);
const { camera, size } = useThree();
const tmpVec = useMemo(() => new THREE.Vector3(), []);
const worldPos = useMemo(() => new THREE.Vector3(), []);
useFrame(() => {
if (!ref.current) return;
ref.current.getWorldPosition(worldPos);
const dist = camera.position.distanceTo(worldPos);
const perspCam = camera as THREE.PerspectiveCamera;
const fov = (perspCam.fov ?? 50) * (Math.PI / 180);
const worldPerPixel = (2 * dist * Math.tan(fov / 2)) / size.height;
// Target: ~6 px radius (radius = 1 in base geometry → scale = 6*wpp).
const targetWorld = Math.max(0.0005, 6 * worldPerPixel);
// Divide by parent world scale so the final world size matches `targetWorld`.
const parentScale = ref.current.parent ? ref.current.parent.getWorldScale(tmpVec).x || 1 : 1;
ref.current.scale.setScalar(targetWorld / parentScale);
});
return (
<mesh ref={ref} position={position}>
<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 a single measurement's geometry + label.
* Coordinates are interpreted in whichever frame the component is mounted in. */
function MeasurementGraphic({ m }: { m: import('@/stores/useModelStore').Measurement }) {
const a: [number, number, number] = [m.pointA.x, m.pointA.y, m.pointA.z];
const b: [number, number, number] = [m.pointB.x, m.pointB.y, m.pointB.z];
const mid: [number, number, number] = [
(a[0] + b[0]) / 2,
(a[1] + b[1]) / 2,
(a[2] + b[2]) / 2,
];
if (m.kind === 'hole') {
return (
<group>
<PointMarker position={a} color="#f59e0b" />
<MeasurementLabel position={a} text={m.label ?? ${m.distanceMM.toFixed(1)}`} variant="accent" />
</group>
);
}
if (m.kind === 'edge') {
return (
<group>
<PointMarker position={a} color="#22c55e" />
<PointMarker position={b} color="#22c55e" />
<Line points={[a, b]} color="#22c55e" lineWidth={2} depthTest={false} />
<MeasurementLabel position={mid} text={m.label ?? `${m.distanceMM.toFixed(1)} mm`} variant="success" />
</group>
);
}
return (
<group>
<PointMarker position={a} color="#22c55e" />
<PointMarker position={b} color="#22c55e" />
<Line points={[a, b]} color="#22c55e" lineWidth={2} depthTest={false} />
<MeasurementLabel position={mid} text={`${m.distanceMM.toFixed(1)} mm`} variant="success" />
</group>
);
}
/** Renders measurements attached to a given model, in that model's local frame.
* Mounted inside GLBModel so they follow the model when it is repositioned. */
function ModelMeasurements({ modelId }: { modelId: string }) {
const measurements = useModelStore((s) => s.measurements);
const attached = measurements.filter((m) => m.modelId === modelId);
if (attached.length === 0) return null;
return (
<>
{attached.map((m) => (
<MeasurementGraphic key={m.id} m={m} />
))}
</>
);
}
/** Renders snap indicator, hover tooltip, pending point and any *legacy*
* world-frame measurements that aren't attached to a specific model. */
function MeasurementOverlay() {
const measurements = useModelStore((s) => s.measurements);
const measurePoints = useModelStore((s) => s.measurePoints);
const snapPoint = useModelStore((s) => s.snapPoint);
const hoverInfo = useModelStore((s) => s.hoverInfo);
const measureMode = useModelStore((s) => s.measureMode);
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 (always in world coords) */}
{measurePoints.length === 1 && (
<PointMarker position={[measurePoints[0].x, measurePoints[0].y, measurePoints[0].z]} color="#e8a838" />
)}
{/* Legacy world-frame measurements (no modelId) */}
{measurements.filter((m) => !m.modelId).map((m) => (
<MeasurementGraphic key={m.id} m={m} />
))}
</>
);
}
/** 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;
const onR3FMiss = (event: Event) => {
const detail = (event as CustomEvent<PointerEvent>).detail;
if (!detail || detail.button !== 0) return;
const rect = canvas.getBoundingClientRect();
mouse.x = ((detail.clientX - rect.left) / rect.width) * 2 - 1;
mouse.y = -((detail.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);
const modelId = useModelStore.getState().activeModelId;
if (!modelId || !element) return;
useModelStore.getState().toggleElementSelection(elementKey(modelId, element));
};
canvas.addEventListener('tsxr-selection-miss', onR3FMiss);
return () => {
canvas.removeEventListener('tsxr-selection-miss', onR3FMiss);
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"
onPointerMissed={(e) => {
if (useModelStore.getState().selectionMode) {
(e.target as HTMLCanvasElement).dispatchEvent(new CustomEvent('tsxr-selection-miss', { detail: e }));
}
}}
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>
);
}