import { Suspense, useEffect, useMemo, useRef } from "react"; import { Canvas, useThree } from "@react-three/fiber"; import { CameraControls, Grid } from "@react-three/drei"; import * as THREE from "three"; import { useAtlasStore, type ClipAxis } from "../../store/useAtlasStore.js"; import { SystemModel } from "./SystemModel.js"; import type { Structure } from "../../lib/api.js"; const AXIS_CONFIG: Record = { sagittal: { normal: [1, 0, 0], min: -0.4, max: 0.4 }, coronal: { normal: [0, 0, 1], min: -0.3, max: 0.3 }, transverse: { normal: [0, -1, 0], min: -1.8, max: 0 }, }; function useClippingPlanes(): THREE.Plane[] { const { enabled, axis, position } = useAtlasStore((s) => s.clipping); return useMemo(() => { if (!enabled) return []; const cfg = AXIS_CONFIG[axis]; const t = (position + 1) / 2; // normalize -1..1 -> 0..1 const constant = cfg.min + t * (cfg.max - cfg.min); const normal = new THREE.Vector3(...cfg.normal); return [new THREE.Plane(normal, -constant)]; }, [enabled, axis, position]); } function CameraController({ modelFiles }: { modelFiles: string[] }) { const controlsRef = useRef(null); const { scene, camera } = useThree(); const focusRequest = useAtlasStore((s) => s.focusRequest); // Malhas reais (Z-Anatomy) não seguem a mesma escala/origem que os // modelos esquemáticos antigos (podem vir em cm, ou centralizadas em // outro ponto) — em vez de assumir uma posição de câmera fixa, sempre // que o conjunto de arquivos carregados muda, enquadra a câmera no // bounding box real de tudo que está na cena. useEffect(() => { if (!controlsRef.current || modelFiles.length === 0) return; const id = requestAnimationFrame(() => { const box = new THREE.Box3().setFromObject(scene); if (box.isEmpty()) return; const size = box.getSize(new THREE.Vector3()).length(); // Ajusta os planos de corte da câmera ao tamanho real do conteúdo — // um "far" fixo pequeno cortaria (tornaria invisível) um modelo bem // maior do que o esperado. camera.near = Math.max(size / 1000, 0.001); camera.far = size * 100; if (camera instanceof THREE.PerspectiveCamera) camera.updateProjectionMatrix(); controlsRef.current?.fitToBox(box, true, { paddingLeft: 0.2, paddingRight: 0.2, paddingTop: 0.2, paddingBottom: 0.2, }); }); return () => cancelAnimationFrame(id); }, [modelFiles, scene, camera]); useEffect(() => { if (!focusRequest || !controlsRef.current) return; const obj = scene.getObjectByName(focusRequest.slug); if (!obj) return; const box = new THREE.Box3().setFromObject(obj); if (box.isEmpty()) return; controlsRef.current.fitToBox(box, true, { paddingLeft: 0.4, paddingRight: 0.4, paddingTop: 0.4, paddingBottom: 0.4, }); }, [focusRequest, scene]); return ( ); } export function Scene({ bySlug }: { bySlug: Map }) { const visibleSystems = useAtlasStore((s) => s.visibleSystems); const selectedStructureId = useAtlasStore((s) => s.selectedStructureId); const selectStructure = useAtlasStore((s) => s.selectStructure); const clippingPlanes = useClippingPlanes(); const selectedSlug = selectedStructureId ? [...bySlug.values()].find((s) => s.id === selectedStructureId)?.slug ?? null : null; // Um arquivo .glb pode conter estruturas de mais de um sistema (ex: o // "visceral-organs.glb" tem estruturas de "respiratorio" e "visceral"), // então carregamos qualquer arquivo que tenha ao menos uma estrutura de // um sistema visível — a visibilidade por malha é resolvida dentro de // cada SystemModel. const structuresByFile = useMemo(() => { const structuresList = [...bySlug.values()]; const map = new Map(); for (const s of structuresList) { if (!map.has(s.modelFile)) map.set(s.modelFile, []); map.get(s.modelFile)!.push(s); } return map; }, [bySlug]); const modelFiles = useMemo( () => [...structuresByFile.entries()] .filter(([, items]) => items.some((s) => visibleSystems.has(s.system.slug))) .map(([file]) => file), [structuresByFile, visibleSystems], ); return ( {modelFiles.map((modelFile) => ( { const structure = bySlug.get(slug); if (structure) selectStructure(structure.id); }} /> ))} ); }