@glissade/scene 0.19.0 → 0.20.0-pre.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,384 @@
1
+ import { H as IDENTITY, K as matEquals, L as createDisplayListBuilder, V as collapseReplacer, f as roundedRectSegs, p as Node, r as Group } from "./nodes.js";
2
+ import { a as evaluate } from "./scene.js";
3
+ import { signal, vec2Signal } from "@glissade/core";
4
+ //#region src/displayDiff.ts
5
+ /** A flat, stable JSON value for one command with its resource ids INLINED to content. */
6
+ function commandView(cmd, resources) {
7
+ const out = {};
8
+ for (const [k, v] of Object.entries(cmd)) out[k] = v;
9
+ if (cmd.op === "clip" || cmd.op === "fillPath" || cmd.op === "strokePath") out["path"] = resources[cmd.path];
10
+ else if (cmd.op === "drawImage") out["image"] = resources[cmd.image];
11
+ return out;
12
+ }
13
+ const stable = (v) => JSON.stringify(v, collapseReplacer);
14
+ /** Field-level diff of two same-op command views (shallow over top-level props). */
15
+ function diffFields(a, b) {
16
+ const changes = [];
17
+ const keys = new Set([...Object.keys(a), ...Object.keys(b)]);
18
+ for (const k of keys) {
19
+ if (k === "op") continue;
20
+ if (stable(a[k]) !== stable(b[k])) changes.push({
21
+ path: k,
22
+ from: a[k],
23
+ to: b[k]
24
+ });
25
+ }
26
+ return changes;
27
+ }
28
+ /**
29
+ * Index-aligned positional diff of two DisplayLists. Command i in `a` is
30
+ * compared to command i in `b`; trailing commands become `add`/`remove`. A
31
+ * single insert/remove cascades — this is the documented v1 cliff.
32
+ */
33
+ function diffDisplayLists(a, b) {
34
+ const deltas = [];
35
+ const n = Math.max(a.commands.length, b.commands.length);
36
+ for (let i = 0; i < n; i++) {
37
+ const ca = a.commands[i];
38
+ const cb = b.commands[i];
39
+ if (ca !== void 0 && cb !== void 0) {
40
+ const va = commandView(ca, a.resources);
41
+ const vb = commandView(cb, b.resources);
42
+ if (stable(va) === stable(vb)) continue;
43
+ if (ca.op !== cb.op) deltas.push({
44
+ index: i,
45
+ kind: "change",
46
+ opA: ca.op,
47
+ opB: cb.op,
48
+ fields: [{
49
+ path: "op",
50
+ from: ca.op,
51
+ to: cb.op
52
+ }]
53
+ });
54
+ else deltas.push({
55
+ index: i,
56
+ kind: "change",
57
+ opA: ca.op,
58
+ opB: cb.op,
59
+ fields: diffFields(va, vb)
60
+ });
61
+ } else if (cb !== void 0) deltas.push({
62
+ index: i,
63
+ kind: "add",
64
+ opB: cb.op,
65
+ fields: []
66
+ });
67
+ else if (ca !== void 0) deltas.push({
68
+ index: i,
69
+ kind: "remove",
70
+ opA: ca.op,
71
+ fields: []
72
+ });
73
+ }
74
+ const sizeDiffers = a.size.w !== b.size.w || a.size.h !== b.size.h;
75
+ return {
76
+ equal: deltas.length === 0 && !sizeDiffers,
77
+ deltas,
78
+ ...sizeDiffers ? { size: {
79
+ from: a.size,
80
+ to: b.size
81
+ } } : {}
82
+ };
83
+ }
84
+ /** Human-readable, multi-line rendering of a DisplayDiff (CLI command-tree). */
85
+ function formatDisplayDiff(diff) {
86
+ if (diff.equal) return "DisplayLists are identical.";
87
+ const lines = [];
88
+ if (diff.size) lines.push(`size ${diff.size.from.w}x${diff.size.from.h} -> ${diff.size.to.w}x${diff.size.to.h}`);
89
+ for (const d of diff.deltas) if (d.kind === "add") lines.push(`+ [${d.index}] add ${d.opB ?? "?"}`);
90
+ else if (d.kind === "remove") lines.push(`- [${d.index}] remove ${d.opA ?? "?"}`);
91
+ else {
92
+ const op = d.opA === d.opB ? d.opA : `${d.opA ?? "?"} -> ${d.opB ?? "?"}`;
93
+ lines.push(`~ [${d.index}] ${op}`);
94
+ for (const f of d.fields) lines.push(` ${f.path}: ${stable(f.from)} -> ${stable(f.to)}`);
95
+ }
96
+ return lines.join("\n");
97
+ }
98
+ /**
99
+ * The `.dl.json` snapshot interchange schema (DESIGN §7.4). Users commit
100
+ * `.dl.json` baselines, so this carries the SAME break-policy obligation as
101
+ * `Timeline.version` and `SidecarDoc.sidecarVersion`: bump on a breaking shape
102
+ * change. Independent of the API version.
103
+ */
104
+ const DL_SNAPSHOT_VERSION = 1;
105
+ /**
106
+ * Serialize a DisplayList to a stable `.dl.json` document string (reusing the
107
+ * byte-preserving collapse-replacer). Round-trips through `parseDisplaySnapshot`.
108
+ */
109
+ function serializeDisplayList(dl) {
110
+ const snapshot = {
111
+ dlSnapshotVersion: 1,
112
+ size: dl.size,
113
+ commands: dl.commands,
114
+ resources: dl.resources
115
+ };
116
+ return JSON.stringify(snapshot, collapseReplacer, 2);
117
+ }
118
+ var DlSnapshotError = class extends Error {
119
+ constructor(message) {
120
+ super(message);
121
+ this.name = "DlSnapshotError";
122
+ }
123
+ };
124
+ /** Parse a `.dl.json` snapshot back into a DisplayList (validates the version). */
125
+ function parseDisplaySnapshot(json) {
126
+ const doc = JSON.parse(json);
127
+ if (doc.dlSnapshotVersion !== 1) throw new DlSnapshotError(`unsupported dlSnapshotVersion ${String(doc.dlSnapshotVersion)}; this build reads 1`);
128
+ if (!doc.size || !Array.isArray(doc.commands) || !Array.isArray(doc.resources)) throw new DlSnapshotError("malformed .dl.json snapshot (need size, commands[], resources[])");
129
+ return {
130
+ size: doc.size,
131
+ commands: doc.commands,
132
+ resources: doc.resources
133
+ };
134
+ }
135
+ //#endregion
136
+ //#region src/cacheColdAudit.ts
137
+ /** Stable string of a DisplayList for comparison (opaque resources collapse to a marker). */
138
+ function hashDisplayList(dl) {
139
+ return JSON.stringify(dl, collapseReplacer);
140
+ }
141
+ /**
142
+ * Evaluate two fresh scenes from `createScene` at `t` and confirm the
143
+ * DisplayLists are byte-identical. Returns `{ ok: true }` for a pure scene, or
144
+ * `{ ok: false, node }` naming the first divergent node. DEV-only — never on
145
+ * the render hot path.
146
+ */
147
+ function auditCacheCold(createScene, doc, t) {
148
+ const warm = createScene();
149
+ const cold = createScene();
150
+ const a = evaluate(warm, doc, t);
151
+ const b = evaluate(cold, doc, t);
152
+ if (hashDisplayList(a) === hashDisplayList(b)) return { ok: true };
153
+ const frame = doc.fps !== void 0 ? Math.round(t * doc.fps) : -1;
154
+ const ctxA = {
155
+ time: t,
156
+ frame,
157
+ measurer: warm.textMeasurer
158
+ };
159
+ const ctxB = {
160
+ time: t,
161
+ frame,
162
+ measurer: cold.textMeasurer
163
+ };
164
+ let groupFallback;
165
+ for (const [id, nodeA] of warm.nodes) {
166
+ const nodeB = cold.nodes.get(id);
167
+ if (!nodeB) return {
168
+ ok: false,
169
+ node: id
170
+ };
171
+ const ea = createDisplayListBuilder(warm.size);
172
+ nodeA.emit(ea, ctxA);
173
+ const eb = createDisplayListBuilder(cold.size);
174
+ nodeB.emit(eb, ctxB);
175
+ const dlA = ea.finish();
176
+ const dlB = eb.finish();
177
+ if (hashDisplayList(dlA) === hashDisplayList(dlB)) continue;
178
+ if (nodeA instanceof Group) {
179
+ groupFallback ??= id;
180
+ continue;
181
+ }
182
+ const first = diffDisplayLists(dlA, dlB).deltas[0];
183
+ return {
184
+ ok: false,
185
+ node: id,
186
+ ...first !== void 0 ? { delta: first } : {}
187
+ };
188
+ }
189
+ return {
190
+ ok: false,
191
+ ...groupFallback !== void 0 ? { node: groupFallback } : {}
192
+ };
193
+ }
194
+ //#endregion
195
+ //#region src/tokenHighlight.ts
196
+ /**
197
+ * Multi-range token highlight: sub-line ranges over a Text node's wordBoxes,
198
+ * each with its OWN animatable fill/opacity/progress/scale — four-color
199
+ * category passes, per-token flips, karaoke-with-color. Design answers from
200
+ * downstream production (the NNDL verification ritual):
201
+ * - ranges VALIDATE at construction and THROW on copy drift at draw — the
202
+ * throw is load-bearing (it catches edited copy that no longer matches);
203
+ * `rematch: true` opts animated text into per-frame re-resolution.
204
+ * - a range spanning a wrap produces one rect per line segment.
205
+ * - string matches are whitespace-stripped consecutive box runs and must end
206
+ * boundary-exact (mid-segment end = error listing the actual segments);
207
+ * `[wordIndex, wordIndex]` ranges sidestep matching entirely.
208
+ */
209
+ var TokenMatchError = class extends Error {
210
+ constructor(message) {
211
+ super(message);
212
+ this.name = "TokenMatchError";
213
+ }
214
+ };
215
+ const stripWs = (s) => s.replace(/\s+/g, "");
216
+ /**
217
+ * Find the Nth whitespace-stripped consecutive run of boxes equal to token.
218
+ * Boundary-exact: a run that diverges mid-segment is not a match; ending
219
+ * mid-segment throws (with the real segment list) rather than half-boxing.
220
+ */
221
+ function matchTokenRun(boxes, token, occurrence = 1) {
222
+ const want = stripWs(token);
223
+ if (want.length === 0) throw new TokenMatchError("empty token");
224
+ let seen = 0;
225
+ for (let i = 0; i < boxes.length; i++) {
226
+ let acc = "";
227
+ for (let j = i; j < boxes.length; j++) {
228
+ acc += stripWs(boxes[j].text);
229
+ if (acc === want) {
230
+ seen++;
231
+ if (seen === occurrence) return [i, j];
232
+ break;
233
+ }
234
+ if (!want.startsWith(acc)) break;
235
+ }
236
+ }
237
+ throw new TokenMatchError(`no match for token '${token}'${occurrence > 1 ? ` (occurrence ${occurrence})` : ""} — segments are [${boxes.map((b) => `'${b.text}'`).join(", ")}]`);
238
+ }
239
+ var TokenHighlight = class extends Node {
240
+ target;
241
+ padding;
242
+ cornerRadius;
243
+ rematch;
244
+ ranges;
245
+ constructor(props) {
246
+ super(props);
247
+ this.target = props.text;
248
+ this.padding = props.padding ?? [4, 2];
249
+ this.cornerRadius = props.cornerRadius ?? 4;
250
+ this.rematch = props.rematch ?? false;
251
+ const boxes = this.target.wordBoxes();
252
+ this.ranges = props.ranges.map((spec, index) => {
253
+ const run = this.resolveRun(boxes, spec);
254
+ const id = spec.id ?? `r${index}`;
255
+ const r = {
256
+ spec,
257
+ fill: init(signal("#ffe066"), spec.fill),
258
+ opacity: init(signal(1), spec.opacity),
259
+ progress: init(signal(1), spec.progress),
260
+ scale: init(signal(1), spec.scale),
261
+ offset: initVec(vec2Signal([0, 0]), spec.offset),
262
+ run,
263
+ bound: runText(boxes, run)
264
+ };
265
+ this.registerTarget(`${id}/fill`, r.fill, "color");
266
+ this.registerTarget(`${id}/opacity`, r.opacity, "number");
267
+ this.registerTarget(`${id}/progress`, r.progress, "number");
268
+ this.registerTarget(`${id}/scale`, r.scale, "number");
269
+ this.registerTarget(`${id}/offset`, r.offset, "vec2");
270
+ this.registerTarget(`${id}/offset.x`, r.offset.x, "number");
271
+ this.registerTarget(`${id}/offset.y`, r.offset.y, "number");
272
+ return r;
273
+ });
274
+ }
275
+ resolveRun(boxes, spec) {
276
+ if (typeof spec.match !== "string") {
277
+ const [from, to] = spec.match;
278
+ if (from < 0 || to >= boxes.length || from > to) throw new TokenMatchError(`word index range [${from}, ${to}] out of bounds (${boxes.length} boxes)`);
279
+ return [from, to];
280
+ }
281
+ return matchTokenRun(boxes, spec.match, spec.occurrence ?? 1);
282
+ }
283
+ draw(out, ctx) {
284
+ const boxes = this.target.wordBoxes(ctx.measurer);
285
+ if (boxes.length === 0) return;
286
+ const tm = this.target.localMatrix();
287
+ const [px, py] = this.padding;
288
+ let pushedTransform = false;
289
+ for (const r of this.ranges) {
290
+ const opacity = r.opacity();
291
+ if (opacity <= 0) continue;
292
+ const progress = Math.min(1, Math.max(0, r.progress()));
293
+ if (progress <= 0) continue;
294
+ let run = r.run;
295
+ if (this.rematch && typeof r.spec.match === "string") run = matchTokenRun(boxes, r.spec.match, r.spec.occurrence ?? 1);
296
+ else if (runText(boxes, run) !== r.bound) throw new TokenMatchError(`bound token '${r.bound}' no longer matches boxes [${run[0]}, ${run[1]}] — the text changed (segments now [${boxes.slice(run[0], run[1] + 1).map((b) => `'${b.text}'`).join(", ")}]); pass rematch: true for animated text`);
297
+ const lineRects = [];
298
+ for (let i = run[0]; i <= run[1]; i++) {
299
+ const b = boxes[i];
300
+ const last = lineRects[lineRects.length - 1];
301
+ const cur = i > run[0] && boxes[i - 1].line === b.line ? last : void 0;
302
+ if (cur) {
303
+ cur.w = b.x + b.w + px - cur.x;
304
+ cur.y = Math.min(cur.y, b.y - py);
305
+ cur.h = Math.max(cur.h, b.y + b.h + py - cur.y);
306
+ } else lineRects.push({
307
+ x: b.x - px,
308
+ y: b.y - py,
309
+ w: b.w + 2 * px,
310
+ h: b.h + 2 * py
311
+ });
312
+ }
313
+ if (!pushedTransform && !matEquals(tm, IDENTITY)) {
314
+ out.push({
315
+ op: "transform",
316
+ m: tm
317
+ });
318
+ pushedTransform = true;
319
+ }
320
+ const grouped = opacity < 1;
321
+ if (grouped) out.push({
322
+ op: "pushGroup",
323
+ opacity,
324
+ blend: "source-over",
325
+ filters: []
326
+ });
327
+ const fill = r.fill();
328
+ const scale = r.scale();
329
+ const [ox, oy] = r.offset();
330
+ let remaining = progress * lineRects.reduce((sum, q) => sum + q.w, 0);
331
+ for (const q of lineRects) {
332
+ const fillW = Math.min(q.w, remaining);
333
+ remaining -= fillW;
334
+ if (fillW <= 0) break;
335
+ const cx = q.x + q.w / 2 + ox;
336
+ const cy = q.y + q.h / 2 + oy;
337
+ const w = fillW * scale;
338
+ const h = q.h * scale;
339
+ const x = cx - q.w / 2 * scale;
340
+ const y = cy - h / 2;
341
+ const rad = Math.min(this.cornerRadius, w / 2, h / 2);
342
+ const path = out.resource({
343
+ kind: "path",
344
+ segs: roundedRectSegs(x, y, w, h, rad)
345
+ });
346
+ out.push({
347
+ op: "fillPath",
348
+ path,
349
+ paint: {
350
+ kind: "color",
351
+ color: fill
352
+ }
353
+ });
354
+ if (remaining <= 0) break;
355
+ }
356
+ if (grouped) out.push({ op: "popGroup" });
357
+ }
358
+ }
359
+ };
360
+ function runText(boxes, run) {
361
+ return boxes.slice(run[0], run[1] + 1).map((b) => stripWs(b.text)).join("");
362
+ }
363
+ /**
364
+ * `children: [tokenHighlight(para, { ranges: [{ match: '$48,200', fill: cat.money }] }), para]`
365
+ * — each range animates independently via '<id>/<rangeId>/fill|opacity|progress|scale'.
366
+ */
367
+ function tokenHighlight(text, props) {
368
+ return new TokenHighlight({
369
+ ...props,
370
+ text
371
+ });
372
+ }
373
+ function init(sig, v) {
374
+ if (typeof v === "function") sig.bindSource(v);
375
+ else if (v !== void 0) sig.set(v);
376
+ return sig;
377
+ }
378
+ function initVec(sig, v) {
379
+ if (typeof v === "function") sig.bindSource(v);
380
+ else if (v !== void 0) sig.set(v);
381
+ return sig;
382
+ }
383
+ //#endregion
384
+ export { DL_SNAPSHOT_VERSION, DlSnapshotError, TokenHighlight, TokenMatchError, auditCacheCold, collapseReplacer, diffDisplayLists, formatDisplayDiff, matchTokenRun, parseDisplaySnapshot, serializeDisplayList, tokenHighlight };
@@ -46,6 +46,18 @@ interface FontSpec {
46
46
  size: number;
47
47
  weight?: number;
48
48
  style?: 'normal' | 'italic';
49
+ /**
50
+ * Variable-font axis settings in CSS `font-variation-settings` form
51
+ * (e.g. `'"wght" 700, "opsz" 14'`). 0.20 STATIC passthrough: applied on the
52
+ * Skia/export path (`@napi-rs/canvas` exposes `ctx.fontVariationSettings`),
53
+ * best-effort in the browser (the DOM 2D context has no such property — a
54
+ * guarded no-op there). OMITTED for default Text, so a node without axes
55
+ * emits a byte-identical FontSpec (the golden corpus depends on this).
56
+ * Animatable axes (a `wght` track) are deferred to 1.0 — the string isn't
57
+ * lerp-able, and a track targeting `<id>/fontVariationSettings` already
58
+ * hard-throws `UnboundTargetError` (no signal resolves to it).
59
+ */
60
+ fontVariationSettings?: string;
49
61
  }
