This commit is contained in:
Rodrigo Bertoldo de Sousa Martins committed 2026-09-13 18:36:08 -03:00
1 parent 5e6d870410
commit 038ad91bbe
1 file changed
+227 -64
+227 -64
View File
@@ -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))