@glissade/scene 0.17.0-pre.0 → 0.17.1-pre.0
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/dist/displayList.d.ts +194 -0
- package/dist/index.d.ts +3 -2
- package/dist/index.js +3 -2
- package/dist/layout.d.ts +2 -1
- package/dist/layout.js +2 -1
- package/dist/layoutEngine.d.ts +22 -195
- package/dist/layoutEngine.js +1 -2018
- package/dist/nodes.js +2035 -0
- package/dist/path.d.ts +23 -0
- package/dist/path.js +135 -0
- package/package.json +6 -2
package/dist/nodes.js
ADDED
|
@@ -0,0 +1,2035 @@
|
|
|
1
|
+
import { TARGET_PATH, computed, emitDevWarning, random, signal, vec2Signal } from "@glissade/core";
|
|
2
|
+
//#region src/matrix.ts
|
|
3
|
+
const IDENTITY = [
|
|
4
|
+
1,
|
|
5
|
+
0,
|
|
6
|
+
0,
|
|
7
|
+
1,
|
|
8
|
+
0,
|
|
9
|
+
0
|
|
10
|
+
];
|
|
11
|
+
const z = (v) => v === 0 ? 0 : v;
|
|
12
|
+
function multiply(m1, m2) {
|
|
13
|
+
const [a1, b1, c1, d1, e1, f1] = m1;
|
|
14
|
+
const [a2, b2, c2, d2, e2, f2] = m2;
|
|
15
|
+
return [
|
|
16
|
+
z(a1 * a2 + c1 * b2),
|
|
17
|
+
z(b1 * a2 + d1 * b2),
|
|
18
|
+
z(a1 * c2 + c1 * d2),
|
|
19
|
+
z(b1 * c2 + d1 * d2),
|
|
20
|
+
z(a1 * e2 + c1 * f2 + e1),
|
|
21
|
+
z(b1 * e2 + d1 * f2 + f1)
|
|
22
|
+
];
|
|
23
|
+
}
|
|
24
|
+
/** Compose translate × rotate × scale (rotation in degrees). */
|
|
25
|
+
function fromTRS(position, rotationDeg, scale) {
|
|
26
|
+
const r = rotationDeg * Math.PI / 180;
|
|
27
|
+
const cos = Math.cos(r);
|
|
28
|
+
const sin = Math.sin(r);
|
|
29
|
+
const [sx, sy] = scale;
|
|
30
|
+
return [
|
|
31
|
+
z(cos * sx),
|
|
32
|
+
z(sin * sx),
|
|
33
|
+
z(-sin * sy),
|
|
34
|
+
z(cos * sy),
|
|
35
|
+
z(position[0]),
|
|
36
|
+
z(position[1])
|
|
37
|
+
];
|
|
38
|
+
}
|
|
39
|
+
/** Inverse affine: [A | t]⁻¹ = [A⁻¹ | −A⁻¹t]; null when degenerate (det 0). */
|
|
40
|
+
function invert(m) {
|
|
41
|
+
const [a, b, c, d, e, f] = m;
|
|
42
|
+
const det = a * d - b * c;
|
|
43
|
+
if (det === 0) return null;
|
|
44
|
+
const ia = d / det;
|
|
45
|
+
const ib = -b / det;
|
|
46
|
+
const ic = -c / det;
|
|
47
|
+
const id = a / det;
|
|
48
|
+
return [
|
|
49
|
+
z(ia),
|
|
50
|
+
z(ib),
|
|
51
|
+
z(ic),
|
|
52
|
+
z(id),
|
|
53
|
+
z(-(ia * e + ic * f)),
|
|
54
|
+
z(-(ib * e + id * f))
|
|
55
|
+
];
|
|
56
|
+
}
|
|
57
|
+
function applyToPoint(m, p) {
|
|
58
|
+
return [m[0] * p[0] + m[2] * p[1] + m[4], m[1] * p[0] + m[3] * p[1] + m[5]];
|
|
59
|
+
}
|
|
60
|
+
function matEquals(a, b) {
|
|
61
|
+
return a[0] === b[0] && a[1] === b[1] && a[2] === b[2] && a[3] === b[3] && a[4] === b[4] && a[5] === b[5];
|
|
62
|
+
}
|
|
63
|
+
//#endregion
|
|
64
|
+
//#region src/displayDiff.ts
|
|
65
|
+
/**
|
|
66
|
+
* The collapse-replacer shared by the cacheKey serializer
|
|
67
|
+
* (`createDisplayListBuilder().cacheKey`), `cacheColdAudit.hashDisplayList`,
|
|
68
|
+
* and `serializeDisplayList` here. CRITICAL: this is BYTE-PRESERVING — the
|
|
69
|
+
* cacheKey it backs stamps into pushGroup and keys the §3.5 raster cache, so
|
|
70
|
+
* its output must not move. The three call sites previously DUPLICATED this
|
|
71
|
+
* byte-for-byte; it lives here once.
|
|
72
|
+
*
|
|
73
|
+
* - ArrayBuffer / typed-array views collapse to a `ab:<len>` / `view:<len>`
|
|
74
|
+
* length marker (opaque binary never belongs in a structural key).
|
|
75
|
+
* - Functions drop (JSON would already drop them; explicit for parity).
|
|
76
|
+
* - Non-finite numbers (`NaN`/`Infinity`/`-Infinity`) collapse to DISTINCT
|
|
77
|
+
* string sentinels. `JSON.stringify` natively serializes all three to the
|
|
78
|
+
* SAME token (`null`), which would collide the cacheKey of two DisplayLists
|
|
79
|
+
* that differ only in WHICH non-finite value reaches a draw field — a stale
|
|
80
|
+
* raster + an `auditCacheCold` false-OK. The distinct sentinels keep them
|
|
81
|
+
* apart. This does NOT touch FINITE numbers (the common path): only the
|
|
82
|
+
* three non-finite inputs change, so the §3.5 cacheKey bytes for every real
|
|
83
|
+
* (finite) list are byte-identical — pinned by the regression guard.
|
|
84
|
+
*
|
|
85
|
+
* NOTE: `-0` is intentionally NOT normalized here. The matrix layer
|
|
86
|
+
* (`matrix.ts`) already normalizes `-0 → 0` at the source, and adding a `-0`
|
|
87
|
+
* pass to THIS replacer would change the cacheKey bytes for any list that ever
|
|
88
|
+
* carried a raw `-0` — silently invalidating the cache cluster-wide. (`-0` is
|
|
89
|
+
* finite, so the non-finite branch never touches it.) Byte preservation wins;
|
|
90
|
+
* the regression guard pins the exact key.
|
|
91
|
+
*/
|
|
92
|
+
function collapseReplacer(_key, value) {
|
|
93
|
+
if (value instanceof ArrayBuffer) return `ab:${value.byteLength}`;
|
|
94
|
+
if (ArrayBuffer.isView(value)) return `view:${value.byteLength}`;
|
|
95
|
+
if (typeof value === "function") return void 0;
|
|
96
|
+
if (typeof value === "number" && !Number.isFinite(value)) {
|
|
97
|
+
if (Number.isNaN(value)) return "NaN";
|
|
98
|
+
return value > 0 ? "Infinity" : "-Infinity";
|
|
99
|
+
}
|
|
100
|
+
return value;
|
|
101
|
+
}
|
|
102
|
+
/** A flat, stable JSON value for one command with its resource ids INLINED to content. */
|
|
103
|
+
function commandView(cmd, resources) {
|
|
104
|
+
const out = {};
|
|
105
|
+
for (const [k, v] of Object.entries(cmd)) out[k] = v;
|
|
106
|
+
if (cmd.op === "clip" || cmd.op === "fillPath" || cmd.op === "strokePath") out["path"] = resources[cmd.path];
|
|
107
|
+
else if (cmd.op === "drawImage") out["image"] = resources[cmd.image];
|
|
108
|
+
return out;
|
|
109
|
+
}
|
|
110
|
+
const stable = (v) => JSON.stringify(v, collapseReplacer);
|
|
111
|
+
/** Field-level diff of two same-op command views (shallow over top-level props). */
|
|
112
|
+
function diffFields(a, b) {
|
|
113
|
+
const changes = [];
|
|
114
|
+
const keys = new Set([...Object.keys(a), ...Object.keys(b)]);
|
|
115
|
+
for (const k of keys) {
|
|
116
|
+
if (k === "op") continue;
|
|
117
|
+
if (stable(a[k]) !== stable(b[k])) changes.push({
|
|
118
|
+
path: k,
|
|
119
|
+
from: a[k],
|
|
120
|
+
to: b[k]
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
return changes;
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Index-aligned positional diff of two DisplayLists. Command i in `a` is
|
|
127
|
+
* compared to command i in `b`; trailing commands become `add`/`remove`. A
|
|
128
|
+
* single insert/remove cascades — this is the documented v1 cliff.
|
|
129
|
+
*/
|
|
130
|
+
function diffDisplayLists(a, b) {
|
|
131
|
+
const deltas = [];
|
|
132
|
+
const n = Math.max(a.commands.length, b.commands.length);
|
|
133
|
+
for (let i = 0; i < n; i++) {
|
|
134
|
+
const ca = a.commands[i];
|
|
135
|
+
const cb = b.commands[i];
|
|
136
|
+
if (ca !== void 0 && cb !== void 0) {
|
|
137
|
+
const va = commandView(ca, a.resources);
|
|
138
|
+
const vb = commandView(cb, b.resources);
|
|
139
|
+
if (stable(va) === stable(vb)) continue;
|
|
140
|
+
if (ca.op !== cb.op) deltas.push({
|
|
141
|
+
index: i,
|
|
142
|
+
kind: "change",
|
|
143
|
+
opA: ca.op,
|
|
144
|
+
opB: cb.op,
|
|
145
|
+
fields: [{
|
|
146
|
+
path: "op",
|
|
147
|
+
from: ca.op,
|
|
148
|
+
to: cb.op
|
|
149
|
+
}]
|
|
150
|
+
});
|
|
151
|
+
else deltas.push({
|
|
152
|
+
index: i,
|
|
153
|
+
kind: "change",
|
|
154
|
+
opA: ca.op,
|
|
155
|
+
opB: cb.op,
|
|
156
|
+
fields: diffFields(va, vb)
|
|
157
|
+
});
|
|
158
|
+
} else if (cb !== void 0) deltas.push({
|
|
159
|
+
index: i,
|
|
160
|
+
kind: "add",
|
|
161
|
+
opB: cb.op,
|
|
162
|
+
fields: []
|
|
163
|
+
});
|
|
164
|
+
else if (ca !== void 0) deltas.push({
|
|
165
|
+
index: i,
|
|
166
|
+
kind: "remove",
|
|
167
|
+
opA: ca.op,
|
|
168
|
+
fields: []
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
const sizeDiffers = a.size.w !== b.size.w || a.size.h !== b.size.h;
|
|
172
|
+
return {
|
|
173
|
+
equal: deltas.length === 0 && !sizeDiffers,
|
|
174
|
+
deltas,
|
|
175
|
+
...sizeDiffers ? { size: {
|
|
176
|
+
from: a.size,
|
|
177
|
+
to: b.size
|
|
178
|
+
} } : {}
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
/** Human-readable, multi-line rendering of a DisplayDiff (CLI command-tree). */
|
|
182
|
+
function formatDisplayDiff(diff) {
|
|
183
|
+
if (diff.equal) return "DisplayLists are identical.";
|
|
184
|
+
const lines = [];
|
|
185
|
+
if (diff.size) lines.push(`size ${diff.size.from.w}x${diff.size.from.h} -> ${diff.size.to.w}x${diff.size.to.h}`);
|
|
186
|
+
for (const d of diff.deltas) if (d.kind === "add") lines.push(`+ [${d.index}] add ${d.opB ?? "?"}`);
|
|
187
|
+
else if (d.kind === "remove") lines.push(`- [${d.index}] remove ${d.opA ?? "?"}`);
|
|
188
|
+
else {
|
|
189
|
+
const op = d.opA === d.opB ? d.opA : `${d.opA ?? "?"} -> ${d.opB ?? "?"}`;
|
|
190
|
+
lines.push(`~ [${d.index}] ${op}`);
|
|
191
|
+
for (const f of d.fields) lines.push(` ${f.path}: ${stable(f.from)} -> ${stable(f.to)}`);
|
|
192
|
+
}
|
|
193
|
+
return lines.join("\n");
|
|
194
|
+
}
|
|
195
|
+
/**
|
|
196
|
+
* The `.dl.json` snapshot interchange schema (DESIGN §7.4). Users commit
|
|
197
|
+
* `.dl.json` baselines, so this carries the SAME break-policy obligation as
|
|
198
|
+
* `Timeline.version` and `SidecarDoc.sidecarVersion`: bump on a breaking shape
|
|
199
|
+
* change. Independent of the API version.
|
|
200
|
+
*/
|
|
201
|
+
const DL_SNAPSHOT_VERSION = 1;
|
|
202
|
+
/**
|
|
203
|
+
* Serialize a DisplayList to a stable `.dl.json` document string (reusing the
|
|
204
|
+
* byte-preserving collapse-replacer). Round-trips through `parseDisplaySnapshot`.
|
|
205
|
+
*/
|
|
206
|
+
function serializeDisplayList(dl) {
|
|
207
|
+
const snapshot = {
|
|
208
|
+
dlSnapshotVersion: 1,
|
|
209
|
+
size: dl.size,
|
|
210
|
+
commands: dl.commands,
|
|
211
|
+
resources: dl.resources
|
|
212
|
+
};
|
|
213
|
+
return JSON.stringify(snapshot, collapseReplacer, 2);
|
|
214
|
+
}
|
|
215
|
+
var DlSnapshotError = class extends Error {
|
|
216
|
+
constructor(message) {
|
|
217
|
+
super(message);
|
|
218
|
+
this.name = "DlSnapshotError";
|
|
219
|
+
}
|
|
220
|
+
};
|
|
221
|
+
/** Parse a `.dl.json` snapshot back into a DisplayList (validates the version). */
|
|
222
|
+
function parseDisplaySnapshot(json) {
|
|
223
|
+
const doc = JSON.parse(json);
|
|
224
|
+
if (doc.dlSnapshotVersion !== 1) throw new DlSnapshotError(`unsupported dlSnapshotVersion ${String(doc.dlSnapshotVersion)}; this build reads 1`);
|
|
225
|
+
if (!doc.size || !Array.isArray(doc.commands) || !Array.isArray(doc.resources)) throw new DlSnapshotError("malformed .dl.json snapshot (need size, commands[], resources[])");
|
|
226
|
+
return {
|
|
227
|
+
size: doc.size,
|
|
228
|
+
commands: doc.commands,
|
|
229
|
+
resources: doc.resources
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
//#endregion
|
|
233
|
+
//#region src/displayList.ts
|
|
234
|
+
var FilterValidationError = class extends Error {
|
|
235
|
+
constructor(message) {
|
|
236
|
+
super(message);
|
|
237
|
+
this.name = "FilterValidationError";
|
|
238
|
+
}
|
|
239
|
+
};
|
|
240
|
+
const FILTER_KINDS = new Set([
|
|
241
|
+
"blur",
|
|
242
|
+
"drop-shadow",
|
|
243
|
+
"brightness",
|
|
244
|
+
"contrast",
|
|
245
|
+
"saturate"
|
|
246
|
+
]);
|
|
247
|
+
/** Document-layer validation: reject unknown kinds and out-of-range params loudly. */
|
|
248
|
+
function validateFilters(filters) {
|
|
249
|
+
for (const f of filters) {
|
|
250
|
+
if (!FILTER_KINDS.has(f.kind)) throw new FilterValidationError(`unknown filter kind '${String(f.kind)}' (have: ${[...FILTER_KINDS].join(", ")})`);
|
|
251
|
+
if (f.kind === "blur" && !(Number.isFinite(f.radius) && f.radius >= 0)) throw new FilterValidationError(`blur radius must be ≥ 0, got ${String(f.radius)}`);
|
|
252
|
+
if (f.kind === "drop-shadow") {
|
|
253
|
+
if (![
|
|
254
|
+
f.dx,
|
|
255
|
+
f.dy,
|
|
256
|
+
f.blur
|
|
257
|
+
].every(Number.isFinite) || f.blur < 0) throw new FilterValidationError("drop-shadow needs finite dx/dy and blur ≥ 0");
|
|
258
|
+
if (typeof f.color !== "string" || f.color.length === 0) throw new FilterValidationError("drop-shadow needs a color string");
|
|
259
|
+
}
|
|
260
|
+
if ((f.kind === "brightness" || f.kind === "contrast" || f.kind === "saturate") && !(Number.isFinite(f.amount) && f.amount >= 0)) throw new FilterValidationError(`${f.kind} amount must be ≥ 0, got ${String(f.amount)}`);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
/**
|
|
264
|
+
* Compile the validated union to the canvas 2D `ctx.filter` syntax — both
|
|
265
|
+
* backends speak it (browser canvas and @napi-rs/canvas/Skia). This is the
|
|
266
|
+
* ONLY place the CSS-like syntax appears; documents never carry it.
|
|
267
|
+
*/
|
|
268
|
+
function filtersToCanvasFilter(filters) {
|
|
269
|
+
if (filters.length === 0) return "none";
|
|
270
|
+
return filters.map((f) => {
|
|
271
|
+
switch (f.kind) {
|
|
272
|
+
case "blur": return `blur(${f.radius}px)`;
|
|
273
|
+
case "drop-shadow": return `drop-shadow(${f.dx}px ${f.dy}px ${f.blur}px ${f.color})`;
|
|
274
|
+
default: return `${f.kind}(${f.amount})`;
|
|
275
|
+
}
|
|
276
|
+
}).join(" ");
|
|
277
|
+
}
|
|
278
|
+
/**
|
|
279
|
+
* Outer glow as stacked zero-offset drop-shadows — the classic recipe, fully
|
|
280
|
+
* deterministic on both backends (it is just filters). intensity stacks more
|
|
281
|
+
* layers; pair with a signal binding to follow an animated fill.
|
|
282
|
+
*/
|
|
283
|
+
function glow(color, radius = 16, intensity = 2) {
|
|
284
|
+
const layers = [];
|
|
285
|
+
for (let i = 0; i < Math.max(1, intensity); i++) layers.push({
|
|
286
|
+
kind: "drop-shadow",
|
|
287
|
+
dx: 0,
|
|
288
|
+
dy: 0,
|
|
289
|
+
blur: radius * (1 + i * 1.5),
|
|
290
|
+
color
|
|
291
|
+
});
|
|
292
|
+
return layers;
|
|
293
|
+
}
|
|
294
|
+
/**
|
|
295
|
+
* Resource ids that appear in a draw command — only path/image/video draws
|
|
296
|
+
* carry one. Keeps the cacheKey serializer in lockstep with DrawCommand.
|
|
297
|
+
*/
|
|
298
|
+
function commandResourceIds(cmd) {
|
|
299
|
+
switch (cmd.op) {
|
|
300
|
+
case "clip":
|
|
301
|
+
case "fillPath":
|
|
302
|
+
case "strokePath": return [cmd.path];
|
|
303
|
+
case "drawImage": return [cmd.image];
|
|
304
|
+
default: return [];
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
/** FNV-1a over a string → an 8-hex-digit stable token (no crypto dep, ESM-safe). */
|
|
308
|
+
function fnv1a(s) {
|
|
309
|
+
let h = 2166136261;
|
|
310
|
+
for (let i = 0; i < s.length; i++) {
|
|
311
|
+
h ^= s.charCodeAt(i);
|
|
312
|
+
h = Math.imul(h, 16777619);
|
|
313
|
+
}
|
|
314
|
+
return (h >>> 0).toString(16).padStart(8, "0");
|
|
315
|
+
}
|
|
316
|
+
function createDisplayListBuilder(size) {
|
|
317
|
+
const commands = [];
|
|
318
|
+
const resources = [];
|
|
319
|
+
const interned = /* @__PURE__ */ new Map();
|
|
320
|
+
return {
|
|
321
|
+
push: (cmd) => {
|
|
322
|
+
if (cmd.op === "pushGroup" && cmd.filters.length > 0) validateFilters(cmd.filters);
|
|
323
|
+
commands.push(cmd);
|
|
324
|
+
},
|
|
325
|
+
resource: (res) => {
|
|
326
|
+
const k = JSON.stringify(res);
|
|
327
|
+
const hit = interned.get(k);
|
|
328
|
+
if (hit !== void 0) return hit;
|
|
329
|
+
const id = resources.length;
|
|
330
|
+
resources.push(res);
|
|
331
|
+
interned.set(k, id);
|
|
332
|
+
return id;
|
|
333
|
+
},
|
|
334
|
+
mark: () => commands.length,
|
|
335
|
+
cacheKey: (start, end) => {
|
|
336
|
+
if (end <= start) return void 0;
|
|
337
|
+
const localIds = /* @__PURE__ */ new Map();
|
|
338
|
+
const usedResources = [];
|
|
339
|
+
const remap = (id) => {
|
|
340
|
+
let local = localIds.get(id);
|
|
341
|
+
if (local === void 0) {
|
|
342
|
+
local = localIds.size;
|
|
343
|
+
localIds.set(id, local);
|
|
344
|
+
usedResources.push(resources[id]);
|
|
345
|
+
}
|
|
346
|
+
return local;
|
|
347
|
+
};
|
|
348
|
+
const sliceCmds = commands.slice(start, end).map((cmd) => {
|
|
349
|
+
if (commandResourceIds(cmd).length === 0) return cmd;
|
|
350
|
+
if (cmd.op === "drawImage") return {
|
|
351
|
+
...cmd,
|
|
352
|
+
image: remap(cmd.image)
|
|
353
|
+
};
|
|
354
|
+
return {
|
|
355
|
+
...cmd,
|
|
356
|
+
path: remap(cmd.path)
|
|
357
|
+
};
|
|
358
|
+
});
|
|
359
|
+
return fnv1a(JSON.stringify({
|
|
360
|
+
c: sliceCmds,
|
|
361
|
+
r: usedResources
|
|
362
|
+
}, collapseReplacer));
|
|
363
|
+
},
|
|
364
|
+
patchCacheKey: (i, key) => {
|
|
365
|
+
const cmd = commands[i];
|
|
366
|
+
if (cmd && cmd.op === "pushGroup") cmd.cacheKey = key;
|
|
367
|
+
},
|
|
368
|
+
finish: () => ({
|
|
369
|
+
commands,
|
|
370
|
+
resources,
|
|
371
|
+
size
|
|
372
|
+
})
|
|
373
|
+
};
|
|
374
|
+
}
|
|
375
|
+
//#endregion
|
|
376
|
+
//#region src/text.ts
|
|
377
|
+
/**
|
|
378
|
+
* §3.6 measurement quantum (px). Scene-owned pre-measure quantizes every
|
|
379
|
+
* layout-feeding advance to this grid ONCE, then hands Yoga frozen integers —
|
|
380
|
+
* so sub-pixel measureText drift between Skia/HarfBuzz versions cannot move a
|
|
381
|
+
* whole layout. The single source of truth for the grid; `quantize` rounds to
|
|
382
|
+
* it. (Yoga's `setMeasureFunc` was considered and rejected — see DESIGN.md §3.6.)
|
|
383
|
+
*/
|
|
384
|
+
const MEASURE_QUANTUM_PX = .5;
|
|
385
|
+
/** §3.6 measurement quantum — round to the MEASURE_QUANTUM_PX grid. */
|
|
386
|
+
function quantize(v) {
|
|
387
|
+
return Math.round(v / MEASURE_QUANTUM_PX) * MEASURE_QUANTUM_PX;
|
|
388
|
+
}
|
|
389
|
+
/**
|
|
390
|
+
* Estimating fallback measurer — used only when no backend has been injected
|
|
391
|
+
* (e.g. evaluating for IR-level tests). Deterministic but not metrically
|
|
392
|
+
* faithful; mount(), the CLI, and exporters always inject the real one.
|
|
393
|
+
*/
|
|
394
|
+
let defaultMeasurer = null;
|
|
395
|
+
/**
|
|
396
|
+
* Process-wide fallback measurer for FACTORY-TIME measurement — component
|
|
397
|
+
* factories run before any scene exists, so Text pulls (measuredSize,
|
|
398
|
+
* lineBoxes, wordBoxes) and createScene fall back here before the estimator.
|
|
399
|
+
* Node consumers: `setDefaultMeasurer(createMeasurer({ fonts }))` from
|
|
400
|
+
* @glissade/backend-skia gives factory code the rasterizer's real metrics.
|
|
401
|
+
* Scene-injected measurers (mount/CLI/golden harness) always win.
|
|
402
|
+
*/
|
|
403
|
+
function setDefaultMeasurer(m) {
|
|
404
|
+
defaultMeasurer = m;
|
|
405
|
+
}
|
|
406
|
+
/** The default-or-estimating chain end; internal fallback for measurer pulls. */
|
|
407
|
+
function fallbackMeasurer() {
|
|
408
|
+
return defaultMeasurer ?? estimatingMeasurer;
|
|
409
|
+
}
|
|
410
|
+
const estimatingMeasurer = { measureText(text, font) {
|
|
411
|
+
return {
|
|
412
|
+
width: text.length * font.size * .52,
|
|
413
|
+
ascent: font.size * .8,
|
|
414
|
+
descent: font.size * .2
|
|
415
|
+
};
|
|
416
|
+
} };
|
|
417
|
+
let wordSegmenter;
|
|
418
|
+
/**
|
|
419
|
+
* The draw-path word segmentation (Intl.Segmenter boundaries, punctuation
|
|
420
|
+
* glued to its predecessor) — exported so Text.wordBoxes() boxes EXACTLY the
|
|
421
|
+
* units the breaker flows.
|
|
422
|
+
*/
|
|
423
|
+
function segmentWords(text) {
|
|
424
|
+
if (wordSegmenter === void 0) wordSegmenter = typeof Intl !== "undefined" && "Segmenter" in Intl ? new Intl.Segmenter(void 0, { granularity: "word" }) : null;
|
|
425
|
+
if (wordSegmenter) {
|
|
426
|
+
const raw = [...wordSegmenter.segment(text)].map((s) => s.segment);
|
|
427
|
+
const glued = [];
|
|
428
|
+
for (const seg of raw) if (glued.length > 0 && /^[^\p{L}\p{N}\s]+$/u.test(seg)) glued[glued.length - 1] += seg;
|
|
429
|
+
else glued.push(seg);
|
|
430
|
+
return glued;
|
|
431
|
+
}
|
|
432
|
+
return text.split(/(\s+)/).filter((w) => w.length > 0);
|
|
433
|
+
}
|
|
434
|
+
let graphemeSegmenter;
|
|
435
|
+
/**
|
|
436
|
+
* Split text into graphemes (user-perceived characters). Exported so Text.draw
|
|
437
|
+
* (reveal masking), Text.graphemes() (authoring), and revealSchedule() (the SFX
|
|
438
|
+
* keystroke contract) all count the SAME units.
|
|
439
|
+
*/
|
|
440
|
+
function segmentGraphemes(text) {
|
|
441
|
+
if (graphemeSegmenter === void 0) graphemeSegmenter = typeof Intl !== "undefined" && "Segmenter" in Intl ? new Intl.Segmenter(void 0, { granularity: "grapheme" }) : null;
|
|
442
|
+
if (graphemeSegmenter) return [...graphemeSegmenter.segment(text)].map((s) => s.segment);
|
|
443
|
+
return Array.from(text);
|
|
444
|
+
}
|
|
445
|
+
/**
|
|
446
|
+
* Greedy line breaking: explicit '\n' always breaks; otherwise word segments
|
|
447
|
+
* flow until maxWidth is exceeded (Intl.Segmenter boundaries, so CJK wraps
|
|
448
|
+
* without spaces). A segment wider than maxWidth gets its own line (no
|
|
449
|
+
* intra-word breaking in v1).
|
|
450
|
+
*/
|
|
451
|
+
function breakLines(text, font, maxWidth, measurer) {
|
|
452
|
+
const paragraphs = text.split("\n");
|
|
453
|
+
if (maxWidth === void 0 || maxWidth <= 0) return paragraphs;
|
|
454
|
+
const lines = [];
|
|
455
|
+
for (const para of paragraphs) {
|
|
456
|
+
const words = segmentWords(para);
|
|
457
|
+
let line = "";
|
|
458
|
+
for (const word of words) {
|
|
459
|
+
const candidate = line + word;
|
|
460
|
+
if (line !== "" && quantize(measurer.measureText(candidate.trimEnd(), font).width) > maxWidth) {
|
|
461
|
+
lines.push(line.trimEnd());
|
|
462
|
+
line = word.trimStart() === "" ? "" : word;
|
|
463
|
+
} else line = candidate;
|
|
464
|
+
}
|
|
465
|
+
lines.push(line.trimEnd());
|
|
466
|
+
}
|
|
467
|
+
return lines;
|
|
468
|
+
}
|
|
469
|
+
//#endregion
|
|
470
|
+
//#region src/node.ts
|
|
471
|
+
/**
|
|
472
|
+
* Scene-graph node base (DESIGN.md §3.1): every animatable property is a
|
|
473
|
+
* signal; transforms are computed matrix signals; emit() is pure — it reads
|
|
474
|
+
* signals and ctx only, and produces IR commands, never canvas calls.
|
|
475
|
+
*/
|
|
476
|
+
const ANCHOR_PRESETS = {
|
|
477
|
+
"center": [.5, .5],
|
|
478
|
+
"top-left": [0, 0],
|
|
479
|
+
"top": [.5, 0],
|
|
480
|
+
"top-right": [1, 0],
|
|
481
|
+
"left": [0, .5],
|
|
482
|
+
"right": [1, .5],
|
|
483
|
+
"bottom-left": [0, 1],
|
|
484
|
+
"bottom": [.5, 1],
|
|
485
|
+
"bottom-right": [1, 1]
|
|
486
|
+
};
|
|
487
|
+
function resolveAnchor(spec) {
|
|
488
|
+
if (typeof spec === "string") {
|
|
489
|
+
const preset = ANCHOR_PRESETS[spec];
|
|
490
|
+
if (!preset) throw new Error(`unknown anchor '${spec}' (use a preset like 'top-left' or a [ax, ay] pair)`);
|
|
491
|
+
return preset;
|
|
492
|
+
}
|
|
493
|
+
return [spec[0], spec[1]];
|
|
494
|
+
}
|
|
495
|
+
function initScalar(sig, init) {
|
|
496
|
+
if (typeof init === "function") sig.bindSource(init);
|
|
497
|
+
else if (init !== void 0) sig.set(init);
|
|
498
|
+
return sig;
|
|
499
|
+
}
|
|
500
|
+
function initVec2(sig, init) {
|
|
501
|
+
if (typeof init === "function") sig.bindSource(init);
|
|
502
|
+
else if (init !== void 0) sig.set(init);
|
|
503
|
+
return sig;
|
|
504
|
+
}
|
|
505
|
+
var Node = class {
|
|
506
|
+
id;
|
|
507
|
+
position;
|
|
508
|
+
rotation;
|
|
509
|
+
scale;
|
|
510
|
+
opacity;
|
|
511
|
+
blend;
|
|
512
|
+
zIndex;
|
|
513
|
+
filters;
|
|
514
|
+
/** Resolved anchor fraction over the intrinsic box; [0.5, 0.5] = center. */
|
|
515
|
+
anchor;
|
|
516
|
+
/** True only when the author SET an anchor — unset keeps the legacy origin. */
|
|
517
|
+
hasAnchor;
|
|
518
|
+
/** §3.5: opt-in cross-frame raster cache. Forces a group + a stamped cacheKey. */
|
|
519
|
+
cache;
|
|
520
|
+
parent = null;
|
|
521
|
+
#warnedAnchor = false;
|
|
522
|
+
/** v2 §C.3: participates in hit testing; set implicitly by attaching a listener. */
|
|
523
|
+
interactive = false;
|
|
524
|
+
/** v2 §C.3: false prunes this subtree from hit testing (PixiJS's flag). */
|
|
525
|
+
interactiveChildren = true;
|
|
526
|
+
/** v2 §C.3: explicit hit-shape override in node-local coordinates. */
|
|
527
|
+
hitArea;
|
|
528
|
+
/**
|
|
529
|
+
* Injected by createScene: the scene's CURRENT TextMeasurer (§3.2), so
|
|
530
|
+
* derived-size bindings (e.g. a background tracking Layout.computedSize)
|
|
531
|
+
* measure with the same rasterizer the flow uses.
|
|
532
|
+
*/
|
|
533
|
+
measurerSource = null;
|
|
534
|
+
localMatrix;
|
|
535
|
+
worldMatrix;
|
|
536
|
+
/** Track-target paths → bindable signals; subclasses register their own props. */
|
|
537
|
+
targets = /* @__PURE__ */ new Map();
|
|
538
|
+
constructor(props = {}) {
|
|
539
|
+
this.id = props.id;
|
|
540
|
+
this.position = initVec2(vec2Signal([0, 0]), props.position);
|
|
541
|
+
this.rotation = initScalar(signal(0), props.rotation);
|
|
542
|
+
this.scale = initVec2(vec2Signal([1, 1]), props.scale);
|
|
543
|
+
this.opacity = initScalar(signal(1), props.opacity);
|
|
544
|
+
this.blend = initScalar(signal("source-over"), props.blend);
|
|
545
|
+
this.zIndex = initScalar(signal(0), props.zIndex);
|
|
546
|
+
this.filters = initScalar(signal([]), props.filters);
|
|
547
|
+
this.hasAnchor = props.anchor !== void 0;
|
|
548
|
+
this.anchor = resolveAnchor(props.anchor ?? "center");
|
|
549
|
+
this.cache = props.cache === true;
|
|
550
|
+
this.localMatrix = computed(() => {
|
|
551
|
+
const trs = fromTRS(this.position(), this.rotation(), this.scale());
|
|
552
|
+
const [sx, sy] = this.anchorShift();
|
|
553
|
+
return sx === 0 && sy === 0 ? trs : multiply(trs, [
|
|
554
|
+
1,
|
|
555
|
+
0,
|
|
556
|
+
0,
|
|
557
|
+
1,
|
|
558
|
+
sx,
|
|
559
|
+
sy
|
|
560
|
+
]);
|
|
561
|
+
}, { equals: matEquals });
|
|
562
|
+
this.worldMatrix = computed(() => this.parent ? multiply(this.parent.worldMatrix(), this.localMatrix()) : this.localMatrix(), { equals: matEquals });
|
|
563
|
+
this.registerTarget("position", this.position, "vec2");
|
|
564
|
+
this.registerTarget("position.x", this.position.x, "number");
|
|
565
|
+
this.registerTarget("position.y", this.position.y, "number");
|
|
566
|
+
this.registerTarget("rotation", this.rotation, "number");
|
|
567
|
+
this.registerTarget("scale", this.scale, "vec2");
|
|
568
|
+
this.registerTarget("scale.x", this.scale.x, "number");
|
|
569
|
+
this.registerTarget("scale.y", this.scale.y, "number");
|
|
570
|
+
this.registerTarget("opacity", this.opacity, "number");
|
|
571
|
+
this.registerTarget("zIndex", this.zIndex, "number");
|
|
572
|
+
}
|
|
573
|
+
/**
|
|
574
|
+
* Register a track-target path → bindable signal, stamping the value type the
|
|
575
|
+
* signal accepts (§2.2). The stamp is what bindTimeline's bind-time guard
|
|
576
|
+
* reads to reject a mismatched track (a scalar on a vec2, a number on a paint
|
|
577
|
+
* prop, …) instead of silently sampling to NaN/undefined.
|
|
578
|
+
*
|
|
579
|
+
* `expects` is OPTIONAL: omitting it (the 2-arg form) leaves the target
|
|
580
|
+
* UNtagged — bindTimeline then skips the type guard for it, which is the
|
|
581
|
+
* back-compat seam for external `Custom`/`Node` subclasses (DESIGN.md §329)
|
|
582
|
+
* and prebuilt 0.13 nodes that called the 2-arg form (0.13 had no guard). A
|
|
583
|
+
* built-in node opts INTO the guard by tagging.
|
|
584
|
+
*/
|
|
585
|
+
registerTarget(path, sig, expects) {
|
|
586
|
+
sig.expects = expects;
|
|
587
|
+
this.targets.set(path, sig);
|
|
588
|
+
if (this.id !== void 0) sig[TARGET_PATH] = `${this.id}/${path}`;
|
|
589
|
+
}
|
|
590
|
+
resolveTarget(path) {
|
|
591
|
+
return this.targets.get(path);
|
|
592
|
+
}
|
|
593
|
+
/**
|
|
594
|
+
* Natural size for flex flow (§3.2); null = not flowable (a Layout parent
|
|
595
|
+
* emits such children absolutely, untouched).
|
|
596
|
+
*/
|
|
597
|
+
intrinsicSize(measurer) {
|
|
598
|
+
return null;
|
|
599
|
+
}
|
|
600
|
+
/**
|
|
601
|
+
* Vector from the DRAW origin to the intrinsic box's top-left, in the
|
|
602
|
+
* geometry space draw() emits into (anchor-independent — the anchor shift
|
|
603
|
+
* lives in localMatrix). Hit testing boxes nodes with this. Default:
|
|
604
|
+
* center-anchored geometry (every shape). Text overrides — it draws from a
|
|
605
|
+
* left/center/right baseline origin; Path from author-positioned bounds.
|
|
606
|
+
*/
|
|
607
|
+
drawOffset(measurer) {
|
|
608
|
+
const m = measurer ?? this.measurerSource?.() ?? fallbackMeasurer();
|
|
609
|
+
const size = this.intrinsicSize(m) ?? {
|
|
610
|
+
w: 0,
|
|
611
|
+
h: 0
|
|
612
|
+
};
|
|
613
|
+
return {
|
|
614
|
+
x: -size.w / 2,
|
|
615
|
+
y: -size.h / 2
|
|
616
|
+
};
|
|
617
|
+
}
|
|
618
|
+
/**
|
|
619
|
+
* Vector from the node ORIGIN (the point `position` places) to the box's
|
|
620
|
+
* top-left, so Layout can place any node. With an anchor this is exactly
|
|
621
|
+
* (−ax·w, −ay·h); the center default reproduces (−w/2, −h/2).
|
|
622
|
+
*/
|
|
623
|
+
flowOffset(measurer) {
|
|
624
|
+
const m = measurer ?? this.measurerSource?.() ?? fallbackMeasurer();
|
|
625
|
+
const d = this.drawOffset(m);
|
|
626
|
+
const [sx, sy] = this.anchorShift(m);
|
|
627
|
+
return {
|
|
628
|
+
x: d.x + sx,
|
|
629
|
+
y: d.y + sy
|
|
630
|
+
};
|
|
631
|
+
}
|
|
632
|
+
/**
|
|
633
|
+
* Translation composed after TRS in localMatrix: moves the drawn box so the
|
|
634
|
+
* anchor point lands on the origin. shift = −(drawOffset + anchor·size).
|
|
635
|
+
* No anchor set → zero shift, the legacy origin (shape center / Text
|
|
636
|
+
* baseline / Path author coords) — every pre-anchor scene is byte-stable.
|
|
637
|
+
* An EXPLICIT anchor pins position to that fraction of the box, even
|
|
638
|
+
* 'center' (which differs from the legacy origin only for Text and Path).
|
|
639
|
+
* Nodes without an intrinsic box (Group) warn once and ignore it.
|
|
640
|
+
*/
|
|
641
|
+
anchorShift(measurer) {
|
|
642
|
+
if (!this.hasAnchor) return [0, 0];
|
|
643
|
+
const [ax, ay] = this.anchor;
|
|
644
|
+
const m = measurer ?? this.measurerSource?.() ?? fallbackMeasurer();
|
|
645
|
+
const size = this.intrinsicSize(m);
|
|
646
|
+
if (!size) {
|
|
647
|
+
if (!this.#warnedAnchor) {
|
|
648
|
+
this.#warnedAnchor = true;
|
|
649
|
+
emitDevWarning(`anchor set on a node without an intrinsic box${this.id ? ` ('${this.id}')` : ""} — ignored (give it a sized node, or position children explicitly)`);
|
|
650
|
+
}
|
|
651
|
+
return [0, 0];
|
|
652
|
+
}
|
|
653
|
+
const d = this.drawOffset(m);
|
|
654
|
+
const sx = -(d.x + ax * size.w);
|
|
655
|
+
const sy = -(d.y + ay * size.h);
|
|
656
|
+
return [sx === 0 ? 0 : sx, sy === 0 ? 0 : sy];
|
|
657
|
+
}
|
|
658
|
+
/** §3.5 predicate: composite-as-a-unit when opacity/blend/filters demand it. */
|
|
659
|
+
requiresGroup() {
|
|
660
|
+
return this.opacity() < 1 || this.blend() !== "source-over" || this.filters().length > 0;
|
|
661
|
+
}
|
|
662
|
+
/** §3.7: a subtree-level shader pass; ShaderEffect overrides. */
|
|
663
|
+
groupShader() {}
|
|
664
|
+
emit(out, ctx) {
|
|
665
|
+
const opacity = this.opacity();
|
|
666
|
+
if (opacity <= 0) return;
|
|
667
|
+
const local = this.localMatrix();
|
|
668
|
+
const isIdentity = matEquals(local, IDENTITY);
|
|
669
|
+
const shader = this.groupShader();
|
|
670
|
+
const group = this.requiresGroup() || shader !== void 0 || this.cache;
|
|
671
|
+
const wantsKey = this.cache && out.mark !== void 0;
|
|
672
|
+
out.push({ op: "save" });
|
|
673
|
+
if (!isIdentity) out.push({
|
|
674
|
+
op: "transform",
|
|
675
|
+
m: local
|
|
676
|
+
});
|
|
677
|
+
let pushIdx = -1;
|
|
678
|
+
if (group) {
|
|
679
|
+
if (wantsKey) pushIdx = out.mark();
|
|
680
|
+
out.push({
|
|
681
|
+
op: "pushGroup",
|
|
682
|
+
opacity,
|
|
683
|
+
blend: this.blend(),
|
|
684
|
+
filters: this.filters(),
|
|
685
|
+
...shader !== void 0 ? { shader } : {}
|
|
686
|
+
});
|
|
687
|
+
}
|
|
688
|
+
const drawStart = wantsKey ? out.mark() : -1;
|
|
689
|
+
this.draw(out, ctx);
|
|
690
|
+
if (group) {
|
|
691
|
+
if (wantsKey) {
|
|
692
|
+
const key = out.cacheKey(drawStart, out.mark());
|
|
693
|
+
if (key !== void 0) out.patchCacheKey(pushIdx, key);
|
|
694
|
+
}
|
|
695
|
+
out.push({ op: "popGroup" });
|
|
696
|
+
}
|
|
697
|
+
out.push({ op: "restore" });
|
|
698
|
+
}
|
|
699
|
+
};
|
|
700
|
+
//#endregion
|
|
701
|
+
//#region src/sketch.ts
|
|
702
|
+
/**
|
|
703
|
+
* Hand-drawn stroke styles via GEOMETRIC roughening — not raster textures. A
|
|
704
|
+
* shape's outline is flattened to polylines, then each segment is redrawn as a
|
|
705
|
+
* slightly jittered, bowed stroke, overlaid in a few passes. Because it's pure
|
|
706
|
+
* path math seeded by a stable per-shape seed, the result is byte-identical on
|
|
707
|
+
* both backends and re-evaluates deterministically (the seed is consumed fresh
|
|
708
|
+
* each draw, never as a shared stateful stream).
|
|
709
|
+
*/
|
|
710
|
+
const KINDS = [
|
|
711
|
+
"marker",
|
|
712
|
+
"crayon",
|
|
713
|
+
"pencil",
|
|
714
|
+
"ink",
|
|
715
|
+
"chalk"
|
|
716
|
+
];
|
|
717
|
+
var SketchValidationError = class extends Error {
|
|
718
|
+
constructor(message) {
|
|
719
|
+
super(message);
|
|
720
|
+
this.name = "SketchValidationError";
|
|
721
|
+
}
|
|
722
|
+
};
|
|
723
|
+
/** Reject unknown kinds / out-of-range params at construction (like validateFilters). */
|
|
724
|
+
function validateSketch(s) {
|
|
725
|
+
if (!KINDS.includes(s.kind)) throw new SketchValidationError(`unknown sketch kind '${String(s.kind)}' (have: ${KINDS.join(", ")})`);
|
|
726
|
+
if (s.width !== void 0 && !(s.width > 0)) throw new SketchValidationError(`sketch width must be > 0, got ${String(s.width)}`);
|
|
727
|
+
if (s.roughness !== void 0 && !(s.roughness >= 0)) throw new SketchValidationError(`sketch roughness must be ≥ 0, got ${String(s.roughness)}`);
|
|
728
|
+
if ((s.kind === "crayon" || s.kind === "pencil") && s.passes !== void 0 && !(s.passes >= 1)) throw new SketchValidationError(`sketch passes must be ≥ 1, got ${String(s.passes)}`);
|
|
729
|
+
if (s.kind === "chalk" && s.dash !== void 0 && (!Array.isArray(s.dash) || s.dash.some((d) => !(d >= 0)))) throw new SketchValidationError("sketch chalk dash must be an array of non-negative numbers");
|
|
730
|
+
}
|
|
731
|
+
/** Per-kind defaults — the character of each look. */
|
|
732
|
+
function resolveSketch(s) {
|
|
733
|
+
switch (s.kind) {
|
|
734
|
+
case "marker": return {
|
|
735
|
+
width: s.width ?? 8,
|
|
736
|
+
roughness: s.roughness ?? 1.2,
|
|
737
|
+
passes: 2
|
|
738
|
+
};
|
|
739
|
+
case "crayon": return {
|
|
740
|
+
width: s.width ?? 4,
|
|
741
|
+
roughness: s.roughness ?? 2.4,
|
|
742
|
+
passes: s.passes ?? 3
|
|
743
|
+
};
|
|
744
|
+
case "pencil": return {
|
|
745
|
+
width: s.width ?? 1.5,
|
|
746
|
+
roughness: s.roughness ?? 1,
|
|
747
|
+
passes: s.passes ?? 2
|
|
748
|
+
};
|
|
749
|
+
case "ink": return {
|
|
750
|
+
width: s.width ?? 2.5,
|
|
751
|
+
roughness: s.roughness ?? .8,
|
|
752
|
+
passes: 1
|
|
753
|
+
};
|
|
754
|
+
case "chalk": return {
|
|
755
|
+
width: s.width ?? 3,
|
|
756
|
+
roughness: s.roughness ?? 1.6,
|
|
757
|
+
passes: 1,
|
|
758
|
+
dash: s.dash ?? [6, 5]
|
|
759
|
+
};
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
const cubic = (p0, c1, c2, p1, t) => {
|
|
763
|
+
const mt = 1 - t;
|
|
764
|
+
const a = mt * mt * mt;
|
|
765
|
+
const b = 3 * mt * mt * t;
|
|
766
|
+
const c = 3 * mt * t * t;
|
|
767
|
+
const d = t * t * t;
|
|
768
|
+
return [a * p0[0] + b * c1[0] + c * c2[0] + d * p1[0], a * p0[1] + b * c1[1] + c * c2[1] + d * p1[1]];
|
|
769
|
+
};
|
|
770
|
+
const quad = (p0, c, p1, t) => {
|
|
771
|
+
const mt = 1 - t;
|
|
772
|
+
return [mt * mt * p0[0] + 2 * mt * t * c[0] + t * t * p1[0], mt * mt * p0[1] + 2 * mt * t * c[1] + t * t * p1[1]];
|
|
773
|
+
};
|
|
774
|
+
const ellipse = (cx, cy, rx, ry, rot, ang) => {
|
|
775
|
+
const ex = rx * Math.cos(ang);
|
|
776
|
+
const ey = ry * Math.sin(ang);
|
|
777
|
+
const cos = Math.cos(rot);
|
|
778
|
+
const sin = Math.sin(rot);
|
|
779
|
+
return [cx + ex * cos - ey * sin, cy + ex * sin + ey * cos];
|
|
780
|
+
};
|
|
781
|
+
/**
|
|
782
|
+
* Flatten a path to polylines — de Casteljau for C/Q, arc sampling for E
|
|
783
|
+
* (Circle and rounded-rect corners are 'E' segments, so this MUST handle them
|
|
784
|
+
* or those shapes roughen wrong). `steps` is the samples per curved segment.
|
|
785
|
+
*/
|
|
786
|
+
function flatten(segs, steps = 16) {
|
|
787
|
+
const polys = [];
|
|
788
|
+
let cur = null;
|
|
789
|
+
let px = 0;
|
|
790
|
+
let py = 0;
|
|
791
|
+
let sx = 0;
|
|
792
|
+
let sy = 0;
|
|
793
|
+
const ensure = (x, y) => {
|
|
794
|
+
if (!cur) {
|
|
795
|
+
cur = {
|
|
796
|
+
points: [[x, y]],
|
|
797
|
+
closed: false
|
|
798
|
+
};
|
|
799
|
+
polys.push(cur);
|
|
800
|
+
sx = x;
|
|
801
|
+
sy = y;
|
|
802
|
+
}
|
|
803
|
+
return cur;
|
|
804
|
+
};
|
|
805
|
+
for (const s of segs) switch (s[0]) {
|
|
806
|
+
case "M":
|
|
807
|
+
cur = {
|
|
808
|
+
points: [[s[1], s[2]]],
|
|
809
|
+
closed: false
|
|
810
|
+
};
|
|
811
|
+
polys.push(cur);
|
|
812
|
+
px = sx = s[1];
|
|
813
|
+
py = sy = s[2];
|
|
814
|
+
break;
|
|
815
|
+
case "L":
|
|
816
|
+
ensure(px, py).points.push([s[1], s[2]]);
|
|
817
|
+
px = s[1];
|
|
818
|
+
py = s[2];
|
|
819
|
+
break;
|
|
820
|
+
case "C": {
|
|
821
|
+
const c = ensure(px, py);
|
|
822
|
+
for (let k = 1; k <= steps; k++) c.points.push(cubic([px, py], [s[1], s[2]], [s[3], s[4]], [s[5], s[6]], k / steps));
|
|
823
|
+
px = s[5];
|
|
824
|
+
py = s[6];
|
|
825
|
+
break;
|
|
826
|
+
}
|
|
827
|
+
case "Q": {
|
|
828
|
+
const c = ensure(px, py);
|
|
829
|
+
for (let k = 1; k <= steps; k++) c.points.push(quad([px, py], [s[1], s[2]], [s[3], s[4]], k / steps));
|
|
830
|
+
px = s[3];
|
|
831
|
+
py = s[4];
|
|
832
|
+
break;
|
|
833
|
+
}
|
|
834
|
+
case "E": {
|
|
835
|
+
const [, cx, cy, rx, ry, rot, a0, a1] = s;
|
|
836
|
+
const begin = ellipse(cx, cy, rx, ry, rot, a0);
|
|
837
|
+
const c = ensure(begin[0], begin[1]);
|
|
838
|
+
for (let k = 1; k <= steps; k++) c.points.push(ellipse(cx, cy, rx, ry, rot, a0 + (a1 - a0) * (k / steps)));
|
|
839
|
+
const end = ellipse(cx, cy, rx, ry, rot, a1);
|
|
840
|
+
px = end[0];
|
|
841
|
+
py = end[1];
|
|
842
|
+
break;
|
|
843
|
+
}
|
|
844
|
+
case "Z":
|
|
845
|
+
if (cur) {
|
|
846
|
+
cur.points.push([sx, sy]);
|
|
847
|
+
cur.closed = true;
|
|
848
|
+
px = sx;
|
|
849
|
+
py = sy;
|
|
850
|
+
}
|
|
851
|
+
break;
|
|
852
|
+
}
|
|
853
|
+
return polys.filter((p) => p.points.length > 0);
|
|
854
|
+
}
|
|
855
|
+
/** Total length of a flattened polyline (for draw-on dashing). */
|
|
856
|
+
function arcLength(poly) {
|
|
857
|
+
let len = 0;
|
|
858
|
+
for (let i = 1; i < poly.points.length; i++) len += Math.hypot(poly.points[i][0] - poly.points[i - 1][0], poly.points[i][1] - poly.points[i - 1][1]);
|
|
859
|
+
return len;
|
|
860
|
+
}
|
|
861
|
+
function validateHachure(h) {
|
|
862
|
+
if (!(h.gap > 0)) throw new SketchValidationError(`hachure gap must be > 0, got ${String(h.gap)}`);
|
|
863
|
+
if (h.roughness !== void 0 && !(h.roughness >= 0)) throw new SketchValidationError(`hachure roughness must be ≥ 0, got ${String(h.roughness)}`);
|
|
864
|
+
}
|
|
865
|
+
/**
|
|
866
|
+
* Parallel hatch lines covering a path's bounding box at `angleRad`, spaced
|
|
867
|
+
* `gap`, lightly jittered. Returned as `M/L` segments to be stroked INSIDE a
|
|
868
|
+
* clip of the shape (the caller emits the clip). Pure; `rng` reseeded per draw.
|
|
869
|
+
*/
|
|
870
|
+
function hachureLines(segs, spec, rng) {
|
|
871
|
+
const polys = flatten(segs);
|
|
872
|
+
let minX = Infinity;
|
|
873
|
+
let minY = Infinity;
|
|
874
|
+
let maxX = -Infinity;
|
|
875
|
+
let maxY = -Infinity;
|
|
876
|
+
for (const p of polys) for (const [x, y] of p.points) {
|
|
877
|
+
if (x < minX) minX = x;
|
|
878
|
+
if (y < minY) minY = y;
|
|
879
|
+
if (x > maxX) maxX = x;
|
|
880
|
+
if (y > maxY) maxY = y;
|
|
881
|
+
}
|
|
882
|
+
if (!Number.isFinite(minX)) return [];
|
|
883
|
+
const cx = (minX + maxX) / 2;
|
|
884
|
+
const cy = (minY + maxY) / 2;
|
|
885
|
+
const ca = Math.cos(spec.angleRad);
|
|
886
|
+
const sa = Math.sin(spec.angleRad);
|
|
887
|
+
const toRot = (x, y) => [(x - cx) * ca + (y - cy) * sa, -(x - cx) * sa + (y - cy) * ca];
|
|
888
|
+
const fromRot = (x, y) => [cx + x * ca - y * sa, cy + x * sa + y * ca];
|
|
889
|
+
let rMinX = Infinity;
|
|
890
|
+
let rMinY = Infinity;
|
|
891
|
+
let rMaxX = -Infinity;
|
|
892
|
+
let rMaxY = -Infinity;
|
|
893
|
+
const corners = [
|
|
894
|
+
[minX, minY],
|
|
895
|
+
[maxX, minY],
|
|
896
|
+
[maxX, maxY],
|
|
897
|
+
[minX, maxY]
|
|
898
|
+
];
|
|
899
|
+
for (const [x, y] of corners) {
|
|
900
|
+
const [rx, ry] = toRot(x, y);
|
|
901
|
+
if (rx < rMinX) rMinX = rx;
|
|
902
|
+
if (ry < rMinY) rMinY = ry;
|
|
903
|
+
if (rx > rMaxX) rMaxX = rx;
|
|
904
|
+
if (ry > rMaxY) rMaxY = ry;
|
|
905
|
+
}
|
|
906
|
+
const rough = spec.roughness ?? 1;
|
|
907
|
+
const jit = () => (rng() * 2 - 1) * rough;
|
|
908
|
+
const out = [];
|
|
909
|
+
for (let y = rMinY + spec.gap / 2; y < rMaxY; y += spec.gap) {
|
|
910
|
+
const a = fromRot(rMinX, y + jit());
|
|
911
|
+
const b = fromRot(rMaxX, y + jit());
|
|
912
|
+
out.push([
|
|
913
|
+
"M",
|
|
914
|
+
a[0],
|
|
915
|
+
a[1]
|
|
916
|
+
], [
|
|
917
|
+
"L",
|
|
918
|
+
b[0],
|
|
919
|
+
b[1]
|
|
920
|
+
]);
|
|
921
|
+
}
|
|
922
|
+
return out;
|
|
923
|
+
}
|
|
924
|
+
/**
|
|
925
|
+
* Roughen a path into hand-drawn stroke passes. Each segment becomes a bowed,
|
|
926
|
+
* jittered quadratic; `passes` overlay slightly different jitters for the
|
|
927
|
+
* built-up look. `rng` must be a freshly seeded generator (the caller reseeds
|
|
928
|
+
* per draw from a stable seed, so evaluate() stays pure).
|
|
929
|
+
*/
|
|
930
|
+
function roughen(segs, style, rng) {
|
|
931
|
+
const resolved = resolveSketch(style);
|
|
932
|
+
const polys = flatten(segs);
|
|
933
|
+
const jit = () => (rng() * 2 - 1) * resolved.roughness;
|
|
934
|
+
const strokes = [];
|
|
935
|
+
for (let pass = 0; pass < resolved.passes; pass++) {
|
|
936
|
+
const out = [];
|
|
937
|
+
for (const poly of polys) {
|
|
938
|
+
const pts = poly.points;
|
|
939
|
+
if (pts.length < 2) continue;
|
|
940
|
+
let ax = pts[0][0] + jit();
|
|
941
|
+
let ay = pts[0][1] + jit();
|
|
942
|
+
out.push([
|
|
943
|
+
"M",
|
|
944
|
+
ax,
|
|
945
|
+
ay
|
|
946
|
+
]);
|
|
947
|
+
for (let i = 1; i < pts.length; i++) {
|
|
948
|
+
const bx = pts[i][0] + jit();
|
|
949
|
+
const by = pts[i][1] + jit();
|
|
950
|
+
const dx = bx - ax;
|
|
951
|
+
const dy = by - ay;
|
|
952
|
+
const len = Math.hypot(dx, dy) || 1;
|
|
953
|
+
const bow = jit() * .5;
|
|
954
|
+
const mx = (ax + bx) / 2 + -dy / len * bow;
|
|
955
|
+
const my = (ay + by) / 2 + dx / len * bow;
|
|
956
|
+
out.push([
|
|
957
|
+
"Q",
|
|
958
|
+
mx,
|
|
959
|
+
my,
|
|
960
|
+
bx,
|
|
961
|
+
by
|
|
962
|
+
]);
|
|
963
|
+
ax = bx;
|
|
964
|
+
ay = by;
|
|
965
|
+
}
|
|
966
|
+
}
|
|
967
|
+
strokes.push(out);
|
|
968
|
+
}
|
|
969
|
+
return {
|
|
970
|
+
strokes,
|
|
971
|
+
resolved
|
|
972
|
+
};
|
|
973
|
+
}
|
|
974
|
+
/** FNV-1a 32-bit — a stable per-shape sketch seed from its id. */
|
|
975
|
+
function hashStr(s) {
|
|
976
|
+
let h = 2166136261;
|
|
977
|
+
for (let i = 0; i < s.length; i++) {
|
|
978
|
+
h ^= s.charCodeAt(i);
|
|
979
|
+
h = Math.imul(h, 16777619);
|
|
980
|
+
}
|
|
981
|
+
return h >>> 0;
|
|
982
|
+
}
|
|
983
|
+
/** Convenience: the rough stroke passes for a path at a given seed. */
|
|
984
|
+
function sketchStrokes(segs, style, seed) {
|
|
985
|
+
return roughen(segs, style, random(seed >>> 0)).strokes;
|
|
986
|
+
}
|
|
987
|
+
//#endregion
|
|
988
|
+
//#region src/nodes.ts
|
|
989
|
+
/**
|
|
990
|
+
* Built-in nodes for M1 (DESIGN.md §3.1): Group, Rect, Circle, Text.
|
|
991
|
+
* Path/Image/Video/Layout arrive with their milestones.
|
|
992
|
+
*/
|
|
993
|
+
/**
|
|
994
|
+
* The NAMED extension point of the closed §3.1 taxonomy: the documented base
|
|
995
|
+
* an author subclasses to emit IR commands (never canvas calls). It adds
|
|
996
|
+
* nothing to `Node` — it exists so "custom-via-subclassing" is a real,
|
|
997
|
+
* exported surface (the ninth taxonomy member) rather than an unnamed
|
|
998
|
+
* convention. Subclasses implement the abstract `draw()` from `Node`.
|
|
999
|
+
*/
|
|
1000
|
+
var Custom = class extends Node {};
|
|
1001
|
+
/** Rounded-rect path segments — Rect's outline, shared with Highlight. */
|
|
1002
|
+
function roundedRectSegs(x, y, w, h, r) {
|
|
1003
|
+
if (r <= 0) return [
|
|
1004
|
+
[
|
|
1005
|
+
"M",
|
|
1006
|
+
x,
|
|
1007
|
+
y
|
|
1008
|
+
],
|
|
1009
|
+
[
|
|
1010
|
+
"L",
|
|
1011
|
+
x + w,
|
|
1012
|
+
y
|
|
1013
|
+
],
|
|
1014
|
+
[
|
|
1015
|
+
"L",
|
|
1016
|
+
x + w,
|
|
1017
|
+
y + h
|
|
1018
|
+
],
|
|
1019
|
+
[
|
|
1020
|
+
"L",
|
|
1021
|
+
x,
|
|
1022
|
+
y + h
|
|
1023
|
+
],
|
|
1024
|
+
["Z"]
|
|
1025
|
+
];
|
|
1026
|
+
const HALF = Math.PI / 2;
|
|
1027
|
+
return [
|
|
1028
|
+
[
|
|
1029
|
+
"M",
|
|
1030
|
+
x + r,
|
|
1031
|
+
y
|
|
1032
|
+
],
|
|
1033
|
+
[
|
|
1034
|
+
"L",
|
|
1035
|
+
x + w - r,
|
|
1036
|
+
y
|
|
1037
|
+
],
|
|
1038
|
+
[
|
|
1039
|
+
"E",
|
|
1040
|
+
x + w - r,
|
|
1041
|
+
y + r,
|
|
1042
|
+
r,
|
|
1043
|
+
r,
|
|
1044
|
+
0,
|
|
1045
|
+
-HALF,
|
|
1046
|
+
0
|
|
1047
|
+
],
|
|
1048
|
+
[
|
|
1049
|
+
"L",
|
|
1050
|
+
x + w,
|
|
1051
|
+
y + h - r
|
|
1052
|
+
],
|
|
1053
|
+
[
|
|
1054
|
+
"E",
|
|
1055
|
+
x + w - r,
|
|
1056
|
+
y + h - r,
|
|
1057
|
+
r,
|
|
1058
|
+
r,
|
|
1059
|
+
0,
|
|
1060
|
+
0,
|
|
1061
|
+
HALF
|
|
1062
|
+
],
|
|
1063
|
+
[
|
|
1064
|
+
"L",
|
|
1065
|
+
x + r,
|
|
1066
|
+
y + h
|
|
1067
|
+
],
|
|
1068
|
+
[
|
|
1069
|
+
"E",
|
|
1070
|
+
x + r,
|
|
1071
|
+
y + h - r,
|
|
1072
|
+
r,
|
|
1073
|
+
r,
|
|
1074
|
+
0,
|
|
1075
|
+
HALF,
|
|
1076
|
+
Math.PI
|
|
1077
|
+
],
|
|
1078
|
+
[
|
|
1079
|
+
"L",
|
|
1080
|
+
x,
|
|
1081
|
+
y + r
|
|
1082
|
+
],
|
|
1083
|
+
[
|
|
1084
|
+
"E",
|
|
1085
|
+
x + r,
|
|
1086
|
+
y + r,
|
|
1087
|
+
r,
|
|
1088
|
+
r,
|
|
1089
|
+
0,
|
|
1090
|
+
Math.PI,
|
|
1091
|
+
Math.PI + HALF
|
|
1092
|
+
],
|
|
1093
|
+
["Z"]
|
|
1094
|
+
];
|
|
1095
|
+
}
|
|
1096
|
+
var Group = class extends Node {
|
|
1097
|
+
children;
|
|
1098
|
+
/** Version bumped on structural child mutation, so a dependency-tracked memo
|
|
1099
|
+
* (e.g. Layout's computed) re-runs when the child SET changes — not only when
|
|
1100
|
+
* a participating prop signal does. */
|
|
1101
|
+
#structure = signal(0);
|
|
1102
|
+
constructor(props = {}) {
|
|
1103
|
+
super(props);
|
|
1104
|
+
this.children = props.children ?? [];
|
|
1105
|
+
for (const child of this.children) child.parent = this;
|
|
1106
|
+
}
|
|
1107
|
+
/** Record the structural version as a dependency — call inside a computed
|
|
1108
|
+
* that walks `children` so add()/remove() invalidate it. */
|
|
1109
|
+
trackStructure() {
|
|
1110
|
+
this.#structure();
|
|
1111
|
+
}
|
|
1112
|
+
add(child) {
|
|
1113
|
+
child.parent = this;
|
|
1114
|
+
this.children.push(child);
|
|
1115
|
+
this.#structure.set(this.#structure.peek() + 1);
|
|
1116
|
+
return this;
|
|
1117
|
+
}
|
|
1118
|
+
/** Remove a child (the reactive counterpart to add()); no-op if absent. */
|
|
1119
|
+
remove(child) {
|
|
1120
|
+
const i = this.children.indexOf(child);
|
|
1121
|
+
if (i >= 0) {
|
|
1122
|
+
this.children.splice(i, 1);
|
|
1123
|
+
if (child.parent === this) child.parent = null;
|
|
1124
|
+
this.#structure.set(this.#structure.peek() + 1);
|
|
1125
|
+
}
|
|
1126
|
+
return this;
|
|
1127
|
+
}
|
|
1128
|
+
draw(out, ctx) {
|
|
1129
|
+
const sorted = this.children.map((node, i) => ({
|
|
1130
|
+
node,
|
|
1131
|
+
i
|
|
1132
|
+
})).sort((a, b) => a.node.zIndex() - b.node.zIndex() || a.i - b.i).map((e) => e.node);
|
|
1133
|
+
for (const child of sorted) child.emit(out, ctx);
|
|
1134
|
+
}
|
|
1135
|
+
};
|
|
1136
|
+
/** A color string is sugar for a solid `color` Paint; a Paint passes through. */
|
|
1137
|
+
function toPaint(fill) {
|
|
1138
|
+
return typeof fill === "string" ? {
|
|
1139
|
+
kind: "color",
|
|
1140
|
+
color: fill
|
|
1141
|
+
} : fill;
|
|
1142
|
+
}
|
|
1143
|
+
var Shape = class extends Node {
|
|
1144
|
+
fill;
|
|
1145
|
+
stroke;
|
|
1146
|
+
strokeWidth;
|
|
1147
|
+
sketch;
|
|
1148
|
+
sketchFill;
|
|
1149
|
+
sketchSeed;
|
|
1150
|
+
reveal;
|
|
1151
|
+
constructor(props = {}) {
|
|
1152
|
+
super(props);
|
|
1153
|
+
this.fill = initProp(signal(""), props.fill);
|
|
1154
|
+
this.stroke = initProp(signal(""), props.stroke);
|
|
1155
|
+
this.strokeWidth = initProp(signal(0), props.strokeWidth);
|
|
1156
|
+
this.reveal = initProp(signal(1), props.reveal);
|
|
1157
|
+
this.registerTarget("fill", this.fill, ["color", "paint"]);
|
|
1158
|
+
this.registerTarget("stroke", this.stroke, "color");
|
|
1159
|
+
this.registerTarget("strokeWidth", this.strokeWidth, "number");
|
|
1160
|
+
this.registerTarget("reveal", this.reveal, "number");
|
|
1161
|
+
if (props.sketch) validateSketch(props.sketch);
|
|
1162
|
+
if (props.sketchFill) validateHachure(props.sketchFill);
|
|
1163
|
+
if (props.sketchFill && !props.sketch) emitDevWarning(`${this.id !== void 0 ? `'${this.id}': ` : ""}sketchFill is ignored without sketch — hachure fill is drawn only by the sketch renderer. Set a sketch style (e.g. { kind: 'pencil' }) to see it.`);
|
|
1164
|
+
this.sketch = props.sketch;
|
|
1165
|
+
this.sketchFill = props.sketchFill;
|
|
1166
|
+
this.sketchSeed = props.sketchSeed ?? (this.id !== void 0 ? hashStr(this.id) : 0);
|
|
1167
|
+
}
|
|
1168
|
+
draw(out) {
|
|
1169
|
+
const segs = this.pathSegs();
|
|
1170
|
+
if (this.sketch) return this.drawSketch(out, segs);
|
|
1171
|
+
const path = out.resource({
|
|
1172
|
+
kind: "path",
|
|
1173
|
+
segs
|
|
1174
|
+
});
|
|
1175
|
+
const fill = this.fill();
|
|
1176
|
+
if (fill) out.push({
|
|
1177
|
+
op: "fillPath",
|
|
1178
|
+
path,
|
|
1179
|
+
paint: toPaint(fill)
|
|
1180
|
+
});
|
|
1181
|
+
const stroke = this.stroke();
|
|
1182
|
+
const width = this.strokeWidth();
|
|
1183
|
+
if (stroke && width > 0) {
|
|
1184
|
+
const reveal = this.reveal();
|
|
1185
|
+
if (reveal < 1) emitDrawOnStroke(out, segs, {
|
|
1186
|
+
kind: "color",
|
|
1187
|
+
color: stroke
|
|
1188
|
+
}, { width }, reveal);
|
|
1189
|
+
else out.push({
|
|
1190
|
+
op: "strokePath",
|
|
1191
|
+
path,
|
|
1192
|
+
paint: {
|
|
1193
|
+
kind: "color",
|
|
1194
|
+
color: stroke
|
|
1195
|
+
},
|
|
1196
|
+
stroke: { width }
|
|
1197
|
+
});
|
|
1198
|
+
}
|
|
1199
|
+
}
|
|
1200
|
+
/** Hand-drawn render: solid fill (if any) under roughened, multi-pass strokes.
|
|
1201
|
+
* The seed is consumed fresh each draw, so re-evaluation is byte-identical. */
|
|
1202
|
+
drawSketch(out, segs) {
|
|
1203
|
+
const rng = random(this.sketchSeed >>> 0);
|
|
1204
|
+
const fill = this.fill();
|
|
1205
|
+
if (fill) {
|
|
1206
|
+
const path = out.resource({
|
|
1207
|
+
kind: "path",
|
|
1208
|
+
segs
|
|
1209
|
+
});
|
|
1210
|
+
out.push({
|
|
1211
|
+
op: "fillPath",
|
|
1212
|
+
path,
|
|
1213
|
+
paint: toPaint(fill)
|
|
1214
|
+
});
|
|
1215
|
+
}
|
|
1216
|
+
const { strokes, resolved } = roughen(segs, this.sketch, rng);
|
|
1217
|
+
const ink = this.stroke() || (typeof fill === "string" ? fill : "") || "#000000";
|
|
1218
|
+
if (this.sketchFill) {
|
|
1219
|
+
const clipPath = out.resource({
|
|
1220
|
+
kind: "path",
|
|
1221
|
+
segs
|
|
1222
|
+
});
|
|
1223
|
+
out.push({ op: "save" });
|
|
1224
|
+
out.push({
|
|
1225
|
+
op: "clip",
|
|
1226
|
+
path: clipPath,
|
|
1227
|
+
rule: "nonzero"
|
|
1228
|
+
});
|
|
1229
|
+
const hatch = hachureLines(segs, this.sketchFill, rng);
|
|
1230
|
+
if (hatch.length > 0) {
|
|
1231
|
+
const hp = out.resource({
|
|
1232
|
+
kind: "path",
|
|
1233
|
+
segs: hatch
|
|
1234
|
+
});
|
|
1235
|
+
out.push({
|
|
1236
|
+
op: "strokePath",
|
|
1237
|
+
path: hp,
|
|
1238
|
+
paint: {
|
|
1239
|
+
kind: "color",
|
|
1240
|
+
color: ink
|
|
1241
|
+
},
|
|
1242
|
+
stroke: {
|
|
1243
|
+
width: Math.max(1, resolved.width * .5),
|
|
1244
|
+
cap: "round"
|
|
1245
|
+
}
|
|
1246
|
+
});
|
|
1247
|
+
}
|
|
1248
|
+
out.push({ op: "restore" });
|
|
1249
|
+
}
|
|
1250
|
+
const reveal = this.reveal();
|
|
1251
|
+
const drawOn = reveal < 1;
|
|
1252
|
+
for (const passSegs of strokes) {
|
|
1253
|
+
if (passSegs.length === 0) continue;
|
|
1254
|
+
if (drawOn) {
|
|
1255
|
+
emitDrawOnStroke(out, passSegs, {
|
|
1256
|
+
kind: "color",
|
|
1257
|
+
color: ink
|
|
1258
|
+
}, {
|
|
1259
|
+
width: resolved.width,
|
|
1260
|
+
cap: "round",
|
|
1261
|
+
join: "round"
|
|
1262
|
+
}, reveal);
|
|
1263
|
+
continue;
|
|
1264
|
+
}
|
|
1265
|
+
const path = out.resource({
|
|
1266
|
+
kind: "path",
|
|
1267
|
+
segs: passSegs
|
|
1268
|
+
});
|
|
1269
|
+
out.push({
|
|
1270
|
+
op: "strokePath",
|
|
1271
|
+
path,
|
|
1272
|
+
paint: {
|
|
1273
|
+
kind: "color",
|
|
1274
|
+
color: ink
|
|
1275
|
+
},
|
|
1276
|
+
stroke: {
|
|
1277
|
+
width: resolved.width,
|
|
1278
|
+
cap: "round",
|
|
1279
|
+
join: "round",
|
|
1280
|
+
...resolved.dash ? { dash: resolved.dash } : {}
|
|
1281
|
+
}
|
|
1282
|
+
});
|
|
1283
|
+
}
|
|
1284
|
+
}
|
|
1285
|
+
};
|
|
1286
|
+
function initProp(sig, init) {
|
|
1287
|
+
if (typeof init === "function") sig.bindSource(init);
|
|
1288
|
+
else if (init !== void 0) sig.set(init);
|
|
1289
|
+
return sig;
|
|
1290
|
+
}
|
|
1291
|
+
/** Split a stroke path into its subpaths (each starting at an 'M'). */
|
|
1292
|
+
function splitContours(segs) {
|
|
1293
|
+
const out = [];
|
|
1294
|
+
let cur = null;
|
|
1295
|
+
for (const s of segs) if (s[0] === "M") {
|
|
1296
|
+
cur = [s];
|
|
1297
|
+
out.push(cur);
|
|
1298
|
+
} else if (cur) cur.push(s);
|
|
1299
|
+
return out;
|
|
1300
|
+
}
|
|
1301
|
+
/**
|
|
1302
|
+
* Emit a stroke that "draws on" by arc length — a retreating dash PER CONTOUR
|
|
1303
|
+
* (canvas restarts the dash phase at each subpath move, so each contour reveals
|
|
1304
|
+
* from its own start). Shared by sketched and plain stroked shapes.
|
|
1305
|
+
*/
|
|
1306
|
+
function emitDrawOnStroke(out, segs, paint, baseStroke, reveal) {
|
|
1307
|
+
for (const contour of splitContours(segs)) {
|
|
1308
|
+
const len = flatten(contour).reduce((s, p) => s + arcLength(p), 0);
|
|
1309
|
+
const path = out.resource({
|
|
1310
|
+
kind: "path",
|
|
1311
|
+
segs: contour
|
|
1312
|
+
});
|
|
1313
|
+
out.push({
|
|
1314
|
+
op: "strokePath",
|
|
1315
|
+
path,
|
|
1316
|
+
paint,
|
|
1317
|
+
stroke: {
|
|
1318
|
+
...baseStroke,
|
|
1319
|
+
dash: [len, len],
|
|
1320
|
+
dashOffset: len * (1 - reveal)
|
|
1321
|
+
}
|
|
1322
|
+
});
|
|
1323
|
+
}
|
|
1324
|
+
}
|
|
1325
|
+
/**
|
|
1326
|
+
* Coerce a `Path.data` init to a `PathValue`: an array of contour objects
|
|
1327
|
+
* passes through; anything else (a string, a number, …) is a construction-time
|
|
1328
|
+
* error. SVG `d` strings are NOT parsed here — that parser lives on the
|
|
1329
|
+
* tree-shakeable `@glissade/scene/path` subpath (kept off the base embed), so a
|
|
1330
|
+
* string `data` throws a clear error pointing at `pathFromSvg(d)`. Returns `[]`
|
|
1331
|
+
* for `undefined` (the empty-path default).
|
|
1332
|
+
*/
|
|
1333
|
+
function coercePathData(data) {
|
|
1334
|
+
if (data === void 0) return [];
|
|
1335
|
+
if (Array.isArray(data)) {
|
|
1336
|
+
if (data.every((c) => typeof c === "object" && c !== null && Array.isArray(c.v) && Array.isArray(c.in) && Array.isArray(c.out))) return data;
|
|
1337
|
+
}
|
|
1338
|
+
throw new TypeError(`Path.data expects PathValue (PathContour[]); for an SVG path 'd' string, parse it with pathFromSvg(d) from "@glissade/scene/path" (or window.glissade.pathFromSvg in the browser bundle)`);
|
|
1339
|
+
}
|
|
1340
|
+
/**
|
|
1341
|
+
* `PathSeg[]` → `PathValue` (Lottie vertex contours) — the inverse of
|
|
1342
|
+
* `Path.pathSegs`, so geometry from `roundedRectSegs` / `sketchStrokes` /
|
|
1343
|
+
* `flatten` can be placed on a `Path` node (to morph, motion-path, or draw-on
|
|
1344
|
+
* it). C/Q become an anchor + relative in/out tangents; L is a zero-tangent
|
|
1345
|
+
* vertex; E samples to vertices; Z closes the contour, folding the closing
|
|
1346
|
+
* tangent back onto the first vertex. Round-trips C-contours exactly.
|
|
1347
|
+
*/
|
|
1348
|
+
function pathFromSegs(segs) {
|
|
1349
|
+
const contours = [];
|
|
1350
|
+
let c = null;
|
|
1351
|
+
const push = (v, inT = [0, 0], outT = [0, 0]) => {
|
|
1352
|
+
c.v.push(v);
|
|
1353
|
+
c.in.push(inT);
|
|
1354
|
+
c.out.push(outT);
|
|
1355
|
+
};
|
|
1356
|
+
const last = () => c.v[c.v.length - 1];
|
|
1357
|
+
for (const s of segs) switch (s[0]) {
|
|
1358
|
+
case "M":
|
|
1359
|
+
c = {
|
|
1360
|
+
closed: false,
|
|
1361
|
+
v: [[s[1], s[2]]],
|
|
1362
|
+
in: [[0, 0]],
|
|
1363
|
+
out: [[0, 0]]
|
|
1364
|
+
};
|
|
1365
|
+
contours.push(c);
|
|
1366
|
+
break;
|
|
1367
|
+
case "L":
|
|
1368
|
+
if (c) push([s[1], s[2]]);
|
|
1369
|
+
break;
|
|
1370
|
+
case "C":
|
|
1371
|
+
if (c) {
|
|
1372
|
+
const p0 = last();
|
|
1373
|
+
c.out[c.out.length - 1] = [s[1] - p0[0], s[2] - p0[1]];
|
|
1374
|
+
push([s[5], s[6]], [s[3] - s[5], s[4] - s[6]]);
|
|
1375
|
+
}
|
|
1376
|
+
break;
|
|
1377
|
+
case "Q":
|
|
1378
|
+
if (c) {
|
|
1379
|
+
const p0 = last();
|
|
1380
|
+
const qx = s[1];
|
|
1381
|
+
const qy = s[2];
|
|
1382
|
+
const px = s[3];
|
|
1383
|
+
const py = s[4];
|
|
1384
|
+
c.out[c.out.length - 1] = [2 / 3 * (qx - p0[0]), 2 / 3 * (qy - p0[1])];
|
|
1385
|
+
push([px, py], [2 / 3 * (qx - px), 2 / 3 * (qy - py)]);
|
|
1386
|
+
}
|
|
1387
|
+
break;
|
|
1388
|
+
case "E":
|
|
1389
|
+
if (c) {
|
|
1390
|
+
const [, cx, cy, rx, ry, rot, a0, a1] = s;
|
|
1391
|
+
const cos = Math.cos(rot);
|
|
1392
|
+
const sin = Math.sin(rot);
|
|
1393
|
+
for (let k = 1; k <= 16; k++) {
|
|
1394
|
+
const ang = a0 + (a1 - a0) * (k / 16);
|
|
1395
|
+
const ex = rx * Math.cos(ang);
|
|
1396
|
+
const ey = ry * Math.sin(ang);
|
|
1397
|
+
push([cx + ex * cos - ey * sin, cy + ex * sin + ey * cos]);
|
|
1398
|
+
}
|
|
1399
|
+
}
|
|
1400
|
+
break;
|
|
1401
|
+
case "Z":
|
|
1402
|
+
if (c) {
|
|
1403
|
+
c.closed = true;
|
|
1404
|
+
const f = c.v[0];
|
|
1405
|
+
const l = last();
|
|
1406
|
+
if (c.v.length > 1 && Math.abs(f[0] - l[0]) < 1e-9 && Math.abs(f[1] - l[1]) < 1e-9) {
|
|
1407
|
+
c.in[0] = c.in[c.in.length - 1];
|
|
1408
|
+
c.v.pop();
|
|
1409
|
+
c.in.pop();
|
|
1410
|
+
c.out.pop();
|
|
1411
|
+
}
|
|
1412
|
+
}
|
|
1413
|
+
break;
|
|
1414
|
+
}
|
|
1415
|
+
return contours;
|
|
1416
|
+
}
|
|
1417
|
+
var Rect = class extends Shape {
|
|
1418
|
+
width;
|
|
1419
|
+
height;
|
|
1420
|
+
/** Corner radius; clamped to half the smaller dimension. radius = h/2 makes a pill. */
|
|
1421
|
+
cornerRadius;
|
|
1422
|
+
constructor(props = {}) {
|
|
1423
|
+
super(props);
|
|
1424
|
+
this.width = initProp(signal(0), props.width);
|
|
1425
|
+
this.height = initProp(signal(0), props.height);
|
|
1426
|
+
this.cornerRadius = initProp(signal(0), props.cornerRadius);
|
|
1427
|
+
this.registerTarget("width", this.width, "number");
|
|
1428
|
+
this.registerTarget("height", this.height, "number");
|
|
1429
|
+
this.registerTarget("cornerRadius", this.cornerRadius, "number");
|
|
1430
|
+
}
|
|
1431
|
+
intrinsicSize() {
|
|
1432
|
+
return {
|
|
1433
|
+
w: this.width(),
|
|
1434
|
+
h: this.height()
|
|
1435
|
+
};
|
|
1436
|
+
}
|
|
1437
|
+
pathSegs() {
|
|
1438
|
+
const w = this.width();
|
|
1439
|
+
const h = this.height();
|
|
1440
|
+
const r = Math.min(Math.max(0, this.cornerRadius()), w / 2, h / 2);
|
|
1441
|
+
return roundedRectSegs(-w / 2, -h / 2, w, h, r);
|
|
1442
|
+
}
|
|
1443
|
+
};
|
|
1444
|
+
var Circle = class extends Shape {
|
|
1445
|
+
radius;
|
|
1446
|
+
constructor(props = {}) {
|
|
1447
|
+
super(props);
|
|
1448
|
+
this.radius = initProp(signal(0), props.radius);
|
|
1449
|
+
this.registerTarget("radius", this.radius, "number");
|
|
1450
|
+
}
|
|
1451
|
+
intrinsicSize() {
|
|
1452
|
+
const d = this.radius() * 2;
|
|
1453
|
+
return {
|
|
1454
|
+
w: d,
|
|
1455
|
+
h: d
|
|
1456
|
+
};
|
|
1457
|
+
}
|
|
1458
|
+
pathSegs() {
|
|
1459
|
+
const r = this.radius();
|
|
1460
|
+
return [[
|
|
1461
|
+
"E",
|
|
1462
|
+
0,
|
|
1463
|
+
0,
|
|
1464
|
+
r,
|
|
1465
|
+
r,
|
|
1466
|
+
0,
|
|
1467
|
+
0,
|
|
1468
|
+
Math.PI * 2
|
|
1469
|
+
], ["Z"]];
|
|
1470
|
+
}
|
|
1471
|
+
};
|
|
1472
|
+
/**
|
|
1473
|
+
* Arbitrary bezier geometry — the Lottie-import landing spot and the target
|
|
1474
|
+
* of native path morphs. Coordinates are node-local (the node origin is
|
|
1475
|
+
* wherever the author put 0,0); flow placement uses the control-point bounds.
|
|
1476
|
+
*/
|
|
1477
|
+
var Path = class extends Shape {
|
|
1478
|
+
data;
|
|
1479
|
+
constructor(props = {}) {
|
|
1480
|
+
super(props);
|
|
1481
|
+
const init = props.data;
|
|
1482
|
+
const coerced = typeof init === "function" ? () => coercePathData(init()) : init !== void 0 ? coercePathData(init) : void 0;
|
|
1483
|
+
this.data = initProp(signal([]), coerced);
|
|
1484
|
+
this.registerTarget("d", this.data, "path");
|
|
1485
|
+
}
|
|
1486
|
+
/** Control-point bounding box (conservative: contains the true curve). */
|
|
1487
|
+
bounds() {
|
|
1488
|
+
let minX = Infinity;
|
|
1489
|
+
let minY = Infinity;
|
|
1490
|
+
let maxX = -Infinity;
|
|
1491
|
+
let maxY = -Infinity;
|
|
1492
|
+
for (const c of this.data()) for (let i = 0; i < c.v.length; i++) {
|
|
1493
|
+
const vx = c.v[i][0];
|
|
1494
|
+
const vy = c.v[i][1];
|
|
1495
|
+
const candidates = [
|
|
1496
|
+
[vx, vy],
|
|
1497
|
+
[vx + c.in[i][0], vy + c.in[i][1]],
|
|
1498
|
+
[vx + c.out[i][0], vy + c.out[i][1]]
|
|
1499
|
+
];
|
|
1500
|
+
for (const p of candidates) {
|
|
1501
|
+
if (p[0] < minX) minX = p[0];
|
|
1502
|
+
if (p[1] < minY) minY = p[1];
|
|
1503
|
+
if (p[0] > maxX) maxX = p[0];
|
|
1504
|
+
if (p[1] > maxY) maxY = p[1];
|
|
1505
|
+
}
|
|
1506
|
+
}
|
|
1507
|
+
if (minX > maxX) return {
|
|
1508
|
+
minX: 0,
|
|
1509
|
+
minY: 0,
|
|
1510
|
+
maxX: 0,
|
|
1511
|
+
maxY: 0
|
|
1512
|
+
};
|
|
1513
|
+
return {
|
|
1514
|
+
minX,
|
|
1515
|
+
minY,
|
|
1516
|
+
maxX,
|
|
1517
|
+
maxY
|
|
1518
|
+
};
|
|
1519
|
+
}
|
|
1520
|
+
intrinsicSize() {
|
|
1521
|
+
const b = this.bounds();
|
|
1522
|
+
return {
|
|
1523
|
+
w: b.maxX - b.minX,
|
|
1524
|
+
h: b.maxY - b.minY
|
|
1525
|
+
};
|
|
1526
|
+
}
|
|
1527
|
+
/** Geometry is node-local, not center-anchored: offset to the box's actual top-left. */
|
|
1528
|
+
drawOffset() {
|
|
1529
|
+
const b = this.bounds();
|
|
1530
|
+
return {
|
|
1531
|
+
x: b.minX,
|
|
1532
|
+
y: b.minY
|
|
1533
|
+
};
|
|
1534
|
+
}
|
|
1535
|
+
pathSegs() {
|
|
1536
|
+
const segs = [];
|
|
1537
|
+
for (const c of this.data()) {
|
|
1538
|
+
const n = c.v.length;
|
|
1539
|
+
if (n === 0) continue;
|
|
1540
|
+
segs.push([
|
|
1541
|
+
"M",
|
|
1542
|
+
c.v[0][0],
|
|
1543
|
+
c.v[0][1]
|
|
1544
|
+
]);
|
|
1545
|
+
for (let i = 0; i < n - 1; i++) segs.push([
|
|
1546
|
+
"C",
|
|
1547
|
+
c.v[i][0] + c.out[i][0],
|
|
1548
|
+
c.v[i][1] + c.out[i][1],
|
|
1549
|
+
c.v[i + 1][0] + c.in[i + 1][0],
|
|
1550
|
+
c.v[i + 1][1] + c.in[i + 1][1],
|
|
1551
|
+
c.v[i + 1][0],
|
|
1552
|
+
c.v[i + 1][1]
|
|
1553
|
+
]);
|
|
1554
|
+
if (c.closed && n > 1) {
|
|
1555
|
+
segs.push([
|
|
1556
|
+
"C",
|
|
1557
|
+
c.v[n - 1][0] + c.out[n - 1][0],
|
|
1558
|
+
c.v[n - 1][1] + c.out[n - 1][1],
|
|
1559
|
+
c.v[0][0] + c.in[0][0],
|
|
1560
|
+
c.v[0][1] + c.in[0][1],
|
|
1561
|
+
c.v[0][0],
|
|
1562
|
+
c.v[0][1]
|
|
1563
|
+
]);
|
|
1564
|
+
segs.push(["Z"]);
|
|
1565
|
+
}
|
|
1566
|
+
}
|
|
1567
|
+
return segs;
|
|
1568
|
+
}
|
|
1569
|
+
};
|
|
1570
|
+
var ImageNode = class extends Node {
|
|
1571
|
+
/** Marks this node as referencing a kind 'image' timeline asset (§2.3). */
|
|
1572
|
+
static assetKind = "image";
|
|
1573
|
+
assetId;
|
|
1574
|
+
width;
|
|
1575
|
+
height;
|
|
1576
|
+
constructor(props) {
|
|
1577
|
+
super(props);
|
|
1578
|
+
this.assetId = props.assetId;
|
|
1579
|
+
this.width = initProp(signal(0), props.width);
|
|
1580
|
+
this.height = initProp(signal(0), props.height);
|
|
1581
|
+
this.registerTarget("width", this.width, "number");
|
|
1582
|
+
this.registerTarget("height", this.height, "number");
|
|
1583
|
+
}
|
|
1584
|
+
intrinsicSize() {
|
|
1585
|
+
return {
|
|
1586
|
+
w: this.width(),
|
|
1587
|
+
h: this.height()
|
|
1588
|
+
};
|
|
1589
|
+
}
|
|
1590
|
+
draw(out) {
|
|
1591
|
+
const w = this.width();
|
|
1592
|
+
const h = this.height();
|
|
1593
|
+
if (w <= 0 || h <= 0) return;
|
|
1594
|
+
const image = out.resource({
|
|
1595
|
+
kind: "image",
|
|
1596
|
+
assetId: this.assetId
|
|
1597
|
+
});
|
|
1598
|
+
out.push({
|
|
1599
|
+
op: "drawImage",
|
|
1600
|
+
image,
|
|
1601
|
+
dst: {
|
|
1602
|
+
x: -w / 2,
|
|
1603
|
+
y: -h / 2,
|
|
1604
|
+
w,
|
|
1605
|
+
h
|
|
1606
|
+
}
|
|
1607
|
+
});
|
|
1608
|
+
}
|
|
1609
|
+
};
|
|
1610
|
+
/**
|
|
1611
|
+
* Pure given a warmed VideoFrameSource (§3.8): emit() does only the
|
|
1612
|
+
* frame-indexed media-time arithmetic — mediaT = trimStart + (t - at) * rate —
|
|
1613
|
+
* and references the exact source-grid frame; backends resolve it.
|
|
1614
|
+
*/
|
|
1615
|
+
var Video = class extends Node {
|
|
1616
|
+
/** Marks this node as referencing a kind 'video' timeline asset (§3.8). */
|
|
1617
|
+
static assetKind = "video";
|
|
1618
|
+
assetId;
|
|
1619
|
+
at;
|
|
1620
|
+
trimStart;
|
|
1621
|
+
playbackRate;
|
|
1622
|
+
clipDuration;
|
|
1623
|
+
sourceFps;
|
|
1624
|
+
width;
|
|
1625
|
+
height;
|
|
1626
|
+
constructor(props) {
|
|
1627
|
+
super(props);
|
|
1628
|
+
this.assetId = props.assetId;
|
|
1629
|
+
this.at = props.at ?? 0;
|
|
1630
|
+
this.trimStart = props.trimStart ?? 0;
|
|
1631
|
+
this.playbackRate = props.playbackRate ?? 1;
|
|
1632
|
+
this.clipDuration = props.clipDuration;
|
|
1633
|
+
this.sourceFps = props.sourceFps;
|
|
1634
|
+
this.width = initProp(signal(0), props.width);
|
|
1635
|
+
this.height = initProp(signal(0), props.height);
|
|
1636
|
+
this.registerTarget("width", this.width, "number");
|
|
1637
|
+
this.registerTarget("height", this.height, "number");
|
|
1638
|
+
}
|
|
1639
|
+
/** Frame-indexed media time for timeline time t; null when outside the clip. */
|
|
1640
|
+
mediaTime(t) {
|
|
1641
|
+
const local = (t - this.at) * this.playbackRate;
|
|
1642
|
+
if (local < 0) return null;
|
|
1643
|
+
if (this.clipDuration !== void 0 && t - this.at >= this.clipDuration) return null;
|
|
1644
|
+
const mediaT = this.trimStart + local;
|
|
1645
|
+
if (this.sourceFps !== void 0) return Math.floor(mediaT * this.sourceFps + 1e-9) / this.sourceFps;
|
|
1646
|
+
return mediaT;
|
|
1647
|
+
}
|
|
1648
|
+
draw(out, ctx) {
|
|
1649
|
+
const mediaT = this.mediaTime(ctx.time);
|
|
1650
|
+
if (mediaT === null) return;
|
|
1651
|
+
const w = this.width();
|
|
1652
|
+
const h = this.height();
|
|
1653
|
+
if (w <= 0 || h <= 0) return;
|
|
1654
|
+
const image = out.resource({
|
|
1655
|
+
kind: "videoFrame",
|
|
1656
|
+
assetId: this.assetId,
|
|
1657
|
+
mediaT
|
|
1658
|
+
});
|
|
1659
|
+
out.push({
|
|
1660
|
+
op: "drawImage",
|
|
1661
|
+
image,
|
|
1662
|
+
dst: {
|
|
1663
|
+
x: -w / 2,
|
|
1664
|
+
y: -h / 2,
|
|
1665
|
+
w,
|
|
1666
|
+
h
|
|
1667
|
+
}
|
|
1668
|
+
});
|
|
1669
|
+
}
|
|
1670
|
+
};
|
|
1671
|
+
var Text = class extends Node {
|
|
1672
|
+
text;
|
|
1673
|
+
fill;
|
|
1674
|
+
fontSize;
|
|
1675
|
+
fontFamily;
|
|
1676
|
+
fontWeight;
|
|
1677
|
+
fontStyle;
|
|
1678
|
+
align;
|
|
1679
|
+
width;
|
|
1680
|
+
lineHeight;
|
|
1681
|
+
reveal;
|
|
1682
|
+
constructor(props = {}) {
|
|
1683
|
+
super(props);
|
|
1684
|
+
this.text = initProp(signal(""), props.text);
|
|
1685
|
+
this.fill = initProp(signal("#000000"), props.fill);
|
|
1686
|
+
this.fontSize = initProp(signal(16), props.fontSize);
|
|
1687
|
+
this.fontFamily = props.fontFamily ?? "sans-serif";
|
|
1688
|
+
this.fontWeight = props.fontWeight ?? 400;
|
|
1689
|
+
this.fontStyle = props.fontStyle ?? "normal";
|
|
1690
|
+
this.align = props.align ?? "left";
|
|
1691
|
+
this.width = initProp(signal(0), props.width);
|
|
1692
|
+
this.lineHeight = props.lineHeight ?? 1.25;
|
|
1693
|
+
this.reveal = initProp(signal(Number.POSITIVE_INFINITY), props.reveal);
|
|
1694
|
+
this.registerTarget("width", this.width, "number");
|
|
1695
|
+
this.registerTarget("text", this.text, "string");
|
|
1696
|
+
this.registerTarget("fill", this.fill, "color");
|
|
1697
|
+
this.registerTarget("fontSize", this.fontSize, "number");
|
|
1698
|
+
this.registerTarget("reveal", this.reveal, "number");
|
|
1699
|
+
}
|
|
1700
|
+
intrinsicSize(measurer) {
|
|
1701
|
+
const text = this.text();
|
|
1702
|
+
if (!text) return {
|
|
1703
|
+
w: 0,
|
|
1704
|
+
h: 0
|
|
1705
|
+
};
|
|
1706
|
+
const font = {
|
|
1707
|
+
family: this.fontFamily,
|
|
1708
|
+
size: this.fontSize(),
|
|
1709
|
+
weight: this.fontWeight,
|
|
1710
|
+
...this.fontStyle === "italic" ? { style: "italic" } : {}
|
|
1711
|
+
};
|
|
1712
|
+
const maxWidth = this.width();
|
|
1713
|
+
const lines = breakLines(text, font, maxWidth > 0 ? maxWidth : void 0, measurer);
|
|
1714
|
+
const widest = Math.max(...lines.map((l) => quantize(measurer.measureText(l, font).width)), 0);
|
|
1715
|
+
return {
|
|
1716
|
+
w: maxWidth > 0 ? maxWidth : widest,
|
|
1717
|
+
h: quantize(font.size * this.lineHeight) * lines.length
|
|
1718
|
+
};
|
|
1719
|
+
}
|
|
1720
|
+
/** Text draws from a baseline origin at its align edge, not a center (§3.6). */
|
|
1721
|
+
drawOffset(measurer) {
|
|
1722
|
+
const m = measurer ?? this.measurerSource?.() ?? fallbackMeasurer();
|
|
1723
|
+
const size = this.intrinsicSize(m);
|
|
1724
|
+
const font = {
|
|
1725
|
+
family: this.fontFamily,
|
|
1726
|
+
size: this.fontSize(),
|
|
1727
|
+
weight: this.fontWeight,
|
|
1728
|
+
...this.fontStyle === "italic" ? { style: "italic" } : {}
|
|
1729
|
+
};
|
|
1730
|
+
const firstLine = breakLines(this.text(), font, this.width() > 0 ? this.width() : void 0, m)[0] ?? "";
|
|
1731
|
+
const ascent = m.measureText(firstLine, font).ascent;
|
|
1732
|
+
return {
|
|
1733
|
+
x: this.align === "left" ? 0 : this.align === "center" ? -size.w / 2 : -size.w,
|
|
1734
|
+
y: -ascent
|
|
1735
|
+
};
|
|
1736
|
+
}
|
|
1737
|
+
/**
|
|
1738
|
+
* The wrapped box {w, h}, measured with the scene's active measurer — the
|
|
1739
|
+
* same numbers Layout flows with, public so bindings never hand-calculate
|
|
1740
|
+
* text dimensions (e.g. underline width = () => title.measuredSize().w).
|
|
1741
|
+
*/
|
|
1742
|
+
measuredSize(measurer) {
|
|
1743
|
+
return this.intrinsicSize(measurer ?? this.measurerSource?.() ?? fallbackMeasurer());
|
|
1744
|
+
}
|
|
1745
|
+
/**
|
|
1746
|
+
* Per-line ink boxes in this node's DRAW space (origin = first baseline at
|
|
1747
|
+
* the align edge), from the same breakLines pass that draws. Pull-based:
|
|
1748
|
+
* re-measures when text/font/width animate. Blank lines (from '\n\n')
|
|
1749
|
+
* produce no box. The substrate for highlights, underlines, per-line
|
|
1750
|
+
* reveals, selections.
|
|
1751
|
+
*/
|
|
1752
|
+
lineBoxes(measurer) {
|
|
1753
|
+
const m = measurer ?? this.measurerSource?.() ?? fallbackMeasurer();
|
|
1754
|
+
const text = this.text();
|
|
1755
|
+
if (!text) return [];
|
|
1756
|
+
const font = {
|
|
1757
|
+
family: this.fontFamily,
|
|
1758
|
+
size: this.fontSize(),
|
|
1759
|
+
weight: this.fontWeight,
|
|
1760
|
+
...this.fontStyle === "italic" ? { style: "italic" } : {}
|
|
1761
|
+
};
|
|
1762
|
+
const maxWidth = this.width();
|
|
1763
|
+
const lines = breakLines(text, font, maxWidth > 0 ? maxWidth : void 0, m);
|
|
1764
|
+
const step = quantize(font.size * this.lineHeight);
|
|
1765
|
+
const boxes = [];
|
|
1766
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1767
|
+
const line = lines[i];
|
|
1768
|
+
if (!line) continue;
|
|
1769
|
+
const met = m.measureText(line, font);
|
|
1770
|
+
const w = quantize(met.width);
|
|
1771
|
+
const x = this.align === "left" ? 0 : this.align === "center" ? -w / 2 : -w;
|
|
1772
|
+
boxes.push({
|
|
1773
|
+
text: line,
|
|
1774
|
+
x,
|
|
1775
|
+
y: i * step - met.ascent,
|
|
1776
|
+
w,
|
|
1777
|
+
h: met.ascent + met.descent
|
|
1778
|
+
});
|
|
1779
|
+
}
|
|
1780
|
+
return boxes;
|
|
1781
|
+
}
|
|
1782
|
+
/**
|
|
1783
|
+
* Per-word ink boxes within each laid-out line — the SAME segmentation the
|
|
1784
|
+
* breaker flows (Intl.Segmenter boundaries, punctuation glued), positioned
|
|
1785
|
+
* by cumulative prefix advances so cross-word kerning is exact and word
|
|
1786
|
+
* widths sum to the line's width. Whitespace contributes advance but no
|
|
1787
|
+
* box. Pair index-wise with a narration manifest's word timestamps for
|
|
1788
|
+
* karaoke; draw your own rects for sub-line multi-color token work.
|
|
1789
|
+
*/
|
|
1790
|
+
wordBoxes(measurer) {
|
|
1791
|
+
const m = measurer ?? this.measurerSource?.() ?? fallbackMeasurer();
|
|
1792
|
+
const text = this.text();
|
|
1793
|
+
if (!text) return [];
|
|
1794
|
+
const font = {
|
|
1795
|
+
family: this.fontFamily,
|
|
1796
|
+
size: this.fontSize(),
|
|
1797
|
+
weight: this.fontWeight,
|
|
1798
|
+
...this.fontStyle === "italic" ? { style: "italic" } : {}
|
|
1799
|
+
};
|
|
1800
|
+
const maxWidth = this.width();
|
|
1801
|
+
const lines = breakLines(text, font, maxWidth > 0 ? maxWidth : void 0, m);
|
|
1802
|
+
const step = quantize(font.size * this.lineHeight);
|
|
1803
|
+
const boxes = [];
|
|
1804
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1805
|
+
const line = lines[i];
|
|
1806
|
+
if (!line) continue;
|
|
1807
|
+
const met = m.measureText(line, font);
|
|
1808
|
+
const lineW = quantize(met.width);
|
|
1809
|
+
const lineX = this.align === "left" ? 0 : this.align === "center" ? -lineW / 2 : -lineW;
|
|
1810
|
+
const y = i * step - met.ascent;
|
|
1811
|
+
const h = met.ascent + met.descent;
|
|
1812
|
+
let prefix = "";
|
|
1813
|
+
for (const seg of segmentWords(line)) {
|
|
1814
|
+
const start = prefix;
|
|
1815
|
+
prefix += seg;
|
|
1816
|
+
const word = seg.trim();
|
|
1817
|
+
if (word === "") continue;
|
|
1818
|
+
const lead = seg.length - seg.trimStart().length;
|
|
1819
|
+
const before = m.measureText(start + seg.slice(0, lead), font).width;
|
|
1820
|
+
const after = m.measureText(start + seg.trimEnd(), font).width;
|
|
1821
|
+
boxes.push({
|
|
1822
|
+
text: word,
|
|
1823
|
+
line: i,
|
|
1824
|
+
x: lineX + before,
|
|
1825
|
+
y,
|
|
1826
|
+
w: after - before,
|
|
1827
|
+
h
|
|
1828
|
+
});
|
|
1829
|
+
}
|
|
1830
|
+
}
|
|
1831
|
+
return boxes;
|
|
1832
|
+
}
|
|
1833
|
+
/**
|
|
1834
|
+
* The laid-out grapheme stream the typewriter reveal advances over — every
|
|
1835
|
+
* grapheme of every wrapped line, in reading order (soft-wrap whitespace is
|
|
1836
|
+
* dropped by the breaker, exactly as drawn, so draw/revealHead/revealSchedule
|
|
1837
|
+
* all agree). Pull-based; its length is the `reveal` count that shows
|
|
1838
|
+
* everything. Author a per-keystroke staircase straight off it:
|
|
1839
|
+
*
|
|
1840
|
+
* const g = title.graphemes();
|
|
1841
|
+
* track('title/reveal', 'number',
|
|
1842
|
+
* g.map((_, i) => key(t0 + i * 0.05, i + 1, { interp: 'hold' })));
|
|
1843
|
+
*/
|
|
1844
|
+
graphemes(measurer) {
|
|
1845
|
+
const m = measurer ?? this.measurerSource?.() ?? fallbackMeasurer();
|
|
1846
|
+
const text = this.text();
|
|
1847
|
+
if (!text) return [];
|
|
1848
|
+
const font = {
|
|
1849
|
+
family: this.fontFamily,
|
|
1850
|
+
size: this.fontSize(),
|
|
1851
|
+
weight: this.fontWeight,
|
|
1852
|
+
...this.fontStyle === "italic" ? { style: "italic" } : {}
|
|
1853
|
+
};
|
|
1854
|
+
const maxWidth = this.width();
|
|
1855
|
+
const lines = breakLines(text, font, maxWidth > 0 ? maxWidth : void 0, m);
|
|
1856
|
+
const out = [];
|
|
1857
|
+
for (const line of lines) for (const g of segmentGraphemes(line)) out.push(g);
|
|
1858
|
+
return out;
|
|
1859
|
+
}
|
|
1860
|
+
/**
|
|
1861
|
+
* Draw-space position of the reveal head — the caret point just after the
|
|
1862
|
+
* last revealed grapheme, for the current `reveal` value. Drives TextCursor;
|
|
1863
|
+
* honours align and wrap exactly like wordBoxes(). At reveal 0 it sits at the
|
|
1864
|
+
* start of the first line; fully revealed, at the end of the last line.
|
|
1865
|
+
*/
|
|
1866
|
+
revealHead(measurer) {
|
|
1867
|
+
const m = measurer ?? this.measurerSource?.() ?? fallbackMeasurer();
|
|
1868
|
+
const font = {
|
|
1869
|
+
family: this.fontFamily,
|
|
1870
|
+
size: this.fontSize(),
|
|
1871
|
+
weight: this.fontWeight,
|
|
1872
|
+
...this.fontStyle === "italic" ? { style: "italic" } : {}
|
|
1873
|
+
};
|
|
1874
|
+
const maxWidth = this.width();
|
|
1875
|
+
const lines = breakLines(this.text(), font, maxWidth > 0 ? maxWidth : void 0, m);
|
|
1876
|
+
const step = quantize(font.size * this.lineHeight);
|
|
1877
|
+
const total = lines.reduce((n, l) => n + segmentGraphemes(l).length, 0);
|
|
1878
|
+
const revealRaw = this.reveal();
|
|
1879
|
+
const shown = Math.max(0, Math.min(Number.isFinite(revealRaw) ? Math.floor(revealRaw) : total, total));
|
|
1880
|
+
let remaining = shown;
|
|
1881
|
+
let last = {
|
|
1882
|
+
x: 0,
|
|
1883
|
+
y: 0,
|
|
1884
|
+
h: 0,
|
|
1885
|
+
line: 0,
|
|
1886
|
+
index: shown
|
|
1887
|
+
};
|
|
1888
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1889
|
+
const line = lines[i] ?? "";
|
|
1890
|
+
const met = m.measureText(line, font);
|
|
1891
|
+
const lineW = quantize(met.width);
|
|
1892
|
+
const lineX = this.align === "left" ? 0 : this.align === "center" ? -lineW / 2 : -lineW;
|
|
1893
|
+
const y = i * step - met.ascent;
|
|
1894
|
+
const h = met.ascent + met.descent;
|
|
1895
|
+
const gs = segmentGraphemes(line);
|
|
1896
|
+
if (remaining <= gs.length) return {
|
|
1897
|
+
x: lineX + (remaining <= 0 ? 0 : m.measureText(gs.slice(0, remaining).join(""), font).width),
|
|
1898
|
+
y,
|
|
1899
|
+
h,
|
|
1900
|
+
line: i,
|
|
1901
|
+
index: shown
|
|
1902
|
+
};
|
|
1903
|
+
remaining -= gs.length;
|
|
1904
|
+
last = {
|
|
1905
|
+
x: lineX + met.width,
|
|
1906
|
+
y,
|
|
1907
|
+
h,
|
|
1908
|
+
line: i,
|
|
1909
|
+
index: shown
|
|
1910
|
+
};
|
|
1911
|
+
}
|
|
1912
|
+
return last;
|
|
1913
|
+
}
|
|
1914
|
+
draw(out, ctx) {
|
|
1915
|
+
const text = this.text();
|
|
1916
|
+
if (!text) return;
|
|
1917
|
+
const font = {
|
|
1918
|
+
family: this.fontFamily,
|
|
1919
|
+
size: this.fontSize(),
|
|
1920
|
+
weight: this.fontWeight,
|
|
1921
|
+
...this.fontStyle === "italic" ? { style: "italic" } : {}
|
|
1922
|
+
};
|
|
1923
|
+
const maxWidth = this.width();
|
|
1924
|
+
const lines = breakLines(text, font, maxWidth > 0 ? maxWidth : void 0, ctx.measurer);
|
|
1925
|
+
const step = quantize(font.size * this.lineHeight);
|
|
1926
|
+
const revealRaw = this.reveal();
|
|
1927
|
+
const masked = Number.isFinite(revealRaw);
|
|
1928
|
+
let remaining = masked ? Math.max(0, Math.floor(revealRaw)) : 0;
|
|
1929
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1930
|
+
const line = lines[i];
|
|
1931
|
+
if (!line) continue;
|
|
1932
|
+
if (!masked) {
|
|
1933
|
+
out.push({
|
|
1934
|
+
op: "fillText",
|
|
1935
|
+
text: line,
|
|
1936
|
+
font,
|
|
1937
|
+
paint: {
|
|
1938
|
+
kind: "color",
|
|
1939
|
+
color: this.fill()
|
|
1940
|
+
},
|
|
1941
|
+
x: 0,
|
|
1942
|
+
y: i * step,
|
|
1943
|
+
...this.align !== "left" ? { align: this.align } : {}
|
|
1944
|
+
});
|
|
1945
|
+
continue;
|
|
1946
|
+
}
|
|
1947
|
+
if (remaining <= 0) break;
|
|
1948
|
+
const gs = segmentGraphemes(line);
|
|
1949
|
+
const show = Math.min(remaining, gs.length);
|
|
1950
|
+
remaining -= show;
|
|
1951
|
+
if (show === gs.length) out.push({
|
|
1952
|
+
op: "fillText",
|
|
1953
|
+
text: line,
|
|
1954
|
+
font,
|
|
1955
|
+
paint: {
|
|
1956
|
+
kind: "color",
|
|
1957
|
+
color: this.fill()
|
|
1958
|
+
},
|
|
1959
|
+
x: 0,
|
|
1960
|
+
y: i * step,
|
|
1961
|
+
...this.align !== "left" ? { align: this.align } : {}
|
|
1962
|
+
});
|
|
1963
|
+
else {
|
|
1964
|
+
const lineW = quantize(ctx.measurer.measureText(line, font).width);
|
|
1965
|
+
const lineX = this.align === "left" ? 0 : this.align === "center" ? -lineW / 2 : -lineW;
|
|
1966
|
+
out.push({
|
|
1967
|
+
op: "fillText",
|
|
1968
|
+
text: gs.slice(0, show).join(""),
|
|
1969
|
+
font,
|
|
1970
|
+
paint: {
|
|
1971
|
+
kind: "color",
|
|
1972
|
+
color: this.fill()
|
|
1973
|
+
},
|
|
1974
|
+
x: lineX,
|
|
1975
|
+
y: i * step
|
|
1976
|
+
});
|
|
1977
|
+
}
|
|
1978
|
+
}
|
|
1979
|
+
}
|
|
1980
|
+
};
|
|
1981
|
+
/**
|
|
1982
|
+
* Pure per-grapheme schedule from a Text and its reveal track — geometry from
|
|
1983
|
+
* the text, timing from the track. A grapheme's time is the first key whose
|
|
1984
|
+
* value reveals it (value >= index + 1); graphemes the track never reaches are
|
|
1985
|
+
* omitted. The single source SFX keystroke-sync consumes (keystrokeClips()):
|
|
1986
|
+
* one click per mark at `at: mark.time`, char-class policy (skip space/newline,
|
|
1987
|
+
* pick a sample) decided downstream from `mark.grapheme`.
|
|
1988
|
+
*/
|
|
1989
|
+
function revealSchedule(text, reveal, measurer) {
|
|
1990
|
+
const m = measurer ?? text.measurerSource?.() ?? fallbackMeasurer();
|
|
1991
|
+
const src = text.text();
|
|
1992
|
+
if (!src) return [];
|
|
1993
|
+
const font = {
|
|
1994
|
+
family: text.fontFamily,
|
|
1995
|
+
size: text.fontSize(),
|
|
1996
|
+
weight: text.fontWeight,
|
|
1997
|
+
...text.fontStyle === "italic" ? { style: "italic" } : {}
|
|
1998
|
+
};
|
|
1999
|
+
const maxWidth = text.width();
|
|
2000
|
+
const lines = breakLines(src, font, maxWidth > 0 ? maxWidth : void 0, m);
|
|
2001
|
+
const step = quantize(font.size * text.lineHeight);
|
|
2002
|
+
const keys = reveal.keys;
|
|
2003
|
+
const marks = [];
|
|
2004
|
+
let k = 0;
|
|
2005
|
+
for (let li = 0; li < lines.length; li++) {
|
|
2006
|
+
const line = lines[li];
|
|
2007
|
+
if (!line) continue;
|
|
2008
|
+
const met = m.measureText(line, font);
|
|
2009
|
+
const lineW = quantize(met.width);
|
|
2010
|
+
const lineX = text.align === "left" ? 0 : text.align === "center" ? -lineW / 2 : -lineW;
|
|
2011
|
+
const y = li * step - met.ascent;
|
|
2012
|
+
let prefix = "";
|
|
2013
|
+
for (const g of segmentGraphemes(line)) {
|
|
2014
|
+
prefix += g;
|
|
2015
|
+
const need = k + 1;
|
|
2016
|
+
let time = Number.POSITIVE_INFINITY;
|
|
2017
|
+
for (const key of keys) if (key.value >= need) {
|
|
2018
|
+
time = key.t;
|
|
2019
|
+
break;
|
|
2020
|
+
}
|
|
2021
|
+
if (Number.isFinite(time)) marks.push({
|
|
2022
|
+
charIndex: k,
|
|
2023
|
+
grapheme: g,
|
|
2024
|
+
time,
|
|
2025
|
+
x: lineX + m.measureText(prefix, font).width,
|
|
2026
|
+
y,
|
|
2027
|
+
line: li
|
|
2028
|
+
});
|
|
2029
|
+
k++;
|
|
2030
|
+
}
|
|
2031
|
+
}
|
|
2032
|
+
return marks;
|
|
2033
|
+
}
|
|
2034
|
+
//#endregion
|
|
2035
|
+
export { segmentGraphemes as A, collapseReplacer as B, Node as C, estimatingMeasurer as D, breakLines as E, filtersToCanvasFilter as F, IDENTITY as G, formatDisplayDiff as H, glow as I, invert as J, applyToPoint as K, validateFilters as L, setDefaultMeasurer as M, FilterValidationError as N, fallbackMeasurer as O, createDisplayListBuilder as P, DL_SNAPSHOT_VERSION as R, validateSketch as S, MEASURE_QUANTUM_PX as T, parseDisplaySnapshot as U, diffDisplayLists as V, serializeDisplayList as W, multiply as X, matEquals as Y, hashStr as _, Path as a, sketchStrokes as b, Video as c, revealSchedule as d, roundedRectSegs as f, hachureLines as g, flatten as h, ImageNode as i, segmentWords as j, quantize as k, coercePathData as l, arcLength as m, Custom as n, Rect as o, SketchValidationError as p, fromTRS as q, Group as r, Text as s, Circle as t, pathFromSegs as u, resolveSketch as v, resolveAnchor as w, validateHachure as x, roughen as y, DlSnapshotError as z };
|