/* Slapstack Studio layer math — NEW mechanics on top of the verified core. core.js is untouched; everything here is presentation-adjacent inference plumbing, tested by tests_studio.js: renderEnv per-layer envelope-energy buffer (for alpha + coverage) coverageOf how much front-stack mass sits on each atom center autoHidden coverage -> per-atom evidence mask (depth occlusion) compositePainter back-to-front alpha compositing of layer buffers composePose Sim(2) composition in vote coordinates [tx,ty,rho,lam] */ "use strict"; const ALPHA_C = 2.0; // alpha = 1 - exp(-ALPHA_C * bounded envelope energy) const COVER_THRESH = 0.55; // atom loses evidence when this covered /* Opacity of a layer = where it actually PAINTS: per-pixel deviation of the pre-sigmoid field from neutral. Cancelling atom pairs (high energy, zero sum) are correctly transparent — envelope-based opacity is not used because it saturates on exactly those invisible pairs. a = 1 - exp(-ALPHA_C * max(0, |preR|+|preG|+|preB| - FLOOR)). */ const ALPHA_FLOOR = 0.08; function alphaFromPre(pre, H, out) { out = out || new Float32Array(H * H); const n = H * H; for (let i = 0; i < n; i++) { const m = Math.abs(pre[i]) + Math.abs(pre[n + i]) + Math.abs(pre[2 * n + i]); out[i] = m > ALPHA_FLOOR ? 1 - Math.exp(-ALPHA_C * (m - ALPHA_FLOOR)) : 0; } return out; } /* coverage of each atom center by a front stack of alpha buffers: cov_i = 1 - prod_front (1 - a_m(x_i)). */ function coverageOf(atoms, frontAlphaBufs, H) { const out = new Float32Array(atoms.length); for (let i = 0; i < atoms.length; i++) { const px = Math.max(0, Math.min(H - 1, Math.round((atoms[i][0] + 1) / 2 * (H - 1)))); const py = Math.max(0, Math.min(H - 1, Math.round((atoms[i][1] + 1) / 2 * (H - 1)))); let keep = 1; for (const buf of frontAlphaBufs) keep *= 1 - buf[py * H + px]; out[i] = 1 - keep; } return out; } /* Per-atom evidence mask from the depth stack. alphaBufs aligned with layers. An atom of layer k is evidence-free if the strictly-in-front stack covers it beyond COVER_THRESH, or its layer is user-hidden. */ function autoHidden(obs, layerOf, layers, alphaBufs, H) { const mask = new Array(obs.length).fill(false); const covFrac = layers.map(() => 0); const counts = layers.map(() => 0); for (let i = 0; i < obs.length; i++) { const k = layerOf[i]; if (k < 0) continue; // clutter: always evidenced counts[k]++; if (layers[k].hidden) { mask[i] = true; covFrac[k]++; continue; } const front = []; for (let m = 0; m < layers.length; m++) if (m !== k && !layers[m].hidden && layers[m].depth > layers[k].depth) front.push(alphaBufs[m]); if (!front.length) continue; const cov = coverageOf([obs[i]], front, H)[0]; if (cov > COVER_THRESH) { mask[i] = true; covFrac[k]++; } } for (let k = 0; k < layers.length; k++) covFrac[k] = counts[k] ? covFrac[k] / counts[k] : 0; return { mask, covFrac }; } /* Painter compositing: layers back-to-front by depth. rgbBufs[k]: Float32Array(3*H*H) pre-sigmoid; alphaBufs[k]: from alphaFromPre. Base is mid-gray (sigmoid(0)). */ function compositePainter(order, rgbBufs, alphaBufs, H, out) { out = out || new Uint8ClampedArray(4 * H * H); const n = H * H; const acc = new Float32Array(3 * n); for (let i = 0; i < n; i++) { acc[i] = 127.5; acc[n + i] = 127.5; acc[2 * n + i] = 127.5; } for (const k of order) { const pre = rgbBufs[k], al = alphaBufs[k]; for (let i = 0; i < n; i++) { const a = al[i]; if (a < 1e-3) continue; acc[i] += a * (255 / (1 + Math.exp(-2 * pre[i])) - acc[i]); acc[n + i] += a * (255 / (1 + Math.exp(-2 * pre[n + i])) - acc[n + i]); acc[2 * n + i] += a * (255 / (1 + Math.exp(-2 * pre[2 * n + i])) - acc[2 * n + i]); } } for (let i = 0; i < n; i++) { out[4 * i] = acc[i]; out[4 * i + 1] = acc[n + i]; out[4 * i + 2] = acc[2 * n + i]; out[4 * i + 3] = 255; } return out; } /* Backdrop trim: whole-image fits carry large-sigma background pads that cover the full frame; for OBJECT layers, drop atoms whose envelope is wider than sigMax. HEURISTIC, honestly: it also kills any genuinely large object parts. The untrimmed layer is kept in the library. */ function trimAtoms(atoms, sigMax = 0.35) { const kept = atoms.filter(a => Math.max(a[3], a[4]) <= sigMax); if (!kept.length) return atoms.slice(); // re-center xy so the trimmed set is a canonical template again let mx = 0, my = 0; for (const a of kept) { mx += a[0]; my += a[1]; } mx /= kept.length; my /= kept.length; return kept.map(a => { const b = a.slice(); b[0] -= mx; b[1] -= my; return b; }); } /* ---- Bet 9 additions: eraser + backdrop + band gains (pure, testable) ---- */ /* Frequency band of an atom. f is cycles/unit; the frame spans 2 units, so cycles-per-image = 2f. Bands follow the original splatstack dials: coarse < 4 c/img, mid 4–10, fine > 10. Returns 0|1|2. */ const BAND_EDGES = [2.0, 5.0]; function bandOf(f) { return f < BAND_EDGES[0] ? 0 : f < BAND_EDGES[1] ? 1 : 2; } /* Erase mask: true for atoms whose center falls inside the brush circle. skip(i) can veto (e.g. atoms of hidden layers stay untouched). */ function eraseMask(obs, wx, wy, r, skip) { const r2 = r * r; return obs.map((a, i) => { if (skip && skip(i)) return false; const dx = a[0] - wx, dy = a[1] - wy; return dx * dx + dy * dy < r2; }); } /* Composite an already-sigmoided field over a backdrop. img: Uint8ClampedArray RGBA from sigmoidField/compositePainter (mutated). alpha: Float32Array(H*H) from alphaFromPre of the (band-gained) pre field. opacity: global gain on alpha, clamped to [0,1] per pixel. bg: either [r,g,b] flat color or a Uint8ClampedArray(4*H*H) RGBA image. Identity check: with alpha==1 everywhere or bg==the field itself the output is unchanged; with opacity such that a==1 the field wins. */ function blendOverBackdrop(img, alpha, opacity, bg, H) { const n = H * H; const flat = !(bg && bg.length === 4 * n); for (let i = 0; i < n; i++) { const a = Math.min(1, alpha[i] * opacity); const br = flat ? bg[0] : bg[4 * i], bgr = flat ? bg[1] : bg[4 * i + 1], bb = flat ? bg[2] : bg[4 * i + 2]; img[4 * i] = br + a * (img[4 * i] - br); img[4 * i + 1] = bgr + a * (img[4 * i + 1] - bgr); img[4 * i + 2] = bb + a * (img[4 * i + 2] - bb); } return img; } /* Sim(2) composition in vote coordinates: (g2 ∘ g1). */ function composePose(g2, g1) { const s2 = Math.exp(g2[3]); const c = Math.cos(g2[2]), s = Math.sin(g2[2]); const wrap = d => ((d + Math.PI) % (2 * Math.PI) + 2 * Math.PI) % (2 * Math.PI) - Math.PI; return [s2 * (c * g1[0] - s * g1[1]) + g2[0], s2 * (s * g1[0] + c * g1[1]) + g2[1], wrap(g1[2] + g2[2]), g1[3] + g2[3]]; } function gestureTranslate(dx, dy) { return [dx, dy, 0, 0]; } function gestureRotateAbout(c, drho) { const co = Math.cos(drho), si = Math.sin(drho); return [c[0] - (co * c[0] - si * c[1]), c[1] - (si * c[0] + co * c[1]), drho, 0]; } function gestureScaleAbout(c, ds) { const s = Math.exp(ds); return [c[0] - s * c[0], c[1] - s * c[1], 0, ds]; } if (typeof module !== "undefined") { module.exports = { ALPHA_C, COVER_THRESH, alphaFromPre, coverageOf, autoHidden, compositePainter, composePose, gestureTranslate, gestureRotateAbout, gestureScaleAbout, trimAtoms, BAND_EDGES, bandOf, eraseMask, blendOverBackdrop, }; }