Files
anatomia/data-pipeline/scripts/extract-manifest.mjs
T

348 lines
15 KiB
JavaScript

// Lê os .glb convertidos do FBX real (Z-Anatomy), recolore por sistema,
// garante nomes de nó únicos por arquivo (Z-Anatomy tem estruturas
// repetidas — costelas, vértebras, falanges — que podem compartilhar o
// mesmo nome bruto) e gera:
// - data-pipeline/seed/models/<arquivo>.glb (final, servido pela API)
// - data-pipeline/seed/manifest-real.json (entradas para o seed do banco)
import { NodeIO } from "@gltf-transform/core";
import { ALL_EXTENSIONS } from "@gltf-transform/extensions";
import { prune, dedup, weld, simplify } from "@gltf-transform/functions";
import { MeshoptSimplifier } from "meshoptimizer";
import { writeFileSync, mkdirSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { SOURCES, SOURCE_TARGETS, RESPIRATORY_KEYWORDS } from "./sources.mjs";
import { translateToPt } from "./translate-pt.mjs";
import { SYSTEMS } from "../seed/systems.mjs";
import { SKELETAL_BONES } from "./skeletal-whitelist.mjs";
import { REGION_DESCRIPTIONS } from "./region-descriptions.mjs";
import { BONE_DESCRIPTIONS } from "./bone-descriptions.mjs";
import { MUSCLE_DESCRIPTIONS } from "./muscle-descriptions.mjs";
import { NERVE_DESCRIPTIONS } from "./nerve-descriptions.mjs";
await MeshoptSimplifier.ready;
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const CONVERTED_DIR = path.resolve(__dirname, "../converted");
const MODELS_OUT_DIR = path.resolve(__dirname, "../seed/models");
mkdirSync(MODELS_OUT_DIR, { recursive: true });
const systemBySlug = new Map(SYSTEMS.map((s) => [s.slug, s]));
function hexToRgb(hex) {
const n = parseInt(hex.replace("#", ""), 16);
return [((n >> 16) & 255) / 255, ((n >> 8) & 255) / 255, (n & 255) / 255, 1];
}
const COMBINING_MARKS = new RegExp("[\\u0300-\\u036f]", "g");
function slugify(s) {
return s
.normalize("NFD")
.replace(COMBINING_MARKS, "") // remove diacríticos (á -> a, ç -> c, ...)
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
}
// O arquivo esquelético real do Z-Anatomy mistura ossos de verdade com
// centenas de "acidentes ósseos" (pontos de referência NO osso, ex:
// "Deltoid tuberosity" — não são ossos) e marcadores de origem/inserção
// muscular (ex: "Pectoralis major muscle.ol" — pertencem ao músculo).
// Por isso usamos uma lista de INCLUSÃO com os 206 ossos reais em vez de
// aceitar tudo: só o que bate exatamente vira uma estrutura esquelética.
const SKELETAL_ALIASES = { atlas: "Atlas (C1)", axis: "Axis (C2)" };
const skeletalLookup = new Map(
Object.entries(SKELETAL_BONES).map(([key, info]) => [
key.toLowerCase(),
{ canonicalKey: key, ...info },
]),
);
function stripSideSuffix(rawName) {
const m = rawName.match(/^(.*)\.([lrij])$/i);
if (m) return { base: m[1].trim(), suffix: m[2].toLowerCase() };
return { base: rawName.trim(), suffix: null };
}
// Retorna null se o nome bruto não corresponder a nenhum dos 206 ossos —
// nesse caso a malha é ignorada (não vira estrutura, mas continua no
// arquivo .glb; como não há "structure" apontando pra ela, o frontend já
// a mantém oculta automaticamente).
function resolveSkeletalBone(rawName) {
const { base, suffix } = stripSideSuffix(rawName);
const normBase = base.toLowerCase();
const aliasTarget = SKELETAL_ALIASES[normBase];
const lookupKey = (aliasTarget ?? base).toLowerCase();
const entry = skeletalLookup.get(lookupKey);
if (!entry) return null;
if (entry.paired) {
// Ignora variantes ".i"/".j"/sem sufixo para ossos pareados — o
// Z-Anatomy parece manter cópias duplicadas (visão isolada vs.
// esqueleto montado); ".l"/".r" é a marcação inequívoca de lado.
if (suffix !== "l" && suffix !== "r") return null;
return { canonicalKey: entry.canonicalKey, pt: entry.pt, side: suffix };
}
return { canonicalKey: entry.canonicalKey, pt: entry.pt, side: "single" };
}
// "Sistema Nervoso" e "Regiões do corpo" não têm uma lista de exclusão
// como o esquelético (206 ossos) — lá, QUALQUER malha nomeada vira uma
// estrutura. O problema: o Z-Anatomy original usa "linhas de chamada"
// (pontos apontando de uma estrutura pra um rótulo de texto, como nos
// diagramas didáticos) que às vezes são a PRÓPRIA malha da estrutura (não
// dá pra filtrar por nome — o usuário confirmou que clicar numa dessas
// linhas resolve num nome real, ex: "Right Anterior region of forearm").
// Em vez de nome, detectamos pela GEOMETRIA: uma linha de chamada é um
// segmento reto e fino (baixíssimo desvio perpendicular ao seu próprio
// eixo mais longo) — bem diferente de uma estrutura anatômica de verdade
// (mesmo um nervo fino tem curvas/ramificações reais). Isso não depende
// de nome nenhum, então não arrisca excluir a estrutura errada.
const LEADER_LINE_SOURCE_KEYS = new Set(["nervous", "regions"]);
function isLeaderLineMesh(mesh) {
try {
const prims = mesh.listPrimitives();
if (prims.length === 0) return false;
return prims.every((prim) => isLeaderLinePrimitive(prim));
} catch {
return false;
}
}
function isLeaderLinePrimitive(prim) {
const accessor = prim.getAttribute("POSITION");
if (!accessor) return false;
const count = accessor.getCount();
if (count < 2) return false;
const arr = accessor.getArray();
if (!arr) return false;
const at = (i) => [arr[i * 3], arr[i * 3 + 1], arr[i * 3 + 2]];
const dist2 = (a, b) => {
const dx = a[0] - b[0], dy = a[1] - b[1], dz = a[2] - b[2];
return dx * dx + dy * dy + dz * dz;
};
// Acha os dois pontos mais distantes entre si (técnica padrão e barata
// pra aproximar o "eixo" mais longo de uma nuvem de pontos): parte de
// um ponto qualquer, acha o mais distante dele (a), depois o mais
// distante de "a" (b) — "a" e "b" aproximam as duas pontas do objeto.
let a = at(0), bestD = 0;
for (let i = 1; i < count; i++) {
const d = dist2(a, at(i));
if (d > bestD) { bestD = d; a = at(i); }
}
let b = a; bestD = 0;
for (let i = 0; i < count; i++) {
const d = dist2(a, at(i));
if (d > bestD) { bestD = d; b = at(i); }
}
const axisLenSq = dist2(a, b);
const axisLen = Math.sqrt(axisLenSq);
if (axisLen < 0.02) return false; // curto demais pra ser uma linha de chamada (poste real de âncora)
const ax = [(b[0] - a[0]) / axisLen, (b[1] - a[1]) / axisLen, (b[2] - a[2]) / axisLen];
let maxPerp = 0;
for (let i = 0; i < count; i++) {
const p = at(i);
const v = [p[0] - a[0], p[1] - a[1], p[2] - a[2]];
const t = v[0] * ax[0] + v[1] * ax[1] + v[2] * ax[2];
const perp2 =
(v[0] - t * ax[0]) ** 2 + (v[1] - t * ax[1]) ** 2 + (v[2] - t * ax[2]) ** 2;
if (perp2 > maxPerp) maxPerp = perp2;
}
// "Linha reta e fina": todos os pontos ficam muito perto do próprio eixo
// mais longo (desvio perpendicular pequeno comparado ao comprimento).
return Math.sqrt(maxPerp) < axisLen * 0.08;
}
function classifySystem(key, rawName) {
const target = SOURCE_TARGETS[key];
if (target.system) return target.system;
// Só o "visceral" tem system === null: decide por palavra-chave.
const lower = rawName.toLowerCase();
return RESPIRATORY_KEYWORDS.some((kw) => lower.includes(kw)) ? "respiratorio" : "visceral";
}
const io = new NodeIO().registerExtensions(ALL_EXTENSIONS);
const manifest = [];
let totalStructures = 0;
for (const { fbx, key } of SOURCES) {
const inPath = path.join(CONVERTED_DIR, `${key}.glb`);
const target = SOURCE_TARGETS[key];
console.log(`Processando ${fbx} (${key})...`);
const doc = await io.read(inPath);
const root = doc.getRoot();
const scene = root.getDefaultScene() ?? root.listScenes()[0];
if (!scene) {
console.warn(` ⚠ nenhuma cena encontrada em ${inPath}, pulando.`);
continue;
}
// Um material por sistema alvo deste arquivo (recoloração consistente,
// independente de como a FBX/Unity original colorizava as malhas).
const materialCache = new Map();
function materialFor(systemSlug) {
if (materialCache.has(systemSlug)) return materialCache.get(systemSlug);
const sys = systemBySlug.get(systemSlug);
const mat = doc
.createMaterial(systemSlug)
.setBaseColorFactor(hexToRgb(sys?.colorHex ?? "#cccccc"))
.setRoughnessFactor(0.55)
.setMetallicFactor(0.05);
materialCache.set(systemSlug, mat);
return mat;
}
const nameCounts = new Map(); // dedupe de nomes brutos duplicados no MESMO arquivo
const includedBones = new Set(); // dedupe de osso+lado no sistema esquelético
let countThisFile = 0;
function visit(node) {
const mesh = node.getMesh();
if (mesh) {
const rawName = (node.getName() || mesh.getName() || "Estrutura sem nome").trim();
const n = (nameCounts.get(rawName) ?? 0) + 1;
nameCounts.set(rawName, n);
const uniqueMeshName = n === 1 ? rawName : `${rawName} (${n})`;
node.setName(uniqueMeshName);
let systemSlug, namePt, nameEn, description;
if (key === "skeletal") {
const resolved = resolveSkeletalBone(rawName);
const dedupeKey = resolved ? `${resolved.canonicalKey}::${resolved.side}` : null;
if (!resolved || includedBones.has(dedupeKey)) {
// Marca explicitamente como excluído (não é um dos 206 ossos —
// é "acidente ósseo"/marcador de origem-inserção muscular ou,
// como no caso das linhas de anotação/ponteiro de rótulo do
// Z-Anatomy original, um objeto sem relação nenhuma com a malha
// do osso, só aninhado dentro dela na hierarquia da FBX). Sem
// essa marca, o frontend (SystemModel.resolveStructure) sobe
// pelos pais à procura de um slug e ACHA o do osso-pai,
// "herdando" visibilidade de algo que devia ficar sempre oculto.
node.setExtras({ excluded: true });
for (const child of node.listChildren()) visit(child);
return;
}
includedBones.add(dedupeKey);
systemSlug = "esqueletico";
const sideLabel = resolved.side === "l" ? " Esquerdo" : resolved.side === "r" ? " Direito" : "";
const sideWord = resolved.side === "l" ? "Left " : resolved.side === "r" ? "Right " : "";
namePt = `${resolved.pt}${sideLabel}`;
nameEn = `${sideWord}${resolved.canonicalKey}`;
description = BONE_DESCRIPTIONS[resolved.canonicalKey] ?? "";
} else if (LEADER_LINE_SOURCE_KEYS.has(key) && isLeaderLineMesh(mesh)) {
// Segmento reto e fino (linha de chamada de rótulo do app
// original) — mesmo tendo um nome "real", não é uma estrutura
// anatômica visível/clicável de verdade. Marcamos excluído (não
// vira estrutura, mesh fica no .glb mas oculto) do mesmo jeito
// que o esquelético já faz pros seus "acidentes ósseos".
node.setExtras({ excluded: true });
for (const child of node.listChildren()) visit(child);
return;
} else {
systemSlug = classifySystem(key, rawName);
// Nomes de muscular/regiões/referências vêm com sufixo ".l"/".r"/
// ".j" (lateralidade) em vez da palavra "Left"/"Right" — sem tirar
// isso antes, translateToPt() nunca batia com o dicionário (o
// sufixo ficava colado no nome) e tudo caía pro fallback em inglês.
const { base, suffix } = stripSideSuffix(rawName);
const sideLabelPt = suffix === "l" ? " Esquerdo" : suffix === "r" ? " Direito" : "";
const sideWordEn = suffix === "l" ? "Left " : suffix === "r" ? "Right " : "";
namePt = `${translateToPt(base)}${sideLabelPt}` + (n > 1 ? ` (${n})` : "");
nameEn = `${sideWordEn}${base}` + (n > 1 ? ` (${n})` : "");
description =
key === "regions"
? REGION_DESCRIPTIONS[base.toLowerCase()] ?? ""
: key === "muscular"
? MUSCLE_DESCRIPTIONS[base.toLowerCase()] ?? ""
: key === "nervous"
? NERVE_DESCRIPTIONS[base.toLowerCase()] ?? ""
: "";
}
const material = materialFor(systemSlug);
for (const prim of mesh.listPrimitives()) prim.setMaterial(material);
const sys = systemBySlug.get(systemSlug);
const slugPart = slugify(key === "skeletal" ? namePt : uniqueMeshName) || `sem-nome-${totalStructures}`;
const slug = `${systemSlug}-${slugPart}`;
// Gravamos o slug como "extras" do nó (→ vira object.userData no
// three.js) em vez de confiar só no "name": nós com mais de um
// material viram um Group + sub-Meshes com nomes gerados pelo
// GLTFLoader, diferentes do nome original — extras não sofre isso.
node.setExtras({ slug });
manifest.push({
system: systemSlug,
slug,
namePt,
nameEn,
synonyms: [],
description,
regionPath: [sys?.namePt ?? systemSlug],
modelFile: target.modelFile,
meshName: uniqueMeshName,
});
countThisFile += 1;
totalStructures += 1;
}
for (const child of node.listChildren()) visit(child);
}
for (const topNode of scene.listChildren()) visit(topNode);
console.log(` ${countThisFile} estruturas nomeadas encontradas.`);
if (countThisFile === 0) {
console.warn(` ⚠ nenhuma malha nomeada encontrada em ${fbx} — confira o log do assimp/conversão.`);
}
if (key === "skeletal") {
const missing = [];
for (const [boneKey, info] of Object.entries(SKELETAL_BONES)) {
const sides = info.paired ? ["l", "r"] : ["single"];
for (const side of sides) {
if (!includedBones.has(`${boneKey}::${side}`)) missing.push(`${boneKey} (${side})`);
}
}
console.log(` → ${includedBones.size}/206 ossos da lista de referência encontrados.`);
if (missing.length > 0) {
console.warn(` ⚠ ${missing.length} ossos da lista NÃO encontrados no arquivo:`);
for (const m of missing) console.warn(` - ${m}`);
}
}
// Malhas do Z-Anatomy vêm pensadas para um app desktop (Unity), com
// densidade de polígonos bem maior do que um visualizador web aguenta
// com dezenas de estruturas na tela ao mesmo tempo. MAS: muitas das
// ~2000 estruturas são objetos pequenos (ossos individuais, contornos
// de inserção muscular) com poucos triângulos desde o início — um
// ratio agressivo (testamos 0.15) colapsa esses objetos pequenos a
// quase nada, embora preserve bem os objetos grandes. 0.5 é bem mais
// conservador: ainda reduz o tamanho dos objetos grandes (que dominam
// o tamanho do arquivo), mas praticamente não afeta os pequenos.
await doc.transform(
weld(),
simplify({ simplifier: MeshoptSimplifier, ratio: 0.5, error: 0.01 }),
prune(),
dedup(),
);
const outPath = path.join(MODELS_OUT_DIR, target.modelFile);
await io.write(outPath, doc);
console.log(` ✓ ${target.modelFile} salvo em seed/models/`);
}
writeFileSync(
path.resolve(__dirname, "../seed/manifest-real.json"),
JSON.stringify({ structures: manifest }, null, 2),
);
console.log(`\nTotal: ${totalStructures} estruturas reais extraídas do Z-Anatomy.`);