@ignix-social/editor 0.1.21 → 0.1.22

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,2254 @@
1
+ /*
2
+ * ink.js — bloque de Ignix Editor: el estilo de dibujo de procedural-film (papel, tinta
3
+ * con temblor, sombreado a plumilla, punteado, plano azul tipo «blueprint», anotaciones
4
+ * técnicas) sobre un <canvas>, como función pura del tiempo.
5
+ *
6
+ * Es FILM.lib de procedural-film SIN MODIFICAR (foundation/src/lib.js), más un adaptador
7
+ * mínimo al final (IgnixInk) para nuestro reloj. Licencia del original, que se conserva:
8
+ *
9
+ * MIT License — Copyright (c) 2026 Dean Kuhn (procedural-film)
10
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of this
11
+ * software and associated documentation files (the "Software"), to deal in the Software
12
+ * without restriction, including without limitation the rights to use, copy, modify,
13
+ * merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
14
+ * permit persons to whom the Software is furnished to do so, subject to the following
15
+ * conditions: The above copyright notice and this permission notice shall be included in
16
+ * all copies or substantial portions of the Software.
17
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
18
+ * INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
19
+ * PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
20
+ * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF
21
+ * CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE
22
+ * OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
23
+ *
24
+ * Uso:
25
+ * const ink = IgnixInk.create(document.getElementById('cv')); // <canvas width="1080" height="1920">
26
+ * const L = ink.lib; // = FILM.lib
27
+ * run({ id, duration, fps }, (t) => ink.frame(t, (ctx) => {
28
+ * L.paper(ctx); // fondo de papel
29
+ * L.inkCircle(ctx, 540, 800, 220, { width: 5, seed: 2 }); // círculo a tinta (tiembla a 12 fps)
30
+ * L.hatch(ctx, L.ellipsePts(540, 800, 220), { spacing: 12 }); // sombreado a plumilla dentro
31
+ * L.inkPath(ctx, [[200, 1200], [880, 1200]], { width: 4, draw: Math.min(1, t / 1.5) }); // se dibuja
32
+ * }));
33
+ * Fondos: L.paper(ctx) o L.blueprint(ctx). Más: stipple, crossHatch, glowDot, ticks, bracket,
34
+ * guideCircle, arcAnnotation, hand (rótulo a mano). Cada función documenta sus opciones aquí abajo.
35
+ */
36
+ /*
37
+ * lib.js : FILM.lib, the shared drawing library.
38
+ *
39
+ * Everything here is a pure function of its arguments plus FILM.lib.T (the global time
40
+ * core sets before each shot draws, used only for the 12 fps line boil).
41
+ * Randomness comes from rng(seed) and hash(...). Caches are keyed by every input.
42
+ *
43
+ * Shape inputs ("clip") accepted by hatch, crossHatch, stipple and hexLattice:
44
+ * - an array of points [[x,y], ...] or [{x,y}, ...] (fast: exact spans, natural ends)
45
+ * - an array of polygons [[[x,y],...], [[x,y],...]] (even-odd, so inner polygons are holes)
46
+ * - a function (ctx) => { ctx.moveTo...; ctx.arc... } (hard clip; pass opts.bounds for speed)
47
+ * - a Path2D (hard clip; pass opts.bounds for speed)
48
+ * - null (no clip; opts.bounds or the whole frame)
49
+ * Bounds are { x, y, w, h } or [x, y, w, h] in the current (logical) coordinates.
50
+ *
51
+ * Angles are radians. Sizes are logical pixels on the 1080x1920 frame.
52
+ */
53
+ (function () {
54
+ 'use strict';
55
+
56
+ const FILM = (window.FILM = window.FILM || {});
57
+ const lib = (FILM.lib = {});
58
+ const W = () => FILM.W || 1080;
59
+ const H = () => FILM.H || 1920;
60
+ const TAU = Math.PI * 2;
61
+
62
+ lib.TAU = TAU;
63
+ // global time of the frame being drawn; core sets it before each shot draws, scenes can only read it
64
+ Object.defineProperty(lib, 'T', { get: () => FILM.frameT || 0, enumerable: true });
65
+
66
+ // ===========================================================================
67
+ // Numbers
68
+ // ===========================================================================
69
+
70
+ const clamp = (v, lo = 0, hi = 1) => (v < lo ? lo : v > hi ? hi : v);
71
+ const lerp = (a, b, t) => a + (b - a) * t;
72
+ const invLerp = (a, b, v) => (b === a ? 0 : (v - a) / (b - a));
73
+ const smoothstep = (e0, e1, x) => {
74
+ const t = clamp(invLerp(e0, e1, x));
75
+ return t * t * (3 - 2 * t);
76
+ };
77
+ lib.clamp = clamp;
78
+ lib.lerp = lerp;
79
+ lib.invLerp = invLerp;
80
+ lib.smoothstep = smoothstep;
81
+
82
+ // ===========================================================================
83
+ // Easing (inputs are clamped to 0..1)
84
+ // ===========================================================================
85
+
86
+ const c01 = (p) => (p < 0 ? 0 : p > 1 ? 1 : p);
87
+ const B1 = 1.70158;
88
+ const B2 = B1 * 1.525;
89
+ const ease = {
90
+ linear: (p) => c01(p),
91
+ inQuad: (p) => ((p = c01(p)), p * p),
92
+ outQuad: (p) => ((p = c01(p)), 1 - (1 - p) * (1 - p)),
93
+ inOutQuad: (p) => ((p = c01(p)), p < 0.5 ? 2 * p * p : 1 - Math.pow(-2 * p + 2, 2) / 2),
94
+ inCubic: (p) => ((p = c01(p)), p * p * p),
95
+ outCubic: (p) => ((p = c01(p)), 1 - Math.pow(1 - p, 3)),
96
+ inOutCubic: (p) => ((p = c01(p)), p < 0.5 ? 4 * p * p * p : 1 - Math.pow(-2 * p + 2, 3) / 2),
97
+ inQuart: (p) => ((p = c01(p)), p * p * p * p),
98
+ outQuart: (p) => ((p = c01(p)), 1 - Math.pow(1 - p, 4)),
99
+ inOutQuart: (p) => ((p = c01(p)), p < 0.5 ? 8 * p * p * p * p : 1 - Math.pow(-2 * p + 2, 4) / 2),
100
+ inQuint: (p) => ((p = c01(p)), p * p * p * p * p),
101
+ outQuint: (p) => ((p = c01(p)), 1 - Math.pow(1 - p, 5)),
102
+ inOutQuint: (p) => ((p = c01(p)), p < 0.5 ? 16 * p * p * p * p * p : 1 - Math.pow(-2 * p + 2, 5) / 2),
103
+ inSine: (p) => ((p = c01(p)), 1 - Math.cos((p * Math.PI) / 2)),
104
+ outSine: (p) => ((p = c01(p)), Math.sin((p * Math.PI) / 2)),
105
+ inOutSine: (p) => ((p = c01(p)), -(Math.cos(Math.PI * p) - 1) / 2),
106
+ inExpo: (p) => ((p = c01(p)), p === 0 ? 0 : Math.pow(2, 10 * p - 10)),
107
+ outExpo: (p) => ((p = c01(p)), p === 1 ? 1 : 1 - Math.pow(2, -10 * p)),
108
+ inOutExpo: (p) => ((p = c01(p)), p === 0 ? 0 : p === 1 ? 1 : p < 0.5 ? Math.pow(2, 20 * p - 10) / 2 : (2 - Math.pow(2, -20 * p + 10)) / 2),
109
+ inCirc: (p) => ((p = c01(p)), 1 - Math.sqrt(1 - p * p)),
110
+ outCirc: (p) => ((p = c01(p)), Math.sqrt(1 - Math.pow(p - 1, 2))),
111
+ inOutCirc: (p) => ((p = c01(p)), p < 0.5 ? (1 - Math.sqrt(1 - Math.pow(2 * p, 2))) / 2 : (Math.sqrt(1 - Math.pow(-2 * p + 2, 2)) + 1) / 2),
112
+ inBack: (p) => ((p = c01(p)), (B1 + 1) * p * p * p - B1 * p * p),
113
+ outBack: (p) => ((p = c01(p)), 1 + (B1 + 1) * Math.pow(p - 1, 3) + B1 * Math.pow(p - 1, 2)),
114
+ inOutBack: (p) => ((p = c01(p)), p < 0.5 ? (Math.pow(2 * p, 2) * ((B2 + 1) * 2 * p - B2)) / 2 : (Math.pow(2 * p - 2, 2) * ((B2 + 1) * (p * 2 - 2) + B2) + 2) / 2),
115
+ outElastic: (p) => ((p = c01(p)), p === 0 ? 0 : p === 1 ? 1 : Math.pow(2, -10 * p) * Math.sin((p * 10 - 0.75) * (TAU / 3)) + 1),
116
+ outBounce: (p) => {
117
+ p = c01(p);
118
+ const n = 7.5625, d = 2.75;
119
+ if (p < 1 / d) return n * p * p;
120
+ if (p < 2 / d) return n * (p -= 1.5 / d) * p + 0.75;
121
+ if (p < 2.5 / d) return n * (p -= 2.25 / d) * p + 0.9375;
122
+ return n * (p -= 2.625 / d) * p + 0.984375;
123
+ },
124
+ /** Snappy settle used for drawn-animation poses: fast out, tiny overshoot. */
125
+ snap: (p) => ((p = c01(p)), 1 + 2.2 * Math.pow(p - 1, 3) + 1.2 * Math.pow(p - 1, 2)),
126
+ /** Factory: hold-and-jump in n steps. */
127
+ steps: (n) => (p) => Math.min(1, Math.floor(c01(p) * n) / n),
128
+ };
129
+ lib.ease = ease;
130
+
131
+ const easeFn = (e) => (typeof e === 'function' ? e : typeof e === 'string' && ease[e] ? ease[e] : ease.linear);
132
+
133
+ /** mapRange(x, a0, a1, b0, b1, easing?) clamps x into [a0,a1] and maps to [b0,b1]. */
134
+ lib.mapRange = (x, a0, a1, b0, b1, e) => b0 + (b1 - b0) * easeFn(e)(clamp(invLerp(a0, a1, x)));
135
+ /** seg(t, t0, t1, easing?) : 0..1 progress of t through [t0,t1]. */
136
+ lib.seg = (t, t0, t1, e) => easeFn(e)(clamp(invLerp(t0, t1, t)));
137
+
138
+ // ===========================================================================
139
+ // Hash, rng, noise
140
+ // ===========================================================================
141
+
142
+ const F64 = new Float64Array(1);
143
+ const U32 = new Uint32Array(F64.buffer);
144
+ function mix32(h) {
145
+ h ^= h >>> 16;
146
+ h = Math.imul(h, 0x7feb352d);
147
+ h ^= h >>> 15;
148
+ h = Math.imul(h, 0x846ca68b);
149
+ h ^= h >>> 16;
150
+ return h >>> 0;
151
+ }
152
+ /** hash(...values) : stable unsigned 32-bit int from numbers and strings. */
153
+ function hash() {
154
+ let h = 0x811c9dc5 ^ arguments.length;
155
+ for (let a = 0; a < arguments.length; a++) {
156
+ const v = arguments[a];
157
+ if (typeof v === 'number') {
158
+ if ((v | 0) === v) {
159
+ h = mix32(h ^ Math.imul(v, 0x9e3779b1));
160
+ } else {
161
+ F64[0] = v;
162
+ h = mix32(h ^ U32[0]);
163
+ h = mix32(h ^ U32[1]);
164
+ }
165
+ } else {
166
+ const s = String(v);
167
+ for (let i = 0; i < s.length; i++) h = Math.imul(h ^ s.charCodeAt(i), 0x01000193);
168
+ h = mix32(h ^ s.length);
169
+ }
170
+ }
171
+ return h >>> 0;
172
+ }
173
+ lib.hash = hash;
174
+
175
+ const seedInt = (s) => (typeof s === 'number' && (s | 0) === s ? s : hash(s) | 0);
176
+
177
+ /** Fast stateless 3-int hash to [0,1). */
178
+ function h3(a, b, c) {
179
+ let h = Math.imul(a | 0, 0x27d4eb2d) ^ Math.imul(b | 0, 0x165667b1) ^ Math.imul(c | 0, 0x9e3779b1);
180
+ h ^= h >>> 15;
181
+ h = Math.imul(h, 0x85ebca6b);
182
+ h ^= h >>> 13;
183
+ h = Math.imul(h, 0xc2b2ae35);
184
+ h ^= h >>> 16;
185
+ return (h >>> 0) / 4294967296;
186
+ }
187
+ lib.h3 = h3;
188
+
189
+ /** rng(seed) : function returning [0,1). Has .range(a,b) .int(a,b) .pick(arr) .sign() .chance(p) .gauss(). */
190
+ function rng(seed) {
191
+ let a = hash(seed === undefined ? 1 : seed) || 0x9e3779b9;
192
+ const r = function () {
193
+ a = (a + 0x6d2b79f5) | 0;
194
+ let t = Math.imul(a ^ (a >>> 15), 1 | a);
195
+ t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
196
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
197
+ };
198
+ r.range = (lo, hi) => lo + (hi - lo) * r();
199
+ r.int = (lo, hi) => lo + Math.floor((hi - lo + 1) * r());
200
+ r.pick = (arr) => arr[Math.floor(r() * arr.length)];
201
+ r.sign = () => (r() < 0.5 ? -1 : 1);
202
+ r.chance = (p) => r() < p;
203
+ r.gauss = () => {
204
+ const u = 1 - r();
205
+ const v = r();
206
+ return Math.sqrt(-2 * Math.log(u)) * Math.cos(TAU * v);
207
+ };
208
+ return r;
209
+ }
210
+ lib.rng = rng;
211
+
212
+ const fade5 = (f) => f * f * f * (f * (f * 6 - 15) + 10);
213
+
214
+ /** noise1(x, seed) : smooth 1D gradient noise in [-1,1]. */
215
+ function noise1(x, seed) {
216
+ const s = seed === undefined ? 0 : seedInt(seed);
217
+ const i = Math.floor(x);
218
+ const f = x - i;
219
+ const g0 = h3(i, 71, s) * 2 - 1;
220
+ const g1 = h3(i + 1, 71, s) * 2 - 1;
221
+ const v = lerp(g0 * f, g1 * (f - 1), fade5(f)) * 2;
222
+ return v < -1 ? -1 : v > 1 ? 1 : v;
223
+ }
224
+ lib.noise1 = noise1;
225
+
226
+ const GX = [1, -1, 1, -1, 1.4142, -1.4142, 0, 0];
227
+ const GY = [1, 1, -1, -1, 0, 0, 1.4142, -1.4142];
228
+ function grad(ix, iy, s, x, y) {
229
+ const k = (h3(ix, iy, s) * 8) | 0;
230
+ return GX[k] * x + GY[k] * y;
231
+ }
232
+ /** noise2(x, y, seed) : smooth 2D gradient noise in [-1,1]. */
233
+ function noise2(x, y, seed) {
234
+ const s = seed === undefined ? 0 : seedInt(seed);
235
+ const ix = Math.floor(x);
236
+ const iy = Math.floor(y);
237
+ const fx = x - ix;
238
+ const fy = y - iy;
239
+ const u = fade5(fx);
240
+ const v = fade5(fy);
241
+ const n00 = grad(ix, iy, s, fx, fy);
242
+ const n10 = grad(ix + 1, iy, s, fx - 1, fy);
243
+ const n01 = grad(ix, iy + 1, s, fx, fy - 1);
244
+ const n11 = grad(ix + 1, iy + 1, s, fx - 1, fy - 1);
245
+ const r = lerp(lerp(n00, n10, u), lerp(n01, n11, u), v) * 1.1;
246
+ return r < -1 ? -1 : r > 1 ? 1 : r;
247
+ }
248
+ lib.noise2 = noise2;
249
+
250
+ lib.fbm1 = (x, seed, oct = 3) => {
251
+ const s = seed === undefined ? 0 : seedInt(seed);
252
+ let a = 0.5, f = 1, sum = 0, norm = 0;
253
+ for (let o = 0; o < oct; o++) {
254
+ sum += a * noise1(x * f, s + o * 101);
255
+ norm += a;
256
+ a *= 0.5;
257
+ f *= 2.03;
258
+ }
259
+ return sum / norm;
260
+ };
261
+ lib.fbm2 = (x, y, seed, oct = 3) => {
262
+ const s = seed === undefined ? 0 : seedInt(seed);
263
+ let a = 0.5, f = 1, sum = 0, norm = 0;
264
+ for (let o = 0; o < oct; o++) {
265
+ sum += a * noise2(x * f, y * f, s + o * 101);
266
+ norm += a;
267
+ a *= 0.5;
268
+ f *= 2.03;
269
+ }
270
+ return sum / norm;
271
+ };
272
+
273
+ // ===========================================================================
274
+ // Animation clocks
275
+ // ===========================================================================
276
+
277
+ /** boil(T, fps=12) : index of the held drawing at global time T. Lines re-wobble when it changes. */
278
+ lib.boil = (T, fps = 12) => Math.floor(T * fps + 1e-6);
279
+ /** onTwos(t) : quantise time to 1/12 s so motion steps like drawn animation. */
280
+ lib.onTwos = (t) => Math.floor(t * 12 + 1e-6) / 12;
281
+
282
+ function boilIndex(o) {
283
+ if (o.boil === false) return 0;
284
+ if (typeof o.boil === 'number') return o.boil;
285
+ return lib.boil(lib.T);
286
+ }
287
+
288
+ // ===========================================================================
289
+ // Colour
290
+ // ===========================================================================
291
+
292
+ // Keys and values follow docs/art-bible.md section 2 (the published palette).
293
+ const pal = {
294
+ // 2.1 warm illustrated palette (paper plate)
295
+ paper: '#EFE3C9',
296
+ paperShade: '#E2D1B0',
297
+ paperDeep: '#CDB58C',
298
+ stripeCream: '#F2E7CF',
299
+ stripeYellow: '#EFDCA3',
300
+ stripeApricot: '#F0D9B5',
301
+ stripeSage: '#DCE3CC',
302
+ stripeSpring: '#E4EDD0',
303
+ stripeSky: '#C9D3D2',
304
+ ink: '#2A1C13',
305
+ inkSoft: '#5B4331',
306
+ inkFaint: '#8A735C',
307
+ tan: '#C8A47A',
308
+ ochre: '#C38F2E',
309
+ rose: '#C88C86',
310
+ duskRose: '#E3B1A1',
311
+ sage: '#94A47F',
312
+ teal: '#3C8783',
313
+ tealDeep: '#285F5D',
314
+ sun: '#F1BF4A',
315
+ nightSky: '#4E3F6E',
316
+ night: '#2F2748',
317
+ white: '#FBF6EA',
318
+ orange: '#D8742B',
319
+ leaf: '#6E8F4F',
320
+ wood: '#A8784C',
321
+ sunset: '#E79D8F',
322
+ dusk: '#5A4878',
323
+ red: '#BF3F2C',
324
+ // 2.2b photo-doodle house colours — the mode's own, used by src/props.js and src/cast.js.
325
+ // A drawn film never reads them. A film's own cast colours go in the subject block below.
326
+ paperMint: '#DCE7DC',
327
+ paperPink: '#F2DCD8',
328
+ paperButter: '#F1E5C2',
329
+ paperSky: '#D7E3EE',
330
+ paperCream: '#EEE5D4',
331
+ paperLilac: '#DFD9EA',
332
+ paperPeach: '#F2DDCA',
333
+ paperSage: '#D9E0CE',
334
+ paperNight: '#1C2340',
335
+ doodleInk: '#26221D',
336
+ chalk: '#F1EDE2',
337
+ washYellow: '#F0C85F',
338
+ washPink: '#EEA6A4',
339
+ washBlue: '#9DC3DF',
340
+ washGreen: '#A7C79A',
341
+ washLilac: '#BFB2DC',
342
+ washRed: '#D2685A',
343
+ washCream: '#EFE1C2',
344
+ washBrown: '#C09A6B',
345
+ blush: '#EFA7A7',
346
+ // 2.2 subject palette, warm — filled per film from docs/art-bible.md section 2.2.
347
+ // Add the subject's named colours here exactly as the art bible publishes them:
348
+ // hero: '#D9772B',
349
+ // heroDeep: '#B55A1C',
350
+ // Scene code reads them as lib.pal.<name>.
351
+ // 2.3 cool schematic palette (blueprint plate)
352
+ navy: '#0B1230',
353
+ navyDeep: '#060A1C',
354
+ navyLight: '#18234D',
355
+ grid: '#3A4A86',
356
+ lavender: '#C8C1EF',
357
+ lineWhite: '#EEF0FF',
358
+ paleBlue: '#9CC2EA',
359
+ glow: '#FFF3DC',
360
+ magenta: '#FF3D98',
361
+ // Subject identity tints — 1 to 3 per film, from art-bible 2.3, e.g. schemHero: '#F2A66A'.
362
+ // Line or dot colours only, never fills; a schematic shot uses at most one besides magenta.
363
+ // 2.4 overlay colours on illustrations
364
+ annMagenta: '#E43D8C',
365
+ annBlue: '#3B8EE0',
366
+ annYellow: '#EAB530',
367
+ };
368
+ // earlier names kept as aliases
369
+ pal.stripeA = pal.stripeCream;
370
+ pal.stripeB = pal.stripeYellow;
371
+ lib.pal = pal;
372
+
373
+ const rgbCache = {};
374
+ function parseColor(c) {
375
+ if (rgbCache[c]) return rgbCache[c];
376
+ let r = 0, g = 0, b = 0;
377
+ if (c[0] === '#') {
378
+ let h = c.slice(1);
379
+ if (h.length === 3) h = h[0] + h[0] + h[1] + h[1] + h[2] + h[2];
380
+ const n = parseInt(h.slice(0, 6), 16);
381
+ r = (n >> 16) & 255;
382
+ g = (n >> 8) & 255;
383
+ b = n & 255;
384
+ } else {
385
+ const m = c.match(/[\d.]+/g) || [0, 0, 0];
386
+ r = +m[0];
387
+ g = +m[1];
388
+ b = +m[2];
389
+ }
390
+ return (rgbCache[c] = [r, g, b]);
391
+ }
392
+ lib.rgb = parseColor;
393
+ /** rgba('#hex' or pal colour, alpha) : css string. */
394
+ lib.rgba = (c, a = 1) => {
395
+ const [r, g, b] = parseColor(c);
396
+ return `rgba(${r},${g},${b},${a})`;
397
+ };
398
+ /** mix(colorA, colorB, t) : css string between two colours. */
399
+ lib.mix = (a, b, t) => {
400
+ const A = parseColor(a);
401
+ const B = parseColor(b);
402
+ return `rgb(${Math.round(lerp(A[0], B[0], t))},${Math.round(lerp(A[1], B[1], t))},${Math.round(lerp(A[2], B[2], t))})`;
403
+ };
404
+
405
+ // ===========================================================================
406
+ // Geometry helpers
407
+ // ===========================================================================
408
+
409
+ const XY = (p) => (Array.isArray(p) ? p : [p.x, p.y]);
410
+
411
+ lib.ellipsePts = (cx, cy, rx, ry = rx, n = 64, rot = 0) => {
412
+ const out = [];
413
+ const cr = Math.cos(rot), sr = Math.sin(rot);
414
+ for (let i = 0; i < n; i++) {
415
+ const a = (i / n) * TAU;
416
+ const x = Math.cos(a) * rx, y = Math.sin(a) * ry;
417
+ out.push([cx + x * cr - y * sr, cy + x * sr + y * cr]);
418
+ }
419
+ return out;
420
+ };
421
+ lib.rectPts = (x, y, w, h, stepPx = 24) => {
422
+ const out = [];
423
+ const edge = (x0, y0, x1, y1) => {
424
+ const n = Math.max(1, Math.ceil(Math.hypot(x1 - x0, y1 - y0) / stepPx));
425
+ for (let i = 0; i < n; i++) out.push([lerp(x0, x1, i / n), lerp(y0, y1, i / n)]);
426
+ };
427
+ edge(x, y, x + w, y);
428
+ edge(x + w, y, x + w, y + h);
429
+ edge(x + w, y + h, x, y + h);
430
+ edge(x, y + h, x, y);
431
+ return out;
432
+ };
433
+ lib.rrectPts = (x, y, w, h, r, stepPx = 24) => {
434
+ r = Math.min(r, w / 2, h / 2);
435
+ const out = [];
436
+ const line = (x0, y0, x1, y1) => {
437
+ const n = Math.max(1, Math.ceil(Math.hypot(x1 - x0, y1 - y0) / stepPx));
438
+ for (let i = 0; i < n; i++) out.push([lerp(x0, x1, i / n), lerp(y0, y1, i / n)]);
439
+ };
440
+ const corner = (cx, cy, a0) => {
441
+ const n = Math.max(2, Math.ceil((r * Math.PI) / 2 / (stepPx * 0.5)));
442
+ for (let i = 0; i < n; i++) {
443
+ const a = a0 + (i / n) * (Math.PI / 2);
444
+ out.push([cx + Math.cos(a) * r, cy + Math.sin(a) * r]);
445
+ }
446
+ };
447
+ line(x + r, y, x + w - r, y);
448
+ corner(x + w - r, y + r, -Math.PI / 2);
449
+ line(x + w, y + r, x + w, y + h - r);
450
+ corner(x + w - r, y + h - r, 0);
451
+ line(x + w - r, y + h, x + r, y + h);
452
+ corner(x + r, y + h - r, Math.PI / 2);
453
+ line(x, y + h - r, x, y + r);
454
+ corner(x + r, y + r, Math.PI);
455
+ return out;
456
+ };
457
+ /** capsulePts(cx, cy, length, radius, rot, n) : a stadium shape along its rotated long axis. */
458
+ lib.capsulePts = (cx, cy, len, r, rot = 0, n = 72) => {
459
+ const out = [];
460
+ const half = Math.max(0, len / 2 - r);
461
+ const cr = Math.cos(rot), sr = Math.sin(rot);
462
+ const k = Math.floor(n / 2);
463
+ for (let i = 0; i <= k; i++) {
464
+ const a = -Math.PI / 2 + (i / k) * Math.PI;
465
+ const x = half + Math.cos(a) * r, y = Math.sin(a) * r;
466
+ out.push([cx + x * cr - y * sr, cy + x * sr + y * cr]);
467
+ }
468
+ for (let i = 0; i <= k; i++) {
469
+ const a = Math.PI / 2 + (i / k) * Math.PI;
470
+ const x = -half + Math.cos(a) * r, y = Math.sin(a) * r;
471
+ out.push([cx + x * cr - y * sr, cy + x * sr + y * cr]);
472
+ }
473
+ return out;
474
+ };
475
+
476
+ /** tracePath(ctx, pts, closed=true) : adds the polyline to the current path (no beginPath). */
477
+ lib.tracePath = (ctx, pts, closed = true) => {
478
+ for (let i = 0; i < pts.length; i++) {
479
+ const p = XY(pts[i]);
480
+ if (i === 0) ctx.moveTo(p[0], p[1]);
481
+ else ctx.lineTo(p[0], p[1]);
482
+ }
483
+ if (closed) ctx.closePath();
484
+ };
485
+
486
+ /**
487
+ * Centripetal Catmull-Rom resample (no overshoot or cusps where long and short segments meet).
488
+ * Returns a flat [x0,y0,x1,y1,...] array (closed: no duplicate end).
489
+ */
490
+ function sampleFlat(P, closed, step, smooth) {
491
+ const n = P.length;
492
+ const out = [];
493
+ const segs = closed ? n : n - 1;
494
+ for (let i = 0; i < segs; i++) {
495
+ const p1 = P[i];
496
+ const p2 = P[(i + 1) % n];
497
+ const d = Math.hypot(p2[0] - p1[0], p2[1] - p1[1]);
498
+ const k = Math.max(1, Math.ceil(d / step));
499
+ if (!smooth || n < 3) {
500
+ for (let j = 0; j < k; j++) out.push(lerp(p1[0], p2[0], j / k), lerp(p1[1], p2[1], j / k));
501
+ continue;
502
+ }
503
+ let p0, p3;
504
+ if (closed) {
505
+ p0 = P[(i - 1 + n) % n];
506
+ p3 = P[(i + 2) % n];
507
+ } else {
508
+ p0 = i > 0 ? P[i - 1] : [2 * p1[0] - p2[0], 2 * p1[1] - p2[1]];
509
+ p3 = i + 2 < n ? P[i + 2] : [2 * p2[0] - p1[0], 2 * p2[1] - p1[1]];
510
+ }
511
+ const t1 = Math.sqrt(Math.hypot(p1[0] - p0[0], p1[1] - p0[1])) || 1e-4;
512
+ const t2 = t1 + (Math.sqrt(d) || 1e-4);
513
+ const t3 = t2 + (Math.sqrt(Math.hypot(p3[0] - p2[0], p3[1] - p2[1])) || 1e-4);
514
+ for (let j = 0; j < k; j++) {
515
+ const t = t1 + (t2 - t1) * (j / k);
516
+ const a1x = ((t1 - t) / t1) * p0[0] + (t / t1) * p1[0];
517
+ const a1y = ((t1 - t) / t1) * p0[1] + (t / t1) * p1[1];
518
+ const a2x = ((t2 - t) / (t2 - t1)) * p1[0] + ((t - t1) / (t2 - t1)) * p2[0];
519
+ const a2y = ((t2 - t) / (t2 - t1)) * p1[1] + ((t - t1) / (t2 - t1)) * p2[1];
520
+ const a3x = ((t3 - t) / (t3 - t2)) * p2[0] + ((t - t2) / (t3 - t2)) * p3[0];
521
+ const a3y = ((t3 - t) / (t3 - t2)) * p2[1] + ((t - t2) / (t3 - t2)) * p3[1];
522
+ const b1x = ((t2 - t) / t2) * a1x + (t / t2) * a2x;
523
+ const b1y = ((t2 - t) / t2) * a1y + (t / t2) * a2y;
524
+ const b2x = ((t3 - t) / (t3 - t1)) * a2x + ((t - t1) / (t3 - t1)) * a3x;
525
+ const b2y = ((t3 - t) / (t3 - t1)) * a2y + ((t - t1) / (t3 - t1)) * a3y;
526
+ out.push(((t2 - t) / (t2 - t1)) * b1x + ((t - t1) / (t2 - t1)) * b2x, ((t2 - t) / (t2 - t1)) * b1y + ((t - t1) / (t2 - t1)) * b2y);
527
+ }
528
+ }
529
+ if (!closed) out.push(P[n - 1][0], P[n - 1][1]);
530
+ return out;
531
+ }
532
+
533
+ /** smoothPts(pts, closed, step=6) : Catmull-Rom resampled points as [[x,y],...]. */
534
+ lib.smoothPts = (pts, closed = true, step = 6) => {
535
+ const f = sampleFlat(pts.map(XY), closed, step, true);
536
+ const out = [];
537
+ for (let i = 0; i < f.length; i += 2) out.push([f[i], f[i + 1]]);
538
+ return out;
539
+ };
540
+
541
+ function toPolys(clip) {
542
+ if (!Array.isArray(clip) || !clip.length) return null;
543
+ const first = clip[0];
544
+ if (Array.isArray(first) && typeof first[0] === 'number') return [clip];
545
+ if (first && typeof first.x === 'number') return [clip.map(XY)];
546
+ return clip.map((poly) => poly.map(XY));
547
+ }
548
+
549
+ /** polyContains(pointsOrPolys, x, y) : even-odd point-in-polygon. */
550
+ function polysContain(polys, x, y) {
551
+ let inside = false;
552
+ for (let p = 0; p < polys.length; p++) {
553
+ const poly = polys[p];
554
+ for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) {
555
+ const xi = poly[i][0], yi = poly[i][1], xj = poly[j][0], yj = poly[j][1];
556
+ if (yi > y !== yj > y && x < ((xj - xi) * (y - yi)) / (yj - yi) + xi) inside = !inside;
557
+ }
558
+ }
559
+ return inside;
560
+ }
561
+ lib.polyContains = (clip, x, y) => polysContain(toPolys(clip), x, y);
562
+
563
+ function polysBounds(polys) {
564
+ let x0 = Infinity, y0 = Infinity, x1 = -Infinity, y1 = -Infinity;
565
+ for (const poly of polys) {
566
+ for (const p of poly) {
567
+ if (p[0] < x0) x0 = p[0];
568
+ if (p[1] < y0) y0 = p[1];
569
+ if (p[0] > x1) x1 = p[0];
570
+ if (p[1] > y1) y1 = p[1];
571
+ }
572
+ }
573
+ return { x: x0, y: y0, w: x1 - x0, h: y1 - y0 };
574
+ }
575
+ lib.bounds = (pts) => polysBounds(toPolys(pts));
576
+
577
+ function normBounds(b) {
578
+ if (!b) return { x: 0, y: 0, w: W(), h: H() };
579
+ if (Array.isArray(b)) return { x: b[0], y: b[1], w: b[2], h: b[3] };
580
+ return b;
581
+ }
582
+
583
+ /** Resolves a clip argument into { polys, bounds, hard(ctx) }. */
584
+ function shapeOf(clip, o) {
585
+ const polys = toPolys(clip);
586
+ if (polys) {
587
+ const b = polysBounds(polys);
588
+ const pad = o.pad != null ? o.pad : 0;
589
+ return {
590
+ polys,
591
+ bounds: { x: b.x - pad, y: b.y - pad, w: b.w + 2 * pad, h: b.h + 2 * pad },
592
+ apply(ctx) {
593
+ ctx.beginPath();
594
+ for (const poly of polys) lib.tracePath(ctx, poly, true);
595
+ ctx.clip('evenodd');
596
+ },
597
+ };
598
+ }
599
+ const bounds = normBounds(o.bounds);
600
+ if (typeof clip === 'function') {
601
+ return { polys: null, bounds, apply(ctx) { ctx.beginPath(); clip(ctx); ctx.clip(o.fillRule || 'nonzero'); } };
602
+ }
603
+ if (typeof Path2D !== 'undefined' && clip instanceof Path2D) {
604
+ return { polys: null, bounds, apply(ctx) { ctx.clip(clip, o.fillRule || 'nonzero'); } };
605
+ }
606
+ return { polys: null, bounds, apply: null };
607
+ }
608
+
609
+ // ===========================================================================
610
+ // Canvas cache (pure: keyed by every input, LRU)
611
+ // ===========================================================================
612
+
613
+ // Sized from the timeline (read lazily: lib loads before timeline.js) so a looping player keeps
614
+ // every shot's paper or blueprint plate warm and does not rebuild one at each shot change.
615
+ const cache = new Map();
616
+ function cacheMax() {
617
+ const tl = FILM.TIMELINE;
618
+ const shots = tl && Array.isArray(tl.shots) ? tl.shots.length : 0;
619
+ return Math.max(24, shots * 2 + 8);
620
+ }
621
+ function cached(key, make) {
622
+ if (cache.has(key)) {
623
+ const v = cache.get(key);
624
+ cache.delete(key);
625
+ cache.set(key, v);
626
+ return v;
627
+ }
628
+ const v = make();
629
+ cache.set(key, v);
630
+ const max = cacheMax();
631
+ while (cache.size > max) cache.delete(cache.keys().next().value);
632
+ return v;
633
+ }
634
+ function newCanvas(w, h) {
635
+ if (FILM.makeCanvas) return FILM.makeCanvas(w, h);
636
+ const c = document.createElement('canvas');
637
+ c.width = w;
638
+ c.height = h;
639
+ return c;
640
+ }
641
+ lib.cached = cached;
642
+
643
+ // ===========================================================================
644
+ // Ink lines
645
+ // ===========================================================================
646
+
647
+ /*
648
+ * One stroke pass over a resampled centreline (flat arrays X, Y with normals NX, NY and
649
+ * arc length S). Builds a pressure-width ribbon out of quads so every overlap unions cleanly,
650
+ * and fills it in one call.
651
+ */
652
+ function ribbon(ctx, X, Y, NX, NY, S, i0, i1, q) {
653
+ const n = i1 - i0 + 1;
654
+ if (n < 2) return null;
655
+ const s0 = S[i0];
656
+ const L = Math.max(1e-6, S[i1] - s0);
657
+ const DX = new Float64Array(n);
658
+ const DY = new Float64Array(n);
659
+ const bs = q.boilSeed;
660
+ const wf = q.wobbleFreq;
661
+ for (let k = 0; k < n; k++) {
662
+ const i = i0 + k;
663
+ const s = S[i] - s0 + q.phase;
664
+ let d =
665
+ q.wobble * (0.72 * noise1(s * wf, q.seed) + 0.28 * noise1(s * wf * 3.3, q.seed + 1)) +
666
+ q.tremble * noise1(s / 7.5, q.seed + 2) +
667
+ q.boilAmp * noise1(s * wf * 2.2 + 0.37, bs) +
668
+ q.tremble * 0.7 * noise1(s / 6.3, bs + 5) +
669
+ q.offset;
670
+ if (q.closeBlend > 0 && S[i1] - S[i] < q.closeBlend) {
671
+ // pen returning to the start: pull toward the start's displacement, not all the way
672
+ const u = 1 - (S[i1] - S[i]) / q.closeBlend;
673
+ const s2 = s - q.loopLen;
674
+ const d0 =
675
+ q.wobble * (0.72 * noise1(s2 * wf, q.seed) + 0.28 * noise1(s2 * wf * 3.3, q.seed + 1)) +
676
+ q.boilAmp * noise1(s2 * wf * 2.2 + 0.37, bs) +
677
+ q.offset;
678
+ d = lerp(d, d0, u * u * (3 - 2 * u) * 0.8);
679
+ }
680
+ DX[k] = X[i] + NX[i] * d;
681
+ DY[k] = Y[i] + NY[i] * d;
682
+ }
683
+ // width profile
684
+ const Wd = new Float64Array(n);
685
+ const tIn = Math.min(q.taperIn, L * 0.45);
686
+ const tOut = Math.min(q.taperOut, L * 0.45);
687
+ for (let k = 0; k < n; k++) {
688
+ const s = S[i0 + k] - s0;
689
+ let w = q.width;
690
+ if (tIn > 0 && s < tIn) w *= q.minW + (1 - q.minW) * Math.pow(s / tIn, 0.55);
691
+ if (tOut > 0 && L - s < tOut) w *= q.minW + (1 - q.minW) * Math.pow((L - s) / tOut, 0.7);
692
+ w *= 1 + q.widthJitter * (0.6 * noise1(s * 0.012 + 3.1, q.seed + 3) + 0.4 * noise1(s * 0.045, q.seed + 4));
693
+ if (q.swell) w *= 1 + q.swell * Math.sin(Math.PI * clamp(s / L));
694
+ if (q.pressure) w *= q.pressure(s / L);
695
+ Wd[k] = Math.max(0.05, w) * 0.5;
696
+ }
697
+ // offset normals from the displaced line
698
+ const LX = new Float64Array(n), LY = new Float64Array(n), RX = new Float64Array(n), RY = new Float64Array(n);
699
+ for (let k = 0; k < n; k++) {
700
+ const a = k > 0 ? k - 1 : k;
701
+ const b = k < n - 1 ? k + 1 : k;
702
+ let tx = DX[b] - DX[a], ty = DY[b] - DY[a];
703
+ const tl = Math.hypot(tx, ty) || 1;
704
+ tx /= tl;
705
+ ty /= tl;
706
+ const s = S[i0 + k];
707
+ const wl = Math.max(0.03, Wd[k] + q.rough * noise1(s / 3.1, q.seed + 20));
708
+ const wr = Math.max(0.03, Wd[k] + q.rough * noise1(s / 3.1, q.seed + 21));
709
+ LX[k] = DX[k] - ty * wl;
710
+ LY[k] = DY[k] + tx * wl;
711
+ RX[k] = DX[k] + ty * wr;
712
+ RY[k] = DY[k] - tx * wr;
713
+ }
714
+ ctx.beginPath();
715
+ for (let k = 0; k < n - 1; k++) {
716
+ ctx.moveTo(LX[k], LY[k]);
717
+ ctx.lineTo(LX[k + 1], LY[k + 1]);
718
+ ctx.lineTo(RX[k + 1], RY[k + 1]);
719
+ ctx.lineTo(RX[k], RY[k]);
720
+ ctx.closePath();
721
+ }
722
+ // soft round ends
723
+ ctx.moveTo(DX[0] + Wd[0], DY[0]);
724
+ ctx.arc(DX[0], DY[0], Wd[0], 0, TAU);
725
+ ctx.moveTo(DX[n - 1] + Wd[n - 1], DY[n - 1]);
726
+ ctx.arc(DX[n - 1], DY[n - 1], Wd[n - 1], 0, TAU);
727
+ ctx.fill('nonzero');
728
+ return { DX, DY };
729
+ }
730
+
731
+ function centreline(pts, closed, step, smooth, startFrac, overlapPx) {
732
+ const P = pts.map(XY);
733
+ let F = sampleFlat(P, closed, step, smooth);
734
+ let m = F.length / 2;
735
+ let loopLen = 0;
736
+ if (closed && m > 2) {
737
+ // rotate so the pen starts at a seeded place, then run past the start
738
+ const st = Math.floor(startFrac * m) % m;
739
+ const R = new Array(F.length);
740
+ for (let k = 0; k < m; k++) {
741
+ R[2 * k] = F[2 * ((k + st) % m)];
742
+ R[2 * k + 1] = F[2 * ((k + st) % m) + 1];
743
+ }
744
+ for (let k = 0; k < m; k++) loopLen += Math.hypot(R[(2 * (k + 1)) % R.length] - R[2 * k], R[((2 * (k + 1)) % R.length) + 1] - R[2 * k + 1]);
745
+ R.push(R[0], R[1]);
746
+ let acc = 0;
747
+ for (let k = 1; k <= m && acc < overlapPx; k++) {
748
+ const x = R[2 * (k % m)], y = R[2 * (k % m) + 1];
749
+ acc += Math.hypot(x - R[R.length - 2], y - R[R.length - 1]);
750
+ R.push(x, y);
751
+ }
752
+ F = R;
753
+ m = F.length / 2;
754
+ }
755
+ const X = new Float64Array(m), Y = new Float64Array(m), S = new Float64Array(m);
756
+ for (let k = 0; k < m; k++) {
757
+ X[k] = F[2 * k];
758
+ Y[k] = F[2 * k + 1];
759
+ if (k > 0) S[k] = S[k - 1] + Math.hypot(X[k] - X[k - 1], Y[k] - Y[k - 1]);
760
+ }
761
+ const NX = new Float64Array(m), NY = new Float64Array(m);
762
+ for (let k = 0; k < m; k++) {
763
+ const a = Math.max(0, k - 2), b = Math.min(m - 1, k + 2);
764
+ let tx = X[b] - X[a], ty = Y[b] - Y[a];
765
+ const tl = Math.hypot(tx, ty) || 1;
766
+ NX[k] = -ty / tl;
767
+ NY[k] = tx / tl;
768
+ }
769
+ return { X, Y, S, NX, NY, m, loopLen };
770
+ }
771
+
772
+ /**
773
+ * inkPath(ctx, points, opts) : a hand-inked line or closed shape.
774
+ * closed false
775
+ * width 3 nominal pen width (art bible: hero 5, secondary 3, detail 1.8)
776
+ * color pal.ink
777
+ * alpha 1
778
+ * seed 1 give each drawn object its own seed so their wobbles differ
779
+ * smooth true Catmull-Rom through the points (false = straight segments)
780
+ * step 2.5 resample spacing in px
781
+ * wobble 2 low-frequency drift amplitude (px)
782
+ * wobbleFreq 1/150 drift frequency (cycles per px)
783
+ * tremble 0.4 high-frequency hand tremble (px)
784
+ * rough 0.22+0.07*width ragged ink edge (px), each side independent
785
+ * boil auto drawing index (default lib.boil(lib.T)); false freezes the line
786
+ * boilAmp 0.7 how far the line moves between boil drawings (px)
787
+ * taper [18,34] px of taper at start and end (number = both)
788
+ * minWidth 0.14 width fraction at the very tips
789
+ * swell 0.2 extra width through the middle of an open stroke
790
+ * widthJitter 0.34 pressure variation (slow plus a faster drag)
791
+ * pressure null fn(u 0..1) => width multiplier
792
+ * overlap 14 closed shapes: how far the pen runs past its start (px)
793
+ * fill null closed shapes: fill colour under the line (uses the wobbled outline)
794
+ * fillAlpha 1
795
+ * draw 1 draw-on progress 0..1 along the stroke (0 draws nothing; a fill waits for 1)
796
+ * double false true or { offset, width, alpha, from, to, seed }: a second quick retrace
797
+ * (defaults: 30 percent of the width, min 1.5 px at 5 px, 3 px clear of the line, alpha 0.4)
798
+ */
799
+ function inkPath(ctx, pts, o = {}) {
800
+ if (!pts || pts.length < 2) return;
801
+ const drawP = o.draw == null ? 1 : clamp(o.draw);
802
+ if (drawP <= 0) return;
803
+ const closed = !!o.closed;
804
+ const seed = seedInt(o.seed === undefined ? 1 : o.seed);
805
+ const width = o.width != null ? o.width : 3;
806
+ const step = o.step || 2.5;
807
+ const smooth = o.smooth !== false;
808
+ const taper = o.taper != null ? o.taper : closed ? [10, 22] : [18, 34];
809
+ const tIn = Array.isArray(taper) ? taper[0] : taper;
810
+ const tOut = Array.isArray(taper) ? taper[1] : taper;
811
+ const overlap = closed ? (o.overlap != null ? o.overlap : 14) : 0;
812
+ const C = centreline(pts, closed, step, smooth, closed ? h3(seed, 11, 3) : 0, overlap);
813
+ if (C.m < 2) return;
814
+ const b = boilIndex(o);
815
+ const q = {
816
+ seed,
817
+ width,
818
+ wobble: o.wobble != null ? o.wobble : 2,
819
+ wobbleFreq: o.wobbleFreq || 1 / 150,
820
+ tremble: o.tremble != null ? o.tremble : 0.4,
821
+ rough: o.rough != null ? o.rough : 0.22 + width * 0.07,
822
+ boilAmp: o.boil === false ? 0 : o.boilAmp != null ? o.boilAmp : 0.7,
823
+ boilSeed: (hash(seed, b) | 0) & 0x7fffffff,
824
+ taperIn: tIn,
825
+ taperOut: tOut,
826
+ minW: o.minWidth != null ? o.minWidth : 0.14,
827
+ swell: closed ? 0 : o.swell != null ? o.swell : 0.2,
828
+ widthJitter: o.widthJitter != null ? o.widthJitter : 0.34,
829
+ pressure: o.pressure || null,
830
+ offset: 0,
831
+ phase: 0,
832
+ closeBlend: closed ? Math.min(60, C.loopLen * 0.25) + overlap : 0,
833
+ loopLen: C.loopLen,
834
+ };
835
+ let iEnd = C.m - 1;
836
+ if (drawP < 1) {
837
+ // stop the pen partway along the centreline; a partial closed shape gets no fill and no
838
+ // close-blend, and its tip is blunt rather than tapered to nothing
839
+ const Lp = C.S[C.m - 1] * drawP;
840
+ while (iEnd > 1 && C.S[iEnd] > Lp) iEnd--;
841
+ q.closeBlend = 0;
842
+ q.taperOut = Math.min(q.taperOut, 6);
843
+ }
844
+ ctx.save();
845
+ const color = o.color || pal.ink;
846
+ const alpha = o.alpha != null ? o.alpha : 1;
847
+ if (closed && o.fill && drawP >= 1) {
848
+ // fill follows the wobbled outline (without the overlap run)
849
+ const F = ribbonLine(C, q, 0, Math.max(1, C.m - 1));
850
+ ctx.beginPath();
851
+ for (let k = 0; k < F.n; k++) {
852
+ if (C.S[k] > C.loopLen) break;
853
+ if (k === 0) ctx.moveTo(F.DX[k], F.DY[k]);
854
+ else ctx.lineTo(F.DX[k], F.DY[k]);
855
+ }
856
+ ctx.closePath();
857
+ ctx.globalAlpha *= o.fillAlpha != null ? o.fillAlpha : 1;
858
+ ctx.fillStyle = o.fill;
859
+ ctx.fill();
860
+ ctx.restore();
861
+ ctx.save();
862
+ }
863
+ ctx.globalAlpha *= alpha;
864
+ ctx.fillStyle = color;
865
+ ribbon(ctx, C.X, C.Y, C.NX, C.NY, C.S, 0, iEnd, q);
866
+ if (o.double && drawP >= 1) {
867
+ const d = o.double === true ? {} : o.double;
868
+ const ds = seedInt(d.seed != null ? d.seed : seed + 977);
869
+ const r = rng(ds);
870
+ const L = C.S[C.m - 1];
871
+ let f0 = d.from != null ? d.from : closed ? r.range(0, 0.35) : r.range(0.03, 0.18);
872
+ let f1 = d.to != null ? d.to : closed ? f0 + r.range(0.45, 0.75) : r.range(0.72, 0.95);
873
+ f1 = Math.min(1, f1);
874
+ let i0 = 0, i1 = C.m - 1;
875
+ while (i0 < C.m - 1 && C.S[i0] < f0 * L) i0++;
876
+ while (i1 > i0 && C.S[i1] > f1 * L) i1--;
877
+ const q2 = Object.assign({}, q, {
878
+ seed: ds,
879
+ width: d.width != null ? width * d.width : Math.max(1.2, width * 0.3),
880
+ rough: 0.15 + (d.width != null ? width * d.width : Math.max(1.2, width * 0.3)) * 0.07,
881
+ offset: d.offset != null ? d.offset : (width / 2 + 3) * (r() < 0.5 ? -1 : 1),
882
+ wobble: q.wobble * 1.3,
883
+ boilSeed: (hash(ds, b) | 0) & 0x7fffffff,
884
+ taperIn: Math.max(tIn, 26),
885
+ taperOut: Math.max(tOut, 40),
886
+ closeBlend: 0,
887
+ swell: 0.35,
888
+ });
889
+ ctx.globalAlpha *= d.alpha != null ? d.alpha : 0.4;
890
+ ribbon(ctx, C.X, C.Y, C.NX, C.NY, C.S, i0, i1, q2);
891
+ }
892
+ ctx.restore();
893
+ }
894
+
895
+ // displaced centreline only (for fills)
896
+ function ribbonLine(C, q, i0, i1) {
897
+ const n = i1 - i0 + 1;
898
+ const DX = new Float64Array(n), DY = new Float64Array(n);
899
+ const wf = q.wobbleFreq;
900
+ for (let k = 0; k < n; k++) {
901
+ const i = i0 + k;
902
+ const s = C.S[i];
903
+ const d =
904
+ q.wobble * (0.72 * noise1(s * wf, q.seed) + 0.28 * noise1(s * wf * 3.3, q.seed + 1)) +
905
+ q.boilAmp * noise1(s * wf * 2.2 + 0.37, q.boilSeed);
906
+ DX[k] = C.X[i] + C.NX[i] * d;
907
+ DY[k] = C.Y[i] + C.NY[i] * d;
908
+ }
909
+ return { DX, DY, n };
910
+ }
911
+
912
+ lib.inkPath = inkPath;
913
+
914
+ // ===========================================================================
915
+ // Photo-doodle mode: watercolour wash, photo cut-outs, handwriting
916
+ // Inert in a zero-asset film — nothing below is called unless a scene calls it.
917
+ // ===========================================================================
918
+
919
+ /**
920
+ * wash(ctx, pts, opts) : a translucent watercolour fill that deliberately misses its outline.
921
+ * color pal.washYellow or pal.sunYellow alpha 0.55 seed 1
922
+ * offset [4, 3] px shift off the ink outline (the mis-registration is the look)
923
+ * spread 3 px of edge wobble p 1 fade-in 0..1 (the wash arrives after its outline)
924
+ * edge 0.35 darker pigment rim strength (0 = none)
925
+ */
926
+ lib.wash = (ctx, pts, o = {}) => {
927
+ const p = o.p == null ? 1 : clamp(o.p);
928
+ if (p <= 0 || !pts || pts.length < 3) return;
929
+ const seed = seedInt(o.seed == null ? 1 : o.seed);
930
+ const off = o.offset || [4, 3];
931
+ const spread = o.spread != null ? o.spread : 3;
932
+ const color = o.color || pal.washYellow || pal.sunYellow || pal.ink;
933
+ const sm = lib.smoothPts(pts, true, 5);
934
+ const n = sm.length;
935
+ let cx = 0, cy = 0;
936
+ for (const q of sm) { cx += q[0]; cy += q[1]; }
937
+ cx /= n; cy /= n;
938
+ const ring = (amp, sd) => {
939
+ ctx.beginPath();
940
+ for (let i = 0; i < n; i++) {
941
+ const [x, y] = sm[i];
942
+ const dx = x - cx, dy = y - cy, dl = Math.hypot(dx, dy) || 1;
943
+ const d = amp * noise1(i * 0.09, sd) + amp * 0.4 * noise1(i * 0.31, sd + 7);
944
+ const X = x + off[0] + (dx / dl) * d, Y = y + off[1] + (dy / dl) * d;
945
+ if (i === 0) ctx.moveTo(X, Y); else ctx.lineTo(X, Y);
946
+ }
947
+ ctx.closePath();
948
+ };
949
+ ctx.save();
950
+ ctx.globalAlpha *= (o.alpha != null ? o.alpha : 0.55) * p;
951
+ ctx.fillStyle = color;
952
+ ring(spread, seed);
953
+ ctx.fill();
954
+ const edge = o.edge != null ? o.edge : 0.35;
955
+ if (edge > 0) {
956
+ ctx.globalAlpha *= edge;
957
+ ctx.strokeStyle = color;
958
+ ctx.lineWidth = 2.2;
959
+ ctx.lineJoin = 'round';
960
+ ring(spread * 1.2, seed + 3);
961
+ ctx.stroke();
962
+ }
963
+ ctx.restore();
964
+ };
965
+
966
+ /**
967
+ * photo(ctx, id, cx, baseY, opts) : a background-removed photograph standing on the paper, with a
968
+ * soft contact shadow. Positioned by bottom-centre, so objects sit on a floor line.
969
+ * h (height px, default 620) or w rot 0 (radians, about the base centre) scale 1 (pop-in)
970
+ * shadow 0.38 (contact shadow alpha, 0 = none) alpha 1
971
+ * Returns { x, y, w, h } of the unscaled, unrotated placement — anchor doodles off this rather
972
+ * than off guessed pixels. Returns null when the id is not in FILM.PHOTOS.
973
+ *
974
+ * One caution the gate will catch late: drawing the SAME photo at two different sizes in one page
975
+ * can resample differently once the browser has a texture history for it, which reads as
976
+ * non-determinism. If a film needs a photo at two sizes (an end-card grid of every shot), draw the
977
+ * small one through an offscreen canvas at 1:1 instead of scaling it live.
978
+ */
979
+ lib.photo = (ctx, id, cx, baseY, o = {}) => {
980
+ const F = typeof window !== 'undefined' ? window.FILM : globalThis.FILM;
981
+ const img = F && F.photo ? F.photo(id) : null;
982
+ const meta = F && F.PHOTOS ? F.PHOTOS[id] : null;
983
+ if (!img || !meta) return null;
984
+ const h = o.w != null ? (o.w * meta.h) / meta.w : o.h != null ? o.h : 620;
985
+ const w = (h * meta.w) / meta.h;
986
+ const box = { x: cx - w / 2, y: baseY - h, w, h };
987
+ const sc = o.scale != null ? o.scale : 1;
988
+ if (sc <= 0) return box;
989
+ ctx.save();
990
+ ctx.globalAlpha *= o.alpha != null ? o.alpha : 1;
991
+ const sh = o.shadow != null ? o.shadow : 0.38;
992
+ if (sh > 0) {
993
+ const g = ctx.createRadialGradient(cx, baseY, 0, cx, baseY, w * 0.62 * sc);
994
+ g.addColorStop(0, `rgba(48,36,24,${sh})`);
995
+ g.addColorStop(0.55, `rgba(48,36,24,${sh * 0.42})`);
996
+ g.addColorStop(1, 'rgba(48,36,24,0)');
997
+ ctx.save();
998
+ ctx.translate(cx, baseY);
999
+ ctx.scale(1, 0.14);
1000
+ ctx.translate(-cx, -baseY);
1001
+ ctx.fillStyle = g;
1002
+ ctx.fillRect(cx - w, baseY - w, w * 2, w * 2);
1003
+ ctx.restore();
1004
+ }
1005
+ ctx.translate(cx, baseY);
1006
+ if (o.rot) ctx.rotate(o.rot);
1007
+ ctx.scale(sc, sc);
1008
+ ctx.imageSmoothingQuality = 'high';
1009
+ ctx.drawImage(img, -w / 2, -h, w, h);
1010
+ ctx.restore();
1011
+ return box;
1012
+ };
1013
+
1014
+ /**
1015
+ * hand(ctx, str, x, y, opts) : handwritten marker lettering in a system hand face (no font files),
1016
+ * revealed letter by letter with p. Weights above 700 fall out of the hand faces onto a geometric
1017
+ * fallback, so they are clamped.
1018
+ */
1019
+ lib.hand = (ctx, str, x, y, o = {}) => {
1020
+ const q = Object.assign({ size: 46, color: pal.ink }, o);
1021
+ q.family = HAND_STACK;
1022
+ q.weight = Math.min(700, Number(q.weight) || 400);
1023
+ return lib.text(ctx, str, x, y, q);
1024
+ };
1025
+ lib.inkLine = (ctx, x1, y1, x2, y2, o = {}) => inkPath(ctx, [[x1, y1], [x2, y2]], Object.assign({ smooth: false }, o));
1026
+ lib.inkCircle = (ctx, cx, cy, r, o = {}) =>
1027
+ inkPath(ctx, lib.ellipsePts(cx, cy, r, o.ry != null ? o.ry : r, Math.max(24, Math.ceil(r * 0.6)), o.rot || 0), Object.assign({ closed: true }, o));
1028
+ lib.inkLine = (ctx, x1, y1, x2, y2, o = {}) => inkPath(ctx, [[x1, y1], [x2, y2]], Object.assign({ smooth: false }, o));
1029
+ lib.inkCircle = (ctx, cx, cy, r, o = {}) =>
1030
+ inkPath(ctx, lib.ellipsePts(cx, cy, r, o.ry != null ? o.ry : r, Math.max(24, Math.ceil(r * 0.6)), o.rot || 0), Object.assign({ closed: true }, o));
1031
+
1032
+ // ===========================================================================
1033
+ // Hatching
1034
+ // ===========================================================================
1035
+
1036
+ /**
1037
+ * hatch(ctx, clip, opts) : parallel pen strokes that build tone inside a shape.
1038
+ * angle -PI/4 stroke direction (radians): 45 degrees rising left to right
1039
+ * spacing 8 px between rows at full density (art bible: 12 light, 8 mid, 5 dark)
1040
+ * width 1.4 pen width (art bible: 1.2 to 1.8)
1041
+ * color pal.ink
1042
+ * alpha 0.9
1043
+ * density 1 0..1, or fn(x, y) => 0..1. Rows drop out evenly as density falls and
1044
+ * stroke ends stagger along the tone edge, like a hand building shade.
1045
+ * length [16,64] stroke length range (px)
1046
+ * gap [2,7] px between strokes along a row
1047
+ * inset 6 polygons: how far a stroke may stop short of the edge
1048
+ * overshoot 3 polygons: how far a stroke may cross the edge
1049
+ * angleJitter 0.052 per stroke (+-3 degrees)
1050
+ * spacingJitter 0.3 fraction of spacing (+-15 percent)
1051
+ * flow 0.05 slow angle drift across the rows (radians)
1052
+ * bow 0.7 random sideways bow per stroke (px)
1053
+ * bend 0 consistent bow (px), follows a rounded form
1054
+ * taper 0.3 width at the flick end (fraction)
1055
+ * edge 0.12 how ragged the tone edge is (threshold noise)
1056
+ * boilAmp 0.45 endpoint shimmer per boil drawing (px)
1057
+ * clip false polygons: also hard-clip to the polygon
1058
+ * bounds frame area to fill when clip is a function, Path2D or null
1059
+ * seed 7
1060
+ */
1061
+ const PHI = 0.6180339887498949;
1062
+ function hatch(ctx, clip, o = {}) {
1063
+ const shape = shapeOf(clip, o);
1064
+ const seed = seedInt(o.seed === undefined ? 7 : o.seed);
1065
+ const r = rng(seed);
1066
+ const angle = o.angle != null ? o.angle : -Math.PI / 4;
1067
+ const spacing = Math.max(0.8, o.spacing || 8);
1068
+ const width = o.width != null ? o.width : 1.4;
1069
+ const density = o.density != null ? o.density : 1;
1070
+ const densFn = typeof density === 'function' ? density : null;
1071
+ const len = o.length || [16, 64];
1072
+ const gap = o.gap || [2, 7];
1073
+ const inset = o.inset != null ? o.inset : 6;
1074
+ const over = o.overshoot != null ? o.overshoot : 3;
1075
+ const aJ = o.angleJitter != null ? o.angleJitter : 0.052;
1076
+ const sJ = o.spacingJitter != null ? o.spacingJitter : 0.3;
1077
+ const flow = o.flow != null ? o.flow : 0.05;
1078
+ const bow = o.bow != null ? o.bow : 0.7;
1079
+ const bend = o.bend || 0;
1080
+ const taper = o.taper != null ? o.taper : 0.3;
1081
+ const edgeN = o.edge != null ? o.edge : 0.12;
1082
+ const boilAmp = o.boil === false ? 0 : o.boilAmp != null ? o.boilAmp : 0.45;
1083
+ const bi = boilIndex(o);
1084
+ const minLen = Math.max(2, Math.min(len[0] * 0.35, 6));
1085
+ const probe = Math.max(3, Math.min(8, len[0] * 0.4));
1086
+ const phase = h3(seed, 3, 9);
1087
+
1088
+ const dx = Math.cos(angle), dy = Math.sin(angle);
1089
+ const nx = -dy, ny = dx;
1090
+ const B = shape.bounds;
1091
+ const corners = [[B.x, B.y], [B.x + B.w, B.y], [B.x, B.y + B.h], [B.x + B.w, B.y + B.h]];
1092
+ let umin = Infinity, umax = -Infinity, vmin = Infinity, vmax = -Infinity;
1093
+ for (const c of corners) {
1094
+ const u = c[0] * dx + c[1] * dy, v = c[0] * nx + c[1] * ny;
1095
+ if (u < umin) umin = u;
1096
+ if (u > umax) umax = u;
1097
+ if (v < vmin) vmin = v;
1098
+ if (v > vmax) vmax = v;
1099
+ }
1100
+
1101
+ let edges = null;
1102
+ if (shape.polys) {
1103
+ edges = [];
1104
+ for (const poly of shape.polys) {
1105
+ for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) {
1106
+ const a = poly[j], b = poly[i];
1107
+ edges.push(a[0] * dx + a[1] * dy, a[0] * nx + a[1] * ny, b[0] * dx + b[1] * dy, b[0] * nx + b[1] * ny);
1108
+ }
1109
+ }
1110
+ }
1111
+
1112
+ const paths = [new Path2D(), new Path2D(), new Path2D()];
1113
+ const spans = [];
1114
+ const on = [];
1115
+ const xs = [];
1116
+ let strokeId = 0;
1117
+
1118
+ const emit = (u, ue, v, row, dAt) => {
1119
+ const sl = ue - u;
1120
+ const um = (u + ue) / 2;
1121
+ const mx = um * dx + v * nx, my = um * dy + v * ny;
1122
+ const ja = aJ * (r() * 2 - 1) + (flow ? flow * noise1(row * 0.09, seed + 5) : 0);
1123
+ const bw = bend + bow * (r() * 2 - 1);
1124
+ const wv = width * lerp(0.78, 1.18, r()) * lerp(0.72, 1, dAt);
1125
+ const p = paths[(r() * 3) | 0];
1126
+ const sid = strokeId++;
1127
+ const ca = Math.cos(angle + ja), sa = Math.sin(angle + ja);
1128
+ const half = sl / 2;
1129
+ const jb0 = boilAmp ? (h3(sid, row, bi + seed) - 0.5) * 2 * boilAmp : 0;
1130
+ const jb1 = boilAmp ? (h3(row, sid, bi + seed + 9) - 0.5) * 2 * boilAmp : 0;
1131
+ const pnx = -sa, pny = ca;
1132
+ const x0 = mx - ca * half + pnx * jb0, y0 = my - sa * half + pny * jb0;
1133
+ const x1 = mx + ca * (half + jb1 * 0.6) + pnx * jb1, y1 = my + sa * (half + jb1 * 0.6) + pny * jb1;
1134
+ const w0 = wv * 0.42, w1 = wv * taper * 0.5, wm = wv * 0.5;
1135
+ const k = (w0 + w1) * 0.5;
1136
+ const cx = mx + pnx * bw * 2, cy = my + pny * bw * 2;
1137
+ p.moveTo(x0 + pnx * w0, y0 + pny * w0);
1138
+ p.quadraticCurveTo(cx + pnx * (wm * 2 - k), cy + pny * (wm * 2 - k), x1 + pnx * w1, y1 + pny * w1);
1139
+ p.lineTo(x1 - pnx * w1, y1 - pny * w1);
1140
+ p.quadraticCurveTo(cx - pnx * (wm * 2 - k), cy - pny * (wm * 2 - k), x0 - pnx * w0, y0 - pny * w0);
1141
+ p.closePath();
1142
+ };
1143
+
1144
+ const breakUp = (a, b, v, row, dAt) => {
1145
+ let u = a;
1146
+ while (u < b - minLen) {
1147
+ let ue = Math.min(u + lerp(len[0], len[1], r()), b);
1148
+ if (b - ue < minLen) ue = b;
1149
+ if (ue - u >= minLen) emit(u, ue, v, row, dAt);
1150
+ u = ue + lerp(gap[0], gap[1], r());
1151
+ }
1152
+ };
1153
+
1154
+ let row = 0;
1155
+ for (let v0 = vmin + spacing * r(); v0 <= vmax; v0 += spacing, row++) {
1156
+ const v = v0 + (r() - 0.5) * spacing * sJ;
1157
+ // evenly distributed per-row threshold: rows vanish uniformly as density falls
1158
+ const rowTh = ((row * PHI + phase) % 1) * 0.94 + 0.03;
1159
+ spans.length = 0;
1160
+ if (edges) {
1161
+ xs.length = 0;
1162
+ for (let e = 0; e < edges.length; e += 4) {
1163
+ const va = edges[e + 1], vb = edges[e + 3];
1164
+ if ((va > v) !== (vb > v)) xs.push(edges[e] + ((v - va) / (vb - va)) * (edges[e + 2] - edges[e]));
1165
+ }
1166
+ if (xs.length < 2) continue;
1167
+ xs.sort((p, q) => p - q);
1168
+ for (let k = 0; k + 1 < xs.length; k += 2) spans.push(xs[k], xs[k + 1]);
1169
+ } else {
1170
+ spans.push(umin, umax);
1171
+ }
1172
+ if (!densFn && density < rowTh) continue;
1173
+ const dConst = densFn ? 1 : clamp(density);
1174
+ for (let sp = 0; sp < spans.length; sp += 2) {
1175
+ let u0 = spans[sp], u1 = spans[sp + 1];
1176
+ if (edges) {
1177
+ u0 += lerp(-over, inset, r() * r());
1178
+ u1 -= lerp(-over, inset, r() * r());
1179
+ } else {
1180
+ u0 -= r() * len[1];
1181
+ }
1182
+ if (u1 - u0 < minLen) continue;
1183
+ if (!densFn) {
1184
+ breakUp(u0, u1, v, row, dConst);
1185
+ continue;
1186
+ }
1187
+ // walk the row; strokes live where density beats the (slightly noisy) row threshold
1188
+ on.length = 0;
1189
+ let start = null;
1190
+ let dSum = 0, dN = 0;
1191
+ for (let u = u0; ; u += probe) {
1192
+ const uu = Math.min(u, u1);
1193
+ const d = clamp(densFn(uu * dx + v * nx, uu * dy + v * ny));
1194
+ const th = rowTh + edgeN * noise1(uu * 0.02 + row * 7.31, seed + 11);
1195
+ if (d > th) {
1196
+ if (start === null) start = uu;
1197
+ dSum += d;
1198
+ dN++;
1199
+ } else if (start !== null) {
1200
+ on.push(start, uu, dSum / dN);
1201
+ start = null;
1202
+ dSum = dN = 0;
1203
+ }
1204
+ if (uu >= u1) break;
1205
+ }
1206
+ if (start !== null) on.push(start, u1, dSum / Math.max(1, dN));
1207
+ for (let k = 0; k < on.length; k += 3) {
1208
+ // soften where the tone edge cuts a stroke
1209
+ const a = on[k] === u0 ? on[k] : on[k] + (r() - 0.5) * probe;
1210
+ const b = on[k + 1] === u1 ? on[k + 1] : on[k + 1] + (r() - 0.5) * probe;
1211
+ breakUp(a, b, v, row, on[k + 2]);
1212
+ }
1213
+ }
1214
+ }
1215
+
1216
+ ctx.save();
1217
+ if (shape.apply && (!shape.polys || o.clip)) shape.apply(ctx);
1218
+ ctx.fillStyle = o.color || pal.ink;
1219
+ const alpha = o.alpha != null ? o.alpha : 0.9;
1220
+ const A = [0.74, 0.88, 1];
1221
+ const base = ctx.globalAlpha;
1222
+ for (let k = 0; k < 3; k++) {
1223
+ ctx.globalAlpha = base * alpha * A[k];
1224
+ ctx.fill(paths[k]);
1225
+ }
1226
+ ctx.restore();
1227
+ }
1228
+ lib.hatch = hatch;
1229
+
1230
+ /**
1231
+ * crossHatch(ctx, clip, opts) : layered hatching where each extra layer only covers darker tone.
1232
+ * tone 1 0..1 overall darkness (with no density, 0.25 = one layer, 1 = four)
1233
+ * layers 2 (4 when tone is given) maximum layer count
1234
+ * density 1 or fn(x,y) => 0..1: local darkness; layer i appears where density*tone*layers > i
1235
+ * angle -PI/4 first layer angle (45 degrees); later layers turn by opts.turn
1236
+ * turn [-PI/3, 0.3, -1.35] offsets for layers 2..4: layer 2 at 105 degrees, 3 and 4 thicken 1 and 2
1237
+ * crossSpacing spacing*1.4 spacing of layers 2..4 (art bible: 5 px base, 7 px cross)
1238
+ * ...all hatch options
1239
+ */
1240
+ function crossHatch(ctx, clip, o = {}) {
1241
+ const layers = o.layers || (o.tone != null ? 4 : 2);
1242
+ const tone = o.tone != null ? clamp(o.tone) : 1;
1243
+ const d = o.density != null ? o.density : 1;
1244
+ const base = o.angle != null ? o.angle : -Math.PI / 4;
1245
+ const turn = o.turn || [-Math.PI / 3, 0.3, -1.35];
1246
+ const seed = seedInt(o.seed === undefined ? 11 : o.seed);
1247
+ for (let i = 0; i < layers; i++) {
1248
+ let dens;
1249
+ if (typeof d === 'function') {
1250
+ dens = (x, y) => clamp(d(x, y) * tone * layers - i);
1251
+ } else {
1252
+ dens = clamp(d * tone * layers - i);
1253
+ if (dens <= 0) break;
1254
+ }
1255
+ hatch(
1256
+ ctx,
1257
+ clip,
1258
+ Object.assign({}, o, {
1259
+ angle: base + (i === 0 ? 0 : turn[(i - 1) % turn.length]),
1260
+ seed: seed + i * 7919,
1261
+ density: dens,
1262
+ spacing: i === 0 ? o.spacing || 8 : o.crossSpacing || (o.spacing || 8) * 1.4,
1263
+ })
1264
+ );
1265
+ }
1266
+ }
1267
+ lib.crossHatch = crossHatch;
1268
+
1269
+ // ===========================================================================
1270
+ // Stipple
1271
+ // ===========================================================================
1272
+
1273
+ /**
1274
+ * stipple(ctx, clip, opts) : seeded dots on a jittered hex grid.
1275
+ * spacing 7.5 mean px between dots at density 1 (about 0.02 dots per px2)
1276
+ * r [1.0, 2.2] dot radius range (bigger where density is higher)
1277
+ * density 1 or fn(x,y) => 0..1
1278
+ * jitter 0.45 fraction of spacing
1279
+ * color pal.ink
1280
+ * alpha 0.9
1281
+ * boilAmp 0.35 px shimmer per boil drawing
1282
+ * clip true polygons: skip dots outside; functions/Path2D always hard-clip
1283
+ * seed 13
1284
+ */
1285
+ function stipple(ctx, clip, o = {}) {
1286
+ const shape = shapeOf(clip, o);
1287
+ const seed = seedInt(o.seed === undefined ? 13 : o.seed);
1288
+ const sp = Math.max(1, o.spacing || 7.5);
1289
+ const rr = o.r || [1.0, 2.2];
1290
+ const density = o.density != null ? o.density : 1;
1291
+ const densFn = typeof density === 'function' ? density : null;
1292
+ const jit = (o.jitter != null ? o.jitter : 0.45) * sp;
1293
+ const boilAmp = o.boil === false ? 0 : o.boilAmp != null ? o.boilAmp : 0.35;
1294
+ const bi = boilIndex(o);
1295
+ const B = shape.bounds;
1296
+ const rowH = sp * 0.866;
1297
+ const p = new Path2D();
1298
+ const i0 = Math.floor(B.y / rowH) - 1, i1 = Math.ceil((B.y + B.h) / rowH) + 1;
1299
+ const j0 = Math.floor(B.x / sp) - 1, j1 = Math.ceil((B.x + B.w) / sp) + 1;
1300
+ for (let i = i0; i <= i1; i++) {
1301
+ const off = i & 1 ? sp * 0.5 : 0;
1302
+ for (let j = j0; j <= j1; j++) {
1303
+ const a = h3(i, j, seed);
1304
+ const b = h3(j, i, seed + 1);
1305
+ const c = h3(i + 7, j - 3, seed + 2);
1306
+ let x = j * sp + off + (a - 0.5) * 2 * jit;
1307
+ let y = i * rowH + (b - 0.5) * 2 * jit;
1308
+ if (x < B.x || x > B.x + B.w || y < B.y || y > B.y + B.h) continue;
1309
+ const dAt = densFn ? clamp(densFn(x, y)) : density;
1310
+ if (c >= dAt) continue;
1311
+ if (shape.polys && !polysContain(shape.polys, x, y)) continue;
1312
+ if (boilAmp) {
1313
+ x += (h3(i, j, seed + bi * 31 + 5) - 0.5) * 2 * boilAmp;
1314
+ y += (h3(j, i, seed + bi * 37 + 6) - 0.5) * 2 * boilAmp;
1315
+ }
1316
+ const rad = lerp(rr[0], rr[1], clamp(h3(j, i, seed + 3) * 0.55 + dAt * 0.45));
1317
+ p.moveTo(x + rad, y);
1318
+ p.arc(x, y, rad, 0, TAU);
1319
+ }
1320
+ }
1321
+ ctx.save();
1322
+ if (shape.apply && !shape.polys) shape.apply(ctx);
1323
+ ctx.globalAlpha *= o.alpha != null ? o.alpha : 0.9;
1324
+ ctx.fillStyle = o.color || pal.ink;
1325
+ ctx.fill(p);
1326
+ ctx.restore();
1327
+ }
1328
+ lib.stipple = stipple;
1329
+
1330
+ // ===========================================================================
1331
+ // Backgrounds: paper, blueprint, stripes
1332
+ // ===========================================================================
1333
+
1334
+ function renderScale() {
1335
+ return FILM.S || 1;
1336
+ }
1337
+
1338
+ /**
1339
+ * paper(ctx, opts) : cream paper with mottling, grain and fibres. Cached by size, seed and options.
1340
+ * x, y, w, h 0, 0, 1080, 1920
1341
+ * color pal.paper
1342
+ * seed 3
1343
+ * grain 1 fine grain strength
1344
+ * fibres 1 fibre count multiplier
1345
+ * mottle 1 large soft blotches
1346
+ * vignette 0.35 darkened edges
1347
+ */
1348
+ function paper(ctx, o = {}) {
1349
+ const x = o.x || 0, y = o.y || 0;
1350
+ const w = o.w || W(), h = o.h || H();
1351
+ const S = renderScale();
1352
+ const color = o.color || pal.paper;
1353
+ const seed = seedInt(o.seed === undefined ? 3 : o.seed);
1354
+ const grain = o.grain != null ? o.grain : 1;
1355
+ const fibres = o.fibres != null ? o.fibres : 1;
1356
+ const mottle = o.mottle != null ? o.mottle : 1;
1357
+ const vignette = o.vignette != null ? o.vignette : 0.35;
1358
+ const key = ['paper', w, h, S, color, seed, grain, fibres, mottle, vignette].join('|');
1359
+ const c = cached(key, () => makePaper(Math.max(1, Math.round(w * S)), Math.max(1, Math.round(h * S)), S, color, seed, grain, fibres, mottle, vignette));
1360
+ ctx.drawImage(c, x, y, w, h);
1361
+ }
1362
+
1363
+ function makePaper(cw, ch, S, color, seed, grain, fibres, mottle, vignette) {
1364
+ const c = newCanvas(cw, ch);
1365
+ const g = c.getContext('2d');
1366
+ const [br, bg, bb] = parseColor(color);
1367
+ // mottling from a low-resolution noise field, upscaled smooth
1368
+ const mw = Math.max(4, Math.ceil(cw / 18)), mh = Math.max(4, Math.ceil(ch / 18));
1369
+ const mf = new Float32Array(mw * mh);
1370
+ for (let j = 0; j < mh; j++) {
1371
+ for (let i = 0; i < mw; i++) {
1372
+ mf[j * mw + i] = lib.fbm2(i * 0.11, j * 0.11, seed, 4) * 0.8 + noise2(i * 0.5, j * 0.5, seed + 9) * 0.2;
1373
+ }
1374
+ }
1375
+ const img = g.createImageData(cw, ch);
1376
+ const d = img.data;
1377
+ const fx = (mw - 1) / cw, fy = (mh - 1) / ch;
1378
+ const cxv = cw / 2, cyv = ch / 2;
1379
+ const vr = Math.hypot(cxv, cyv);
1380
+ for (let py = 0; py < ch; py++) {
1381
+ const my = py * fy;
1382
+ const jy = Math.floor(my), ty = my - jy;
1383
+ const jy1 = Math.min(mh - 1, jy + 1);
1384
+ for (let px = 0; px < cw; px++) {
1385
+ const mx = px * fx;
1386
+ const ix = Math.floor(mx), tx = mx - ix;
1387
+ const ix1 = Math.min(mw - 1, ix + 1);
1388
+ const m =
1389
+ lerp(lerp(mf[jy * mw + ix], mf[jy * mw + ix1], tx), lerp(mf[jy1 * mw + ix], mf[jy1 * mw + ix1], tx), ty);
1390
+ const n = h3(px, py, seed + 77);
1391
+ const n2 = h3(px >> 1, py >> 1, seed + 78);
1392
+ const gr = ((n - 0.5) * 0.6 + (n2 - 0.5) * 0.4) * 0.07 * grain;
1393
+ const dxv = (px - cxv) / vr, dyv = (py - cyv) / vr;
1394
+ const vig = vignette * Math.max(0, dxv * dxv + dyv * dyv - 0.35) * 0.18;
1395
+ const k = 1 + m * 0.055 * mottle + gr - vig;
1396
+ const i = (py * cw + px) * 4;
1397
+ // darker areas go slightly warmer, like aged paper
1398
+ d[i] = br * k;
1399
+ d[i + 1] = bg * (k - (1 - k) * 0.12);
1400
+ d[i + 2] = bb * (k - (1 - k) * 0.35);
1401
+ d[i + 3] = 255;
1402
+ }
1403
+ }
1404
+ g.putImageData(img, 0, 0);
1405
+ // fibres
1406
+ const r = rng(seed + 5);
1407
+ const count = Math.round(((cw * ch) / (S * S)) / 1500 * fibres);
1408
+ const dark = new Path2D(), light = new Path2D();
1409
+ for (let i = 0; i < count; i++) {
1410
+ const x = r() * cw, y = r() * ch;
1411
+ const L = r.range(5, 26) * S * (r() < 0.08 ? 2.5 : 1);
1412
+ const a = r() * TAU;
1413
+ const bend = r.range(-0.5, 0.5) * L;
1414
+ const p = r() < 0.55 ? dark : light;
1415
+ const ex = x + Math.cos(a) * L, ey = y + Math.sin(a) * L;
1416
+ p.moveTo(x, y);
1417
+ p.quadraticCurveTo((x + ex) / 2 - Math.sin(a) * bend, (y + ey) / 2 + Math.cos(a) * bend, ex, ey);
1418
+ }
1419
+ g.lineCap = 'round';
1420
+ g.lineWidth = 0.7 * S;
1421
+ g.strokeStyle = lib.rgba(pal.inkSoft, 0.07);
1422
+ g.stroke(dark);
1423
+ g.lineWidth = 1.1 * S;
1424
+ g.strokeStyle = 'rgba(255,252,242,0.22)';
1425
+ g.stroke(light);
1426
+ // specks and faint foxing spots
1427
+ const specks = new Path2D();
1428
+ for (let i = 0; i < count * 0.08; i++) {
1429
+ const x = r() * cw, y = r() * ch, rad = r.range(0.3, 1.1) * S;
1430
+ specks.moveTo(x + rad, y);
1431
+ specks.arc(x, y, rad, 0, TAU);
1432
+ }
1433
+ g.fillStyle = lib.rgba(pal.inkSoft, 0.22);
1434
+ g.fill(specks);
1435
+ for (let i = 0; i < 6 * mottle; i++) {
1436
+ const x = r() * cw, y = r() * ch, rad = r.range(30, 120) * S;
1437
+ const gr = g.createRadialGradient(x, y, 0, x, y, rad);
1438
+ gr.addColorStop(0, lib.rgba(pal.paperDeep, 0.07));
1439
+ gr.addColorStop(1, lib.rgba(pal.paperDeep, 0));
1440
+ g.fillStyle = gr;
1441
+ g.fillRect(x - rad, y - rad, rad * 2, rad * 2);
1442
+ }
1443
+ return c;
1444
+ }
1445
+ lib.paper = paper;
1446
+
1447
+ /**
1448
+ * blueprint(ctx, opts) : navy plate with faint grid, big guide circles, long diagonals and noise.
1449
+ * Cached by size, seed and options.
1450
+ * x, y, w, h 0, 0, 1080, 1920
1451
+ * color pal.navy
1452
+ * seed 5
1453
+ * grid 60 grid pitch (px); 0 turns the grid off
1454
+ * major 5 every Nth grid line is stronger
1455
+ * center [0.5w, 0.44h] centre of the guide circles
1456
+ * circles 4
1457
+ * diagonals 5
1458
+ * noise 1
1459
+ * marks true corner registration marks
1460
+ */
1461
+ function blueprint(ctx, o = {}) {
1462
+ const x = o.x || 0, y = o.y || 0;
1463
+ const w = o.w || W(), h = o.h || H();
1464
+ const S = renderScale();
1465
+ const opt = {
1466
+ color: o.color || pal.navy,
1467
+ seed: seedInt(o.seed === undefined ? 5 : o.seed),
1468
+ grid: o.grid != null ? o.grid : 60,
1469
+ major: o.major || 5,
1470
+ center: o.center || [w * 0.5, h * 0.44],
1471
+ circles: o.circles != null ? o.circles : 4,
1472
+ diagonals: o.diagonals != null ? o.diagonals : 5,
1473
+ noise: o.noise != null ? o.noise : 1,
1474
+ marks: o.marks !== false,
1475
+ line: o.line || pal.lavender,
1476
+ };
1477
+ const key = ['blueprint', w, h, S, JSON.stringify(opt)].join('|');
1478
+ const c = cached(key, () => makeBlueprint(w, h, S, opt));
1479
+ ctx.drawImage(c, x, y, w, h);
1480
+ }
1481
+
1482
+ function makeBlueprint(w, h, S, o) {
1483
+ const cw = Math.max(1, Math.round(w * S)), ch = Math.max(1, Math.round(h * S));
1484
+ const c = newCanvas(cw, ch);
1485
+ const g = c.getContext('2d');
1486
+ const [br, bgc, bb] = parseColor(o.color);
1487
+ const [dr, dg, db] = parseColor(pal.navyDeep);
1488
+ const [lr, lg, lb] = parseColor(pal.navyLight);
1489
+ const img = g.createImageData(cw, ch);
1490
+ const d = img.data;
1491
+ const ccx = o.center[0] * S, ccy = o.center[1] * S;
1492
+ const R = Math.hypot(cw, ch) * 0.62;
1493
+ for (let py = 0; py < ch; py++) {
1494
+ for (let px = 0; px < cw; px++) {
1495
+ const rd = Math.min(1, Math.hypot(px - ccx, py - ccy) / R);
1496
+ // light centre falling to deep edges
1497
+ const t = rd * rd;
1498
+ let r = rd < 0.35 ? lerp(lr, br, rd / 0.35) : lerp(br, dr, (t - 0.1225) / 0.8775);
1499
+ let gg = rd < 0.35 ? lerp(lg, bgc, rd / 0.35) : lerp(bgc, dg, (t - 0.1225) / 0.8775);
1500
+ let b = rd < 0.35 ? lerp(lb, bb, rd / 0.35) : lerp(bb, db, (t - 0.1225) / 0.8775);
1501
+ const n = (h3(px, py, o.seed + 3) - 0.5) * 9 * o.noise + (h3(px >> 2, py >> 2, o.seed + 4) - 0.5) * 5 * o.noise;
1502
+ const i = (py * cw + px) * 4;
1503
+ d[i] = r + n * 0.8;
1504
+ d[i + 1] = gg + n * 0.85;
1505
+ d[i + 2] = b + n;
1506
+ d[i + 3] = 255;
1507
+ }
1508
+ }
1509
+ g.putImageData(img, 0, 0);
1510
+ g.scale(S, S);
1511
+ const r = rng(o.seed);
1512
+ const line = o.line;
1513
+ // grid
1514
+ if (o.grid > 0) {
1515
+ const minor = new Path2D(), major = new Path2D();
1516
+ let k = 0;
1517
+ for (let gx = (w / 2) % o.grid; gx <= w; gx += o.grid, k++) {
1518
+ const p = Math.round((gx - w / 2) / o.grid) % o.major === 0 ? major : minor;
1519
+ p.moveTo(gx, 0);
1520
+ p.lineTo(gx, h);
1521
+ }
1522
+ for (let gy = (h / 2) % o.grid; gy <= h; gy += o.grid) {
1523
+ const p = Math.round((gy - h / 2) / o.grid) % o.major === 0 ? major : minor;
1524
+ p.moveTo(0, gy);
1525
+ p.lineTo(w, gy);
1526
+ }
1527
+ g.lineWidth = 1;
1528
+ g.strokeStyle = lib.rgba(pal.grid, 0.3);
1529
+ g.stroke(minor);
1530
+ g.strokeStyle = lib.rgba(pal.grid, 0.5);
1531
+ g.stroke(major);
1532
+ }
1533
+ // long diagonals
1534
+ const [cx, cy] = o.center;
1535
+ for (let i = 0; i < o.diagonals; i++) {
1536
+ const a = r.range(0, Math.PI);
1537
+ const ox = cx + r.range(-0.35, 0.35) * w, oy = cy + r.range(-0.3, 0.3) * h;
1538
+ const L = Math.hypot(w, h);
1539
+ g.beginPath();
1540
+ g.moveTo(ox - Math.cos(a) * L, oy - Math.sin(a) * L);
1541
+ g.lineTo(ox + Math.cos(a) * L, oy + Math.sin(a) * L);
1542
+ g.lineWidth = r.range(0.8, 1.3);
1543
+ g.strokeStyle = lib.rgba(line, r.range(0.07, 0.14));
1544
+ if (r() < 0.35) g.setLineDash([r.range(6, 14), r.range(6, 12)]);
1545
+ else g.setLineDash([]);
1546
+ g.stroke();
1547
+ }
1548
+ g.setLineDash([]);
1549
+ // guide circles
1550
+ const minDim = Math.min(w, h);
1551
+ for (let i = 0; i < o.circles; i++) {
1552
+ const rad = minDim * (0.2 + i * 0.17) * r.range(0.95, 1.05);
1553
+ g.beginPath();
1554
+ g.arc(cx, cy, rad, 0, TAU);
1555
+ g.lineWidth = i === o.circles - 1 ? 1.4 : 1;
1556
+ g.strokeStyle = lib.rgba(line, i % 2 ? 0.1 : 0.16);
1557
+ if (i === 1) g.setLineDash([2, 7]);
1558
+ else g.setLineDash([]);
1559
+ g.stroke();
1560
+ }
1561
+ g.setLineDash([]);
1562
+ if (o.circles > 0) {
1563
+ const rad = minDim * (0.2 + (o.circles - 1) * 0.17);
1564
+ ticksImpl(g, cx, cy, { r: rad, n: 120, len: 7, major: 10, majorLen: 16, color: line, alpha: 0.2, width: 1 });
1565
+ // off-centre satellite circle
1566
+ const a = r.range(0, TAU);
1567
+ const sr = minDim * r.range(0.07, 0.12);
1568
+ g.beginPath();
1569
+ g.arc(cx + Math.cos(a) * rad, cy + Math.sin(a) * rad, sr, 0, TAU);
1570
+ g.lineWidth = 1;
1571
+ g.strokeStyle = lib.rgba(line, 0.14);
1572
+ g.stroke();
1573
+ // centre crosshair
1574
+ g.beginPath();
1575
+ g.moveTo(cx - 18, cy);
1576
+ g.lineTo(cx + 18, cy);
1577
+ g.moveTo(cx, cy - 18);
1578
+ g.lineTo(cx, cy + 18);
1579
+ g.strokeStyle = lib.rgba(line, 0.22);
1580
+ g.stroke();
1581
+ }
1582
+ // corner registration marks
1583
+ if (o.marks) {
1584
+ g.strokeStyle = lib.rgba(line, 0.32);
1585
+ g.lineWidth = 1.2;
1586
+ const m = 34, s = 22;
1587
+ for (const [mx, my, sx, sy] of [[m, m, 1, 1], [w - m, m, -1, 1], [m, h - m, 1, -1], [w - m, h - m, -1, -1]]) {
1588
+ g.beginPath();
1589
+ g.moveTo(mx, my + sy * s);
1590
+ g.lineTo(mx, my);
1591
+ g.lineTo(mx + sx * s, my);
1592
+ g.stroke();
1593
+ }
1594
+ }
1595
+ // faint scattered star specks
1596
+ const sp = new Path2D();
1597
+ for (let i = 0; i < (w * h) / 5000 * o.noise; i++) {
1598
+ const x = r() * w, y = r() * h, rad = r.range(0.4, 1.1);
1599
+ sp.moveTo(x + rad, y);
1600
+ sp.arc(x, y, rad, 0, TAU);
1601
+ }
1602
+ g.fillStyle = lib.rgba(pal.lineWhite, 0.22);
1603
+ g.fill(sp);
1604
+ return c;
1605
+ }
1606
+ lib.blueprint = blueprint;
1607
+
1608
+ /**
1609
+ * stripes(ctx, opts) : the wide diagonal stripe background with softly irregular edges.
1610
+ * colors [pal.stripeCream, pal.stripeYellow]
1611
+ * width 140 band width (px); both colours use it
1612
+ * angle -0.52 stripe direction (radians): 30 degrees rising left to right
1613
+ * offset 0 scroll along the normal (animate this)
1614
+ * wobble 1.4 edge irregularity (px)
1615
+ * bounds frame
1616
+ * seed 21
1617
+ */
1618
+ function stripes(ctx, o = {}) {
1619
+ const B = normBounds(o.bounds);
1620
+ const cols = o.colors || [pal.stripeCream, pal.stripeYellow];
1621
+ const sw = o.width || 140;
1622
+ const angle = o.angle != null ? o.angle : -0.52;
1623
+ const offset = o.offset || 0;
1624
+ const wobble = o.wobble != null ? o.wobble : 1.4;
1625
+ const seed = seedInt(o.seed === undefined ? 21 : o.seed);
1626
+ const dx = Math.cos(angle), dy = Math.sin(angle);
1627
+ const nx = -dy, ny = dx;
1628
+ const cx = B.x + B.w / 2, cy = B.y + B.h / 2;
1629
+ const half = Math.hypot(B.w, B.h) / 2 + sw * 2;
1630
+ ctx.save();
1631
+ ctx.beginPath();
1632
+ ctx.rect(B.x, B.y, B.w, B.h);
1633
+ ctx.clip();
1634
+ ctx.fillStyle = cols[0];
1635
+ ctx.fillRect(B.x, B.y, B.w, B.h);
1636
+ const period = sw * cols.length;
1637
+ const shift = ((offset % period) + period) % period;
1638
+ const edge = (v, k, forward) => {
1639
+ const pts = [];
1640
+ const n = Math.ceil((half * 2) / 40);
1641
+ for (let i = 0; i <= n; i++) {
1642
+ const u = -half + (i / n) * half * 2;
1643
+ const vv = v + wobble * noise1(u * 0.012 + k * 3.7, seed) + wobble * 0.3 * noise1(u * 0.05, seed + k);
1644
+ pts.push([cx + dx * u + nx * vv, cy + dy * u + ny * vv]);
1645
+ }
1646
+ if (!forward) pts.reverse();
1647
+ return pts;
1648
+ };
1649
+ for (let ci = 1; ci < cols.length; ci++) {
1650
+ const p = new Path2D();
1651
+ const first = Math.floor((-half - shift) / period) - 1;
1652
+ const last = Math.ceil((half - shift) / period) + 1;
1653
+ for (let k = first; k <= last; k++) {
1654
+ const v0 = k * period + shift + sw * ci - half * 0;
1655
+ const a = edge(v0, k * 2 + ci, true);
1656
+ const b = edge(v0 + sw, k * 2 + ci + 1, false);
1657
+ a.forEach((pt, i) => (i === 0 ? p.moveTo(pt[0], pt[1]) : p.lineTo(pt[0], pt[1])));
1658
+ b.forEach((pt) => p.lineTo(pt[0], pt[1]));
1659
+ p.closePath();
1660
+ }
1661
+ ctx.fillStyle = cols[ci];
1662
+ ctx.fill(p);
1663
+ }
1664
+ ctx.restore();
1665
+ }
1666
+ lib.stripes = stripes;
1667
+
1668
+ // ===========================================================================
1669
+ // Schematic helpers
1670
+ // ===========================================================================
1671
+
1672
+ /**
1673
+ * hexLattice(ctx, clip, opts) : hexagonal cell lattice with shared, slightly irregular vertices.
1674
+ * r 16 cell circumradius (px)
1675
+ * pointy true pointy-top cells (false = flat-top)
1676
+ * width 1
1677
+ * color pal.lavender
1678
+ * alpha 0.35
1679
+ * jitter 1.0 vertex irregularity (px), shared by neighbouring cells
1680
+ * inset 0 >0 draws each cell as its own hexagon shrunk by this many px
1681
+ * cellFn null fn(cx, cy, i, j) => false (skip) | true | { fill, alpha, stroke }
1682
+ * dots 0 radius of a dot at each cell centre (0 = none)
1683
+ * boilAmp 0.35
1684
+ * clip true hard-clip to the shape
1685
+ * bounds frame when clip is a function, Path2D or null
1686
+ * seed 17
1687
+ */
1688
+ function hexLattice(ctx, clip, o = {}) {
1689
+ const shape = shapeOf(clip, Object.assign({ pad: (o.r || 16) * 2 }, o));
1690
+ const R = o.r || 16;
1691
+ const pointy = o.pointy !== false;
1692
+ const seed = seedInt(o.seed === undefined ? 17 : o.seed);
1693
+ const jitter = o.jitter != null ? o.jitter : 1.0;
1694
+ const boilAmp = o.boil === false ? 0 : o.boilAmp != null ? o.boilAmp : 0.35;
1695
+ const bi = boilIndex(o);
1696
+ const inset = o.inset || 0;
1697
+ const B = shape.bounds;
1698
+ const sq3 = Math.sqrt(3);
1699
+ const colW = pointy ? sq3 * R : 1.5 * R;
1700
+ const rowH = pointy ? 1.5 * R : sq3 * R;
1701
+ const vtx = (x, y) => {
1702
+ const kx = Math.round(x * 4), ky = Math.round(y * 4);
1703
+ const jx = (h3(kx, ky, seed) - 0.5) * 2 * jitter + (boilAmp ? (h3(kx, ky, seed + bi * 13 + 1) - 0.5) * 2 * boilAmp : 0);
1704
+ const jy = (h3(ky, kx, seed + 2) - 0.5) * 2 * jitter + (boilAmp ? (h3(ky, kx, seed + bi * 17 + 3) - 0.5) * 2 * boilAmp : 0);
1705
+ return [x + jx, y + jy];
1706
+ };
1707
+ const edges = new Path2D();
1708
+ const dots = new Path2D();
1709
+ const fills = [];
1710
+ const i0 = Math.floor(B.y / rowH) - 1, i1 = Math.ceil((B.y + B.h) / rowH) + 1;
1711
+ const j0 = Math.floor(B.x / colW) - 1, j1 = Math.ceil((B.x + B.w) / colW) + 1;
1712
+ for (let i = i0; i <= i1; i++) {
1713
+ for (let j = j0; j <= j1; j++) {
1714
+ let cx, cy;
1715
+ if (pointy) {
1716
+ cx = j * colW + (i & 1 ? colW / 2 : 0);
1717
+ cy = i * rowH;
1718
+ } else {
1719
+ cx = j * colW;
1720
+ cy = i * rowH + (j & 1 ? rowH / 2 : 0);
1721
+ }
1722
+ if (shape.polys && !polysContain(shape.polys, cx, cy)) {
1723
+ // keep cells that straddle the edge so the hard clip cuts them cleanly
1724
+ let near = false;
1725
+ for (let k = 0; k < 6 && !near; k++) {
1726
+ const a = (k / 6) * TAU + (pointy ? Math.PI / 6 : 0);
1727
+ near = polysContain(shape.polys, cx + Math.cos(a) * R, cy + Math.sin(a) * R);
1728
+ }
1729
+ if (!near) continue;
1730
+ }
1731
+ let cell = true;
1732
+ if (o.cellFn) {
1733
+ cell = o.cellFn(cx, cy, i, j);
1734
+ if (!cell) continue;
1735
+ }
1736
+ const V = [];
1737
+ for (let k = 0; k < 6; k++) {
1738
+ const a = (k / 6) * TAU + (pointy ? Math.PI / 6 : 0);
1739
+ V.push(vtx(cx + Math.cos(a) * R, cy + Math.sin(a) * R));
1740
+ }
1741
+ if (typeof cell === 'object' && cell.fill) fills.push([V, cell]);
1742
+ if (inset > 0) {
1743
+ for (let k = 0; k < 6; k++) {
1744
+ const v = V[k];
1745
+ const f = Math.max(0, 1 - inset / R);
1746
+ const x = cx + (v[0] - cx) * f, y = cy + (v[1] - cy) * f;
1747
+ if (k === 0) edges.moveTo(x, y);
1748
+ else edges.lineTo(x, y);
1749
+ }
1750
+ edges.closePath();
1751
+ } else {
1752
+ // three edges per cell so shared edges are drawn once
1753
+ const ks = pointy ? [5, 0, 1] : [0, 1, 2];
1754
+ for (const k of ks) {
1755
+ edges.moveTo(V[k][0], V[k][1]);
1756
+ edges.lineTo(V[(k + 1) % 6][0], V[(k + 1) % 6][1]);
1757
+ }
1758
+ // cells on the left/top border of the drawn set need their other edges
1759
+ if (o.cellFn || shape.polys) {
1760
+ for (const k of pointy ? [2, 3, 4] : [3, 4, 5]) {
1761
+ edges.moveTo(V[k][0], V[k][1]);
1762
+ edges.lineTo(V[(k + 1) % 6][0], V[(k + 1) % 6][1]);
1763
+ }
1764
+ }
1765
+ }
1766
+ if (o.dots) {
1767
+ dots.moveTo(cx + o.dots, cy);
1768
+ dots.arc(cx, cy, o.dots, 0, TAU);
1769
+ }
1770
+ }
1771
+ }
1772
+ ctx.save();
1773
+ if (shape.apply && o.clip !== false) shape.apply(ctx);
1774
+ const color = o.color || pal.lavender;
1775
+ const alpha = o.alpha != null ? o.alpha : 0.35;
1776
+ for (const [V, cell] of fills) {
1777
+ ctx.beginPath();
1778
+ lib.tracePath(ctx, V, true);
1779
+ ctx.globalAlpha = cell.alpha != null ? cell.alpha : 0.35;
1780
+ ctx.fillStyle = cell.fill;
1781
+ ctx.fill();
1782
+ }
1783
+ ctx.globalAlpha = alpha;
1784
+ ctx.lineWidth = o.width != null ? o.width : 1;
1785
+ ctx.lineJoin = 'round';
1786
+ ctx.lineCap = 'round';
1787
+ ctx.strokeStyle = color;
1788
+ ctx.stroke(edges);
1789
+ if (o.dots) {
1790
+ ctx.fillStyle = color;
1791
+ ctx.fill(dots);
1792
+ }
1793
+ ctx.restore();
1794
+ }
1795
+ lib.hexLattice = hexLattice;
1796
+
1797
+ function needle(p, x0, y0, x1, y1, w0, w1) {
1798
+ const dx = x1 - x0, dy = y1 - y0;
1799
+ const L = Math.hypot(dx, dy) || 1;
1800
+ const nx = -dy / L, ny = dx / L;
1801
+ p.moveTo(x0 + nx * w0, y0 + ny * w0);
1802
+ p.lineTo(x1 + nx * w1, y1 + ny * w1);
1803
+ p.lineTo(x1 - nx * w1, y1 - ny * w1);
1804
+ p.lineTo(x0 - nx * w0, y0 - ny * w0);
1805
+ p.closePath();
1806
+ }
1807
+
1808
+ /**
1809
+ * glowDot(ctx, x, y, r, opts) : soft glow, hot core and star rays (a nucleus, a spark, a star).
1810
+ * color pal.glow glow colour
1811
+ * core '#ffffff'
1812
+ * rays 8 ray count (0 = none); long and short alternate
1813
+ * rayLen 3.4 ray length as a multiple of r
1814
+ * rayWidth 0.22 ray base width as a multiple of r
1815
+ * rot 0
1816
+ * glow 4.5 glow radius as a multiple of r
1817
+ * intensity 1
1818
+ * twinkle 0.18 per boil drawing flicker
1819
+ * additive true 'lighter' blending (use false on paper)
1820
+ * seed 19
1821
+ */
1822
+ function glowDot(ctx, x, y, r, o = {}) {
1823
+ const seed = seedInt(o.seed === undefined ? 19 : o.seed);
1824
+ const bi = boilIndex(o);
1825
+ const tw = o.twinkle != null ? o.twinkle : 0.18;
1826
+ const k = (o.intensity != null ? o.intensity : 1) * (1 - tw + tw * 2 * h3(bi, seed, 23));
1827
+ const color = o.color || pal.glow;
1828
+ const glowR = r * (o.glow != null ? o.glow : 4.5);
1829
+ ctx.save();
1830
+ if (o.additive !== false) ctx.globalCompositeOperation = 'lighter';
1831
+ const base = ctx.globalAlpha;
1832
+ const g = ctx.createRadialGradient(x, y, 0, x, y, glowR);
1833
+ g.addColorStop(0, lib.rgba(color, 0.55 * k));
1834
+ g.addColorStop(0.18, lib.rgba(color, 0.22 * k));
1835
+ g.addColorStop(0.5, lib.rgba(color, 0.06 * k));
1836
+ g.addColorStop(1, lib.rgba(color, 0));
1837
+ ctx.fillStyle = g;
1838
+ ctx.fillRect(x - glowR, y - glowR, glowR * 2, glowR * 2);
1839
+ const rays = o.rays != null ? o.rays : 8;
1840
+ if (rays > 0) {
1841
+ const p = new Path2D();
1842
+ const rot = o.rot || 0;
1843
+ const rl = r * (o.rayLen != null ? o.rayLen : 3.4);
1844
+ const rw = r * (o.rayWidth != null ? o.rayWidth : 0.22);
1845
+ for (let i = 0; i < rays; i++) {
1846
+ const a = rot + (i / rays) * TAU;
1847
+ const L = (i % 2 ? 0.52 : 1) * rl * (0.9 + 0.2 * h3(i, bi, seed));
1848
+ needle(p, x, y, x + Math.cos(a) * L, y + Math.sin(a) * L, rw, 0.05);
1849
+ }
1850
+ ctx.globalAlpha = base * Math.min(1, 0.85 * k);
1851
+ ctx.fillStyle = o.core || '#ffffff';
1852
+ ctx.fill(p);
1853
+ }
1854
+ ctx.globalAlpha = base * Math.min(1, k);
1855
+ const cg = ctx.createRadialGradient(x, y, 0, x, y, r);
1856
+ cg.addColorStop(0, o.core || '#ffffff');
1857
+ cg.addColorStop(0.55, lib.rgba(o.core || '#ffffff', 0.9));
1858
+ cg.addColorStop(1, lib.rgba(color, 0));
1859
+ ctx.fillStyle = cg;
1860
+ ctx.beginPath();
1861
+ ctx.arc(x, y, r, 0, TAU);
1862
+ ctx.fill();
1863
+ ctx.restore();
1864
+ }
1865
+ lib.glowDot = glowDot;
1866
+
1867
+ function ticksImpl(ctx, x, y, o) {
1868
+ const p = new Path2D();
1869
+ const pm = new Path2D();
1870
+ const n = o.n || 24;
1871
+ const len = o.len != null ? o.len : 12;
1872
+ const major = o.major || 0;
1873
+ const majorLen = o.majorLen != null ? o.majorLen : len * 1.8;
1874
+ const prog = o.p != null ? clamp(o.p) : 1;
1875
+ const count = Math.round(n * prog);
1876
+ if (o.kind === 'linear' || (o.r == null && o.length != null)) {
1877
+ const L = o.length || 300;
1878
+ const a = o.angle || 0;
1879
+ const dx = Math.cos(a), dy = Math.sin(a);
1880
+ const side = o.side || 1;
1881
+ const nx = -dy * side, ny = dx * side;
1882
+ for (let i = 0; i <= Math.round(n * prog); i++) {
1883
+ const u = (i / n) * L;
1884
+ const isMajor = major && i % major === 0;
1885
+ const l = isMajor ? majorLen : len;
1886
+ const tgt = isMajor ? pm : p;
1887
+ tgt.moveTo(x + dx * u, y + dy * u);
1888
+ tgt.lineTo(x + dx * u + nx * l, y + dy * u + ny * l);
1889
+ }
1890
+ if (o.baseline !== false) {
1891
+ pm.moveTo(x, y);
1892
+ pm.lineTo(x + dx * L * prog, y + dy * L * prog);
1893
+ }
1894
+ } else {
1895
+ const r = o.r != null ? o.r : 40;
1896
+ const a0 = (o.start || 0) + (o.rot || 0);
1897
+ const span = o.span != null ? o.span : TAU;
1898
+ const full = Math.abs(span - TAU) < 1e-6;
1899
+ const dir = o.inward ? -1 : 1;
1900
+ for (let i = 0; i < count; i++) {
1901
+ const a = a0 + (i / (full ? n : Math.max(1, n - 1))) * span;
1902
+ const isMajor = major && i % major === 0;
1903
+ const l = (isMajor ? majorLen : len) * dir;
1904
+ const tgt = isMajor ? pm : p;
1905
+ const c = Math.cos(a), s = Math.sin(a);
1906
+ tgt.moveTo(x + c * r, y + s * r);
1907
+ tgt.lineTo(x + c * (r + l), y + s * (r + l));
1908
+ }
1909
+ }
1910
+ ctx.save();
1911
+ ctx.lineCap = 'round';
1912
+ ctx.strokeStyle = o.color || pal.lineWhite;
1913
+ ctx.globalAlpha *= o.alpha != null ? o.alpha : 0.6;
1914
+ ctx.lineWidth = o.width != null ? o.width : 1.5;
1915
+ ctx.stroke(p);
1916
+ ctx.lineWidth = (o.width != null ? o.width : 1.5) * 1.35;
1917
+ ctx.stroke(pm);
1918
+ ctx.restore();
1919
+ }
1920
+
1921
+ /**
1922
+ * ticks(ctx, x, y, opts) : radial ticks around a circle, or a linear ruler.
1923
+ * Radial (default): r 40, n 24, len 12, start 0, span TAU, major 0, majorLen len*1.8, inward false, rot 0
1924
+ * Linear (kind 'linear' or length given): length 300, angle 0, n 24, len 12, major 0, side 1, baseline true
1925
+ * Both: color pal.lineWhite, alpha 0.6, width 1.5, p 1 (draw-on progress)
1926
+ */
1927
+ lib.ticks = (ctx, x, y, o = {}) => ticksImpl(ctx, x, y, o);
1928
+
1929
+ function labelAt(ctx, str, x, y, o) {
1930
+ lib.text(ctx, str, x, y, {
1931
+ size: o.labelSize || 22,
1932
+ color: o.labelColor || o.color,
1933
+ alpha: o.alpha != null ? o.alpha : 0.9,
1934
+ align: o.labelAlign || 'center',
1935
+ baseline: 'middle',
1936
+ weight: 400,
1937
+ tracking: 1,
1938
+ });
1939
+ }
1940
+
1941
+ /**
1942
+ * bracket(ctx, x1, y1, x2, y2, opts) : a measurement bracket between two points.
1943
+ * style 'dim' 'dim' = dimension line with end bars and arrow ticks, 'square' = [ shape
1944
+ * offset 0 perpendicular offset of the bracket from the measured points (px)
1945
+ * cap 16 end bar length
1946
+ * color pal.lavender
1947
+ * alpha 0.6
1948
+ * width 1.5
1949
+ * label null text at the middle
1950
+ * p 1 draw-on progress
1951
+ */
1952
+ lib.bracket = (ctx, x1, y1, x2, y2, o = {}) => {
1953
+ const dx = x2 - x1, dy = y2 - y1;
1954
+ const L = Math.hypot(dx, dy) || 1;
1955
+ const ux = dx / L, uy = dy / L;
1956
+ const nx = -uy, ny = ux;
1957
+ const off = o.offset || 0;
1958
+ const cap = o.cap != null ? o.cap : 16;
1959
+ const prog = o.p != null ? clamp(o.p) : 1;
1960
+ const ax = x1 + nx * off, ay = y1 + ny * off;
1961
+ const bx = x2 + nx * off, by = y2 + ny * off;
1962
+ const mx = (ax + bx) / 2, my = (ay + by) / 2;
1963
+ const h = (L / 2) * prog;
1964
+ const p = new Path2D();
1965
+ const style = o.style || 'dim';
1966
+ const labelGap = o.label ? Math.min(h * 0.9, (String(o.label).length * (o.labelSize || 22)) * 0.34 + 10) : 0;
1967
+ const sx = mx - ux * h, sy = my - uy * h, ex = mx + ux * h, ey = my + uy * h;
1968
+ if (style === 'square') {
1969
+ const sgn = off >= 0 ? -1 : 1;
1970
+ p.moveTo(sx + nx * cap * sgn, sy + ny * cap * sgn);
1971
+ p.lineTo(sx, sy);
1972
+ p.lineTo(mx - ux * labelGap, my - uy * labelGap);
1973
+ p.moveTo(mx + ux * labelGap, my + uy * labelGap);
1974
+ p.lineTo(ex, ey);
1975
+ p.lineTo(ex + nx * cap * sgn, ey + ny * cap * sgn);
1976
+ } else {
1977
+ p.moveTo(sx, sy);
1978
+ p.lineTo(mx - ux * labelGap, my - uy * labelGap);
1979
+ p.moveTo(mx + ux * labelGap, my + uy * labelGap);
1980
+ p.lineTo(ex, ey);
1981
+ p.moveTo(sx - nx * cap * 0.5, sy - ny * cap * 0.5);
1982
+ p.lineTo(sx + nx * cap * 0.5, sy + ny * cap * 0.5);
1983
+ p.moveTo(ex - nx * cap * 0.5, ey - ny * cap * 0.5);
1984
+ p.lineTo(ex + nx * cap * 0.5, ey + ny * cap * 0.5);
1985
+ const ah = Math.min(9, h * 0.3);
1986
+ p.moveTo(sx + ux * ah + nx * ah * 0.5, sy + uy * ah + ny * ah * 0.5);
1987
+ p.lineTo(sx, sy);
1988
+ p.lineTo(sx + ux * ah - nx * ah * 0.5, sy + uy * ah - ny * ah * 0.5);
1989
+ p.moveTo(ex - ux * ah + nx * ah * 0.5, ey - uy * ah + ny * ah * 0.5);
1990
+ p.lineTo(ex, ey);
1991
+ p.lineTo(ex - ux * ah - nx * ah * 0.5, ey - uy * ah - ny * ah * 0.5);
1992
+ if (off) {
1993
+ // extension lines back to the measured points
1994
+ p.moveTo(x1 + nx * Math.sign(off) * 4, y1 + ny * Math.sign(off) * 4);
1995
+ p.lineTo(ax + nx * Math.sign(off) * cap * 0.6, ay + ny * Math.sign(off) * cap * 0.6);
1996
+ p.moveTo(x2 + nx * Math.sign(off) * 4, y2 + ny * Math.sign(off) * 4);
1997
+ p.lineTo(bx + nx * Math.sign(off) * cap * 0.6, by + ny * Math.sign(off) * cap * 0.6);
1998
+ }
1999
+ }
2000
+ ctx.save();
2001
+ ctx.lineCap = 'round';
2002
+ ctx.lineJoin = 'round';
2003
+ ctx.strokeStyle = o.color || pal.lavender;
2004
+ ctx.globalAlpha *= o.alpha != null ? o.alpha : 0.6;
2005
+ ctx.lineWidth = o.width != null ? o.width : 1.5;
2006
+ ctx.stroke(p);
2007
+ ctx.restore();
2008
+ if (o.label && prog > 0.6) {
2009
+ ctx.save();
2010
+ ctx.translate(mx, my);
2011
+ let a = Math.atan2(uy, ux);
2012
+ if (a > Math.PI / 2 || a < -Math.PI / 2) a += Math.PI;
2013
+ ctx.rotate(a);
2014
+ ctx.globalAlpha *= clamp((prog - 0.6) / 0.4);
2015
+ labelAt(ctx, o.label, 0, 0, Object.assign({ color: o.color || pal.lavender }, o));
2016
+ ctx.restore();
2017
+ }
2018
+ };
2019
+
2020
+ /**
2021
+ * guideCircle(ctx, cx, cy, r, opts) : a faint construction circle.
2022
+ * color pal.lavender, alpha 0.15, width 1.5, dash null ([on, off]),
2023
+ * p 1 (draw-on progress), start -PI/2, cross 0 (centre crosshair half-size),
2024
+ * quadrants 0 (tick length at the four quadrant points), ink false (hand-drawn via inkPath), seed
2025
+ */
2026
+ lib.guideCircle = (ctx, cx, cy, r, o = {}) => {
2027
+ const prog = o.p != null ? clamp(o.p) : 1;
2028
+ if (prog <= 0) return;
2029
+ const start = o.start != null ? o.start : -Math.PI / 2;
2030
+ const color = o.color || pal.lavender;
2031
+ const alpha = o.alpha != null ? o.alpha : 0.15;
2032
+ const width = o.width != null ? o.width : 1.5;
2033
+ ctx.save();
2034
+ if (o.ink) {
2035
+ const n = Math.max(12, Math.ceil(r * TAU * prog / 10));
2036
+ const pts = [];
2037
+ for (let i = 0; i <= n; i++) {
2038
+ const a = start + (i / n) * TAU * prog;
2039
+ pts.push([cx + Math.cos(a) * r, cy + Math.sin(a) * r]);
2040
+ }
2041
+ inkPath(ctx, pts, { closed: prog >= 1, width: width * 1.4, color, alpha, seed: o.seed, taper: [6, 12], wobble: 1.2 });
2042
+ } else {
2043
+ ctx.beginPath();
2044
+ ctx.arc(cx, cy, r, start, start + TAU * prog);
2045
+ ctx.strokeStyle = color;
2046
+ ctx.globalAlpha *= alpha;
2047
+ ctx.lineWidth = width;
2048
+ ctx.lineCap = 'round';
2049
+ if (o.dash) ctx.setLineDash(o.dash);
2050
+ ctx.stroke();
2051
+ ctx.setLineDash([]);
2052
+ }
2053
+ if (o.cross || o.quadrants) {
2054
+ const p = new Path2D();
2055
+ if (o.cross) {
2056
+ p.moveTo(cx - o.cross, cy);
2057
+ p.lineTo(cx + o.cross, cy);
2058
+ p.moveTo(cx, cy - o.cross);
2059
+ p.lineTo(cx, cy + o.cross);
2060
+ }
2061
+ if (o.quadrants) {
2062
+ for (let k = 0; k < 4; k++) {
2063
+ const a = (k * Math.PI) / 2;
2064
+ const c = Math.cos(a), s = Math.sin(a);
2065
+ p.moveTo(cx + c * (r - o.quadrants), cy + s * (r - o.quadrants));
2066
+ p.lineTo(cx + c * (r + o.quadrants), cy + s * (r + o.quadrants));
2067
+ }
2068
+ }
2069
+ if (o.ink) {
2070
+ ctx.strokeStyle = color;
2071
+ ctx.globalAlpha *= alpha;
2072
+ }
2073
+ ctx.lineWidth = width;
2074
+ ctx.stroke(p);
2075
+ }
2076
+ ctx.restore();
2077
+ };
2078
+
2079
+ /**
2080
+ * arcAnnotation(ctx, cx, cy, r, a0, a1, opts) : a thin coloured arc over an illustration
2081
+ * (flight paths, sound, attention), with an arrowhead and an origin dot.
2082
+ * color pal.annMagenta, width 2, alpha 1, p 1 (draw-on progress), endTicks 8 (0 = none),
2083
+ * arrow 0 (arrowhead size), dot 0 (origin dot radius), dash null, label null, labelOffset 26
2084
+ */
2085
+ lib.arcAnnotation = (ctx, cx, cy, r, a0, a1, o = {}) => {
2086
+ const prog = o.p != null ? clamp(o.p) : 1;
2087
+ if (prog <= 0) return;
2088
+ const color = o.color || pal.annMagenta;
2089
+ const width = o.width != null ? o.width : 2;
2090
+ const ae = a0 + (a1 - a0) * prog;
2091
+ const ccw = a1 < a0;
2092
+ ctx.save();
2093
+ ctx.globalAlpha *= o.alpha != null ? o.alpha : 1;
2094
+ ctx.strokeStyle = color;
2095
+ ctx.fillStyle = color;
2096
+ ctx.lineWidth = width;
2097
+ ctx.lineCap = 'round';
2098
+ if (o.dash) ctx.setLineDash(o.dash);
2099
+ ctx.beginPath();
2100
+ ctx.arc(cx, cy, r, a0, ae, ccw);
2101
+ ctx.stroke();
2102
+ ctx.setLineDash([]);
2103
+ const et = o.endTicks != null ? o.endTicks : 8;
2104
+ if (et) {
2105
+ ctx.beginPath();
2106
+ for (const a of prog >= 1 ? [a0, ae] : [a0]) {
2107
+ ctx.moveTo(cx + Math.cos(a) * (r - et / 2), cy + Math.sin(a) * (r - et / 2));
2108
+ ctx.lineTo(cx + Math.cos(a) * (r + et / 2), cy + Math.sin(a) * (r + et / 2));
2109
+ }
2110
+ ctx.stroke();
2111
+ }
2112
+ const dot = o.dot != null ? o.dot : 0;
2113
+ if (dot) {
2114
+ ctx.beginPath();
2115
+ ctx.arc(cx + Math.cos(a0) * r, cy + Math.sin(a0) * r, dot, 0, TAU);
2116
+ ctx.fill();
2117
+ }
2118
+ const ah = o.arrow != null ? o.arrow : 0;
2119
+ if (ah) {
2120
+ const ex = cx + Math.cos(ae) * r, ey = cy + Math.sin(ae) * r;
2121
+ const tdir = ae + (ccw ? -Math.PI / 2 : Math.PI / 2);
2122
+ const tx = Math.cos(tdir), ty = Math.sin(tdir);
2123
+ const nx = -ty, ny = tx;
2124
+ ctx.beginPath();
2125
+ ctx.moveTo(ex + tx * ah * 0.35, ey + ty * ah * 0.35);
2126
+ ctx.lineTo(ex - tx * ah * 0.75 + nx * ah * 0.45, ey - ty * ah * 0.75 + ny * ah * 0.45);
2127
+ ctx.lineTo(ex - tx * ah * 0.45, ey - ty * ah * 0.45);
2128
+ ctx.lineTo(ex - tx * ah * 0.75 - nx * ah * 0.45, ey - ty * ah * 0.75 - ny * ah * 0.45);
2129
+ ctx.closePath();
2130
+ ctx.fill();
2131
+ }
2132
+ if (o.label) {
2133
+ const am = (a0 + ae) / 2;
2134
+ const lo = o.labelOffset != null ? o.labelOffset : 26;
2135
+ lib.text(ctx, o.label, cx + Math.cos(am) * (r + lo), cy + Math.sin(am) * (r + lo), {
2136
+ size: o.labelSize || 24,
2137
+ color,
2138
+ align: 'center',
2139
+ baseline: 'middle',
2140
+ weight: 500,
2141
+ });
2142
+ }
2143
+ ctx.restore();
2144
+ };
2145
+
2146
+ // ===========================================================================
2147
+ // Camera and text
2148
+ // ===========================================================================
2149
+
2150
+ /**
2151
+ * camera(ctx, { x, y, zoom, rot }, fn) : draws fn(ctx) with world point (x, y) at the frame centre,
2152
+ * scaled by zoom and rotated by rot. Defaults: the frame centre, zoom 1, rot 0.
2153
+ */
2154
+ lib.camera = (ctx, cam, fn) => {
2155
+ const c = cam || {};
2156
+ const x = c.x != null ? c.x : W() / 2;
2157
+ const y = c.y != null ? c.y : H() / 2;
2158
+ ctx.save();
2159
+ ctx.translate(W() / 2, H() / 2);
2160
+ if (c.rot) ctx.rotate(c.rot);
2161
+ if (c.zoom != null && c.zoom !== 1) ctx.scale(c.zoom, c.zoom);
2162
+ ctx.translate(-x, -y);
2163
+ let out;
2164
+ try {
2165
+ out = fn(ctx);
2166
+ } finally {
2167
+ ctx.restore();
2168
+ }
2169
+ return out;
2170
+ };
2171
+
2172
+ const FONT_STACK = '"SF Pro Rounded", ui-rounded, "Helvetica Neue", system-ui, -apple-system, "Segoe UI", Arial, sans-serif';
2173
+
2174
+ /**
2175
+ * text(ctx, str, x, y, opts) : a thin single-line wordmark in the system sans-serif (no font files).
2176
+ * size 42, weight 300, color pal.ink, alpha 1, align 'left', baseline 'alphabetic',
2177
+ * tracking 0 (px number, or a css length such as '0.12em'; letterSpacing is an alias),
2178
+ * family (system stack), p 1 (typewriter reveal fraction), italic false
2179
+ */
2180
+ const HAND_STACK = '"Chalkboard SE", "Marker Felt", "Comic Sans MS", cursive';
2181
+ lib.text = (ctx, str, x, y, o = {}) => {
2182
+ let s = String(str);
2183
+ if (o.p != null) s = s.slice(0, Math.round(s.length * clamp(o.p)));
2184
+ if (!s) return;
2185
+ ctx.save();
2186
+ ctx.font = `${o.italic ? 'italic ' : ''}${o.weight || 300} ${o.size || 42}px ${o.family || FONT_STACK}`;
2187
+ ctx.fillStyle = o.color || pal.ink;
2188
+ ctx.globalAlpha *= o.alpha != null ? o.alpha : 1;
2189
+ ctx.textAlign = o.align || 'left';
2190
+ ctx.textBaseline = o.baseline || 'alphabetic';
2191
+ if ('letterSpacing' in ctx) {
2192
+ const tr = o.letterSpacing != null ? o.letterSpacing : o.tracking;
2193
+ ctx.letterSpacing = typeof tr === 'string' ? tr : `${tr || 0}px`;
2194
+ }
2195
+ ctx.fillText(s, x, y);
2196
+ ctx.restore();
2197
+ };
2198
+
2199
+ // ===========================================================================
2200
+ // Read-only
2201
+ // ===========================================================================
2202
+
2203
+ // A scene that changed lib, lib.pal or lib.ease would leak into every shot drawn after it, in
2204
+ // whatever order frames happen to be drawn (and differently in each render worker). So all of
2205
+ // it is frozen:
2206
+ // - lib itself is a plain frozen object: a write throws in strict code and is ignored in
2207
+ // non-strict code, so nothing leaks, and reading lib.fn in a hot loop stays at full speed.
2208
+ // - pal and ease are also wrapped so a write throws even from non-strict scene code (core
2209
+ // records it as a draw error, tools/check.cjs reports it). The wrapper makes each read about
2210
+ // 20 ns slower, so hoist colours out of per-point loops (const ink = P.ink). Code inside lib
2211
+ // uses the raw objects and pays nothing.
2212
+ function readOnly(target, name) {
2213
+ const fail = (verb, prop) => {
2214
+ throw new TypeError(
2215
+ `${name} is read-only: a scene cannot ${verb} '${String(prop)}' (it would leak into other shots). ` +
2216
+ `Make a local copy instead, for example const P = Object.assign({}, FILM.lib.pal, { ink: '#000' }).`
2217
+ );
2218
+ };
2219
+ return new Proxy(Object.freeze(target), {
2220
+ set: (t, prop) => fail('set', prop),
2221
+ defineProperty: (t, prop) => fail('define', prop),
2222
+ deleteProperty: (t, prop) => fail('delete', prop),
2223
+ setPrototypeOf: () => fail('change the prototype of', name),
2224
+ });
2225
+ }
2226
+ for (const key of Object.keys(ease)) Object.freeze(ease[key]);
2227
+ lib.pal = readOnly(pal, 'FILM.lib.pal');
2228
+ lib.ease = readOnly(ease, 'FILM.lib.ease');
2229
+ for (const key of Object.keys(lib)) {
2230
+ const v = Object.getOwnPropertyDescriptor(lib, key).value;
2231
+ if (typeof v === 'function') Object.freeze(v);
2232
+ }
2233
+ Object.defineProperty(FILM, 'lib', { value: Object.freeze(lib), writable: false, enumerable: true, configurable: false });
2234
+ })();
2235
+
2236
+ // ── Adaptador de Ignix Editor ────────────────────────────────────────────────
2237
+ (function () {
2238
+ const FILM = window.FILM;
2239
+ function create(canvas, { width = canvas.width || 1080, height = canvas.height || 1920 } = {}) {
2240
+ FILM.W = width;
2241
+ FILM.H = height;
2242
+ FILM.S = 1;
2243
+ const ctx = canvas.getContext('2d');
2244
+ // Dibuja el fotograma t: borra y llama a draw(ctx). El temblor de la tinta lee FILM.frameT.
2245
+ function frame(t, draw) {
2246
+ FILM.frameT = t;
2247
+ ctx.setTransform(1, 0, 0, 1, 0, 0);
2248
+ ctx.clearRect(0, 0, width, height);
2249
+ draw(ctx, FILM.lib);
2250
+ }
2251
+ return { frame, lib: FILM.lib, ctx };
2252
+ }
2253
+ window.IgnixInk = { create, lib: FILM.lib };
2254
+ })();