Signed-off-by: Rodrigo Bertoldo de Sousa Martins <rodrigo@martinsevieira.adv.br>
1013 lines
46 KiB
JavaScript
1013 lines
46 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];
|
||
}
|
||
|
||
function hexToHsl(hex) {
|
||
const [r, g, b] = hexToRgb(hex);
|
||
const max = Math.max(r, g, b), min = Math.min(r, g, b);
|
||
const l = (max + min) / 2;
|
||
if (max === min) return [0, 0, l];
|
||
const d = max - min;
|
||
const s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
|
||
let h;
|
||
if (max === r) h = ((g - b) / d + (g < b ? 6 : 0)) / 6;
|
||
else if (max === g) h = ((b - r) / d + 2) / 6;
|
||
else h = ((r - g) / d + 4) / 6;
|
||
return [h, s, l];
|
||
}
|
||
|
||
function hslToHex(h, s, l) {
|
||
h = ((h % 1) + 1) % 1; // normaliza pra [0,1) mesmo se ficou negativo
|
||
s = Math.min(1, Math.max(0, s));
|
||
l = Math.min(1, Math.max(0, l));
|
||
const hue2rgb = (p, q, t) => {
|
||
if (t < 0) t += 1;
|
||
if (t > 1) t -= 1;
|
||
if (t < 1 / 6) return p + (q - p) * 6 * t;
|
||
if (t < 1 / 2) return q;
|
||
if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6;
|
||
return p;
|
||
};
|
||
let r, g, b;
|
||
if (s === 0) {
|
||
r = g = b = l;
|
||
} else {
|
||
const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
|
||
const p = 2 * l - q;
|
||
r = hue2rgb(p, q, h + 1 / 3);
|
||
g = hue2rgb(p, q, h);
|
||
b = hue2rgb(p, q, h - 1 / 3);
|
||
}
|
||
const toHex = (v) => Math.round(v * 255).toString(16).padStart(2, "0");
|
||
return `#${toHex(r)}${toHex(g)}${toHex(b)}`;
|
||
}
|
||
|
||
// Hash determinístico simples (não precisa ser criptográfico, só estável
|
||
// entre execuções) — usado pra variar a cor de cada músculo sempre do
|
||
// MESMO jeito, sem depender de sorte/aleatoriedade a cada rodada do
|
||
// pipeline.
|
||
function stringHash(s) {
|
||
let h = 0;
|
||
for (let i = 0; i < s.length; i++) {
|
||
h = (h * 31 + s.charCodeAt(i)) | 0;
|
||
}
|
||
return h >>> 0;
|
||
}
|
||
|
||
// O sistema muscular real do Z-Anatomy tem uma limitação: no tronco
|
||
// (peitoral, oblíquos, reto do abdome, serrátil), a malha em si é um
|
||
// "casco" externo liso, sem o relevo 3D que separa um músculo do outro
|
||
// (confirmado comparando com a exportação bruta, antes de qualquer
|
||
// processamento nosso — não é algo que a simplificação introduziu). Com
|
||
// UMA cor lisa pro sistema inteiro (como todo outro sistema usa), músculos
|
||
// vizinhos ficam visualmente indistinguíveis um do outro ali, mesmo sendo
|
||
// objetos e nomes diferentes por baixo — "parece tudo agrupado". Damos a
|
||
// cada músculo (mesma cor nos dois lados esquerdo/direito, já que é o
|
||
// MESMO músculo) uma variação pequena e determinística de matiz/luminosidade
|
||
// em torno da cor-base do sistema, só o bastante pra criar um limite visível
|
||
// entre estruturas vizinhas sem fugir da identidade de cor do sistema.
|
||
function muscleColorVariant(baseHex, variantKey) {
|
||
const [h, s, l] = hexToHsl(baseHex);
|
||
const hash = stringHash(variantKey);
|
||
const hueShift = (((hash % 21) - 10) / 360) * 2; // até ±10° de matiz
|
||
const lightShift = (((hash >> 8) % 21) - 10) / 100; // até ±10% de luminosidade
|
||
return hslToHex(h + hueShift, s, Math.min(0.85, Math.max(0.25, l + lightShift)));
|
||
}
|
||
|
||
// Regiões de superfície (epigástrica, mesogástrica/umbilical, hipogástrica
|
||
// etc.) são o oposto do caso muscular: em vez de estruturas ANATOMICAMENTE
|
||
// relacionadas que precisam parecer "da mesma família" (uma leve variação
|
||
// em torno da cor-base do sistema já resolve), aqui são divisões
|
||
// TOPOGRÁFICAS lado a lado (as 9 regiões do abdome, por exemplo) que
|
||
// precisam ser claramente distinguíveis umas das outras à primeira vista —
|
||
// como num diagrama didático de livro-texto.
|
||
//
|
||
// Sortear o matiz por hash (`hash % 360`) parece razoável, mas com ~170
|
||
// nomes de região caindo aleatoriamente num círculo de 360°, é comum dois
|
||
// nomes vizinhos (ex: "umbilical region" e "hypogastric region", que ficam
|
||
// EMPILHADOS no abdome) calharem de cair perto um do outro por pura
|
||
// coincidência de hash — testamos e foi exatamente o que aconteceu.
|
||
// Em vez disso, distribuímos os matizes pelo "ângulo dourado" (~137.5°) na
|
||
// ordem alfabética de TODOS os nomes de região conhecidos (as chaves de
|
||
// REGION_DESCRIPTIONS): esse incremento é a técnica clássica pra espalhar
|
||
// N categorias por um círculo de cores sem duas vizinhas na lista saírem
|
||
// vizinhas na cor. Saturação/luminosidade ficam fixas numa faixa pastel,
|
||
// pra toda região continuar parecendo parte do mesmo sistema visual.
|
||
// Esquerda/direita da MESMA região (mesmo variantKey, já sem sufixo de
|
||
// lado) sempre saem com a cor igual.
|
||
const REGION_HUE_ORDER = Object.keys(REGION_DESCRIPTIONS).sort();
|
||
const REGION_HUE_INDEX = new Map(REGION_HUE_ORDER.map((k, i) => [k, i]));
|
||
const GOLDEN_ANGLE_DEG = 137.50776;
|
||
|
||
function regionColorVariant(variantKey) {
|
||
const index = REGION_HUE_INDEX.has(variantKey)
|
||
? REGION_HUE_INDEX.get(variantKey)
|
||
: REGION_HUE_ORDER.length + (stringHash(variantKey) % 360); // nomes fora da lista (raro): ainda determinístico
|
||
const hue = ((index * GOLDEN_ANGLE_DEG) % 360) / 360;
|
||
const hash = stringHash(variantKey);
|
||
const sat = 0.5 + ((hash >> 8) % 16) / 100; // 0.50–0.65
|
||
const light = 0.6 + ((hash >> 16) % 14) / 100; // 0.60–0.73
|
||
return hslToHex(hue, sat, light);
|
||
}
|
||
|
||
// Tom mais escuro da MESMA cor do músculo — usado nos sulcos cosméticos
|
||
// (ver MUSCULAR_COSMETIC_GROOVES), pra parecer uma "dobra"/sombra na
|
||
// própria superfície do músculo em vez de um elemento estranho.
|
||
function darkenHex(hex, amount) {
|
||
const [h, s, l] = hexToHsl(hex);
|
||
return hslToHex(h, s, Math.max(0.06, l - amount));
|
||
}
|
||
|
||
// ---- Sulcos cosméticos (só peitoral/abdome) --------------------------
|
||
//
|
||
// Confirmamos (comparando com a exportação bruta E com o app oficial do
|
||
// Z-Anatomy ao vivo, incluindo a versão completa do projeto Unity, não só
|
||
// a amostra) que reto do abdome, serrátil anterior e oblíquo externo do
|
||
// abdome são cada um UMA malha lisa e contínua no dado de origem — sem as
|
||
// intersecções tendíneas ("tanquinho") nem as digitações nas costelas que
|
||
// o atlas de Moore desenha. Não existe uma versão mais detalhada pra
|
||
// buscar. A pedido do usuário, desenhamos essas linhas por cima da malha
|
||
// real como sulcos decorativos — pequenos prismas finos, um pouco à
|
||
// frente da superfície real — só para dar a leitura visual de divisão.
|
||
// Cada sulco carrega o MESMO slug do músculo real ao qual pertence (via
|
||
// extras), então: (1) o frontend não o esconde por "não catalogado" e
|
||
// (2) clicar nele seleciona e mostra o nome do músculo de verdade — não
|
||
// existe uma "sub-estrutura" nova, é só decoração por cima da mesma.
|
||
const MUSCULAR_COSMETIC_GROOVES = {
|
||
"rectus abdominis muscle": "bands",
|
||
"serratus anterior muscle": "digitations",
|
||
"external abdominal oblique muscle": "digitations",
|
||
};
|
||
|
||
function vecSub(a, b) { return [a[0] - b[0], a[1] - b[1], a[2] - b[2]]; }
|
||
function vecAdd(a, b) { return [a[0] + b[0], a[1] + b[1], a[2] + b[2]]; }
|
||
function vecScale(a, s) { return [a[0] * s, a[1] * s, a[2] * s]; }
|
||
function vecCross(a, b) {
|
||
return [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]];
|
||
}
|
||
function vecLen(a) { return Math.hypot(a[0], a[1], a[2]); }
|
||
function vecNorm(a) {
|
||
const l = vecLen(a) || 1;
|
||
return [a[0] / l, a[1] / l, a[2] / l];
|
||
}
|
||
|
||
// Aplica um quaternion [x,y,z,w] a um vetor — usado só pra descobrir, em
|
||
// espaço de MUNDO, pra que lado cada eixo LOCAL do nó aponta (cada
|
||
// músculo do Z-Anatomy pode vir rotacionado de um jeito diferente).
|
||
function rotateByQuat(q, v) {
|
||
const [qx, qy, qz, qw] = q;
|
||
const tx = 2 * (qy * v[2] - qz * v[1]);
|
||
const ty = 2 * (qz * v[0] - qx * v[2]);
|
||
const tz = 2 * (qx * v[1] - qy * v[0]);
|
||
return [
|
||
v[0] + qw * tx + (qy * tz - qz * ty),
|
||
v[1] + qw * ty + (qz * tx - qx * tz),
|
||
v[2] + qw * tz + (qx * ty - qy * tx),
|
||
];
|
||
}
|
||
|
||
// Descobre qual eixo LOCAL (0=x,1=y,2=z) do nó aponta pra "cima" (mundo
|
||
// +Y) e qual aponta pra "frente" (mundo +Z) — cada músculo pode ter uma
|
||
// rotação de nó diferente (confirmamos isso comparando alguns músculos
|
||
// reais: um vem sem rotação, outro vem rotacionado -90° no eixo X).
|
||
function localAxisMapping(node) {
|
||
const q = node.getRotation();
|
||
const worldOf = [rotateByQuat(q, [1, 0, 0]), rotateByQuat(q, [0, 1, 0]), rotateByQuat(q, [0, 0, 1])];
|
||
let vertIdx = 0, vertSign = 1, best = -1;
|
||
for (let i = 0; i < 3; i++) {
|
||
const abs = Math.abs(worldOf[i][1]);
|
||
if (abs > best) { best = abs; vertIdx = i; vertSign = worldOf[i][1] >= 0 ? 1 : -1; }
|
||
}
|
||
let depthIdx = -1, depthSign = 1;
|
||
best = -1;
|
||
for (let i = 0; i < 3; i++) {
|
||
if (i === vertIdx) continue;
|
||
const abs = Math.abs(worldOf[i][2]);
|
||
if (abs > best) { best = abs; depthIdx = i; depthSign = worldOf[i][2] >= 0 ? 1 : -1; }
|
||
}
|
||
const lateralIdx = [0, 1, 2].find((i) => i !== vertIdx && i !== depthIdx);
|
||
return { vertIdx, vertSign, depthIdx, depthSign, lateralIdx };
|
||
}
|
||
|
||
function meshLocalBBox(mesh) {
|
||
const min = [Infinity, Infinity, Infinity];
|
||
const max = [-Infinity, -Infinity, -Infinity];
|
||
for (const prim of mesh.listPrimitives()) {
|
||
const accessor = prim.getAttribute("POSITION");
|
||
if (!accessor) continue;
|
||
const arr = accessor.getArray();
|
||
const count = accessor.getCount();
|
||
for (let i = 0; i < count; i++) {
|
||
for (let axis = 0; axis < 3; axis++) {
|
||
const v = arr[i * 3 + axis];
|
||
if (v < min[axis]) min[axis] = v;
|
||
if (v > max[axis]) max[axis] = v;
|
||
}
|
||
}
|
||
}
|
||
return { min, max };
|
||
}
|
||
|
||
// Lê TODO vértice real da malha (posição + normal, os dois já calculados
|
||
// a partir da geometria de verdade) — é nisso que os sulcos vão se
|
||
// apoiar, em vez de uma forma geométrica desenhada à mão. Poucos milhares
|
||
// de pontos por músculo, então busca por vizinho mais próximo por força
|
||
// bruta é rápida o bastante (não precisa de estrutura de indexação).
|
||
function collectSurfacePoints(mesh) {
|
||
const points = [];
|
||
for (const prim of mesh.listPrimitives()) {
|
||
const posAcc = prim.getAttribute("POSITION");
|
||
if (!posAcc) continue;
|
||
const normAcc = prim.getAttribute("NORMAL");
|
||
const posArr = posAcc.getArray();
|
||
const normArr = normAcc ? normAcc.getArray() : null;
|
||
const count = posAcc.getCount();
|
||
for (let i = 0; i < count; i++) {
|
||
const pos = [posArr[i * 3], posArr[i * 3 + 1], posArr[i * 3 + 2]];
|
||
const normal = normArr
|
||
? vecNorm([normArr[i * 3], normArr[i * 3 + 1], normArr[i * 3 + 2]])
|
||
: [0, 0, 0];
|
||
points.push({ pos, normal });
|
||
}
|
||
}
|
||
return points;
|
||
}
|
||
|
||
// Acha um ponto REPRESENTATIVO da superfície frontal perto do alvo —
|
||
// dentro de uma pequena janela lateral/vertical, faz a MÉDIA dos vértices
|
||
// que estão entre os mais frontais dali (não o vértice mais próximo de um
|
||
// ponto no espaço, nem só o único vértice mais frontal). Testamos as duas
|
||
// alternativas mais simples e as duas falharam: com um "isca" longe da
|
||
// malha, a diferença de profundidade domina a distância e quase toda
|
||
// amostra cai perto do MESMO vértice mais extremo do músculo inteiro
|
||
// (colapsa tudo num ponto); pegando só o único vértice mais frontal
|
||
// dentro da janela, a malha real tem uma textura fina de fibra muscular
|
||
// esculpida na geometria (visível como as "listras" na superfície), então
|
||
// o "mais frontal" pula de uma micro-ondulação pra outra a cada amostra —
|
||
// uma linha em ziguezague em vez de acompanhar a curva real. Fazer a
|
||
// MÉDIA de vários vértices próximos e frontais suaviza esse ruído fino
|
||
// sem deixar de seguir a curvatura de verdade (a média se desloca suave
|
||
// conforme a janela desliza pelo músculo).
|
||
function frontSurfacePointNear(points, target, lateralIdx, vertIdx, depthIdx, depthSign, lateralWindow0, vertWindow0) {
|
||
let lateralWindow = lateralWindow0;
|
||
let vertWindow = vertWindow0;
|
||
for (let attempt = 0; attempt < 8; attempt++) {
|
||
let maxDepth = -Infinity;
|
||
let minDepth = Infinity;
|
||
const candidates = [];
|
||
for (const p of points) {
|
||
if (Math.abs(p.pos[lateralIdx] - target[lateralIdx]) > lateralWindow) continue;
|
||
if (Math.abs(p.pos[vertIdx] - target[vertIdx]) > vertWindow) continue;
|
||
const depthVal = depthSign * p.pos[depthIdx];
|
||
candidates.push({ p, depthVal });
|
||
if (depthVal > maxDepth) maxDepth = depthVal;
|
||
if (depthVal < minDepth) minDepth = depthVal;
|
||
}
|
||
if (candidates.length > 0) {
|
||
// Só os 35% mais frontais da janela — descarta a parte de trás do
|
||
// músculo (que também pode cair dentro da janela lateral/vertical)
|
||
// sem restringir tanto a ponto de voltar a ser um único vértice.
|
||
const threshold = maxDepth - (maxDepth - minDepth) * 0.35;
|
||
const front = candidates.filter((c) => c.depthVal >= threshold);
|
||
const pos = [0, 0, 0], normal = [0, 0, 0];
|
||
for (const { p } of front) {
|
||
pos[0] += p.pos[0]; pos[1] += p.pos[1]; pos[2] += p.pos[2];
|
||
normal[0] += p.normal[0]; normal[1] += p.normal[1]; normal[2] += p.normal[2];
|
||
}
|
||
const n = front.length;
|
||
return { pos: [pos[0] / n, pos[1] / n, pos[2] / n], normal: vecNorm(normal) };
|
||
}
|
||
lateralWindow *= 1.8;
|
||
vertWindow *= 1.8;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
// Catmull-Rom: curva suave que passa exatamente pelos pontos-âncora
|
||
// (diferente de Bézier, que só passa pelas pontas). Usada pra desenhar
|
||
// uma linha fluida entre POUCOS pontos-âncora bem estáveis, em vez de
|
||
// tentar grudar em cada vértice individual da malha (ruidoso demais —
|
||
// ver frontSurfacePointNear).
|
||
function catmullRom1D(p0, p1, p2, p3, t) {
|
||
const t2 = t * t, t3 = t2 * t;
|
||
return 0.5 * (2 * p1 + (-p0 + p2) * t + (2 * p0 - 5 * p1 + 4 * p2 - p3) * t2 + (-p0 + 3 * p1 - 3 * p2 + p3) * t3);
|
||
}
|
||
function catmullRomVec(p0, p1, p2, p3, t) {
|
||
return [0, 1, 2].map((i) => catmullRom1D(p0[i], p1[i], p2[i], p3[i], t));
|
||
}
|
||
|
||
// Interpola uma curva suave passando por TODAS as âncoras (duplica a
|
||
// primeira/última pra servir de tangente nas pontas) e devolve uma
|
||
// sequência bem mais longa e fluida de pontos+normais interpolados —
|
||
// essa sequência, não as âncoras cruas, é o que vira a malha do sulco.
|
||
function smoothAnchoredPath(anchors, subdivisionsPerSegment) {
|
||
if (anchors.length < 2) return anchors;
|
||
const pos = anchors.map((a) => a.pos);
|
||
const norm = anchors.map((a) => a.normal);
|
||
const extPos = [pos[0], ...pos, pos[pos.length - 1]];
|
||
const extNorm = [norm[0], ...norm, norm[norm.length - 1]];
|
||
const result = [];
|
||
for (let i = 0; i < pos.length - 1; i++) {
|
||
const startJ = i === 0 ? 0 : 1; // evita duplicar o ponto na junção de segmentos
|
||
for (let j = startJ; j <= subdivisionsPerSegment; j++) {
|
||
const t = j / subdivisionsPerSegment;
|
||
const p = catmullRomVec(extPos[i], extPos[i + 1], extPos[i + 2], extPos[i + 3], t);
|
||
const n = vecNorm(catmullRomVec(extNorm[i], extNorm[i + 1], extNorm[i + 2], extNorm[i + 3], t));
|
||
result.push({ pos: p, normal: n });
|
||
}
|
||
}
|
||
return result;
|
||
}
|
||
|
||
// Uma LINHA de verdade (primitiva glTF modo LINE_STRIP — sem largura,
|
||
// sem "volume", sem faces pra triangular) passando pela sequência de
|
||
// pontos já suavizada. Chegamos a tentar isso como uma LINHA de verdade
|
||
// (primitiva glTF modo LINE_STRIP, sem volume nenhum) — funcionava bem
|
||
// nos dados (conferimos com log: os pontos seguem a superfície real e
|
||
// cobrem a largura toda do músculo, sem serrilhado), mas a renderização
|
||
// de linhas em WebGL via software (o ambiente headless que usamos pra
|
||
// validar aqui) é pouco confiável — não dá pra garantir que apareça igual
|
||
// num navegador de verdade sem depender só disso. Por segurança, voltamos
|
||
// a uma "crista" sólida (2 faces finas formando um "telhado" baixo,
|
||
// erguido ao longo da normal real de cada ponto) — geometria comum,
|
||
// renderiza igual em qualquer motor. A serrinha que essa abordagem tinha
|
||
// antes (ver histórico) era efeito da amostragem ruidosa ponto a ponto;
|
||
// agora a entrada já vem suavizada pela curva Catmull-Rom
|
||
// (smoothAnchoredPath), então a crista nasce lisa. Dupla face
|
||
// (setDoubleSided no material) dispensa acertar a ordem de enrolamento.
|
||
function addQuad(positions, normals, indices, p0, p1, p2, p3) {
|
||
const e1 = vecSub(p1, p0), e2 = vecSub(p2, p0);
|
||
let n = vecCross(e1, e2);
|
||
const len = vecLen(n);
|
||
n = len > 1e-9 ? vecScale(n, 1 / len) : [0, 0, 1];
|
||
const base = positions.length / 3;
|
||
for (const p of [p0, p1, p2, p3]) {
|
||
positions.push(...p);
|
||
normals.push(...n);
|
||
}
|
||
indices.push(base, base + 1, base + 2, base, base + 2, base + 3);
|
||
}
|
||
|
||
// `outward` é uma direção FIXA (mesma pra todo mundo na linha), não a
|
||
// normal individual de cada vértice amostrado. Tentamos com a normal real
|
||
// de cada ponto primeiro — parecia o mais "correto" em teoria — mas a
|
||
// malha real tem normais um pouco inconsistentes de vértice a vértice
|
||
// (não é perfeitamente lisa), então a crista ficava ora virada pra fora,
|
||
// ora quase de lado, sumindo atrás da própria malha em pedaços alternados.
|
||
// Uma direção fixa (a mesma usada pra achar a superfície frontal em
|
||
// frontSurfacePointNear) resolve isso: todo o traço se ergue igual, sem
|
||
// alternância.
|
||
function buildRidgeMesh(doc, material, samples, halfWidth, height, outward) {
|
||
if (samples.length < 2) return null;
|
||
const n = samples.length;
|
||
const cross = samples.map((s, i) => {
|
||
const prev = samples[Math.max(0, i - 1)].pos;
|
||
const next = samples[Math.min(n - 1, i + 1)].pos;
|
||
const tangent = vecNorm(vecSub(next, prev));
|
||
let binormal = vecCross(tangent, outward);
|
||
if (vecLen(binormal) < 1e-6) binormal = vecCross(tangent, [1, 0, 0]);
|
||
binormal = vecNorm(binormal);
|
||
return {
|
||
baseL: vecAdd(s.pos, vecScale(binormal, -halfWidth)),
|
||
baseR: vecAdd(s.pos, vecScale(binormal, halfWidth)),
|
||
apex: vecAdd(s.pos, vecScale(outward, height)),
|
||
};
|
||
});
|
||
|
||
const positions = [];
|
||
const normals = [];
|
||
const indices = [];
|
||
for (let i = 0; i < n - 1; i++) {
|
||
const a = cross[i], b = cross[i + 1];
|
||
addQuad(positions, normals, indices, a.baseL, a.apex, b.apex, b.baseL);
|
||
addQuad(positions, normals, indices, a.apex, a.baseR, b.baseR, b.apex);
|
||
}
|
||
|
||
const posAccessor = doc.createAccessor().setType("VEC3").setArray(new Float32Array(positions));
|
||
const normAccessor = doc.createAccessor().setType("VEC3").setArray(new Float32Array(normals));
|
||
const idxAccessor = doc.createAccessor().setType("SCALAR").setArray(new Uint16Array(indices));
|
||
const primitive = doc
|
||
.createPrimitive()
|
||
.setAttribute("POSITION", posAccessor)
|
||
.setAttribute("NORMAL", normAccessor)
|
||
.setIndices(idxAccessor)
|
||
.setMaterial(material);
|
||
return doc.createMesh().addPrimitive(primitive);
|
||
}
|
||
|
||
// Adiciona os sulcos decorativos como filhos do próprio nó do músculo —
|
||
// herdam a transformação do pai automaticamente, então as coordenadas
|
||
// aqui usam o MESMO espaço local da malha real. `slug` é o do músculo
|
||
// real: o sulco não é uma estrutura própria, só decoração com a MESMA
|
||
// identidade (mesmo slug → clicar nele resolve pro nome certo).
|
||
function addCosmeticGrooves(doc, targetNode, kind, material, slug) {
|
||
const mesh = targetNode.getMesh();
|
||
if (!mesh) return;
|
||
const { min, max } = meshLocalBBox(mesh);
|
||
const { vertIdx, vertSign, depthIdx, depthSign, lateralIdx } = localAxisMapping(targetNode);
|
||
const vRange = max[vertIdx] - min[vertIdx];
|
||
const dRange = max[depthIdx] - min[depthIdx];
|
||
const lRange = max[lateralIdx] - min[lateralIdx];
|
||
if (vRange <= 0 || dRange <= 0 || lRange <= 0) return;
|
||
|
||
const surfacePoints = collectSurfacePoints(mesh);
|
||
if (surfacePoints.length === 0) return;
|
||
|
||
const vertAt = (frac) => (vertSign > 0 ? min[vertIdx] + frac * vRange : max[vertIdx] - frac * vRange);
|
||
const lateralAt = (frac) => min[lateralIdx] + frac * lRange;
|
||
|
||
const assemble = (lateralVal, vertVal, depthVal) => {
|
||
const p = [0, 0, 0];
|
||
p[lateralIdx] = lateralVal;
|
||
p[vertIdx] = vertVal;
|
||
p[depthIdx] = depthVal;
|
||
return p;
|
||
};
|
||
|
||
// Janela GRANDE (a média de uma porção generosa da malha ao redor de
|
||
// cada âncora) — poucos pontos-âncora, mas cada um bem estável. A
|
||
// suavidade da linha final vem da curva Catmull-Rom entre eles
|
||
// (smoothAnchoredPath), não de tentar grudar em cada vértice
|
||
// individual: testamos isso primeiro e a textura fina de fibra
|
||
// muscular esculpida na malha real fazia a linha "tremer" (ziguezague),
|
||
// mesmo com janela pequena e média local.
|
||
const lateralWindow0 = lRange * 0.16;
|
||
const vertWindow0 = vRange * 0.14;
|
||
const surfaceSampleAt = (lateralFrac, vertFrac) =>
|
||
frontSurfacePointNear(
|
||
surfacePoints,
|
||
assemble(lateralAt(lateralFrac), vertAt(vertFrac), 0),
|
||
lateralIdx, vertIdx, depthIdx, depthSign,
|
||
lateralWindow0, vertWindow0,
|
||
);
|
||
|
||
const ridgeHeight = Math.min(vRange, lRange) * 0.05;
|
||
const ridgeHalfWidth = Math.min(vRange, lRange) * 0.018;
|
||
const SUBDIVISIONS = 10; // pontos interpolados entre cada par de âncoras
|
||
|
||
// Direção "pra fora" FIXA (a mesma usada pra achar a superfície frontal
|
||
// acima) — ver comentário em buildRidgeMesh sobre por que não usamos a
|
||
// normal individual de cada vértice amostrado.
|
||
const outward = [0, 0, 0];
|
||
outward[depthIdx] = depthSign;
|
||
|
||
const grooveMeshes = [];
|
||
if (kind === "bands") {
|
||
// Intersecções tendíneas do reto do abdome — aproximação: 3 faixas
|
||
// horizontais igualmente espaçadas (a posição real varia de pessoa
|
||
// pra pessoa, então não há um "certo" a buscar aqui), cada uma com 5
|
||
// âncoras ao longo da largura, suavizadas numa curva só.
|
||
for (const frac of [0.3, 0.55, 0.78]) {
|
||
const anchors = [];
|
||
for (let i = 0; i <= 4; i++) {
|
||
const lateralFrac = 0.14 + (i / 4) * 0.72;
|
||
const s = surfaceSampleAt(lateralFrac, frac);
|
||
if (s) anchors.push(s);
|
||
}
|
||
const m = buildRidgeMesh(
|
||
doc, material, smoothAnchoredPath(anchors, SUBDIVISIONS), ridgeHalfWidth, ridgeHeight, outward,
|
||
);
|
||
if (m) grooveMeshes.push(m);
|
||
}
|
||
} else if (kind === "digitations") {
|
||
// Digitações (serrátil anterior / oblíquo externo nas costelas) —
|
||
// aproximação: marcas diagonais curtas espaçadas perto da borda
|
||
// superior do músculo, cada uma com 3 âncoras suavizadas.
|
||
const count = 4;
|
||
for (let i = 0; i < count; i++) {
|
||
const latCenter = (i + 0.5) / count;
|
||
const dLat = (1 / count) * 0.16;
|
||
const dVert = Math.min(0.1, (1 / count) * 0.3);
|
||
const anchors = [];
|
||
for (let j = 0; j <= 2; j++) {
|
||
const t = j / 2;
|
||
const s = surfaceSampleAt(latCenter - dLat + t * 2 * dLat, 0.7 + t * dVert);
|
||
if (s) anchors.push(s);
|
||
}
|
||
const m = buildRidgeMesh(
|
||
doc, material, smoothAnchoredPath(anchors, SUBDIVISIONS), ridgeHalfWidth * 0.85, ridgeHeight * 0.85, outward,
|
||
);
|
||
if (m) grooveMeshes.push(m);
|
||
}
|
||
}
|
||
|
||
grooveMeshes.forEach((mesh, i) => {
|
||
const grooveNode = doc.createNode(`${targetNode.getName()} groove ${i}`).setMesh(mesh).setExtras({ slug });
|
||
targetNode.addChild(grooveNode);
|
||
});
|
||
}
|
||
|
||
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 };
|
||
}
|
||
|
||
// O Z-Anatomy tem os 3 músculos intercostais (externo/interno/íntimo)
|
||
// como 11 instâncias repetidas por lado (uma por espaço intercostal), mas
|
||
// só com o nome genérico — sem indicar QUAL espaço. Nomeamos cada
|
||
// instância pelo espaço intercostal, usando `n` (o contador de ocorrência
|
||
// já calculado por nome bruto — ver `nameCounts` no loop principal): a
|
||
// ordem em que o Z-Anatomy lista as instâncias no arquivo original segue
|
||
// a ordem anatômica das costelas (1º espaço primeiro, ao lado da
|
||
// clavícula), então o contador de ocorrência já serve como número do
|
||
// espaço, sem precisar calcular posição 3D nenhuma. Só pra pedidos de
|
||
// espaço intercostal — não altera nenhum outro músculo.
|
||
const INTERCOSTAL_MUSCLE_BASES = new Set([
|
||
"external intercostal muscles",
|
||
"internal intercostal muscles",
|
||
"innermost intercostal muscles",
|
||
]);
|
||
|
||
function ordinalEn(n) {
|
||
const rem100 = n % 100;
|
||
if (rem100 >= 11 && rem100 <= 13) return `${n}th`;
|
||
switch (n % 10) {
|
||
case 1: return `${n}st`;
|
||
case 2: return `${n}nd`;
|
||
case 3: return `${n}rd`;
|
||
default: return `${n}th`;
|
||
}
|
||
}
|
||
|
||
// 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"]);
|
||
|
||
// Fáscias (envoltórios musculares) do Z-Anatomy são lâminas/tubos que
|
||
// embrulham um GRUPO INTEIRO de músculos (ex: "fascia lata" cobre toda a
|
||
// coxa, "crural fascia" cobre toda a perna) — diferente de bursas,
|
||
// bainhas tendíneas, retináculos e septos (pequenos, locais, não cobrem
|
||
// superfície nenhuma). Mantidas como malha visível, elas tampam
|
||
// visualmente os músculos individuais logo abaixo — o interesse do atlas
|
||
// é ver os músculos de verdade (ex: os do atlas de Moore), não a fáscia
|
||
// que os embrulha. Por pedido do usuário, essas malhas são removidas do
|
||
// sistema muscular: nem viram estrutura clicável, nem ficam na malha
|
||
// renderizada (node.setMesh(null), diferente da exclusão de linha de
|
||
// chamada acima, que só esconde a malha "de estrutura" mas a mantém no
|
||
// arquivo). Nomes exatamente como em MUSCLE_DESCRIPTIONS (chave = nome
|
||
// bruto em inglês, minúsculo, sem sufixo de lateralidade).
|
||
//
|
||
// EXCEÇÃO: as 3 camadas da fáscia toracolombar NÃO entram aqui — a
|
||
// imagem de referência do Moore rotula explicitamente "Aponeurose
|
||
// toracolombar" na vista posterior do tronco. Diferente das outras
|
||
// fáscias (que só embrulham e escondem músculo por baixo, sem serem elas
|
||
// mesmas o alvo de estudo), essa É uma estrutura nomeada no atlas de
|
||
// Moore — remover era "esquecer a fáscia" longe demais.
|
||
const MUSCULAR_FASCIA_NAMES = new Set([
|
||
"antebrachial fascia",
|
||
"brachial fascia",
|
||
"clavipectoral fascia",
|
||
"crural fascia",
|
||
"deltoid fascia",
|
||
"diaphragmatic fascia",
|
||
"dorsal fascia of hand",
|
||
"fascia lata",
|
||
"iliopsoas fascia",
|
||
"investing abdominal fascia",
|
||
"masseteric fascia",
|
||
"pectoral fascia",
|
||
"piriformis fascia",
|
||
"popliteal fascia",
|
||
"superficial investing cervical fascia",
|
||
"superficial layer of temporal fascia",
|
||
"transversalis fascia",
|
||
]);
|
||
|
||
// Uma linha de chamada de verdade do app original é um objeto decorativo
|
||
// simples — poucos vértices, só o suficiente pra desenhar um segmento fino
|
||
// (às vezes com um pequeno gancho na ponta). Uma faixa real de pele, MESMO
|
||
// estreita (ex: bem em cima da coluna), é parte de uma superfície contínua
|
||
// do corpo e tem muito mais vértices, porque segue o contorno real do
|
||
// tronco em vez de ser um segmento reto desenhado à mão. Sem esse teto, o
|
||
// teste de "reto e fino" sozinho não distingue as duas (uma faixa comprida
|
||
// e estreita também passa) — foi exatamente o que causou o buraco na pele
|
||
// da coluna. Esse limite só torna a classificação MAIS RESTRITIVA (nunca
|
||
// exclui mais do que excluía antes), então não tem como abrir um buraco
|
||
// novo — na pior hipótese, deixa passar algum tracinho residual.
|
||
const MAX_LEADER_LINE_MESH_VERTS = 60;
|
||
|
||
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]]);
|
||
}
|
||
}
|
||
if (points.length > MAX_LEADER_LINE_MESH_VERTS) return false;
|
||
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) — ou,
|
||
// quando variantKey é passado (sistema muscular, ver muscleColorVariant),
|
||
// um material por músculo, com uma variação de cor em torno da cor-base
|
||
// do sistema.
|
||
const materialCache = new Map();
|
||
function materialFor(systemSlug, variantKey) {
|
||
const cacheKey = variantKey ? `${systemSlug}::${variantKey}` : systemSlug;
|
||
if (materialCache.has(cacheKey)) return materialCache.get(cacheKey);
|
||
const sys = systemBySlug.get(systemSlug);
|
||
const baseHex = sys?.colorHex ?? "#cccccc";
|
||
const colorHex = !variantKey
|
||
? baseHex
|
||
: systemSlug === "regioes"
|
||
? regionColorVariant(variantKey)
|
||
: muscleColorVariant(baseHex, variantKey);
|
||
const mat = doc
|
||
.createMaterial(cacheKey)
|
||
.setBaseColorFactor(hexToRgb(colorHex))
|
||
.setRoughnessFactor(0.55)
|
||
.setMetallicFactor(0.05);
|
||
materialCache.set(cacheKey, 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
|
||
const cosmeticGrooveTargets = []; // {node, kind, colorHex, slug} — só sistema muscular
|
||
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, muscleColorKey;
|
||
|
||
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 if (key === "muscular" && MUSCULAR_FASCIA_NAMES.has(stripSideSuffix(rawName).base.toLowerCase())) {
|
||
node.setExtras({ excluded: true });
|
||
node.setMesh(null);
|
||
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 " : "";
|
||
const isIntercostal = key === "muscular" && INTERCOSTAL_MUSCLE_BASES.has(base.toLowerCase());
|
||
namePt = isIntercostal
|
||
? `${translateToPt(base)}${sideLabelPt} — ${n}º Espaço Intercostal`
|
||
: `${translateToPt(base)}${sideLabelPt}` + (n > 1 ? ` (${n})` : "");
|
||
nameEn = isIntercostal
|
||
? `${sideWordEn}${base} — ${ordinalEn(n)} Intercostal Space`
|
||
: `${sideWordEn}${base}` + (n > 1 ? ` (${n})` : "");
|
||
description =
|
||
key === "regions"
|
||
? REGION_DESCRIPTIONS[base.toLowerCase()] ?? ""
|
||
: key === "muscular"
|
||
? MUSCLE_DESCRIPTIONS[base.toLowerCase()] ?? ""
|
||
: key === "nervous"
|
||
? NERVE_DESCRIPTIONS[base.toLowerCase()] ?? ""
|
||
: "";
|
||
if (key === "muscular" || key === "regions") muscleColorKey = base.toLowerCase();
|
||
}
|
||
|
||
const material = materialFor(systemSlug, muscleColorKey);
|
||
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 });
|
||
|
||
if (key === "muscular" && muscleColorKey && MUSCULAR_COSMETIC_GROOVES[muscleColorKey]) {
|
||
cosmeticGrooveTargets.push({ node, kind: MUSCULAR_COSMETIC_GROOVES[muscleColorKey], slug, muscleColorKey });
|
||
}
|
||
|
||
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.
|
||
);
|
||
|
||
// Sulcos cosméticos (ver MUSCULAR_COSMETIC_GROOVES) — adicionados DEPOIS
|
||
// do weld/simplify/prune pra não serem afetados pela simplificação
|
||
// (são poucos triângulos, gerados por nós, não precisam disso) e não
|
||
// atrapalharem o cálculo do bounding box (que precisa da malha REAL,
|
||
// sem os sulcos, já convertida — os sulcos usam side-effect de node
|
||
// rotation/bbox calculados a partir dela).
|
||
if (cosmeticGrooveTargets.length > 0) {
|
||
const grooveMaterialCache = new Map();
|
||
for (const { node, kind, slug, muscleColorKey } of cosmeticGrooveTargets) {
|
||
if (!grooveMaterialCache.has(muscleColorKey)) {
|
||
const baseHex = systemBySlug.get("muscular")?.colorHex ?? "#cccccc";
|
||
const muscleHex = muscleColorVariant(baseHex, muscleColorKey);
|
||
const grooveHex = darkenHex(muscleHex, 0.24);
|
||
const mat = doc
|
||
.createMaterial(`groove::${muscleColorKey}`)
|
||
.setBaseColorFactor(hexToRgb(grooveHex))
|
||
.setRoughnessFactor(0.7)
|
||
.setMetallicFactor(0.05)
|
||
.setDoubleSided(true);
|
||
grooveMaterialCache.set(muscleColorKey, mat);
|
||
}
|
||
addCosmeticGrooves(doc, node, kind, grooveMaterialCache.get(muscleColorKey), slug);
|
||
}
|
||
console.log(` + sulcos cosméticos adicionados em ${cosmeticGrooveTargets.length} músculo(s) do tronco.`);
|
||
}
|
||
|
||
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.`);
|