@glissade/scene 0.1.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.
@@ -0,0 +1,655 @@
1
+ import { TARGET_PATH, computed, signal, vec2Signal } from "@glissade/core";
2
+ //#region src/matrix.ts
3
+ const IDENTITY = [
4
+ 1,
5
+ 0,
6
+ 0,
7
+ 1,
8
+ 0,
9
+ 0
10
+ ];
11
+ const z = (v) => v === 0 ? 0 : v;
12
+ function multiply(m1, m2) {
13
+ const [a1, b1, c1, d1, e1, f1] = m1;
14
+ const [a2, b2, c2, d2, e2, f2] = m2;
15
+ return [
16
+ z(a1 * a2 + c1 * b2),
17
+ z(b1 * a2 + d1 * b2),
18
+ z(a1 * c2 + c1 * d2),
19
+ z(b1 * c2 + d1 * d2),
20
+ z(a1 * e2 + c1 * f2 + e1),
21
+ z(b1 * e2 + d1 * f2 + f1)
22
+ ];
23
+ }
24
+ /** Compose translate × rotate × scale (rotation in degrees). */
25
+ function fromTRS(position, rotationDeg, scale) {
26
+ const r = rotationDeg * Math.PI / 180;
27
+ const cos = Math.cos(r);
28
+ const sin = Math.sin(r);
29
+ const [sx, sy] = scale;
30
+ return [
31
+ z(cos * sx),
32
+ z(sin * sx),
33
+ z(-sin * sy),
34
+ z(cos * sy),
35
+ z(position[0]),
36
+ z(position[1])
37
+ ];
38
+ }
39
+ function applyToPoint(m, p) {
40
+ return [m[0] * p[0] + m[2] * p[1] + m[4], m[1] * p[0] + m[3] * p[1] + m[5]];
41
+ }
42
+ function matEquals(a, b) {
43
+ return a[0] === b[0] && a[1] === b[1] && a[2] === b[2] && a[3] === b[3] && a[4] === b[4] && a[5] === b[5];
44
+ }
45
+ //#endregion
46
+ //#region src/displayList.ts
47
+ function createDisplayListBuilder(size) {
48
+ const commands = [];
49
+ const resources = [];
50
+ const interned = /* @__PURE__ */ new Map();
51
+ return {
52
+ push: (cmd) => {
53
+ commands.push(cmd);
54
+ },
55
+ resource: (res) => {
56
+ const k = JSON.stringify(res);
57
+ const hit = interned.get(k);
58
+ if (hit !== void 0) return hit;
59
+ const id = resources.length;
60
+ resources.push(res);
61
+ interned.set(k, id);
62
+ return id;
63
+ },
64
+ finish: () => ({
65
+ commands,
66
+ resources,
67
+ size
68
+ })
69
+ };
70
+ }
71
+ //#endregion
72
+ //#region src/text.ts
73
+ /** §3.6 measurement quantum. */
74
+ function quantize(v) {
75
+ return Math.round(v * 2) / 2;
76
+ }
77
+ /**
78
+ * Estimating fallback measurer — used only when no backend has been injected
79
+ * (e.g. evaluating for IR-level tests). Deterministic but not metrically
80
+ * faithful; mount(), the CLI, and exporters always inject the real one.
81
+ */
82
+ const estimatingMeasurer = { measureText(text, font) {
83
+ return {
84
+ width: text.length * font.size * .52,
85
+ ascent: font.size * .8,
86
+ descent: font.size * .2
87
+ };
88
+ } };
89
+ let wordSegmenter;
90
+ function segmentWords(text) {
91
+ if (wordSegmenter === void 0) wordSegmenter = typeof Intl !== "undefined" && "Segmenter" in Intl ? new Intl.Segmenter(void 0, { granularity: "word" }) : null;
92
+ if (wordSegmenter) {
93
+ const raw = [...wordSegmenter.segment(text)].map((s) => s.segment);
94
+ const glued = [];
95
+ for (const seg of raw) if (glued.length > 0 && /^[^\p{L}\p{N}\s]+$/u.test(seg)) glued[glued.length - 1] += seg;
96
+ else glued.push(seg);
97
+ return glued;
98
+ }
99
+ return text.split(/(\s+)/).filter((w) => w.length > 0);
100
+ }
101
+ /**
102
+ * Greedy line breaking: explicit '\n' always breaks; otherwise word segments
103
+ * flow until maxWidth is exceeded (Intl.Segmenter boundaries, so CJK wraps
104
+ * without spaces). A segment wider than maxWidth gets its own line (no
105
+ * intra-word breaking in v1).
106
+ */
107
+ function breakLines(text, font, maxWidth, measurer) {
108
+ const paragraphs = text.split("\n");
109
+ if (maxWidth === void 0 || maxWidth <= 0) return paragraphs;
110
+ const lines = [];
111
+ for (const para of paragraphs) {
112
+ const words = segmentWords(para);
113
+ let line = "";
114
+ for (const word of words) {
115
+ const candidate = line + word;
116
+ if (line !== "" && quantize(measurer.measureText(candidate.trimEnd(), font).width) > maxWidth) {
117
+ lines.push(line.trimEnd());
118
+ line = word.trimStart() === "" ? "" : word;
119
+ } else line = candidate;
120
+ }
121
+ lines.push(line.trimEnd());
122
+ }
123
+ return lines;
124
+ }
125
+ //#endregion
126
+ //#region src/node.ts
127
+ /**
128
+ * Scene-graph node base (DESIGN.md §3.1): every animatable property is a
129
+ * signal; transforms are computed matrix signals; emit() is pure — it reads
130
+ * signals and ctx only, and produces IR commands, never canvas calls.
131
+ */
132
+ function initScalar(sig, init) {
133
+ if (typeof init === "function") sig.bindSource(init);
134
+ else if (init !== void 0) sig.set(init);
135
+ return sig;
136
+ }
137
+ function initVec2(sig, init) {
138
+ if (typeof init === "function") sig.bindSource(init);
139
+ else if (init !== void 0) sig.set(init);
140
+ return sig;
141
+ }
142
+ var Node = class {
143
+ id;
144
+ position;
145
+ rotation;
146
+ scale;
147
+ opacity;
148
+ blend;
149
+ zIndex;
150
+ filters;
151
+ parent = null;
152
+ localMatrix;
153
+ worldMatrix;
154
+ /** Track-target paths → bindable signals; subclasses register their own props. */
155
+ targets = /* @__PURE__ */ new Map();
156
+ constructor(props = {}) {
157
+ this.id = props.id;
158
+ this.position = initVec2(vec2Signal([0, 0]), props.position);
159
+ this.rotation = initScalar(signal(0), props.rotation);
160
+ this.scale = initVec2(vec2Signal([1, 1]), props.scale);
161
+ this.opacity = initScalar(signal(1), props.opacity);
162
+ this.blend = initScalar(signal("source-over"), props.blend);
163
+ this.zIndex = initScalar(signal(0), props.zIndex);
164
+ this.filters = initScalar(signal([]), void 0);
165
+ this.localMatrix = computed(() => fromTRS(this.position(), this.rotation(), this.scale()), { equals: matEquals });
166
+ this.worldMatrix = computed(() => this.parent ? multiply(this.parent.worldMatrix(), this.localMatrix()) : this.localMatrix(), { equals: matEquals });
167
+ this.registerTarget("position", this.position);
168
+ this.registerTarget("position.x", this.position.x);
169
+ this.registerTarget("position.y", this.position.y);
170
+ this.registerTarget("rotation", this.rotation);
171
+ this.registerTarget("scale", this.scale);
172
+ this.registerTarget("scale.x", this.scale.x);
173
+ this.registerTarget("scale.y", this.scale.y);
174
+ this.registerTarget("opacity", this.opacity);
175
+ this.registerTarget("zIndex", this.zIndex);
176
+ }
177
+ registerTarget(path, sig) {
178
+ this.targets.set(path, sig);
179
+ if (this.id !== void 0) sig[TARGET_PATH] = `${this.id}/${path}`;
180
+ }
181
+ resolveTarget(path) {
182
+ return this.targets.get(path);
183
+ }
184
+ /**
185
+ * Natural size for flex flow (§3.2); null = not flowable (a Layout parent
186
+ * emits such children absolutely, untouched).
187
+ */
188
+ intrinsicSize(measurer) {
189
+ return null;
190
+ }
191
+ /**
192
+ * Vector from the node origin to its intrinsic box's TOP-LEFT, so Layout
193
+ * can place any anchor correctly. Default: center-anchored (every shape).
194
+ * Text overrides — it draws from a left/center/right baseline origin.
195
+ */
196
+ flowOffset(measurer) {
197
+ const size = this.intrinsicSize(measurer) ?? {
198
+ w: 0,
199
+ h: 0
200
+ };
201
+ return {
202
+ x: -size.w / 2,
203
+ y: -size.h / 2
204
+ };
205
+ }
206
+ /** §3.5 predicate: composite-as-a-unit when opacity/blend demand it. */
207
+ requiresGroup() {
208
+ return this.opacity() < 1 || this.blend() !== "source-over";
209
+ }
210
+ emit(out, ctx) {
211
+ const opacity = this.opacity();
212
+ if (opacity <= 0) return;
213
+ const local = this.localMatrix();
214
+ const isIdentity = matEquals(local, IDENTITY);
215
+ const group = this.requiresGroup();
216
+ out.push({ op: "save" });
217
+ if (!isIdentity) out.push({
218
+ op: "transform",
219
+ m: local
220
+ });
221
+ if (group) out.push({
222
+ op: "pushGroup",
223
+ opacity,
224
+ blend: this.blend(),
225
+ filters: this.filters()
226
+ });
227
+ this.draw(out, ctx);
228
+ if (group) out.push({ op: "popGroup" });
229
+ out.push({ op: "restore" });
230
+ }
231
+ };
232
+ //#endregion
233
+ //#region src/nodes.ts
234
+ /**
235
+ * Built-in nodes for M1 (DESIGN.md §3.1): Group, Rect, Circle, Text.
236
+ * Path/Image/Video/Layout arrive with their milestones.
237
+ */
238
+ var Group = class extends Node {
239
+ children;
240
+ constructor(props = {}) {
241
+ super(props);
242
+ this.children = props.children ?? [];
243
+ for (const child of this.children) child.parent = this;
244
+ }
245
+ add(child) {
246
+ child.parent = this;
247
+ this.children.push(child);
248
+ return this;
249
+ }
250
+ draw(out, ctx) {
251
+ const sorted = this.children.map((node, i) => ({
252
+ node,
253
+ i
254
+ })).sort((a, b) => a.node.zIndex() - b.node.zIndex() || a.i - b.i).map((e) => e.node);
255
+ for (const child of sorted) child.emit(out, ctx);
256
+ }
257
+ };
258
+ var Shape = class extends Node {
259
+ fill;
260
+ stroke;
261
+ strokeWidth;
262
+ constructor(props = {}) {
263
+ super(props);
264
+ this.fill = initProp(signal(""), props.fill);
265
+ this.stroke = initProp(signal(""), props.stroke);
266
+ this.strokeWidth = initProp(signal(0), props.strokeWidth);
267
+ this.registerTarget("fill", this.fill);
268
+ this.registerTarget("stroke", this.stroke);
269
+ this.registerTarget("strokeWidth", this.strokeWidth);
270
+ }
271
+ draw(out) {
272
+ const segs = this.pathSegs();
273
+ const path = out.resource({
274
+ kind: "path",
275
+ segs
276
+ });
277
+ const fill = this.fill();
278
+ if (fill) out.push({
279
+ op: "fillPath",
280
+ path,
281
+ paint: {
282
+ kind: "color",
283
+ color: fill
284
+ }
285
+ });
286
+ const stroke = this.stroke();
287
+ const width = this.strokeWidth();
288
+ if (stroke && width > 0) out.push({
289
+ op: "strokePath",
290
+ path,
291
+ paint: {
292
+ kind: "color",
293
+ color: stroke
294
+ },
295
+ stroke: { width }
296
+ });
297
+ }
298
+ };
299
+ function initProp(sig, init) {
300
+ if (typeof init === "function") sig.bindSource(init);
301
+ else if (init !== void 0) sig.set(init);
302
+ return sig;
303
+ }
304
+ var Rect = class extends Shape {
305
+ width;
306
+ height;
307
+ /** Corner radius; clamped to half the smaller dimension. radius = h/2 makes a pill. */
308
+ cornerRadius;
309
+ constructor(props = {}) {
310
+ super(props);
311
+ this.width = initProp(signal(0), props.width);
312
+ this.height = initProp(signal(0), props.height);
313
+ this.cornerRadius = initProp(signal(0), props.cornerRadius);
314
+ this.registerTarget("width", this.width);
315
+ this.registerTarget("height", this.height);
316
+ this.registerTarget("cornerRadius", this.cornerRadius);
317
+ }
318
+ intrinsicSize() {
319
+ return {
320
+ w: this.width(),
321
+ h: this.height()
322
+ };
323
+ }
324
+ pathSegs() {
325
+ const w = this.width();
326
+ const h = this.height();
327
+ const x = -w / 2;
328
+ const y = -h / 2;
329
+ const r = Math.min(Math.max(0, this.cornerRadius()), w / 2, h / 2);
330
+ if (r <= 0) return [
331
+ [
332
+ "M",
333
+ x,
334
+ y
335
+ ],
336
+ [
337
+ "L",
338
+ x + w,
339
+ y
340
+ ],
341
+ [
342
+ "L",
343
+ x + w,
344
+ y + h
345
+ ],
346
+ [
347
+ "L",
348
+ x,
349
+ y + h
350
+ ],
351
+ ["Z"]
352
+ ];
353
+ const HALF = Math.PI / 2;
354
+ return [
355
+ [
356
+ "M",
357
+ x + r,
358
+ y
359
+ ],
360
+ [
361
+ "L",
362
+ x + w - r,
363
+ y
364
+ ],
365
+ [
366
+ "E",
367
+ x + w - r,
368
+ y + r,
369
+ r,
370
+ r,
371
+ 0,
372
+ -HALF,
373
+ 0
374
+ ],
375
+ [
376
+ "L",
377
+ x + w,
378
+ y + h - r
379
+ ],
380
+ [
381
+ "E",
382
+ x + w - r,
383
+ y + h - r,
384
+ r,
385
+ r,
386
+ 0,
387
+ 0,
388
+ HALF
389
+ ],
390
+ [
391
+ "L",
392
+ x + r,
393
+ y + h
394
+ ],
395
+ [
396
+ "E",
397
+ x + r,
398
+ y + h - r,
399
+ r,
400
+ r,
401
+ 0,
402
+ HALF,
403
+ Math.PI
404
+ ],
405
+ [
406
+ "L",
407
+ x,
408
+ y + r
409
+ ],
410
+ [
411
+ "E",
412
+ x + r,
413
+ y + r,
414
+ r,
415
+ r,
416
+ 0,
417
+ Math.PI,
418
+ Math.PI + HALF
419
+ ],
420
+ ["Z"]
421
+ ];
422
+ }
423
+ };
424
+ var Circle = class extends Shape {
425
+ radius;
426
+ constructor(props = {}) {
427
+ super(props);
428
+ this.radius = initProp(signal(0), props.radius);
429
+ this.registerTarget("radius", this.radius);
430
+ }
431
+ intrinsicSize() {
432
+ const d = this.radius() * 2;
433
+ return {
434
+ w: d,
435
+ h: d
436
+ };
437
+ }
438
+ pathSegs() {
439
+ const r = this.radius();
440
+ return [[
441
+ "E",
442
+ 0,
443
+ 0,
444
+ r,
445
+ r,
446
+ 0,
447
+ 0,
448
+ Math.PI * 2
449
+ ], ["Z"]];
450
+ }
451
+ };
452
+ var ImageNode = class extends Node {
453
+ assetId;
454
+ width;
455
+ height;
456
+ constructor(props) {
457
+ super(props);
458
+ this.assetId = props.assetId;
459
+ this.width = initProp(signal(0), props.width);
460
+ this.height = initProp(signal(0), props.height);
461
+ this.registerTarget("width", this.width);
462
+ this.registerTarget("height", this.height);
463
+ }
464
+ intrinsicSize() {
465
+ return {
466
+ w: this.width(),
467
+ h: this.height()
468
+ };
469
+ }
470
+ draw(out) {
471
+ const w = this.width();
472
+ const h = this.height();
473
+ if (w <= 0 || h <= 0) return;
474
+ const image = out.resource({
475
+ kind: "image",
476
+ assetId: this.assetId
477
+ });
478
+ out.push({
479
+ op: "drawImage",
480
+ image,
481
+ dst: {
482
+ x: -w / 2,
483
+ y: -h / 2,
484
+ w,
485
+ h
486
+ }
487
+ });
488
+ }
489
+ };
490
+ /**
491
+ * Pure given a warmed VideoFrameSource (§3.8): emit() does only the
492
+ * frame-indexed media-time arithmetic — mediaT = trimStart + (t - at) * rate —
493
+ * and references the exact source-grid frame; backends resolve it.
494
+ */
495
+ var Video = class extends Node {
496
+ assetId;
497
+ at;
498
+ trimStart;
499
+ playbackRate;
500
+ clipDuration;
501
+ sourceFps;
502
+ width;
503
+ height;
504
+ constructor(props) {
505
+ super(props);
506
+ this.assetId = props.assetId;
507
+ this.at = props.at ?? 0;
508
+ this.trimStart = props.trimStart ?? 0;
509
+ this.playbackRate = props.playbackRate ?? 1;
510
+ this.clipDuration = props.clipDuration;
511
+ this.sourceFps = props.sourceFps;
512
+ this.width = initProp(signal(0), props.width);
513
+ this.height = initProp(signal(0), props.height);
514
+ this.registerTarget("width", this.width);
515
+ this.registerTarget("height", this.height);
516
+ }
517
+ /** Frame-indexed media time for timeline time t; null when outside the clip. */
518
+ mediaTime(t) {
519
+ const local = (t - this.at) * this.playbackRate;
520
+ if (local < 0) return null;
521
+ if (this.clipDuration !== void 0 && t - this.at >= this.clipDuration) return null;
522
+ const mediaT = this.trimStart + local;
523
+ if (this.sourceFps !== void 0) return Math.floor(mediaT * this.sourceFps + 1e-9) / this.sourceFps;
524
+ return mediaT;
525
+ }
526
+ draw(out, ctx) {
527
+ const mediaT = this.mediaTime(ctx.time);
528
+ if (mediaT === null) return;
529
+ const w = this.width();
530
+ const h = this.height();
531
+ if (w <= 0 || h <= 0) return;
532
+ const image = out.resource({
533
+ kind: "videoFrame",
534
+ assetId: this.assetId,
535
+ mediaT
536
+ });
537
+ out.push({
538
+ op: "drawImage",
539
+ image,
540
+ dst: {
541
+ x: -w / 2,
542
+ y: -h / 2,
543
+ w,
544
+ h
545
+ }
546
+ });
547
+ }
548
+ };
549
+ var Text = class extends Node {
550
+ text;
551
+ fill;
552
+ fontSize;
553
+ fontFamily;
554
+ fontWeight;
555
+ align;
556
+ width;
557
+ lineHeight;
558
+ constructor(props = {}) {
559
+ super(props);
560
+ this.text = initProp(signal(""), props.text);
561
+ this.fill = initProp(signal("#000000"), props.fill);
562
+ this.fontSize = initProp(signal(16), props.fontSize);
563
+ this.fontFamily = props.fontFamily ?? "sans-serif";
564
+ this.fontWeight = props.fontWeight ?? 400;
565
+ this.align = props.align ?? "left";
566
+ this.width = initProp(signal(0), props.width);
567
+ this.lineHeight = props.lineHeight ?? 1.25;
568
+ this.registerTarget("width", this.width);
569
+ this.registerTarget("text", this.text);
570
+ this.registerTarget("fill", this.fill);
571
+ this.registerTarget("fontSize", this.fontSize);
572
+ }
573
+ intrinsicSize(measurer) {
574
+ const text = this.text();
575
+ if (!text) return {
576
+ w: 0,
577
+ h: 0
578
+ };
579
+ const font = {
580
+ family: this.fontFamily,
581
+ size: this.fontSize(),
582
+ weight: this.fontWeight
583
+ };
584
+ const maxWidth = this.width();
585
+ const lines = breakLines(text, font, maxWidth > 0 ? maxWidth : void 0, measurer);
586
+ const widest = Math.max(...lines.map((l) => quantize(measurer.measureText(l, font).width)), 0);
587
+ return {
588
+ w: maxWidth > 0 ? maxWidth : widest,
589
+ h: quantize(font.size * this.lineHeight) * lines.length
590
+ };
591
+ }
592
+ /** Text draws from a baseline origin at its align edge, not a center (§3.6). */
593
+ flowOffset(measurer) {
594
+ const size = this.intrinsicSize(measurer);
595
+ const font = {
596
+ family: this.fontFamily,
597
+ size: this.fontSize(),
598
+ weight: this.fontWeight
599
+ };
600
+ const firstLine = breakLines(this.text(), font, this.width() > 0 ? this.width() : void 0, measurer)[0] ?? "";
601
+ const ascent = measurer.measureText(firstLine, font).ascent;
602
+ return {
603
+ x: this.align === "left" ? 0 : this.align === "center" ? -size.w / 2 : -size.w,
604
+ y: -ascent
605
+ };
606
+ }
607
+ draw(out, ctx) {
608
+ const text = this.text();
609
+ if (!text) return;
610
+ const font = {
611
+ family: this.fontFamily,
612
+ size: this.fontSize(),
613
+ weight: this.fontWeight
614
+ };
615
+ const maxWidth = this.width();
616
+ const lines = breakLines(text, font, maxWidth > 0 ? maxWidth : void 0, ctx.measurer);
617
+ const step = quantize(font.size * this.lineHeight);
618
+ for (let i = 0; i < lines.length; i++) {
619
+ if (!lines[i]) continue;
620
+ out.push({
621
+ op: "fillText",
622
+ text: lines[i],
623
+ font,
624
+ paint: {
625
+ kind: "color",
626
+ color: this.fill()
627
+ },
628
+ x: 0,
629
+ y: i * step,
630
+ ...this.align !== "left" ? { align: this.align } : {}
631
+ });
632
+ }
633
+ }
634
+ };
635
+ //#endregion
636
+ //#region src/layoutEngine.ts
637
+ var LayoutEngineMissingError = class extends Error {
638
+ constructor() {
639
+ super("a Layout node was evaluated but no LayoutEngine is registered — await loadYogaLayoutEngine() from '@glissade/scene/layout' before mounting/rendering (the engine is wasm and loads async; evaluate() never awaits, §2.5)");
640
+ this.name = "LayoutEngineMissingError";
641
+ }
642
+ };
643
+ let engine = null;
644
+ function setLayoutEngine(e) {
645
+ engine = e;
646
+ }
647
+ function getLayoutEngine() {
648
+ return engine;
649
+ }
650
+ function requireLayoutEngine() {
651
+ if (!engine) throw new LayoutEngineMissingError();
652
+ return engine;
653
+ }
654
+ //#endregion
655
+ export { applyToPoint as _, Circle as a, multiply as b, Rect as c, Node as d, breakLines as f, IDENTITY as g, createDisplayListBuilder as h, setLayoutEngine as i, Text as l, quantize as m, getLayoutEngine as n, Group as o, estimatingMeasurer as p, requireLayoutEngine as r, ImageNode as s, LayoutEngineMissingError as t, Video as u, fromTRS as v, matEquals as y };
package/package.json ADDED
@@ -0,0 +1,34 @@
1
+ {
2
+ "name": "@glissade/scene",
3
+ "version": "0.1.0",
4
+ "description": "glissade scene graph: nodes, transforms, DisplayList emission. Renderer-agnostic; zero DOM/Node dependencies.",
5
+ "license": "Apache-2.0",
6
+ "type": "module",
7
+ "sideEffects": false,
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "default": "./dist/index.js"
12
+ },
13
+ "./layout": {
14
+ "types": "./dist/layout.d.ts",
15
+ "default": "./dist/layout.js"
16
+ }
17
+ },
18
+ "files": [
19
+ "dist"
20
+ ],
21
+ "dependencies": {
22
+ "yoga-layout": "^3.2.1",
23
+ "@glissade/core": "0.1.0"
24
+ },
25
+ "repository": {
26
+ "type": "git",
27
+ "url": "git+https://github.com/tyevco/glissade.git",
28
+ "directory": "packages/scene"
29
+ },
30
+ "scripts": {
31
+ "build": "tsdown",
32
+ "typecheck": "tsc --noEmit"
33
+ }
34
+ }