web interface

This commit is contained in:
2026-06-02 19:17:19 -04:00
parent 52453fba67
commit 219eb6632c
140 changed files with 2793 additions and 40 deletions

257
src/compose/composition.js Normal file
View File

@@ -0,0 +1,257 @@
/* ============================================================
compose/composition.js — the layered piece as ONE grouped schema.
Each group ≈ a craft / a plex sheet, and every placeable layer
carries a uniform transform { x, y, rotation, scale } (x,y are the
layer-centre offset in frame-normalized 1..1; rotation in degrees;
scale about that centre). Groups:
background — base colour + film/diffusion + aging + grain
fieldSea — Field 1: the ridgeline vacuum carpet (N plate sheets)
fieldGrid — Field 2: the rippled perspective depth grid (2 sheets)
disk — the shock disk / "sun" (own centre/rotation/scale)
bubble — the particle event / tracks (own centre/rotation/scale)
fiduciaries — frame-level "No 001" + arrow (does NOT scale w/ event)
renderComposition(comp, size) → one composited SVG string.
============================================================ */
import { carpetSVG } from '../qft/carpet.js';
import { perspectiveGridSVG, perspFloorSVG } from '../qft/perspgrid.js';
import { fiduciarySVG } from './fiduciary.js';
import { generateScene } from '../scene/scene.js';
import { renderSVG } from '../render/svgVector.js';
import { paramsFromSeed } from '../scene/params.js';
import { GROUPS, TOGGLES, FIXED } from '../ui/controls.js';
// base64 that works in Node (Buffer) and the browser (btoa, unicode-safe)
const b64 = (s) => (typeof Buffer !== 'undefined')
? Buffer.from(s).toString('base64')
: btoa(unescape(encodeURIComponent(s)));
const uri = (svg) => 'data:image/svg+xml;base64,' + b64(svg);
const lerp = (a, b, t) => a + (b - a) * t;
// ---------- background sub-layers (film / aging / grain) ----------
function filmSVG(W, o = {}) {
const { seed = 8, freq = 0.0016, octaves = 4, tone = [236, 228, 208], density = 0.55 } = o;
const t = tone.map(v => (v / 255).toFixed(3));
return `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${W}" viewBox="0 0 ${W} ${W}">
<defs><filter id="fog" x="0" y="0" width="100%" height="100%"><feTurbulence type="fractalNoise" baseFrequency="${freq}" numOctaves="${octaves}" seed="${seed}" stitchTiles="stitch" result="n"/><feColorMatrix in="n" type="matrix" values="0 0 0 0 ${t[0]} 0 0 0 0 ${t[1]} 0 0 0 0 ${t[2]} 0 0 0 ${density} 0"/></filter></defs>
<rect width="${W}" height="${W}" filter="url(#fog)"/></svg>`;
}
function grainSVG(W, o = {}) {
const { seed = 19, tone = [38, 32, 26], intensity = 0.5 } = o;
const t = tone.map(v => (v / 255).toFixed(3));
return `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${W}" viewBox="0 0 ${W} ${W}">
<defs><filter id="g" x="0" y="0" width="100%" height="100%"><feTurbulence type="fractalNoise" baseFrequency="0.9" numOctaves="2" seed="${seed}" stitchTiles="stitch" result="n"/><feColorMatrix in="n" type="matrix" values="0 0 0 0 ${t[0]} 0 0 0 0 ${t[1]} 0 0 0 0 ${t[2]} 0 0 0 ${intensity} 0"/></filter></defs>
<rect width="${W}" height="${W}" filter="url(#g)"/></svg>`;
}
function agingSVG(W, o = {}) {
const u = W / 1000;
const { seed = 5, scratches = 6, dust = 0.5, foxing = 0.5, tone = [60, 48, 34] } = o;
const t = tone.map(v => (v / 255).toFixed(3));
let lcg = (seed * 9301 + 49297) % 233280; const rnd = () => (lcg = (lcg * 9301 + 49297) % 233280) / 233280;
let lines = '';
for (let i = 0; i < scratches; i++) {
const x = rnd() * W, y0 = rnd() * W * 0.4, len = (0.3 + rnd() * 0.6) * W;
lines += `<line x1="${x.toFixed(0)}" y1="${y0.toFixed(0)}" x2="${(x + (rnd() - 0.5) * 40).toFixed(0)}" y2="${(y0 + len).toFixed(0)}" stroke="rgb(${tone.join(',')})" stroke-opacity="${(0.05 + rnd() * 0.12).toFixed(3)}" stroke-width="${(0.4 + rnd() * 0.8).toFixed(2)}"/>`;
}
return `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${W}" viewBox="0 0 ${W} ${W}">
<defs><filter id="dust" x="0" y="0" width="100%" height="100%"><feTurbulence type="turbulence" baseFrequency="0.5" numOctaves="2" seed="${seed + 5}" stitchTiles="stitch" result="n"/><feColorMatrix in="n" type="matrix" values="0 0 0 0 ${t[0]} 0 0 0 0 ${t[1]} 0 0 0 0 ${t[2]} 0 0 0 ${(dust * 0.5).toFixed(3)} -0.18"/></filter>
<filter id="fox" x="0" y="0" width="100%" height="100%"><feTurbulence type="fractalNoise" baseFrequency="0.004" numOctaves="3" seed="${seed + 9}" stitchTiles="stitch" result="n"/><feColorMatrix in="n" type="matrix" values="0 0 0 0 0.42 0 0 0 0 0.3 0 0 0 0 0.16 0 0 0 ${(foxing * 0.32).toFixed(3)} -0.12"/></filter></defs>
<rect width="${W}" height="${W}" filter="url(#fox)"/><rect width="${W}" height="${W}" filter="url(#dust)"/>${lines}</svg>`;
}
// ---------- lit-page glow + vignette (restores the photographic 'glow') ----------
function parseRGB(c) { const m = (c || '').match(/\d+/g); return m ? m.slice(0, 3).map(Number) : [229, 222, 203]; }
function glowSVG(W, baseRGB, strength) {
const lc = baseRGB.map(v => Math.min(255, Math.round(v + 30))); // lighter warm centre
return `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${W}" viewBox="0 0 ${W} ${W}">
<defs><radialGradient id="glow" cx="50%" cy="44%" r="76%">
<stop offset="0%" stop-color="rgb(${lc})" stop-opacity="${(0.95 * strength).toFixed(3)}"/>
<stop offset="62%" stop-color="rgb(${lc})" stop-opacity="0"/></radialGradient></defs>
<rect width="${W}" height="${W}" fill="url(#glow)"/></svg>`;
}
function vignetteSVG(W, strength) {
return `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${W}" viewBox="0 0 ${W} ${W}">
<defs><radialGradient id="vig" cx="50%" cy="48%" r="75%">
<stop offset="52%" stop-color="#000" stop-opacity="0"/>
<stop offset="100%" stop-color="#000" stop-opacity="${(0.72 * strength).toFixed(3)}"/></radialGradient></defs>
<rect width="${W}" height="${W}" fill="url(#vig)"/></svg>`;
}
// ---------- bubble-chamber sub-renders (centered, transparent, single group) ----------
function bcParamsBase(seed) {
const p = { ...FIXED, ...paramsFromSeed(seed) };
for (const g of GROUPS) for (const c of g.controls) if (!(c.id in p)) p[c.id] = c.value;
for (const t of TOGGLES) if (!(t.id in p)) p[t.id] = t.value;
p.invert = true; p.showHeader = false; p.transparentPaper = true;
return p;
}
function diskLayer(W, seed, d) {
const p = bcParamsBase(seed);
Object.assign(p, {
palette: d.palette ?? 'magentarise', shock: true, shockX: 0, shockY: 0,
diskHue: d.hue ?? 0.06, diskSat: d.sat ?? 0.82, shockSize: d.size ?? 0.16,
diskPressure: d.pressure ?? 0, shockIntensity: d.intensity ?? 0.85,
shockStriations: d.striations ?? 0.6, shockStain: d.stain ?? 0.35, diskSoften: d.soften ?? 1.35,
emit: ['disk'],
});
return renderSVG(generateScene(p), p, W);
}
function bubbleLayer(W, seed, b) {
const p = bcParamsBase(seed);
p.transparentPaper = b.transparentBase !== false; // false → keep the paper base
Object.assign(p, {
palette: b.palette ?? 'magentarise', saturation: b.saturation ?? 1.05, traceHue: b.traceHue ?? 0,
eventX: 0, eventY: 0,
primaries: b.primaries ?? 18, sweepers: b.sweepers ?? 5, cosmics: b.cosmics ?? 6,
vdecay: b.vdecay ?? 3, burst: b.burst ?? 0.7, eloss: b.eloss ?? 0.34, bfield: b.bfield ?? 1.0,
pspread: b.pspread ?? 0.6, deltaRate: b.deltaRate ?? 0.6, deltaTight: b.deltaTight ?? 0.7,
emit: ['bubble'],
});
return renderSVG(generateScene(p), p, W);
}
// ---------- field decks ----------
function seaSheets(W, f) {
const n = f.layers ?? 3;
const base = {
mode: 'plate', rows: f.lines ?? 46, horizon: f.horizonY ?? 0.36, wFar: 0.58, wNear: 0.7,
overlap: f.overlap ?? 1.7, mound: f.mound ?? 0.3, sat: (f.color?.sat) ?? 0.6, lightNear: 0.33, lightFar: 0.56, blips: f.blips ?? 0.7,
};
const hueBack = f.color?.hueBack ?? 0.54, hueFront = f.color?.hueFront ?? 0.47;
const strokes = [{ near: 2.6, far: 1.0 }, { near: 1.6, far: 0.6 }, { near: 1.0, far: 0.38 }];
const blur = f.blurPerLayer ?? [2.6, 1.1, 0];
const seeds = f.seedPerLayer ?? ['fieldA', 'fieldB', 'fieldC'];
const op = f.opacityPerLayer ?? [0.5, 0.72, 0.95];
const out = [];
for (let i = 0; i < n; i++) {
const t = n > 1 ? i / (n - 1) : 0;
const svg = carpetSVG(W, {
...base, salt: seeds[i % seeds.length], seed: f.seed || 'VACUUM-5113',
hue: lerp(hueBack, hueFront, t), hue2: lerp(hueBack, hueFront, t) + 0.035,
chaos: lerp((f.chaos ?? 0.32) * 0.8, f.chaos ?? 0.32, t),
strokeNear: (strokes[i] || strokes[2]).near, strokeFar: (strokes[i] || strokes[2]).far,
});
out.push({ href: uri(svg), blur: blur[i] ?? 0, opacity: op[i] ?? 0.9 });
}
return out;
}
const D = Math.PI / 180;
function gridSheets(W, f) {
const color = (typeof f.color === 'object' ? f.color : {});
const blur = f.blurPerLayer ?? [1.1, 0];
const op = f.opacity ?? 0.4;
if (f.style === 'radial') { // legacy radial funnel
const pb = {
mode: 'plate', vp: f.vp ?? [0, 0], dir: f.dir ?? 0, spread: f.spread ?? Math.PI * 2,
rays: f.rays ?? 30, depthLines: f.depthLines ?? 17, depthPow: f.depthPow ?? 2.3, rMin: f.rMin ?? 0.04, rMax: f.rMax ?? 2.8,
rippleAmp: f.ripple?.amp ?? 0.07, rippleAmpRad: f.ripple?.ampRad ?? 0.06, rippleFreqR: f.ripple?.freqR ?? 2.4, rippleFreqA: f.ripple?.freqA ?? 5, ripplePhase: f.ripple?.phase ?? 0, ...color,
};
const back = perspectiveGridSVG(W, { ...pb, salt: 'pA', stroke: 2.2, strokeFar: 0.8 });
const front = perspectiveGridSVG(W, { ...pb, salt: 'pB', stroke: 1.1, strokeFar: 0.4, rippleFreqR: pb.rippleFreqR * 1.24, rippleFreqA: pb.rippleFreqA + 1.5, ripplePhase: pb.ripplePhase + 0.95 });
return [{ href: uri(back), blur: blur[0] ?? 1.1, opacity: op }, { href: uri(front), blur: blur[1] ?? 0, opacity: op + 0.18 }];
}
// DEFAULT: straight-lined perspective FLOOR grid (reads cleanly)
const pb = {
mode: 'plate', nx: f.nx ?? 16, nz: f.nz ?? 24,
pitch: f.pitch ?? 28 * D, yaw: f.yaw ?? 0, roll: f.roll ?? 0, persp: f.persp ?? 1.0,
dist: f.dist ?? 3.0, zShift: f.zShift ?? 0, scale: f.scale ?? 1, originX: f.originX ?? 0, originY: f.originY ?? 0.34,
rippleAmp: f.ripple?.amp ?? 0, rippleFreqI: f.ripple?.freqI ?? 0.5, rippleFreqK: f.ripple?.freqK ?? 0.35, ripplePhase: f.ripple?.phase ?? 0, ...color,
};
const back = perspFloorSVG(W, { ...pb, salt: 'fA', stroke: 2.0, strokeFar: 0.7 });
const front = perspFloorSVG(W, { ...pb, salt: 'fB', stroke: 1.0, strokeFar: 0.35, ripplePhase: pb.ripplePhase + (pb.rippleAmp ? 0.8 : 0) });
return [{ href: uri(back), blur: blur[0] ?? 1.1, opacity: op }, { href: uri(front), blur: blur[1] ?? 0, opacity: op + 0.16 }];
}
// The 6 craft groups, in conceptual order.
export const COMPOSITION_GROUPS = ['background', 'fieldSea', 'fieldGrid', 'disk', 'bubble', 'fiduciaries'];
/* Render ONE group to a set of draw "pieces" (z-ordered). The UI caches these
per group so editing one layer only re-renders that layer. Each piece:
{ z, rect? , href?, transform?, blur?, opacity?, blend? } */
export function buildGroupPieces(group, comp, W) {
const seed = comp.seed || 'MESON-5113';
const out = [];
const push = (z, o) => out.push({ z, ...o });
switch (group) {
case 'background': {
const b = comp.background || {};
const rgb = parseRGB(b.color);
push(0, { rect: b.color || 'rgb(229,222,203)' });
if (b.glow && b.glow.strength) push(0.6, { href: uri(glowSVG(W, rgb, b.glow.strength)), opacity: 1 }); // lit-page glow (behind the fields)
if (b.film && b.film.opacity !== 0) push(1, { href: uri(filmSVG(W, b.film)), opacity: b.film.opacity ?? 0.6 });
if (b.vignette && b.vignette.strength) push(90, { href: uri(vignetteSVG(W, b.vignette.strength)), opacity: 1, blend: 'multiply' }); // edge darkening (over the piece)
if (b.aging && b.aging.opacity !== 0) push(80, { href: uri(agingSVG(W, b.aging)), opacity: b.aging.opacity ?? 0.55, blend: 'multiply' });
if (b.grain && b.grain.opacity !== 0) push(95, { href: uri(grainSVG(W, { seed: b.grain.seed, tone: b.grain.tone, intensity: b.grain.intensity })), opacity: b.grain.opacity ?? 0.42, blend: 'multiply' });
break;
}
case 'fieldSea': {
const f = comp.fieldSea; if (!f || f.enabled === false) break;
seaSheets(W, f).forEach((s, i) => push(10 + i, { href: s.href, blur: s.blur, opacity: s.opacity, transform: f.transform }));
break;
}
case 'fieldGrid': {
const f = comp.fieldGrid; if (!f || f.enabled === false) break;
const z0 = f.pos === 'behind' ? 5 : 25;
gridSheets(W, f).forEach((s, i) => push(z0 + i, { href: s.href, blur: s.blur, opacity: s.opacity, transform: f.transform }));
break;
}
case 'disk': {
const d = comp.disk; if (!d || d.enabled === false) break;
push(40, { href: uri(diskLayer(W, seed, d)), opacity: d.opacity ?? 1, transform: d.transform });
break;
}
case 'bubble': {
const b = comp.bubble; if (!b || b.enabled === false) break;
// transparentBase (default true): float as an object (normal blend). When
// false, the event keeps its paper base and composites via multiply.
const tb = b.transparentBase !== false;
push(50, { href: uri(bubbleLayer(W, seed, b)), opacity: b.opacity ?? 1, transform: b.transform, blend: tb ? undefined : 'multiply' });
break;
}
case 'fiduciaries': {
const fd = comp.fiduciaries; if (!fd || fd.enabled === false) break;
const target = fd.target || [comp.bubble?.transform?.x ?? 0, comp.bubble?.transform?.y ?? 0];
push(85, { href: uri(fiduciarySVG(W, { ...fd, target, seed })), opacity: 1, transform: fd.transform });
break;
}
}
// common per-layer OPACITY: multiply every piece's opacity (baseOpacity kept so
// the UI can re-apply cheaply without regenerating the layer)
const lo = comp[group] && comp[group].layerOpacity;
if (lo != null) out.forEach((p) => { if (!p.rect) { p.baseOpacity = p.opacity ?? 1; p.opacity = p.baseOpacity * lo; } });
return out;
}
// transform a canvas-centred layer to (x,y) offset / rotation / scale
function tfStr(t, W, H) {
if (!t) return null;
const x = t.x ?? 0, y = t.y ?? 0, rot = t.rotation ?? 0, sc = t.scale ?? 1;
return `translate(${(W / 2 + x * W / 2).toFixed(1)} ${(H / 2 + y * H / 2).toFixed(1)}) rotate(${rot}) scale(${sc}) translate(${(-W / 2).toFixed(1)} ${(-H / 2).toFixed(1)})`;
}
// Assemble z-sorted pieces (from one or more groups) into the final SVG.
export function assemblePieces(pieces, W) {
const H = W, u = W / 1000;
const blurFilters = new Map();
const blurId = (px) => { if (!px) return null; const k = (+px).toFixed(2); if (!blurFilters.has(k)) blurFilters.set(k, `bl${blurFilters.size}`); return blurFilters.get(k); };
const body = [...pieces].sort((a, b) => a.z - b.z).map((p) => {
if (p.rect) return `<rect width="${W}" height="${H}" fill="${p.rect}"/>`;
const bid = blurId(p.blur), t = tfStr(p.transform, W, H);
const img = `<image x="0" y="0" width="${W}" height="${H}" href="${p.href}" opacity="${p.opacity ?? 1}"${bid ? ` filter="url(#${bid})"` : ''}${p.blend ? ` style="mix-blend-mode:${p.blend}"` : ''}/>`;
return t ? `<g transform="${t}">${img}</g>` : img;
});
const defs = `<defs>${[...blurFilters.entries()].map(([px, id]) => `<filter id="${id}" x="-8%" y="-8%" width="116%" height="116%"><feGaussianBlur stdDeviation="${((+px) * u).toFixed(2)}"/></filter>`).join('')}</defs>`;
return `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}">
${defs}
${body.join('\n')}
</svg>`;
}
export function renderComposition(comp, size) {
const W = size || comp.size || 1500;
const pieces = COMPOSITION_GROUPS.flatMap((g) => buildGroupPieces(g, comp, W));
return assemblePieces(pieces, W);
}

