@glissade/scene 0.56.0 → 0.57.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/describe.js CHANGED
@@ -23,7 +23,7 @@ import { easings, listValueTypes } from "@glissade/core";
23
23
  * never pulled onto the base embed path — a scene that never calls `describe()`
24
24
  * pays zero bytes for it.
25
25
  */
26
- const RAW_VERSION = "0.56.0";
26
+ const RAW_VERSION = "0.57.0";
27
27
  const PACKAGE_VERSION = RAW_VERSION.includes("GLISSADE_".concat("VERSION")) ? "0.0.0-dev" : RAW_VERSION;
28
28
  /**
29
29
  * Parse the documented positional-arg count from a helper `usage` string — the
@@ -582,6 +582,30 @@ const HELPERS = [
582
582
  import: "@glissade/scene/motion",
583
583
  usage: "shake(node: Node, opts: { seed: number, translate?: number, rotate?: number, frequency?: number }): Node"
584
584
  },
585
+ {
586
+ name: "particles",
587
+ summary: "A small SEEDED, BAKED particle emitter (FACTORY, no `new`): composes each() (count fixed slot nodes at `${id}/${i}`) + bake() (seeded physics → position/opacity/scale/rotation tracks on those SAME ids). Every slot is a real node with real tracks → a real exportable Lottie layer, faithful BY CONSTRUCTION (no render-only/custom-draw path). `count` is the MAX-CONCURRENT ring-buffer pool (bounded 200 — over THROWS, never clamps), NOT total emitted; opacity-0-for-the-whole-window slots are pruned so the layer count stays proportional. Seed defaults to hashStr(id); byte-identical run-to-run, a different seed varies. ESCAPE HATCH: `appearance` (any Node/glyph template), `step` (raw per-particle sim), `...` velocity/forces/lifetime. Tree-shakeable (@glissade/scene/motion).",
588
+ import: "@glissade/scene/motion",
589
+ usage: "particles(spec: { id, count, box: {w,h}, duration, fps, origin: [fx,fy], lifetime: number | [min,max], velocity: { speed:[min,max], angle:[min,max] (deg) }, appearance: (i, ctx) => Node | { node, opacityOverLife?, scaleOverLife? }, rate?, burst?: number | {at,n}[], seed?, area?, forces?: { gravity?, drag?, wind? }, spin?, opacityOverLife?, scaleOverLife?, step?: (p, dt, rng) => void }): { node: Group, tracks: Track[], end } — supply rate and/or burst; count > 200 throws."
590
+ },
591
+ {
592
+ name: "drift",
593
+ summary: "Particles preset: ambient low-opacity motes floating gently up (a bokeh companion). Continuous low-rate; DEFAULTS to a small max-concurrent count (24) so the exported layer count stays proportional, NOT 200 near-empty layers. `appearance` is the primary control (a themed dot); `...rest` forwards to particles() (velocity/forces/lifetime/step). Factory (no `new`). Tree-shakeable (@glissade/scene/motion).",
594
+ import: "@glissade/scene/motion",
595
+ usage: "drift(opts: { box: {w,h}, duration, fps, count?, rate?, origin?, color?, radius?, seed?, id?, ...rest (lifetime/velocity/forces/appearance/step) }): { node: Group, tracks: Track[], end }"
596
+ },
597
+ {
598
+ name: "sparks",
599
+ summary: "Particles preset: a subtle corporate-safe radial impact burst (win-beat / habit-stamp flourish) — short-life dots thrown outward from origin, shrinking + fading with a touch of gravity. LOW density by default. `...rest` forwards to particles() (the escape-hatch appearance/step/velocity). Factory (no `new`). Tree-shakeable (@glissade/scene/motion).",
600
+ import: "@glissade/scene/motion",
601
+ usage: "sparks(origin: [fx,fy], opts: { box: {w,h}, duration, fps, count?, at?, color?, radius?, seed?, id?, ...rest (lifetime/velocity/forces/appearance/step) }): { node: Group, tracks: Track[], end }"
602
+ },
603
+ {
604
+ name: "dispense",
605
+ summary: "Particles preset: a DIRECTIONAL sparks variant — a small themed sparkle emanating one way at a beat (the vending \"AS ASKED\" flourish ON the drop, not a stream). Directional angle bias + an optional GLYPH node-template. `...rest` forwards to particles(). Factory (no `new`). Tree-shakeable (@glissade/scene/motion).",
606
+ import: "@glissade/scene/motion",
607
+ usage: "dispense(origin: [fx,fy], opts: { box: {w,h}, duration, fps, angle?, spread?, glyph?, glyphSize?, glyphFamily?, count?, at?, color?, seed?, id?, ...rest (appearance/step/velocity/forces) }): { node: Group, tracks: Track[], end }"
608
+ },
585
609
  {
586
610
  name: "valueNoise",
587
611
  summary: "Closed-form smooth value noise: a PURE function of (seed, t) — lerp(rand(⌊t⌋), rand(⌊t⌋+1), smoothstep(fract t)) with core’s seeded hash. No state, no bake; deterministic by construction (byte-identical run-to-run), fps-independent, O(1), seekable — the closed-form sibling of a spring. Range [0,1); center a signed wobble with *2-1. The primitive behind shake + camera shake.",
@@ -1,7 +1,7 @@
1
1
  import { i as DrawCommand, m as Resource, n as DisplayList } from "./displayList.js";
2
2
  import { t as collapseReplacer } from "./collapseReplacer.js";
3
3
  import { r as Scene } from "./scene.js";
4
- import { Timeline } from "@glissade/core";
4
+ import { CoverageReport, FontMode, FontUsage, Timeline } from "@glissade/core";
5
5
 
6
6
  //#region src/displayDiff.d.ts
7
7
 
@@ -103,4 +103,43 @@ interface CacheColdResult {
103
103
  */
