initial remote
This commit is contained in:
@@ -40,7 +40,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');
|
||||
|
||||
@@ -135,7 +135,7 @@ export 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
|
||||
@@ -195,51 +195,63 @@ export 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
|
||||
@@ -263,13 +275,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();
|
||||
|
||||
@@ -80,17 +80,34 @@ export 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)
|
||||
|
||||
@@ -5,12 +5,25 @@
|
||||
with a shared radial-gradient fill; tracks get a faint
|
||||
continuity under-stroke. Grain/mottle are intentionally
|
||||
omitted (raster-only effects); this is the graphic version.
|
||||
|
||||
Output is organised into named LAYERS using the Inkscape
|
||||
convention (inkscape:groupmode="layer" + inkscape:label), so
|
||||
the file opens as toggleable layers in Inkscape / Affinity and
|
||||
as named groups in Illustrator — for easy post manipulation.
|
||||
============================================================ */
|
||||
import { makeRng, cyrb53 } from '../rng.js';
|
||||
import { sampleBubbles, trackInkWeight } from '../scene/bubbles.js';
|
||||
|
||||
const MARGIN = 0.02;
|
||||
|
||||
// which named track layer each particle kind belongs to (z-order: later = on top)
|
||||
const TRACK_LAYERS = [
|
||||
{ id: 'tracks-primary', label: 'Tracks · primary', kinds: ['primary'] },
|
||||
{ id: 'tracks-cosmic', label: 'Tracks · cosmic & sweepers', kinds: ['cosmic', 'sweep'] },
|
||||
{ id: 'tracks-vdecay', label: 'Tracks · V-decays', kinds: ['vdecay'] },
|
||||
{ id: 'tracks-delta', label: 'Tracks · δ-rays (curls)', kinds: ['delta'] },
|
||||
];
|
||||
|
||||
export function renderSVG(scene, params, sizePx = 4800) {
|
||||
const w = sizePx, h = sizePx;
|
||||
const scale = (w / 2) * (1 - MARGIN);
|
||||
@@ -24,12 +37,190 @@ export function renderSVG(scene, params, sizePx = 4800) {
|
||||
const glowIn = inv ? '#e2dbc7' : '#211e18';
|
||||
const glowOut = inv ? '#b3aa92' : '#070605';
|
||||
const ink = inv ? '#1c1814' : '#e9e4d6';
|
||||
const inkRGB = inv ? '28,24,20' : '233,228,214';
|
||||
|
||||
// wrap content as a named, Inkscape/Affinity-compatible layer
|
||||
const layer = (id, label, content) =>
|
||||
content ? `<g id="${id}" inkscape:groupmode="layer" inkscape:label="${label}" style="display:inline">\n${content}\n</g>\n` : '';
|
||||
|
||||
/* ---------- Background ---------- */
|
||||
const bg = `<rect width="${w}" height="${h}" fill="${paper}"/>\n<rect width="${w}" height="${h}" fill="url(#paper)"/>`;
|
||||
|
||||
/* ---------- Chamber optics (boundary + structural geometry) ---------- */
|
||||
let optics = '';
|
||||
if (params.showBoundary) {
|
||||
const bcx = cx, bcy = cy + h * 0.35, br = w * 0.45;
|
||||
const a1 = Math.PI * 0.15, a2 = Math.PI - Math.PI * 0.15;
|
||||
const x1 = bcx + Math.cos(Math.PI + a1) * br, y1 = bcy + Math.sin(Math.PI + a1) * br;
|
||||
const x2 = bcx + Math.cos(Math.PI + a2) * br, y2 = bcy + Math.sin(Math.PI + a2) * br;
|
||||
optics += `<path d="M ${x1.toFixed(1)} ${y1.toFixed(1)} A ${br} ${br} 0 0 1 ${x2.toFixed(1)} ${y2.toFixed(1)}" fill="none" stroke="${ink}" stroke-opacity="0.4" stroke-width="${2 * u}"/>\n`;
|
||||
}
|
||||
if (scene.instrument) {
|
||||
const inst = scene.instrument;
|
||||
let g = `<g fill="none" stroke="${ink}" stroke-linecap="round">`;
|
||||
for (const l of inst.lines)
|
||||
g += `<line x1="${tx(l.x1)}" y1="${ty(l.y1)}" x2="${tx(l.x2)}" y2="${ty(l.y2)}" stroke-opacity="${l.opacity.toFixed(3)}" stroke-width="${(l.width * u).toFixed(2)}"/>`;
|
||||
for (const a of inst.arcs) {
|
||||
let d = `M ${tx(a.pts[0].x)} ${ty(a.pts[0].y)}`;
|
||||
for (let i = 1; i < a.pts.length; i++) d += ` L ${tx(a.pts[i].x)} ${ty(a.pts[i].y)}`;
|
||||
g += `<path d="${d}" stroke-opacity="${a.opacity.toFixed(3)}" stroke-width="${(a.width * u).toFixed(2)}"/>`;
|
||||
}
|
||||
optics += g + `</g>`;
|
||||
}
|
||||
|
||||
/* ---------- Shock disk ---------- */
|
||||
let shock = '';
|
||||
if (scene.shock) {
|
||||
const sh = scene.shock;
|
||||
const px = +tx(sh.x), py = +ty(sh.y), R = sh.r * scale;
|
||||
const bodyOpacity = (params.diskBubbles !== false) ? 0.6 : 1;
|
||||
shock += `<circle cx="${px}" cy="${py}" r="${R.toFixed(1)}" fill="url(#shockcore)" fill-opacity="${bodyOpacity}"/>\n`;
|
||||
if (params.diskBubbles !== false && sh.bubbleStrokes) {
|
||||
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) {
|
||||
shock += `<g fill="url(#bub)" fill-opacity="${alpha}">`;
|
||||
for (const b of bubs) shock += `<circle cx="${tx(b.x)}" cy="${ty(b.y)}" r="${Math.max(b.r * scale * 1.15, 0.5).toFixed(2)}"/>`;
|
||||
shock += `</g>\n`;
|
||||
}
|
||||
} else {
|
||||
let g = `<g stroke="${ink}" stroke-linecap="round" fill="none">`;
|
||||
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;
|
||||
g += `<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)
|
||||
g += `<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;
|
||||
g += `<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)
|
||||
g += `<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)}"/>`;
|
||||
shock += g + `</g>\n`;
|
||||
}
|
||||
let st2 = '';
|
||||
for (const st of (sh.stains || [])) {
|
||||
const sr = (st.r * scale).toFixed(1);
|
||||
st2 += `<circle cx="${tx(st.x)}" cy="${ty(st.y)}" r="${sr}" fill="url(#${st.dark ? 'shockstain' : 'shockclean'})" fill-opacity="${st.opacity.toFixed(3)}"/>`;
|
||||
}
|
||||
shock += st2;
|
||||
}
|
||||
|
||||
/* ---------- Tracks (split by particle kind, bubble positions unchanged) ---------- */
|
||||
const labelFor = {};
|
||||
for (const L of TRACK_LAYERS) for (const k of L.kinds) labelFor[k] = L.id;
|
||||
const under = new Map(); // layerId -> understroke string
|
||||
const buckets = new Map(); // layerId -> Map(alpha -> circles[])
|
||||
const ensure = (id) => { if (!under.has(id)) { under.set(id, ''); buckets.set(id, new Map()); } };
|
||||
|
||||
const bubbleRng = makeRng(params.seed, 'bubbles'); // consumed in scene.tracks order → deterministic
|
||||
for (const track of scene.tracks) {
|
||||
if (track.pts.length < 2) continue;
|
||||
const id = labelFor[track.kind] || 'tracks-primary';
|
||||
ensure(id);
|
||||
// continuity under-stroke
|
||||
const iw = trackInkWeight(track);
|
||||
const lw = Math.min(2.6, 0.25 + Math.sqrt(iw) * 0.12) * u * params.size * track.weight;
|
||||
if (lw >= 0.2 * u) {
|
||||
let d = `M ${tx(track.pts[0].x)} ${ty(track.pts[0].y)}`;
|
||||
for (let i = 1; i < track.pts.length; i++) d += ` L ${tx(track.pts[i].x)} ${ty(track.pts[i].y)}`;
|
||||
under.set(id, under.get(id) + `<path d="${d}" stroke-opacity="${(0.14 * track.weight).toFixed(3)}" stroke-width="${lw.toFixed(2)}"/>`);
|
||||
}
|
||||
// bubbles
|
||||
const alpha = Math.round(Math.min(1, 0.45 + track.weight * 0.5) * 20) / 20;
|
||||
const m = buckets.get(id);
|
||||
if (!m.has(alpha)) m.set(alpha, []);
|
||||
const arr = m.get(alpha);
|
||||
for (const b of sampleBubbles(track, params, bubbleRng)) {
|
||||
arr.push(`<circle cx="${tx(b.x)}" cy="${ty(b.y)}" r="${Math.max(b.r * scale * 1.15, 0.5).toFixed(2)}"/>`);
|
||||
}
|
||||
}
|
||||
const trackLayers = TRACK_LAYERS.map(L => {
|
||||
if (!under.has(L.id)) return '';
|
||||
let content = '';
|
||||
const us = under.get(L.id);
|
||||
if (us) content += `<g fill="none" stroke="${ink}" stroke-linecap="round" stroke-linejoin="round">${us}</g>\n`;
|
||||
for (const [alpha, circles] of buckets.get(L.id)) {
|
||||
if (circles.length) content += `<g fill="url(#bub)" fill-opacity="${alpha}">${circles.join('')}</g>\n`;
|
||||
}
|
||||
return layer(L.id, L.label, content);
|
||||
}).join('');
|
||||
|
||||
/* ---------- Plate damage ---------- */
|
||||
let damage = '';
|
||||
const A = scene.artifacts;
|
||||
if (A) {
|
||||
let g = `<g stroke="${ink}" fill="none" stroke-linecap="round">`;
|
||||
for (const ring of A.rings)
|
||||
g += `<circle cx="${tx(ring.x)}" cy="${ty(ring.y)}" r="${(ring.r * scale).toFixed(1)}" stroke-opacity="${ring.opacity.toFixed(3)}" stroke-width="${(ring.width * u).toFixed(2)}"/>`;
|
||||
for (const sc of A.scratches)
|
||||
g += `<line x1="${tx(sc.x1)}" y1="${ty(sc.y1)}" x2="${tx(sc.x2)}" y2="${ty(sc.y2)}" stroke-opacity="${sc.opacity.toFixed(3)}" stroke-width="${(sc.width * u).toFixed(2)}"/>`;
|
||||
for (const hair of A.hairs) {
|
||||
let d = `M ${tx(hair.pts[0].x)} ${ty(hair.pts[0].y)}`;
|
||||
for (let i = 1; i < hair.pts.length; i++) d += ` L ${tx(hair.pts[i].x)} ${ty(hair.pts[i].y)}`;
|
||||
g += `<path d="${d}" stroke-opacity="${hair.opacity.toFixed(3)}" stroke-width="${(hair.width * u).toFixed(2)}"/>`;
|
||||
}
|
||||
g += `</g><g fill="${ink}">`;
|
||||
for (const sp of A.specks)
|
||||
g += `<circle cx="${tx(sp.x)}" cy="${ty(sp.y)}" r="${Math.max(sp.r * scale, 0.5).toFixed(2)}" fill-opacity="${sp.opacity.toFixed(3)}"/>`;
|
||||
damage = g + `</g>`;
|
||||
}
|
||||
|
||||
/* ---------- Fiducials ---------- */
|
||||
let fids = '';
|
||||
if (params.showFiducials) {
|
||||
let g = `<g stroke="${ink}" stroke-opacity="0.55" stroke-width="${1.2 * u}">`;
|
||||
const F = [[-0.85, -0.85], [0.85, -0.85], [-0.85, 0.85], [0.85, 0.85], [0, -0.85], [-0.85, 0], [0.85, 0]];
|
||||
const sz = 9 * u;
|
||||
for (const [fx, fy] of F) {
|
||||
const px = +tx(fx), py = +ty(fy);
|
||||
g += `<line x1="${px - sz}" y1="${py}" x2="${px + sz}" y2="${py}"/><line x1="${px}" y1="${py - sz}" x2="${px}" y2="${py + sz}"/>`;
|
||||
}
|
||||
fids = g + `</g>`;
|
||||
}
|
||||
|
||||
/* ---------- Vignette ---------- */
|
||||
const vign = params.vign > 0 ? `<rect width="${w}" height="${h}" fill="url(#vign)"/>` : '';
|
||||
|
||||
/* ---------- Header ---------- */
|
||||
let header = '';
|
||||
if (params.showHeader) {
|
||||
const pad = 26 * u;
|
||||
const esc = (t) => String(t).replace(/[<&]/g, c => (c === '<' ? '<' : '&'));
|
||||
header = `<g fill="${ink}" font-family="'JetBrains Mono', monospace">`
|
||||
+ `<text x="${pad.toFixed(0)}" y="${(pad + 11 * u).toFixed(0)}" font-size="${(11 * u).toFixed(0)}" fill-opacity="0.62">${esc(scene.lab.toUpperCase())}</text>`
|
||||
+ `<text x="${pad.toFixed(0)}" y="${(pad + 27 * u).toFixed(0)}" font-size="${(9 * u).toFixed(0)}" fill-opacity="0.5">SEED ${esc(params.seed)}</text>`
|
||||
+ `<text x="${(w - pad).toFixed(0)}" y="${(h - pad - 13 * u).toFixed(0)}" font-size="${(10 * u).toFixed(0)}" text-anchor="end" fill-opacity="0.58">PLATE ${scene.plate}</text>`
|
||||
+ `<text x="${(w - pad).toFixed(0)}" y="${(h - pad).toFixed(0)}" font-size="${(10 * u).toFixed(0)}" text-anchor="end" fill-opacity="0.58">EXPOSED ${scene.exposure}</text>`
|
||||
+ `</g>`;
|
||||
}
|
||||
|
||||
/* ---------- Assemble ---------- */
|
||||
let s = `<?xml version="1.0" encoding="UTF-8"?>\n`;
|
||||
s += `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}" viewBox="0 0 ${w} ${h}">\n`;
|
||||
s += `<svg xmlns="http://www.w3.org/2000/svg" xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" width="${w}" height="${h}" viewBox="0 0 ${w} ${h}">\n`;
|
||||
s += `<metadata>Bubble Chamber · seed=${params.seed} · hash=${cyrb53(params.seed)}</metadata>\n`;
|
||||
s += `<defs>
|
||||
s += defs(inv, paper, glowIn, glowOut, ink, params);
|
||||
s += layer('background', 'Background', bg);
|
||||
s += layer('optics', 'Chamber optics', optics);
|
||||
s += layer('shock', 'Shock disk', shock);
|
||||
s += trackLayers;
|
||||
s += layer('damage', 'Plate damage', damage);
|
||||
s += layer('fiducials', 'Fiducials', fids);
|
||||
s += layer('vignette', 'Vignette', vign);
|
||||
s += layer('header', 'Archival header', header);
|
||||
s += `</svg>\n`;
|
||||
return s;
|
||||
}
|
||||
|
||||
function defs(inv, paper, glowIn, glowOut, ink, params) {
|
||||
return `<defs>
|
||||
<radialGradient id="paper" cx="50%" cy="42%" r="72%">
|
||||
<stop offset="0%" stop-color="${glowIn}"/><stop offset="100%" stop-color="${glowOut}"/>
|
||||
</radialGradient>
|
||||
@@ -56,139 +247,4 @@ export function renderSVG(scene, params, sizePx = 4800) {
|
||||
<stop offset="100%" stop-color="${inv ? '#382e1e' : '#000'}" stop-opacity="${(inv ? 0.5 : 0.85) * params.vign}"/>
|
||||
</radialGradient>
|
||||
</defs>\n`;
|
||||
s += `<rect width="${w}" height="${h}" fill="${paper}"/>\n`;
|
||||
s += `<rect width="${w}" height="${h}" fill="url(#paper)"/>\n`;
|
||||
|
||||
// boundary
|
||||
if (params.showBoundary) {
|
||||
const bcx = cx, bcy = cy + h * 0.35, br = w * 0.45;
|
||||
const a1 = Math.PI * 0.15, a2 = Math.PI - Math.PI * 0.15;
|
||||
const x1 = bcx + Math.cos(Math.PI + a1) * br, y1 = bcy + Math.sin(Math.PI + a1) * br;
|
||||
const x2 = bcx + Math.cos(Math.PI + a2) * br, y2 = bcy + Math.sin(Math.PI + a2) * br;
|
||||
s += `<path d="M ${x1.toFixed(1)} ${y1.toFixed(1)} A ${br} ${br} 0 0 1 ${x2.toFixed(1)} ${y2.toFixed(1)}" fill="none" stroke="${ink}" stroke-opacity="0.4" stroke-width="${2 * u}"/>\n`;
|
||||
}
|
||||
|
||||
// chamber optics / structural geometry
|
||||
if (scene.instrument) {
|
||||
const inst = scene.instrument;
|
||||
s += `<g fill="none" stroke="${ink}" stroke-linecap="round">\n`;
|
||||
for (const l of inst.lines)
|
||||
s += `<line x1="${tx(l.x1)}" y1="${ty(l.y1)}" x2="${tx(l.x2)}" y2="${ty(l.y2)}" stroke-opacity="${l.opacity.toFixed(3)}" stroke-width="${(l.width * u).toFixed(2)}"/>`;
|
||||
for (const a of inst.arcs) {
|
||||
let d = `M ${tx(a.pts[0].x)} ${ty(a.pts[0].y)}`;
|
||||
for (let i = 1; i < a.pts.length; i++) d += ` L ${tx(a.pts[i].x)} ${ty(a.pts[i].y)}`;
|
||||
s += `<path d="${d}" stroke-opacity="${a.opacity.toFixed(3)}" stroke-width="${(a.width * u).toFixed(2)}"/>`;
|
||||
}
|
||||
s += `\n</g>\n`;
|
||||
}
|
||||
|
||||
// continuity under-strokes
|
||||
s += `<g fill="none" stroke="${ink}" stroke-linecap="round" stroke-linejoin="round">\n`;
|
||||
for (const track of scene.tracks) {
|
||||
if (track.pts.length < 2) continue;
|
||||
const iw = trackInkWeight(track);
|
||||
const lw = Math.min(2.6, 0.25 + Math.sqrt(iw) * 0.12) * u * params.size * track.weight;
|
||||
if (lw < 0.2 * u) continue;
|
||||
let d = `M ${tx(track.pts[0].x)} ${ty(track.pts[0].y)}`;
|
||||
for (let i = 1; i < track.pts.length; i++) d += ` L ${tx(track.pts[i].x)} ${ty(track.pts[i].y)}`;
|
||||
s += `<path d="${d}" stroke-opacity="${(0.14 * track.weight).toFixed(3)}" stroke-width="${lw.toFixed(2)}"/>`;
|
||||
}
|
||||
s += `\n</g>\n`;
|
||||
|
||||
// shock disk
|
||||
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)}"/>`;
|
||||
}
|
||||
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);
|
||||
s += `<circle cx="${tx(st.x)}" cy="${ty(st.y)}" r="${sr}" fill="url(#${st.dark ? 'shockstain' : 'shockclean'})" fill-opacity="${st.opacity.toFixed(3)}"/>`;
|
||||
}
|
||||
s += `\n`;
|
||||
}
|
||||
|
||||
// bubbles, bucketed by opacity
|
||||
const buckets = new Map();
|
||||
const bubbleRng = makeRng(params.seed, 'bubbles');
|
||||
for (const track of scene.tracks) {
|
||||
const key = Math.round(Math.min(1, 0.45 + track.weight * 0.5) * 20) / 20;
|
||||
if (!buckets.has(key)) buckets.set(key, []);
|
||||
buckets.get(key).push(...sampleBubbles(track, params, bubbleRng));
|
||||
}
|
||||
for (const [alpha, bubs] of buckets) {
|
||||
s += `<g fill="url(#bub)" fill-opacity="${alpha}">`;
|
||||
for (const b of bubs) {
|
||||
const r = Math.max(b.r * scale * 1.15, 0.5);
|
||||
s += `<circle cx="${tx(b.x)}" cy="${ty(b.y)}" r="${r.toFixed(2)}"/>`;
|
||||
}
|
||||
s += `</g>\n`;
|
||||
}
|
||||
|
||||
// plate damage
|
||||
const A = scene.artifacts;
|
||||
if (A) {
|
||||
s += `<g stroke="${ink}" fill="none" stroke-linecap="round">\n`;
|
||||
for (const ring of A.rings)
|
||||
s += `<circle cx="${tx(ring.x)}" cy="${ty(ring.y)}" r="${(ring.r * scale).toFixed(1)}" stroke-opacity="${ring.opacity.toFixed(3)}" stroke-width="${(ring.width * u).toFixed(2)}"/>`;
|
||||
for (const sc of A.scratches)
|
||||
s += `<line x1="${tx(sc.x1)}" y1="${ty(sc.y1)}" x2="${tx(sc.x2)}" y2="${ty(sc.y2)}" stroke-opacity="${sc.opacity.toFixed(3)}" stroke-width="${(sc.width * u).toFixed(2)}"/>`;
|
||||
for (const hair of A.hairs) {
|
||||
let d = `M ${tx(hair.pts[0].x)} ${ty(hair.pts[0].y)}`;
|
||||
for (let i = 1; i < hair.pts.length; i++) d += ` L ${tx(hair.pts[i].x)} ${ty(hair.pts[i].y)}`;
|
||||
s += `<path d="${d}" stroke-opacity="${hair.opacity.toFixed(3)}" stroke-width="${(hair.width * u).toFixed(2)}"/>`;
|
||||
}
|
||||
s += `\n</g>\n`;
|
||||
s += `<g fill="${ink}">`;
|
||||
for (const sp of A.specks)
|
||||
s += `<circle cx="${tx(sp.x)}" cy="${ty(sp.y)}" r="${Math.max(sp.r * scale, 0.5).toFixed(2)}" fill-opacity="${sp.opacity.toFixed(3)}"/>`;
|
||||
s += `</g>\n`;
|
||||
}
|
||||
|
||||
// fiducials
|
||||
if (params.showFiducials) {
|
||||
s += `<g stroke="${ink}" stroke-opacity="0.55" stroke-width="${1.2 * u}">\n`;
|
||||
const fids = [[-0.85, -0.85], [0.85, -0.85], [-0.85, 0.85], [0.85, 0.85], [0, -0.85], [-0.85, 0], [0.85, 0]];
|
||||
const sz = 9 * u;
|
||||
for (const [fx, fy] of fids) {
|
||||
const px = +tx(fx), py = +ty(fy);
|
||||
s += `<line x1="${px - sz}" y1="${py}" x2="${px + sz}" y2="${py}"/><line x1="${px}" y1="${py - sz}" x2="${px}" y2="${py + sz}"/>`;
|
||||
}
|
||||
s += `\n</g>\n`;
|
||||
}
|
||||
|
||||
if (params.vign > 0) s += `<rect width="${w}" height="${h}" fill="url(#vign)"/>\n`;
|
||||
|
||||
// archival header
|
||||
if (params.showHeader) {
|
||||
const pad = 26 * u;
|
||||
const esc = (t) => String(t).replace(/[<&]/g, c => (c === '<' ? '<' : '&'));
|
||||
s += `<g fill="${ink}" font-family="'JetBrains Mono', monospace">\n`;
|
||||
s += `<text x="${pad.toFixed(0)}" y="${(pad + 11 * u).toFixed(0)}" font-size="${(11 * u).toFixed(0)}" fill-opacity="0.62">${esc(scene.lab.toUpperCase())}</text>`;
|
||||
s += `<text x="${pad.toFixed(0)}" y="${(pad + 27 * u).toFixed(0)}" font-size="${(9 * u).toFixed(0)}" fill-opacity="0.5">SEED ${esc(params.seed)}</text>`;
|
||||
s += `<text x="${(w - pad).toFixed(0)}" y="${(h - pad - 13 * u).toFixed(0)}" font-size="${(10 * u).toFixed(0)}" text-anchor="end" fill-opacity="0.58">PLATE ${scene.plate}</text>`;
|
||||
s += `<text x="${(w - pad).toFixed(0)}" y="${(h - pad).toFixed(0)}" font-size="${(10 * u).toFixed(0)}" text-anchor="end" fill-opacity="0.58">EXPOSED ${scene.exposure}</text>`;
|
||||
s += `\n</g>\n`;
|
||||
}
|
||||
s += `</svg>\n`;
|
||||
return s;
|
||||
}
|
||||
|
||||
@@ -12,6 +12,12 @@ export 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;
|
||||
@@ -19,7 +25,7 @@ export 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);
|
||||
|
||||
@@ -28,8 +34,8 @@ export 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;
|
||||
|
||||
@@ -44,7 +44,7 @@ export 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),
|
||||
|
||||
@@ -105,5 +105,62 @@ export 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;
|
||||
}
|
||||
|
||||
1
src/ui/controls.js
vendored
1
src/ui/controls.js
vendored
@@ -71,6 +71,7 @@ export const GROUPS = [
|
||||
|
||||
export 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' },
|
||||
|
||||
Reference in New Issue
Block a user