Fable Review

This commit is contained in:
2026-07-04 09:47:50 -04:00
parent e7fb2e9a68
commit 4314087557
15 changed files with 1302 additions and 3 deletions

58
tools/inkwash-export.mjs Normal file
View File

@@ -0,0 +1,58 @@
/* ============================================================
inkwash-export.mjs — bubble-chamber geometry → inkwash strokes.
Exploratory bridge: take a generated scene's tracks and emit a
strokes.js that the forked inkwash play.html replays through its
own brush engine + fluid sim → a watercolor/ink rendering of a
particle event. Each track becomes one pen stroke; β drives the
pen pressure (slow/dense end of range = heavier ink).
NOTE (2026-06-24): the wet look now lives NATIVELY in the project as
`src/render/watercolor.js` (renderer=watercolor), on the same scene model —
no external fork needed. This bridge is kept as a record / for driving the
external play.html, but the live watercolour path is the renderer.
Usage: node tools/inkwash-export.mjs [seed] [out.js] [maxTracks] [deltaFrac]
============================================================ */
import { writeFileSync } from 'node:fs';
import { generateScene } from '../src/scene/scene.js';
import { paramsFromSeed } from '../src/scene/params.js';
const SEED = process.argv[2] || 'KAON-0088';
const OUT = process.argv[3] || '/tmp/inkwash/strokes.js';
const MAXTRACKS = +(process.argv[4] || 26);
const DELTAFRAC = +(process.argv[5] ?? 0.08); // fraction of δ-ray curls to keep
const FIT = 0.62; // event half-extent in uv (×0.5)
const p = paramsFromSeed(SEED);
const scene = generateScene(p);
// frame coords (1.2..1.2, y down) → inkwash uv (0..1, y up)
const toUv = (x, y) => [0.5 + x * 0.5 * FIT, 0.5 - y * 0.5 * FIT];
// the heavy structural tracks first; δ-rays are tiny curls — keep only a few
const heavy = scene.tracks.filter(t => t.kind !== 'delta');
const deltas = scene.tracks.filter(t => t.kind === 'delta');
const keepDeltas = deltas.filter((_, i) => i % Math.max(1, Math.round(1 / Math.max(DELTAFRAC, 1e-3))) === 0);
// longest/most-energetic first so the cap keeps the spine of the event
const ranked = heavy.sort((a, b) => b.pts.length - a.pts.length).slice(0, MAXTRACKS);
const chosen = [...ranked, ...keepDeltas];
const strokes = [];
for (const tr of chosen) {
const pts = tr.pts.map(pt => {
const [u, v] = toUv(pt.x, pt.y);
// β low (slow, end of range) ⇒ denser ionisation ⇒ heavier ink
const pr = Math.min(0.95, Math.max(0.18, 0.30 + 0.65 * (1 - (pt.beta ?? 0.6))));
return [+u.toFixed(4), +v.toFixed(4), +pr.toFixed(3)];
}).filter(([u, v]) => u > -0.2 && u < 1.2 && v > -0.2 && v < 1.2);
if (pts.length >= 2) strokes.push({ mode: 'pen', pts });
}
const opts = { stepUv: 0.008, gapFrames: 6, settleFrames: 120, fixEnd: true };
const totalPts = strokes.reduce((s, k) => s + k.pts.length, 0);
writeFileSync(OUT,
`/* generated from bubble-chamber seed ${SEED}${strokes.length} strokes, ${totalPts} pts */\n` +
`window.INKWASH_OPTS = ${JSON.stringify(opts)};\n` +
`window.INKWASH_STROKES = ${JSON.stringify(strokes)};\n`);
console.log(`inkwash-export: seed ${SEED}${OUT}`);
console.log(` ${chosen.length} tracks (${ranked.length} structural + ${keepDeltas.length} δ) → ${strokes.length} strokes, ${totalPts} pts`);

34
tools/milkweed.html Normal file
View File

@@ -0,0 +1,34 @@
<!DOCTYPE html>
<html>
<head><meta charset="UTF-8"><style>html,body{margin:0;background:#000}canvas{display:block}</style></head>
<body>
<canvas id="c"></canvas>
<script type="module">
import { generateUmbel } from '../src/milkweed/umbel.js';
import { renderWatercolor } from '../src/render/watercolor.js';
const q = new URLSearchParams(location.search);
const params = {
seed: q.get('seed') || 'ASCLEPIAS-0001',
palette: 'milkweed',
paperTone: 'cream', toneStrength: 1, paperBright: 1.06, glow: 0.5,
saturation: 1, hueShift: 0, size: 1, density: 1, depth: 0, aging: 0,
showHeader: true,
};
// pass through any query param (numbers/bools), incl. wc* dials and umbel* knobs
for (const [k, v] of q) {
if (k === 'size_px' || k === 'hq') continue;
params[k] = (v === 'true') ? true : (v === 'false') ? false : (isNaN(+v) ? v : +v);
}
const SIZE = +(q.get('size_px') || 1200);
const c = document.getElementById('c');
c.width = c.height = SIZE;
const ctx = c.getContext('2d', { willReadFrequently: true });
const scene = generateUmbel(params);
renderWatercolor(ctx, SIZE, SIZE, scene, params, { preview: q.get('hq') !== '1' });
document.title = 'done';
window.__done = true;
</script>
</body>
</html>

View File

@@ -6,6 +6,7 @@
<script type="module">
import { generateScene } from '../src/scene/scene.js';
import { renderCanvasPhoto } from '../src/render/canvasPhoto.js';
import { renderWatercolor } from '../src/render/watercolor.js';
import { GROUPS, TOGGLES, SELECTS, FIXED, PRESETS } from '../src/ui/controls.js';
import { paramsFromSeed } from '../src/scene/params.js';
@@ -24,8 +25,9 @@ if (q.get('fromseed') === '1') {
}
if (q.get('seed')) params.seed = q.get('seed');
for (const [k, v] of q) {
if (k === 'seed' || k === 'preset' || k === 'size_px') continue;
if (k in params) params[k] = (v === 'true') ? true : (v === 'false') ? false : (isNaN(+v) ? v : +v);
if (k === 'seed' || k === 'preset' || k === 'size_px' || k === 'renderer' || k === 'hq') continue;
// pass through known params plus any wc* watercolour dials (not in the base set)
if (k in params || k.startsWith('wc')) params[k] = (v === 'true') ? true : (v === 'false') ? false : (isNaN(+v) ? v : +v);
}
const SIZE = +(q.get('size_px') || 1200);
@@ -33,7 +35,8 @@ const c = document.getElementById('c');
c.width = c.height = SIZE;
const ctx = c.getContext('2d', { willReadFrequently: true });
const scene = generateScene(params);
renderCanvasPhoto(ctx, SIZE, SIZE, scene, params, { preview: q.get('hq') !== '1' });
const render = q.get('renderer') === 'watercolor' ? renderWatercolor : renderCanvasPhoto;
render(ctx, SIZE, SIZE, scene, params, { preview: q.get('hq') !== '1' });
document.title = 'done';
window.__done = true;
</script>