1516 lines
54 KiB
TypeScript
1516 lines
54 KiB
TypeScript
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>
|
||
);
|
||
}
|