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 (
);
}
return (
);
}
function LoadingFallback() {
return (
Carregando modelo…
);
}
function GLBModel({ sceneModel, isActive }: { sceneModel: SceneModel; isActive: boolean }) {
const [rawScene, setRawScene] = useState(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(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