// 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/.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 — sem isso os .glb // ficam grandes demais para transferir e derrubam o contexto WebGL // (GPU sobrecarregada). Reduzimos para ~15% dos triângulos originais, // que mantém a forma reconhecível para fins de estudo. await doc.transform( weld(), simplify({ simplifier: MeshoptSimplifier, ratio: 0.15, 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.`);