67
src/compose/fiduciary.js Normal file
View File

@@ -0,0 +1,67 @@
/* ============================================================
compose/fiduciary.js — FRAME-LEVEL archival annotation, fully
decoupled from the bubble-chamber scene. A hand "No 001" + arrow
pointing at an explicit target, optional registration corners and
a plate caption. Lives on its own (does NOT scale with the event).
Coordinates are frame-normalized [-1,1]; (0,0) = centre.
============================================================ */
import { makeRng, gauss } from '../rng.js';
export function fiduciarySVG(size, opts = {}) {
const o = Object.assign({
label: 'No 001', target: [0, 0], from: null,
pencil: '#39312a', width: 1.0, fontSize: 0.03, opacity: 0.9,
arrow: true, corners: false, caption: null, captionAt: [-0.92, 0.93],
seed: 'FID', salt: 'a',
}, opts);
const W = size, H = size, u = size / 1000;
const rng = makeRng(o.seed, o.salt);
const X = (nx) => (nx + 1) / 2 * W, Y = (ny) => (ny + 1) / 2 * H;
const sw = (o.width * u).toFixed(2);
let body = '';
const tx = X(o.target[0]), ty = Y(o.target[1]);
const from = o.from || [o.target[0] + (o.target[0] <= 0 ? 0.24 : -0.24), o.target[1] - 0.22];
const fx = X(from[0]), fy = Y(from[1]);
if (o.arrow) {
// wobbly shaft from `from` toward `target` (stops just short)
const seg = 9, pts = [];
const ex = tx + (fx - tx) * 0.12, ey = ty + (fy - ty) * 0.12; // tip stops short of target
for (let i = 0; i <= seg; i++) {
const t = i / seg;
pts.push([fx + (ex - fx) * t + gauss(rng) * 5 * u, fy + (ey - fy) * t + gauss(rng) * 5 * u]);
}
let d = `M ${pts[0][0].toFixed(1)} ${pts[0][1].toFixed(1)}`;
for (let i = 1; i < pts.length; i++) d += ` L ${pts[i][0].toFixed(1)} ${pts[i][1].toFixed(1)}`;
const ang = Math.atan2(ey - fy, ex - fx), hl = 26 * u;
d += ` M ${ex.toFixed(1)} ${ey.toFixed(1)} L ${(ex - Math.cos(ang - 0.4) * hl).toFixed(1)} ${(ey - Math.sin(ang - 0.4) * hl).toFixed(1)}`;
d += ` M ${ex.toFixed(1)} ${ey.toFixed(1)} L ${(ex - Math.cos(ang + 0.4) * hl).toFixed(1)} ${(ey - Math.sin(ang + 0.4) * hl).toFixed(1)}`;
body += `<path d="${d}" fill="none" stroke="${o.pencil}" stroke-width="${sw}" stroke-opacity="${o.opacity}" stroke-linecap="round" stroke-linejoin="round"/>\n`;
}
// the label, set near the arrow's tail
if (o.label) {
const lx = X(from[0] + (from[0] <= 0 ? 0.02 : -0.02)), ly = Y(from[1] - 0.03);
body += `<text x="${lx.toFixed(1)}" y="${ly.toFixed(1)}" font-size="${(o.fontSize * size).toFixed(0)}" font-family="'Bradley Hand','Segoe Script','Comic Sans MS',cursive" fill="${o.pencil}" fill-opacity="${o.opacity}" transform="rotate(${(gauss(rng) * 2).toFixed(1)} ${lx.toFixed(1)} ${ly.toFixed(1)})">${esc(o.label)}</text>\n`;
}
// optional registration "+" corners
if (o.corners) {
const m = 0.9, t = 14 * u;
for (const [sx, sy] of [[-m, -m], [m, -m], [-m, m], [m, m]]) {
const cx = X(sx), cy = Y(sy);
body += `<path d="M ${(cx - t).toFixed(1)} ${cy.toFixed(1)} L ${(cx + t).toFixed(1)} ${cy.toFixed(1)} M ${cx.toFixed(1)} ${(cy - t).toFixed(1)} L ${cx.toFixed(1)} ${(cy + t).toFixed(1)}" stroke="${o.pencil}" stroke-width="${sw}" stroke-opacity="${(o.opacity * 0.6).toFixed(2)}"/>\n`;
}
}
// optional plate caption (margin scrawl)
if (o.caption) {
const cx = X(o.captionAt[0]), cy = Y(o.captionAt[1]);
body += `<text x="${cx.toFixed(1)}" y="${cy.toFixed(1)}" font-size="${(o.fontSize * 0.85 * size).toFixed(0)}" font-family="'Bradley Hand','Segoe Script','Comic Sans MS',cursive" fill="${o.pencil}" fill-opacity="${(o.opacity * 0.85).toFixed(2)}">${esc(o.caption)}</text>\n`;
}
return `<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}"><g>${body}</g></svg>`;
}
function esc(s) { return String(s).replace(/[<&]/g, c => c === '<' ? '&lt;' : '&amp;'); }

