@glissade/scene 0.26.0-pre.0 → 0.26.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
@@ -22,7 +22,7 @@ import { easings, listValueTypes } from "@glissade/core";
22
22
  * never pulled onto the base embed path — a scene that never calls `describe()`
23
23
  * pays zero bytes for it.
24
24
  */
25
- const RAW_VERSION = "0.26.0-pre.0";
25
+ const RAW_VERSION = "0.26.0";
26
26
  const PACKAGE_VERSION = RAW_VERSION.includes("GLISSADE_".concat("VERSION")) ? "0.0.0-dev" : RAW_VERSION;
27
27
  /** Arity of a value type's numeric repr: vec2/vec2-arc → 2, number → 1; others (color/paint/path/string/boolean) carry no scalar arity. */
28
28
  function arityOf(type) {
@@ -339,6 +339,12 @@ const HELPERS = [
339
339
  import: "@glissade/scene/motion",
340
340
  usage: "lookAt(target: Node, at: Node, opts?: { id?, offset?: number }): LookAt"
341
341
  },
342
+ {
343
+ name: "echo",
344
+ summary: "Motion trails / onion-skin: wrap a child so it renders at K past playhead offsets (t − i·spacing), each trailing copy fading by decay. A pure multi-time re-eval (the playhead is re-addressed per copy and restored), byte-stable in the golden corpus. Add the returned Echo to the scene.",
345
+ import: "@glissade/scene",
346
+ usage: "echo(child: Node, opts?: { id?, count?: number, spacing?: number, decay?: number }): Echo"
347
+ },
342
348
  {
343
349
  name: "clip",
344
350
  summary: "A reusable, target-agnostic motion captured once as a relative-time key schedule, then applied to a node at a wall-clock start time. Build-time sugar: clip.apply() compiles to ordinary Track[].",
package/dist/echo.js ADDED
@@ -0,0 +1,78 @@
1
+ import { r as Group } from "./nodes.js";
2
+ import { batch } from "@glissade/core";
3
+ //#region src/echo.ts
4
+ /**
5
+ * Echo (0.26): motion trails / onion-skin. A wrapper group that renders its
6
+ * children K times — at the playhead and at K−1 earlier offsets (t − i·spacing)
7
+ * — each trailing copy fading by `decay`. The leading copy is the live frame;
8
+ * the ghosts are the subtree "as it was" a few slices ago.
9
+ *
10
+ * It is the pure render form of "re-evaluate at t + k·spacing": within one
11
+ * frame it re-addresses the SCENE PLAYHEAD to each offset time, emits the
12
+ * children (whose bound signals re-derive at that time — tracks, followPath,
13
+ * computeds all follow), then RESTORES the playhead before the walk continues.
14
+ * The whole dance is wrapped in `batch()` so the playhead's subscribers (a
15
+ * player repaint) coalesce to a single, idempotent notification at the restored
16
+ * time — never a mid-emit reentrancy storm. Headless (goldens/export) the
17
+ * playhead has no subscribers, so it is a plain pure multi-sample: evaluate()
18
+ * stays a pure function of time and the DisplayList is byte-stable.
19
+ *
20
+ * Determinism note: the offsets are pure functions of the current playhead, so
21
+ * a cold re-eval reproduces byte-identically (the cache-cold audit passes).
22
+ * Ghost times before the first key clamp to the initial pose (track semantics).
23
+ */
24
+ var Echo = class extends Group {
25
+ get describeType() {
26
+ return "Echo";
27
+ }
28
+ count;
29
+ spacing;
30
+ decay;
31
+ constructor(props = {}) {
32
+ super(props);
33
+ this.count = Math.max(1, Math.floor(props.count ?? 5));
34
+ this.spacing = props.spacing ?? .08;
35
+ this.decay = props.decay ?? .6;
36
+ }
37
+ draw(out, ctx) {
38
+ const playhead = ctx.playhead;
39
+ if (this.count <= 1 || this.spacing === 0 || playhead === void 0) {
40
+ super.draw(out, ctx);
41
+ return;
42
+ }
43
+ const now = ctx.time;
44
+ batch(() => {
45
+ try {
46
+ for (let i = this.count - 1; i >= 0; i--) {
47
+ const alpha = i === 0 ? 1 : this.decay ** i;
48
+ if (alpha <= 0) continue;
49
+ const offsetT = now - i * this.spacing;
50
+ playhead.forceSet(offsetT);
51
+ out.push({
52
+ op: "pushGroup",
53
+ opacity: alpha,
54
+ blend: "source-over",
55
+ filters: []
56
+ });
57
+ super.draw(out, {
58
+ ...ctx,
59
+ time: offsetT
60
+ });
61
+ out.push({ op: "popGroup" });
62
+ }
63
+ } finally {
64
+ playhead.forceSet(now);
65
+ }
66
+ });
67
+ }
68
+ };
69
+ /** `children: [echo(mover, { count: 6, spacing: 0.05 })]` — mover leaves a fading trail.
70
+ * A convenience wrapper: pass the trailing content as children of the returned Echo. */
71
+ function echo(child, props = {}) {
72
+ return new Echo({
73
+ ...props,
74
+ children: [child]
75
+ });
76
+ }
77
+ //#endregion
78
+ export { echo as n, Echo as t };
package/dist/examples.js CHANGED
@@ -4,9 +4,10 @@ import { a as evaluate, i as createScene } from "./scene.js";
4
4
  import { Grid } from "./grid.js";
5
5
  import { Stack } from "./layoutCtors.js";
6
6
  import { splitText } from "./type.js";
7
- import { r as motionPath } from "./motionPath.js";
7
+ import { i as orientToPath, r as lookAt, s as motionPath } from "./orient.js";
8
+ import { n as echo } from "./echo.js";
8
9
  import { pathFromSvg } from "./path.js";
9
- import { key, timeline, track } from "@glissade/core";
10
+ import { key, retime, timeline, track } from "@glissade/core";
10
11
  //#region src/examples.ts
11
12
  /**
12
13
  * `@glissade/scene/examples` — the runnable example corpus (§0.24 onboarding,
@@ -204,6 +205,58 @@ const EXAMPLES = [
204
205
  key: "motionPath",
205
206
  code: "import { motionPath } from '@glissade/scene/motion';\nimport { pathFromSvg } from '@glissade/scene/path';\nconst mp = motionPath(pathFromSvg('M0 0 C50 0 50 100 100 100'));\nconst pointHalfway = mp.atProgress(0.5); // { x, y }",
206
207
  run: () => void motionPath(pathFromSvg("M0 0 C50 0 50 100 100 100")).atProgress(.5)
208
+ },
209
+ {
210
+ key: "orientToPath",
211
+ code: "import { Rect } from '@glissade/scene';\nimport { orientToPath } from '@glissade/scene/motion';\nimport { pathFromSvg } from '@glissade/scene/path';\n// rotation-only sibling of followPath: banks the target to the path tangent while its\n// POSITION comes from elsewhere. Drive '<id>/progress' with a track; `offset` if it rests facing up.\nconst sprite = new Rect({ id: 'sprite', width: 12, height: 12 });\norientToPath(sprite, pathFromSvg('M0 0 L100 0 L100 100'), { id: 'bank', progress: 0.5 });",
212
+ run: () => {
213
+ orientToPath(new Rect({
214
+ id: "sprite",
215
+ width: 12,
216
+ height: 12
217
+ }), pathFromSvg("M0 0 L100 0 L100 100"), {
218
+ id: "bank",
219
+ progress: .5
220
+ });
221
+ }
222
+ },
223
+ {
224
+ key: "lookAt",
225
+ code: "import { Rect, Circle } from '@glissade/scene';\nimport { lookAt } from '@glissade/scene/motion';\n// aim the target's local +x axis at another node's world origin (a turret tracking a mover)\nconst turret = new Rect({ id: 'turret', width: 12, height: 12, position: [0, 0] });\nconst mover = new Circle({ id: 'mover', radius: 6, position: [40, 20] });\nlookAt(turret, mover);",
226
+ run: () => {
227
+ lookAt(new Rect({
228
+ id: "turret",
229
+ width: 12,
230
+ height: 12,
231
+ position: [0, 0]
232
+ }), new Circle({
233
+ id: "mover",
234
+ radius: 6,
235
+ position: [40, 20]
236
+ }));
237
+ }
238
+ },
239
+ {
240
+ key: "retime",
241
+ code: "import { retime, track, key } from '@glissade/core';\n// pure key-time transform → ordinary retimed tracks (speed / shift / reverse / pingpong)\nconst move = [track('box/position.x', 'number', [key(0, 0), key(1, 100, 'easeInCubic')])];\nconst slow = retime(move, { speed: 0.5 }); // half speed\nconst back = retime(move, { reverse: true }); // play it backward",
242
+ run: () => {
243
+ const move = [track("box/position.x", "number", [key(0, 0), key(1, 100, "easeInCubic")])];
244
+ retime(move, { speed: .5 });
245
+ retime(move, { reverse: true });
246
+ }
247
+ },
248
+ {
249
+ key: "echo",
250
+ code: "import { Circle, echo } from '@glissade/scene';\n// motion trail / onion-skin: renders the child at K past playhead offsets, each fading by `decay`.\n// Add the returned Echo to the scene; drive the child however you like (its ghosts re-derive at each offset).\nconst dot = new Circle({ id: 'dot', radius: 8, fill: '#39e0ff' });\nconst trail = echo(dot, { count: 6, spacing: 0.05, decay: 0.7 });",
251
+ run: () => void echo(new Circle({
252
+ id: "dot",
253
+ radius: 8,
254
+ fill: "#39e0ff"
255
+ }), {
256
+ count: 6,
257
+ spacing: .05,
258
+ decay: .7
259
+ })
207
260
  }
208
261
  ];
209
262
  /**
package/dist/index.js CHANGED
@@ -1,7 +1,8 @@
1
1
  import { $ as multiply, A as setDefaultMeasurer, B as validateHachure, C as estimatingMeasurer, D as quantize, E as measureWrappedText, F as hachureLines, G as glow, H as FilterValidationError, I as hashStr, J as IDENTITY, K as validateFilters, L as resolveSketch, M as SketchValidationError, N as arcLength, O as segmentGraphemes, P as flatten, Q as matEquals, R as roughen, S as breakLines, T as isEstimatingMeasurer, U as createDisplayListBuilder, V as validateSketch, W as filtersToCanvasFilter, X as fromTRS, Y as applyToPoint, Z as invert, a as Path, b as __resetEstimateWarnings, c as Video, d as revealSchedule, f as roundedRectSegs, h as resolveAnchor, i as ImageNode, k as segmentWords, l as coercePathData, m as NodeConstructionError, n as Custom, o as Rect, p as Node, q as collapseReplacer, r as Group, s as Text, t as Circle, u as pathFromSegs, x as assertFiniteFontSize, y as MEASURE_QUANTUM_PX, z as sketchStrokes } from "./nodes.js";
2
2
  import { a as evaluate, i as createScene, n as ReservedNodeIdError, r as bindScene, t as DuplicateNodeIdError } from "./scene.js";
3
3
  import { i as setLayoutEngine, n as getLayoutEngine, r as requireLayoutEngine, t as LayoutEngineMissingError } from "./layoutEngine.js";
4
- import { batch, buildFontRegistry, emitDevWarning, key, lerpColor, oklabToRgba, parseCmap, parseColor, random, rgbaToOklab, signal, stagger, track, validateFonts } from "@glissade/core";
4
+ import { n as echo, t as Echo } from "./echo.js";
5
+ import { buildFontRegistry, emitDevWarning, key, lerpColor, oklabToRgba, parseCmap, parseColor, random, rgbaToOklab, signal, stagger, track, validateFonts } from "@glissade/core";
5
6
  //#region src/taxonomy.ts
6
7
  /**
7
8
  * The CLOSED node taxonomy (DESIGN.md §3.1): exactly nine built-in node TYPES.
@@ -670,81 +671,6 @@ var ShaderEffect = class extends Group {
670
671
  };
671
672
  }
672
673
  };
673
- //#endregion
674
- //#region src/echo.ts
675
- /**
676
- * Echo (0.26): motion trails / onion-skin. A wrapper group that renders its
677
- * children K times — at the playhead and at K−1 earlier offsets (t − i·spacing)
678
- * — each trailing copy fading by `decay`. The leading copy is the live frame;
679
- * the ghosts are the subtree "as it was" a few slices ago.
680
- *
681
- * It is the pure render form of "re-evaluate at t + k·spacing": within one
682
- * frame it re-addresses the SCENE PLAYHEAD to each offset time, emits the
683
- * children (whose bound signals re-derive at that time — tracks, followPath,
684
- * computeds all follow), then RESTORES the playhead before the walk continues.
685
- * The whole dance is wrapped in `batch()` so the playhead's subscribers (a
686
- * player repaint) coalesce to a single, idempotent notification at the restored
687
- * time — never a mid-emit reentrancy storm. Headless (goldens/export) the
688
- * playhead has no subscribers, so it is a plain pure multi-sample: evaluate()
689
- * stays a pure function of time and the DisplayList is byte-stable.
690
- *
691
- * Determinism note: the offsets are pure functions of the current playhead, so
692
- * a cold re-eval reproduces byte-identically (the cache-cold audit passes).
693
- * Ghost times before the first key clamp to the initial pose (track semantics).
694
- */
695
- var Echo = class extends Group {
696
- get describeType() {
697
- return "Echo";
698
- }
699
- count;
700
- spacing;
701
- decay;
702
- constructor(props = {}) {
703
- super(props);
704
- this.count = Math.max(1, Math.floor(props.count ?? 5));
705
- this.spacing = props.spacing ?? .08;
706
- this.decay = props.decay ?? .6;
707
- }
708
- draw(out, ctx) {
709
- const playhead = ctx.playhead;
710
- if (this.count <= 1 || this.spacing === 0 || playhead === void 0) {
711
- super.draw(out, ctx);
712
- return;
713
- }
714
- const now = ctx.time;
715
- batch(() => {
716
- try {
717
- for (let i = this.count - 1; i >= 0; i--) {
718
- const alpha = i === 0 ? 1 : this.decay ** i;
719
- if (alpha <= 0) continue;
720
- const offsetT = now - i * this.spacing;
721
- playhead.forceSet(offsetT);
722
- out.push({
723
- op: "pushGroup",
724
- opacity: alpha,
725
- blend: "source-over",
726
- filters: []
727
- });
728
- super.draw(out, {
729
- ...ctx,
730
- time: offsetT
731
- });
732
- out.push({ op: "popGroup" });
733
- }
734
- } finally {
735
- playhead.forceSet(now);
736
- }
737
- });
738
- }
739
- };
740
- /** `children: [echo(mover, { count: 6, spacing: 0.05 })]` — mover leaves a fading trail.
741
- * A convenience wrapper: pass the trailing content as children of the returned Echo. */
742
- function echo(child, props = {}) {
743
- return new Echo({
744
- ...props,
745
- children: [child]
746
- });
747
- }
748
674
  const smoothstep = (u) => u * u * (3 - 2 * u);
749
675
  const GAUSS_K = 2.4;
750
676
  const GAUSS_NORM = 1 - Math.exp(-5.76 / 2);
package/dist/motion.js CHANGED
@@ -1,110 +1,2 @@
1
- import { Y as applyToPoint, a as Path, p as Node } from "./nodes.js";
2
- import { a as pointAtLength, i as pathLength, n as followPath, r as motionPath, t as FollowPath } from "./motionPath.js";
3
- import { signal } from "@glissade/core";
4
- //#region src/orient.ts
5
- /**
6
- * Orientation drivers: rotate a node to face where it's *heading* (a path
7
- * tangent) or to *look at* another node. Both are the rotation-only siblings of
8
- * `followPath` — companion driver nodes that own only the target's `rotation`
9
- * via pull-based binding, so position stays whatever else drives it (keyframes,
10
- * layout, a separate followPath). Nothing executes at play time beyond a pure
11
- * read of signals already in the graph, so evaluate() stays a pure function of
12
- * time and the goldens are byte-stable.
13
- *
14
- * These live on the tree-shakeable `@glissade/scene/motion` subpath (like
15
- * followPath) — the base embed doesn't pay for them.
16
- */
17
- const DEG = 180 / Math.PI;
18
- /**
19
- * A node's origin in WORLD space, computed WITHOUT reading its own
20
- * `worldMatrix`/`localMatrix` signals. That matters for the node being oriented:
21
- * its localMatrix depends on `rotation`, so a rotation source that read
22
- * `this.worldMatrix()` would form a rotation → worldMatrix → rotation cycle.
23
- * Projecting `position` through the PARENT's world transform breaks the cycle
24
- * (a parent's world never depends on its child's rotation). This aims from the
25
- * node's position origin; an explicit `anchor` offset is not applied (v1).
26
- */
27
- function worldOrigin(n) {
28
- return n.parent ? applyToPoint(n.parent.worldMatrix(), n.position()) : n.position();
29
- }
30
- /**
31
- * Owns `target.rotation`, binding it to the path tangent at `progress` — a node
32
- * banks to face the direction of travel while its POSITION is driven elsewhere
33
- * (keyframes, layout, or a sibling followPath sharing the same `progress`). The
34
- * rotation-only half of followPath's `orient`. Add it to the scene; it draws
35
- * nothing. Animate `<id>/progress`.
36
- */
37
- var OrientToPath = class extends Node {
38
- target;
39
- progress;
40
- constructor(props) {
41
- super(props);
42
- this.target = props.target;
43
- this.progress = signal(1);
44
- if (typeof props.progress === "function") this.progress.bindSource(props.progress);
45
- else if (props.progress !== void 0) this.progress.set(props.progress);
46
- this.registerTarget("progress", this.progress, "number");
47
- const sOpts = props.samplesPerSegment !== void 0 ? { samplesPerSegment: props.samplesPerSegment } : {};
48
- const getPath = props.path instanceof Path ? () => props.path.data() : () => props.path;
49
- let cachedPath = getPath();
50
- let cachedSampler = motionPath(cachedPath, sOpts);
51
- const sampler = () => {
52
- const pv = getPath();
53
- if (pv !== cachedPath) {
54
- cachedPath = pv;
55
- cachedSampler = motionPath(pv, sOpts);
56
- }
57
- return cachedSampler;
58
- };
59
- const offset = props.offset ?? 0;
60
- props.target.rotation.bindSource(() => {
61
- const t = sampler().tangentAtProgress(this.progress());
62
- return Math.atan2(t[1], t[0]) * DEG + offset;
63
- });
64
- }
65
- draw() {}
66
- };
67
- /** `children: [node, orientToPath(node, route, { progress: 0.5 })]` — node banks
68
- * to the route's direction at progress 0.5 while its position comes from elsewhere. */
69
- function orientToPath(target, path, props = {}) {
70
- return new OrientToPath({
71
- ...props,
72
- target,
73
- path
74
- });
75
- }
76
- /**
77
- * Owns `target.rotation`, aiming target's local +x axis at `at`'s world origin —
78
- * a turret tracking a mover, an arrow pointing at a label. The angle is computed
79
- * in WORLD space and applied as `target`'s LOCAL rotation, which is exact when
80
- * target's parent is unrotated (the common case); a rotated parent would need
81
- * the parent's world rotation subtracted (v1 does not). Pure: rotation re-derives
82
- * from both nodes' positions each read, no stored state. Add it to the scene; it
83
- * draws nothing.
84
- */
85
- var LookAt = class extends Node {
86
- target;
87
- at;
88
- constructor(props) {
89
- super(props);
90
- this.target = props.target;
91
- this.at = props.at;
92
- const offset = props.offset ?? 0;
93
- props.target.rotation.bindSource(() => {
94
- const self = worldOrigin(props.target);
95
- const to = worldOrigin(props.at);
96
- return Math.atan2(to[1] - self[1], to[0] - self[0]) * DEG + offset;
97
- });
98
- }
99
- draw() {}
100
- };
101
- /** `children: [turret, mover, lookAt(turret, mover)]` — turret always faces the mover. */
102
- function lookAt(target, at, props = {}) {
103
- return new LookAt({
104
- ...props,
105
- target,
106
- at
107
- });
108
- }
109
- //#endregion
1
+ import { a as FollowPath, c as pathLength, i as orientToPath, l as pointAtLength, n as OrientToPath, o as followPath, r as lookAt, s as motionPath, t as LookAt } from "./orient.js";
110
2
  export { FollowPath, LookAt, OrientToPath, followPath, lookAt, motionPath, orientToPath, pathLength, pointAtLength };
@@ -1,4 +1,4 @@
1
- import { a as Path, p as Node } from "./nodes.js";
1
+ import { Y as applyToPoint, a as Path, p as Node } from "./nodes.js";
2
2
  import { signal } from "@glissade/core";
3
3
  //#region src/motionPath.ts
4
4
  /**
@@ -168,4 +168,110 @@ function followPath(target, path, props = {}) {
168
168
  });
169
169
  }
170
170
  //#endregion
171
- export { pointAtLength as a, pathLength as i, followPath as n, motionPath as r, FollowPath as t };
171
+ //#region src/orient.ts
172
+ /**
173
+ * Orientation drivers: rotate a node to face where it's *heading* (a path
174
+ * tangent) or to *look at* another node. Both are the rotation-only siblings of
175
+ * `followPath` — companion driver nodes that own only the target's `rotation`
176
+ * via pull-based binding, so position stays whatever else drives it (keyframes,
177
+ * layout, a separate followPath). Nothing executes at play time beyond a pure
178
+ * read of signals already in the graph, so evaluate() stays a pure function of
179
+ * time and the goldens are byte-stable.
180
+ *
181
+ * These live on the tree-shakeable `@glissade/scene/motion` subpath (like
182
+ * followPath) — the base embed doesn't pay for them.
183
+ */
184
+ const DEG = 180 / Math.PI;
185
+ /**
186
+ * A node's origin in WORLD space, computed WITHOUT reading its own
187
+ * `worldMatrix`/`localMatrix` signals. That matters for the node being oriented:
188
+ * its localMatrix depends on `rotation`, so a rotation source that read
189
+ * `this.worldMatrix()` would form a rotation → worldMatrix → rotation cycle.
190
+ * Projecting `position` through the PARENT's world transform breaks the cycle
191
+ * (a parent's world never depends on its child's rotation). This aims from the
192
+ * node's position origin; an explicit `anchor` offset is not applied (v1).
193
+ */
194
+ function worldOrigin(n) {
195
+ return n.parent ? applyToPoint(n.parent.worldMatrix(), n.position()) : n.position();
196
+ }
197
+ /**
198
+ * Owns `target.rotation`, binding it to the path tangent at `progress` — a node
199
+ * banks to face the direction of travel while its POSITION is driven elsewhere
200
+ * (keyframes, layout, or a sibling followPath sharing the same `progress`). The
201
+ * rotation-only half of followPath's `orient`. Add it to the scene; it draws
202
+ * nothing. Animate `<id>/progress`.
203
+ */
204
+ var OrientToPath = class extends Node {
205
+ target;
206
+ progress;
207
+ constructor(props) {
208
+ super(props);
209
+ this.target = props.target;
210
+ this.progress = signal(1);
211
+ if (typeof props.progress === "function") this.progress.bindSource(props.progress);
212
+ else if (props.progress !== void 0) this.progress.set(props.progress);
213
+ this.registerTarget("progress", this.progress, "number");
214
+ const sOpts = props.samplesPerSegment !== void 0 ? { samplesPerSegment: props.samplesPerSegment } : {};
215
+ const getPath = props.path instanceof Path ? () => props.path.data() : () => props.path;
216
+ let cachedPath = getPath();
217
+ let cachedSampler = motionPath(cachedPath, sOpts);
218
+ const sampler = () => {
219
+ const pv = getPath();
220
+ if (pv !== cachedPath) {
221
+ cachedPath = pv;
222
+ cachedSampler = motionPath(pv, sOpts);
223
+ }
224
+ return cachedSampler;
225
+ };
226
+ const offset = props.offset ?? 0;
227
+ props.target.rotation.bindSource(() => {
228
+ const t = sampler().tangentAtProgress(this.progress());
229
+ return Math.atan2(t[1], t[0]) * DEG + offset;
230
+ });
231
+ }
232
+ draw() {}
233
+ };
234
+ /** `children: [node, orientToPath(node, route, { progress: 0.5 })]` — node banks
235
+ * to the route's direction at progress 0.5 while its position comes from elsewhere. */
236
+ function orientToPath(target, path, props = {}) {
237
+ return new OrientToPath({
238
+ ...props,
239
+ target,
240
+ path
241
+ });
242
+ }
243
+ /**
244
+ * Owns `target.rotation`, aiming target's local +x axis at `at`'s world origin —
245
+ * a turret tracking a mover, an arrow pointing at a label. The angle is computed
246
+ * in WORLD space and applied as `target`'s LOCAL rotation, which is exact when
247
+ * target's parent is unrotated (the common case); a rotated parent would need
248
+ * the parent's world rotation subtracted (v1 does not). Pure: rotation re-derives
249
+ * from both nodes' positions each read, no stored state. Add it to the scene; it
250
+ * draws nothing.
251
+ */
252
+ var LookAt = class extends Node {
253
+ target;
254
+ at;
255
+ constructor(props) {
256
+ super(props);
257
+ this.target = props.target;
258
+ this.at = props.at;
259
+ const offset = props.offset ?? 0;
260
+ props.target.rotation.bindSource(() => {
261
+ const self = worldOrigin(props.target);
262
+ const to = worldOrigin(props.at);
263
+ return Math.atan2(to[1] - self[1], to[0] - self[0]) * DEG + offset;
264
+ });
265
+ }
266
+ draw() {}
267
+ };
268
+ /** `children: [turret, mover, lookAt(turret, mover)]` — turret always faces the mover. */
269
+ function lookAt(target, at, props = {}) {
270
+ return new LookAt({
271
+ ...props,
272
+ target,
273
+ at
274
+ });
275
+ }
276
+ //#endregion
277
+ export { FollowPath as a, pathLength as c, orientToPath as i, pointAtLength as l, OrientToPath as n, followPath as o, lookAt as r, motionPath as s, LookAt as t };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@glissade/scene",
3
- "version": "0.26.0-pre.0",
3
+ "version": "0.26.0",
4
4
  "description": "glissade scene graph: nodes, transforms, DisplayList emission. Renderer-agnostic; zero DOM/Node dependencies.",
5
5
  "license": "Apache-2.0",
6
6
  "engines": {
@@ -65,7 +65,7 @@
65
65
  ],
66
66
  "dependencies": {
67
67
  "yoga-layout": "^3.2.1",
68
- "@glissade/core": "0.26.0-pre.0"
68
+ "@glissade/core": "0.26.0"
69
69
  },
70
70
  "repository": {
71
71
  "type": "git",