177 lines
6.4 KiB
TypeScript
177 lines
6.4 KiB
TypeScript
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<ClipAxis, { normal: [number, number, number]; min: number; max: number }> = {
|
|
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 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;
|
|
}
|
|
|
|
function CameraController({ modelFiles }: { modelFiles: string[] }) {
|
|
const controlsRef = useRef<CameraControls>(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 = computeVisibleBoundingBox(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 = 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]);
|
|
|
|
return (
|
|
<CameraControls
|
|
ref={controlsRef}
|
|
makeDefault
|
|
minDistance={0.001}
|
|
maxDistance={1000000}
|
|
/>
|
|
);
|
|
}
|
|
|
|
export function Scene({ bySlug }: { bySlug: Map<string, Structure> }) {
|
|
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<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)))
|
|
.map(([file]) => file),
|
|
[structuresByFile, visibleSystems],
|
|
);
|
|
|
|
return (
|
|
<Canvas
|
|
shadows
|
|
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}
|
|
onSelect={(slug) => {
|
|
const structure = bySlug.get(slug);
|
|
if (structure) selectStructure(structure.id);
|
|
}}
|
|
/>
|
|
))}
|
|
</Suspense>
|
|
<Grid
|
|
position={[0, 0, 0]}
|
|
args={[4, 4]}
|
|
cellColor="#1e293b"
|
|
sectionColor="#334155"
|
|
fadeDistance={6}
|
|
infiniteGrid
|
|
/>
|
|
<CameraController modelFiles={modelFiles} />
|
|
</Canvas>
|
|
);
|
|
}
|