initial remote

This commit is contained in:
2026-05-20 17:10:32 -04:00
parent dd138d5c4f
commit 3983cf2e0d
18 changed files with 553606 additions and 272 deletions

View File

@@ -181,7 +181,7 @@ function buildPanel() {
}
// toggles belonging to a group title go after Film & Plate / Shock
panel.appendChild(grp);
if (g.title === 'Shock-wave Disk') addToggle(grp, 'shock');
if (g.title === 'Shock-wave Disk') { addToggle(grp, 'shock'); addToggle(grp, 'diskBubbles'); }
if (g.title === 'Film & Plate') {
addToggle(grp, 'showFiducials');
addToggle(grp, 'showBoundary');
@@ -869,7 +869,64 @@ function generateShock(params, rng) {
});
}
return { x, y, r, intensity: I, stain, striations, rimSegs, rings, stains, core, bright: r * 0.12 };
const shock = { x, y, r, intensity: I, stain, striations, rimSegs, rings, stains, core, bright: r * 0.12 };
// Describe the disk's line work with the SAME bubble-nucleation method as the
// particle tracks, so it shares their granular texture instead of reading as
// clean vector strokes. Each entry is a track-like polyline for sampleBubbles.
shock.bubbleStrokes = buildBubbleStrokes(shock);
return shock;
}
const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
function buildBubbleStrokes(sh) {
const { x, y, r } = sh;
const out = [];
// radial striations → short streaks (perpendicular jitter gives them width)
for (const s of sh.striations) {
out.push({
pts: [
{ x: x + Math.cos(s.a) * s.inner, y: y + Math.sin(s.a) * s.inner, beta: 0.58 },
{ x: x + Math.cos(s.a) * s.outer, y: y + Math.sin(s.a) * s.outer, beta: 0.5 },
],
weight: clamp(s.opacity * 2.0, 0.35, 0.95),
densityScale: 0.22, sizeScale: 0.9, jitter: 0.0045,
});
}
// eroded rim segments → dense arcs of bubbles
for (const seg of sh.rimSegs) {
const steps = Math.max(3, Math.round((seg.a1 - seg.a0) / 0.06));
const pts = [];
for (let i = 0; i <= steps; i++) {
const a = seg.a0 + (seg.a1 - seg.a0) * (i / steps);
pts.push({ x: x + Math.cos(a) * r, y: y + Math.sin(a) * r, beta: 0.45 });
}
out.push({ pts, weight: clamp(seg.opacity * 1.6, 0.4, 0.95), densityScale: 0.34, sizeScale: 1.0, jitter: 0.004 });
}
// concentric pressure-front rings → faint dotted circles
for (const ring of sh.rings) {
const n = Math.max(40, Math.round(ring.rr * 200));
const pts = [];
for (let i = 0; i <= n; i++) {
const a = (i / n) * Math.PI * 2;
pts.push({ x: x + Math.cos(a) * ring.rr, y: y + Math.sin(a) * ring.rr, beta: 0.7 });
}
out.push({ pts, weight: clamp(ring.opacity * 2.6, 0.3, 0.8), densityScale: 0.12, sizeScale: 0.85, jitter: 0.003 });
}
// textured core cracks → short bubble streaks
for (const k of sh.core) {
out.push({
pts: [{ x: k.x1, y: k.y1, beta: 0.42 }, { x: k.x2, y: k.y2, beta: 0.42 }],
weight: clamp(k.opacity * 1.6, 0.35, 0.9),
densityScale: 0.3, sizeScale: 0.95, jitter: 0.0045,
});
}
return out;
}
Object.assign(exports, { generateShock });
@@ -1184,7 +1241,7 @@ function renderCanvasPhoto(ctx, w, h, scene, params, opts = {}) {
ic.globalAlpha = 1;
// 3c. shock disk drawn into the ink layer so bloom catches it
if (scene.shock) drawShock(ic, scene.shock, tx, ty, scale, u, P);
if (scene.shock) drawShock(ic, scene.shock, tx, ty, scale, u, P, params, sprite);
/* ---------- Pass 4: halation / bloom ---------- */
// composite a blurred copy of the ink under the sharp ink for soft spread
@@ -1244,51 +1301,63 @@ function renderCanvasPhoto(ctx, w, h, scene, params, opts = {}) {
}
/* ---- shock disk ---- */
function drawShock(c, shock, tx, ty, scale, u, P) {
function drawShock(c, shock, tx, ty, scale, u, P, params, sprite) {
const [r, g, b] = P.ink;
const px = tx(shock.x), py = ty(shock.y), R = shock.r * scale;
const useBubbles = params.diskBubbles !== false;
// disk body: dark annulus that keeps a lighter, detailed centre
// disk body: dark annulus that keeps a lighter, detailed centre.
// softer when the line work is bubbles (the bubbles carry the density).
const bodyK = useBubbles ? 0.6 : 1.0;
const core = c.createRadialGradient(px, py, 0, px, py, R);
core.addColorStop(0.0, `rgba(${r},${g},${b},${0.06 * shock.intensity})`);
core.addColorStop(0.35, `rgba(${r},${g},${b},${0.18 * shock.intensity})`);
core.addColorStop(0.72, `rgba(${r},${g},${b},${0.38 * shock.intensity})`);
core.addColorStop(0.94, `rgba(${r},${g},${b},${0.34 * shock.intensity})`);
core.addColorStop(0.0, `rgba(${r},${g},${b},${0.06 * shock.intensity * bodyK})`);
core.addColorStop(0.35, `rgba(${r},${g},${b},${0.18 * shock.intensity * bodyK})`);
core.addColorStop(0.72, `rgba(${r},${g},${b},${0.38 * shock.intensity * bodyK})`);
core.addColorStop(0.94, `rgba(${r},${g},${b},${0.34 * shock.intensity * bodyK})`);
core.addColorStop(1, `rgba(${r},${g},${b},0)`);
c.fillStyle = core;
c.beginPath(); c.arc(px, py, R, 0, Math.PI * 2); c.fill();
// radial striations (the sunburst)
c.lineCap = 'round';
for (const s of shock.striations) {
const ix = px + Math.cos(s.a) * s.inner * scale;
const iy = py + Math.sin(s.a) * s.inner * scale;
const ox = px + Math.cos(s.a) * s.outer * scale;
const oy = py + Math.sin(s.a) * s.outer * scale;
const mx = (ix + ox) / 2 + Math.cos(s.a + Math.PI / 2) * s.wobble * scale;
const my = (iy + oy) / 2 + Math.sin(s.a + Math.PI / 2) * s.wobble * scale;
c.strokeStyle = `rgba(${r},${g},${b},${s.opacity})`;
c.lineWidth = s.width * u;
c.beginPath();
c.moveTo(ix, iy);
c.quadraticCurveTo(mx, my, ox, oy);
c.stroke();
}
// inner pressure-front rings
for (const ring of shock.rings) {
c.strokeStyle = `rgba(${r},${g},${b},${ring.opacity})`;
c.lineWidth = ring.width * u;
c.beginPath(); c.arc(px, py, ring.rr * scale, 0, Math.PI * 2); c.stroke();
}
// rim as eroded arc segments
if (shock.rimSegs) {
for (const seg of shock.rimSegs) {
if (useBubbles && shock.bubbleStrokes) {
// describe striations / rings / rim / core with the particle method
const dRng = makeRng(params.seed, 'diskbubbles');
for (const stroke of shock.bubbleStrokes) {
const bubs = sampleBubbles(stroke, params, dRng);
c.globalAlpha = Math.min(1, 0.45 + stroke.weight * 0.5);
for (const bb of bubs) {
const rr = Math.max(bb.r * scale, 0.45);
const d = rr * 2.4;
c.drawImage(sprite, tx(bb.x) - d / 2, ty(bb.y) - d / 2, d, d);
}
}
c.globalAlpha = 1;
} else {
// legacy: clean vector strokes
c.lineCap = 'round';
for (const s of shock.striations) {
const ix = px + Math.cos(s.a) * s.inner * scale, iy = py + Math.sin(s.a) * s.inner * scale;
const ox = px + Math.cos(s.a) * s.outer * scale, oy = py + Math.sin(s.a) * s.outer * scale;
const mx = (ix + ox) / 2 + Math.cos(s.a + Math.PI / 2) * s.wobble * scale;
const my = (iy + oy) / 2 + Math.sin(s.a + Math.PI / 2) * s.wobble * scale;
c.strokeStyle = `rgba(${r},${g},${b},${s.opacity})`;
c.lineWidth = s.width * u;
c.beginPath(); c.moveTo(ix, iy); c.quadraticCurveTo(mx, my, ox, oy); c.stroke();
}
for (const ring of shock.rings) {
c.strokeStyle = `rgba(${r},${g},${b},${ring.opacity})`;
c.lineWidth = ring.width * u;
c.beginPath(); c.arc(px, py, ring.rr * scale, 0, Math.PI * 2); c.stroke();
}
for (const seg of (shock.rimSegs || [])) {
c.strokeStyle = `rgba(${r},${g},${b},${seg.opacity})`;
c.lineWidth = seg.width * u;
c.beginPath(); c.arc(px, py, shock.r * scale, seg.a0, seg.a1); c.stroke();
}
for (const k of shock.core) {
c.strokeStyle = `rgba(${r},${g},${b},${k.opacity})`;
c.lineWidth = k.width * u;
c.beginPath(); c.moveTo(tx(k.x1), ty(k.y1)); c.lineTo(tx(k.x2), ty(k.y2)); c.stroke();
}
}
// staining blotches: dark = grime, light = lifted/washed clean spots
@@ -1312,13 +1381,6 @@ function drawShock(c, shock, tx, ty, scale, u, P) {
c.globalCompositeOperation = 'source-over';
}
// textured core chords
for (const k of shock.core) {
c.strokeStyle = `rgba(${r},${g},${b},${k.opacity})`;
c.lineWidth = k.width * u;
c.beginPath(); c.moveTo(tx(k.x1), ty(k.y1)); c.lineTo(tx(k.x2), ty(k.y2)); c.stroke();
}
// keep a bright, detailed centre by clearing a soft hole in the ink
if (shock.bright) {
c.save();
@@ -1477,6 +1539,12 @@ function sampleBubbles(track, params, rng) {
const pts = track.pts;
if (pts.length < 2) return bubbles;
// per-track scales let non-track features (e.g. the shock disk) reuse the
// exact same nucleation method with their own density/size/jitter.
const dScale = track.densityScale ?? 1;
const sScale = track.sizeScale ?? 1;
const jitter = track.jitter ?? 0.0016;
for (let i = 1; i < pts.length; i++) {
const a = pts[i - 1], b = pts[i];
const dx = b.x - a.x, dy = b.y - a.y;
@@ -1484,7 +1552,7 @@ function sampleBubbles(track, params, rng) {
if (segLen === 0) continue;
const beta = Math.max(a.beta, 0.13);
const lambda = params.density * track.weight * (1 / (beta * beta)) * 420;
const lambda = params.density * track.weight * dScale * (1 / (beta * beta)) * 420;
const expected = lambda * segLen;
const nb = Math.floor(expected) + (rng() < (expected - Math.floor(expected)) ? 1 : 0);
@@ -1493,8 +1561,8 @@ function sampleBubbles(track, params, rng) {
const t = rng();
const px = a.x + dx * t;
const py = a.y + dy * t;
const j = gauss(rng) * 0.0016;
const baseR = (0.0011 + (1 - beta) * 0.0017) * params.size;
const j = gauss(rng) * jitter;
const baseR = (0.0011 + (1 - beta) * 0.0017) * params.size * sScale;
// occasional fat bubble (clumped nucleation)
const fat = rng() < 0.05 ? 1.8 + rng() : 1;
const r = baseR * (0.65 + rng() * 0.55) * fat;
@@ -1718,26 +1786,43 @@ function renderSVG(scene, params, sizePx = 4800) {
if (scene.shock) {
const sh = scene.shock;
const px = +tx(sh.x), py = +ty(sh.y), R = sh.r * scale;
s += `<circle cx="${px}" cy="${py}" r="${R.toFixed(1)}" fill="url(#shockcore)"/>\n`;
s += `<g stroke="${ink}" stroke-linecap="round" fill="none">\n`;
for (const st of sh.striations) {
const ix = px + Math.cos(st.a) * st.inner * scale, iy = py + Math.sin(st.a) * st.inner * scale;
const ox = px + Math.cos(st.a) * st.outer * scale, oy = py + Math.sin(st.a) * st.outer * scale;
s += `<line x1="${ix.toFixed(1)}" y1="${iy.toFixed(1)}" x2="${ox.toFixed(1)}" y2="${oy.toFixed(1)}" stroke-opacity="${st.opacity.toFixed(3)}" stroke-width="${(st.width * u).toFixed(2)}"/>`;
const bodyOpacity = (params.diskBubbles !== false) ? 0.6 : 1;
s += `<circle cx="${px}" cy="${py}" r="${R.toFixed(1)}" fill="url(#shockcore)" fill-opacity="${bodyOpacity}"/>\n`;
if (params.diskBubbles !== false && sh.bubbleStrokes) {
// describe the disk line work with bubbles (same method as tracks)
const dRng = makeRng(params.seed, 'diskbubbles');
const dbk = new Map();
for (const stroke of sh.bubbleStrokes) {
const key = Math.round(Math.min(1, 0.45 + stroke.weight * 0.5) * 20) / 20;
if (!dbk.has(key)) dbk.set(key, []);
dbk.get(key).push(...sampleBubbles(stroke, params, dRng));
}
for (const [alpha, bubs] of dbk) {
s += `<g fill="url(#bub)" fill-opacity="${alpha}">`;
for (const b of bubs) s += `<circle cx="${tx(b.x)}" cy="${ty(b.y)}" r="${Math.max(b.r * scale * 1.15, 0.5).toFixed(2)}"/>`;
s += `</g>\n`;
}
} else {
s += `<g stroke="${ink}" stroke-linecap="round" fill="none">\n`;
for (const st of sh.striations) {
const ix = px + Math.cos(st.a) * st.inner * scale, iy = py + Math.sin(st.a) * st.inner * scale;
const ox = px + Math.cos(st.a) * st.outer * scale, oy = py + Math.sin(st.a) * st.outer * scale;
s += `<line x1="${ix.toFixed(1)}" y1="${iy.toFixed(1)}" x2="${ox.toFixed(1)}" y2="${oy.toFixed(1)}" stroke-opacity="${st.opacity.toFixed(3)}" stroke-width="${(st.width * u).toFixed(2)}"/>`;
}
for (const ring of sh.rings) {
s += `<circle cx="${px}" cy="${py}" r="${(ring.rr * scale).toFixed(1)}" stroke-opacity="${ring.opacity.toFixed(3)}" stroke-width="${(ring.width * u).toFixed(2)}"/>`;
}
for (const seg of (sh.rimSegs || [])) {
const x0 = px + Math.cos(seg.a0) * R, y0 = py + Math.sin(seg.a0) * R;
const x1a = px + Math.cos(seg.a1) * R, y1a = py + Math.sin(seg.a1) * R;
s += `<path d="M ${x0.toFixed(1)} ${y0.toFixed(1)} A ${R.toFixed(1)} ${R.toFixed(1)} 0 0 1 ${x1a.toFixed(1)} ${y1a.toFixed(1)}" stroke-opacity="${seg.opacity.toFixed(3)}" stroke-width="${(seg.width * u).toFixed(2)}"/>`;
}
for (const k of sh.core) {
s += `<line x1="${tx(k.x1)}" y1="${ty(k.y1)}" x2="${tx(k.x2)}" y2="${ty(k.y2)}" stroke-opacity="${k.opacity.toFixed(3)}" stroke-width="${(k.width * u).toFixed(2)}"/>`;
}
s += `\n</g>\n`;
}
for (const ring of sh.rings) {
s += `<circle cx="${px}" cy="${py}" r="${(ring.rr * scale).toFixed(1)}" stroke-opacity="${ring.opacity.toFixed(3)}" stroke-width="${(ring.width * u).toFixed(2)}"/>`;
}
// eroded rim segments
for (const seg of (sh.rimSegs || [])) {
const x0 = px + Math.cos(seg.a0) * R, y0 = py + Math.sin(seg.a0) * R;
const x1a = px + Math.cos(seg.a1) * R, y1a = py + Math.sin(seg.a1) * R;
s += `<path d="M ${x0.toFixed(1)} ${y0.toFixed(1)} A ${R.toFixed(1)} ${R.toFixed(1)} 0 0 1 ${x1a.toFixed(1)} ${y1a.toFixed(1)}" stroke-opacity="${seg.opacity.toFixed(3)}" stroke-width="${(seg.width * u).toFixed(2)}"/>`;
}
for (const k of sh.core) {
s += `<line x1="${tx(k.x1)}" y1="${ty(k.y1)}" x2="${tx(k.x2)}" y2="${ty(k.y2)}" stroke-opacity="${k.opacity.toFixed(3)}" stroke-width="${(k.width * u).toFixed(2)}"/>`;
}
s += `\n</g>\n`;
// staining blotches (dark grime / light washed spots)
for (const st of (sh.stains || [])) {
const sr = (st.r * scale).toFixed(1);
@@ -1898,17 +1983,34 @@ function buildPDF(scene, params, pageSize = 1728) {
if (scene.shock) {
const sh = scene.shock;
const px = tx(sh.x), py = ty(sh.y);
c += `q\n${inkCMYK} K\n1 J\n`;
for (const st of sh.striations) {
const ix = px + Math.cos(st.a) * st.inner * scale, iy = py - Math.sin(st.a) * st.inner * scale;
const ox = px + Math.cos(st.a) * st.outer * scale, oy = py - Math.sin(st.a) * st.outer * scale;
c += `${gs(st.opacity)}${(st.width * u).toFixed(2)} w\n${ix.toFixed(1)} ${iy.toFixed(1)} m ${ox.toFixed(1)} ${oy.toFixed(1)} l S\n`;
if (params.diskBubbles !== false && sh.bubbleStrokes) {
// disk line work as bubbles (same method as tracks)
const dRng = makeRng(params.seed, 'diskbubbles');
const dbk = new Map();
for (const stroke of sh.bubbleStrokes) {
const key = Math.round(Math.min(1, 0.45 + stroke.weight * 0.5) * 20) / 20;
if (!dbk.has(key)) dbk.set(key, []);
dbk.get(key).push(...sampleBubbles(stroke, params, dRng));
}
c += `q\n${inkCMYK} k\n`;
for (const [alpha, bubs] of dbk) {
c += gs(alpha);
for (const b of bubs) c += circlePath(tx(b.x), ty(b.y), Math.max(b.r * scale, 0.5)) + 'f\n';
}
c += `Q\n`;
} else {
c += `q\n${inkCMYK} K\n1 J\n`;
for (const st of sh.striations) {
const ix = px + Math.cos(st.a) * st.inner * scale, iy = py - Math.sin(st.a) * st.inner * scale;
const ox = px + Math.cos(st.a) * st.outer * scale, oy = py - Math.sin(st.a) * st.outer * scale;
c += `${gs(st.opacity)}${(st.width * u).toFixed(2)} w\n${ix.toFixed(1)} ${iy.toFixed(1)} m ${ox.toFixed(1)} ${oy.toFixed(1)} l S\n`;
}
for (const k of sh.core) {
c += `${gs(k.opacity)}${(k.width * u).toFixed(2)} w\n${tx(k.x1).toFixed(1)} ${ty(k.y1).toFixed(1)} m ${tx(k.x2).toFixed(1)} ${ty(k.y2).toFixed(1)} l S\n`;
}
for (const ring of sh.rings) c += `${gs(ring.opacity)}${(ring.width * u).toFixed(2)} w\n` + strokeCircle(px, py, ring.rr * scale);
c += `Q\n`;
}
for (const k of sh.core) {
c += `${gs(k.opacity)}${(k.width * u).toFixed(2)} w\n${tx(k.x1).toFixed(1)} ${ty(k.y1).toFixed(1)} m ${tx(k.x2).toFixed(1)} ${ty(k.y2).toFixed(1)} l S\n`;
}
for (const ring of sh.rings) c += `${gs(ring.opacity)}${(ring.width * u).toFixed(2)} w\n` + strokeCircle(px, py, ring.rr * scale);
c += `Q\n`;
}
// bubbles (bucketed by alpha)
@@ -2091,6 +2193,7 @@ const GROUPS = [
const TOGGLES = [
{ id: 'shock', label: 'Shock-wave disk', value: true, mode: 'scene' },
{ id: 'diskBubbles', label: 'Disk as bubbles', value: true, mode: 'scene' },
{ id: 'showFiducials', label: 'Fiducial marks', value: true, mode: 'render' },
{ id: 'showBoundary', label: 'Chamber boundary', value: true, mode: 'render' },
{ id: 'showHeader', label: 'Archival header', value: true, mode: 'render' },
@@ -2186,7 +2289,7 @@ function paramsFromSeed(seed) {
cosmics: ri(4, 10), sweepers: ri(2, 7),
bfield: r(0.8, 1.6), eloss: r(0.45, 0.75), pspread: r(0.6, 0.85),
deltaRate: r(0.6, 0.95), deltaTight: r(0.6, 1.0),
shock: true, shockIntensity: r(0.6, 0.9), shockSize: r(0.26, 0.4),
shock: true, diskBubbles: true, shockIntensity: r(0.6, 0.9), shockSize: r(0.26, 0.4),
shockStriations: r(0.45, 0.85), shockY: r(0.4, 0.6), shockX: r(-0.12, 0.12),
shockStain: Math.pow(rng(), 1.5), // skew cleaner, allow grime
instrument: r(0.25, 0.6),