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

249 lines
10 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";
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" };
}
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)) {
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 = "";
} 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()] ?? "" : "";
}
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.`);