@ignix-social/editor 0.1.21 → 0.1.23
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.
- package/package.json +1 -1
- package/skills/ignix-editor/SKILL.md +58 -4
- package/src/runtime/blocks/beforeafter.js +53 -0
- package/src/runtime/blocks/character.js +55 -0
- package/src/runtime/blocks/headline.js +47 -0
- package/src/runtime/blocks/infographic.js +74 -0
- package/src/runtime/blocks/ink.js +2254 -0
- package/src/runtime/blocks/map.js +68 -0
- package/src/runtime/blocks/menu.js +69 -0
- package/src/runtime/blocks/offer.js +51 -0
- package/src/runtime/blocks/photos.js +48 -0
- package/src/runtime/blocks/quiz.js +64 -0
- package/src/runtime/blocks/reviews.js +61 -0
- package/src/runtime/blocks/slots.js +69 -0
- package/src/runtime/blocks/steps.js +49 -0
- package/src/runtime/blocks/stickman.js +13 -6
- package/src/runtime/blocks/whiteboard.js +26 -6
- package/src/transcribe.mjs +5 -2
|
@@ -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
|
+
})();
|