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 ClipAxis } from "../../store/useAtlasStore.js"; import { SystemModel } from "./SystemModel.js"; import { IntercostalLabels } from "./IntercostalLabels.js"; import type { Structure } from "../../lib/api.js"; import { computeVisibleBoundingBox, regionSubBox } from "../../lib/bodyRegionBox.js"; // Sistemas sem .glb pra carregar — tegumentar/histologia são // fotomicrografias 2D reais com hotspots (ver SkinDiagram.tsx e // MicroscopyDiagram.tsx) e citologia é um modelo 3D ilustrativo desenhado // à mão em Three.js (ver CellDiagram3D.tsx), não uma malha carregada de // arquivo. const NON_3D_SYSTEMS = new Set(["tegumentar", "histologia", "citologia"]); // 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]); } // Ângulo polar "de pé, olhando na horizontal" (não de cima nem de baixo) — // usado tanto pro enquadramento inicial quanto pelos botões Anterior/ // Posterior, pra sempre ancorar numa vista de frente/costas limpa em vez de // um ângulo 3/4 arbitrário. const EYE_LEVEL_POLAR = Math.PI / 2; const ANTERIOR_AZIMUTH = 0; const POSTERIOR_AZIMUTH = Math.PI; 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 viewAngleRequest = useAtlasStore((s) => s.viewAngleRequest); const setContentBox = useAtlasStore((s) => s.setContentBox); // focusRequest/regionFocusRequest moram no store GLOBAL (compartilhado // entre páginas) — se o usuário clicou "Cabeça" no Atlas e depois foi pro // Exame Físico, um novo montaria aqui e replayaria esse clique // antigo, zoomando numa região errada sem o usuário ter clicado em nada // nesta página. Guardamos o nonce já visto NA MONTAGEM (valor inicial do // useRef) pra só reagir a cliques que aconteçam DEPOIS que esta instância // da câmera já existe. const seenFocusNonce = useRef(focusRequest?.nonce); const seenRegionNonce = useRef(regionFocusRequest?.nonce); const seenViewAngleNonce = useRef(viewAngleRequest?.nonce); // Último box enquadrado (corpo inteiro, região ou estrutura) — os botões // Anterior/Posterior giram a câmera em torno DESSE enquadramento, não // sempre do corpo inteiro, senão trocar de lado enquanto o usuário está // olhando uma região específica (ex: "Pelve") chutaria o zoom pra fora. const lastFitBoxRef = useRef(null); // 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. // // O carregamento do .glb é assíncrono (Suspense) e pode levar bem mais // que 1 frame — um único requestAnimationFrame às vezes media a cena // ainda vazia/parcial (só 1-2 ossos montados) e nunca corrige depois, // porque "modelFiles" não muda de novo uma vez carregado. Por isso agora // fica tentando de novo a cada 200ms até o tamanho do conteúdo ficar // ESTÁVEL (duas leituras seguidas iguais = terminou de montar), só então // enquadra a câmera. useEffect(() => { if (modelFiles.length === 0) return; let cancelled = false; let attempts = 0; let lastSize = -1; let timer: ReturnType; function tryFit() { if (cancelled || !controlsRef.current) return; const box = computeVisibleBoundingBox(scene); attempts += 1; if (box.isEmpty()) { if (attempts < 50) timer = setTimeout(tryFit, 200); return; } const size = box.getSize(new THREE.Vector3()).length(); const stable = Math.abs(size - lastSize) < lastSize * 0.01; lastSize = size; if (!stable && attempts < 50) { timer = setTimeout(tryFit, 200); return; } setContentBox({ min: [box.min.x, box.min.y, box.min.z], max: [box.max.x, box.max.y, box.max.z], }); // 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(); lastFitBoxRef.current = box.clone(); // fitToBox arredonda o ângulo ATUAL da câmera pro eixo mais próximo // (frente/trás/lado/cima/baixo) em vez de manter um 3/4 arbitrário — // na carga inicial isso dependia de quão perto a posição default // [1.2, 1.4, 1.6] ficava de um múltiplo de 90°, o que podia (ex: após // qualquer ajuste futuro nesse valor) arredondar pra uma vista de // cima/baixo e enquadrar só os pés ou só a cabeça. Fixamos o ângulo // explicitamente numa vista frontal, na altura dos olhos, ANTES do // primeiro fitToBox, pra carga inicial ser sempre determinística. controlsRef.current.rotateTo(ANTERIOR_AZIMUTH, EYE_LEVEL_POLAR, false); controlsRef.current.fitToBox(box, false, { paddingLeft: 0.2, paddingRight: 0.2, paddingTop: 0.2, paddingBottom: 0.2, }); } const id = requestAnimationFrame(tryFit); return () => { cancelled = true; cancelAnimationFrame(id); clearTimeout(timer); }; }, [modelFiles, scene, camera, setContentBox]); useEffect(() => { if (!focusRequest || !controlsRef.current) return; if (focusRequest.nonce === seenFocusNonce.current) return; seenFocusNonce.current = focusRequest.nonce; const obj = scene.getObjectByName(focusRequest.slug); if (!obj) return; const box = computeVisibleBoundingBox(obj); if (box.isEmpty()) return; lastFitBoxRef.current = box.clone(); 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; if (regionFocusRequest.nonce === seenRegionNonce.current) return; seenRegionNonce.current = regionFocusRequest.nonce; const total = computeVisibleBoundingBox(scene); if (total.isEmpty()) return; const box = regionSubBox(total, regionFocusRequest.region); if (box.isEmpty()) return; lastFitBoxRef.current = box.clone(); controlsRef.current.fitToBox(box, true, { paddingLeft: 0.3, paddingRight: 0.3, paddingTop: 0.3, paddingBottom: 0.3, }); }, [regionFocusRequest, scene]); // Vista Anterior/Posterior — gira a câmera 180° em torno do último // enquadramento (corpo inteiro, região ou estrutura), sem perder o zoom // atual, pra comparar a face anterior e posterior de uma mesma parte. useEffect(() => { if (!viewAngleRequest || !controlsRef.current) return; if (viewAngleRequest.nonce === seenViewAngleNonce.current) return; seenViewAngleNonce.current = viewAngleRequest.nonce; const box = lastFitBoxRef.current ?? computeVisibleBoundingBox(scene); if (box.isEmpty()) return; const azimuth = viewAngleRequest.angle === "anterior" ? ANTERIOR_AZIMUTH : POSTERIOR_AZIMUTH; controlsRef.current.rotateTo(azimuth, EYE_LEVEL_POLAR, true); controlsRef.current.fitToBox(box, true, { paddingLeft: 0.25, paddingRight: 0.25, paddingTop: 0.25, paddingBottom: 0.25, }); }, [viewAngleRequest, scene]); return ( ); } // Ossos finos (costela vista de perfil, falange, etc.) são fáceis de // "errar" clicando — o raio único do clique padrão do three.js precisa // acertar exatamente um triângulo da malha. Em vez de um único raio no // pixel exato do clique, lançamos vários raios num pequeno raio ao redor // dele (como um "dedo gordo") e ficamos com o resultado válido mais // próximo da câmera — bem mais tolerante sem precisar mexer na geometria. const CLICK_SAMPLE_OFFSETS: [number, number][] = [ [0, 0], [5, 0], [-5, 0], [0, 5], [0, -5], [4, 4], [-4, 4], [4, -4], [-4, -4], ]; function ClickSelector({ bySlug, onSelect, }: { bySlug: Map; onSelect: (slug: string) => void; }) { const { camera, scene, gl, raycaster } = useThree(); useEffect(() => { const canvas = gl.domElement; const ndc = new THREE.Vector2(); function handleClick(event: PointerEvent) { const rect = canvas.getBoundingClientRect(); let best: { distance: number; slug: string } | null = null; for (const [dx, dy] of CLICK_SAMPLE_OFFSETS) { const x = ((event.clientX + dx - rect.left) / rect.width) * 2 - 1; const y = -((event.clientY + dy - rect.top) / rect.height) * 2 + 1; if (x < -1 || x > 1 || y < -1 || y > 1) continue; ndc.set(x, y); raycaster.setFromCamera(ndc, camera); const hits = raycaster.intersectObjects(scene.children, true); const hit = hits.find((h) => h.object.visible && h.object.name && bySlug.has(h.object.name)); if (hit && (!best || hit.distance < best.distance)) { best = { distance: hit.distance, slug: hit.object.name }; } } if (best) onSelect(best.slug); } canvas.addEventListener("click", handleClick); return () => canvas.removeEventListener("click", handleClick); }, [camera, scene, gl, raycaster, bySlug, onSelect]); return null; } 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/Citologia/Histologia não têm malha 3D (são diagramas 2D // com fotomicrografias reais — ver SkinDiagram.tsx e // MicroscopyDiagram.tsx), então nunca entram na busca de .glb, senão o // GLTFLoader tentaria carregar o modelFile dessas estruturas (que é o // caminho de uma imagem, não um .glb). const structuresList = [...bySlug.values()].filter( (s) => !NON_3D_SYSTEMS.has(s.system.slug), ); 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} ); }