2026-05-20 16:53:23 -04:00
/ * = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = =
pdf . js — single - page vector PDF writer for the print shop .
Native PDF drawing ops ( no embedded raster ) . Uses :
• DeviceCMYK colour with a controllable rich black , so a
print shop gets proper separations rather than RGB ;
2026-05-21 05:59:44 -04:00
• ExtGState soft - mask alpha ( / c a , / C A ) f o r t r u e o p a c i t y ;
• Optional Content Groups ( / O C G ) s o t h e f i l e o p e n s w i t h
toggleable LAYERS in Acrobat / Illustrator / Preview ;
2026-05-20 16:53:23 -04:00
• a base - 14 Helvetica archival header ( no font embedding ) .
Geometry comes from the same scene model as every renderer .
2026-05-21 05:59:44 -04:00
( Note : the disk - soften Gaussian is raster / SVG only — PDF keeps
the disk crisp . )
2026-05-20 16:53:23 -04:00
=== === === === === === === === === === === === === === === === === === === === * /
import { makeRng } from '../rng.js' ;
Colour palettes, depth/exposure, disk & halo effects; stop tracking rasters
Generator
- Depth & exposure dynamics: per-track chamber depth (z) + event age drive
opacity, bubble size and defocus (depthFactors); depth/aging dials.
- Palette abstraction (src/render/palette.js) — one registry entry per "feel":
mono, charge, beta, kind, kindlife, kindrise, lifecycle, psychedelic,
cyanotype, magentarise. Per-track ink + per-bubble bubbleInk hooks.
- Global colour controls: saturation, hue shift; paper toning (cream/sepia/
selenium/cool/olive/neutral + brightness + gas-glow), bubble edge softness,
iridescent disk (spectral sunburst), chromatic halo. Ink blend chosen by
ground luminance so light-on-dark chemistries composite correctly.
- Tracks carry charge q; bubbles carry lifecycle position + local beta.
- All effects in raster + layered SVG + CMYK/OCG PDF; B&W remains the default.
Tooling & art
- tools/find-semicircle.mjs; render-svg/pdf --seed mode + k=v overrides.
- Curated vector SVG sets under output/ with browsable index.html.
Repo hygiene
- .gitignore: stop tracking generated rasters/PDFs (reproducible from seeds),
the reference image, and a stray db; keep curated SVGs + code + docs.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 15:55:03 -04:00
import { sampleBubbles , trackInkWeight , depthFactors } from '../scene/bubbles.js' ;
import { resolvePalette , paperTone , rgb01 , rgbKey , hslToRgb , mix } from './palette.js' ;
2026-05-20 16:53:23 -04:00
const MARGIN = 0.02 ;
2026-05-21 05:59:44 -04:00
const KIND _LAYER = {
primary : 'Tracks - primary' ,
cosmic : 'Tracks - cosmic & sweepers' ,
sweep : 'Tracks - cosmic & sweepers' ,
vdecay : 'Tracks - V-decays' ,
delta : 'Tracks - delta-rays' ,
} ;
const TRACK _ORDER = [ 'Tracks - primary' , 'Tracks - cosmic & sweepers' , 'Tracks - V-decays' , 'Tracks - delta-rays' ] ;
2026-05-20 16:53:23 -04:00
export function buildPDF ( scene , params , pageSize = 1728 ) {
const scale = ( pageSize / 2 ) * ( 1 - MARGIN ) ;
const cx = pageSize / 2 , cy = pageSize / 2 ;
const tx = ( x ) => ( cx + x * scale ) ;
const ty = ( y ) => ( cy - y * scale ) ; // PDF y-up
const u = pageSize / 1000 ;
const inv = params . invert ;
const paperCMYK = inv ? '0.04 0.06 0.16 0.00' : '0.30 0.30 0.32 1.00' ;
const inkCMYK = inv ? '0.30 0.30 0.32 0.98' : '0.03 0.05 0.14 0.00' ;
Colour palettes, depth/exposure, disk & halo effects; stop tracking rasters
Generator
- Depth & exposure dynamics: per-track chamber depth (z) + event age drive
opacity, bubble size and defocus (depthFactors); depth/aging dials.
- Palette abstraction (src/render/palette.js) — one registry entry per "feel":
mono, charge, beta, kind, kindlife, kindrise, lifecycle, psychedelic,
cyanotype, magentarise. Per-track ink + per-bubble bubbleInk hooks.
- Global colour controls: saturation, hue shift; paper toning (cream/sepia/
selenium/cool/olive/neutral + brightness + gas-glow), bubble edge softness,
iridescent disk (spectral sunburst), chromatic halo. Ink blend chosen by
ground luminance so light-on-dark chemistries composite correctly.
- Tracks carry charge q; bubbles carry lifecycle position + local beta.
- All effects in raster + layered SVG + CMYK/OCG PDF; B&W remains the default.
Tooling & art
- tools/find-semicircle.mjs; render-svg/pdf --seed mode + k=v overrides.
- Curated vector SVG sets under output/ with browsable index.html.
Repo hygiene
- .gitignore: stop tracking generated rasters/PDFs (reproducible from seeds),
the reference image, and a stray db; keep curated SVGs + code + docs.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 15:55:03 -04:00
// palette: mono keeps CMYK rich black (print-grade); colour feels use RGB for
// the particle trails. Features/paper stay CMYK.
const mono = ( params . palette || 'mono' ) === 'mono' ;
const pt = paperTone ( params , inv ) ;
const pal = resolvePalette ( params . palette , {
inv , sat : params . saturation ? ? 1 , hue : ( params . hueShift ? ? 0 ) * 360 , cycles : params . hueCycles ? ? 3 ,
baseInk : inv ? [ 28 , 24 , 20 ] : [ 233 , 228 , 214 ] ,
basePaper : { flat : pt . flat , glowIn : pt . glowIn , glowOut : pt . glowOut } , baseVign : [ 0 , 0 , 0 ] ,
} ) ;
// paper is CMYK at the default cream (print-grade rich); toned papers or palette
// chemistries (cyanotype) emit RGB
const toned = pal . hasPaper || ( params . paperTone && params . paperTone !== 'cream' ) || ( params . toneStrength ? ? 1 ) !== 1 || ( params . paperBright ? ? 1 ) !== 1 || ( params . glow ? ? 0.5 ) !== 0.5 ;
const paperOp = toned ? ` ${ rgb01 ( pal . paper ( ) . flat ) . join ( ' ' ) } rg ` : ` ${ paperCMYK } k ` ;
const bubbleFill = ( track , b ) => {
if ( mono ) return { op : ` ${ inkCMYK } k ` , key : 'k' } ;
const c = pal . bubbleInk ( track , b . life , b . beta ) ;
return { op : ` ${ rgb01 ( c ) . join ( ' ' ) } rg ` , key : rgbKey ( c ) } ;
} ;
const trackStroke = ( track ) => mono
? ` ${ inkCMYK } K \n `
: ` ${ rgb01 ( pal . ink ( track ) ) . join ( ' ' ) } RG \n ` ;
2026-05-20 16:53:23 -04:00
const gsSet = new Map ( ) ;
const gs = ( alpha ) => {
const k = Math . max ( 0 , Math . min ( 100 , Math . round ( alpha * 100 ) ) ) ;
if ( ! gsSet . has ( k ) ) gsSet . set ( k , ` GS ${ k } ` ) ;
return ` /GS ${ k } gs \n ` ;
} ;
2026-05-21 05:59:44 -04:00
// optional-content layer accumulation
const ocgNames = [ ] ;
let content = '' ;
const emit = ( name , ops ) => {
if ( ! ops || ! ops . trim ( ) ) return ;
const i = ocgNames . length ;
ocgNames . push ( name ) ;
content += ` /OC /OC ${ i } BDC \n ${ ops } EMC \n ` ;
} ;
2026-05-20 16:53:23 -04:00
2026-05-21 05:59:44 -04:00
/* ---------- Background ---------- */
Colour palettes, depth/exposure, disk & halo effects; stop tracking rasters
Generator
- Depth & exposure dynamics: per-track chamber depth (z) + event age drive
opacity, bubble size and defocus (depthFactors); depth/aging dials.
- Palette abstraction (src/render/palette.js) — one registry entry per "feel":
mono, charge, beta, kind, kindlife, kindrise, lifecycle, psychedelic,
cyanotype, magentarise. Per-track ink + per-bubble bubbleInk hooks.
- Global colour controls: saturation, hue shift; paper toning (cream/sepia/
selenium/cool/olive/neutral + brightness + gas-glow), bubble edge softness,
iridescent disk (spectral sunburst), chromatic halo. Ink blend chosen by
ground luminance so light-on-dark chemistries composite correctly.
- Tracks carry charge q; bubbles carry lifecycle position + local beta.
- All effects in raster + layered SVG + CMYK/OCG PDF; B&W remains the default.
Tooling & art
- tools/find-semicircle.mjs; render-svg/pdf --seed mode + k=v overrides.
- Curated vector SVG sets under output/ with browsable index.html.
Repo hygiene
- .gitignore: stop tracking generated rasters/PDFs (reproducible from seeds),
the reference image, and a stray db; keep curated SVGs + code + docs.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 15:55:03 -04:00
emit ( 'Background' , ` ${ paperOp } \n 0 0 ${ pageSize } ${ pageSize } re f \n ` ) ;
2026-05-20 16:53:23 -04:00
2026-05-21 05:59:44 -04:00
/* ---------- Chamber optics ---------- */
{
let o = '' ;
if ( params . showBoundary ) {
o += ` q \n ${ gs ( 0.4 ) } ${ inkCMYK } K \n ${ ( 2 * u ) . toFixed ( 2 ) } w \n ` ;
const bcx = cx , bcy = cy - pageSize * 0.35 , br = pageSize * 0.45 ;
const a1 = Math . PI * 0.15 , a2 = Math . PI - Math . PI * 0.15 , steps = 90 ;
for ( let i = 0 ; i <= steps ; i ++ ) {
const a = Math . PI + a1 + ( a2 - a1 ) * ( i / steps ) ;
const px = bcx + Math . cos ( a ) * br , py = bcy - Math . sin ( a ) * br ;
o += ` ${ px . toFixed ( 1 ) } ${ py . toFixed ( 1 ) } ${ i === 0 ? 'm' : 'l' } \n ` ;
}
o += ` S \n Q \n ` ;
}
if ( scene . instrument ) {
o += ` q \n ${ inkCMYK } K \n 1 J \n ` ;
for ( const l of scene . instrument . lines )
o += ` ${ gs ( l . opacity ) } ${ ( l . width * u ) . toFixed ( 2 ) } w \n ${ tx ( l . x1 ) . toFixed ( 1 ) } ${ ty ( l . y1 ) . toFixed ( 1 ) } m ${ tx ( l . x2 ) . toFixed ( 1 ) } ${ ty ( l . y2 ) . toFixed ( 1 ) } l S \n ` ;
for ( const a of scene . instrument . arcs ) {
o += ` ${ gs ( a . opacity ) } ${ ( a . width * u ) . toFixed ( 2 ) } w \n ${ tx ( a . pts [ 0 ] . x ) . toFixed ( 1 ) } ${ ty ( a . pts [ 0 ] . y ) . toFixed ( 1 ) } m \n ` ;
for ( let i = 1 ; i < a . pts . length ; i ++ ) o += ` ${ tx ( a . pts [ i ] . x ) . toFixed ( 1 ) } ${ ty ( a . pts [ i ] . y ) . toFixed ( 1 ) } l \n ` ;
o += ` S \n ` ;
}
o += ` Q \n ` ;
}
emit ( 'Chamber optics' , o ) ;
2026-05-20 16:53:23 -04:00
}
2026-05-21 05:59:44 -04:00
/* ---------- Shock disk ---------- */
2026-05-20 16:53:23 -04:00
if ( scene . shock ) {
2026-05-21 05:59:44 -04:00
const sh = scene . shock , px = tx ( sh . x ) , py = ty ( sh . y ) ;
let o = '' ;
2026-05-20 17:10:32 -04:00
if ( params . diskBubbles !== false && sh . bubbleStrokes ) {
const dRng = makeRng ( params . seed , 'diskbubbles' ) ;
const dbk = new Map ( ) ;
for ( const stroke of sh . bubbleStrokes ) {
const key = Math . round ( Math . min ( 1 , 0.45 + stroke . weight * 0.5 ) * 20 ) / 20 ;
if ( ! dbk . has ( key ) ) dbk . set ( key , [ ] ) ;
dbk . get ( key ) . push ( ... sampleBubbles ( stroke , params , dRng ) ) ;
}
2026-05-21 05:59:44 -04:00
o += ` q \n ${ inkCMYK } k \n ` ;
2026-05-20 17:10:32 -04:00
for ( const [ alpha , bubs ] of dbk ) {
2026-05-21 05:59:44 -04:00
o += gs ( alpha ) ;
for ( const b of bubs ) o += circlePath ( tx ( b . x ) , ty ( b . y ) , Math . max ( b . r * scale , 0.5 ) ) + 'f\n' ;
2026-05-20 17:10:32 -04:00
}
2026-05-21 05:59:44 -04:00
o += ` Q \n ` ;
2026-05-20 17:10:32 -04:00
} else {
Colour palettes, depth/exposure, disk & halo effects; stop tracking rasters
Generator
- Depth & exposure dynamics: per-track chamber depth (z) + event age drive
opacity, bubble size and defocus (depthFactors); depth/aging dials.
- Palette abstraction (src/render/palette.js) — one registry entry per "feel":
mono, charge, beta, kind, kindlife, kindrise, lifecycle, psychedelic,
cyanotype, magentarise. Per-track ink + per-bubble bubbleInk hooks.
- Global colour controls: saturation, hue shift; paper toning (cream/sepia/
selenium/cool/olive/neutral + brightness + gas-glow), bubble edge softness,
iridescent disk (spectral sunburst), chromatic halo. Ink blend chosen by
ground luminance so light-on-dark chemistries composite correctly.
- Tracks carry charge q; bubbles carry lifecycle position + local beta.
- All effects in raster + layered SVG + CMYK/OCG PDF; B&W remains the default.
Tooling & art
- tools/find-semicircle.mjs; render-svg/pdf --seed mode + k=v overrides.
- Curated vector SVG sets under output/ with browsable index.html.
Repo hygiene
- .gitignore: stop tracking generated rasters/PDFs (reproducible from seeds),
the reference image, and a stray db; keep curated SVGs + code + docs.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 15:55:03 -04:00
// optionally iridescent — per-element RGB stroke across the sunburst
const spec = params . diskSpectrum || 0 ;
const featRGB = pal . feature ( ) ;
const TWO _PI = Math . PI * 2 ;
const sStroke = ( frac ) => spec <= 0 ? ` ${ inkCMYK } K \n `
: ` ${ rgb01 ( mix ( featRGB , hslToRgb ( ( ( ( frac + ( params . hueShift || 0 ) ) % 1 ) + 1 ) % 1 , 0.85 * ( params . saturation ? ? 1 ) , 0.52 ) , spec ) ) . join ( ' ' ) } RG \n ` ;
o += ` q \n 1 J \n ` ;
2026-05-20 17:10:32 -04:00
for ( const st of sh . striations ) {
const ix = px + Math . cos ( st . a ) * st . inner * scale , iy = py - Math . sin ( st . a ) * st . inner * scale ;
const ox = px + Math . cos ( st . a ) * st . outer * scale , oy = py - Math . sin ( st . a ) * st . outer * scale ;
Colour palettes, depth/exposure, disk & halo effects; stop tracking rasters
Generator
- Depth & exposure dynamics: per-track chamber depth (z) + event age drive
opacity, bubble size and defocus (depthFactors); depth/aging dials.
- Palette abstraction (src/render/palette.js) — one registry entry per "feel":
mono, charge, beta, kind, kindlife, kindrise, lifecycle, psychedelic,
cyanotype, magentarise. Per-track ink + per-bubble bubbleInk hooks.
- Global colour controls: saturation, hue shift; paper toning (cream/sepia/
selenium/cool/olive/neutral + brightness + gas-glow), bubble edge softness,
iridescent disk (spectral sunburst), chromatic halo. Ink blend chosen by
ground luminance so light-on-dark chemistries composite correctly.
- Tracks carry charge q; bubbles carry lifecycle position + local beta.
- All effects in raster + layered SVG + CMYK/OCG PDF; B&W remains the default.
Tooling & art
- tools/find-semicircle.mjs; render-svg/pdf --seed mode + k=v overrides.
- Curated vector SVG sets under output/ with browsable index.html.
Repo hygiene
- .gitignore: stop tracking generated rasters/PDFs (reproducible from seeds),
the reference image, and a stray db; keep curated SVGs + code + docs.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 15:55:03 -04:00
o += ` ${ sStroke ( st . a / TWO _PI ) } ${ gs ( st . opacity ) } ${ ( st . width * u ) . toFixed ( 2 ) } w \n ${ ix . toFixed ( 1 ) } ${ iy . toFixed ( 1 ) } m ${ ox . toFixed ( 1 ) } ${ oy . toFixed ( 1 ) } l S \n ` ;
2026-05-20 17:10:32 -04:00
}
Colour palettes, depth/exposure, disk & halo effects; stop tracking rasters
Generator
- Depth & exposure dynamics: per-track chamber depth (z) + event age drive
opacity, bubble size and defocus (depthFactors); depth/aging dials.
- Palette abstraction (src/render/palette.js) — one registry entry per "feel":
mono, charge, beta, kind, kindlife, kindrise, lifecycle, psychedelic,
cyanotype, magentarise. Per-track ink + per-bubble bubbleInk hooks.
- Global colour controls: saturation, hue shift; paper toning (cream/sepia/
selenium/cool/olive/neutral + brightness + gas-glow), bubble edge softness,
iridescent disk (spectral sunburst), chromatic halo. Ink blend chosen by
ground luminance so light-on-dark chemistries composite correctly.
- Tracks carry charge q; bubbles carry lifecycle position + local beta.
- All effects in raster + layered SVG + CMYK/OCG PDF; B&W remains the default.
Tooling & art
- tools/find-semicircle.mjs; render-svg/pdf --seed mode + k=v overrides.
- Curated vector SVG sets under output/ with browsable index.html.
Repo hygiene
- .gitignore: stop tracking generated rasters/PDFs (reproducible from seeds),
the reference image, and a stray db; keep curated SVGs + code + docs.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 15:55:03 -04:00
for ( const k of sh . core ) {
const ca = Math . atan2 ( k . y1 - sh . y , k . x1 - sh . x ) ;
o += ` ${ sStroke ( ca / TWO _PI ) } ${ gs ( k . opacity ) } ${ ( k . width * u ) . toFixed ( 2 ) } w \n ${ tx ( k . x1 ) . toFixed ( 1 ) } ${ ty ( k . y1 ) . toFixed ( 1 ) } m ${ tx ( k . x2 ) . toFixed ( 1 ) } ${ ty ( k . y2 ) . toFixed ( 1 ) } l S \n ` ;
}
for ( const ring of sh . rings ) o += ` ${ sStroke ( ring . rr / sh . r ) } ${ gs ( ring . opacity ) } ${ ( ring . width * u ) . toFixed ( 2 ) } w \n ` + strokeCircle ( px , py , ring . rr * scale ) ;
for ( const seg of ( sh . rimSegs || [ ] ) ) o += ` ${ sStroke ( seg . a0 / TWO _PI ) } ${ gs ( seg . opacity ) } ${ ( seg . width * u ) . toFixed ( 2 ) } w \n ` + arcStroke ( px , py , sh . r * scale , - seg . a0 , - seg . a1 ) ;
2026-05-21 05:59:44 -04:00
o += ` Q \n ` ;
2026-05-20 16:53:23 -04:00
}
2026-05-21 05:59:44 -04:00
emit ( 'Shock disk' , o ) ;
2026-05-20 16:53:23 -04:00
}
2026-05-21 05:59:44 -04:00
/* ---------- Tracks (split by kind; bubble rng order preserved) ---------- */
Colour palettes, depth/exposure, disk & halo effects; stop tracking rasters
Generator
- Depth & exposure dynamics: per-track chamber depth (z) + event age drive
opacity, bubble size and defocus (depthFactors); depth/aging dials.
- Palette abstraction (src/render/palette.js) — one registry entry per "feel":
mono, charge, beta, kind, kindlife, kindrise, lifecycle, psychedelic,
cyanotype, magentarise. Per-track ink + per-bubble bubbleInk hooks.
- Global colour controls: saturation, hue shift; paper toning (cream/sepia/
selenium/cool/olive/neutral + brightness + gas-glow), bubble edge softness,
iridescent disk (spectral sunburst), chromatic halo. Ink blend chosen by
ground luminance so light-on-dark chemistries composite correctly.
- Tracks carry charge q; bubbles carry lifecycle position + local beta.
- All effects in raster + layered SVG + CMYK/OCG PDF; B&W remains the default.
Tooling & art
- tools/find-semicircle.mjs; render-svg/pdf --seed mode + k=v overrides.
- Curated vector SVG sets under output/ with browsable index.html.
Repo hygiene
- .gitignore: stop tracking generated rasters/PDFs (reproducible from seeds),
the reference image, and a stray db; keep curated SVGs + code + docs.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 15:55:03 -04:00
const under = new Map ( ) ; // layer -> stroke ops (per-track colour set inline)
const buck = new Map ( ) ; // layer -> Map(colourKey|alpha -> {op, alpha, arr})
2026-05-20 16:53:23 -04:00
const bRng = makeRng ( params . seed , 'bubbles' ) ;
for ( const t of scene . tracks ) {
2026-05-21 05:59:44 -04:00
const name = KIND _LAYER [ t . kind ] || 'Tracks - primary' ;
if ( ! under . has ( name ) ) { under . set ( name , '' ) ; buck . set ( name , new Map ( ) ) ; }
Colour palettes, depth/exposure, disk & halo effects; stop tracking rasters
Generator
- Depth & exposure dynamics: per-track chamber depth (z) + event age drive
opacity, bubble size and defocus (depthFactors); depth/aging dials.
- Palette abstraction (src/render/palette.js) — one registry entry per "feel":
mono, charge, beta, kind, kindlife, kindrise, lifecycle, psychedelic,
cyanotype, magentarise. Per-track ink + per-bubble bubbleInk hooks.
- Global colour controls: saturation, hue shift; paper toning (cream/sepia/
selenium/cool/olive/neutral + brightness + gas-glow), bubble edge softness,
iridescent disk (spectral sunburst), chromatic halo. Ink blend chosen by
ground luminance so light-on-dark chemistries composite correctly.
- Tracks carry charge q; bubbles carry lifecycle position + local beta.
- All effects in raster + layered SVG + CMYK/OCG PDF; B&W remains the default.
Tooling & art
- tools/find-semicircle.mjs; render-svg/pdf --seed mode + k=v overrides.
- Curated vector SVG sets under output/ with browsable index.html.
Repo hygiene
- .gitignore: stop tracking generated rasters/PDFs (reproducible from seeds),
the reference image, and a stray db; keep curated SVGs + code + docs.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 15:55:03 -04:00
const df = depthFactors ( t , params ) ;
2026-05-21 05:59:44 -04:00
if ( t . pts . length >= 2 ) {
const lw = Math . min ( 2.6 , 0.25 + Math . sqrt ( trackInkWeight ( t ) ) * 0.12 ) * u * params . size * t . weight ;
if ( lw >= 0.2 * u ) {
Colour palettes, depth/exposure, disk & halo effects; stop tracking rasters
Generator
- Depth & exposure dynamics: per-track chamber depth (z) + event age drive
opacity, bubble size and defocus (depthFactors); depth/aging dials.
- Palette abstraction (src/render/palette.js) — one registry entry per "feel":
mono, charge, beta, kind, kindlife, kindrise, lifecycle, psychedelic,
cyanotype, magentarise. Per-track ink + per-bubble bubbleInk hooks.
- Global colour controls: saturation, hue shift; paper toning (cream/sepia/
selenium/cool/olive/neutral + brightness + gas-glow), bubble edge softness,
iridescent disk (spectral sunburst), chromatic halo. Ink blend chosen by
ground luminance so light-on-dark chemistries composite correctly.
- Tracks carry charge q; bubbles carry lifecycle position + local beta.
- All effects in raster + layered SVG + CMYK/OCG PDF; B&W remains the default.
Tooling & art
- tools/find-semicircle.mjs; render-svg/pdf --seed mode + k=v overrides.
- Curated vector SVG sets under output/ with browsable index.html.
Repo hygiene
- .gitignore: stop tracking generated rasters/PDFs (reproducible from seeds),
the reference image, and a stray db; keep curated SVGs + code + docs.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 15:55:03 -04:00
let su = ` ${ trackStroke ( t ) } ${ gs ( 0.14 * df . tone ) } ${ lw . toFixed ( 2 ) } w \n ${ tx ( t . pts [ 0 ] . x ) . toFixed ( 2 ) } ${ ty ( t . pts [ 0 ] . y ) . toFixed ( 2 ) } m \n ` ;
2026-05-21 05:59:44 -04:00
for ( let i = 1 ; i < t . pts . length ; i ++ ) su += ` ${ tx ( t . pts [ i ] . x ) . toFixed ( 2 ) } ${ ty ( t . pts [ i ] . y ) . toFixed ( 2 ) } l \n ` ;
under . set ( name , under . get ( name ) + su + ` S \n ` ) ;
}
}
Colour palettes, depth/exposure, disk & halo effects; stop tracking rasters
Generator
- Depth & exposure dynamics: per-track chamber depth (z) + event age drive
opacity, bubble size and defocus (depthFactors); depth/aging dials.
- Palette abstraction (src/render/palette.js) — one registry entry per "feel":
mono, charge, beta, kind, kindlife, kindrise, lifecycle, psychedelic,
cyanotype, magentarise. Per-track ink + per-bubble bubbleInk hooks.
- Global colour controls: saturation, hue shift; paper toning (cream/sepia/
selenium/cool/olive/neutral + brightness + gas-glow), bubble edge softness,
iridescent disk (spectral sunburst), chromatic halo. Ink blend chosen by
ground luminance so light-on-dark chemistries composite correctly.
- Tracks carry charge q; bubbles carry lifecycle position + local beta.
- All effects in raster + layered SVG + CMYK/OCG PDF; B&W remains the default.
Tooling & art
- tools/find-semicircle.mjs; render-svg/pdf --seed mode + k=v overrides.
- Curated vector SVG sets under output/ with browsable index.html.
Repo hygiene
- .gitignore: stop tracking generated rasters/PDFs (reproducible from seeds),
the reference image, and a stray db; keep curated SVGs + code + docs.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 15:55:03 -04:00
const alpha = Math . round ( Math . min ( 1 , 0.36 + df . tone * 0.58 ) * 20 ) / 20 ;
2026-05-21 05:59:44 -04:00
const m = buck . get ( name ) ;
Colour palettes, depth/exposure, disk & halo effects; stop tracking rasters
Generator
- Depth & exposure dynamics: per-track chamber depth (z) + event age drive
opacity, bubble size and defocus (depthFactors); depth/aging dials.
- Palette abstraction (src/render/palette.js) — one registry entry per "feel":
mono, charge, beta, kind, kindlife, kindrise, lifecycle, psychedelic,
cyanotype, magentarise. Per-track ink + per-bubble bubbleInk hooks.
- Global colour controls: saturation, hue shift; paper toning (cream/sepia/
selenium/cool/olive/neutral + brightness + gas-glow), bubble edge softness,
iridescent disk (spectral sunburst), chromatic halo. Ink blend chosen by
ground luminance so light-on-dark chemistries composite correctly.
- Tracks carry charge q; bubbles carry lifecycle position + local beta.
- All effects in raster + layered SVG + CMYK/OCG PDF; B&W remains the default.
Tooling & art
- tools/find-semicircle.mjs; render-svg/pdf --seed mode + k=v overrides.
- Curated vector SVG sets under output/ with browsable index.html.
Repo hygiene
- .gitignore: stop tracking generated rasters/PDFs (reproducible from seeds),
the reference image, and a stray db; keep curated SVGs + code + docs.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 15:55:03 -04:00
for ( const b of sampleBubbles ( { ... t , sizeScale : df . sizeScale } , params , bRng ) ) {
const fill = bubbleFill ( t , b ) ;
const bkey = fill . key + '|' + alpha ;
if ( ! m . has ( bkey ) ) m . set ( bkey , { op : fill . op , alpha , arr : [ ] } ) ;
m . get ( bkey ) . arr . push ( circlePath ( tx ( b . x ) , ty ( b . y ) , Math . max ( b . r * scale , 0.5 ) ) + 'f\n' ) ;
}
2026-05-20 16:53:23 -04:00
}
2026-05-21 05:59:44 -04:00
for ( const name of TRACK _ORDER ) {
if ( ! under . has ( name ) ) continue ;
let o = '' ;
const us = under . get ( name ) ;
Colour palettes, depth/exposure, disk & halo effects; stop tracking rasters
Generator
- Depth & exposure dynamics: per-track chamber depth (z) + event age drive
opacity, bubble size and defocus (depthFactors); depth/aging dials.
- Palette abstraction (src/render/palette.js) — one registry entry per "feel":
mono, charge, beta, kind, kindlife, kindrise, lifecycle, psychedelic,
cyanotype, magentarise. Per-track ink + per-bubble bubbleInk hooks.
- Global colour controls: saturation, hue shift; paper toning (cream/sepia/
selenium/cool/olive/neutral + brightness + gas-glow), bubble edge softness,
iridescent disk (spectral sunburst), chromatic halo. Ink blend chosen by
ground luminance so light-on-dark chemistries composite correctly.
- Tracks carry charge q; bubbles carry lifecycle position + local beta.
- All effects in raster + layered SVG + CMYK/OCG PDF; B&W remains the default.
Tooling & art
- tools/find-semicircle.mjs; render-svg/pdf --seed mode + k=v overrides.
- Curated vector SVG sets under output/ with browsable index.html.
Repo hygiene
- .gitignore: stop tracking generated rasters/PDFs (reproducible from seeds),
the reference image, and a stray db; keep curated SVGs + code + docs.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 15:55:03 -04:00
if ( us ) o += ` q \n 1 J \n 1 j \n ${ us } Q \n ` ;
2026-05-21 05:59:44 -04:00
let bub = '' ;
Colour palettes, depth/exposure, disk & halo effects; stop tracking rasters
Generator
- Depth & exposure dynamics: per-track chamber depth (z) + event age drive
opacity, bubble size and defocus (depthFactors); depth/aging dials.
- Palette abstraction (src/render/palette.js) — one registry entry per "feel":
mono, charge, beta, kind, kindlife, kindrise, lifecycle, psychedelic,
cyanotype, magentarise. Per-track ink + per-bubble bubbleInk hooks.
- Global colour controls: saturation, hue shift; paper toning (cream/sepia/
selenium/cool/olive/neutral + brightness + gas-glow), bubble edge softness,
iridescent disk (spectral sunburst), chromatic halo. Ink blend chosen by
ground luminance so light-on-dark chemistries composite correctly.
- Tracks carry charge q; bubbles carry lifecycle position + local beta.
- All effects in raster + layered SVG + CMYK/OCG PDF; B&W remains the default.
Tooling & art
- tools/find-semicircle.mjs; render-svg/pdf --seed mode + k=v overrides.
- Curated vector SVG sets under output/ with browsable index.html.
Repo hygiene
- .gitignore: stop tracking generated rasters/PDFs (reproducible from seeds),
the reference image, and a stray db; keep curated SVGs + code + docs.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 15:55:03 -04:00
for ( const { op , alpha , arr } of buck . get ( name ) . values ( ) ) if ( arr . length ) bub += ` ${ op } \n ${ gs ( alpha ) } ${ arr . join ( '' ) } ` ;
if ( bub ) o += ` q \n ${ bub } Q \n ` ;
2026-05-21 05:59:44 -04:00
emit ( name , o ) ;
2026-05-20 16:53:23 -04:00
}
2026-05-21 05:59:44 -04:00
/* ---------- Plate damage ---------- */
2026-05-20 16:53:23 -04:00
const A = scene . artifacts ;
if ( A ) {
2026-05-21 05:59:44 -04:00
let o = ` q \n ${ inkCMYK } K \n 1 J \n ` ;
for ( const sc of A . scratches ) o += ` ${ gs ( sc . opacity ) } ${ ( sc . width * u ) . toFixed ( 2 ) } w \n ${ tx ( sc . x1 ) . toFixed ( 1 ) } ${ ty ( sc . y1 ) . toFixed ( 1 ) } m ${ tx ( sc . x2 ) . toFixed ( 1 ) } ${ ty ( sc . y2 ) . toFixed ( 1 ) } l S \n ` ;
2026-05-20 16:53:23 -04:00
for ( const hair of A . hairs ) {
2026-05-21 05:59:44 -04:00
o += ` ${ gs ( hair . opacity ) } ${ ( hair . width * u ) . toFixed ( 2 ) } w \n ${ tx ( hair . pts [ 0 ] . x ) . toFixed ( 1 ) } ${ ty ( hair . pts [ 0 ] . y ) . toFixed ( 1 ) } m \n ` ;
for ( let i = 1 ; i < hair . pts . length ; i ++ ) o += ` ${ tx ( hair . pts [ i ] . x ) . toFixed ( 1 ) } ${ ty ( hair . pts [ i ] . y ) . toFixed ( 1 ) } l \n ` ;
o += ` S \n ` ;
2026-05-20 16:53:23 -04:00
}
2026-05-21 05:59:44 -04:00
for ( const ring of A . rings ) o += ` ${ gs ( ring . opacity ) } ${ ( ring . width * u ) . toFixed ( 2 ) } w \n ` + strokeCircle ( tx ( ring . x ) , ty ( ring . y ) , ring . r * scale ) ;
o += ` Q \n q \n ${ inkCMYK } k \n ` ;
for ( const sp of A . specks ) o += gs ( sp . opacity ) + circlePath ( tx ( sp . x ) , ty ( sp . y ) , Math . max ( sp . r * scale , 0.5 ) ) + 'f\n' ;
o += ` Q \n ` ;
emit ( 'Plate damage' , o ) ;
2026-05-20 16:53:23 -04:00
}
2026-05-21 05:59:44 -04:00
/* ---------- Fiducials ---------- */
2026-05-20 16:53:23 -04:00
if ( params . showFiducials ) {
2026-05-21 05:59:44 -04:00
let o = ` q \n ${ gs ( 0.55 ) } ${ inkCMYK } K \n ${ ( 1.2 * u ) . toFixed ( 2 ) } w \n ` ;
2026-05-20 16:53:23 -04:00
const fids = [ [ - 0.85 , - 0.85 ] , [ 0.85 , - 0.85 ] , [ - 0.85 , 0.85 ] , [ 0.85 , 0.85 ] , [ 0 , - 0.85 ] , [ - 0.85 , 0 ] , [ 0.85 , 0 ] ] ;
const s = 8 * u ;
for ( const [ fx , fy ] of fids ) {
const px = tx ( fx ) , py = ty ( fy ) ;
2026-05-21 05:59:44 -04:00
o += ` ${ px - s } ${ py } m ${ px + s } ${ py } l S \n ${ px } ${ py - s } m ${ px } ${ py + s } l S \n ` ;
2026-05-20 16:53:23 -04:00
}
2026-05-21 05:59:44 -04:00
o += ` Q \n ` ;
emit ( 'Fiducials' , o ) ;
2026-05-20 16:53:23 -04:00
}
2026-05-21 05:59:44 -04:00
/* ---------- Archival header ---------- */
2026-05-20 16:53:23 -04:00
if ( params . showHeader ) {
const pad = 26 * u ;
2026-05-21 05:59:44 -04:00
let o = ` q \n ${ gs ( 0.6 ) } ${ inkCMYK } k \n BT \n /F0 ${ ( 11 * u ) . toFixed ( 1 ) } Tf \n 1 0 0 1 ${ pad . toFixed ( 1 ) } ${ ( pageSize - pad - 11 * u ) . toFixed ( 1 ) } Tm ( ${ pdfStr ( scene . lab . toUpperCase ( ) ) } ) Tj \n ET \n ` ;
o += ` BT \n /F0 ${ ( 9 * u ) . toFixed ( 1 ) } Tf \n 1 0 0 1 ${ pad . toFixed ( 1 ) } ${ ( pageSize - pad - 27 * u ) . toFixed ( 1 ) } Tm (SEED ${ pdfStr ( params . seed ) } ) Tj \n ET \n ` ;
2026-05-20 16:53:23 -04:00
const fs = 10 * u ;
const rt = ( txt , yoff ) => {
const wEst = txt . length * fs * 0.5 ;
2026-05-21 05:59:44 -04:00
o += ` BT \n /F0 ${ fs . toFixed ( 1 ) } Tf \n 1 0 0 1 ${ ( pageSize - pad - wEst ) . toFixed ( 1 ) } ${ yoff . toFixed ( 1 ) } Tm ( ${ pdfStr ( txt ) } ) Tj \n ET \n ` ;
2026-05-20 16:53:23 -04:00
} ;
rt ( ` PLATE ${ scene . plate } ` , pad + 13 * u ) ;
rt ( ` EXPOSED ${ scene . exposure } ` , pad ) ;
2026-05-21 05:59:44 -04:00
o += ` Q \n ` ;
emit ( 'Archival header' , o ) ;
2026-05-20 16:53:23 -04:00
}
let extg = '' ;
for ( const [ k , name ] of gsSet ) extg += ` / ${ name } << /ca ${ ( k / 100 ) . toFixed ( 2 ) } /CA ${ ( k / 100 ) . toFixed ( 2 ) } >> ` ;
2026-05-21 05:59:44 -04:00
return assemblePDF ( content , pageSize , extg , ocgNames ) ;
}
/* a stroked arc approximated by line segments (for rim segments) */
function arcStroke ( px , py , r , a0 , a1 ) {
const steps = Math . max ( 2 , Math . round ( Math . abs ( a1 - a0 ) / 0.12 ) ) ;
let s = '' ;
for ( let i = 0 ; i <= steps ; i ++ ) {
const a = a0 + ( a1 - a0 ) * ( i / steps ) ;
s += ` ${ ( px + Math . cos ( a ) * r ) . toFixed ( 1 ) } ${ ( py + Math . sin ( a ) * r ) . toFixed ( 1 ) } ${ i === 0 ? 'm' : 'l' } \n ` ;
}
return s + 'S\n' ;
2026-05-20 16:53:23 -04:00
}
function circlePath ( px , py , r ) {
const k = 0.5522847498 * r ;
return ` ${ ( px - r ) . toFixed ( 2 ) } ${ py . toFixed ( 2 ) } m \n ` +
` ${ ( px - r ) . toFixed ( 2 ) } ${ ( py + k ) . toFixed ( 2 ) } ${ ( px - k ) . toFixed ( 2 ) } ${ ( py + r ) . toFixed ( 2 ) } ${ px . toFixed ( 2 ) } ${ ( py + r ) . toFixed ( 2 ) } c \n ` +
` ${ ( px + k ) . toFixed ( 2 ) } ${ ( py + r ) . toFixed ( 2 ) } ${ ( px + r ) . toFixed ( 2 ) } ${ ( py + k ) . toFixed ( 2 ) } ${ ( px + r ) . toFixed ( 2 ) } ${ py . toFixed ( 2 ) } c \n ` +
` ${ ( px + r ) . toFixed ( 2 ) } ${ ( py - k ) . toFixed ( 2 ) } ${ ( px + k ) . toFixed ( 2 ) } ${ ( py - r ) . toFixed ( 2 ) } ${ px . toFixed ( 2 ) } ${ ( py - r ) . toFixed ( 2 ) } c \n ` +
` ${ ( px - k ) . toFixed ( 2 ) } ${ ( py - r ) . toFixed ( 2 ) } ${ ( px - r ) . toFixed ( 2 ) } ${ ( py - k ) . toFixed ( 2 ) } ${ ( px - r ) . toFixed ( 2 ) } ${ py . toFixed ( 2 ) } c \n ` ;
}
function strokeCircle ( px , py , r ) {
const k = 0.5522847498 * r ;
return ` ${ ( px - r ) . toFixed ( 2 ) } ${ py . toFixed ( 2 ) } m ` +
` ${ ( px - r ) . toFixed ( 2 ) } ${ ( py + k ) . toFixed ( 2 ) } ${ ( px - k ) . toFixed ( 2 ) } ${ ( py + r ) . toFixed ( 2 ) } ${ px . toFixed ( 2 ) } ${ ( py + r ) . toFixed ( 2 ) } c ` +
` ${ ( px + k ) . toFixed ( 2 ) } ${ ( py + r ) . toFixed ( 2 ) } ${ ( px + r ) . toFixed ( 2 ) } ${ ( py + k ) . toFixed ( 2 ) } ${ ( px + r ) . toFixed ( 2 ) } ${ py . toFixed ( 2 ) } c ` +
` ${ ( px + r ) . toFixed ( 2 ) } ${ ( py - k ) . toFixed ( 2 ) } ${ ( px + k ) . toFixed ( 2 ) } ${ ( py - r ) . toFixed ( 2 ) } ${ px . toFixed ( 2 ) } ${ ( py - r ) . toFixed ( 2 ) } c ` +
` ${ ( px - k ) . toFixed ( 2 ) } ${ ( py - r ) . toFixed ( 2 ) } ${ ( px - r ) . toFixed ( 2 ) } ${ ( py - k ) . toFixed ( 2 ) } ${ ( px - r ) . toFixed ( 2 ) } ${ py . toFixed ( 2 ) } c S \n ` ;
}
function pdfStr ( s ) { return String ( s ) . replace ( /([()\\])/g , '\\$1' ) ; }
2026-05-21 05:59:44 -04:00
function assemblePDF ( content , pageSize , extg , ocgNames ) {
2026-05-20 16:53:23 -04:00
const enc = new TextEncoder ( ) ;
const contentBytes = enc . encode ( content ) ;
2026-05-21 05:59:44 -04:00
let body = ` %PDF-1.5 \n % \x C3 \x A0 \x C3 \x A1 \x C3 \x A2 \x C3 \x A3 \n ` ;
2026-05-20 16:53:23 -04:00
const offsets = [ ] ;
const addObj = ( s ) => { offsets . push ( body . length ) ; body += s ; } ;
2026-05-21 05:59:44 -04:00
const ocgRefs = ocgNames . map ( ( _ , i ) => ` ${ 5 + i } 0 R ` ) . join ( ' ' ) ;
const props = ocgNames . map ( ( _ , i ) => ` /OC ${ i } ${ 5 + i } 0 R ` ) . join ( ' ' ) ;
const resources = ` << /ExtGState << ${ extg } >> /Font << /F0 << /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> >> /Properties << ${ props } >> >> ` ;
addObj ( ` 1 0 obj \n << /Type /Catalog /Pages 2 0 R /OCProperties << /OCGs [ ${ ocgRefs } ] /D << /Order [ ${ ocgRefs } ] >> >> >> \n endobj \n ` ) ;
2026-05-20 16:53:23 -04:00
addObj ( ` 2 0 obj \n << /Type /Pages /Kids [3 0 R] /Count 1 >> \n endobj \n ` ) ;
addObj ( ` 3 0 obj \n << /Type /Page /Parent 2 0 R /MediaBox [0 0 ${ pageSize } ${ pageSize } ] /Contents 4 0 R /Resources ${ resources } >> \n endobj \n ` ) ;
addObj ( ` 4 0 obj \n << /Length ${ contentBytes . length } >> \n stream \n ${ content } \n endstream \n endobj \n ` ) ;
2026-05-21 05:59:44 -04:00
ocgNames . forEach ( ( name ) => addObj ( ` ${ offsets . length + 1 } 0 obj \n << /Type /OCG /Name ( ${ pdfStr ( name ) } ) >> \n endobj \n ` ) ) ;
const size = 4 + ocgNames . length + 1 ;
2026-05-20 16:53:23 -04:00
const xref = body . length ;
2026-05-21 05:59:44 -04:00
body += ` xref \n 0 ${ size } \n 0000000000 65535 f \n ` ;
2026-05-20 16:53:23 -04:00
for ( const o of offsets ) body += String ( o ) . padStart ( 10 , '0' ) + ' 00000 n \n' ;
2026-05-21 05:59:44 -04:00
body += ` trailer \n << /Size ${ size } /Root 1 0 R >> \n startxref \n ${ xref } \n %%EOF \n ` ;
2026-05-20 16:53:23 -04:00
return new Uint8Array ( enc . encode ( body ) ) ;
}