104
104
  declare function auditCacheCold(createScene: () => Scene, doc: Timeline, t: number): CacheColdResult;
105
105
  //#endregion
106
- export { type CacheColdResult, type CommandDelta, DL_SNAPSHOT_VERSION, type DisplayDiff, type DlSnapshot, DlSnapshotError, type FieldChange, auditCacheCold, collapseReplacer, diffDisplayLists, formatDisplayDiff, parseDisplaySnapshot, serializeDisplayList };
106
+ //#region src/fontUsage.d.ts
107
+ /** Walk `scene` for Text nodes; one usage per node carrying its full text. */
108
+ declare function collectTextUsages(scene: Scene): FontUsage[];
109
+ /**
110
+ * Collect font usages from the POST-localize document's STRING tracks (FIX 3,
111
+ * 0.14 canary). For every `'string'` track whose target node is a Text node,
112
+ * emit one usage per distinct localized KEY VALUE under that node's fontFamily —
113
+ * so a localized CJK message bound to a Latin-only font surfaces as an uncovered
114
+ * glyph. `collectTextUsages` only sees the authored BASE `node.text()`, which is
115
+ * resolved BEFORE the localized string tracks bind, so it misses this.
116
+ */
117
+ declare function collectLocalizedTextUsages(scene: Scene, doc: Timeline): FontUsage[];
118
+ /**
119
+ * Caller-supplied I/O: fetch the raw bytes for a font face URL (the export
120
+ * paths read a file / fetch a URL; this keeps core pure). Returning undefined
121
+ * means "could not load" — that family contributes no coverage, surfacing as
122
+ * missing glyphs (strict) / a dev warning, never a hang.
123
+ */
124
+ type FontByteLoader = (url: string) => Promise<ArrayBuffer | undefined>;
125
+ interface ValidateSceneFontsOptions {
126
+ mode?: FontMode;
127
+ /** OS-installed families to treat as registered (case-insensitive). */
128
+ osFamilies?: ReadonlySet<string> | undefined;
129
+ /**
130
+ * Additional usages to validate alongside the scene's authored Text (FIX 3):
131
+ * the POST-localize document's localized string-track values, which the
132
+ * scene-walk can't see (they bind AFTER `node.text()` is read). Build them
133
+ * with `collectLocalizedTextUsages(scene, localizedDoc)`.
134
+ */
135
+ extraUsages?: readonly FontUsage[] | undefined;
136
+ }
137
+ /**
138
+ * Run §3.6 font validation for a scene + its timeline document. Builds the
139
+ * registry from `doc.assets`, loads each registered face's cmap via `loadBytes`
140
+ * (once per family — the first face's URL is enough for coverage), and runs the
141
+ * pure `validateFonts`. Strict mode throws FontValidationError; dev warns.
142
+ */
143
+ declare function validateSceneFonts(scene: Scene, doc: Timeline, loadBytes: FontByteLoader, options?: ValidateSceneFontsOptions): Promise<CoverageReport>;
144
+ //#endregion
145
+ export { type CacheColdResult, type CommandDelta, DL_SNAPSHOT_VERSION, type DisplayDiff, type DlSnapshot, DlSnapshotError, type FieldChange, type FontByteLoader, type ValidateSceneFontsOptions, auditCacheCold, collapseReplacer, collectLocalizedTextUsages, collectTextUsages, diffDisplayLists, formatDisplayDiff, parseDisplaySnapshot, serializeDisplayList, validateSceneFonts };
@@ -1,5 +1,6 @@
1
- import { U as createDisplayListBuilder, q as collapseReplacer, r as Group } from "./nodes.js";
1
+ import { U as createDisplayListBuilder, q as collapseReplacer, r as Group, s as Text } from "./nodes.js";
2
2
  import { a as evaluate } from "./scene.js";
