Ready to test print
This commit is contained in:
67
src/compose/waterline.js
Normal file
67
src/compose/waterline.js
Normal file
@@ -0,0 +1,67 @@
|
||||
/* ============================================================
|
||||
waterline.js — the world is sovereign, and it doesn't notice.
|
||||
Splits track polylines at a horizon line yH (scene coords,
|
||||
[-1,1]) into ABOVE and BELOW sets so a compositor can treat
|
||||
the submerged evidence differently: the sea never reacts to
|
||||
the event; everything that crosses the surface is altered BY
|
||||
the water instead —
|
||||
· a refraction KINK at the crossing (horizontal offset
|
||||
driven by the entry slope),
|
||||
· a slight vertical COMPRESSION of the submerged world
|
||||
toward the surface (optical squash),
|
||||
· (tint / blur / dimming are the compositor's job).
|
||||
β, weight, kind, z, age are preserved across the split so
|
||||
physics-law palettes keep their colour continuity.
|
||||
============================================================ */
|
||||
|
||||
const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
|
||||
|
||||
/* one track → runs of consecutive points on one side, with exact
|
||||
crossing points interpolated onto BOTH adjacent runs */
|
||||
function runsOf(track, yH) {
|
||||
const runs = [];
|
||||
let cur = null;
|
||||
const pts = track.pts;
|
||||
const side = (p) => (p.y < yH ? 'above' : 'below');
|
||||
for (let i = 0; i < pts.length; i++) {
|
||||
const p = pts[i];
|
||||
if (!cur) { cur = { side: side(p), pts: [p], entrySlope: 0 }; continue; }
|
||||
if (side(p) === cur.side) { cur.pts.push(p); continue; }
|
||||
// crossing: interpolate the surface point
|
||||
const q = pts[i - 1];
|
||||
const f = (yH - q.y) / (p.y - q.y);
|
||||
const cross = {
|
||||
x: q.x + (p.x - q.x) * f, y: yH,
|
||||
beta: (q.beta ?? 0.5) + ((p.beta ?? 0.5) - (q.beta ?? 0.5)) * f,
|
||||
theta: q.theta ?? 0,
|
||||
};
|
||||
cur.pts.push(cross);
|
||||
runs.push(cur);
|
||||
const slope = (p.x - q.x) / Math.max(1e-6, Math.abs(p.y - q.y));
|
||||
cur = { side: side(p), pts: [{ ...cross }], entrySlope: clamp(slope, -1.4, 1.4) };
|
||||
}
|
||||
if (cur) runs.push(cur);
|
||||
return runs;
|
||||
}
|
||||
|
||||
/* split a track list at the waterline.
|
||||
opts: kink (horizontal refraction offset per unit entry slope),
|
||||
compress (vertical squash of the submerged world, 1 = none) */
|
||||
export function splitTracksAtWaterline(tracks, yH, opts = {}) {
|
||||
const kink = opts.kink ?? 0.016;
|
||||
const comp = opts.compress ?? 0.95;
|
||||
const above = [], below = [];
|
||||
for (const t of tracks) {
|
||||
if (!t.pts || t.pts.length < 2) continue;
|
||||
for (const run of runsOf(t, yH)) {
|
||||
if (run.pts.length < 2) continue;
|
||||
if (run.side === 'above') { above.push({ ...t, pts: run.pts }); continue; }
|
||||
const dx = run.entrySlope * kink;
|
||||
below.push({
|
||||
...t,
|
||||
pts: run.pts.map(p => ({ ...p, x: p.x + dx, y: yH + (p.y - yH) * comp })),
|
||||
});
|
||||
}
|
||||
}
|
||||
return { above, below };
|
||||
}
|
||||
@@ -51,7 +51,7 @@ export function carpetSVG(size, opts = {}) {
|
||||
// power-spectrum-shaped spectrum)
|
||||
warpFn: null, phaseFn: null, modesFn: null,
|
||||
}, opts);
|
||||
const W = size, H = size, u = size / 1000;
|
||||
const W = o.width ?? size, H = o.height ?? size, u = size / 1000; // width/height default to a square of `size`
|
||||
const paper = resolveSubstrate(o.substrate).paper.flat;
|
||||
const rng = makeRng(o.seed, o.salt);
|
||||
|
||||
@@ -66,6 +66,13 @@ export function carpetSVG(size, opts = {}) {
|
||||
}
|
||||
const norm = modes.reduce((s, m) => s + m.a, 0) || 1;
|
||||
|
||||
// coherentRows: express the standing-wave field in a SHARED row coordinate
|
||||
// (this reference count) instead of the layer's own `rows`. Two carpets with
|
||||
// the same seed/salt/chaos/blips but DIFFERENT rows then phase-lock — the
|
||||
// crests and spiralling packets land at the same depth, so a stack of sheets
|
||||
// with different line counts reads as one sea sampled at several resolutions.
|
||||
const REF = o.coherentRows ?? o.rows;
|
||||
|
||||
// ---- coherent excitations: soft wave-packets that drift + rotate phase
|
||||
// across rows, so they SPIRAL through the depth of the stack ----
|
||||
const nExc = Math.round((2 + o.chaos * 5) * o.blips);
|
||||
@@ -73,26 +80,30 @@ export function carpetSVG(size, opts = {}) {
|
||||
for (let e = 0; e < nExc; e++) {
|
||||
exc.push({
|
||||
x0: range(rng, 0.12, 0.88),
|
||||
row0: range(rng, 0, o.rows - 1),
|
||||
span: range(rng, 0.12, 0.3) * o.rows, // rows over which it lives
|
||||
row0: range(rng, 0, REF - 1),
|
||||
span: range(rng, 0.12, 0.3) * REF, // reference rows over which it lives
|
||||
w: range(rng, 0.05, 0.11), // packet width (soft edge)
|
||||
k: range(rng, 3.5, 7), // oscillations within the packet
|
||||
amp: range(rng, 0.30, 0.7),
|
||||
drift: range(rng, -0.018, 0.018), // lateral drift per row
|
||||
drift: range(rng, -0.018, 0.018), // lateral drift per ref-row
|
||||
phase: range(rng, 0, Math.PI * 2),
|
||||
phaseAdv: range(rng, -0.45, 0.45), // phase rotation per row → spiral
|
||||
phaseAdv: range(rng, -0.45, 0.45), // phase rotation per ref-row → spiral
|
||||
sign: rng() < 0.5 ? -1 : 1,
|
||||
});
|
||||
}
|
||||
|
||||
// r is this layer's integer row; rr is the shared reference-row coordinate at
|
||||
// the same depth, so the field is a function of depth, not of line count.
|
||||
const value = (t, r) => {
|
||||
const d = o.rows > 1 ? r / (o.rows - 1) : 0;
|
||||
const rr = d * (REF - 1);
|
||||
let s = 0;
|
||||
const dphi = o.phaseFn ? o.phaseFn(t, r) : 0;
|
||||
for (const m of modes) s += m.a * Math.sin(2 * Math.PI * m.f * t + m.phi + r * m.drift + dphi);
|
||||
for (const m of modes) s += m.a * Math.sin(2 * Math.PI * m.f * t + m.phi + rr * m.drift + dphi);
|
||||
s /= norm;
|
||||
let blip = 0;
|
||||
for (const e of exc) {
|
||||
const dr = r - e.row0;
|
||||
const dr = rr - e.row0;
|
||||
const env = Math.exp(-Math.pow(dr / (e.span * 0.5), 2));
|
||||
if (env < 0.02) continue;
|
||||
const cx = e.x0 + e.drift * dr;
|
||||
|
||||
@@ -112,6 +112,25 @@ export function bubbleStops(soft) {
|
||||
const midA = Math.max(0.15, 0.9 - 0.32 * s);
|
||||
return [[0, coreA], [midOff, midA], [1, 0]];
|
||||
}
|
||||
|
||||
// Hollow "hybrid" bubble: a faint paper-tinted veil in the core + a brighter,
|
||||
// SATURATED rim that softens outward — a refracting bubble rather than a filled
|
||||
// disc. Each stop carries [offset, alpha, paperMix]: paperMix 1 = fully toward
|
||||
// paper (pale core), 0 = full family colour (jewel rim). `weight` (0..1) scales
|
||||
// the rim's peak alpha for delicacy; `soft` widens the rim inward.
|
||||
export function bubbleHollowStops(weight = 0.7, soft = 0.3, peakOverride = null) {
|
||||
const s = clamp(soft, 0, 1.6);
|
||||
const peak = peakOverride ?? (0.35 + 0.45 * clamp(weight, 0, 1)); // ~0.7 at weight 0.78
|
||||
const rimOff = clamp(0.80 - 0.14 * s, 0.6, 0.86); // rim sits ~0.7–0.8 of radius
|
||||
return [
|
||||
[0.00, peak * 0.36, 0.74], // pale veil at the very centre
|
||||
[Math.max(0.22, rimOff - 0.5), peak * 0.26, 0.5], // dip — the transparent belly
|
||||
[rimOff - 0.16, peak * 0.72, 0.12], // rim rising, colour saturating
|
||||
[rimOff, peak, 0.0], // bright saturated rim (peak)
|
||||
[Math.min(0.94, rimOff + 0.12), peak * 0.55, 0.04],
|
||||
[1.00, 0.0, 0.0], // feather to nothing
|
||||
];
|
||||
}
|
||||
export const bubbleFoot = (soft) => 2.4 + clamp(soft, 0, 1.6) * 1.5; // sprite footprint grows with softness
|
||||
|
||||
/* ---------- the colour mappings ---------- */
|
||||
@@ -246,6 +265,52 @@ export const PALETTES = {
|
||||
feature: (e) => featureHue(e.inv, e.diskHue ?? 0.06, e.diskSat ?? 0.82), // disk + furniture
|
||||
},
|
||||
|
||||
// magentarise's type-family with the β law living INSIDE each member: kind
|
||||
// chooses the hue (hot magenta primaries, pink δ-rays, cooler purples for
|
||||
// cosmics and V's — the family structure that made seethe-bold's chord), and
|
||||
// within each track β drives transparent birth → the family hue at full
|
||||
// strength → a dive to dark violet only at the very end (dying is slowing).
|
||||
magbeta: {
|
||||
id: 'magbeta', label: 'Magenta family · β within',
|
||||
bubbleInk: (b, e) => {
|
||||
const bb = Math.round(clamp(b.beta ?? 0.5, 0, 1) * 20) / 20;
|
||||
const slow = 1 - bb;
|
||||
const kindH = (((MAG_HUE[b.track.kind] ?? 0.9) + (e.traceHue ?? 0)) % 1 + 1) % 1;
|
||||
const dive = Math.pow(slow, 3.2); // hold the family hue; violet is the last word
|
||||
const h = kindH - (kindH - 0.755) * dive;
|
||||
const s = 0.62 + 0.28 * slow;
|
||||
const l = e.inv ? 0.56 - 0.22 * slow : 0.32 + 0.30 * slow;
|
||||
const c = hslToRgb(h, s, l);
|
||||
const toPaper = Math.pow(bb, 1.4) * 0.7; // fast → dissolves into the substrate
|
||||
return mix(c, e.basePaper.flat, toPaper);
|
||||
},
|
||||
feature: (e) => featureHue(e.inv, e.diskHue ?? 0.06, e.diskSat ?? 0.82),
|
||||
},
|
||||
|
||||
// β-law: the strict physics legend. Opacity is already physics (bubble density
|
||||
// ∝ 1/β² — fast particles barely nucleate), and ink now follows the same law:
|
||||
// born fast = barely tints the PAPER (mixing toward the substrate, not white —
|
||||
// transparent births), slowing = saturating through the magenta family, at
|
||||
// rest = dense violet. "Death earns the violet" emerges without a narrative
|
||||
// rule, because dying IS slowing. Feature/disk defaults to the ember heart.
|
||||
betalaw: {
|
||||
id: 'betalaw', label: 'β law (fast → faint · rest → violet)',
|
||||
bubbleInk: (b, e) => {
|
||||
const bb = Math.round(clamp(b.beta ?? 0.5, 0, 1) * 20) / 20; // quantise: bounded gradients
|
||||
const slow = 1 - bb;
|
||||
// quadratic hue: the family HOLDS hot magenta through most of a life and
|
||||
// dives to violet only at the very end — the violet is the last word, not
|
||||
// the whole sentence.
|
||||
const h = 0.95 - 0.19 * slow * slow;
|
||||
const s = 0.60 + 0.30 * slow;
|
||||
const l = e.inv ? 0.58 - 0.24 * slow : 0.30 + 0.32 * slow;
|
||||
const c = hslToRgb(h, s, l);
|
||||
const toPaper = Math.pow(bb, 1.3) * 0.72; // fast → dissolves into the substrate
|
||||
return mix(c, e.basePaper.flat, toPaper);
|
||||
},
|
||||
feature: (e) => featureHue(e.inv, e.diskHue ?? 0.02, e.diskSat ?? 0.88),
|
||||
},
|
||||
|
||||
// a complete "chemistry": overrides paper + ink together (deep Prussian-blue
|
||||
// ground, pale lines) — the blueprint/cyanotype look.
|
||||
cyanotype: {
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
============================================================ */
|
||||
import { makeRng, cyrb53 } from '../rng.js';
|
||||
import { sampleBubbles, trackInkWeight, depthFactors } from '../scene/bubbles.js';
|
||||
import { resolvePalette, paperTone, rgbHex, rgbKey, bubbleStops, hslToRgb, mix } from './palette.js';
|
||||
import { resolvePalette, paperTone, rgbHex, rgbKey, bubbleStops, bubbleHollowStops, hslToRgb, mix } from './palette.js';
|
||||
|
||||
const MARGIN = 0.02;
|
||||
|
||||
@@ -37,12 +37,18 @@ function rgb2hsl([r, g, b]) {
|
||||
}
|
||||
|
||||
export function renderSVG(scene, params, sizePx = 4800) {
|
||||
const w = sizePx, h = sizePx;
|
||||
const scale = (w / 2) * (1 - MARGIN);
|
||||
const cx = w / 2, cy = h / 2;
|
||||
// Non-square canvas support (defaults preserve exact square behaviour):
|
||||
// canvasW/canvasH → explicit page size · originX/originY → scene origin (px)
|
||||
// scale stays isotropic (min dimension) so the event never distorts; the
|
||||
// scene origin can be placed anywhere on the page (e.g. a golden intersection).
|
||||
const w = params.canvasW ?? sizePx, h = params.canvasH ?? sizePx;
|
||||
// sceneZoom > 1 magnifies the whole event about its origin (bubbles AND spray
|
||||
// extent grow together); content may spill past the page edge. Default 1 = unchanged.
|
||||
const scale = (Math.min(w, h) / 2) * (1 - MARGIN) * (params.sceneZoom ?? 1);
|
||||
const cx = params.originX ?? w / 2, cy = params.originY ?? h / 2;
|
||||
const tx = (x) => (cx + x * scale).toFixed(2);
|
||||
const ty = (y) => (cy + y * scale).toFixed(2);
|
||||
const u = w / 1000;
|
||||
const u = Math.min(w, h) / 1000;
|
||||
|
||||
const inv = params.invert;
|
||||
// base ink (mono), toned paper (independent of ink palette), then resolve feel
|
||||
@@ -60,7 +66,10 @@ export function renderSVG(scene, params, sizePx = 4800) {
|
||||
// so the centre reads as compressed colour rather than a hole.
|
||||
const press = Math.max(0, Math.min(1, params.diskPressure ?? 0));
|
||||
const [fh, fs, fl] = rgb2hsl(pal.feature());
|
||||
const coreInk = rgbHex(hslToRgb(fh, Math.min(1, fs * (1 + 0.25 * press)), fl * (1 - 0.6 * press)));
|
||||
// diskHollow winds the compression back: the core ink relaxes toward the
|
||||
// feature hue as the centre opens (a grey plug would just become a grey ring)
|
||||
const hol0 = Math.max(0, Math.min(1, params.diskHollow ?? 0));
|
||||
const coreInk = rgbHex(hslToRgb(fh, Math.min(1, fs * (1 + 0.25 * press)), fl * (1 - 0.6 * press * (1 - 0.75 * hol0))));
|
||||
const featKey = rgbKey(pal.feature());
|
||||
const colorMap = new Map([[featKey, pal.feature()]]); // distinct bubble colours → gradients
|
||||
|
||||
@@ -155,6 +164,12 @@ export function renderSVG(scene, params, sizePx = 4800) {
|
||||
const buckets = new Map(); // layerId -> Map(colorKey|alpha -> {key, alpha, arr})
|
||||
const ensure = (id) => { if (!under.has(id)) { under.set(id, ''); buckets.set(id, new Map()); } };
|
||||
|
||||
const bop = params.bubbleOpacity ?? 1; // master bubble opacity (a single slider)
|
||||
const hollowOn = (params.bubbleHollow ?? 0) > 0;
|
||||
// bubbleMerge: crowded bubbles fuse into ONE flat union at a uniform density
|
||||
// (group opacity isolates → overlaps don't accumulate). Dense knots become
|
||||
// even lace, not mud — even ink density = even backlit transmission.
|
||||
const merge = !!params.bubbleMerge;
|
||||
const bubbleRng = makeRng(params.seed, 'bubbles'); // scene.tracks order → deterministic
|
||||
for (const track of scene.tracks) {
|
||||
if (track.pts.length < 2) continue;
|
||||
@@ -162,16 +177,18 @@ export function renderSVG(scene, params, sizePx = 4800) {
|
||||
ensure(id);
|
||||
const df = depthFactors(track, params);
|
||||
const repCol = pal.ink(track); // representative ink (under-stroke)
|
||||
// continuity under-stroke
|
||||
// continuity under-stroke. When bubbles are hollow, thin this solid core
|
||||
// hard so the delicate rings carry the trail instead of a filled spine.
|
||||
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) {
|
||||
const lw = Math.min(2.6, 0.25 + Math.sqrt(iw) * 0.12) * u * params.size * track.weight * (hollowOn ? 0.6 : 1);
|
||||
if (lw >= 0.2 * u && !merge) { // merge carries continuity via the union itself
|
||||
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="${rgbHex(repCol)}" stroke-opacity="${(0.14 * df.tone).toFixed(3)}" stroke-width="${lw.toFixed(2)}"/>`);
|
||||
const uop = 0.14 * df.tone * bop * (hollowOn ? 0.4 : 1);
|
||||
under.set(id, under.get(id) + `<path d="${d}" stroke="${rgbHex(repCol)}" stroke-opacity="${uop.toFixed(3)}" stroke-width="${lw.toFixed(2)}"/>`);
|
||||
}
|
||||
// bubbles — opacity from depth/age tone, colour per-bubble from palette
|
||||
const alpha = Math.round(Math.min(1, 0.36 + df.tone * 0.58) * 20) / 20;
|
||||
// bubbles — merge: uniform bucket (density capped by the group). else: opacity from depth/age tone × master.
|
||||
const alpha = merge ? 1 : Math.round(Math.min(1, (0.36 + df.tone * 0.58) * bop) * 100) / 100;
|
||||
const m = buckets.get(id);
|
||||
for (const b of sampleBubbles({ ...track, sizeScale: df.sizeScale }, params, bubbleRng)) {
|
||||
const bcol = pal.bubbleInk(track, b.life, b.beta), bkc = rgbKey(bcol);
|
||||
@@ -187,7 +204,11 @@ export function renderSVG(scene, params, sizePx = 4800) {
|
||||
const us = under.get(L.id);
|
||||
if (us) content += `<g fill="none" stroke-linecap="round" stroke-linejoin="round">${us}</g>\n`;
|
||||
for (const { key, alpha, arr } of buckets.get(L.id).values()) {
|
||||
if (arr.length) content += `<g fill="url(#bub-${key})" fill-opacity="${alpha}">${arr.join('')}</g>\n`;
|
||||
if (!arr.length) continue;
|
||||
// merge: one isolated group at the uniform target → overlaps stay flat (no mud).
|
||||
content += merge
|
||||
? `<g fill="url(#bub-${key})" opacity="${bop.toFixed(2)}" style="isolation:isolate">${arr.join('')}</g>\n`
|
||||
: `<g fill="url(#bub-${key})" fill-opacity="${alpha}">${arr.join('')}</g>\n`;
|
||||
}
|
||||
return layer(L.id, L.label, content);
|
||||
}).join('');
|
||||
@@ -309,7 +330,7 @@ export function renderSVG(scene, params, sizePx = 4800) {
|
||||
let s = `<?xml version="1.0" encoding="UTF-8"?>\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)} · palette=${params.palette || 'mono'}</metadata>\n`;
|
||||
s += defs({ paperC, ink, coreInk, press, baseVign: pal.vign(), params, u, colorMap });
|
||||
s += defs({ paperC, paperFlat: paperRGB.flat, ink, coreInk, press, baseVign: pal.vign(), params, u, colorMap });
|
||||
// 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;
|
||||
@@ -327,19 +348,52 @@ export function renderSVG(scene, params, sizePx = 4800) {
|
||||
return s;
|
||||
}
|
||||
|
||||
function defs({ paperC, ink, coreInk, press = 0, baseVign, params, u, colorMap }) {
|
||||
// diskHollow: 0 = pressure plug (dense core), 1 = the centre dissolves to the
|
||||
// paper — density gathers into an ember RING and the heart is left open (for
|
||||
// backlit media the unprinted centre passes lamp light: the sun glows).
|
||||
function shockcoreStops({ coreInk, ink, press, hollow }) {
|
||||
const hol = Math.max(0, Math.min(1, hollow ?? 0));
|
||||
const core = coreInk || ink;
|
||||
const a0 = 0.5 + press * 0.45, a1 = 0.32 + press * 0.32;
|
||||
if (hol <= 0) return `<stop offset="0%" stop-color="${core}" stop-opacity="${a0.toFixed(2)}"/>
|
||||
<stop offset="28%" stop-color="${core}" stop-opacity="${a1.toFixed(2)}"/>
|
||||
<stop offset="62%" stop-color="${ink}" stop-opacity="0.28"/>
|
||||
<stop offset="100%" stop-color="${ink}" stop-opacity="0"/>`;
|
||||
return `<stop offset="0%" stop-color="${core}" stop-opacity="${(a0 * (1 - hol)).toFixed(2)}"/>
|
||||
<stop offset="${Math.round(16 + 8 * hol)}%" stop-color="${core}" stop-opacity="${(a0 * (1 - 0.72 * hol)).toFixed(2)}"/>
|
||||
<stop offset="${Math.round(28 + 12 * hol)}%" stop-color="${core}" stop-opacity="${(a1 * (1 - 0.45 * hol)).toFixed(2)}"/>
|
||||
<stop offset="62%" stop-color="${ink}" stop-opacity="0.28"/>
|
||||
<stop offset="100%" stop-color="${ink}" stop-opacity="0"/>`;
|
||||
}
|
||||
|
||||
function defs({ paperC, paperFlat, ink, coreInk, press = 0, baseVign, params, u, colorMap }) {
|
||||
const soften = params.diskSoften > 0
|
||||
? `<filter id="soften" x="-20%" y="-20%" width="140%" height="140%"><feGaussianBlur stdDeviation="${(params.diskSoften * u).toFixed(2)}"/></filter>`
|
||||
: '';
|
||||
// one soft-bubble gradient per distinct ink colour used (edge profile shared
|
||||
// with the raster sprite via bubbleStops)
|
||||
const stops = bubbleStops(params.bubbleSoft ?? 0.3);
|
||||
// one bubble gradient per distinct ink colour. Default: soft filled disc
|
||||
// (edge profile shared with the raster sprite via bubbleStops). Opt-in
|
||||
// bubbleHollow>0: a hollow refracting bubble — pale core, saturated rim.
|
||||
const hollow = params.bubbleHollow ?? 0;
|
||||
let bubGrads = '';
|
||||
for (const [key, col] of colorMap) {
|
||||
const c = rgbHex(col);
|
||||
let st = '';
|
||||
for (const [off, a] of stops) st += `<stop offset="${(off * 100).toFixed(0)}%" stop-color="${c}" stop-opacity="${a.toFixed(3)}"/>`;
|
||||
bubGrads += `<radialGradient id="bub-${key}" cx="50%" cy="50%" r="50%">${st}</radialGradient>`;
|
||||
if (hollow > 0) {
|
||||
// merge wants a near-opaque rim so union coverage is flat within the group
|
||||
const hy = bubbleHollowStops(params.bubbleWeight ?? 0.7, params.bubbleSoft ?? 0.3, params.bubbleMerge ? 0.97 : null);
|
||||
for (const [key, col] of colorMap) {
|
||||
let st = '';
|
||||
for (const [off, a, pm] of hy) {
|
||||
const c = rgbHex(mix(col, paperFlat, pm * hollow)); // rim = family colour, core → paper
|
||||
st += `<stop offset="${(off * 100).toFixed(0)}%" stop-color="${c}" stop-opacity="${a.toFixed(3)}"/>`;
|
||||
}
|
||||
bubGrads += `<radialGradient id="bub-${key}" cx="50%" cy="50%" r="50%">${st}</radialGradient>`;
|
||||
}
|
||||
} else {
|
||||
const stops = bubbleStops(params.bubbleSoft ?? 0.3);
|
||||
for (const [key, col] of colorMap) {
|
||||
const c = rgbHex(col);
|
||||
let st = '';
|
||||
for (const [off, a] of stops) st += `<stop offset="${(off * 100).toFixed(0)}%" stop-color="${c}" stop-opacity="${a.toFixed(3)}"/>`;
|
||||
bubGrads += `<radialGradient id="bub-${key}" cx="50%" cy="50%" r="50%">${st}</radialGradient>`;
|
||||
}
|
||||
}
|
||||
const vignHex = rgbHex(baseVign);
|
||||
return `<defs>
|
||||
@@ -349,10 +403,7 @@ function defs({ paperC, ink, coreInk, press = 0, baseVign, params, u, colorMap }
|
||||
</radialGradient>
|
||||
${bubGrads}
|
||||
<radialGradient id="shockcore" cx="50%" cy="50%" r="50%">
|
||||
<stop offset="0%" stop-color="${coreInk || ink}" stop-opacity="${(0.5 + press * 0.45).toFixed(2)}"/>
|
||||
<stop offset="28%" stop-color="${coreInk || ink}" stop-opacity="${(0.32 + press * 0.32).toFixed(2)}"/>
|
||||
<stop offset="62%" stop-color="${ink}" stop-opacity="0.28"/>
|
||||
<stop offset="100%" stop-color="${ink}" stop-opacity="0"/>
|
||||
${shockcoreStops({ coreInk, ink, press, hollow: params.diskHollow })}
|
||||
</radialGradient>
|
||||
<radialGradient id="shockstain" cx="50%" cy="50%" r="50%">
|
||||
<stop offset="0%" stop-color="${ink}" stop-opacity="0.9"/>
|
||||
|
||||
105
src/scene/chinagraph.js
Normal file
105
src/scene/chinagraph.js
Normal file
@@ -0,0 +1,105 @@
|
||||
/* ============================================================
|
||||
chinagraph.js — the fictional scanner's hand, done right.
|
||||
Letterforms built as STROKES (no fonts anywhere): each glyph
|
||||
is a set of polylines in a unit box, rendered with seeded
|
||||
wobble, per-character baseline drift and rotation, and a
|
||||
waxy double-pass — the soft fat grease pencil of a 1955–85
|
||||
scanning table. Seeded per plate: one plate's hand is
|
||||
consistent with itself, no two plates' hands identical.
|
||||
Charset is the scanner's working set: digits, Nº, θ, °, ·,
|
||||
colon, dash, question — measurement, not prose.
|
||||
============================================================ */
|
||||
import { gauss } from '../rng.js';
|
||||
|
||||
/* glyphs in a unit box, y down; each = array of polylines */
|
||||
const ellipse = (cx, cy, rx, ry, n = 11, a0 = -0.4) => {
|
||||
const pts = [];
|
||||
for (let i = 0; i <= n; i++) {
|
||||
const a = a0 + (i / n) * Math.PI * 2 * 1.03; // slight overshoot — doesn't close cleanly
|
||||
pts.push([cx + Math.cos(a) * rx, cy + Math.sin(a) * ry]);
|
||||
}
|
||||
return pts;
|
||||
};
|
||||
const GLYPHS = {
|
||||
'0': [ellipse(0.48, 0.5, 0.3, 0.44)],
|
||||
'1': [[[0.3, 0.26], [0.55, 0.08], [0.55, 0.95]]],
|
||||
'2': [[[0.16, 0.3], [0.2, 0.12], [0.5, 0.05], [0.76, 0.16], [0.77, 0.36], [0.15, 0.95], [0.85, 0.93]]],
|
||||
'3': [[[0.16, 0.13], [0.6, 0.06], [0.8, 0.25], [0.53, 0.46]], [[0.53, 0.46], [0.84, 0.68], [0.6, 0.93], [0.15, 0.87]]],
|
||||
'4': [[[0.64, 0.08], [0.13, 0.66], [0.87, 0.66]], [[0.64, 0.3], [0.64, 0.95]]],
|
||||
'5': [[[0.8, 0.08], [0.22, 0.09], [0.17, 0.46], [0.55, 0.4], [0.82, 0.6], [0.6, 0.92], [0.17, 0.87]]],
|
||||
'6': [[[0.7, 0.07], [0.32, 0.34], [0.17, 0.64], [0.3, 0.92], [0.64, 0.9], [0.75, 0.66], [0.52, 0.5], [0.22, 0.62]]],
|
||||
'7': [[[0.15, 0.1], [0.85, 0.09], [0.42, 0.95]]],
|
||||
'8': [[[0.5, 0.08], [0.25, 0.2], [0.31, 0.42], [0.5, 0.5], [0.72, 0.63], [0.66, 0.88], [0.4, 0.92], [0.27, 0.79], [0.35, 0.56], [0.5, 0.5], [0.71, 0.36], [0.67, 0.15], [0.5, 0.08]]],
|
||||
'9': [[[0.74, 0.38], [0.5, 0.5], [0.27, 0.44], [0.22, 0.2], [0.46, 0.07], [0.7, 0.13], [0.75, 0.4], [0.6, 0.7], [0.35, 0.94]]],
|
||||
'N': [[[0.1, 0.95], [0.1, 0.08]], [[0.1, 0.1], [0.8, 0.93]], [[0.8, 0.95], [0.8, 0.08]]],
|
||||
'º': [ellipse(0.42, 0.24, 0.17, 0.17, 8), [[0.14, 0.6], [0.72, 0.58]]],
|
||||
'°': [ellipse(0.45, 0.18, 0.14, 0.14, 8)],
|
||||
'θ': [ellipse(0.48, 0.5, 0.28, 0.44), [[0.22, 0.5], [0.75, 0.5]]],
|
||||
':': [[[0.48, 0.3], [0.51, 0.33]], [[0.48, 0.72], [0.51, 0.75]]],
|
||||
'·': [[[0.48, 0.5], [0.52, 0.53]]],
|
||||
'-': [[[0.15, 0.52], [0.8, 0.5]]],
|
||||
'+': [[[0.16, 0.5], [0.8, 0.49]], [[0.48, 0.18], [0.49, 0.82]]],
|
||||
'h': [[[0.16, 0.08], [0.16, 0.95]], [[0.16, 0.5], [0.4, 0.36], [0.66, 0.42], [0.7, 0.95]]],
|
||||
'?': [[[0.2, 0.24], [0.32, 0.08], [0.62, 0.06], [0.78, 0.22], [0.68, 0.44], [0.48, 0.55], [0.48, 0.7]], [[0.47, 0.88], [0.5, 0.91]]],
|
||||
' ': [],
|
||||
};
|
||||
|
||||
/* render a string as chinagraph strokes.
|
||||
o: { x, y px origin (baseline-left)
|
||||
h char height px
|
||||
rng seeded stream (one per plate → consistent hand)
|
||||
ink css colour
|
||||
width base stroke width px
|
||||
slant italic shear (default slight) }
|
||||
Returns an SVG fragment (a <g>). */
|
||||
export function chinagraphText(str, o) {
|
||||
const { x, y, h, rng, ink } = o;
|
||||
const w0 = o.width ?? h * 0.11;
|
||||
const slant = o.slant ?? 0.07;
|
||||
const cw = h * 0.72;
|
||||
let cx = x;
|
||||
let out = '';
|
||||
for (const ch of String(str)) {
|
||||
const glyph = GLYPHS[ch] ?? GLYPHS['·'];
|
||||
const rot = gauss(rng) * 0.035;
|
||||
const dy = gauss(rng) * 0.045 * h;
|
||||
const cos = Math.cos(rot), sin = Math.sin(rot);
|
||||
for (const stroke of glyph) {
|
||||
if (!stroke.length) continue;
|
||||
// two passes: the waxy body + a lighter ghost slightly offset (pencil drag)
|
||||
for (let pass = 0; pass < 2; pass++) {
|
||||
const off = pass * 0.05 * h, op = pass ? 0.3 : 0.82;
|
||||
const wv = w0 * (0.8 + rng() * 0.45);
|
||||
let d = '';
|
||||
stroke.forEach((pt, i) => {
|
||||
let gx = (pt[0] - slant * (1 - pt[1])) * cw * 1.18;
|
||||
let gy = (pt[1] - 0.9) * h;
|
||||
const rx = gx * cos - gy * sin, ry = gx * sin + gy * cos;
|
||||
const jx = gauss(rng) * 0.013 * h, jy = gauss(rng) * 0.013 * h;
|
||||
d += `${i ? 'L' : 'M'} ${(cx + rx + jx + off).toFixed(1)} ${(y + dy + ry + jy + off * 0.4).toFixed(1)} `;
|
||||
});
|
||||
out += `<path d="${d}" fill="none" stroke="${ink}" stroke-opacity="${op}" stroke-width="${wv.toFixed(2)}" stroke-linecap="round" stroke-linejoin="round"/>`;
|
||||
}
|
||||
}
|
||||
cx += cw * (ch === '·' || ch === ':' ? 0.55 : ch === ' ' ? 0.6 : 1);
|
||||
}
|
||||
return `<g>${out}</g>`;
|
||||
}
|
||||
|
||||
/* a wobbly grease arrow (shaft + open head), same hand */
|
||||
export function chinagraphArrow(x1, y1, x2, y2, o) {
|
||||
const { rng, ink } = o;
|
||||
const w = o.width ?? 3;
|
||||
const seg = 8;
|
||||
let d = '';
|
||||
for (let i = 0; i <= seg; i++) {
|
||||
const t = i / seg;
|
||||
const px = x1 + (x2 - x1) * t + gauss(rng) * 2.2;
|
||||
const py = y1 + (y2 - y1) * t + gauss(rng) * 2.2;
|
||||
d += `${i ? 'L' : 'M'} ${px.toFixed(1)} ${py.toFixed(1)} `;
|
||||
}
|
||||
const ang = Math.atan2(y2 - y1, x2 - x1), hl = Math.hypot(x2 - x1, y2 - y1) * 0.14;
|
||||
const head = (da) => `M ${x2.toFixed(1)} ${y2.toFixed(1)} L ${(x2 - Math.cos(ang + da) * hl + gauss(rng) * 1.5).toFixed(1)} ${(y2 - Math.sin(ang + da) * hl + gauss(rng) * 1.5).toFixed(1)} `;
|
||||
return `<g fill="none" stroke="${ink}" stroke-linecap="round" stroke-opacity="0.8" stroke-width="${w}">`
|
||||
+ `<path d="${d}"/><path d="${head(0.42)}${head(-0.42)}"/></g>`;
|
||||
}
|
||||
Reference in New Issue
Block a user