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

440 lines
20 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.
//
// TENTATIVA DESCARTADA: chegamos a tentar ir além e remover, dentro da
// MESMA malha de uma estrutura real, só o "pedaço" desconectado que fosse
// reto/fino (pra pegar linhas de chamada soldadas junto de uma estrutura
// de verdade). Reverted: uma faixa real de pele fina e comprida (ex: bem
// em cima da coluna) tem a MESMA assinatura geométrica de uma linha de
// chamada (poucos vértices, reta, fina) depois de simplificada — não dá
// pra distinguir as duas só pela forma, e a tentativa abriu buracos de
// verdade na malha (pele sumindo, esqueleto aparecendo por baixo). Fica
// só a classificação por MALHA INTEIRA abaixo, que é mais conservadora:
// pode deixar passar algum tracinho residual, mas nunca teve o risco de
// apagar uma estrutura real.
const LEADER_LINE_SOURCE_KEYS = new Set(["nervous", "regions"]);
// Fáscias (envoltórios musculares) do Z-Anatomy são lâminas/tubos que
// embrulham um GRUPO INTEIRO de músculos (ex: "fascia lata" cobre toda a
// coxa, "crural fascia" cobre toda a perna) — diferente de bursas,
// bainhas tendíneas, retináculos e septos (pequenos, locais, não cobrem
// superfície nenhuma). Mantidas como malha visível, elas tampam
// visualmente os músculos individuais logo abaixo — o interesse do atlas
// é ver os músculos de verdade (ex: os do atlas de Moore), não a fáscia
// que os embrulha. Por pedido do usuário, essas malhas são removidas do
// sistema muscular: nem viram estrutura clicável, nem ficam na malha
// renderizada (node.setMesh(null), diferente da exclusão de linha de
// chamada acima, que só esconde a malha "de estrutura" mas a mantém no
// arquivo). Nomes exatamente como em MUSCLE_DESCRIPTIONS (chave = nome
// bruto em inglês, minúsculo, sem sufixo de lateralidade).
const MUSCULAR_FASCIA_NAMES = new Set([
"antebrachial fascia",
"brachial fascia",
"clavipectoral fascia",
"crural fascia",
"deltoid fascia",
"diaphragmatic fascia",
"dorsal fascia of hand",
"fascia lata",
"iliopsoas fascia",
"investing abdominal fascia",
"masseteric fascia",
"pectoral fascia",
"piriformis fascia",
"popliteal fascia",
"superficial investing cervical fascia",
"superficial layer of temporal fascia",
"anterior layer of thoracolumbar fascia",
"middle layer of thoracolumbar fascia",
"posterior layer of thoracolumbar fascia",
"transversalis fascia",
]);
// Uma linha de chamada de verdade do app original é um objeto decorativo
// simples — poucos vértices, só o suficiente pra desenhar um segmento fino
// (às vezes com um pequeno gancho na ponta). Uma faixa real de pele, MESMO
// estreita (ex: bem em cima da coluna), é parte de uma superfície contínua
// do corpo e tem muito mais vértices, porque segue o contorno real do
// tronco em vez de ser um segmento reto desenhado à mão. Sem esse teto, o
// teste de "reto e fino" sozinho não distingue as duas (uma faixa comprida
// e estreita também passa) — foi exatamente o que causou o buraco na pele
// da coluna. Esse limite só torna a classificação MAIS RESTRITIVA (nunca
// exclui mais do que excluía antes), então não tem como abrir um buraco
// novo — na pior hipótese, deixa passar algum tracinho residual.
const MAX_LEADER_LINE_MESH_VERTS = 60;
function isLeaderLineMesh(mesh) {
try {
// Antes, cada primitivo era julgado sozinho ("every"). Isso deixava
// passar batido linhas de chamada com um pequeno "gancho"/marcador na
// ponta (comum no app original, pra indicar o ponto exato) — esse
// gancho é um primitivo à parte que não é colinear, então "every"
// reprovava a malha inteira. Agora juntamos os vértices de TODOS os
// primitivos da malha num só cálculo e usamos o percentil 90 do desvio
// (em vez do máximo), assim uma pontinha que afeta só uma minoria dos
// vértices não derruba a classificação, mas uma estrutura real (curva,
// ramificada, com volume) continua reprovando.
const points = [];
for (const prim of mesh.listPrimitives()) {
const accessor = prim.getAttribute("POSITION");
if (!accessor) continue;
const count = accessor.getCount();
const arr = accessor.getArray();
if (!arr) continue;
for (let i = 0; i < count; i++) {
points.push([arr[i * 3], arr[i * 3 + 1], arr[i * 3 + 2]]);
}
}
if (points.length > MAX_LEADER_LINE_MESH_VERTS) return false;
return isLeaderLinePointCloud(points);
} catch {
return false;
}
}
function isLeaderLinePointCloud(points) {
const count = points.length;
if (count < 2) return false;
const at = (i) => points[i];
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];
const perps = new Array(count);
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;
perps[i] = Math.sqrt(perp2);
}
perps.sort((x, y) => x - y);
const p90 = perps[Math.floor(0.9 * (count - 1))];
// "Linha reta e fina": 90% dos pontos ficam muito perto do próprio eixo
// mais longo (desvio perpendicular pequeno comparado ao comprimento) —
// tolera até 10% de vértices "fora da linha" (gancho/marcador na ponta).
return p90 < axisLen * 0.1;
}
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 if (key === "muscular" && MUSCULAR_FASCIA_NAMES.has(stripSideSuffix(rawName).base.toLowerCase())) {
node.setExtras({ excluded: true });
node.setMesh(null);
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(),
// Chegamos a testar compressão Meshopt aqui (EXT_meshopt_compression)
// pra reduzir o tamanho final do .glb — mas ela quantiza posição/normal
// pra poucos bits, e em superfícies grandes e lisas (a pele do sistema
// "regioes", por exemplo) isso ficou visível como um efeito de
// manchas/facetas na iluminação. Não vale a troca: o ganho de banda
// não compensa a qualidade visual pior justamente no sistema mais
// visível do app. Removido — ver histórico do git se quiser retomar
// com bits de quantização mais altos.
);
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.`);