136
src/compose/schema.js Normal file
View File

@@ -0,0 +1,136 @@
/* ============================================================
compose/schema.js — UI control descriptors for the composer, and
the DEFAULT composition (two-point floor grid). Data only (no DOM).
Each group: { id, label, transform?, enable?, controls:[ {path,label,type,...} ] }
path = dotted path INTO the group's config. types:
range {min,max,step} · color (#hex) · select {options} · toggle · text
Groups with transform:true get an auto x/y/rotation/scale block.
============================================================ */
export const DEFAULT_COMPOSITION = {
size: 1600, seed: 'MESON-5113',
background: {
color: 'rgb(229,222,203)',
film: { opacity: 0.6, density: 0.5, seed: 8 },
aging: { opacity: 0.5, scratches: 5, dust: 0.45, foxing: 0.5, seed: 5 },
grain: { opacity: 0.42, intensity: 0.42, seed: 19 },
glow: { strength: 0.55 }, // lit-page glow (lighter warm centre)
vignette: { strength: 0.4 }, // soft darkening toward the edges
},
fieldSea: {
enabled: true, layerOpacity: 1, transform: { x: 0, y: 0, rotation: 0, scale: 1 }, seed: 'VACUUM-5113',
color: { hueBack: 0.54, hueFront: 0.47, sat: 0.6 },
layers: 3, chaos: 0.3, blips: 0.7, mound: 0.3, horizonY: 0.36, lines: 46,
},
fieldGrid: {
enabled: true, layerOpacity: 1, pos: 'over', style: 'floor', transform: { x: 0, y: 0, rotation: 0, scale: 1 },
color: { hue: 0.5, hue2: 0.55, sat: 0.32, lightNear: 0.34, lightFar: 0.62 }, opacity: 0.44,
pitch: 0.45, yaw: 0.42, persp: 1.1, dist: 2.8, nx: 16, nz: 24, originY: 0.34, ripple: { amp: 0, freqI: 0.5, freqK: 0.35, phase: 0 },
},
disk: {
enabled: true, layerOpacity: 1, transform: { x: 0, y: -0.26, rotation: 0, scale: 0.78 },
hue: 0.06, sat: 0.82, size: 0.16, pressure: 0.85, intensity: 0.85, striations: 0.6, stain: 0.35, soften: 1.35,
},
bubble: {
enabled: true, layerOpacity: 1, transform: { x: 0.28, y: -0.1, rotation: 10, scale: 0.78 },
palette: 'magentarise', saturation: 1.05, traceHue: 0, transparentBase: true,
primaries: 18, sweepers: 5, cosmics: 6, eloss: 0.34, bfield: 1.0, deltaRate: 0.6,
},
fiduciaries: {
enabled: true, layerOpacity: 1, label: 'No 001', pencil: '#39312a', width: 1.0, arrow: true, corners: false, caption: '',
},
};
const R = (path, label, min, max, step) => ({ path, label, type: 'range', min, max, step });
export const GROUPS_SCHEMA = [
{
id: 'background', label: 'Background', controls: [
{ path: 'color', label: 'Paper colour', type: 'color' },
R('film.opacity', 'Film opacity', 0, 1, 0.01),
R('film.density', 'Film density', 0, 1, 0.01),
R('aging.opacity', 'Aging opacity', 0, 1, 0.01),
R('aging.scratches', 'Scratches', 0, 20, 1),
R('aging.foxing', 'Foxing', 0, 1, 0.01),
R('aging.dust', 'Dust', 0, 1, 0.01),
R('grain.opacity', 'Grain opacity', 0, 1, 0.01),
R('grain.intensity', 'Grain intensity', 0, 1, 0.01),
R('glow.strength', 'Page glow', 0, 1, 0.01),
R('vignette.strength', 'Vignette', 0, 1, 0.01),
],
},
{
id: 'fieldSea', label: 'Field · Sea', transform: true, enable: true, controls: [
R('color.hueBack', 'Hue · far', 0, 1, 0.005),
R('color.hueFront', 'Hue · near', 0, 1, 0.005),
R('color.sat', 'Saturation', 0, 1, 0.01),
R('layers', 'Plate layers', 1, 3, 1),
R('chaos', 'Chaos', 0, 1, 0.01),
R('blips', 'Blips', 0, 2, 0.05),
R('mound', 'Mound', 0, 1, 0.01),
R('horizonY', 'Horizon Y', 0.2, 0.6, 0.01),
R('lines', '# lines', 16, 80, 1),
{ path: 'seed', label: 'Seed', type: 'text' },
],
},
{
id: 'fieldGrid', label: 'Field · Grid', transform: true, enable: true, controls: [
{ path: 'style', label: 'Style', type: 'select', options: ['floor', 'radial'] },
{ path: 'pos', label: 'Stack position', type: 'select', options: ['over', 'behind'] },
R('opacity', 'Opacity', 0, 1, 0.01),
R('pitch', 'Pitch', 0, 1.4, 0.01),
R('yaw', 'Yaw · two-point', -1, 1, 0.01),
R('persp', 'Perspective', 0, 2, 0.01),
R('dist', 'Camera distance', 1.2, 6, 0.05),
R('nx', 'Width lines', 4, 36, 1),
R('nz', 'Depth lines', 4, 48, 1),
R('originY', 'Horizon Y', -0.4, 0.6, 0.01),
R('color.hue', 'Hue', 0, 1, 0.005),
R('color.sat', 'Saturation', 0, 1, 0.01),
R('ripple.amp', 'Floor ripple', 0, 2, 0.02),
],
},
{
id: 'disk', label: 'Disk · sun', transform: true, enable: true, controls: [
R('hue', 'Hue', 0, 1, 0.005),
R('sat', 'Saturation', 0, 1, 0.01),
R('size', 'Size', 0.05, 0.4, 0.005),
R('pressure', 'Pressure · dark core', 0, 1, 0.01),
R('intensity', 'Intensity', 0, 1, 0.01),
R('striations', 'Striations', 0, 1, 0.01),
R('stain', 'Staining', 0, 1, 0.01),
],
},
{
id: 'bubble', label: 'Bubble · event', transform: true, enable: true, controls: [
{ path: 'palette', label: 'Palette', type: 'select', options: ['magentarise', 'mono', 'kind', 'kindrise', 'kindlife', 'charge', 'beta', 'lifecycle', 'cyanotype'] },
{ path: 'transparentBase', label: 'Transparent base', type: 'toggle' },
R('saturation', 'Saturation', 0, 1.5, 0.01),
R('traceHue', 'Trace hue', 0, 1, 0.005),
R('primaries', 'Primaries', 3, 40, 1),
R('sweepers', 'Sweepers · arcs', 0, 12, 1),
R('cosmics', 'Cosmics', 0, 16, 1),
R('eloss', 'Energy loss', 0, 1.5, 0.01),
R('bfield', 'B-field', 0.2, 3, 0.01),
R('deltaRate', 'δ-ray rate', 0, 1, 0.01),
],
},
{
id: 'fiduciaries', label: 'Fiduciaries', transform: true, enable: true, controls: [
{ path: 'label', label: 'Label', type: 'text' },
{ path: 'pencil', label: 'Pencil', type: 'color' },
R('width', 'Line width', 0.3, 3, 0.1),
{ path: 'arrow', label: 'Arrow', type: 'toggle' },
{ path: 'corners', label: 'Registration corners', type: 'toggle' },
{ path: 'caption', label: 'Caption', type: 'text' },
],
},
];
// the common transform block, generated for groups with transform:true
export const COMMON_CONTROLS = [
R('layerOpacity', 'Opacity', 0, 1, 0.01),
R('transform.x', 'Centre X', -1, 1, 0.01),
R('transform.y', 'Centre Y', -1, 1, 0.01),
R('transform.rotation', 'Rotation°', -180, 180, 1),
R('transform.scale', 'Scale', 0.1, 2.5, 0.01),
];

