From 5e6d870410150a4838576c90cd8fca0b57b15001 Mon Sep 17 00:00:00 2001 From: Rodrigo Bertoldo de Sousa Martins Date: Fri, 11 Sep 2026 22:02:24 -0300 Subject: [PATCH] retrabalho muscular --- data-pipeline/scripts/extract-manifest.mjs | 255 +++++++++++++++++++++ data-pipeline/scripts/translate-pt.mjs | 2 +- 2 files changed, 256 insertions(+), 1 deletion(-) diff --git a/data-pipeline/scripts/extract-manifest.mjs b/data-pipeline/scripts/extract-manifest.mjs index a43c6ae..c7afb31 100644 --- a/data-pipeline/scripts/extract-manifest.mjs +++ b/data-pipeline/scripts/extract-manifest.mjs @@ -106,6 +106,230 @@ function muscleColorVariant(baseHex, variantKey) { 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 }; +} + +// Monta um "prisma fino" (caixa alongada) indo do ponto p0 ao p1, com +// espessura `thickness` no plano perpendicular ao segmento e +// `depthThickness` ao longo de `depthUnit` — é o sulco decorativo em si. +// Dupla face (setDoubleSided) evita se preocupar com a ordem de +// enrolamento exata de cada uma das 6 faces (irrelevante pra um detalhe +// decorativo pequeno). +function buildGrooveMesh(doc, material, p0, p1, thickness, depthThickness, depthUnit) { + const dir = vecSub(p1, p0); + const len = vecLen(dir); + if (len < 1e-6) return null; + const dirN = vecScale(dir, 1 / len); + let perp = vecCross(dirN, depthUnit); + if (vecLen(perp) < 1e-6) perp = vecCross(dirN, [1, 0, 0]); + perp = vecNorm(perp); + const halfT = thickness / 2; + const halfD = depthThickness / 2; + const corner = (pt, sPerp, sDepth) => + vecAdd(vecAdd(pt, vecScale(perp, sPerp * halfT)), vecScale(depthUnit, sDepth * halfD)); + + const c = [ + corner(p0, -1, -1), corner(p1, -1, -1), corner(p1, 1, -1), corner(p0, 1, -1), + corner(p0, -1, 1), corner(p1, -1, 1), corner(p1, 1, 1), corner(p0, 1, 1), + ]; + const negDepth = vecScale(depthUnit, -1); + const negPerp = vecScale(perp, -1); + const negDir = vecScale(dirN, -1); + const faces = [ + { idx: [0, 1, 2, 3], n: negDepth }, + { idx: [7, 6, 5, 4], n: depthUnit }, + { idx: [4, 5, 1, 0], n: negPerp }, + { idx: [3, 2, 6, 7], n: perp }, + { idx: [0, 3, 7, 4], n: negDir }, + { idx: [1, 5, 6, 2], n: dirN }, + ]; + + const positions = []; + const normals = []; + const indices = []; + for (const face of faces) { + const base = positions.length / 3; + for (const i of face.idx) { + positions.push(...c[i]); + normals.push(...face.n); + } + indices.push(base, base + 1, base + 2, base, base + 2, base + 3); + } + + 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 (o bounding box calculado +// em cima dela). `slug` é o do músculo real: o sulco não é uma estrutura +// própria, só decoração com a MESMA identidade. +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 vertAt = (frac) => (vertSign > 0 ? min[vertIdx] + frac * vRange : max[vertIdx] - frac * vRange); + const lateralAt = (frac) => min[lateralIdx] + frac * lRange; + const frontDepth = (depthSign > 0 ? max[depthIdx] : min[depthIdx]) + depthSign * dRange * 0.08; + + const assemble = (lateralVal, vertVal, depthVal) => { + const p = [0, 0, 0]; + p[lateralIdx] = lateralVal; + p[vertIdx] = vertVal; + p[depthIdx] = depthVal; + return p; + }; + const depthUnit = assemble(0, 0, 1).map((v, i) => (i === depthIdx ? depthSign : 0)); + + 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). + for (const frac of [0.3, 0.55, 0.78]) { + const v = vertAt(frac); + const p0 = assemble(lateralAt(0.12), v, frontDepth); + const p1 = assemble(lateralAt(0.88), v, frontDepth); + const m = buildGrooveMesh(doc, material, p0, p1, vRange * 0.03, dRange * 0.2, depthUnit); + if (m) grooveMeshes.push(m); + } + } else if (kind === "digitations") { + // Digitações (serrátil anterior / oblíquo externo nas costelas) — + // aproximação: marcas diagonais CURTAS (relativas ao próprio espaço + // entre elas, não ao tamanho total do músculo — a malha real desses + // dois costuma ter um bounding box bem maior que a parte visível por + // fora, então usar frações grandes do bbox inteiro fazia as marcas + // saírem enormes, passando da silhueta do corpo). + const count = 4; + const step = lRange / count; + for (let i = 0; i < count; i++) { + const latCenter = lateralAt((i + 0.5) / count); + const dx = step * 0.14; + const vBase = vertAt(0.72); + const vTop = vertAt(0.72 + (vRange > 0 ? Math.min(0.1, (step * 0.28) / vRange) : 0.1)); + const p0 = assemble(latCenter - dx, vBase, frontDepth); + const p1 = assemble(latCenter + dx, vTop, frontDepth); + const m = buildGrooveMesh(doc, material, p0, p1, step * 0.22, dRange * 0.2, depthUnit); + 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) { @@ -361,6 +585,7 @@ for (const { fbx, key } of SOURCES) { 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) { @@ -446,6 +671,10 @@ for (const { fbx, key } of SOURCES) { // 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, @@ -509,6 +738,32 @@ for (const { fbx, key } of SOURCES) { // 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.16); + 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/`); diff --git a/data-pipeline/scripts/translate-pt.mjs b/data-pipeline/scripts/translate-pt.mjs index 57c72db..fbb9eea 100644 --- a/data-pipeline/scripts/translate-pt.mjs +++ b/data-pipeline/scripts/translate-pt.mjs @@ -428,7 +428,7 @@ const EXACT_PT = { "splenius capitis muscle": "Esplênio da Cabeça", "splenius colli muscle": "Esplênio do Pescoço", "sternocleidomastoid muscle": "Esternocleidomastóideo", - "sternocostal head of pectoralis major muscle": "Cabeça Esternocostal do Peitoral Maior", + "sternocostal head of pectoralis major muscle": "Peitoral Maior", "sternohyoid muscle": "Esternoióideo", "sternothyroid muscle": "Esternotireóideo", "straight part of cricothyroid muscle": "Porção Reta do Cricotireóideo",