@glissade/scene 0.2.0 → 0.4.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/README.md +26 -0
- package/dist/index.d.ts +110 -3
- package/dist/index.js +391 -6
- package/dist/layout.d.ts +19 -5
- package/dist/layout.js +57 -20
- package/dist/layoutEngine.d.ts +111 -11
- package/dist/layoutEngine.js +173 -6
- package/package.json +2 -2
package/README.md
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# @glissade/scene
|
|
2
|
+
|
|
3
|
+
The scene graph and the **DisplayList IR**: nodes (Group, Rect, Circle, Text, Image, Video) whose every animatable property is a signal, `createScene`, and the canonical `evaluate(scene, timeline, t) → DisplayList`. Text layout (explicit fonts, Intl.Segmenter line breaking) is deterministic across browser and headless render. Yoga flexbox lives behind the LayoutEngine seam at the separate `@glissade/scene/layout` entry — the base path never pays for wasm — including `width/height: 'auto'` content sizing and `computedSize()`.
|
|
4
|
+
|
|
5
|
+
```sh
|
|
6
|
+
npm i @glissade/scene @glissade/core
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
```ts
|
|
10
|
+
import { createScene, Circle, evaluate } from '@glissade/scene';
|
|
11
|
+
|
|
12
|
+
const scene = createScene({
|
|
13
|
+
size: { w: 640, h: 360 },
|
|
14
|
+
children: [new Circle({ id: 'dot', radius: 40, fill: '#e6a700', position: [320, 180] })],
|
|
15
|
+
});
|
|
16
|
+
const displayList = evaluate(scene, doc, 1.25); // pure, serializable, renderer-agnostic
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Part of glissade
|
|
20
|
+
|
|
21
|
+
*(glide & slide)* — programmatic motion graphics for TypeScript: realtime-first in any web page, deterministic headless video export from the same code, a visual studio over the same document. No generator functions.
|
|
22
|
+
|
|
23
|
+
- [Repository & full README](https://github.com/tyevco/glissade)
|
|
24
|
+
- [Getting started](https://github.com/tyevco/glissade/blob/main/docs/getting-started.md) · [Concepts](https://github.com/tyevco/glissade/blob/main/docs/concepts.md) · [Interactivity](https://github.com/tyevco/glissade/blob/main/docs/interactivity.md)
|
|
25
|
+
|
|
26
|
+
Apache-2.0.
|
package/dist/index.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { A as
|
|
2
|
-
import { BoundTimeline, CompiledTimeline, Playhead, Timeline } from "@glissade/core";
|
|
1
|
+
import { $ as applyToPoint, A as breakLines, B as Paint, C as EvalContext, D as PropInit, E as NodeProps, F as DisplayListBuilder, G as ShaderRef, H as Rect$1, I as DrawCommand, J as filtersToCanvasFilter, K as StrokeStyle, L as FilterSpec, M as quantize, N as BlendMode, O as TextMeasurer, P as DisplayList, Q as Mat2x3, R as FilterValidationError, S as BindablePropTarget, T as Node, U as Resource, V as PathSeg, W as ResourceId, X as validateFilters, Y as glow, Z as IDENTITY, _ as ShapeProps, a as LayoutEngineMissingError, b as Video, c as requireLayoutEngine, d as Group, et as fromTRS, f as ImageNode, g as Rect, h as PathProps, i as LayoutEngine, j as estimatingMeasurer, k as TextMetricsLite, l as setLayoutEngine, m as Path, n as LayoutChildSpec, nt as matEquals, p as ImageProps, q as createDisplayListBuilder, r as LayoutContainerSpec, rt as multiply, s as getLayoutEngine, t as LayoutBox, tt as invert, u as Circle, v as Text, w as HitArea, x as VideoProps, y as TextProps, z as FontSpec } from "./layoutEngine.js";
|
|
2
|
+
import { BindableSignal, BoundTimeline, CompiledTimeline, Playhead, Timeline } from "@glissade/core";
|
|
3
3
|
|
|
4
4
|
//#region src/assets.d.ts
|
|
5
5
|
|
|
@@ -37,6 +37,113 @@ declare class ColdAssetError extends Error {
|
|
|
37
37
|
constructor(assetId: string, detail: string, mediaT?: number);
|
|
38
38
|
}
|
|
39
39
|
//#endregion
|
|
40
|
+
//#region src/shaderEffect.d.ts
|
|
41
|
+
|
|
42
|
+
interface ShaderEffectProps extends NodeProps {
|
|
43
|
+
children?: Node[];
|
|
44
|
+
/** WGSL fragment module: `struct Uniforms {...}` + `@fragment fn effect(@location(0) uv: vec2f) -> @location(0) vec4f`. */
|
|
45
|
+
wgsl: string;
|
|
46
|
+
/** Initial scalar uniforms; each becomes an animatable signal + track target 'u.<name>'. */
|
|
47
|
+
uniforms?: Record<string, number>;
|
|
48
|
+
}
|
|
49
|
+
declare class ShaderEffect extends Group {
|
|
50
|
+
readonly wgsl: string;
|
|
51
|
+
readonly uniformSignals: ReadonlyMap<string, BindableSignal<number>>;
|
|
52
|
+
constructor(props: ShaderEffectProps);
|
|
53
|
+
/** The live uniform signal (throws on unknown names — typos fail loudly). */
|
|
54
|
+
uniform(name: string): BindableSignal<number>;
|
|
55
|
+
protected groupShader(): ShaderRef;
|
|
56
|
+
}
|
|
57
|
+
//#endregion
|
|
58
|
+
//#region src/raster2d.d.ts
|
|
59
|
+
/** The structural path surface buildPath drives — DOM Path2D and @napi-rs Path2D both satisfy it. */
|
|
60
|
+
interface PathLike {
|
|
61
|
+
moveTo(x: number, y: number): void;
|
|
62
|
+
lineTo(x: number, y: number): void;
|
|
63
|
+
bezierCurveTo(x1: number, y1: number, x2: number, y2: number, x: number, y: number): void;
|
|
64
|
+
quadraticCurveTo(cx: number, cy: number, x: number, y: number): void;
|
|
65
|
+
ellipse(cx: number, cy: number, rx: number, ry: number, rot: number, a0: number, a1: number): void;
|
|
66
|
+
closePath(): void;
|
|
67
|
+
}
|
|
68
|
+
/** The exact 2D-context surface the interpreter uses — nothing more. */
|
|
69
|
+
interface Ctx2DLike<TPath, TDrawable> {
|
|
70
|
+
save(): void;
|
|
71
|
+
restore(): void;
|
|
72
|
+
transform(a: number, b: number, c: number, d: number, e: number, f: number): void;
|
|
73
|
+
resetTransform(): void;
|
|
74
|
+
getTransform(): unknown;
|
|
75
|
+
setTransform(m: unknown): void;
|
|
76
|
+
clearRect(x: number, y: number, w: number, h: number): void;
|
|
77
|
+
clip(path: TPath, rule: 'nonzero' | 'evenodd'): void;
|
|
78
|
+
fill(path: TPath): void;
|
|
79
|
+
stroke(path: TPath): void;
|
|
80
|
+
fillText(text: string, x: number, y: number): void;
|
|
81
|
+
measureText(text: string): {
|
|
82
|
+
width: number;
|
|
83
|
+
};
|
|
84
|
+
drawImage(image: TDrawable, x: number, y: number, w?: number, h?: number): void;
|
|
85
|
+
drawImage(image: TDrawable, sx: number, sy: number, sw: number, sh: number, x: number, y: number, w: number, h: number): void;
|
|
86
|
+
setLineDash(segments: number[]): void;
|
|
87
|
+
fillStyle: unknown;
|
|
88
|
+
strokeStyle: unknown;
|
|
89
|
+
lineWidth: number;
|
|
90
|
+
lineCap: string;
|
|
91
|
+
lineJoin: string;
|
|
92
|
+
font: string;
|
|
93
|
+
textBaseline: string;
|
|
94
|
+
textAlign: string;
|
|
95
|
+
globalAlpha: number;
|
|
96
|
+
globalCompositeOperation: string;
|
|
97
|
+
filter: string;
|
|
98
|
+
imageSmoothingEnabled: boolean;
|
|
99
|
+
}
|
|
100
|
+
interface CanvasLike {
|
|
101
|
+
width: number;
|
|
102
|
+
height: number;
|
|
103
|
+
}
|
|
104
|
+
/** What a backend supplies: constructors and context access for its canvas flavor. */
|
|
105
|
+
interface Raster2DHost<TCanvas extends CanvasLike, TPath extends PathLike, TDrawable> {
|
|
106
|
+
context(canvas: TCanvas): Ctx2DLike<TPath, TDrawable>;
|
|
107
|
+
createCanvas(w: number, h: number): TCanvas;
|
|
108
|
+
newPath(): TPath;
|
|
109
|
+
/**
|
|
110
|
+
* §3.7 shader pass: run the WGSL effect over the group layer and return a
|
|
111
|
+
* drawable replacement, or null when unavailable. Absent/null → the layer
|
|
112
|
+
* composites unfiltered per caps.shaders (warn by default, error opt-in).
|
|
113
|
+
* Only browser hosts wire this (via @glissade/effects-webgpu); headless
|
|
114
|
+
* backends stay GPU-free by construction.
|
|
115
|
+
*/
|
|
116
|
+
applyShader?(layer: TCanvas, shader: ShaderRef, w: number, h: number): TDrawable | null;
|
|
117
|
+
}
|
|
118
|
+
type ShaderCaps = 'warn' | 'error';
|
|
119
|
+
declare function fontString(font: FontSpec): string;
|
|
120
|
+
declare class Raster2D<TCanvas extends CanvasLike, TPath extends PathLike, TDrawable> {
|
|
121
|
+
private readonly host;
|
|
122
|
+
/** caps.shaders (§3.7): what happens when a shader can't run here. */
|
|
123
|
+
private readonly shaderCaps;
|
|
124
|
+
private readonly pool;
|
|
125
|
+
private readonly pathCache;
|
|
126
|
+
private readonly pathBoundsCache;
|
|
127
|
+
private readonly images;
|
|
128
|
+
private readonly videos;
|
|
129
|
+
private warnedShaders;
|
|
130
|
+
constructor(host: Raster2DHost<TCanvas, TPath, TDrawable>, /** caps.shaders (§3.7): what happens when a shader can't run here. */
|
|
131
|
+
shaderCaps?: ShaderCaps);
|
|
132
|
+
/** Register a decoded still (kind 'image' assets). */
|
|
133
|
+
setImageAsset(assetId: string, image: TDrawable): void;
|
|
134
|
+
/** Register a warmed-on-demand video source (kind 'video' assets, §3.8). */
|
|
135
|
+
setVideoAsset(assetId: string, source: VideoFrameSource): void;
|
|
136
|
+
dispose(): void;
|
|
137
|
+
private resolveDrawable;
|
|
138
|
+
private path;
|
|
139
|
+
private pathBounds;
|
|
140
|
+
private buildPath;
|
|
141
|
+
private acquire;
|
|
142
|
+
private release;
|
|
143
|
+
/** The command walk — order and operations identical to the pre-extraction twins. */
|
|
144
|
+
render(target: TCanvas, list: DisplayList): void;
|
|
145
|
+
}
|
|
146
|
+
//#endregion
|
|
40
147
|
//#region src/scene.d.ts
|
|
41
148
|
interface Scene {
|
|
42
149
|
readonly root: Group;
|
|
@@ -86,4 +193,4 @@ declare function bindScene(scene: Scene, doc: Timeline): BindingCacheEntry;
|
|
|
86
193
|
*/
|
|
87
194
|
declare function evaluate(scene: Scene, doc: Timeline, t: number): DisplayList;
|
|
88
195
|
//#endregion
|
|
89
|
-
export { type BindablePropTarget, type BlendMode, Circle, ColdAssetError, type DisplayList, type DisplayListBuilder, type DrawCommand, DuplicateNodeIdError, type EvalContext, type FilterSpec, type FontSpec, Group, type HitArea, IDENTITY, type ImageHandle, ImageNode, type ImageProps, type LayoutBox, type LayoutChildSpec, type LayoutContainerSpec, type LayoutEngine, LayoutEngineMissingError, type Mat2x3, Node, type NodeProps, type Paint, type PathSeg, type PropInit, Rect, type Rect$1 as RectShape, type Resource, type ResourceId, type Scene, type SceneInit, type SceneModule, type ShapeProps, type StrokeStyle, Text, type TextMeasurer, type TextMetricsLite, type TextProps, Video, type VideoFrameSource, type VideoProps, applyToPoint, bindScene, breakLines, createDisplayListBuilder, createScene, estimatingMeasurer, evaluate, fromTRS, getLayoutEngine, invert, matEquals, multiply, quantize, requireLayoutEngine, setLayoutEngine };
|
|
196
|
+
export { type BindablePropTarget, type BlendMode, type CanvasLike, Circle, ColdAssetError, type Ctx2DLike, type DisplayList, type DisplayListBuilder, type DrawCommand, DuplicateNodeIdError, type EvalContext, type FilterSpec, FilterValidationError, type FontSpec, Group, type HitArea, IDENTITY, type ImageHandle, ImageNode, type ImageProps, type LayoutBox, type LayoutChildSpec, type LayoutContainerSpec, type LayoutEngine, LayoutEngineMissingError, type Mat2x3, Node, type NodeProps, type Paint, Path, type PathLike, type PathProps, type PathSeg, type PropInit, Raster2D, type Raster2DHost, Rect, type Rect$1 as RectShape, type Resource, type ResourceId, type Scene, type SceneInit, type SceneModule, type ShaderCaps, ShaderEffect, type ShaderEffectProps, type ShaderRef, type ShapeProps, type StrokeStyle, Text, type TextMeasurer, type TextMetricsLite, type TextProps, Video, type VideoFrameSource, type VideoProps, applyToPoint, bindScene, breakLines, createDisplayListBuilder, createScene, estimatingMeasurer, evaluate, filtersToCanvasFilter, fontString, fromTRS, getLayoutEngine, glow, invert, matEquals, multiply, quantize, requireLayoutEngine, setLayoutEngine, validateFilters };
|
package/dist/index.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { bindTimeline, compileTimeline, createPlayhead, evaluateAt } from "@glissade/core";
|
|
1
|
+
import { C as fromTRS, E as multiply, S as applyToPoint, T as matEquals, _ as createDisplayListBuilder, a as Circle, b as validateFilters, c as Path, d as Video, f as Node, g as FilterValidationError, h as quantize, i as setLayoutEngine, l as Rect, m as estimatingMeasurer, n as getLayoutEngine, o as Group, p as breakLines, r as requireLayoutEngine, s as ImageNode, t as LayoutEngineMissingError, u as Text, v as filtersToCanvasFilter, w as invert, x as IDENTITY, y as glow } from "./layoutEngine.js";
|
|
2
|
+
import { bindTimeline, compileTimeline, createPlayhead, emitDevWarning, evaluateAt, signal } from "@glissade/core";
|
|
3
3
|
//#region src/assets.ts
|
|
4
4
|
var ColdAssetError = class extends Error {
|
|
5
5
|
assetId;
|
|
@@ -15,6 +15,390 @@ var ColdAssetError = class extends Error {
|
|
|
15
15
|
}
|
|
16
16
|
};
|
|
17
17
|
//#endregion
|
|
18
|
+
//#region src/shaderEffect.ts
|
|
19
|
+
/**
|
|
20
|
+
* ShaderEffect (§3.7): a group whose rasterized subtree runs through a WGSL
|
|
21
|
+
* pass. THIS FILE IS PURE DATA — the GPU runner lives in the browser-only
|
|
22
|
+
* @glissade/effects-webgpu package; headless backends degrade per
|
|
23
|
+
* caps.shaders (passthrough + warning by default). Uniforms are per-name
|
|
24
|
+
* number signals registered as '<id>/u.<name>' track targets, so shader
|
|
25
|
+
* params animate exactly like any other property.
|
|
26
|
+
*/
|
|
27
|
+
var ShaderEffect = class extends Group {
|
|
28
|
+
wgsl;
|
|
29
|
+
uniformSignals;
|
|
30
|
+
constructor(props) {
|
|
31
|
+
super(props);
|
|
32
|
+
this.wgsl = props.wgsl;
|
|
33
|
+
const map = /* @__PURE__ */ new Map();
|
|
34
|
+
for (const [name, value] of Object.entries(props.uniforms ?? {})) {
|
|
35
|
+
const sig = signal(value);
|
|
36
|
+
map.set(name, sig);
|
|
37
|
+
this.registerTarget(`u.${name}`, sig);
|
|
38
|
+
}
|
|
39
|
+
this.uniformSignals = map;
|
|
40
|
+
}
|
|
41
|
+
/** The live uniform signal (throws on unknown names — typos fail loudly). */
|
|
42
|
+
uniform(name) {
|
|
43
|
+
const sig = this.uniformSignals.get(name);
|
|
44
|
+
if (!sig) throw new Error(`ShaderEffect has no uniform '${name}' (have: ${[...this.uniformSignals.keys()].join(", ")})`);
|
|
45
|
+
return sig;
|
|
46
|
+
}
|
|
47
|
+
groupShader() {
|
|
48
|
+
const uniforms = {};
|
|
49
|
+
for (const [name, sig] of this.uniformSignals) uniforms[name] = sig();
|
|
50
|
+
return {
|
|
51
|
+
wgsl: this.wgsl,
|
|
52
|
+
uniforms
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
//#endregion
|
|
57
|
+
//#region src/raster2d.ts
|
|
58
|
+
/**
|
|
59
|
+
* The shared DisplayList interpreter (§3.4): one command walk over the
|
|
60
|
+
* canvas-2d-shaped API, generic over the host's canvas/path/drawable types.
|
|
61
|
+
* backend-canvas2d (DOM) and backend-skia (@napi-rs) instantiate it with
|
|
62
|
+
* four-line adapters, so the twin rasterizers structurally cannot drift —
|
|
63
|
+
* the golden + SSIM suites verify the refactor preserved every byte.
|
|
64
|
+
*/
|
|
65
|
+
function fontString(font) {
|
|
66
|
+
return `${font.style === "italic" ? "italic " : ""}${font.weight !== void 0 && font.weight !== 400 ? `${font.weight} ` : ""}${font.size}px ${font.family}`;
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* How far a filter chain can paint beyond its input (device px). Each stage
|
|
70
|
+
* feeds the next, so outsets ADD. Gaussian reach: Skia truncates at 3σ and
|
|
71
|
+
* the CSS blur/shadow radii are ≥ σ, so 3× the radius over-covers either
|
|
72
|
+
* convention. Color-only filters map transparent → transparent: zero outset.
|
|
73
|
+
*/
|
|
74
|
+
function filterOutset(filters) {
|
|
75
|
+
let total = 0;
|
|
76
|
+
for (const f of filters ?? []) if (f.kind === "blur") total += 3 * f.radius;
|
|
77
|
+
else if (f.kind === "drop-shadow") total += Math.max(Math.abs(f.dx), Math.abs(f.dy)) + 3 * f.blur;
|
|
78
|
+
return total;
|
|
79
|
+
}
|
|
80
|
+
function growBounds(b, x, y) {
|
|
81
|
+
if (!b) return {
|
|
82
|
+
minX: x,
|
|
83
|
+
minY: y,
|
|
84
|
+
maxX: x,
|
|
85
|
+
maxY: y
|
|
86
|
+
};
|
|
87
|
+
if (x < b.minX) b.minX = x;
|
|
88
|
+
if (y < b.minY) b.minY = y;
|
|
89
|
+
if (x > b.maxX) b.maxX = x;
|
|
90
|
+
if (y > b.maxY) b.maxY = y;
|
|
91
|
+
return b;
|
|
92
|
+
}
|
|
93
|
+
/** Local-space rect (already outset) → device-space box under m. */
|
|
94
|
+
function accumulateRect(layer, m, x0, y0, x1, y1) {
|
|
95
|
+
for (const [x, y] of [
|
|
96
|
+
[x0, y0],
|
|
97
|
+
[x1, y0],
|
|
98
|
+
[x0, y1],
|
|
99
|
+
[x1, y1]
|
|
100
|
+
]) layer.bounds = growBounds(layer.bounds, m[0] * x + m[2] * y + m[4], m[1] * x + m[3] * y + m[5]);
|
|
101
|
+
}
|
|
102
|
+
/** Control-point box of a path — curves and rotated ellipses stay inside it. */
|
|
103
|
+
function segsBounds(segs) {
|
|
104
|
+
let b = null;
|
|
105
|
+
const pt = (x, y) => {
|
|
106
|
+
b = growBounds(b, x, y);
|
|
107
|
+
};
|
|
108
|
+
for (const seg of segs) switch (seg[0]) {
|
|
109
|
+
case "M":
|
|
110
|
+
case "L":
|
|
111
|
+
pt(seg[1], seg[2]);
|
|
112
|
+
break;
|
|
113
|
+
case "C":
|
|
114
|
+
pt(seg[1], seg[2]);
|
|
115
|
+
pt(seg[3], seg[4]);
|
|
116
|
+
pt(seg[5], seg[6]);
|
|
117
|
+
break;
|
|
118
|
+
case "Q":
|
|
119
|
+
pt(seg[1], seg[2]);
|
|
120
|
+
pt(seg[3], seg[4]);
|
|
121
|
+
break;
|
|
122
|
+
case "E": {
|
|
123
|
+
const r = Math.max(seg[3], seg[4]);
|
|
124
|
+
pt(seg[1] - r, seg[2] - r);
|
|
125
|
+
pt(seg[1] + r, seg[2] + r);
|
|
126
|
+
break;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return b;
|
|
130
|
+
}
|
|
131
|
+
var Raster2D = class {
|
|
132
|
+
host;
|
|
133
|
+
shaderCaps;
|
|
134
|
+
pool = [];
|
|
135
|
+
pathCache = /* @__PURE__ */ new WeakMap();
|
|
136
|
+
pathBoundsCache = /* @__PURE__ */ new WeakMap();
|
|
137
|
+
images = /* @__PURE__ */ new Map();
|
|
138
|
+
videos = /* @__PURE__ */ new Map();
|
|
139
|
+
warnedShaders = false;
|
|
140
|
+
constructor(host, shaderCaps = "warn") {
|
|
141
|
+
this.host = host;
|
|
142
|
+
this.shaderCaps = shaderCaps;
|
|
143
|
+
}
|
|
144
|
+
/** Register a decoded still (kind 'image' assets). */
|
|
145
|
+
setImageAsset(assetId, image) {
|
|
146
|
+
this.images.set(assetId, image);
|
|
147
|
+
}
|
|
148
|
+
/** Register a warmed-on-demand video source (kind 'video' assets, §3.8). */
|
|
149
|
+
setVideoAsset(assetId, source) {
|
|
150
|
+
this.videos.set(assetId, source);
|
|
151
|
+
}
|
|
152
|
+
dispose() {
|
|
153
|
+
this.pool.length = 0;
|
|
154
|
+
}
|
|
155
|
+
resolveDrawable(res, id) {
|
|
156
|
+
if (res.kind === "image") {
|
|
157
|
+
const img = this.images.get(res.assetId);
|
|
158
|
+
if (!img) throw new ColdAssetError(res.assetId, "no decoded image registered");
|
|
159
|
+
return img;
|
|
160
|
+
}
|
|
161
|
+
if (res.kind === "videoFrame") {
|
|
162
|
+
const source = this.videos.get(res.assetId);
|
|
163
|
+
if (!source) throw new ColdAssetError(res.assetId, "no VideoFrameSource registered", res.mediaT);
|
|
164
|
+
try {
|
|
165
|
+
return source.getFrameSync(res.mediaT);
|
|
166
|
+
} catch (e) {
|
|
167
|
+
if (e instanceof ColdAssetError) throw new ColdAssetError(res.assetId, e.detail, res.mediaT);
|
|
168
|
+
throw e;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
throw new Error(`resource ${id} is not drawable`);
|
|
172
|
+
}
|
|
173
|
+
path(resources, id) {
|
|
174
|
+
const res = resources[id];
|
|
175
|
+
if (!res || res.kind !== "path") throw new Error(`resource ${id} is not a path`);
|
|
176
|
+
let p = this.pathCache.get(res);
|
|
177
|
+
if (!p) {
|
|
178
|
+
p = this.buildPath(res.segs);
|
|
179
|
+
this.pathCache.set(res, p);
|
|
180
|
+
}
|
|
181
|
+
return p;
|
|
182
|
+
}
|
|
183
|
+
pathBounds(resources, id) {
|
|
184
|
+
const res = resources[id];
|
|
185
|
+
if (!res || res.kind !== "path") return null;
|
|
186
|
+
if (!this.pathBoundsCache.has(res)) this.pathBoundsCache.set(res, segsBounds(res.segs));
|
|
187
|
+
return this.pathBoundsCache.get(res) ?? null;
|
|
188
|
+
}
|
|
189
|
+
buildPath(segs) {
|
|
190
|
+
const p = this.host.newPath();
|
|
191
|
+
for (const seg of segs) switch (seg[0]) {
|
|
192
|
+
case "M":
|
|
193
|
+
p.moveTo(seg[1], seg[2]);
|
|
194
|
+
break;
|
|
195
|
+
case "L":
|
|
196
|
+
p.lineTo(seg[1], seg[2]);
|
|
197
|
+
break;
|
|
198
|
+
case "C":
|
|
199
|
+
p.bezierCurveTo(seg[1], seg[2], seg[3], seg[4], seg[5], seg[6]);
|
|
200
|
+
break;
|
|
201
|
+
case "Q":
|
|
202
|
+
p.quadraticCurveTo(seg[1], seg[2], seg[3], seg[4]);
|
|
203
|
+
break;
|
|
204
|
+
case "E":
|
|
205
|
+
p.ellipse(seg[1], seg[2], seg[3], seg[4], seg[5], seg[6], seg[7]);
|
|
206
|
+
break;
|
|
207
|
+
case "Z":
|
|
208
|
+
p.closePath();
|
|
209
|
+
break;
|
|
210
|
+
}
|
|
211
|
+
return p;
|
|
212
|
+
}
|
|
213
|
+
acquire(w, h) {
|
|
214
|
+
const pooled = this.pool.pop();
|
|
215
|
+
if (pooled) {
|
|
216
|
+
if (pooled.width !== w) pooled.width = w;
|
|
217
|
+
if (pooled.height !== h) pooled.height = h;
|
|
218
|
+
return pooled;
|
|
219
|
+
}
|
|
220
|
+
return this.host.createCanvas(w, h);
|
|
221
|
+
}
|
|
222
|
+
release(canvas) {
|
|
223
|
+
if (this.pool.length < 8) this.pool.push(canvas);
|
|
224
|
+
}
|
|
225
|
+
/** The command walk — order and operations identical to the pre-extraction twins. */
|
|
226
|
+
render(target, list) {
|
|
227
|
+
const { w, h } = list.size;
|
|
228
|
+
if (target.width !== w) target.width = w;
|
|
229
|
+
if (target.height !== h) target.height = h;
|
|
230
|
+
const base = this.host.context(target);
|
|
231
|
+
base.resetTransform();
|
|
232
|
+
base.clearRect(0, 0, w, h);
|
|
233
|
+
const layers = [{
|
|
234
|
+
ctx: base,
|
|
235
|
+
canvas: null,
|
|
236
|
+
opacity: 1,
|
|
237
|
+
blend: "source-over",
|
|
238
|
+
bounds: null,
|
|
239
|
+
unbounded: false
|
|
240
|
+
}];
|
|
241
|
+
const ctxOf = () => layers[layers.length - 1].ctx;
|
|
242
|
+
const top = () => layers[layers.length - 1];
|
|
243
|
+
let mat = IDENTITY;
|
|
244
|
+
const matStack = [];
|
|
245
|
+
for (const cmd of list.commands) switch (cmd.op) {
|
|
246
|
+
case "save":
|
|
247
|
+
matStack.push(mat);
|
|
248
|
+
ctxOf().save();
|
|
249
|
+
break;
|
|
250
|
+
case "restore":
|
|
251
|
+
mat = matStack.pop() ?? mat;
|
|
252
|
+
ctxOf().restore();
|
|
253
|
+
break;
|
|
254
|
+
case "transform":
|
|
255
|
+
mat = multiply(mat, cmd.m);
|
|
256
|
+
ctxOf().transform(cmd.m[0], cmd.m[1], cmd.m[2], cmd.m[3], cmd.m[4], cmd.m[5]);
|
|
257
|
+
break;
|
|
258
|
+
case "clip":
|
|
259
|
+
ctxOf().clip(this.path(list.resources, cmd.path), cmd.rule ?? "nonzero");
|
|
260
|
+
break;
|
|
261
|
+
case "fillPath": {
|
|
262
|
+
const ctx = ctxOf();
|
|
263
|
+
ctx.fillStyle = cmd.paint.color;
|
|
264
|
+
ctx.fill(this.path(list.resources, cmd.path));
|
|
265
|
+
const b = this.pathBounds(list.resources, cmd.path);
|
|
266
|
+
if (b) accumulateRect(top(), mat, b.minX, b.minY, b.maxX, b.maxY);
|
|
267
|
+
break;
|
|
268
|
+
}
|
|
269
|
+
case "strokePath": {
|
|
270
|
+
const ctx = ctxOf();
|
|
271
|
+
ctx.strokeStyle = cmd.paint.color;
|
|
272
|
+
ctx.lineWidth = cmd.stroke.width;
|
|
273
|
+
ctx.lineCap = cmd.stroke.cap ?? "butt";
|
|
274
|
+
ctx.lineJoin = cmd.stroke.join ?? "miter";
|
|
275
|
+
if (cmd.stroke.dash) ctx.setLineDash(cmd.stroke.dash);
|
|
276
|
+
ctx.stroke(this.path(list.resources, cmd.path));
|
|
277
|
+
if (cmd.stroke.dash) ctx.setLineDash([]);
|
|
278
|
+
const b = this.pathBounds(list.resources, cmd.path);
|
|
279
|
+
if (b) {
|
|
280
|
+
const o = cmd.stroke.width * ((cmd.stroke.join ?? "miter") === "miter" ? 5 : 1);
|
|
281
|
+
accumulateRect(top(), mat, b.minX - o, b.minY - o, b.maxX + o, b.maxY + o);
|
|
282
|
+
}
|
|
283
|
+
break;
|
|
284
|
+
}
|
|
285
|
+
case "fillText": {
|
|
286
|
+
const ctx = ctxOf();
|
|
287
|
+
ctx.font = fontString(cmd.font);
|
|
288
|
+
ctx.fillStyle = cmd.paint.color;
|
|
289
|
+
ctx.textBaseline = "alphabetic";
|
|
290
|
+
ctx.textAlign = cmd.align ?? "left";
|
|
291
|
+
ctx.fillText(cmd.text, cmd.x, cmd.y);
|
|
292
|
+
try {
|
|
293
|
+
const width = ctx.measureText(cmd.text).width;
|
|
294
|
+
const align = cmd.align ?? "left";
|
|
295
|
+
const x0 = align === "center" ? cmd.x - width / 2 : align === "right" ? cmd.x - width : cmd.x;
|
|
296
|
+
const m = cmd.font.size;
|
|
297
|
+
accumulateRect(top(), mat, x0 - m, cmd.y - 1.5 * m, x0 + width + m, cmd.y + .75 * m);
|
|
298
|
+
} catch {
|
|
299
|
+
top().unbounded = true;
|
|
300
|
+
}
|
|
301
|
+
break;
|
|
302
|
+
}
|
|
303
|
+
case "drawImage": {
|
|
304
|
+
const res = list.resources[cmd.image];
|
|
305
|
+
if (!res) throw new Error(`drawImage references missing resource ${cmd.image}`);
|
|
306
|
+
const drawable = this.resolveDrawable(res, cmd.image);
|
|
307
|
+
const ctx = ctxOf();
|
|
308
|
+
if (cmd.smoothing !== void 0) ctx.imageSmoothingEnabled = cmd.smoothing;
|
|
309
|
+
const { x, y, w: dw, h: dh } = cmd.dst;
|
|
310
|
+
if (cmd.src) ctx.drawImage(drawable, cmd.src.x, cmd.src.y, cmd.src.w, cmd.src.h, x, y, dw, dh);
|
|
311
|
+
else ctx.drawImage(drawable, x, y, dw, dh);
|
|
312
|
+
accumulateRect(top(), mat, x, y, x + dw, y + dh);
|
|
313
|
+
break;
|
|
314
|
+
}
|
|
315
|
+
case "pushGroup": {
|
|
316
|
+
const parent = ctxOf();
|
|
317
|
+
const layerCanvas = this.acquire(w, h);
|
|
318
|
+
const layerCtx = this.host.context(layerCanvas);
|
|
319
|
+
layerCtx.resetTransform();
|
|
320
|
+
layerCtx.clearRect(0, 0, w, h);
|
|
321
|
+
layerCtx.setTransform(parent.getTransform());
|
|
322
|
+
layers.push({
|
|
323
|
+
ctx: layerCtx,
|
|
324
|
+
canvas: layerCanvas,
|
|
325
|
+
opacity: cmd.opacity,
|
|
326
|
+
blend: cmd.blend,
|
|
327
|
+
filter: filtersToCanvasFilter(cmd.filters),
|
|
328
|
+
filters: cmd.filters,
|
|
329
|
+
...cmd.shader !== void 0 ? { shader: cmd.shader } : {},
|
|
330
|
+
bounds: null,
|
|
331
|
+
unbounded: false
|
|
332
|
+
});
|
|
333
|
+
break;
|
|
334
|
+
}
|
|
335
|
+
case "popGroup": {
|
|
336
|
+
const layer = layers.pop();
|
|
337
|
+
if (!layer || layer.canvas === null) throw new Error("popGroup without matching pushGroup");
|
|
338
|
+
const parent = ctxOf();
|
|
339
|
+
let drawable = layer.canvas;
|
|
340
|
+
let shaderReplaced = false;
|
|
341
|
+
if (layer.shader !== void 0) {
|
|
342
|
+
const replaced = this.host.applyShader?.(layer.canvas, layer.shader, w, h) ?? null;
|
|
343
|
+
if (replaced !== null) {
|
|
344
|
+
drawable = replaced;
|
|
345
|
+
shaderReplaced = true;
|
|
346
|
+
layer.bounds = {
|
|
347
|
+
minX: 0,
|
|
348
|
+
minY: 0,
|
|
349
|
+
maxX: w,
|
|
350
|
+
maxY: h
|
|
351
|
+
};
|
|
352
|
+
layer.unbounded = false;
|
|
353
|
+
} else if (this.shaderCaps === "error") throw new Error("a ShaderEffect reached a backend without a shader runner (§3.7) — load @glissade/effects-webgpu in the browser, or accept passthrough with caps.shaders: warn");
|
|
354
|
+
else if (!this.warnedShaders) {
|
|
355
|
+
this.warnedShaders = true;
|
|
356
|
+
emitDevWarning("ShaderEffect pass skipped: no shader runner here (headless or webgpu-less browser) — subtree composites unfiltered (§3.7 caps.shaders)");
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
const hasFilter = layer.filter !== void 0 && layer.filter !== "none";
|
|
360
|
+
const outset = hasFilter ? filterOutset(layer.filters) : 0;
|
|
361
|
+
const parentLayer = top();
|
|
362
|
+
if (layer.unbounded || layer.blend !== "source-over") parentLayer.unbounded = true;
|
|
363
|
+
else if (layer.bounds) accumulateRect(parentLayer, IDENTITY, layer.bounds.minX - outset, layer.bounds.minY - outset, layer.bounds.maxX + outset, layer.bounds.maxY + outset);
|
|
364
|
+
const clippable = hasFilter && !shaderReplaced && !layer.unbounded && layer.blend === "source-over";
|
|
365
|
+
if (clippable && layer.bounds === null) {
|
|
366
|
+
this.release(layer.canvas);
|
|
367
|
+
break;
|
|
368
|
+
}
|
|
369
|
+
parent.save();
|
|
370
|
+
parent.resetTransform();
|
|
371
|
+
if (clippable && layer.bounds) {
|
|
372
|
+
const x0 = Math.max(0, Math.floor(layer.bounds.minX - outset));
|
|
373
|
+
const y0 = Math.max(0, Math.floor(layer.bounds.minY - outset));
|
|
374
|
+
const x1 = Math.min(w, Math.ceil(layer.bounds.maxX + outset));
|
|
375
|
+
const y1 = Math.min(h, Math.ceil(layer.bounds.maxY + outset));
|
|
376
|
+
if (x0 >= x1 || y0 >= y1) {
|
|
377
|
+
parent.restore();
|
|
378
|
+
this.release(layer.canvas);
|
|
379
|
+
break;
|
|
380
|
+
}
|
|
381
|
+
const clip = this.host.newPath();
|
|
382
|
+
clip.moveTo(x0, y0);
|
|
383
|
+
clip.lineTo(x1, y0);
|
|
384
|
+
clip.lineTo(x1, y1);
|
|
385
|
+
clip.lineTo(x0, y1);
|
|
386
|
+
clip.closePath();
|
|
387
|
+
parent.clip(clip, "nonzero");
|
|
388
|
+
}
|
|
389
|
+
parent.globalAlpha = layer.opacity;
|
|
390
|
+
if (hasFilter) parent.filter = layer.filter;
|
|
391
|
+
parent.globalCompositeOperation = layer.blend;
|
|
392
|
+
parent.drawImage(drawable, 0, 0);
|
|
393
|
+
parent.restore();
|
|
394
|
+
this.release(layer.canvas);
|
|
395
|
+
break;
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
if (layers.length !== 1) throw new Error("unbalanced pushGroup/popGroup in DisplayList");
|
|
399
|
+
}
|
|
400
|
+
};
|
|
401
|
+
//#endregion
|
|
18
402
|
//#region src/scene.ts
|
|
19
403
|
/**
|
|
20
404
|
* Scene assembly + the canonical evaluate(scene, timeline, t) (DESIGN.md §2.5,
|
|
@@ -27,12 +411,13 @@ var DuplicateNodeIdError = class extends Error {
|
|
|
27
411
|
this.name = "DuplicateNodeIdError";
|
|
28
412
|
}
|
|
29
413
|
};
|
|
30
|
-
function indexNodes(root, into) {
|
|
414
|
+
function indexNodes(root, into, measurerSource) {
|
|
415
|
+
root.measurerSource = measurerSource;
|
|
31
416
|
if (root.id !== void 0) {
|
|
32
417
|
if (into.has(root.id)) throw new DuplicateNodeIdError(root.id);
|
|
33
418
|
into.set(root.id, root);
|
|
34
419
|
}
|
|
35
|
-
if (root instanceof Group) for (const child of root.children) indexNodes(child, into);
|
|
420
|
+
if (root instanceof Group) for (const child of root.children) indexNodes(child, into, measurerSource);
|
|
36
421
|
}
|
|
37
422
|
function createScene(init) {
|
|
38
423
|
const root = new Group({
|
|
@@ -40,9 +425,9 @@ function createScene(init) {
|
|
|
40
425
|
children: init.children
|
|
41
426
|
});
|
|
42
427
|
const nodes = /* @__PURE__ */ new Map();
|
|
43
|
-
indexNodes(root, nodes);
|
|
44
428
|
const playhead = createPlayhead();
|
|
45
429
|
let measurer = estimatingMeasurer;
|
|
430
|
+
indexNodes(root, nodes, () => measurer);
|
|
46
431
|
return {
|
|
47
432
|
root,
|
|
48
433
|
nodes,
|
|
@@ -99,4 +484,4 @@ function evaluate(scene, doc, t) {
|
|
|
99
484
|
});
|
|
100
485
|
}
|
|
101
486
|
//#endregion
|
|
102
|
-
export { Circle, ColdAssetError, DuplicateNodeIdError, Group, IDENTITY, ImageNode, LayoutEngineMissingError, Node, Rect, Text, Video, applyToPoint, bindScene, breakLines, createDisplayListBuilder, createScene, estimatingMeasurer, evaluate, fromTRS, getLayoutEngine, invert, matEquals, multiply, quantize, requireLayoutEngine, setLayoutEngine };
|
|
487
|
+
export { Circle, ColdAssetError, DuplicateNodeIdError, FilterValidationError, Group, IDENTITY, ImageNode, LayoutEngineMissingError, Node, Path, Raster2D, Rect, ShaderEffect, Text, Video, applyToPoint, bindScene, breakLines, createDisplayListBuilder, createScene, estimatingMeasurer, evaluate, filtersToCanvasFilter, fontString, fromTRS, getLayoutEngine, glow, invert, matEquals, multiply, quantize, requireLayoutEngine, setLayoutEngine, validateFilters };
|
package/dist/layout.d.ts
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
|
-
import { C as NodeProps,
|
|
1
|
+
import { C as EvalContext, D as PropInit, E as NodeProps, F as DisplayListBuilder, O as TextMeasurer, T as Node, a as LayoutEngineMissingError, d as Group, i as LayoutEngine, l as setLayoutEngine, n as LayoutChildSpec, o as LayoutResult, r as LayoutContainerSpec, s as getLayoutEngine, t as LayoutBox } from "./layoutEngine.js";
|
|
2
2
|
import { BindableSignal } from "@glissade/core";
|
|
3
3
|
|
|
4
4
|
//#region src/layout.d.ts
|
|
5
5
|
|
|
6
6
|
interface LayoutProps extends NodeProps {
|
|
7
7
|
children?: Node[];
|
|
8
|
-
|
|
9
|
-
|
|
8
|
+
/** 'auto': the axis sizes itself from content (Yoga content sizing). */
|
|
9
|
+
width?: PropInit<number> | 'auto';
|
|
10
|
+
height?: PropInit<number> | 'auto';
|
|
10
11
|
direction?: 'row' | 'column';
|
|
11
12
|
gap?: PropInit<number>;
|
|
12
13
|
padding?: PropInit<number>;
|
|
@@ -33,8 +34,21 @@ declare class Layout extends Group {
|
|
|
33
34
|
readonly direction: 'row' | 'column';
|
|
34
35
|
readonly justify: LayoutContainerSpec['justify'];
|
|
35
36
|
readonly align: LayoutContainerSpec['align'];
|
|
37
|
+
/** Content-sized axes ('auto'): the size signal is ignored, Yoga computes it. */
|
|
38
|
+
readonly autoWidth: boolean;
|
|
39
|
+
readonly autoHeight: boolean;
|
|
36
40
|
constructor(props?: LayoutProps);
|
|
37
|
-
intrinsicSize(): {
|
|
41
|
+
intrinsicSize(measurer: TextMeasurer): {
|
|
42
|
+
w: number;
|
|
43
|
+
h: number;
|
|
44
|
+
};
|
|
45
|
+
/**
|
|
46
|
+
* The resolved container size — content-driven on 'auto' axes. Pure pull:
|
|
47
|
+
* reads the same signals the flow reads, so a sibling bound to it
|
|
48
|
+
* (e.g. panelBg height = () => panel.computedSize().h) tracks every input.
|
|
49
|
+
* The measurer defaults to the scene-injected one (estimating pre-scene).
|
|
50
|
+
*/
|
|
51
|
+
computedSize(measurer?: TextMeasurer): {
|
|
38
52
|
w: number;
|
|
39
53
|
h: number;
|
|
40
54
|
};
|
|
@@ -43,4 +57,4 @@ declare class Layout extends Group {
|
|
|
43
57
|
/** Load Yoga (wasm) and register it as the active LayoutEngine. Idempotent. */
|
|
44
58
|
declare function loadYogaLayoutEngine(): Promise<LayoutEngine>;
|
|
45
59
|
//#endregion
|
|
46
|
-
export { Layout, type LayoutBox, type LayoutChildSpec, type LayoutContainerSpec, type LayoutEngine, LayoutEngineMissingError, LayoutProps, getLayoutEngine, loadYogaLayoutEngine, setLayoutEngine };
|
|
60
|
+
export { Layout, type LayoutBox, type LayoutChildSpec, type LayoutContainerSpec, type LayoutEngine, LayoutEngineMissingError, LayoutProps, type LayoutResult, getLayoutEngine, loadYogaLayoutEngine, setLayoutEngine };
|
package/dist/layout.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { i as setLayoutEngine, n as getLayoutEngine, o as Group, r as requireLayoutEngine, t as LayoutEngineMissingError } from "./layoutEngine.js";
|
|
1
|
+
import { i as setLayoutEngine, m as estimatingMeasurer, n as getLayoutEngine, o as Group, r as requireLayoutEngine, t as LayoutEngineMissingError } from "./layoutEngine.js";
|
|
2
2
|
import { signal } from "@glissade/core";
|
|
3
3
|
//#region src/layout.ts
|
|
4
4
|
/**
|
|
@@ -29,12 +29,21 @@ var Layout = class extends Group {
|
|
|
29
29
|
direction;
|
|
30
30
|
justify;
|
|
31
31
|
align;
|
|
32
|
+
/** Content-sized axes ('auto'): the size signal is ignored, Yoga computes it. */
|
|
33
|
+
autoWidth;
|
|
34
|
+
autoHeight;
|
|
32
35
|
#memoKey = "";
|
|
33
|
-
#
|
|
36
|
+
#memoResult = {
|
|
37
|
+
width: 0,
|
|
38
|
+
height: 0,
|
|
39
|
+
boxes: []
|
|
40
|
+
};
|
|
34
41
|
constructor(props = {}) {
|
|
35
42
|
super(props);
|
|
36
|
-
this.
|
|
37
|
-
this.
|
|
43
|
+
this.autoWidth = props.width === "auto";
|
|
44
|
+
this.autoHeight = props.height === "auto";
|
|
45
|
+
this.width = initProp(signal(0), this.autoWidth ? void 0 : props.width);
|
|
46
|
+
this.height = initProp(signal(0), this.autoHeight ? void 0 : props.height);
|
|
38
47
|
this.gap = initProp(signal(0), props.gap);
|
|
39
48
|
this.padding = initProp(signal(0), props.padding);
|
|
40
49
|
this.direction = props.direction ?? "row";
|
|
@@ -45,16 +54,27 @@ var Layout = class extends Group {
|
|
|
45
54
|
this.registerTarget("gap", this.gap);
|
|
46
55
|
this.registerTarget("padding", this.padding);
|
|
47
56
|
}
|
|
48
|
-
intrinsicSize() {
|
|
49
|
-
return {
|
|
57
|
+
intrinsicSize(measurer) {
|
|
58
|
+
if (!this.autoWidth && !this.autoHeight) return {
|
|
50
59
|
w: this.width(),
|
|
51
60
|
h: this.height()
|
|
52
61
|
};
|
|
62
|
+
return this.#compute(measurer).size;
|
|
53
63
|
}
|
|
54
|
-
|
|
64
|
+
/**
|
|
65
|
+
* The resolved container size — content-driven on 'auto' axes. Pure pull:
|
|
66
|
+
* reads the same signals the flow reads, so a sibling bound to it
|
|
67
|
+
* (e.g. panelBg height = () => panel.computedSize().h) tracks every input.
|
|
68
|
+
* The measurer defaults to the scene-injected one (estimating pre-scene).
|
|
69
|
+
*/
|
|
70
|
+
computedSize(measurer) {
|
|
71
|
+
const m = measurer ?? this.measurerSource?.() ?? estimatingMeasurer;
|
|
72
|
+
return this.#compute(m).size;
|
|
73
|
+
}
|
|
74
|
+
#compute(measurer) {
|
|
55
75
|
const container = {
|
|
56
|
-
width: this.width(),
|
|
57
|
-
height: this.height(),
|
|
76
|
+
width: this.autoWidth ? "auto" : this.width(),
|
|
77
|
+
height: this.autoHeight ? "auto" : this.height(),
|
|
58
78
|
direction: this.direction,
|
|
59
79
|
gap: this.gap(),
|
|
60
80
|
padding: this.padding(),
|
|
@@ -64,7 +84,7 @@ var Layout = class extends Group {
|
|
|
64
84
|
const flowable = [];
|
|
65
85
|
const absolute = [];
|
|
66
86
|
this.children.forEach((child, index) => {
|
|
67
|
-
const size = child.intrinsicSize(
|
|
87
|
+
const size = child.intrinsicSize(measurer);
|
|
68
88
|
if (size) flowable.push({
|
|
69
89
|
node: child,
|
|
70
90
|
spec: {
|
|
@@ -77,13 +97,26 @@ var Layout = class extends Group {
|
|
|
77
97
|
});
|
|
78
98
|
const key = JSON.stringify([container, flowable.map((f) => f.spec)]);
|
|
79
99
|
if (key !== this.#memoKey) {
|
|
80
|
-
this.#
|
|
100
|
+
this.#memoResult = requireLayoutEngine().compute(container, flowable.map((f) => f.spec));
|
|
81
101
|
this.#memoKey = key;
|
|
82
102
|
}
|
|
83
|
-
const
|
|
103
|
+
const size = {
|
|
104
|
+
w: this.autoWidth ? this.#memoResult.width : container.width,
|
|
105
|
+
h: this.autoHeight ? this.#memoResult.height : container.height
|
|
106
|
+
};
|
|
107
|
+
return {
|
|
108
|
+
result: this.#memoResult,
|
|
109
|
+
size,
|
|
110
|
+
flowable,
|
|
111
|
+
absolute
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
draw(out, ctx) {
|
|
115
|
+
const { result, size, flowable, absolute } = this.#compute(ctx.measurer);
|
|
116
|
+
const boxes = result.boxes;
|
|
84
117
|
const order = [...flowable].sort((a, b) => a.node.zIndex() - b.node.zIndex() || a.index - b.index);
|
|
85
|
-
const ox = -
|
|
86
|
-
const oy = -
|
|
118
|
+
const ox = -size.w / 2;
|
|
119
|
+
const oy = -size.h / 2;
|
|
87
120
|
for (const child of absolute) child.emit(out, ctx);
|
|
88
121
|
for (const entry of order) {
|
|
89
122
|
const box = boxes[flowable.indexOf(entry)];
|
|
@@ -130,8 +163,8 @@ async function loadYogaLayoutEngine() {
|
|
|
130
163
|
const engine = { compute(container, children) {
|
|
131
164
|
const root = yoga.Node.create();
|
|
132
165
|
try {
|
|
133
|
-
root.setWidth(container.width);
|
|
134
|
-
root.setHeight(container.height);
|
|
166
|
+
if (container.width !== "auto") root.setWidth(container.width);
|
|
167
|
+
if (container.height !== "auto") root.setHeight(container.height);
|
|
135
168
|
root.setFlexDirection(container.direction === "row" ? FlexDirection.Row : FlexDirection.Column);
|
|
136
169
|
root.setGap(Gutter.All, container.gap);
|
|
137
170
|
root.setPadding(Edge.All, container.padding);
|
|
@@ -145,18 +178,22 @@ async function loadYogaLayoutEngine() {
|
|
|
145
178
|
if (children[i].margin !== void 0) child.setMargin(Edge.All, children[i].margin);
|
|
146
179
|
root.insertChild(child, i);
|
|
147
180
|
}
|
|
148
|
-
root.calculateLayout(container.width, container.height, Direction.LTR);
|
|
149
|
-
const
|
|
181
|
+
root.calculateLayout(container.width === "auto" ? void 0 : container.width, container.height === "auto" ? void 0 : container.height, Direction.LTR);
|
|
182
|
+
const result = {
|
|
183
|
+
width: root.getComputedWidth(),
|
|
184
|
+
height: root.getComputedHeight(),
|
|
185
|
+
boxes: []
|
|
186
|
+
};
|
|
150
187
|
for (let i = 0; i < children.length; i++) {
|
|
151
188
|
const child = root.getChild(i);
|
|
152
|
-
boxes.push({
|
|
189
|
+
result.boxes.push({
|
|
153
190
|
x: child.getComputedLeft(),
|
|
154
191
|
y: child.getComputedTop(),
|
|
155
192
|
width: child.getComputedWidth(),
|
|
156
193
|
height: child.getComputedHeight()
|
|
157
194
|
});
|
|
158
195
|
}
|
|
159
|
-
return
|
|
196
|
+
return result;
|
|
160
197
|
} finally {
|
|
161
198
|
root.freeRecursive();
|
|
162
199
|
}
|
package/dist/layoutEngine.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { BindableSignal, ReadonlySignal, Vec2, Vec2Signal } from "@glissade/core";
|
|
1
|
+
import { BindableSignal, PathValue, ReadonlySignal, Vec2, Vec2Signal } from "@glissade/core";
|
|
2
2
|
|
|
3
3
|
//#region src/matrix.d.ts
|
|
4
4
|
|
|
@@ -52,17 +52,68 @@ interface FontSpec {
|
|
|
52
52
|
weight?: number;
|
|
53
53
|
style?: 'normal' | 'italic';
|
|
54
54
|
}
|
|
55
|
-
/**
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
55
|
+
/**
|
|
56
|
+
* Group filters (§3.4): a CLOSED union — validated data, never a CSS
|
|
57
|
+
* passthrough string — limited to effects both rasterizers implement
|
|
58
|
+
* faithfully. Cross-backend parity is perceptual (SSIM), not byte-exact:
|
|
59
|
+
* filters are where rasterizers diverge most. Per-backend output stays
|
|
60
|
+
* deterministic on the pinned toolchain (golden-tested on Skia).
|
|
61
|
+
*/
|
|
62
|
+
type FilterSpec = {
|
|
63
|
+
kind: 'blur'; /** Gaussian stdDeviation, px; ≥ 0. */
|
|
64
|
+
radius: number;
|
|
65
|
+
} | {
|
|
66
|
+
kind: 'drop-shadow';
|
|
67
|
+
dx: number;
|
|
68
|
+
dy: number; /** ≥ 0 */
|
|
69
|
+
blur: number;
|
|
70
|
+
color: string;
|
|
71
|
+
} | {
|
|
72
|
+
kind: 'brightness'; /** 1 = identity; ≥ 0. */
|
|
73
|
+
amount: number;
|
|
74
|
+
} | {
|
|
75
|
+
kind: 'contrast'; /** 1 = identity; ≥ 0. */
|
|
76
|
+
amount: number;
|
|
77
|
+
} | {
|
|
78
|
+
kind: 'saturate'; /** 1 = identity; ≥ 0. */
|
|
79
|
+
amount: number;
|
|
80
|
+
};
|
|
81
|
+
declare class FilterValidationError extends Error {
|
|
82
|
+
constructor(message: string);
|
|
59
83
|
}
|
|
84
|
+
/** Document-layer validation: reject unknown kinds and out-of-range params loudly. */
|
|
85
|
+
declare function validateFilters(filters: readonly FilterSpec[]): void;
|
|
86
|
+
/**
|
|
87
|
+
* Compile the validated union to the canvas 2D `ctx.filter` syntax — both
|
|
88
|
+
* backends speak it (browser canvas and @napi-rs/canvas/Skia). This is the
|
|
89
|
+
* ONLY place the CSS-like syntax appears; documents never carry it.
|
|
90
|
+
*/
|
|
91
|
+
declare function filtersToCanvasFilter(filters: readonly FilterSpec[]): string;
|
|
92
|
+
/**
|
|
93
|
+
* Outer glow as stacked zero-offset drop-shadows — the classic recipe, fully
|
|
94
|
+
* deterministic on both backends (it is just filters). intensity stacks more
|
|
95
|
+
* layers; pair with a signal binding to follow an animated fill.
|
|
96
|
+
*/
|
|
97
|
+
declare function glow(color: string, radius?: number, intensity?: number): FilterSpec[];
|
|
60
98
|
interface Rect$1 {
|
|
61
99
|
x: number;
|
|
62
100
|
y: number;
|
|
63
101
|
w: number;
|
|
64
102
|
h: number;
|
|
65
103
|
}
|
|
104
|
+
/**
|
|
105
|
+
* Shader effect pass (§3.7): runs over the group's rasterized texture.
|
|
106
|
+
* EXPLICITLY outside the determinism guarantee — GPU/driver per-pixel
|
|
107
|
+
* variance breaks distributed reproducibility; export with shaders is
|
|
108
|
+
* best-effort, single machine. Uniform VALUES are resolved at emit time
|
|
109
|
+
* (they ride on signals), so the IR stays a plain serializable snapshot.
|
|
110
|
+
*/
|
|
111
|
+
interface ShaderRef {
|
|
112
|
+
/** WGSL fragment module: declare `struct Uniforms` + `@fragment fn effect(@location(0) uv: vec2f) -> @location(0) vec4f`. */
|
|
113
|
+
wgsl: string;
|
|
114
|
+
/** Scalar uniforms, packed as f32 in SORTED KEY ORDER into the Uniforms struct. */
|
|
115
|
+
uniforms: Record<string, number>;
|
|
116
|
+
}
|
|
66
117
|
type DrawCommand = {
|
|
67
118
|
op: 'save';
|
|
68
119
|
} | {
|
|
@@ -102,6 +153,7 @@ type DrawCommand = {
|
|
|
102
153
|
opacity: number;
|
|
103
154
|
blend: BlendMode;
|
|
104
155
|
filters: FilterSpec[];
|
|
156
|
+
shader?: ShaderRef;
|
|
105
157
|
cacheKey?: string;
|
|
106
158
|
} | {
|
|
107
159
|
op: 'popGroup';
|
|
@@ -169,6 +221,8 @@ interface NodeProps {
|
|
|
169
221
|
opacity?: PropInit<number>;
|
|
170
222
|
blend?: PropInit<BlendMode>;
|
|
171
223
|
zIndex?: PropInit<number>;
|
|
224
|
+
/** Group filters (§3.4): the subtree composites as a unit through them. */
|
|
225
|
+
filters?: PropInit<FilterSpec[]>;
|
|
172
226
|
}
|
|
173
227
|
interface BindablePropTarget {
|
|
174
228
|
bindSource(fn: () => unknown): void;
|
|
@@ -203,6 +257,12 @@ declare abstract class Node {
|
|
|
203
257
|
interactiveChildren: boolean;
|
|
204
258
|
/** v2 §C.3: explicit hit-shape override in node-local coordinates. */
|
|
205
259
|
hitArea: HitArea | undefined;
|
|
260
|
+
/**
|
|
261
|
+
* Injected by createScene: the scene's CURRENT TextMeasurer (§3.2), so
|
|
262
|
+
* derived-size bindings (e.g. a background tracking Layout.computedSize)
|
|
263
|
+
* measure with the same rasterizer the flow uses.
|
|
264
|
+
*/
|
|
265
|
+
measurerSource: (() => TextMeasurer) | null;
|
|
206
266
|
readonly localMatrix: ReadonlySignal<Mat2x3>;
|
|
207
267
|
readonly worldMatrix: ReadonlySignal<Mat2x3>;
|
|
208
268
|
/** Track-target paths → bindable signals; subclasses register their own props. */
|
|
@@ -229,8 +289,10 @@ declare abstract class Node {
|
|
|
229
289
|
x: number;
|
|
230
290
|
y: number;
|
|
231
291
|
};
|
|
232
|
-
/** §3.5 predicate: composite-as-a-unit when opacity/blend demand it. */
|
|
292
|
+
/** §3.5 predicate: composite-as-a-unit when opacity/blend/filters demand it. */
|
|
233
293
|
protected requiresGroup(): boolean;
|
|
294
|
+
/** §3.7: a subtree-level shader pass; ShaderEffect overrides. */
|
|
295
|
+
protected groupShader(): ShaderRef | undefined;
|
|
234
296
|
emit(out: DisplayListBuilder, ctx: EvalContext): void;
|
|
235
297
|
}
|
|
236
298
|
//#endregion
|
|
@@ -283,6 +345,36 @@ declare class Circle extends Shape {
|
|
|
283
345
|
};
|
|
284
346
|
protected pathSegs(): PathSeg[];
|
|
285
347
|
}
|
|
348
|
+
interface PathProps extends ShapeProps {
|
|
349
|
+
/** The geometry (§2.2 'path' value): bezier contours in vertex form, animatable via a track on '<id>/d'. */
|
|
350
|
+
data?: PropInit<PathValue>;
|
|
351
|
+
}
|
|
352
|
+
/**
|
|
353
|
+
* Arbitrary bezier geometry — the Lottie-import landing spot and the target
|
|
354
|
+
* of native path morphs. Coordinates are node-local (the node origin is
|
|
355
|
+
* wherever the author put 0,0); flow placement uses the control-point bounds.
|
|
356
|
+
*/
|
|
357
|
+
declare class Path extends Shape {
|
|
358
|
+
readonly data: BindableSignal<PathValue>;
|
|
359
|
+
constructor(props?: PathProps);
|
|
360
|
+
/** Control-point bounding box (conservative: contains the true curve). */
|
|
361
|
+
bounds(): {
|
|
362
|
+
minX: number;
|
|
363
|
+
minY: number;
|
|
364
|
+
maxX: number;
|
|
365
|
+
maxY: number;
|
|
366
|
+
};
|
|
367
|
+
intrinsicSize(): {
|
|
368
|
+
w: number;
|
|
369
|
+
h: number;
|
|
370
|
+
};
|
|
371
|
+
/** Geometry is node-local, not center-anchored: offset to the box's actual top-left. */
|
|
372
|
+
flowOffset(): {
|
|
373
|
+
x: number;
|
|
374
|
+
y: number;
|
|
375
|
+
};
|
|
376
|
+
protected pathSegs(): PathSeg[];
|
|
377
|
+
}
|
|
286
378
|
interface ImageProps extends NodeProps {
|
|
287
379
|
/** Asset id from the Timeline manifest (§2.3). */
|
|
288
380
|
assetId: string;
|
|
@@ -394,17 +486,25 @@ interface LayoutChildSpec {
|
|
|
394
486
|
margin?: number;
|
|
395
487
|
}
|
|
396
488
|
interface LayoutContainerSpec {
|
|
397
|
-
|
|
398
|
-
|
|
489
|
+
/** 'auto': size the axis from content (Yoga computes it). */
|
|
490
|
+
width: number | 'auto';
|
|
491
|
+
height: number | 'auto';
|
|
399
492
|
direction: 'row' | 'column';
|
|
400
493
|
gap: number;
|
|
401
494
|
padding: number;
|
|
402
495
|
justify: 'start' | 'center' | 'end' | 'space-between' | 'space-around';
|
|
403
496
|
align: 'start' | 'center' | 'end' | 'stretch';
|
|
404
497
|
}
|
|
498
|
+
interface LayoutResult {
|
|
499
|
+
/** Resolved container size — equals the spec on fixed axes, content-driven on 'auto'. */
|
|
500
|
+
width: number;
|
|
501
|
+
height: number;
|
|
502
|
+
/** Child boxes (top-left relative to the container's top-left). */
|
|
503
|
+
boxes: LayoutBox[];
|
|
504
|
+
}
|
|
405
505
|
interface LayoutEngine {
|
|
406
|
-
/** Pure:
|
|
407
|
-
compute(container: LayoutContainerSpec, children: LayoutChildSpec[]):
|
|
506
|
+
/** Pure: resolved container size + child boxes. */
|
|
507
|
+
compute(container: LayoutContainerSpec, children: LayoutChildSpec[]): LayoutResult;
|
|
408
508
|
}
|
|
409
509
|
declare class LayoutEngineMissingError extends Error {
|
|
410
510
|
constructor();
|
|
@@ -413,4 +513,4 @@ declare function setLayoutEngine(e: LayoutEngine): void;
|
|
|
413
513
|
declare function getLayoutEngine(): LayoutEngine | null;
|
|
414
514
|
declare function requireLayoutEngine(): LayoutEngine;
|
|
415
515
|
//#endregion
|
|
416
|
-
export {
|
|
516
|
+
export { applyToPoint as $, breakLines as A, Paint as B, EvalContext as C, PropInit as D, NodeProps as E, DisplayListBuilder as F, ShaderRef as G, Rect$1 as H, DrawCommand as I, filtersToCanvasFilter as J, StrokeStyle as K, FilterSpec as L, quantize as M, BlendMode as N, TextMeasurer as O, DisplayList as P, Mat2x3 as Q, FilterValidationError as R, BindablePropTarget as S, Node as T, Resource as U, PathSeg as V, ResourceId as W, validateFilters as X, glow as Y, IDENTITY as Z, ShapeProps as _, LayoutEngineMissingError as a, Video as b, requireLayoutEngine as c, Group as d, fromTRS as et, ImageNode as f, Rect as g, PathProps as h, LayoutEngine as i, estimatingMeasurer as j, TextMetricsLite as k, setLayoutEngine as l, Path as m, LayoutChildSpec as n, matEquals as nt, LayoutResult as o, ImageProps as p, createDisplayListBuilder as q, LayoutContainerSpec as r, multiply as rt, getLayoutEngine as s, LayoutBox as t, invert as tt, Circle as u, Text as v, HitArea as w, VideoProps as x, TextProps as y, FontSpec as z };
|
package/dist/layoutEngine.js
CHANGED
|
@@ -62,12 +62,73 @@ function matEquals(a, b) {
|
|
|
62
62
|
}
|
|
63
63
|
//#endregion
|
|
64
64
|
//#region src/displayList.ts
|
|
65
|
+
var FilterValidationError = class extends Error {
|
|
66
|
+
constructor(message) {
|
|
67
|
+
super(message);
|
|
68
|
+
this.name = "FilterValidationError";
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
const FILTER_KINDS = new Set([
|
|
72
|
+
"blur",
|
|
73
|
+
"drop-shadow",
|
|
74
|
+
"brightness",
|
|
75
|
+
"contrast",
|
|
76
|
+
"saturate"
|
|
77
|
+
]);
|
|
78
|
+
/** Document-layer validation: reject unknown kinds and out-of-range params loudly. */
|
|
79
|
+
function validateFilters(filters) {
|
|
80
|
+
for (const f of filters) {
|
|
81
|
+
if (!FILTER_KINDS.has(f.kind)) throw new FilterValidationError(`unknown filter kind '${String(f.kind)}' (have: ${[...FILTER_KINDS].join(", ")})`);
|
|
82
|
+
if (f.kind === "blur" && !(Number.isFinite(f.radius) && f.radius >= 0)) throw new FilterValidationError(`blur radius must be ≥ 0, got ${String(f.radius)}`);
|
|
83
|
+
if (f.kind === "drop-shadow") {
|
|
84
|
+
if (![
|
|
85
|
+
f.dx,
|
|
86
|
+
f.dy,
|
|
87
|
+
f.blur
|
|
88
|
+
].every(Number.isFinite) || f.blur < 0) throw new FilterValidationError("drop-shadow needs finite dx/dy and blur ≥ 0");
|
|
89
|
+
if (typeof f.color !== "string" || f.color.length === 0) throw new FilterValidationError("drop-shadow needs a color string");
|
|
90
|
+
}
|
|
91
|
+
if ((f.kind === "brightness" || f.kind === "contrast" || f.kind === "saturate") && !(Number.isFinite(f.amount) && f.amount >= 0)) throw new FilterValidationError(`${f.kind} amount must be ≥ 0, got ${String(f.amount)}`);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Compile the validated union to the canvas 2D `ctx.filter` syntax — both
|
|
96
|
+
* backends speak it (browser canvas and @napi-rs/canvas/Skia). This is the
|
|
97
|
+
* ONLY place the CSS-like syntax appears; documents never carry it.
|
|
98
|
+
*/
|
|
99
|
+
function filtersToCanvasFilter(filters) {
|
|
100
|
+
if (filters.length === 0) return "none";
|
|
101
|
+
return filters.map((f) => {
|
|
102
|
+
switch (f.kind) {
|
|
103
|
+
case "blur": return `blur(${f.radius}px)`;
|
|
104
|
+
case "drop-shadow": return `drop-shadow(${f.dx}px ${f.dy}px ${f.blur}px ${f.color})`;
|
|
105
|
+
default: return `${f.kind}(${f.amount})`;
|
|
106
|
+
}
|
|
107
|
+
}).join(" ");
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Outer glow as stacked zero-offset drop-shadows — the classic recipe, fully
|
|
111
|
+
* deterministic on both backends (it is just filters). intensity stacks more
|
|
112
|
+
* layers; pair with a signal binding to follow an animated fill.
|
|
113
|
+
*/
|
|
114
|
+
function glow(color, radius = 16, intensity = 2) {
|
|
115
|
+
const layers = [];
|
|
116
|
+
for (let i = 0; i < Math.max(1, intensity); i++) layers.push({
|
|
117
|
+
kind: "drop-shadow",
|
|
118
|
+
dx: 0,
|
|
119
|
+
dy: 0,
|
|
120
|
+
blur: radius * (1 + i * 1.5),
|
|
121
|
+
color
|
|
122
|
+
});
|
|
123
|
+
return layers;
|
|
124
|
+
}
|
|
65
125
|
function createDisplayListBuilder(size) {
|
|
66
126
|
const commands = [];
|
|
67
127
|
const resources = [];
|
|
68
128
|
const interned = /* @__PURE__ */ new Map();
|
|
69
129
|
return {
|
|
70
130
|
push: (cmd) => {
|
|
131
|
+
if (cmd.op === "pushGroup" && cmd.filters.length > 0) validateFilters(cmd.filters);
|
|
71
132
|
commands.push(cmd);
|
|
72
133
|
},
|
|
73
134
|
resource: (res) => {
|
|
@@ -173,6 +234,12 @@ var Node = class {
|
|
|
173
234
|
interactiveChildren = true;
|
|
174
235
|
/** v2 §C.3: explicit hit-shape override in node-local coordinates. */
|
|
175
236
|
hitArea;
|
|
237
|
+
/**
|
|
238
|
+
* Injected by createScene: the scene's CURRENT TextMeasurer (§3.2), so
|
|
239
|
+
* derived-size bindings (e.g. a background tracking Layout.computedSize)
|
|
240
|
+
* measure with the same rasterizer the flow uses.
|
|
241
|
+
*/
|
|
242
|
+
measurerSource = null;
|
|
176
243
|
localMatrix;
|
|
177
244
|
worldMatrix;
|
|
178
245
|
/** Track-target paths → bindable signals; subclasses register their own props. */
|
|
@@ -185,7 +252,7 @@ var Node = class {
|
|
|
185
252
|
this.opacity = initScalar(signal(1), props.opacity);
|
|
186
253
|
this.blend = initScalar(signal("source-over"), props.blend);
|
|
187
254
|
this.zIndex = initScalar(signal(0), props.zIndex);
|
|
188
|
-
this.filters = initScalar(signal([]),
|
|
255
|
+
this.filters = initScalar(signal([]), props.filters);
|
|
189
256
|
this.localMatrix = computed(() => fromTRS(this.position(), this.rotation(), this.scale()), { equals: matEquals });
|
|
190
257
|
this.worldMatrix = computed(() => this.parent ? multiply(this.parent.worldMatrix(), this.localMatrix()) : this.localMatrix(), { equals: matEquals });
|
|
191
258
|
this.registerTarget("position", this.position);
|
|
@@ -227,16 +294,19 @@ var Node = class {
|
|
|
227
294
|
y: -size.h / 2
|
|
228
295
|
};
|
|
229
296
|
}
|
|
230
|
-
/** §3.5 predicate: composite-as-a-unit when opacity/blend demand it. */
|
|
297
|
+
/** §3.5 predicate: composite-as-a-unit when opacity/blend/filters demand it. */
|
|
231
298
|
requiresGroup() {
|
|
232
|
-
return this.opacity() < 1 || this.blend() !== "source-over";
|
|
299
|
+
return this.opacity() < 1 || this.blend() !== "source-over" || this.filters().length > 0;
|
|
233
300
|
}
|
|
301
|
+
/** §3.7: a subtree-level shader pass; ShaderEffect overrides. */
|
|
302
|
+
groupShader() {}
|
|
234
303
|
emit(out, ctx) {
|
|
235
304
|
const opacity = this.opacity();
|
|
236
305
|
if (opacity <= 0) return;
|
|
237
306
|
const local = this.localMatrix();
|
|
238
307
|
const isIdentity = matEquals(local, IDENTITY);
|
|
239
|
-
const
|
|
308
|
+
const shader = this.groupShader();
|
|
309
|
+
const group = this.requiresGroup() || shader !== void 0;
|
|
240
310
|
out.push({ op: "save" });
|
|
241
311
|
if (!isIdentity) out.push({
|
|
242
312
|
op: "transform",
|
|
@@ -246,7 +316,8 @@ var Node = class {
|
|
|
246
316
|
op: "pushGroup",
|
|
247
317
|
opacity,
|
|
248
318
|
blend: this.blend(),
|
|
249
|
-
filters: this.filters()
|
|
319
|
+
filters: this.filters(),
|
|
320
|
+
...shader !== void 0 ? { shader } : {}
|
|
250
321
|
});
|
|
251
322
|
this.draw(out, ctx);
|
|
252
323
|
if (group) out.push({ op: "popGroup" });
|
|
@@ -473,6 +544,102 @@ var Circle = class extends Shape {
|
|
|
473
544
|
], ["Z"]];
|
|
474
545
|
}
|
|
475
546
|
};
|
|
547
|
+
/**
|
|
548
|
+
* Arbitrary bezier geometry — the Lottie-import landing spot and the target
|
|
549
|
+
* of native path morphs. Coordinates are node-local (the node origin is
|
|
550
|
+
* wherever the author put 0,0); flow placement uses the control-point bounds.
|
|
551
|
+
*/
|
|
552
|
+
var Path = class extends Shape {
|
|
553
|
+
data;
|
|
554
|
+
constructor(props = {}) {
|
|
555
|
+
super(props);
|
|
556
|
+
this.data = initProp(signal([]), props.data);
|
|
557
|
+
this.registerTarget("d", this.data);
|
|
558
|
+
}
|
|
559
|
+
/** Control-point bounding box (conservative: contains the true curve). */
|
|
560
|
+
bounds() {
|
|
561
|
+
let minX = Infinity;
|
|
562
|
+
let minY = Infinity;
|
|
563
|
+
let maxX = -Infinity;
|
|
564
|
+
let maxY = -Infinity;
|
|
565
|
+
for (const c of this.data()) for (let i = 0; i < c.v.length; i++) {
|
|
566
|
+
const vx = c.v[i][0];
|
|
567
|
+
const vy = c.v[i][1];
|
|
568
|
+
const candidates = [
|
|
569
|
+
[vx, vy],
|
|
570
|
+
[vx + c.in[i][0], vy + c.in[i][1]],
|
|
571
|
+
[vx + c.out[i][0], vy + c.out[i][1]]
|
|
572
|
+
];
|
|
573
|
+
for (const p of candidates) {
|
|
574
|
+
if (p[0] < minX) minX = p[0];
|
|
575
|
+
if (p[1] < minY) minY = p[1];
|
|
576
|
+
if (p[0] > maxX) maxX = p[0];
|
|
577
|
+
if (p[1] > maxY) maxY = p[1];
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
if (minX > maxX) return {
|
|
581
|
+
minX: 0,
|
|
582
|
+
minY: 0,
|
|
583
|
+
maxX: 0,
|
|
584
|
+
maxY: 0
|
|
585
|
+
};
|
|
586
|
+
return {
|
|
587
|
+
minX,
|
|
588
|
+
minY,
|
|
589
|
+
maxX,
|
|
590
|
+
maxY
|
|
591
|
+
};
|
|
592
|
+
}
|
|
593
|
+
intrinsicSize() {
|
|
594
|
+
const b = this.bounds();
|
|
595
|
+
return {
|
|
596
|
+
w: b.maxX - b.minX,
|
|
597
|
+
h: b.maxY - b.minY
|
|
598
|
+
};
|
|
599
|
+
}
|
|
600
|
+
/** Geometry is node-local, not center-anchored: offset to the box's actual top-left. */
|
|
601
|
+
flowOffset() {
|
|
602
|
+
const b = this.bounds();
|
|
603
|
+
return {
|
|
604
|
+
x: b.minX,
|
|
605
|
+
y: b.minY
|
|
606
|
+
};
|
|
607
|
+
}
|
|
608
|
+
pathSegs() {
|
|
609
|
+
const segs = [];
|
|
610
|
+
for (const c of this.data()) {
|
|
611
|
+
const n = c.v.length;
|
|
612
|
+
if (n === 0) continue;
|
|
613
|
+
segs.push([
|
|
614
|
+
"M",
|
|
615
|
+
c.v[0][0],
|
|
616
|
+
c.v[0][1]
|
|
617
|
+
]);
|
|
618
|
+
for (let i = 0; i < n - 1; i++) segs.push([
|
|
619
|
+
"C",
|
|
620
|
+
c.v[i][0] + c.out[i][0],
|
|
621
|
+
c.v[i][1] + c.out[i][1],
|
|
622
|
+
c.v[i + 1][0] + c.in[i + 1][0],
|
|
623
|
+
c.v[i + 1][1] + c.in[i + 1][1],
|
|
624
|
+
c.v[i + 1][0],
|
|
625
|
+
c.v[i + 1][1]
|
|
626
|
+
]);
|
|
627
|
+
if (c.closed && n > 1) {
|
|
628
|
+
segs.push([
|
|
629
|
+
"C",
|
|
630
|
+
c.v[n - 1][0] + c.out[n - 1][0],
|
|
631
|
+
c.v[n - 1][1] + c.out[n - 1][1],
|
|
632
|
+
c.v[0][0] + c.in[0][0],
|
|
633
|
+
c.v[0][1] + c.in[0][1],
|
|
634
|
+
c.v[0][0],
|
|
635
|
+
c.v[0][1]
|
|
636
|
+
]);
|
|
637
|
+
segs.push(["Z"]);
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
return segs;
|
|
641
|
+
}
|
|
642
|
+
};
|
|
476
643
|
var ImageNode = class extends Node {
|
|
477
644
|
assetId;
|
|
478
645
|
width;
|
|
@@ -676,4 +843,4 @@ function requireLayoutEngine() {
|
|
|
676
843
|
return engine;
|
|
677
844
|
}
|
|
678
845
|
//#endregion
|
|
679
|
-
export { applyToPoint as _, Circle as a,
|
|
846
|
+
export { fromTRS as C, multiply as E, applyToPoint as S, matEquals as T, createDisplayListBuilder as _, Circle as a, validateFilters as b, Path as c, Video as d, Node as f, FilterValidationError as g, quantize as h, setLayoutEngine as i, Rect as l, estimatingMeasurer as m, getLayoutEngine as n, Group as o, breakLines as p, requireLayoutEngine as r, ImageNode as s, LayoutEngineMissingError as t, Text as u, filtersToCanvasFilter as v, invert as w, IDENTITY as x, glow as y };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@glissade/scene",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.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
|
"type": "module",
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
],
|
|
21
21
|
"dependencies": {
|
|
22
22
|
"yoga-layout": "^3.2.1",
|
|
23
|
-
"@glissade/core": "0.
|
|
23
|
+
"@glissade/core": "0.4.0"
|
|
24
24
|
},
|
|
25
25
|
"repository": {
|
|
26
26
|
"type": "git",
|