import { Suspense, useEffect, useMemo, useRef, type ReactNode } from "react"; import { Canvas, useThree } from "@react-three/fiber"; import { CameraControls, Grid } from "@react-three/drei"; import * as THREE from "three"; import { useAtlasStore, type BodyRegion, type ClipAxis } from "../../store/useAtlasStore.js"; import { SystemModel } from "./SystemModel.js"; import type { Structure } from "../../lib/api.js"; // Normais de referência em anatomia: sagital separa esq./dir., coronal // (= frontal) separa ant./post., transversal separa sup./inf. const AXIS_NORMAL: Record = { sagittal: [1, 0, 0], coronal: [0, 0, 1], transverse: [0, -1, 0], }; // Corte oblíquo = pega a normal de um dos 3 planos-base e inclina em torno // dos outros dois eixos (em graus) — cobre qualquer ângulo, não só os 3 // puros. A posição do corte é sempre relativa ao bounding box REAL do // conteúdo carregado (não a um intervalo fixo chutado), então o corte // atravessa a parte do corpo certa não importa a escala/origem dos dados. function useClippingPlanes(): THREE.Plane[] { const { enabled, axis, position, tiltX, tiltZ } = useAtlasStore((s) => s.clipping); const contentBox = useAtlasStore((s) => s.contentBox); return useMemo(() => { if (!enabled || !contentBox) return []; const normal = new THREE.Vector3(...AXIS_NORMAL[axis]); const euler = new THREE.Euler( THREE.MathUtils.degToRad(tiltX), 0, THREE.MathUtils.degToRad(tiltZ), ); normal.applyEuler(euler).normalize(); const box = new THREE.Box3( new THREE.Vector3(...contentBox.min), new THREE.Vector3(...contentBox.max), ); const center = box.getCenter(new THREE.Vector3()); const size = box.getSize(new THREE.Vector3()); const halfExtent = (Math.abs(normal.x) * size.x + Math.abs(normal.y) * size.y + Math.abs(normal.z) * size.z) / 2; const pointOnPlane = center.clone().addScaledVector(normal, position * halfExtent); const constant = -normal.dot(pointOnPlane); return [new THREE.Plane(normal, constant)]; }, [enabled, axis, position, tiltX, tiltZ, contentBox]); } function isFiniteVec3(v: THREE.Vector3): boolean { return Number.isFinite(v.x) && Number.isFinite(v.y) && Number.isFinite(v.z); } // Une o bounding box só das malhas VISÍVEIS e com geometria válida — // alguns objetos do dataset real (anotações/curvas do Z-Anatomy) podem ter // vértices degenerados/NaN, e Box3.setFromObject(scene) ingenuamente // incluiria isso, distorcendo o enquadramento (ou escondendo tudo o mais // fora do "zoom" resultante). function computeVisibleBoundingBox(root: THREE.Object3D): THREE.Box3 { const union = new THREE.Box3(); const tmp = new THREE.Box3(); root.traverse((obj) => { const mesh = obj as THREE.Mesh; if (!mesh.isMesh || !mesh.visible) return; tmp.setFromObject(mesh); if (tmp.isEmpty()) return; if (!isFiniteVec3(tmp.min) || !isFiniteVec3(tmp.max)) return; union.union(tmp); }); return union; } // Faixas aproximadas de altura (0 = pés, 1 = topo da cabeça) usadas pela // navegação rápida por região — proporções corporais padrão de um adulto // em pé. Regiões dos membros usam a largura toda (os braços ficam nas // bordas laterais dessa faixa de altura). const BODY_REGION_Y_RANGES: Record, [number, number]> = { cabeca: [0.85, 1.0], pescoco: [0.8, 0.87], torax: [0.62, 0.83], abdome: [0.52, 0.65], pelve: [0.42, 0.55], membros_superiores: [0.55, 0.88], membros_inferiores: [0.0, 0.45], }; function regionSubBox(total: THREE.Box3, region: BodyRegion): THREE.Box3 { if (region === "corpo_inteiro") return total; const [t0, t1] = BODY_REGION_Y_RANGES[region]; const yMin = THREE.MathUtils.lerp(total.min.y, total.max.y, t0); const yMax = THREE.MathUtils.lerp(total.min.y, total.max.y, t1); return new THREE.Box3( new THREE.Vector3(total.min.x, yMin, total.min.z), new THREE.Vector3(total.max.x, yMax, total.max.z), ); } function CameraController({ modelFiles }: { modelFiles: string[] }) { const controlsRef = useRef(null); const { scene, camera } = useThree(); const focusRequest = useAtlasStore((s) => s.focusRequest); const regionFocusRequest = useAtlasStore((s) => s.regionFocusRequest); const setContentBox = useAtlasStore((s) => s.setContentBox); // 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 = computeVisibleBoundingBox(scene); if (box.isEmpty()) return; setContentBox({ min: [box.min.x, box.min.y, box.min.z], max: [box.max.x, box.max.y, box.max.z], }); 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, setContentBox]); useEffect(() => { if (!focusRequest || !controlsRef.current) return; const obj = scene.getObjectByName(focusRequest.slug); if (!obj) return; const box = computeVisibleBoundingBox(obj); if (box.isEmpty()) return; controlsRef.current.fitToBox(box, true, { paddingLeft: 0.4, paddingRight: 0.4, paddingTop: 0.4, paddingBottom: 0.4, }); }, [focusRequest, scene]); useEffect(() => { if (!regionFocusRequest || !controlsRef.current) return; const total = computeVisibleBoundingBox(scene); if (total.isEmpty()) return; const box = regionSubBox(total, regionFocusRequest.region); if (box.isEmpty()) return; controlsRef.current.fitToBox(box, true, { paddingLeft: 0.3, paddingRight: 0.3, paddingTop: 0.3, paddingBottom: 0.3, }); }, [regionFocusRequest, scene]); return ( ); } export function Scene({ bySlug, children, alwaysLoadSystems, }: { bySlug: Map; // Ponto de extensão pra conteúdo 3D extra dentro do MESMO Canvas (e do // mesmo contexto do react-three-fiber) — usado pelo módulo de Exame // Físico pra desenhar os pontos de exame em cima do corpo real, sem // duplicar toda a lógica de carregamento de malhas/câmera daqui. children?: ReactNode; // Sistemas cujo .glb deve carregar mesmo com a camada desligada na barra // lateral (fica invisível — mesh.visible continua obedecendo // visibleSystems — mas presente na cena). Usado pelo Exame Físico: os // ossos de referência (esterno, bacia, crânio...) usados pra posicionar // os pontos de exame precisam existir na cena mesmo se o usuário não // marcou "Esquelético". alwaysLoadSystems?: string[]; }) { 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(() => { // Tegumentar não tem malha 3D (é um diagrama 2D — ver SkinDiagram.tsx), // então nunca entra na busca de .glb, senão o GLTFLoader tentaria // carregar o modelFile placeholder ("n/a") dessas estruturas. const structuresList = [...bySlug.values()].filter((s) => s.system.slug !== "tegumentar"); 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) || alwaysLoadSystems?.includes(s.system.slug), ), ) .map(([file]) => file), [structuresByFile, visibleSystems, alwaysLoadSystems], ); return ( {modelFiles.map((modelFile) => ( { const structure = bySlug.get(slug); if (structure) selectStructure(structure.id); }} /> ))} {children} ); }