392 lines
16 KiB
TypeScript
392 lines
16 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 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<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]);
|
|
}
|
|
|
|
|
|
// Â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<CameraControls>(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 <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);
|
|
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<THREE.Box3 | null>(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<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();
|
|
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 (
|
|
<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/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<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
|
|
// three.js removeu PCFSoftShadowMap nesta versão (era o default do
|
|
// R3F pra `shadows={true}`) — sem isso, caía silenciosamente pro
|
|
// PCFShadowMap (sombra de borda dura). VSMShadowMap é o substituto
|
|
// recomendado pra sombra suave.
|
|
shadows={{ type: THREE.VSMShadowMap }}
|
|
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} />
|
|
<IntercostalLabels bySlug={bySlug} />
|
|
{children}
|
|
</Canvas>
|
|
);
|
|
}
|