175
src/qft/perspgrid.js Normal file
View File

@@ -0,0 +1,175 @@
/* ============================================================
qft/perspgrid.js — a PERSPECTIVE DEPTH GRID that RIPPLES.
Draughtsman's construction lines converging to a vanishing point
(VP, need not be on the horizon), but every line is displaced by a
shared wave field W(r,θ) so the whole grid undulates like the sea.
Two layers with DISTINCT ripple (frequency/phase) overlaid → gentle
moiré interference. Kept faint / near the paper tone — a structure
half-seen, a guess at the unknowable geometry behind the field.
• rays — recede from the VP, wiggling perpendicular to their run
• depth — transverse rings at perspective-spaced radii, breathing
in/out radially
Lines fade toward the VP (atmospheric convergence to infinity).
modes: solid (opaque paper) · plate (transparent, for plexi stacking)
============================================================ */
import { makeRng, range } from '../rng.js';
import { resolveSubstrate } from './palette.js';
function hslToRgb(h, s, l) {
h = ((h % 1) + 1) % 1;
const a = s * Math.min(l, 1 - l);
const f = (n) => { const k = (n + h * 12) % 12; return l - a * Math.max(-1, Math.min(k - 3, 9 - k, 1)); };
return [Math.round(f(0) * 255), Math.round(f(8) * 255), Math.round(f(4) * 255)];
}
const css = (c) => `rgb(${c[0]},${c[1]},${c[2]})`;
function smoothPath(pts, close = false) {
if (pts.length < 3) return 'M ' + pts.map(p => `${p.x.toFixed(1)} ${p.y.toFixed(1)}`).join(' L ');
let d = `M ${pts[0].x.toFixed(1)} ${pts[0].y.toFixed(1)} `;
const n = pts.length;
const get = (i) => pts[close ? (i + n) % n : Math.max(0, Math.min(n - 1, i))];
for (let i = 0; i < (close ? n : n - 1); i++) {
const p0 = get(i - 1), p1 = get(i), p2 = get(i + 1), p3 = get(i + 2);
const c1x = p1.x + (p2.x - p0.x) / 6, c1y = p1.y + (p2.y - p0.y) / 6;
const c2x = p2.x - (p3.x - p1.x) / 6, c2y = p2.y - (p3.y - p1.y) / 6;
d += `C ${c1x.toFixed(1)} ${c1y.toFixed(1)} ${c2x.toFixed(1)} ${c2y.toFixed(1)} ${p2.x.toFixed(1)} ${p2.y.toFixed(1)} `;
}
if (close) d += 'Z';
return d;
}
/* perspFloorSVG — a STRAIGHT-LINED perspective grid: a planar cartesian grid
(rails + ties) projected with a movable camera, converging to a vanishing
point. Reads instantly as receding space. Optional GENTLE floor-height ripple
(default ~0, stays straight). yaw>0 → two-point perspective. */
export function perspFloorSVG(size, opts = {}) {
const o = Object.assign({
seed: 'GAUGE-2046', salt: 'floor', mode: 'solid', substrate: 'cream',
nx: 14, nz: 22, yaw: 0, pitch: 0.5, roll: 0, persp: 1.0, dist: 3.0, zShift: 0,
scale: 1.0, originX: 0, originY: 0.32,
rippleAmp: 0, rippleFreqI: 0.5, rippleFreqK: 0.4, ripplePhase: 0,
hue: 0.56, hue2: 0.5, sat: 0.26, lightNear: 0.32, lightFar: 0.62,
stroke: 1.5, strokeFar: 0.45, opacityMul: 1,
}, opts);
const W = size, H = size, u = size / 1000;
const paper = resolveSubstrate(o.substrate).paper.flat;
const transparent = o.mode === 'plate';
const cy = Math.cos(o.yaw), sy = Math.sin(o.yaw), cx = Math.cos(o.pitch), sx = Math.sin(o.pitch), cz = Math.cos(o.roll), sz = Math.sin(o.roll);
const dist = o.dist * Math.max(1, o.nz), near = 0.12 * dist;
const Lh = size / 2;
const cssR = (c) => `rgb(${c[0]},${c[1]},${c[2]})`;
// project a floor point (i, k); returns screen px + depth + clip flag
const proj = (i, k) => {
const yWorld = o.rippleAmp ? o.rippleAmp * Math.sin(o.rippleFreqI * i + o.rippleFreqK * k + o.ripplePhase) : 0;
let x = i, y = yWorld, z = k;
const x1 = x * cy + z * sy, z1 = -x * sy + z * cy, y1 = y;
const y2 = y1 * cx - z1 * sx, z2 = y1 * sx + z1 * cx, x2 = x1;
const xr = x2 * cz - y2 * sz, yr = x2 * sz + y2 * cz, zr = z2 + o.zShift;
const denom = dist - zr * o.persp, clip = o.persp > 0 && denom <= near;
const f = o.persp > 0 ? dist / Math.max(denom, near) : 1;
return {
x: W / 2 + (xr * f * o.scale * 0.5 + o.originX) * Lh,
y: H / 2 + (-yr * f * o.scale * 0.5 + o.originY) * Lh,
depth: zr, clip,
};
};
// grid of vertices
const V = [];
for (let i = -o.nx; i <= o.nx; i++) { const row = []; for (let k = -o.nz; k <= o.nz; k++) row.push(proj(i, k)); V.push(row); }
// depth range for atmospheric fade
let zmin = Infinity, zmax = -Infinity;
for (const row of V) for (const p of row) { if (p.clip) continue; if (p.depth < zmin) zmin = p.depth; if (p.depth > zmax) zmax = p.depth; }
const span = (zmax - zmin) || 1;
const fade = (d) => Math.min(1, Math.max(0, (zmax - d) / span)); // 1 near → 0 far
const colAt = (t) => cssR(hslToRgb(o.hue + (o.hue2 - o.hue) * (1 - t), o.sat * (0.5 + 0.5 * t), o.lightFar + (o.lightNear - o.lightFar) * t));
const swAt = (t) => (o.strokeFar + (o.stroke - o.strokeFar) * t) * u;
const opAt = (t) => ((transparent ? 0.18 + 0.55 * t : 0.28 + 0.55 * t) * o.opacityMul);
const seg = (a, b) => {
if (a.clip || b.clip) return '';
const t = fade((a.depth + b.depth) / 2);
return `<line x1="${a.x.toFixed(1)}" y1="${a.y.toFixed(1)}" x2="${b.x.toFixed(1)}" y2="${b.y.toFixed(1)}" stroke="${colAt(t)}" stroke-width="${swAt(t).toFixed(2)}" stroke-opacity="${opAt(t).toFixed(2)}" stroke-linecap="round"/>`;
};
let body = '';
for (let i = 0; i < V.length; i++) for (let k = 0; k < V[i].length; k++) {
if (k + 1 < V[i].length) body += seg(V[i][k], V[i][k + 1]); // rails (constant i, into depth)
if (i + 1 < V.length) body += seg(V[i][k], V[i + 1][k]); // ties (constant k, across)
}
const bg = transparent ? '' : `<rect width="${W}" height="${H}" fill="${cssR(paper)}"/>`;
return `<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}">
${bg}
<g>${body}</g>
</svg>`;
}
export function perspectiveGridSVG(size, opts = {}) {
const o = Object.assign({
seed: 'GAUGE-2046', salt: 'persp', mode: 'solid', substrate: 'cream',
vp: [0, -0.1], dir: Math.PI / 2, spread: Math.PI * 2,
rays: 30, depthLines: 16, depthPow: 2.3, rMin: 0.05, rMax: 2.8,
// ripple wave field
rippleAmp: 0.07, // perpendicular wiggle of rays (normalized)
rippleAmpRad: 0.06, // radial breathing of depth rings
rippleFreqR: 2.4, // cycles along the radius
rippleFreqA: 5, // cycles around the angle
ripplePhase: 0,
// faint, near-paper colour by default
hue: 0.55, hue2: 0.5, sat: 0.28, lightNear: 0.42, lightFar: 0.66,
stroke: 1.4, strokeFar: 0.5, opacityMul: 1,
}, opts);
const W = size, H = size, u = size / 1000;
const paper = resolveSubstrate(o.substrate).paper.flat;
const rng = makeRng(o.seed, o.salt);
const transparent = o.mode === 'plate';
const X = (nx) => (nx + 1) / 2 * W, Y = (ny) => (ny + 1) / 2 * H, L = size / 2;
const vpx = X(o.vp[0]), vpy = Y(o.vp[1]);
const full = o.spread >= Math.PI * 2 - 1e-3;
const a0 = o.dir - o.spread / 2;
const da = o.spread / (full ? o.rays : (o.rays - 1));
const angles = [];
for (let i = 0; i < o.rays; i++) angles.push(a0 + i * da);
// shared wave field — rays and rings ripple together (coherent), but each
// LAYER gets a distinct phase/freq so two overlaid layers interfere.
const Wf = (rN, ang) => Math.sin(2 * Math.PI * o.rippleFreqR * rN + o.rippleFreqA * ang + o.ripplePhase)
+ 0.4 * Math.sin(2 * Math.PI * o.rippleFreqR * 1.7 * rN + o.ripplePhase * 1.3 + 1.1);
const colAt = (t) => css(hslToRgb(o.hue + (o.hue2 - o.hue) * t, o.sat * (0.5 + 0.5 * t), o.lightFar + (o.lightNear - o.lightFar) * t));
const swAt = (t) => (o.strokeFar + (o.stroke - o.strokeFar) * t) * u;
const opAt = (t) => ((transparent ? 0.22 + 0.5 * t : 0.32 + 0.5 * t) * o.opacityMul);
let body = '';
// ---- rays (receding orthogonals, wiggling) ----
for (const ang of angles) {
const dx = Math.cos(ang), dy = Math.sin(ang), px = -Math.sin(ang), py = Math.cos(ang);
const pts = []; const steps = 44;
for (let s = 0; s <= steps; s++) {
const r = o.rMin + (o.rMax - o.rMin) * (s / steps), rN = r / o.rMax;
const off = o.rippleAmp * Wf(rN, ang) * (0.15 + 0.85 * rN);
pts.push({ x: vpx + (dx * r + px * off) * L, y: vpy + (dy * r + py * off) * L });
}
const t = 0.7;
body += `<path d="${smoothPath(pts)}" fill="none" stroke="${colAt(t)}" stroke-width="${swAt(t).toFixed(2)}" stroke-opacity="${opAt(t).toFixed(2)}" stroke-linecap="round"/>\n`;
}
// ---- depth rings (transverse, breathing radially) ----
const aSamples = full ? Math.max(o.rays, 48) : o.rays;
for (let j = 0; j < o.depthLines; j++) {
const rj = o.rMin + (o.rMax - o.rMin) * Math.pow((j + 1) / o.depthLines, o.depthPow), rN = rj / o.rMax;
const pts = [];
for (let i = 0; i < aSamples; i++) {
const ang = full ? (i / aSamples) * Math.PI * 2 : a0 + (i / (aSamples - 1)) * o.spread;
const rr = rj * (1 + o.rippleAmpRad * Wf(rN, ang));
pts.push({ x: vpx + Math.cos(ang) * rr * L, y: vpy + Math.sin(ang) * rr * L });
}
body += `<path d="${smoothPath(pts, full)}" fill="none" stroke="${colAt(rN)}" stroke-width="${swAt(rN).toFixed(2)}" stroke-opacity="${opAt(rN).toFixed(2)}" stroke-linejoin="round"/>\n`;
}
const bg = transparent ? '' : `<rect width="${W}" height="${H}" fill="${css(paper)}"/>`;
return `<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}">
${bg}
<g>${body}</g>
</svg>`;
}

