// Lê os .glb convertidos do FBX real (Z-Anatomy), recolore por sistema, // garante nomes de nó únicos por arquivo (Z-Anatomy tem estruturas // repetidas — costelas, vértebras, falanges — que podem compartilhar o // mesmo nome bruto) e gera: // - data-pipeline/seed/models/.glb (final, servido pela API) // - data-pipeline/seed/manifest-real.json (entradas para o seed do banco) import { NodeIO } from "@gltf-transform/core"; import { ALL_EXTENSIONS } from "@gltf-transform/extensions"; import { prune, dedup, weld, simplify } from "@gltf-transform/functions"; import { MeshoptSimplifier } from "meshoptimizer"; import { writeFileSync, mkdirSync } from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { SOURCES, SOURCE_TARGETS, RESPIRATORY_KEYWORDS } from "./sources.mjs"; import { translateToPt } from "./translate-pt.mjs"; import { SYSTEMS } from "../seed/systems.mjs"; import { SKELETAL_BONES } from "./skeletal-whitelist.mjs"; import { REGION_DESCRIPTIONS } from "./region-descriptions.mjs"; import { BONE_DESCRIPTIONS } from "./bone-descriptions.mjs"; import { MUSCLE_DESCRIPTIONS } from "./muscle-descriptions.mjs"; import { NERVE_DESCRIPTIONS } from "./nerve-descriptions.mjs"; await MeshoptSimplifier.ready; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const CONVERTED_DIR = path.resolve(__dirname, "../converted"); const MODELS_OUT_DIR = path.resolve(__dirname, "../seed/models"); mkdirSync(MODELS_OUT_DIR, { recursive: true }); const systemBySlug = new Map(SYSTEMS.map((s) => [s.slug, s])); function hexToRgb(hex) { const n = parseInt(hex.replace("#", ""), 16); return [((n >> 16) & 255) / 255, ((n >> 8) & 255) / 255, (n & 255) / 255, 1]; } 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.`);