Files
anatomia/frontend/src/components/viewer/Scene.tsx
T

374 lines
14 KiB
TypeScript

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<ClipAxis, [number, number, number]> = {
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<Exclude<BodyRegion, "corpo_inteiro">, [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<CameraControls>(null);
const { scene, camera } = useThree();
const focusRequest = useAtlasStore((s) => s.focusRequest);
const regionFocusRequest = useAtlasStore((s) => s.regionFocusRequest);
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 <Scene> 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);
// 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<typeof setTimeout>;
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();
controlsRef.current.fitToBox(box, true, {
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;
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;
controlsRef.current.fitToBox(box, true, {
paddingLeft: 0.3,
paddingRight: 0.3,
paddingTop: 0.3,
paddingBottom: 0.3,
});
}, [regionFocusRequest, scene]);
return (
<CameraControls
ref={controlsRef}
makeDefault
minDistance={0.001}
maxDistance={1000000}
/>
);
}
// 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<string, Structure>;
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<string, Structure>;
// 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<string, Structure[]>();
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 (
<Canvas
shadows
dpr={[1, 1.5]}
camera={{ position: [1.2, 1.4, 1.6], fov: 45, near: 0.01, far: 100000 }}
gl={{ localClippingEnabled: true }}
>
<color attach="background" args={["#0b1220"]} />
<ambientLight intensity={0.55} />
<directionalLight position={[2, 3, 2]} intensity={1.1} castShadow />
<directionalLight position={[-2, 1, -2]} intensity={0.4} />
<Suspense fallback={null}>
{modelFiles.map((modelFile) => (
<SystemModel
key={modelFile}
modelFile={modelFile}
structuresInFile={structuresByFile.get(modelFile) ?? []}
visibleSystems={visibleSystems}
selectedSlug={selectedSlug}
clippingPlanes={clippingPlanes}
/>
))}
</Suspense>
<ClickSelector
bySlug={bySlug}
onSelect={(slug) => {
const structure = bySlug.get(slug);
if (structure) selectStructure(structure.id);
}}
/>
<Grid
position={[0, 0, 0]}
args={[4, 4]}
cellColor="#1e293b"
sectionColor="#334155"
fadeDistance={6}
infiniteGrid
/>
<CameraController modelFiles={modelFiles} />
{children}
</Canvas>
);
}