133 lines
5.3 KiB
TypeScript
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} />;
|
|
}
|