@glissade/scene 0.56.0-pre.0 → 0.57.0-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/describe.js +25 -1
- package/dist/diagnostics.d.ts +41 -2
- package/dist/diagnostics.js +81 -2
- package/dist/each.d.ts +125 -0
- package/dist/each.js +168 -0
- package/dist/index.d.ts +3 -161
- package/dist/index.js +4 -246
- package/dist/motion.d.ts +207 -2
- package/dist/motion.js +396 -3
- package/package.json +2 -2
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.
|
|
26
|
+
const RAW_VERSION = "0.57.0-pre.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.",
|
package/dist/diagnostics.d.ts
CHANGED
|
@@ -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
|
-
|
|
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 };
|
package/dist/diagnostics.js
CHANGED
|
@@ -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
|
-
|
|
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 };
|