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

133 lines
5.3 KiB
TypeScript

import { useEffect, useMemo, useRef } from "react";
import { useGLTF } from "@react-three/drei";
import * as THREE from "three";
import { modelUrl, type Structure } from "../../lib/api.js";
interface Props {
modelFile: string;
// Estruturas do banco cujo modelFile é este arquivo — pode conter mais de
// um sistema (ex: o glb "visceral-organs" tem estruturas de "respiratorio"
// e de "visceral" ao mesmo tempo).
structuresInFile: Structure[];
visibleSystems: Set<string>;
selectedSlug: string | null;
clippingPlanes: THREE.Plane[];
}
const HIGHLIGHT = new THREE.Color("#22d3ee");
const BLACK = new THREE.Color(0x000000);
export function SystemModel({
modelFile,
structuresInFile,
visibleSystems,
selectedSlug,
clippingPlanes,
}: Props) {
const { scene } = useGLTF(modelUrl(modelFile));
const clonedRef = useRef<THREE.Group>();
// Nome original de cada objeto (fallback), capturado uma única vez no
// clone — usado apenas se o objeto não tiver o "slug" gravado em
// userData (ver abaixo).
const originalNames = useRef<WeakMap<THREE.Object3D, string>>(new WeakMap());
if (!clonedRef.current) {
const clone = scene.clone(true);
clone.traverse((obj) => {
originalNames.current.set(obj, obj.name);
const mesh = obj as THREE.Mesh;
if (mesh.isMesh && mesh.material) {
mesh.material = (mesh.material as THREE.MeshStandardMaterial).clone();
// Malhas finas tipo lâmina (fáscias, aponeuroses) saem da simplificação
// do pipeline (weld + meshopt) com normais corrompidas — a frente e o
// verso dessas "folhas" quase se tocam, então o weld mistura normais
// opostas num vértice só, gerando aquele efeito de iluminação
// "manchado"/ruidoso. Recalcular a partir da geometria final (já
// soldada) sempre dá um resultado consistente com a malha real.
mesh.geometry.computeVertexNormals();
}
});
clonedRef.current = clone;
}
const root = clonedRef.current!;
const bySlugInFile = useMemo(
() => new Map(structuresInFile.map((s) => [s.slug, s])),
[structuresInFile],
);
const byMeshNameInFile = useMemo(
() => new Map(structuresInFile.map((s) => [s.meshName, s])),
[structuresInFile],
);
// Sobe pelos pais até achar uma correspondência. Preferimos o "slug"
// gravado em userData (extras do glTF) — sobrevive intacto por todo o
// pipeline (assimp, simplificação, GLTFLoader). O nome ("name") é usado
// só como fallback, pois um nó com mais de um material vira um Group
// (com o nome original) + sub-Meshes com nomes gerados pelo loader.
function resolveStructure(obj: THREE.Object3D): Structure | undefined {
let node: THREE.Object3D | null = obj;
while (node) {
const slug = node.userData?.slug as string | undefined;
if (slug) {
const bySlug = bySlugInFile.get(slug);
if (bySlug) return bySlug;
}
// O pipeline marca explicitamente nós que NÃO são um dos 206 ossos
// (acidentes ósseos, marcadores de origem/inserção muscular, linhas
// de anotação do app original) — parar aqui evita que esse nó
// "herde" o slug de um osso-pai ao continuar subindo a árvore.
if (node.userData?.excluded) return undefined;
const name = originalNames.current.get(node);
if (name) {
const byName = byMeshNameInFile.get(name);
if (byName) return byName;
}
node = node.parent;
}
return undefined;
}
useEffect(() => {
root.traverse((obj) => {
// O FBX original do Z-Anatomy tem "espinhos" de anotação (linhas
// apontando pra um rótulo, usadas no app de origem) que não são
// Mesh — são Line/LineSegments/Points. Como eles nunca batem com
// nenhuma das estruturas catalogadas, sempre deveriam ficar
// ocultos, mas um filtro que só olhava "isMesh" deixava passar
// batido (objeto sem dono nunca tinha visible=false setado).
const renderable = obj as THREE.Object3D & {
isMesh?: boolean;
isLine?: boolean;
isLineSegments?: boolean;
isPoints?: boolean;
};
if (!renderable.isMesh && !renderable.isLine && !renderable.isLineSegments && !renderable.isPoints) return;
const mesh = obj as THREE.Mesh;
const structure = resolveStructure(mesh);
if (!structure) {
// Malha sem correspondência no manifesto (não catalogada) — oculta.
mesh.visible = false;
return;
}
mesh.name = structure.slug;
mesh.visible = visibleSystems.has(structure.system.slug);
const mat = mesh.material as THREE.MeshStandardMaterial;
mat.clippingPlanes = clippingPlanes;
mat.clipShadows = true;
const isSelected = structure.slug === selectedSlug;
mat.emissive = isSelected ? HIGHLIGHT : BLACK;
mat.emissiveIntensity = isSelected ? 0.6 : 0;
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [root, bySlugInFile, byMeshNameInFile, visibleSystems, selectedSlug, clippingPlanes]);
// Seleção por clique agora é feita centralizadamente em Scene.tsx
// (ClickSelector) — um "clique gordo" com vários raios em vez de só o
// pixel exato, bem mais tolerante com ossos finos (costela de perfil,
// falange etc.) do que o onClick padrão por malha.
return <primitive object={root} />;
}