50
62
  /**
51
63
  * Group filters (§3.4): a CLOSED union — validated data, never a CSS
@@ -183,6 +195,21 @@ interface DisplayListBuilder {
183
195
  cacheKey?(start: number, end: number): string | undefined;
184
196
  /** Stamp a cacheKey onto the pushGroup already emitted at index `i`. */
185
197
  patchCacheKey?(i: number, key: string): void;
198
+ /**
199
+ * OUT-OF-BAND node-identity seam (S1, the DOM-backend readiness prerequisite —
200
+ * see docs/design/dom-backend.md "Seam 1"). `Node.emit` announces the node it
201
+ * is about to emit (`enterNode(this.id)`) and announces completion
202
+ * (`exitNode()`) — a strictly LIFO pair around the whole save…restore slice,
203
+ * so the instrumented builder can attribute each `push` to the emitting node
204
+ * and produce a positional `NodeIdStream` ALONGSIDE the DisplayList, never
205
+ * inside it. Both are OPTIONAL: the default `createDisplayListBuilder` does NOT
206
+ * implement them, so `Node.emit`'s guarded `out.enterNode?.()` /
207
+ * `out.exitNode?.()` calls are no-ops on the normal evaluate/render path and
208
+ * every DrawCommand stays byte-identical (the 262-golden contract). Only the
209
+ * opt-in `emitWithIds` builder (`@glissade/scene/identity`) supplies them.
210
+ */
211
+ enterNode?(id: string | undefined): void;
212
+ exitNode?(): void;
186
213
  }
