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

154 lines
5.8 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";
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, "");
}
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
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);
const systemSlug = classifySystem(key, rawName);
const material = materialFor(systemSlug);
for (const prim of mesh.listPrimitives()) prim.setMaterial(material);
const sys = systemBySlug.get(systemSlug);
const slugPart = slugify(uniqueMeshName) || `sem-nome-${totalStructures}`;
const slug = `${systemSlug}-${slugPart}`;
manifest.push({
system: systemSlug,
slug,
namePt: translateToPt(rawName) + (n > 1 ? ` (${n})` : ""),
nameEn: rawName + (n > 1 ? ` (${n})` : ""),
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.`);
}
// 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.`);