Files
SteelXR/src/components/three/ModelViewer.tsx
T

1516 lines
54 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, type ReactNode, useState } from 'react';
import { Canvas, useThree, useFrame } from '@react-three/fiber';
import { OrbitControls, useGLTF, Grid, Html, Line, PerspectiveCamera, OrthographicCamera, PointerLockControls, Sky, Environment } 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, getModelWorldScaleFactor, getAllModelLocalGroups } from '@/lib/modelTransforms';
import { parseIFCtoThree } from '@/lib/convertIFC';
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js';
import { mainCameraRef, mainControlsRef, viewAnim, calibration, pushModelFaceNormal, computeCalibrationQuaternion, subscribeCalibration } from './viewCubeBus';
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, camera } = useThree();
useEffect(() => {
sceneRef.current = scene;
return () => { if (sceneRef.current === scene) sceneRef.current = null; };
}, [scene]);
useEffect(() => {
mainCameraRef.current = camera;
return () => {
if (mainCameraRef.current === camera) mainCameraRef.current = null;
};
}, [camera]);
return null;
}
/** Drives the camera animation requested by the ViewCube. */
function ViewCubeAnimator() {
const { camera } = useThree();
useFrame((_state, delta) => {
if (!viewAnim.active) return;
viewAnim.t = Math.min(1, viewAnim.t + delta / viewAnim.duration);
const k = viewAnim.t < 0.5
? 2 * viewAnim.t * viewAnim.t
: 1 - Math.pow(-2 * viewAnim.t + 2, 2) / 2;
camera.position.lerpVectors(viewAnim.startPos, viewAnim.endPos, k);
camera.up.lerpVectors(viewAnim.startUp, viewAnim.endUp, k).normalize();
const controls = mainControlsRef.current;
if (controls?.target) camera.lookAt(controls.target);
controls?.update?.();
if (viewAnim.t >= 1) viewAnim.active = false;
});
return null;
}
/** Switches between perspective and orthographic projection while keeping
* the visual framing (position, target, on-screen size of the scene). */
function CameraSwitcher() {
const mode = useModelStore((s) => s.cameraMode);
const { size } = useThree();
// Snapshot current view from whichever camera is active right now, before swap.
const snap = useMemo(() => {
const c = mainCameraRef.current;
const ctrl = mainControlsRef.current;
const target = (ctrl?.target ?? new THREE.Vector3()).clone();
const pos = c ? c.position.clone() : new THREE.Vector3(2, 2, 2);
const up = c ? c.up.clone() : new THREE.Vector3(0, 1, 0);
const dist = pos.distanceTo(target);
// Compute ortho zoom that matches current perspective framing.
let zoom = 100;
if (c && (c as THREE.PerspectiveCamera).isPerspectiveCamera) {
const fov = ((c as THREE.PerspectiveCamera).fov ?? 50) * (Math.PI / 180);
const worldHeight = 2 * Math.max(dist, 0.001) * Math.tan(fov / 2);
zoom = Math.max(1, size.height / Math.max(worldHeight, 1e-6));
} else if (c && (c as THREE.OrthographicCamera).isOrthographicCamera) {
zoom = (c as THREE.OrthographicCamera).zoom;
}
return { target, pos, up, zoom };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [mode]);
// Sync OrbitControls target after camera swap.
useEffect(() => {
const ctrl = mainControlsRef.current;
if (ctrl?.target) ctrl.target.copy(snap.target);
ctrl?.update?.();
}, [snap]);
if (mode === 'ortho') {
return (
<OrthographicCamera
makeDefault
position={snap.pos.toArray()}
up={snap.up.toArray()}
zoom={snap.zoom}
near={-1000}
far={1000}
/>
);
}
return (
<PerspectiveCamera
makeDefault
position={snap.pos.toArray()}
up={snap.up.toArray()}
fov={50}
near={0.001}
far={1000}
/>
);
}
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 [rawScene, setRawScene] = useState<THREE.Object3D | null>(null);
const scene = useMemo(() => rawScene ? rawScene.clone(true) : null, [rawScene]);
useEffect(() => {
let active = true;
const isIfc = sceneModel.fileName.toLowerCase().endsWith('.ifc');
if (isIfc) {
fetch(sceneModel.url)
.then((res) => res.arrayBuffer())
.then((buf) => parseIFCtoThree(buf))
.then((threeScene) => {
if (active) setRawScene(threeScene);
})
.catch((err) => console.error('[GLBModel] IFC parsing error', err));
} else {
const loader = new GLTFLoader();
loader.load(
sceneModel.url,
(gltf) => {
if (active) setRawScene(gltf.scene);
},
undefined,
(err) => console.error('[GLBModel] GLTF loading error', err)
);
}
return () => {
active = false;
};
}, [sceneModel.url, sceneModel.fileName]);
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 measureMode = useModelStore((s) => s.measureMode);
const fineTuning = sceneModel.fineTuning;
// Re-render when calibration state changes (so calQuat resets to identity during the flow).
const [, setCalTick] = useState(0);
useEffect(() => subscribeCalibration(() => setCalTick(t => t + 1)), []);
const modelInfo = useMemo(() => {
if (!scene) return { size: new THREE.Vector3(), center: new THREE.Vector3() };
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(() => {
if (!scene) return;
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' && !measureMode) {
mat.visible = false;
} else {
mat.visible = true;
const targetOpacity = measureMode ? 0.25 : opacity;
mat.transparent = targetOpacity < 1;
mat.opacity = targetOpacity;
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' || measureMode) && child.geometry) {
const edgesGeo = new THREE.EdgesGeometry(child.geometry, edgeThresholdAngle);
const lineMat = new THREE.LineBasicMaterial({
color: measureMode ? '#00f3ff' : wireframeColor,
linewidth: measureMode ? 2 : wireframeThickness,
toneMapped: false,
transparent: true,
opacity: 0.95,
});
const lineSegments = new THREE.LineSegments(edgesGeo, lineMat);
lineSegments.userData.__edgeLine = true;
child.add(lineSegments);
}
}
});
}, [scene, opacity, renderMode, checklist, wireframeColor, wireframeThickness, edgeThresholdAngle, sceneModel.color, measureMode]);
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;
// Calibration quaternion (applied innermost, around center). We render with identity
// while calibration is in progress for this model so face normals reflect the
// un-calibrated frame; the result is committed at the end.
const calQuatArr = sceneModel.calibrationQuat;
const isCalibratingThis = calibration.modelId === sceneModel.id && calibration.step !== 'idle' && calibration.step !== 'done';
const calQuat = useMemo(() => {
if (isCalibratingThis) return new THREE.Quaternion();
if (!calQuatArr) return new THREE.Quaternion();
return new THREE.Quaternion(calQuatArr[0], calQuatArr[1], calQuatArr[2], calQuatArr[3]);
}, [calQuatArr, isCalibratingThis]);
const calGroupRef = useRef<THREE.Group>(null);
if (!sceneModel.visible) return null;
if (!scene) 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;
// Calibration: capture model face normal in world space.
if (
calibration.modelId === sceneModel.id &&
(calibration.step === 'await-model-1' || calibration.step === 'await-model-2' || calibration.step === 'await-model-3') &&
e.face && e.object
) {
const n = e.face.normal.clone();
const nm = new THREE.Matrix3().getNormalMatrix(e.object.matrixWorld);
n.applyMatrix3(nm).normalize();
const wq = new THREE.Quaternion();
(calGroupRef.current ?? e.object).getWorldQuaternion(wq);
pushModelFaceNormal(n, wq);
// Apply / refresh calibration whenever we have ≥2 pairs.
if (calibration.pairs.length >= 2) {
const q = computeCalibrationQuaternion(calibration.pairs);
if (q) {
useModelStore.getState().setCalibration(sceneModel.id, [q.x, q.y, q.z, q.w]);
}
}
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]}
>
{/* Calibration rotation (innermost, around center). */}
<group ref={calGroupRef} quaternion={calQuat}>
<ModelLocalFrame modelId={sceneModel.id} center={modelInfo.center}>
<primitive object={scene} />
<ModelMeasurements modelId={sceneModel.id} />
</ModelLocalFrame>
</group>
</group>
</group>
</group>
);
}
/** Innermost local frame of a model. Registers itself in the global model
* registry so the store can convert world points to this group's local frame. */
function ModelLocalFrame({
modelId,
center,
children,
}: {
modelId: string;
center: THREE.Vector3;
children: ReactNode;
}) {
const ref = useRef<THREE.Group>(null);
useEffect(() => {
const g = ref.current;
if (!g) return;
registerModelLocalGroup(modelId, g);
return () => unregisterModelLocalGroup(modelId, g);
}, [modelId]);
return (
<group ref={ref} position={[-center.x, -center.y, -center.z]}>
{children}
</group>
);
}
function SceneModels() {
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} />
))}
</>
);
}
/** World-units-per-pixel for the active camera (persp or ortho). */
function computeWorldPerPixel(
camera: THREE.Camera,
size: { height: number },
worldPos: THREE.Vector3,
): number {
const ortho = camera as THREE.OrthographicCamera;
if ('isOrthographicCamera' in ortho) {
const visibleHeight = (ortho.top - ortho.bottom) / (ortho.zoom || 1);
return visibleHeight / Math.max(1, size.height);
}
const persp = camera as THREE.PerspectiveCamera;
const dist = camera.position.distanceTo(worldPos);
const fov = (persp.fov ?? 50) * (Math.PI / 180);
return (2 * dist * Math.tan(fov / 2)) / Math.max(1, size.height);
}
/** 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();
const tmpVec = useMemo(() => new THREE.Vector3(), []);
const worldPos = useMemo(() => new THREE.Vector3(), []);
useFrame(() => {
if (!ref.current) return;
ref.current.getWorldPosition(worldPos);
const wpp = computeWorldPerPixel(camera, size, worldPos);
const targetWorld = Math.max(0.0005, 6 * wpp);
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 — fixed ~10 mm diameter in world units (follows piece scale).
* World units in this app are meters (1 unit = 1000 mm), so 5 mm radius = 0.005
* world units, divided by the model's cumulative world scale so it remains
* ~10 mm physically when the piece is scaled. */
function SnapRing({ position, color = '#eab308' }: { position: [number, number, number]; color?: string }) {
const ref = useRef<THREE.Mesh>(null);
const { camera, size } = useThree();
const worldPos = useMemo(() => new THREE.Vector3(), []);
useFrame(() => {
if (!ref.current) return;
ref.current.lookAt(camera.position);
const activeId = useModelStore.getState().activeModelId;
const sf = getModelWorldScaleFactor(activeId);
let radiusWorld = 0.005 / Math.max(1e-6, sf); // 5 mm in world units
if (!Number.isFinite(radiusWorld) || radiusWorld <= 0) {
ref.current.getWorldPosition(worldPos);
const wpp = computeWorldPerPixel(camera, size, worldPos);
radiusWorld = Math.max(0.0008, 8 * wpp);
}
ref.current.scale.setScalar(radiusWorld);
});
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>
);
}
function MeasurementLabel({ position, text, variant }: { position: [number, number, number]; text: string; variant: 'success' | 'primary' | 'accent'; fixed?: boolean }) {
const { camera } = useThree();
const isOrtho = (camera as THREE.OrthographicCamera).isOrthographicCamera === true;
// In orthographic mode, drei's distanceFactor uses perspective math and can
// explode → the HTML label scales massively and visually wipes the model.
// Use a plain screen-space Html label in ortho; perspective keeps a stable factor.
const df = isOrtho ? undefined : 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. */
export 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}
/>
);
}
/** Continuously aligns the grid Y to the bottom of all registered model
* groups (not the entire scene), so the piece always sits on the grid —
* including after calibration, fine-tuning or repositioning. */
function GridAutoFollower() {
const lastY = useRef<number>(Number.NaN);
const acc = useRef(0);
useFrame((_, dt) => {
const auto = useModelStore.getState().gridAutoFollow;
if (!auto) return;
acc.current += dt;
if (acc.current < 0.1) return;
acc.current = 0;
const groups = getAllModelLocalGroups();
if (groups.length === 0) return;
const box = new THREE.Box3();
let has = false;
const tmp = new THREE.Box3();
for (const g of groups) {
g.updateWorldMatrix(true, true);
tmp.makeEmpty();
g.traverse((obj) => {
if (!(obj instanceof THREE.Mesh) || !obj.geometry) return;
if (obj.geometry instanceof THREE.SphereGeometry) return;
if (obj.geometry instanceof THREE.RingGeometry) return;
if (obj.userData.__edgeLine) return;
const b = new THREE.Box3().setFromObject(obj);
if (isFinite(b.min.y)) tmp.union(b);
});
if (!tmp.isEmpty() && isFinite(tmp.min.y)) {
if (!has) { box.copy(tmp); has = true; }
else box.union(tmp);
}
}
if (!has) return;
const target = box.min.y - 0.005;
if (!Number.isFinite(lastY.current) || Math.abs(target - lastY.current) > 1e-4) {
lastY.current = target;
useModelStore.setState({ gridY: target });
}
});
return null;
}
/** When gridCalibMode is on, the next click on the model sets the grid Y to
* the world Y of the clicked surface point and disables auto-follow. */
function GridCalibrationHandler() {
const { camera, scene, gl } = useThree();
const gridCalibMode = useModelStore((s) => s.gridCalibMode);
const raycaster = useMemo(() => new THREE.Raycaster(), []);
const mouse = useMemo(() => new THREE.Vector2(), []);
useEffect(() => {
if (!gridCalibMode) return;
const canvas = gl.domElement;
const prevCursor = canvas.style.cursor;
canvas.style.cursor = 'crosshair';
const onPointerUp = (ev: PointerEvent) => {
if (ev.button !== 0) return;
const rect = canvas.getBoundingClientRect();
mouse.x = ((ev.clientX - rect.left) / rect.width) * 2 - 1;
mouse.y = -((ev.clientY - rect.top) / rect.height) * 2 + 1;
raycaster.setFromCamera(mouse, camera);
const hits = raycaster.intersectObjects(scene.children, true);
const hit = hits.find((h) => isPickableModelMesh(h.object));
if (!hit) return;
useModelStore.setState({
gridY: hit.point.y - 0.001,
gridAutoFollow: false,
gridCalibMode: false,
showGrid: true,
});
};
canvas.addEventListener('pointerup', onPointerUp);
return () => {
canvas.removeEventListener('pointerup', onPointerUp);
canvas.style.cursor = prevCursor;
};
}, [gridCalibMode, camera, scene, gl, raycaster, mouse]);
return null;
}
/** Applies axis-aligned clipping planes (X / Y / Z) to every mesh inside the
* registered model groups. Listens to the section state in the store; when
* all axes are off, restores empty clipping arrays so meshes render whole. */
function SectionClippingApplier() {
const enabled = useModelStore((s) => s.sectionEnabled);
const invert = useModelStore((s) => s.sectionInvert);
const level = useModelStore((s) => s.sectionLevel);
const planes = useMemo(() => ({
x: new THREE.Plane(new THREE.Vector3(1, 0, 0), 0),
y: new THREE.Plane(new THREE.Vector3(0, 1, 0), 0),
z: new THREE.Plane(new THREE.Vector3(0, 0, 1), 0),
}), []);
useEffect(() => {
// Configure each plane: keep the half-space on the positive side of the
// normal. Inverting flips the normal direction.
(['x', 'y', 'z'] as const).forEach((axis) => {
const plane = planes[axis];
const sign = invert[axis] ? -1 : 1;
plane.normal.set(
axis === 'x' ? sign : 0,
axis === 'y' ? sign : 0,
axis === 'z' ? sign : 0,
);
// For plane equation n·p + d = 0, keeping `n·p >= level*sign_axis` means
// d = -level when normal is +axis, d = +level when normal is -axis.
plane.constant = -sign * level[axis];
});
const active: THREE.Plane[] = [];
if (enabled.x) active.push(planes.x);
if (enabled.y) active.push(planes.y);
if (enabled.z) active.push(planes.z);
const groups = getAllModelLocalGroups();
const touched: THREE.Material[] = [];
const apply = (clipping: THREE.Plane[]) => {
for (const g of groups) {
g.traverse((obj) => {
if (!(obj instanceof THREE.Mesh)) return;
const mats = Array.isArray(obj.material) ? obj.material : [obj.material];
for (const mat of mats) {
if (!mat) continue;
mat.clippingPlanes = clipping;
mat.clipShadows = true;
mat.needsUpdate = true;
touched.push(mat);
}
});
}
};
apply(active);
return () => {
// Cleanup: clear clipping from any material we touched.
for (const mat of touched) {
mat.clippingPlanes = [];
mat.needsUpdate = true;
}
};
}, [enabled.x, enabled.y, enabled.z, invert, invert.x, invert.y, invert.z, level, level.x, level.y, level.z, planes]);
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;
}
function WalkControls() {
const walkMode = useModelStore(s => s.walkMode);
const setWalkMode = useModelStore(s => s.setWalkMode);
const gridY = useModelStore(s => s.gridY);
const { camera } = useThree();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const controlsRef = useRef<any>(null);
const keys = useRef({ w: false, a: false, s: false, d: false, shift: false });
const initialized = useRef(false);
useEffect(() => {
if (walkMode && !initialized.current) {
camera.position.set(0, gridY + 1.7, 5);
// Give the camera an initial rotation looking forward
camera.rotation.set(0, 0, 0);
initialized.current = true;
} else if (!walkMode) {
initialized.current = false;
}
}, [walkMode, camera, gridY]);
useEffect(() => {
if (walkMode) {
// Small timeout to allow canvas to render and be clickable
const timer = setTimeout(() => {
try { controlsRef.current?.lock(); } catch (e) { /* ignore */ }
}, 100);
return () => clearTimeout(timer);
}
}, [walkMode]);
useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => {
if (e.code === 'KeyW') keys.current.w = true;
if (e.code === 'KeyA') keys.current.a = true;
if (e.code === 'KeyS') keys.current.s = true;
if (e.code === 'KeyD') keys.current.d = true;
if (e.code === 'ShiftLeft' || e.code === 'ShiftRight') keys.current.shift = true;
};
const onKeyUp = (e: KeyboardEvent) => {
if (e.code === 'KeyW') keys.current.w = false;
if (e.code === 'KeyA') keys.current.a = false;
if (e.code === 'KeyS') keys.current.s = false;
if (e.code === 'KeyD') keys.current.d = false;
if (e.code === 'ShiftLeft' || e.code === 'ShiftRight') keys.current.shift = false;
};
window.addEventListener('keydown', onKeyDown);
window.addEventListener('keyup', onKeyUp);
return () => {
window.removeEventListener('keydown', onKeyDown);
window.removeEventListener('keyup', onKeyUp);
};
}, []);
useFrame((_, dt) => {
if (!walkMode) return;
const speed = keys.current.shift ? 6.0 : 2.5;
const dir = new THREE.Vector3();
// Front vector (ignoring pitch to walk purely horizontally)
const front = new THREE.Vector3(0, 0, -1).applyQuaternion(camera.quaternion);
front.y = 0;
if (front.lengthSq() > 0.001) front.normalize();
// Right vector
const right = new THREE.Vector3(1, 0, 0).applyQuaternion(camera.quaternion);
right.y = 0;
if (right.lengthSq() > 0.001) right.normalize();
if (keys.current.w) dir.add(front);
if (keys.current.s) dir.sub(front);
if (keys.current.a) dir.sub(right);
if (keys.current.d) dir.add(right);
if (dir.lengthSq() > 0) {
dir.normalize();
camera.position.addScaledVector(dir, speed * dt);
}
// Always stick to exactly 1.7m above ground
camera.position.y = gridY + 1.7;
});
if (!walkMode) return null;
return (
<PointerLockControls
ref={controlsRef}
onUnlock={() => {
setWalkMode(false);
}}
/>
);
}
function WalkEnvironment() {
const walkMode = useModelStore(s => s.walkMode);
const gridY = useModelStore(s => s.gridY);
if (!walkMode) return null;
return (
<group>
{/* Realistic blue sky with some clouds/haze */}
<Sky sunPosition={[100, 20, 100]} turbidity={0.1} rayleigh={0.5} mieCoefficient={0.005} mieDirectionalG={0.8} />
{/* Background HDRI provides distant trees, mountains, houses on the horizon */}
<Environment preset="park" background />
{/* Infinite walkable grass floor */}
<mesh rotation={[-Math.PI / 2, 0, 0]} position={[0, gridY - 0.01, 0]} receiveShadow>
<planeGeometry args={[1000, 1000]} />
<meshStandardMaterial color="#4ade80" roughness={0.9} metalness={0.1} />
</mesh>
</group>
);
}
export function ModelViewerCanvas({ url }: ModelViewerProps) {
const positionMode = useModelStore((s) => s.positionMode);
const measureMode = useModelStore((s) => s.measureMode);
const selectionMode = useModelStore((s) => s.selectionMode);
const walkMode = useModelStore((s) => s.walkMode);
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));
gl.localClippingEnabled = true;
}}
>
<CameraSwitcher />
<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 />
<GridCalibrationHandler />
<SectionClippingApplier />
<PositionDragHandler />
<SelectionHandler />
<VisibilityApplier />
<SceneRefCapture />
<ViewCubeAnimator />
<WalkControls />
<WalkEnvironment />
<OrbitControls
makeDefault
enableDamping
dampingFactor={0.1}
minDistance={0.05}
maxDistance={50}
minZoom={5}
maxZoom={5000}
enabled={!positionMode && !walkMode}
ref={(c: unknown) => {
mainControlsRef.current = c;
}}
/>
</Canvas>
);
}