// 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"; 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" }; } 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 { 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.`);