941 lines
42 KiB
JavaScript
941 lines
42 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)));
|
|
}
|
|
|
|
// 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 };
|
|
}
|
|
|
|
// 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 ? muscleColorVariant(baseHex, variantKey) : baseHex;
|
|
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 " : "";
|
|
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()] ?? ""
|
|
: "";
|
|
if (key === "muscular") 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.`);
|