View File

@@ -295,15 +295,19 @@ export function renderSVG(scene, params, sizePx = 4800) {
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)} · palette=${params.palette || 'mono'}</metadata>\n`;
s += defs({ paperC, ink, coreInk, press, baseVign: pal.vign(), params, u, colorMap });
s += layer('background', 'Background', bg);
s += layer('optics', 'Chamber optics', optics);
s += layer('shock', 'Shock disk', shock, params.diskSoften > 0 ? 'filter="url(#soften)"' : '');
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 += layer('media', 'Media & hand', media);
// emit: optional array of layer-GROUPS to include, so the plate can be rendered
// as decoupled sub-layers — 'background' | 'disk' | 'bubble' | 'fiduciaries'.
const emit = params.emit;
const keep = (gp) => !emit || emit.includes(gp);
if (keep('background')) s += layer('background', 'Background', bg);
if (keep('bubble')) s += layer('optics', 'Chamber optics', optics);
if (keep('disk')) s += layer('shock', 'Shock disk', shock, params.diskSoften > 0 ? 'filter="url(#soften)"' : '');
if (keep('bubble')) s += trackLayers;
if (keep('bubble')) s += layer('damage', 'Plate damage', damage);
if (keep('fiduciaries')) s += layer('fiducials', 'Fiducials', fids);
if (keep('background')) s += layer('vignette', 'Vignette', vign);
if (keep('fiduciaries')) s += layer('header', 'Archival header', header);
if (keep('fiduciaries')) s += layer('media', 'Media & hand', media);
s += `</svg>\n`;
return s;
}