385 lines
17 KiB
JavaScript
385 lines
17 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"]);
|
|
|
|
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]]);
|
|
}
|
|
}
|
|
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 {
|
|
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.`);
|