diff --git a/data-pipeline/scripts/extract-manifest.mjs b/data-pipeline/scripts/extract-manifest.mjs index c7afb31..7079c56 100644 --- a/data-pipeline/scripts/extract-manifest.mjs +++ b/data-pipeline/scripts/extract-manifest.mjs @@ -204,51 +204,179 @@ function meshLocalBBox(mesh) { 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)); +// 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; +} - 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 }, - ]; +// 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 (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); + 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)); @@ -265,9 +393,9 @@ function buildGrooveMesh(doc, material, p0, p1, thickness, depthThickness, depth // 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. +// 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; @@ -278,9 +406,11 @@ function addCosmeticGrooves(doc, targetNode, kind, material, slug) { 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 frontDepth = (depthSign > 0 ? max[depthIdx] : min[depthIdx]) + depthSign * dRange * 0.08; const assemble = (lateralVal, vertVal, depthVal) => { const p = [0, 0, 0]; @@ -289,37 +419,70 @@ function addCosmeticGrooves(doc, targetNode, kind, material, slug) { p[depthIdx] = depthVal; return p; }; - const depthUnit = assemble(0, 0, 1).map((v, i) => (i === depthIdx ? depthSign : 0)); + + // 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). + // 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 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); + 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 (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). + // aproximação: marcas diagonais curtas espaçadas perto da borda + // superior do músculo, cada uma com 3 âncoras suavizadas. 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); + 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); } } @@ -750,7 +913,7 @@ for (const { fbx, key } of SOURCES) { if (!grooveMaterialCache.has(muscleColorKey)) { const baseHex = systemBySlug.get("muscular")?.colorHex ?? "#cccccc"; const muscleHex = muscleColorVariant(baseHex, muscleColorKey); - const grooveHex = darkenHex(muscleHex, 0.16); + const grooveHex = darkenHex(muscleHex, 0.24); const mat = doc .createMaterial(`groove::${muscleColorKey}`) .setBaseColorFactor(hexToRgb(grooveHex))