3
+ import { buildFontRegistry, parseCmap, validateFonts } from "@glissade/core";
3
4
  //#region src/displayDiff.ts
4
5
  /** A flat, stable JSON value for one command with its resource ids INLINED to content. */
5
6
  function commandView(cmd, resources) {
@@ -193,4 +194,82 @@ function auditCacheCold(createScene, doc, t) {
193
194
  };
194
195
  }
195
196
  //#endregion
196
- export { DL_SNAPSHOT_VERSION, DlSnapshotError, auditCacheCold, collapseReplacer, diffDisplayLists, formatDisplayDiff, parseDisplaySnapshot, serializeDisplayList };
197
+ //#region src/fontUsage.ts
198
+ /**
199
+ * Scene → font-validation bridge (DESIGN.md §3.6). Core owns the AssetRef,
200
+ * FontRegistry, cmap reader, and the pure validation; this module owns the
201
+ * node-walk (which only `scene` can do) and the I/O seam that loads a font
202
+ * face's bytes so core stays DOM/Node-free.
203
+ *
204
+ * `collectTextUsages` walks every Text node and reads the FULL `.text()` (not
205
+ * the reveal-masked prefix) — coverage is a property of the authored content,
206
+ * independent of the playhead, so it stays out of the pure evaluate() path.
207
+ */
208
+ /** Walk `scene` for Text nodes; one usage per node carrying its full text. */
209
+ function collectTextUsages(scene) {
210
+ const out = [];
211
+ const visit = (node) => {
212
+ if (node instanceof Text) {
213
+ const text = node.text();
214
+ if (text) out.push({
215
+ family: node.fontFamily,
216
+ text
217
+ });
218
+ }
219
+ if (node instanceof Group) for (const child of node.children) visit(child);
220
+ };
221
+ visit(scene.root);
222
+ return out;
223
+ }
224
+ /** The node-id of a track target ('<nodeId>/<prop.path>' → '<nodeId>'). */
225
+ function nodeIdOf(target) {
226
+ const slash = target.indexOf("/");
227
+ return slash >= 0 ? target.slice(0, slash) : target;
228
+ }
229
+ /**
230
+ * Collect font usages from the POST-localize document's STRING tracks (FIX 3,
231
+ * 0.14 canary). For every `'string'` track whose target node is a Text node,
232
+ * emit one usage per distinct localized KEY VALUE under that node's fontFamily —
233
+ * so a localized CJK message bound to a Latin-only font surfaces as an uncovered
234
+ * glyph. `collectTextUsages` only sees the authored BASE `node.text()`, which is
235
+ * resolved BEFORE the localized string tracks bind, so it misses this.
236
+ */
237
+ function collectLocalizedTextUsages(scene, doc) {
238
+ const out = [];
239
+ for (const tr of doc.tracks) {
240
+ if (tr.type !== "string") continue;
241
+ const node = scene.nodes.get(nodeIdOf(tr.target));
242
+ if (!(node instanceof Text)) continue;
243
+ for (const k of tr.keys) {
244
+ const value = k.value;
245
+ if (typeof value === "string" && value) out.push({
246
+ family: node.fontFamily,
247
+ text: value
248
+ });
249
+ }
250
+ }
251
+ return out;
252
+ }
253
+ /**
254
+ * Run §3.6 font validation for a scene + its timeline document. Builds the
255
+ * registry from `doc.assets`, loads each registered face's cmap via `loadBytes`
256
+ * (once per family — the first face's URL is enough for coverage), and runs the
257
+ * pure `validateFonts`. Strict mode throws FontValidationError; dev warns.
258
+ */
259
+ async function validateSceneFonts(scene, doc, loadBytes, options = {}) {
260
+ const mode = options.mode ?? "dev";
261
+ const registry = buildFontRegistry(doc.assets);
262
+ const usages = [...collectTextUsages(scene), ...options.extraUsages ?? []];
263
+ const wanted = /* @__PURE__ */ new Set();
264
+ for (const u of usages) if (registry.has(u.family)) for (const f of registry.fallbackChain(u.family)) wanted.add(f);
265
+ const cmaps = /* @__PURE__ */ new Map();
266
+ for (const family of wanted) {
267
+ const face = registry.resolveFace(family);
268
+ if (!face) continue;
269
+ const bytes = await loadBytes(face.url);
270
+ if (bytes) cmaps.set(family, parseCmap(bytes));
271
+ }
272
+ return validateFonts(usages, registry, cmaps, mode, { ...options.osFamilies !== void 0 ? { osFamilies: options.osFamilies } : {} });
273
+ }
274
+ //#endregion
275
+ export { DL_SNAPSHOT_VERSION, DlSnapshotError, auditCacheCold, collapseReplacer, collectLocalizedTextUsages, collectTextUsages, diffDisplayLists, formatDisplayDiff, parseDisplaySnapshot, serializeDisplayList, validateSceneFonts };
package/dist/each.d.ts ADDED
@@ -0,0 +1,125 @@
1
+ import { B as Node, a as Group } from "./nodes.js";
2
+ import { Rng, Track } from "@glissade/core";
3
+ import { ChannelOverride, Clip } from "@glissade/core/clips";
4
+
5
+ //#region src/each.d.ts
6
+
7
+ /** An aspect-fraction placement: [fx, fy], each conventionally in [0, 1]. */
8
+ type Place = readonly [number, number];
9
+ /**
10
+ * Built-in layouts (a discriminated union — NOT factory fns) plus the escape
11
+ * hatch `(i, n) => [fx, fy]`. Every built-in is PURE arithmetic in aspect
12
+ * fractions; mapping to px happens only when `box` is given (see `places`).
13
+ */
14
+ type EachLayout = {
15
+ kind: 'row';
16
+ gap?: number;
17
+ align?: number;
18
+ } | {
19
+ kind: 'column';
20
+ gap?: number;
21
+ align?: number;
22
+ } | {
23
+ kind: 'grid';
24
+ cols: number;
25
+ rows?: number;
26
+ gapX?: number;
27
+ gapY?: number;
28
+ order?: 'row' | 'column';
29
+ } | {
30
+ kind: 'ring';
31
+ radius?: number;
32
+ center?: Place;
33
+ startAngle?: number;
34
+ sweep?: number;
35
+ } | ((i: number, n: number) => Place);
36
+ /** How a `stagger` delay distributes across the clones. */
37
+ type EachDistribute = 'delay' | 'from-center' | 'from-edges';
38
+ /** Per-index motion: a clip fanned across the clones with stagger + jitter. */
39
+ interface EachMotion {
40
+ /** The motion clip applied to every clone (TYPE: `Clip` from core/clips). */
41
+ clip: Clip;
42
+ /** Wall-clock start second of the first clone. Default 0. */
43
+ startSec?: number;
44
+ /** Per-index delay (seconds) or a function of the index. Default 0. */
45
+ stagger?: number | ((i: number) => number);
46
+ /**
47
+ * Shape a numeric `stagger` gap into a distribution. `from-center` ramps the
48
+ * delay outward from the middle clone, `from-edges` inward toward it; `delay`
49
+ * (the default) is the plain `i * gap` ramp. Ignored when `stagger` is a fn.
50
+ */
51
+ distribute?: EachDistribute;
52
+ /** Per-index clip overrides, seeded — `(i, rng, n) => overrides`. */
53
+ jitter?: (i: number, rng: Rng, n: number) => Record<string, ChannelOverride>;
54
+ /** Clip speed (passed straight to `clip.apply`). */
55
+ speed?: number;
56
+ }
57
+ /** Pixel box for mapping aspect-fraction places to a concrete coordinate frame. */
58
+ interface EachBox {
59
+ w: number;
60
+ h: number;
61
+ /** Top-left of the box in scene coords; default [0, 0]. */
62
+ origin?: Place;
63
+ }
64
+ interface EachOpts {
65
+ /** Stable id prefix; clones are `${id}/${i}`, the wrapping group is `${id}`. */
66
+ id: string;
67
+ layout: EachLayout;
68
+ motion?: EachMotion;
69
+ /** Seed for per-clone RNG; defaults to a stable hash of `id`. */
70
+ seed?: number;
71
+ /** When given, `places` also carries the px-mapped points (see EachResult). */
72
+ box?: EachBox;
73
+ }
74
+ /** The per-clone authoring context handed to the factory. */
75
+ interface EachContext {
76
+ /** Clone index, 0..n-1. */
77
+ i: number;
78
+ /** Total clone count. */
79
+ n: number;
80
+ /** This clone's id (`${opts.id}/${i}`). */
81
+ id: string;
82
+ /** Aspect-fraction placement [fx, fy] — ALWAYS a fraction (px is separate). */
83
+ place: Place;
84
+ /** Seeded generator for this clone: `random(mix(seed, i))`. */
85
+ rng: Rng;
86
+ /** The resolved base seed (`opts.seed ?? hash(id)`). */
87
+ seed: number;
88
+ }
89
+ interface EachResult {
90
+ /** The wrapping group (`id: opts.id`) holding every generated child. */
91
+ node: Group;
92
+ /** The generated children, in index order. */
93
+ children: Node[];
94
+ /** The compiled motion tracks (empty when no `motion`). */
95
+ tracks: Track[];
96
+ /** Max child clip end (== startSec when no motion). */
97
+ end: number;
98
+ /**
99
+ * Per-clone placement. `frac` is the aspect-fraction [fx, fy] every layout
100
+ * produces; `px` is present only when `opts.box` was given (frac mapped into
101
+ * the box). Authoring a `box` once here is the single place fraction→px lives.
102
+ */
103
+ places: {
104
+ frac: Place;
105
+ px?: Place;
106
+ }[];
107
+ }
108
+ declare class EachError extends Error {
109
+ constructor(message: string);
110
+ }
111
+ /**
112
+ * Generate `n` clones from `factory`, lay them out, and (optionally) stagger a
113
+ * motion clip across them.
114
+ *
115
+ * const grid = each(9, (i) => new Rect({ width: 40, height: 40, fill: '#9ef0c0' }), {
116
+ * id: 'card',
117
+ * layout: { kind: 'grid', cols: 3 },
118
+ * box: { w: 600, h: 360 },
119
+ * motion: { clip: popIn(), stagger: 0.08, distribute: 'from-center' },
120
+ * });
121
+ * // scene children: [grid.node]; timeline tracks: [...grid.tracks]
122
+ */
123
+ declare function each(n: number, factory: (i: number, ctx: EachContext) => Node, opts: EachOpts): EachResult;
124
+ //#endregion
125
+ export { EachLayout as a, EachResult as c, EachError as i, Place as l, EachContext as n, EachMotion as o, EachDistribute as r, EachOpts as s, EachBox as t, each as u };
package/dist/each.js ADDED
@@ -0,0 +1,168 @@
1
+ import { C as Node, _ as hashStr, r as Group } from "./nodes.js";
2
+ import { random } from "@glissade/core";
3
+ //#region src/each.ts
4
+ /**
5
+ * `each()` — deterministic parametric instancing (0.13 clip-tier sugar). Pure
6
+ * BUILD-TIME fan-out: it generates N scene nodes from a factory, lays them out
7
+ * in aspect-fraction space, and (optionally) staggers a motion `clip` across
8
+ * them — compiling to ordinary keyed `Track[]` plus a `Group` of children with
9
+ * stable `${id}/${i}` ids. Nothing executes at play time; the emitted tracks are
10
+ * byte-indistinguishable from hand-authored ones, so goldens hold by
11
+ * construction and every `--workers` export shard reconstructs the same id set.
12
+ *
13
+ * The clip runtime is imported TYPE-ONLY (the `Clip` instance the author passes
14
+ * carries its own `apply`), so `each` adds no clip bytes to the embed: the
15
+ * `@glissade/core/clips` runtime lands in the consumer's bundle, never scene's.
16
+ */
17
+ var EachError = class extends Error {
18
+ constructor(message) {
19
+ super(message);
20
+ this.name = "EachError";
21
+ }
22
+ };
23
+ /**
24
+ * Fold a base seed and an index into a fresh per-clone seed. splitmix-style
25
+ * avalanche so adjacent indices decorrelate (a bare `seed + i` would hand
26
+ * near-identical streams to neighbours).
27
+ */
28
+ function mix(seed, i) {
29
+ let h = (seed ^ Math.imul(i + 1, 2654435769)) >>> 0;
30
+ h = Math.imul(h ^ h >>> 16, 569420461);
31
+ h = Math.imul(h ^ h >>> 15, 1935289751);
32
+ return (h ^ h >>> 15) >>> 0;
33
+ }
34
+ /**
35
+ * Salt folded into the motion-jitter seed so the per-index jitter rng
36
+ * decorrelates from `ctx.rng` (the factory rng). Both axes derive from
37
+ * `mix(baseSeed, i)`; without a distinct salt they would be the SAME stream,
38
+ * so a factory that draws from `ctx.rng` and a `jitter` callback would see
39
+ * correlated "independent" randomness. An arbitrary fixed odd constant.
40
+ */
41
+ const JITTER_SALT = 1779033703;
42
+ /** Resolve a built-in layout (or call the escape-hatch fn) to a fraction. */
43
+ function placeAt(layout, i, n) {
44
+ if (typeof layout === "function") return layout(i, n);
45
+ switch (layout.kind) {
46
+ case "row": {
47
+ const gap = layout.gap ?? (n > 1 ? 1 / (n - 1) : 0);
48
+ const align = layout.align ?? .5;
49
+ const x0 = .5 - gap * (n - 1) / 2;
50
+ return [n === 1 ? .5 : x0 + gap * i, align];
51
+ }
52
+ case "column": {
53
+ const gap = layout.gap ?? (n > 1 ? 1 / (n - 1) : 0);
54
+ const align = layout.align ?? .5;
55
+ const y0 = .5 - gap * (n - 1) / 2;
56
+ return [align, n === 1 ? .5 : y0 + gap * i];
57
+ }
58
+ case "grid": {
59
+ const cols = layout.cols;
60
+ if (!(cols >= 1)) throw new EachError(`grid layout needs cols >= 1 (got ${cols})`);
61
+ const rows = layout.rows ?? Math.ceil(n / cols);
62
+ const order = layout.order ?? "row";
63
+ const col = order === "row" ? i % cols : Math.floor(i / rows);
64
+ const row = order === "row" ? Math.floor(i / cols) : i % rows;
65
+ const gapX = layout.gapX ?? (cols > 1 ? 1 / (cols - 1) : 0);
66
+ const gapY = layout.gapY ?? (rows > 1 ? 1 / (rows - 1) : 0);
67
+ const spanX = gapX * (cols - 1);
68
+ const spanY = gapY * (rows - 1);
69
+ return [cols === 1 ? .5 : .5 - spanX / 2 + gapX * col, rows === 1 ? .5 : .5 - spanY / 2 + gapY * row];
70
+ }
71
+ case "ring": {
72
+ const radius = layout.radius ?? .5;
73
+ const [cx, cy] = layout.center ?? [.5, .5];
74
+ const theta = (layout.startAngle ?? -Math.PI / 2) + (layout.sweep ?? Math.PI * 2) * (n === 0 ? 0 : i / n);
75
+ return [cx + radius * Math.cos(theta), cy + radius * Math.sin(theta)];
76
+ }
77
+ }
78
+ }
79
+ /** Compile a `distribute` mode + numeric gap into a stagger delay fn. */
80
+ function distributeFn(distribute, gap, n) {
81
+ const mid = (n - 1) / 2;
82
+ switch (distribute) {
83
+ case "from-center": return (i) => Math.abs(i - mid) * gap;
84
+ case "from-edges": return (i) => (mid - Math.abs(i - mid)) * gap;
85
+ case "delay": return (i) => i * gap;
86
+ }
87
+ }
88
+ /** Resolve the motion's per-index delay into a plain `(i) => seconds` fn. */
89
+ function staggerFn(motion, n) {
90
+ const s = motion.stagger ?? 0;
91
+ if (typeof s === "function") return s;
92
+ return distributeFn(motion.distribute ?? "delay", s, n);
93
+ }
94
+ /**
95
+ * Generate `n` clones from `factory`, lay them out, and (optionally) stagger a
96
+ * motion clip across them.
97
+ *
98
+ * const grid = each(9, (i) => new Rect({ width: 40, height: 40, fill: '#9ef0c0' }), {
99
+ * id: 'card',
100
+ * layout: { kind: 'grid', cols: 3 },
101
+ * box: { w: 600, h: 360 },
102
+ * motion: { clip: popIn(), stagger: 0.08, distribute: 'from-center' },
103
+ * });
104
+ * // scene children: [grid.node]; timeline tracks: [...grid.tracks]
105
+ */
106
+ function each(n, factory, opts) {
107
+ if (!Number.isInteger(n) || n < 0) throw new EachError(`each() count must be a non-negative integer (got ${n})`);
108
+ const baseSeed = (opts.seed ?? hashStr(opts.id)) >>> 0;
109
+ const box = opts.box;
110
+ const [ox, oy] = box?.origin ?? [0, 0];
111
+ const children = [];
112
+ const places = [];
113
+ const seen = /* @__PURE__ */ new Set();
114
+ for (let i = 0; i < n; i++) {
115
+ const id = `${opts.id}/${i}`;
116
+ const frac = placeAt(opts.layout, i, n);
117
+ const ctx = {
118
+ i,
119
+ n,
120
+ id,
121
+ place: frac,
122
+ rng: random(mix(baseSeed, i)),
123
+ seed: baseSeed
124
+ };
125
+ const child = factory(i, ctx);
126
+ if (!(child instanceof Node)) throw new EachError(`each() factory must return a Node for index ${i} (got ${typeof child})`);
127
+ if (seen.has(child)) throw new EachError(`each() factory returned the same Node instance for index ${i} — the factory must construct a new node per index (it is called once per clone)`);
128
+ seen.add(child);
129
+ if (child.id === void 0) child.id = id;
130
+ else if (child.id !== id) throw new EachError(`each() factory set id '${child.id}' on index ${i}, but each owns the id namespace — leave it unset so it becomes '${id}'`);
131
+ children.push(child);
132
+ places.push(box ? {
133
+ frac,
134
+ px: [ox + frac[0] * box.w, oy + frac[1] * box.h]
135
+ } : { frac });
136
+ }
137
+ const node = new Group({
138
+ id: opts.id,
139
+ children
140
+ });
141
+ const tracks = [];
142
+ let end = opts.motion?.startSec ?? 0;
143
+ if (opts.motion) {
144
+ const m = opts.motion;
145
+ const start = m.startSec ?? 0;
146
+ const at = staggerFn(m, n);
147
+ for (let i = 0; i < n; i++) {
148
+ const rngI = random(mix(mix(baseSeed, i), JITTER_SALT));
149
+ const overrides = m.jitter?.(i, rngI, n);
150
+ const applyOpts = {
151
+ ...overrides !== void 0 ? { overrides } : {},
152
+ ...m.speed !== void 0 ? { speed: m.speed } : {}
153
+ };
154
+ const r = m.clip.apply(`${opts.id}/${i}`, start + at(i), applyOpts);
155
+ tracks.push(...r.tracks);
156
+ if (r.end > end) end = r.end;
157
+ }
158
+ }
159
+ return {
160
+ node,
161
+ children,
162
+ tracks,
163
+ end,
164
+ places
165
+ };
166
+ }
167
+ //#endregion
168
+ export { each as n, EachError as t };