web interface
This commit is contained in:
257
src/compose/composition.js
Normal file
257
src/compose/composition.js
Normal 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
67
src/compose/fiduciary.js
Normal 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 === '<' ? '<' : '&'); }
|
||||
136
src/compose/schema.js
Normal file
136
src/compose/schema.js
Normal 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
175
src/qft/perspgrid.js
Normal 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>`;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user