187
214
  declare function createDisplayListBuilder(size: {
188
215
  w: number;
package/dist/grid.d.ts ADDED
@@ -0,0 +1,56 @@
1
+ import { B as NodeProps, i as Group, z as Node } from "./nodes.js";
2
+
3
+ //#region src/grid.d.ts
4
+
5
+ declare class GridError extends Error {
6
+ constructor(message: string);
7
+ }
8
+ /**
9
+ * A single column track. A bare number is a FIXED px width; `{ fr }` is a
10
+ * flexible track that shares the leftover width (after fixed tracks + gaps)
11
+ * proportionally — the CSS `fr` unit. The array form `columns: [1, { fr: 1 }, …]`
12
+ * mixes them; the scalar form `columns: 3` is sugar for three equal `{ fr: 1 }`.
13
+ */
14
+ type GridTrack = number | {
15
+ fr: number;
16
+ };
17
+ interface GridProps extends NodeProps {
18
+ /**
19
+ * Column tracks. A number N is sugar for N equal `fr` columns; an array spells
20
+ * each track (`5` = 5px fixed, `{ fr: 2 }` = a 2-fraction flexible track).
21
+ */
22
+ columns: number | readonly GridTrack[];
23
+ /** The nodes to place, row-major (child[i] → row floor(i/cols), col i%cols). */
24
+ children?: Node[];
25
+ /** Gap between columns AND rows (px). Overridden per-axis by columnGap/rowGap. */
26
+ gap?: number;
27
+ /** Horizontal gap between columns (px). Defaults to `gap`. */
28
+ columnGap?: number;
29
+ /** Vertical gap between rows (px). Defaults to `gap`. */
30
+ rowGap?: number;
31
+ /**
32
+ * Total content width the tracks divide (px). REQUIRED when any `fr` track is
33
+ * present (an `fr` needs a total to resolve against). Fixed-only track lists
34
+ * may omit it — the width is then the sum of the fixed tracks + gaps.
35
+ */
36
+ width?: number;
37
+ /**
38
+ * Row pitch (px) — the center-to-center distance between rows is
39
+ * `cellHeight + rowGap`. REQUIRED when the children span more than one row.
40
+ * (v1 is position-only, so the grid does not measure child heights.)
41
+ */
42
+ cellHeight?: number;
43
+ }
44
+ /**
45
+ * Lay `children` out into a column grid and return a `Group` holding them, each
46
+ * repositioned to its cell center. Center-anchored: the grid's own origin is the
47
+ * center of its content box, so child positions are symmetric about [0, 0]
48
+ * (matching every other glissade node's center-anchor convention).
49
+ *
50
+ * The children are NOT cloned — their `position` signal is SET to the resolved
51
+ * cell center (a plain `signal.set`, so a later bind still wins if the author
52
+ * rebinds it). Pass freshly constructed nodes for a clean, deterministic layout.
53
+ */
54
+ declare function Grid(props: GridProps): Group;
55
+ //#endregion
56
+ export { Grid, GridError, GridProps, GridTrack };
package/dist/grid.js ADDED
@@ -0,0 +1,83 @@
1
+ import { r as Group } from "./nodes.js";
2
+ //#region src/grid.ts
3
+ var GridError = class extends Error {
4
+ constructor(message) {
5
+ super(message);
6
+ this.name = "GridError";
7
+ }
8
+ };
9
+ /** Resolve the column spec into per-column [centerX] offsets from the grid's left edge. */
10
+ function resolveColumns(spec, columnGap, totalWidth) {
11
+ const tracks = typeof spec === "number" ? (() => {
12
+ if (!Number.isInteger(spec) || spec < 1) throw new GridError(`Grid columns must be a positive integer (got ${spec})`);
13
+ return Array.from({ length: spec }, () => ({ fr: 1 }));
14
+ })() : [...spec];
15
+ if (tracks.length === 0) throw new GridError("Grid needs at least one column");
16
+ const cols = tracks.length;
17
+ const gapTotal = columnGap * (cols - 1);
18
+ const fixedTotal = tracks.reduce((s, t) => s + (typeof t === "number" ? t : 0), 0);
19
+ const frTotal = tracks.reduce((s, t) => s + (typeof t === "number" ? 0 : t.fr), 0);
20
+ let widths;
21
+ let width;
22
+ if (frTotal > 0) {
23
+ if (totalWidth === void 0) throw new GridError("Grid with fr columns needs an explicit `width` to resolve fractions against");
24
+ const free = Math.max(0, totalWidth - fixedTotal - gapTotal);
25
+ widths = tracks.map((t) => typeof t === "number" ? t : free * t.fr / frTotal);
26
+ width = totalWidth;
27
+ } else {
28
+ widths = tracks.map((t) => t);
29
+ width = totalWidth ?? fixedTotal + gapTotal;
30
+ }
31
+ const centers = [];
32
+ let x = 0;
33
+ for (let c = 0; c < cols; c++) {
34
+ centers.push(x + widths[c] / 2);
35
+ x += widths[c] + columnGap;
36
+ }
37
+ return {
38
+ centers,
39
+ width
40
+ };
41
+ }
42
+ /**
43
+ * Lay `children` out into a column grid and return a `Group` holding them, each
44
+ * repositioned to its cell center. Center-anchored: the grid's own origin is the
45
+ * center of its content box, so child positions are symmetric about [0, 0]
46
+ * (matching every other glissade node's center-anchor convention).
47
+ *
48
+ * The children are NOT cloned — their `position` signal is SET to the resolved
49
+ * cell center (a plain `signal.set`, so a later bind still wins if the author
50
+ * rebinds it). Pass freshly constructed nodes for a clean, deterministic layout.
51
+ */
52
+ function Grid(props) {
53
+ const { columns, gap = 0, columnGap, rowGap, width, cellHeight } = props;
54
+ const children = props.children ?? [];
55
+ const colGap = columnGap ?? gap;
56
+ const rowGapPx = rowGap ?? gap;
57
+ const { centers, width: gridWidth } = resolveColumns(columns, colGap, width);
58
+ const cols = centers.length;
59
+ const rows = Math.ceil(children.length / cols);
60
+ if (rows > 1 && cellHeight === void 0) throw new GridError("Grid spanning more than one row needs `cellHeight` (the row pitch) — v1 is position-only and does not measure child heights");
61
+ const rowPitch = (cellHeight ?? 0) + rowGapPx;
62
+ const gridHeight = rows > 0 ? rows * (cellHeight ?? 0) + (rows - 1) * rowGapPx : 0;
63
+ const ox = -gridWidth / 2;
64
+ const oy = -gridHeight / 2;
65
+ children.forEach((child, i) => {
66
+ const col = i % cols;
67
+ const row = Math.floor(i / cols);
68
+ const cx = ox + centers[col];
69
+ const cy = oy + row * rowPitch + (cellHeight ?? 0) / 2;
70
+ child.position.set([cx, cy]);
71
+ });
72
+ return new Group({
73
+ children,
74
+ ...stripGridOnly(props)
75
+ });
76
+ }
77
+ /** Strip Grid's own props so the rest (id/position/opacity/…) pass to the Group. */
78
+ function stripGridOnly(props) {
79
+ const { columns, children, gap, columnGap, rowGap, width, cellHeight, ...nodeProps } = props;
80
+ return nodeProps;
81
+ }
82
+ //#endregion
83
+ export { Grid, GridError };
@@ -0,0 +1,33 @@
1
+ import { n as DisplayList } from "./displayList.js";
2
+ import { r as Scene } from "./scene.js";
3
+ import { Timeline } from "@glissade/core";
4
+
5
+ //#region src/identity.d.ts
6
+
7
+ /**
8
+ * The out-of-band identity stream: one optional stable id per DisplayList
9
+ * command INDEX. Positional, aligned 1:1 with `displayList.commands` — command
10
+ * *i* was emitted by the node whose explicit id is `ids[i]` (or `undefined` for
11
+ * a node without one). Because the emit walk is stable + deterministic (the
12
+ * §3.3 positional discipline `diffDisplayLists` relies on), the stream is stable
13
+ * across re-emits of an unchanged graph at a given `t`.
14
+ */
15
+ type NodeIdStream = readonly (string | undefined)[];
16
+ interface EmitWithIdsResult {
17
+ /** Byte/deep-equal to the normal `evaluate(scene, timeline, t)` DisplayList. */
18
+ readonly displayList: DisplayList;
19
+ /** Positional id stream, `ids.length === displayList.commands.length`. */
20
+ readonly ids: NodeIdStream;
21
+ }
22
+ /**
23
+ * Opt-in instrumented emit: the SAME pure evaluation as `evaluate(scene, doc, t)`,
24
+ * but additionally producing the out-of-band `NodeIdStream`. Returns
25
+ * `{ displayList, ids }`. The `displayList` is byte/deep-equal to `evaluate()`'s
26
+ * (the producer never alters geometry); `ids` is positional by command index.
27
+ *
28
+ * Off the hot path: this is a tree-shakeable subpath; the normal evaluate/render
29
+ * never touches it, so canvas/export pay nothing.
30
+ */
31
+ declare function emitWithIds(scene: Scene, doc: Timeline, t: number): EmitWithIdsResult;
32
+ //#endregion
33
+ export { EmitWithIdsResult, NodeIdStream, emitWithIds };
@@ -0,0 +1,67 @@
1
+ import { L as createDisplayListBuilder } from "./nodes.js";
2
+ import { r as bindScene } from "./scene.js";
3
+ import { evaluateAt } from "@glissade/core";
4
+ //#region src/identity.ts
5
+ /**
6
+ * Wrap a real `DisplayListBuilder` so it records the emitting node's id per
7
+ * command index. It delegates every command/resource to the inner builder
8
+ * UNCHANGED (so the produced DisplayList is byte-identical), and adds only the
9
+ * `enterNode`/`exitNode` seam + an `ids` side array. The inner builder's
10
+ * §3.5 cacheKey methods (mark/cacheKey/patchCacheKey) are forwarded so a
11
+ * `cache:true` node behaves exactly as on the normal path.
12
+ */
13
+ function instrument(inner) {
14
+ const ids = [];
15
+ const stack = [];
16
+ const builder = {
17
+ push(cmd) {
18
+ ids.push(stack.length > 0 ? stack[stack.length - 1] : void 0);
19
+ inner.push(cmd);
20
+ },
21
+ resource(res) {
22
+ return inner.resource(res);
23
+ },
24
+ enterNode(id) {
25
+ stack.push(id);
26
+ },
27
+ exitNode() {
28
+ stack.pop();
29
+ },
30
+ finish() {
31
+ return inner.finish();
32
+ },
33
+ ids
34
+ };
35
+ if (inner.mark) builder.mark = () => inner.mark();
36
+ if (inner.cacheKey) builder.cacheKey = (s, e) => inner.cacheKey(s, e);
37
+ if (inner.patchCacheKey) builder.patchCacheKey = (i, k) => inner.patchCacheKey(i, k);
38
+ return builder;
39
+ }
40
+ /**
41
+ * Opt-in instrumented emit: the SAME pure evaluation as `evaluate(scene, doc, t)`,
42
+ * but additionally producing the out-of-band `NodeIdStream`. Returns
43
+ * `{ displayList, ids }`. The `displayList` is byte/deep-equal to `evaluate()`'s
44
+ * (the producer never alters geometry); `ids` is positional by command index.
45
+ *
46
+ * Off the hot path: this is a tree-shakeable subpath; the normal evaluate/render
47
+ * never touches it, so canvas/export pay nothing.
48
+ */
49
+ function emitWithIds(scene, doc, t) {
50
+ bindScene(scene, doc);
51
+ const fps = doc.fps;
52
+ const ctx = {
53
+ time: t,
54
+ frame: fps !== void 0 ? Math.round(t * fps) : -1,
55
+ measurer: scene.textMeasurer
56
+ };
57
+ return evaluateAt(scene.playhead, t, () => {
58
+ const builder = instrument(createDisplayListBuilder(scene.size));
59
+ scene.root.emit(builder, ctx);
60
+ return {
61
+ displayList: builder.finish(),
62
+ ids: builder.ids
63
+ };
64
+ });
65
+ }
66
+ //#endregion
67
+ export { emitWithIds };