@glissade/scene 0.9.1-pre.0 → 0.10.0-pre.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { $ as quantize, A as ResolvedSketch, B as validateSketch, C as VideoProps, Ct as applyToPoint, D as roundedRectSegs, Dt as multiply, E as revealSchedule, Et as matEquals, F as hachureLines, G as Node, H as BindablePropTarget, I as resolveSketch, J as resolveAnchor, K as NodeProps, L as roughen, M as SketchValidationError, N as arcLength, O as HachureSpec, P as flatten, Q as estimatingMeasurer, R as sketchStrokes, S as Video, St as Mat2x3, T as pathFromSegs, Tt as invert, U as EvalContext, V as AnchorSpec, W as HitArea, X as TextMetricsLite, Y as TextMeasurer, Z as breakLines, _ as Rect, _t as createDisplayListBuilder, a as LayoutEngineMissingError, at as DisplayListBuilder, b as Text, bt as validateFilters, c as requireLayoutEngine, ct as FilterValidationError, d as Group, dt as PathSeg, et as segmentGraphemes, f as ImageNode, ft as Rect$1, g as PathProps, gt as StrokeStyle, h as Path, ht as ShaderRef, i as LayoutEngine, it as DisplayList, j as SketchStyle, k as Polyline, l as setLayoutEngine, lt as FontSpec, m as LineBox, mt as ResourceId, n as LayoutChildSpec, nt as setDefaultMeasurer, ot as DrawCommand, p as ImageProps, pt as Resource, q as PropInit, r as LayoutContainerSpec, rt as BlendMode, s as getLayoutEngine, st as FilterSpec, t as LayoutBox, tt as segmentWords, u as Circle, ut as Paint, v as RevealMark, vt as filtersToCanvasFilter, w as WordBox, wt as fromTRS, x as TextProps, xt as IDENTITY, y as ShapeProps, yt as glow, z as validateHachure } from "./layoutEngine.js";
1
+ import { $ as estimatingMeasurer, A as ResolvedSketch, B as validateSketch, C as VideoProps, Ct as Mat2x3, D as roundedRectSegs, Dt as matEquals, E as revealSchedule, Et as invert, F as hachureLines, G as Node, H as BindablePropTarget, I as resolveSketch, J as resolveAnchor, K as NodeProps, L as roughen, M as SketchValidationError, N as arcLength, O as HachureSpec, Ot as multiply, P as flatten, Q as breakLines, R as sketchStrokes, S as Video, St as IDENTITY, T as pathFromSegs, Tt as fromTRS, U as EvalContext, V as AnchorSpec, W as HitArea, X as TextMeasurer, Y as MEASURE_QUANTUM_PX, Z as TextMetricsLite, _ as Rect, _t as StrokeStyle, a as LayoutEngineMissingError, at as DisplayList, b as Text, bt as glow, c as requireLayoutEngine, ct as FilterSpec, d as Group, dt as Paint, et as quantize, f as ImageNode, ft as PathSeg, g as PathProps, gt as ShaderRef, h as Path, ht as ResourceId, i as LayoutEngine, it as BlendMode, j as SketchStyle, k as Polyline, l as setLayoutEngine, lt as FilterValidationError, m as LineBox, mt as Resource, n as LayoutChildSpec, nt as segmentWords, ot as DisplayListBuilder, p as ImageProps, pt as Rect$1, q as PropInit, r as LayoutContainerSpec, rt as setDefaultMeasurer, s as getLayoutEngine, st as DrawCommand, t as LayoutBox, tt as segmentGraphemes, u as Circle, ut as FontSpec, v as RevealMark, vt as createDisplayListBuilder, w as WordBox, wt as applyToPoint, x as TextProps, xt as validateFilters, y as ShapeProps, yt as filtersToCanvasFilter, z as validateHachure } from "./layoutEngine.js";
2
2
  import { BindableSignal, BoundTimeline, CompiledTimeline, CoverageReport, EaseSpec, FontMode, FontUsage, PathValue, Playhead, Timeline, Track, Vec2 } from "@glissade/core";
3
3
 
4
4
  //#region src/highlight.d.ts
@@ -512,8 +512,22 @@ declare class Raster2D<TCanvas extends CanvasLike, TPath extends PathLike, TDraw
512
512
  private readonly images;
513
513
  private readonly videos;
514
514
  private warnedShaders;
515
+ /**
516
+ * §3.5 bitmap LRU: device-transform-qualified cacheKey → rasterized layer.
517
+ * A Map preserves insertion order, so the oldest key is `keys().next()` —
518
+ * touch-on-hit by delete+set keeps it a true LRU. Disabled (stays empty) when
519
+ * `cacheEnabled` is false, so cache-cold === cache-warm is testable directly.
520
+ */
521
+ private readonly rasterCache;
522
+ private readonly cacheEnabled;
515
523
  constructor(host: Raster2DHost<TCanvas, TPath, TDrawable>, /** caps.shaders (§3.7): what happens when a shader can't run here. */
516
- shaderCaps?: ShaderCaps);
524
+ shaderCaps?: ShaderCaps,
525
+ /**
526
+ * §3.5: opt-OUT switch for the bitmap LRU. Defaults on, but the env var
527
+ * RASTER_CACHE=0 force-disables it (the equality test renders both ways).
528
+ * A disabled cache is byte-identical — it just always takes the miss path.
529
+ */
530
+ cacheEnabled?: boolean);
517
531
  /** Register a decoded still (kind 'image' assets). */
518
532
  setImageAsset(assetId: string, image: TDrawable): void;
519
533
  /** Register a warmed-on-demand video source (kind 'video' assets, §3.8). */
@@ -525,8 +539,23 @@ declare class Raster2D<TCanvas extends CanvasLike, TPath extends PathLike, TDraw
525
539
  private buildPath;
526
540
  private acquire;
527
541
  private release;
542
+ /**
543
+ * §3.5 LRU insert with touch-on-hit + eviction-to-pool. Storing under a key
544
+ * that already holds a (different) canvas releases the old one first.
545
+ */
546
+ private cacheStore;
547
+ private cacheTouch;
548
+ /**
549
+ * §3.4/§3.5 composite of a finished group layer onto its parent — the EXACT
550
+ * same save/resetTransform/clip/globalAlpha/filter/blend/drawImage sequence
551
+ * for both the freshly-rasterized miss path and a cache-blit hit, so a HIT is
552
+ * byte-identical to a MISS. `bounds`/`unbounded` come from the layer (miss) or
553
+ * the cache entry (hit); the composite params (opacity/blend/filters) always
554
+ * come from the LIVE pushGroup command, never the cache.
555
+ */
556
+ private composite;
528
557
  /** The command walk — order and operations identical to the pre-extraction twins. */
529
558
  render(target: TCanvas, list: DisplayList): void;
530
559
  }
531
560
  //#endregion
532
- export { ALL_FILTER_KINDS, type AnchorSpec, type BackendCaps, type BindablePropTarget, type BlendMode, type CacheColdResult, type CanvasLike, Circle, ColdAssetError, type Ctx2DLike, DeterminismViolationError, type DisplayList, type DisplayListBuilder, type DrawCommand, type DrawOnEachOptions, type DrawOnOptions, DuplicateNodeIdError, type EditMark, type EvalContext, type FilterKind, type FilterSpec, FilterValidationError, FollowPath, type FollowPathProps, type FontByteLoader, type FontSpec, Group, type GuardMode, type HachureSpec, Highlight, type HighlightProps, type HitArea, IDENTITY, type ImageHandle, ImageNode, type ImageProps, type LayoutBox, type LayoutChildSpec, type LayoutContainerSpec, type LayoutEngine, LayoutEngineMissingError, type LineBox, type Mat2x3, Node, type NodeProps, type Paint, Path, type PathLike, type PathProps, type PathSampler, type PathSeg, type Polyline, type PropInit, Raster2D, type Raster2DHost, Rect, type Rect$1 as RectShape, type RenderBackend, ReservedNodeIdError, type ResolvedSketch, type Resource, type ResourceId, type RevealMark, type Scene, type SceneInit, type SceneModule, type ShaderCaps, ShaderEffect, type ShaderEffectProps, type ShaderRef, type ShapeProps, type SketchStyle, SketchValidationError, type StepMark, type StrokeStyle, Text, TextCursor, type TextCursorProps, type TextMeasurer, type TextMetricsLite, type TextProps, TokenHighlight, type TokenHighlightProps, TokenMatchError, type TokenRange, type TypeEdit, type TypewriterResult, type ValidateSceneFontsOptions, Video, type VideoFrameSource, type VideoProps, type WordBox, applyToPoint, arcLength, auditCacheCold, bindScene, breakLines, collectTextUsages, createDisplayListBuilder, createScene, drawOn, drawOnEach, estimatingMeasurer, evaluate, filtersToCanvasFilter, flatten, followPath, fontString, fromTRS, getLayoutEngine, glow, hachureLines, highlight, invert, matEquals, matchTokenRun, motionPath, multiply, pathFromSegs, pathLength, pointAtLength, quantize, requireLayoutEngine, resolveAnchor, resolveSketch, revealSchedule, roughen, roundedRectSegs, segmentGraphemes, segmentWords, setDefaultMeasurer, setLayoutEngine, sketchStrokes, textCursor, tokenHighlight, typewriter, validateFilters, validateHachure, validateSceneFonts, validateSketch, withDeterminismGuards };
561
+ export { ALL_FILTER_KINDS, type AnchorSpec, type BackendCaps, type BindablePropTarget, type BlendMode, type CacheColdResult, type CanvasLike, Circle, ColdAssetError, type Ctx2DLike, DeterminismViolationError, type DisplayList, type DisplayListBuilder, type DrawCommand, type DrawOnEachOptions, type DrawOnOptions, DuplicateNodeIdError, type EditMark, type EvalContext, type FilterKind, type FilterSpec, FilterValidationError, FollowPath, type FollowPathProps, type FontByteLoader, type FontSpec, Group, type GuardMode, type HachureSpec, Highlight, type HighlightProps, type HitArea, IDENTITY, type ImageHandle, ImageNode, type ImageProps, type LayoutBox, type LayoutChildSpec, type LayoutContainerSpec, type LayoutEngine, LayoutEngineMissingError, type LineBox, MEASURE_QUANTUM_PX, type Mat2x3, Node, type NodeProps, type Paint, Path, type PathLike, type PathProps, type PathSampler, type PathSeg, type Polyline, type PropInit, Raster2D, type Raster2DHost, Rect, type Rect$1 as RectShape, type RenderBackend, ReservedNodeIdError, type ResolvedSketch, type Resource, type ResourceId, type RevealMark, type Scene, type SceneInit, type SceneModule, type ShaderCaps, ShaderEffect, type ShaderEffectProps, type ShaderRef, type ShapeProps, type SketchStyle, SketchValidationError, type StepMark, type StrokeStyle, Text, TextCursor, type TextCursorProps, type TextMeasurer, type TextMetricsLite, type TextProps, TokenHighlight, type TokenHighlightProps, TokenMatchError, type TokenRange, type TypeEdit, type TypewriterResult, type ValidateSceneFontsOptions, Video, type VideoFrameSource, type VideoProps, type WordBox, applyToPoint, arcLength, auditCacheCold, bindScene, breakLines, collectTextUsages, createDisplayListBuilder, createScene, drawOn, drawOnEach, estimatingMeasurer, evaluate, filtersToCanvasFilter, flatten, followPath, fontString, fromTRS, getLayoutEngine, glow, hachureLines, highlight, invert, matEquals, matchTokenRun, motionPath, multiply, pathFromSegs, pathLength, pointAtLength, quantize, requireLayoutEngine, resolveAnchor, resolveSketch, revealSchedule, roughen, roundedRectSegs, segmentGraphemes, segmentWords, setDefaultMeasurer, setLayoutEngine, sketchStrokes, textCursor, tokenHighlight, typewriter, validateFilters, validateHachure, validateSceneFonts, validateSketch, withDeterminismGuards };
package/dist/index.js CHANGED
@@ -1,4 +1,4 @@
1
- import { A as segmentGraphemes, B as fromTRS, C as validateSketch, D as estimatingMeasurer, E as breakLines, F as filtersToCanvasFilter, H as matEquals, I as glow, L as validateFilters, M as setDefaultMeasurer, N as FilterValidationError, O as fallbackMeasurer, P as createDisplayListBuilder, R as IDENTITY, S as validateHachure, T as resolveAnchor, U as multiply, V as invert, _ as flatten, a as Circle, b as roughen, c as Path, d as Video, f as pathFromSegs, g as arcLength, h as SketchValidationError, i as setLayoutEngine, j as segmentWords, k as quantize, l as Rect, m as roundedRectSegs, n as getLayoutEngine, o as Group, p as revealSchedule, r as requireLayoutEngine, s as ImageNode, t as LayoutEngineMissingError, u as Text, v as hachureLines, w as Node, x as sketchStrokes, y as resolveSketch, z as applyToPoint } from "./layoutEngine.js";
1
+ import { A as quantize, B as applyToPoint, C as validateSketch, D as breakLines, E as MEASURE_QUANTUM_PX, F as createDisplayListBuilder, H as invert, I as filtersToCanvasFilter, L as glow, M as segmentWords, N as setDefaultMeasurer, O as estimatingMeasurer, P as FilterValidationError, R as validateFilters, S as validateHachure, T as resolveAnchor, U as matEquals, V as fromTRS, W as multiply, _ as flatten, a as Circle, b as roughen, c as Path, d as Video, f as pathFromSegs, g as arcLength, h as SketchValidationError, i as setLayoutEngine, j as segmentGraphemes, k as fallbackMeasurer, l as Rect, m as roundedRectSegs, n as getLayoutEngine, o as Group, p as revealSchedule, r as requireLayoutEngine, s as ImageNode, t as LayoutEngineMissingError, u as Text, v as hachureLines, w as Node, x as sketchStrokes, y as resolveSketch, z as IDENTITY } from "./layoutEngine.js";
2
2
  import { bindTimeline, buildFontRegistry, compileTimeline, createPlayhead, emitDevWarning, evaluateAt, key, parseCmap, signal, stagger, track, validateFonts, vec2Signal } from "@glissade/core";
3
3
  //#region src/highlight.ts
4
4
  /**
@@ -970,6 +970,21 @@ var ShaderEffect = class extends Group {
970
970
  function fontString(font) {
971
971
  return `${font.style === "italic" ? "italic " : ""}${font.weight !== void 0 && font.weight !== 400 ? `${font.weight} ` : ""}${font.size}px ${font.family}`;
972
972
  }
973
+ /** Hardcoded LRU cap (§3.5). Evicted canvases return to the raster pool. */
974
+ const RASTER_CACHE_CAP = 16;
975
+ /**
976
+ * Round a device-transform component into the cache key. The layer bakes the
977
+ * parent CTM into its pixels, so two frames that share a cacheKey but differ in
978
+ * device transform are NOT interchangeable — they must key separately. Rounding
979
+ * to 1e-4 collapses float jitter from matrix re-multiplication of an unchanged
980
+ * transform (so a genuinely static parent HITs) while keeping any visible move
981
+ * to a distinct key (so a stale-CTM bitmap can never blit). The float is taken
982
+ * verbatim into the string — no lossy truncation that could alias two CTMs.
983
+ */
984
+ function transformKey(m) {
985
+ const q = (v) => Object.is(v, -0) ? "0" : String(Math.round(v * 1e4) / 1e4);
986
+ return `${q(m[0])},${q(m[1])},${q(m[2])},${q(m[3])},${q(m[4])},${q(m[5])}`;
987
+ }
973
988
  /**
974
989
  * How far a filter chain can paint beyond its input (device px). Each stage
975
990
  * feeds the next, so outsets ADD. Gaussian reach: Skia truncates at 3σ and
@@ -1042,9 +1057,18 @@ var Raster2D = class {
1042
1057
  images = /* @__PURE__ */ new Map();
1043
1058
  videos = /* @__PURE__ */ new Map();
1044
1059
  warnedShaders = false;
1045
- constructor(host, shaderCaps = "warn") {
1060
+ /**
1061
+ * §3.5 bitmap LRU: device-transform-qualified cacheKey → rasterized layer.
1062
+ * A Map preserves insertion order, so the oldest key is `keys().next()` —
1063
+ * touch-on-hit by delete+set keeps it a true LRU. Disabled (stays empty) when
1064
+ * `cacheEnabled` is false, so cache-cold === cache-warm is testable directly.
1065
+ */
1066
+ rasterCache = /* @__PURE__ */ new Map();
1067
+ cacheEnabled;
1068
+ constructor(host, shaderCaps = "warn", cacheEnabled = (globalThis.process?.env?.["RASTER_CACHE"] ?? "1") !== "0") {
1046
1069
  this.host = host;
1047
1070
  this.shaderCaps = shaderCaps;
1071
+ this.cacheEnabled = cacheEnabled;
1048
1072
  }
1049
1073
  /** Register a decoded still (kind 'image' assets). */
1050
1074
  setImageAsset(assetId, image) {
@@ -1056,6 +1080,7 @@ var Raster2D = class {
1056
1080
  }
1057
1081
  dispose() {
1058
1082
  this.pool.length = 0;
1083
+ this.rasterCache.clear();
1059
1084
  }
1060
1085
  resolveDrawable(res, id) {
1061
1086
  if (res.kind === "image") {
@@ -1127,6 +1152,73 @@ var Raster2D = class {
1127
1152
  release(canvas) {
1128
1153
  if (this.pool.length < 8) this.pool.push(canvas);
1129
1154
  }
1155
+ /**
1156
+ * §3.5 LRU insert with touch-on-hit + eviction-to-pool. Storing under a key
1157
+ * that already holds a (different) canvas releases the old one first.
1158
+ */
1159
+ cacheStore(key, entry) {
1160
+ const prior = this.rasterCache.get(key);
1161
+ if (prior) {
1162
+ this.rasterCache.delete(key);
1163
+ if (prior.canvas !== entry.canvas) this.release(prior.canvas);
1164
+ }
1165
+ this.rasterCache.set(key, entry);
1166
+ while (this.rasterCache.size > RASTER_CACHE_CAP) {
1167
+ const oldest = this.rasterCache.keys().next().value;
1168
+ if (oldest === void 0) break;
1169
+ const evicted = this.rasterCache.get(oldest);
1170
+ this.rasterCache.delete(oldest);
1171
+ this.release(evicted.canvas);
1172
+ }
1173
+ }
1174
+ cacheTouch(key) {
1175
+ const hit = this.rasterCache.get(key);
1176
+ if (hit) {
1177
+ this.rasterCache.delete(key);
1178
+ this.rasterCache.set(key, hit);
1179
+ }
1180
+ return hit;
1181
+ }
1182
+ /**
1183
+ * §3.4/§3.5 composite of a finished group layer onto its parent — the EXACT
1184
+ * same save/resetTransform/clip/globalAlpha/filter/blend/drawImage sequence
1185
+ * for both the freshly-rasterized miss path and a cache-blit hit, so a HIT is
1186
+ * byte-identical to a MISS. `bounds`/`unbounded` come from the layer (miss) or
1187
+ * the cache entry (hit); the composite params (opacity/blend/filters) always
1188
+ * come from the LIVE pushGroup command, never the cache.
1189
+ */
1190
+ composite(parent, parentLayer, drawable, bounds, unbounded, shaderReplaced, opacity, blend, filter, filters, w, h) {
1191
+ const hasFilter = filter !== void 0 && filter !== "none";
1192
+ const outset = hasFilter ? filterOutset(filters) : 0;
1193
+ if (unbounded || blend !== "source-over") parentLayer.unbounded = true;
1194
+ else if (bounds) accumulateRect(parentLayer, IDENTITY, bounds.minX - outset, bounds.minY - outset, bounds.maxX + outset, bounds.maxY + outset);
1195
+ const clippable = hasFilter && !shaderReplaced && !unbounded && blend === "source-over";
1196
+ if (clippable && bounds === null) return;
1197
+ parent.save();
1198
+ parent.resetTransform();
1199
+ if (clippable && bounds) {
1200
+ const x0 = Math.max(0, Math.floor(bounds.minX - outset));
1201
+ const y0 = Math.max(0, Math.floor(bounds.minY - outset));
1202
+ const x1 = Math.min(w, Math.ceil(bounds.maxX + outset));
1203
+ const y1 = Math.min(h, Math.ceil(bounds.maxY + outset));
1204
+ if (x0 >= x1 || y0 >= y1) {
1205
+ parent.restore();
1206
+ return;
1207
+ }
1208
+ const clip = this.host.newPath();
1209
+ clip.moveTo(x0, y0);
1210
+ clip.lineTo(x1, y0);
1211
+ clip.lineTo(x1, y1);
1212
+ clip.lineTo(x0, y1);
1213
+ clip.closePath();
1214
+ parent.clip(clip, "nonzero");
1215
+ }
1216
+ parent.globalAlpha = opacity;
1217
+ if (hasFilter) parent.filter = filter;
1218
+ parent.globalCompositeOperation = blend;
1219
+ parent.drawImage(drawable, 0, 0);
1220
+ parent.restore();
1221
+ }
1130
1222
  /** The command walk — order and operations identical to the pre-extraction twins. */
1131
1223
  render(target, list) {
1132
1224
  const { w, h } = list.size;
@@ -1147,167 +1239,157 @@ var Raster2D = class {
1147
1239
  const top = () => layers[layers.length - 1];
1148
1240
  let mat = IDENTITY;
1149
1241
  const matStack = [];
1150
- for (const cmd of list.commands) switch (cmd.op) {
1151
- case "save":
1152
- matStack.push(mat);
1153
- ctxOf().save();
1154
- break;
1155
- case "restore":
1156
- mat = matStack.pop() ?? mat;
1157
- ctxOf().restore();
1158
- break;
1159
- case "transform":
1160
- mat = multiply(mat, cmd.m);
1161
- ctxOf().transform(cmd.m[0], cmd.m[1], cmd.m[2], cmd.m[3], cmd.m[4], cmd.m[5]);
1162
- break;
1163
- case "clip":
1164
- ctxOf().clip(this.path(list.resources, cmd.path), cmd.rule ?? "nonzero");
1165
- break;
1166
- case "fillPath": {
1167
- const ctx = ctxOf();
1168
- ctx.fillStyle = cmd.paint.color;
1169
- ctx.fill(this.path(list.resources, cmd.path));
1170
- const b = this.pathBounds(list.resources, cmd.path);
1171
- if (b) accumulateRect(top(), mat, b.minX, b.minY, b.maxX, b.maxY);
1172
- break;
1173
- }
1174
- case "strokePath": {
1175
- const ctx = ctxOf();
1176
- ctx.strokeStyle = cmd.paint.color;
1177
- ctx.lineWidth = cmd.stroke.width;
1178
- ctx.lineCap = cmd.stroke.cap ?? "butt";
1179
- ctx.lineJoin = cmd.stroke.join ?? "miter";
1180
- if (cmd.stroke.dash) {
1181
- ctx.setLineDash(cmd.stroke.dash);
1182
- ctx.lineDashOffset = cmd.stroke.dashOffset ?? 0;
1242
+ const commands = list.commands;
1243
+ for (let ci = 0; ci < commands.length; ci++) {
1244
+ const cmd = commands[ci];
1245
+ switch (cmd.op) {
1246
+ case "save":
1247
+ matStack.push(mat);
1248
+ ctxOf().save();
1249
+ break;
1250
+ case "restore":
1251
+ mat = matStack.pop() ?? mat;
1252
+ ctxOf().restore();
1253
+ break;
1254
+ case "transform":
1255
+ mat = multiply(mat, cmd.m);
1256
+ ctxOf().transform(cmd.m[0], cmd.m[1], cmd.m[2], cmd.m[3], cmd.m[4], cmd.m[5]);
1257
+ break;
1258
+ case "clip":
1259
+ ctxOf().clip(this.path(list.resources, cmd.path), cmd.rule ?? "nonzero");
1260
+ break;
1261
+ case "fillPath": {
1262
+ const ctx = ctxOf();
1263
+ ctx.fillStyle = cmd.paint.color;
1264
+ ctx.fill(this.path(list.resources, cmd.path));
1265
+ const b = this.pathBounds(list.resources, cmd.path);
1266
+ if (b) accumulateRect(top(), mat, b.minX, b.minY, b.maxX, b.maxY);
1267
+ break;
1183
1268
  }
1184
- ctx.stroke(this.path(list.resources, cmd.path));
1185
- if (cmd.stroke.dash) {
1186
- ctx.setLineDash([]);
1187
- ctx.lineDashOffset = 0;
1269
+ case "strokePath": {
1270
+ const ctx = ctxOf();
1271
+ ctx.strokeStyle = cmd.paint.color;
1272
+ ctx.lineWidth = cmd.stroke.width;
1273
+ ctx.lineCap = cmd.stroke.cap ?? "butt";
1274
+ ctx.lineJoin = cmd.stroke.join ?? "miter";
1275
+ if (cmd.stroke.dash) {
1276
+ ctx.setLineDash(cmd.stroke.dash);
1277
+ ctx.lineDashOffset = cmd.stroke.dashOffset ?? 0;
1278
+ }
1279
+ ctx.stroke(this.path(list.resources, cmd.path));
1280
+ if (cmd.stroke.dash) {
1281
+ ctx.setLineDash([]);
1282
+ ctx.lineDashOffset = 0;
1283
+ }
1284
+ const b = this.pathBounds(list.resources, cmd.path);
1285
+ if (b) {
1286
+ const o = cmd.stroke.width * ((cmd.stroke.join ?? "miter") === "miter" ? 5 : 1);
1287
+ accumulateRect(top(), mat, b.minX - o, b.minY - o, b.maxX + o, b.maxY + o);
1288
+ }
1289
+ break;
1188
1290
  }
1189
- const b = this.pathBounds(list.resources, cmd.path);
1190
- if (b) {
1191
- const o = cmd.stroke.width * ((cmd.stroke.join ?? "miter") === "miter" ? 5 : 1);
1192
- accumulateRect(top(), mat, b.minX - o, b.minY - o, b.maxX + o, b.maxY + o);
1291
+ case "fillText": {
1292
+ const ctx = ctxOf();
1293
+ ctx.font = fontString(cmd.font);
1294
+ ctx.fillStyle = cmd.paint.color;
1295
+ ctx.textBaseline = "alphabetic";
1296
+ ctx.textAlign = cmd.align ?? "left";
1297
+ ctx.fillText(cmd.text, cmd.x, cmd.y);
1298
+ try {
1299
+ const width = ctx.measureText(cmd.text).width;
1300
+ const align = cmd.align ?? "left";
1301
+ const x0 = align === "center" ? cmd.x - width / 2 : align === "right" ? cmd.x - width : cmd.x;
1302
+ const m = cmd.font.size;
1303
+ accumulateRect(top(), mat, x0 - m, cmd.y - 1.5 * m, x0 + width + m, cmd.y + .75 * m);
1304
+ } catch {
1305
+ top().unbounded = true;
1306
+ }
1307
+ break;
1193
1308
  }
1194
- break;
1195
- }
1196
- case "fillText": {
1197
- const ctx = ctxOf();
1198
- ctx.font = fontString(cmd.font);
1199
- ctx.fillStyle = cmd.paint.color;
1200
- ctx.textBaseline = "alphabetic";
1201
- ctx.textAlign = cmd.align ?? "left";
1202
- ctx.fillText(cmd.text, cmd.x, cmd.y);
1203
- try {
1204
- const width = ctx.measureText(cmd.text).width;
1205
- const align = cmd.align ?? "left";
1206
- const x0 = align === "center" ? cmd.x - width / 2 : align === "right" ? cmd.x - width : cmd.x;
1207
- const m = cmd.font.size;
1208
- accumulateRect(top(), mat, x0 - m, cmd.y - 1.5 * m, x0 + width + m, cmd.y + .75 * m);
1209
- } catch {
1210
- top().unbounded = true;
1309
+ case "drawImage": {
1310
+ const res = list.resources[cmd.image];
1311
+ if (!res) throw new Error(`drawImage references missing resource ${cmd.image}`);
1312
+ const drawable = this.resolveDrawable(res, cmd.image);
1313
+ const ctx = ctxOf();
1314
+ if (cmd.smoothing !== void 0) ctx.imageSmoothingEnabled = cmd.smoothing;
1315
+ const { x, y, w: dw, h: dh } = cmd.dst;
1316
+ if (cmd.src) ctx.drawImage(drawable, cmd.src.x, cmd.src.y, cmd.src.w, cmd.src.h, x, y, dw, dh);
1317
+ else ctx.drawImage(drawable, x, y, dw, dh);
1318
+ accumulateRect(top(), mat, x, y, x + dw, y + dh);
1319
+ break;
1211
1320
  }
1212
- break;
1213
- }
1214
- case "drawImage": {
1215
- const res = list.resources[cmd.image];
1216
- if (!res) throw new Error(`drawImage references missing resource ${cmd.image}`);
1217
- const drawable = this.resolveDrawable(res, cmd.image);
1218
- const ctx = ctxOf();
1219
- if (cmd.smoothing !== void 0) ctx.imageSmoothingEnabled = cmd.smoothing;
1220
- const { x, y, w: dw, h: dh } = cmd.dst;
1221
- if (cmd.src) ctx.drawImage(drawable, cmd.src.x, cmd.src.y, cmd.src.w, cmd.src.h, x, y, dw, dh);
1222
- else ctx.drawImage(drawable, x, y, dw, dh);
1223
- accumulateRect(top(), mat, x, y, x + dw, y + dh);
1224
- break;
1225
- }
1226
- case "pushGroup": {
1227
- const parent = ctxOf();
1228
- const layerCanvas = this.acquire(w, h);
1229
- const layerCtx = this.host.context(layerCanvas);
1230
- layerCtx.resetTransform();
1231
- layerCtx.clearRect(0, 0, w, h);
1232
- layerCtx.setTransform(parent.getTransform());
1233
- layers.push({
1234
- ctx: layerCtx,
1235
- canvas: layerCanvas,
1236
- opacity: cmd.opacity,
1237
- blend: cmd.blend,
1238
- filter: filtersToCanvasFilter(cmd.filters),
1239
- filters: cmd.filters,
1240
- ...cmd.shader !== void 0 ? { shader: cmd.shader } : {},
1241
- bounds: null,
1242
- unbounded: false
1243
- });
1244
- break;
1245
- }
1246
- case "popGroup": {
1247
- const layer = layers.pop();
1248
- if (!layer || layer.canvas === null) throw new Error("popGroup without matching pushGroup");
1249
- const parent = ctxOf();
1250
- let drawable = layer.canvas;
1251
- let shaderReplaced = false;
1252
- if (layer.shader !== void 0) {
1253
- const replaced = this.host.applyShader?.(layer.canvas, layer.shader, w, h) ?? null;
1254
- if (replaced !== null) {
1255
- drawable = replaced;
1256
- shaderReplaced = true;
1257
- layer.bounds = {
1258
- minX: 0,
1259
- minY: 0,
1260
- maxX: w,
1261
- maxY: h
1262
- };
1263
- layer.unbounded = false;
1264
- } 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");
1265
- else if (!this.warnedShaders) {
1266
- this.warnedShaders = true;
1267
- emitDevWarning("ShaderEffect pass skipped: no shader runner here (headless or webgpu-less browser) — subtree composites unfiltered (§3.7 caps.shaders)");
1321
+ case "pushGroup": {
1322
+ const parent = ctxOf();
1323
+ const lruKey = this.cacheEnabled && cmd.cacheKey !== void 0 && cmd.shader === void 0 ? `${cmd.cacheKey}@${transformKey(mat)}` : void 0;
1324
+ if (lruKey !== void 0) {
1325
+ const hit = this.cacheTouch(lruKey);
1326
+ if (hit) {
1327
+ this.composite(parent, top(), hit.canvas, hit.bounds, hit.unbounded, false, cmd.opacity, cmd.blend, filtersToCanvasFilter(cmd.filters), cmd.filters, w, h);
1328
+ let depth = 1;
1329
+ while (depth > 0 && ++ci < commands.length) {
1330
+ const c = commands[ci];
1331
+ if (c.op === "pushGroup") depth++;
1332
+ else if (c.op === "popGroup") depth--;
1333
+ }
1334
+ break;
1335
+ }
1268
1336
  }
1269
- }
1270
- const hasFilter = layer.filter !== void 0 && layer.filter !== "none";
1271
- const outset = hasFilter ? filterOutset(layer.filters) : 0;
1272
- const parentLayer = top();
1273
- if (layer.unbounded || layer.blend !== "source-over") parentLayer.unbounded = true;
1274
- else if (layer.bounds) accumulateRect(parentLayer, IDENTITY, layer.bounds.minX - outset, layer.bounds.minY - outset, layer.bounds.maxX + outset, layer.bounds.maxY + outset);
1275
- const clippable = hasFilter && !shaderReplaced && !layer.unbounded && layer.blend === "source-over";
1276
- if (clippable && layer.bounds === null) {
1277
- this.release(layer.canvas);
1337
+ const layerCanvas = this.acquire(w, h);
1338
+ const layerCtx = this.host.context(layerCanvas);
1339
+ layerCtx.resetTransform();
1340
+ layerCtx.clearRect(0, 0, w, h);
1341
+ layerCtx.setTransform(parent.getTransform());
1342
+ layers.push({
1343
+ ctx: layerCtx,
1344
+ canvas: layerCanvas,
1345
+ opacity: cmd.opacity,
1346
+ blend: cmd.blend,
1347
+ filter: filtersToCanvasFilter(cmd.filters),
1348
+ filters: cmd.filters,
1349
+ ...cmd.shader !== void 0 ? { shader: cmd.shader } : {},
1350
+ bounds: null,
1351
+ unbounded: false,
1352
+ ...lruKey !== void 0 ? { cacheStoreKey: lruKey } : {}
1353
+ });
1278
1354
  break;
1279
1355
  }
1280
- parent.save();
1281
- parent.resetTransform();
1282
- if (clippable && layer.bounds) {
1283
- const x0 = Math.max(0, Math.floor(layer.bounds.minX - outset));
1284
- const y0 = Math.max(0, Math.floor(layer.bounds.minY - outset));
1285
- const x1 = Math.min(w, Math.ceil(layer.bounds.maxX + outset));
1286
- const y1 = Math.min(h, Math.ceil(layer.bounds.maxY + outset));
1287
- if (x0 >= x1 || y0 >= y1) {
1288
- parent.restore();
1289
- this.release(layer.canvas);
1290
- break;
1356
+ case "popGroup": {
1357
+ const layer = layers.pop();
1358
+ if (!layer || layer.canvas === null) throw new Error("popGroup without matching pushGroup");
1359
+ const parent = ctxOf();
1360
+ let drawable = layer.canvas;
1361
+ let shaderReplaced = false;
1362
+ if (layer.shader !== void 0) {
1363
+ const replaced = this.host.applyShader?.(layer.canvas, layer.shader, w, h) ?? null;
1364
+ if (replaced !== null) {
1365
+ drawable = replaced;
1366
+ shaderReplaced = true;
1367
+ layer.bounds = {
1368
+ minX: 0,
1369
+ minY: 0,
1370
+ maxX: w,
1371
+ maxY: h
1372
+ };
1373
+ layer.unbounded = false;
1374
+ } 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");
1375
+ else if (!this.warnedShaders) {
1376
+ this.warnedShaders = true;
1377
+ emitDevWarning("ShaderEffect pass skipped: no shader runner here (headless or webgpu-less browser) — subtree composites unfiltered (§3.7 caps.shaders)");
1378
+ }
1291
1379
  }
1292
- const clip = this.host.newPath();
1293
- clip.moveTo(x0, y0);
1294
- clip.lineTo(x1, y0);
1295
- clip.lineTo(x1, y1);
1296
- clip.lineTo(x0, y1);
1297
- clip.closePath();
1298
- parent.clip(clip, "nonzero");
1380
+ this.composite(parent, top(), drawable, layer.bounds, layer.unbounded, shaderReplaced, layer.opacity, layer.blend, layer.filter, layer.filters, w, h);
1381
+ if (layer.cacheStoreKey !== void 0 && !shaderReplaced) this.cacheStore(layer.cacheStoreKey, {
1382
+ canvas: layer.canvas,
1383
+ bounds: layer.bounds,
1384
+ unbounded: layer.unbounded
1385
+ });
1386
+ else this.release(layer.canvas);
1387
+ break;
1299
1388
  }
1300
- parent.globalAlpha = layer.opacity;
1301
- if (hasFilter) parent.filter = layer.filter;
1302
- parent.globalCompositeOperation = layer.blend;
1303
- parent.drawImage(drawable, 0, 0);
1304
- parent.restore();
1305
- this.release(layer.canvas);
1306
- break;
1307
1389
  }
1308
1390
  }
1309
1391
  if (layers.length !== 1) throw new Error("unbalanced pushGroup/popGroup in DisplayList");
1310
1392
  }
1311
1393
  };
1312
1394
  //#endregion
1313
- export { ALL_FILTER_KINDS, Circle, ColdAssetError, DeterminismViolationError, DuplicateNodeIdError, FilterValidationError, FollowPath, Group, Highlight, IDENTITY, ImageNode, LayoutEngineMissingError, Node, Path, Raster2D, Rect, ReservedNodeIdError, ShaderEffect, SketchValidationError, Text, TextCursor, TokenHighlight, TokenMatchError, Video, applyToPoint, arcLength, auditCacheCold, bindScene, breakLines, collectTextUsages, createDisplayListBuilder, createScene, drawOn, drawOnEach, estimatingMeasurer, evaluate, filtersToCanvasFilter, flatten, followPath, fontString, fromTRS, getLayoutEngine, glow, hachureLines, highlight, invert, matEquals, matchTokenRun, motionPath, multiply, pathFromSegs, pathLength, pointAtLength, quantize, requireLayoutEngine, resolveAnchor, resolveSketch, revealSchedule, roughen, roundedRectSegs, segmentGraphemes, segmentWords, setDefaultMeasurer, setLayoutEngine, sketchStrokes, textCursor, tokenHighlight, typewriter, validateFilters, validateHachure, validateSceneFonts, validateSketch, withDeterminismGuards };
1395
+ export { ALL_FILTER_KINDS, Circle, ColdAssetError, DeterminismViolationError, DuplicateNodeIdError, FilterValidationError, FollowPath, Group, Highlight, IDENTITY, ImageNode, LayoutEngineMissingError, MEASURE_QUANTUM_PX, Node, Path, Raster2D, Rect, ReservedNodeIdError, ShaderEffect, SketchValidationError, Text, TextCursor, TokenHighlight, TokenMatchError, Video, applyToPoint, arcLength, auditCacheCold, bindScene, breakLines, collectTextUsages, createDisplayListBuilder, createScene, drawOn, drawOnEach, estimatingMeasurer, evaluate, filtersToCanvasFilter, flatten, followPath, fontString, fromTRS, getLayoutEngine, glow, hachureLines, highlight, invert, matEquals, matchTokenRun, motionPath, multiply, pathFromSegs, pathLength, pointAtLength, quantize, requireLayoutEngine, resolveAnchor, resolveSketch, revealSchedule, roughen, roundedRectSegs, segmentGraphemes, segmentWords, setDefaultMeasurer, setLayoutEngine, sketchStrokes, textCursor, tokenHighlight, typewriter, validateFilters, validateHachure, validateSceneFonts, validateSketch, withDeterminismGuards };
package/dist/layout.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { G as Node, K as NodeProps, U as EvalContext, Y as TextMeasurer, a as LayoutEngineMissingError, at as DisplayListBuilder, d as Group, i as LayoutEngine, l as setLayoutEngine, n as LayoutChildSpec, o as LayoutResult, q as PropInit, r as LayoutContainerSpec, s as getLayoutEngine, t as LayoutBox } from "./layoutEngine.js";
1
+ import { G as Node, K as NodeProps, U as EvalContext, X as TextMeasurer, a as LayoutEngineMissingError, d as Group, i as LayoutEngine, l as setLayoutEngine, n as LayoutChildSpec, o as LayoutResult, ot as DisplayListBuilder, q as PropInit, 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
package/dist/layout.js CHANGED
@@ -1,5 +1,5 @@
1
- import { O as fallbackMeasurer, i as setLayoutEngine, n as getLayoutEngine, o as Group, r as requireLayoutEngine, t as LayoutEngineMissingError } from "./layoutEngine.js";
2
- import { signal } from "@glissade/core";
1
+ import { i as setLayoutEngine, k as fallbackMeasurer, n as getLayoutEngine, o as Group, r as requireLayoutEngine, t as LayoutEngineMissingError } from "./layoutEngine.js";
2
+ import { computed, signal } from "@glissade/core";
3
3
  //#region src/layout.ts
4
4
  /**
5
5
  * '@glissade/scene/layout' (DESIGN.md §3.2): the flexbox Layout node and the
@@ -32,12 +32,16 @@ var Layout = class extends Group {
32
32
  /** Content-sized axes ('auto'): the size signal is ignored, Yoga computes it. */
33
33
  autoWidth;
34
34
  autoHeight;
35
- #memoKey = "";
36
- #memoResult = {
37
- width: 0,
38
- height: 0,
39
- boxes: []
40
- };
35
+ /**
36
+ * Sanctioned memoization (§2.1), core-`computed()`-backed: a pure function of
37
+ * the PARTICIPATING signals. The compute reads exactly the container props
38
+ * and child intrinsic-size signals it consumes, so the signal graph records
39
+ * those as deps and re-invokes Yoga only when one of THEM changes — a sibling
40
+ * mutating a non-participating signal does not invalidate the layout. Pulls
41
+ * the scene-injected measurer (the same one draw() uses via ctx.measurer);
42
+ * a caller-supplied non-default measurer bypasses this cache (see #compute).
43
+ */
44
+ #memo = computed(() => this.#computeUncached(this.measurerSource?.() ?? fallbackMeasurer()));
41
45
  constructor(props = {}) {
42
46
  super(props);
43
47
  this.autoWidth = props.width === "auto";
@@ -71,7 +75,17 @@ var Layout = class extends Group {
71
75
  const m = measurer ?? this.measurerSource?.() ?? fallbackMeasurer();
72
76
  return this.#compute(m).size;
73
77
  }
78
+ /**
79
+ * Route through the #memo (the dependency-tracked computed) when `measurer`
80
+ * is the default the memo itself pulls; otherwise compute fresh & UNCACHED —
81
+ * a caller-supplied non-default measurer must never read (or poison) a cache
82
+ * keyed on the scene-singleton measurer (the `computedSize(customMeasurer)`
83
+ * escape hatch).
84
+ */
74
85
  #compute(measurer) {
86
+ return measurer === (this.measurerSource?.() ?? fallbackMeasurer()) ? this.#memo() : this.#computeUncached(measurer);
87
+ }
88
+ #computeUncached(measurer) {
75
89
  const container = {
76
90
  width: this.autoWidth ? "auto" : this.width(),
77
91
  height: this.autoHeight ? "auto" : this.height(),
@@ -95,18 +109,13 @@ var Layout = class extends Group {
95
109
  });
96
110
  else absolute.push(child);
97
111
  });
98
- const key = JSON.stringify([container, flowable.map((f) => f.spec)]);
99
- if (key !== this.#memoKey) {
100
- this.#memoResult = requireLayoutEngine().compute(container, flowable.map((f) => f.spec));
101
- this.#memoKey = key;
102
- }
103
- const size = {
104
- w: this.autoWidth ? this.#memoResult.width : container.width,
105
- h: this.autoHeight ? this.#memoResult.height : container.height
106
- };
112
+ const result = requireLayoutEngine().compute(container, flowable.map((f) => f.spec));
107
113
  return {
108
- result: this.#memoResult,
109
- size,
114
+ result,
115
+ size: {
116
+ w: this.autoWidth ? result.width : container.width,
117
+ h: this.autoHeight ? result.height : container.height
118
+ },
110
119
  flowable,
111
120
  absolute
112
121
  };
@@ -171,6 +171,23 @@ interface DisplayList {
171
171
  interface DisplayListBuilder {
172
172
  push(cmd: DrawCommand): void;
173
173
  resource(res: Resource): ResourceId;
174
+ /**
175
+ * §3.5 cacheKey seam — OPTIONAL so non-cache emits and lightweight mock
176
+ * builders never need them. `createDisplayListBuilder` supplies all three;
177
+ * `Node.emit` calls them only for `cache:true` nodes when present.
178
+ */
179
+ /** Count of commands emitted so far — a markpoint for cacheKey ranges. */
180
+ mark?(): number;
181
+ /**
182
+ * A stable hash of the command slice [start, end) plus the FULL content of
183
+ * every resource those commands reference (not just ids — interned ids are a
184
+ * per-list detail). Pure function of the slice; identical slices at two times
185
+ * hash equal, so a static subtree caches. Opaque buffers collapse to a length
186
+ * marker (mirrors cacheColdAudit's serializer). Undefined for an empty slice.
187
+ */
188
+ cacheKey?(start: number, end: number): string | undefined;
189
+ /** Stamp a cacheKey onto the pushGroup already emitted at index `i`. */
190
+ patchCacheKey?(i: number, key: string): void;
174
191
  }
175
192
  declare function createDisplayListBuilder(size: {
176
193
  w: number;
@@ -188,7 +205,15 @@ interface TextMetricsLite {
188
205
  interface TextMeasurer {
189
206
  measureText(text: string, font: FontSpec): TextMetricsLite;
190
207
  }
191
- /** §3.6 measurement quantum. */
208
+ /**
209
+ * §3.6 measurement quantum (px). Scene-owned pre-measure quantizes every
210
+ * layout-feeding advance to this grid ONCE, then hands Yoga frozen integers —
211
+ * so sub-pixel measureText drift between Skia/HarfBuzz versions cannot move a
212
+ * whole layout. The single source of truth for the grid; `quantize` rounds to
213
+ * it. (Yoga's `setMeasureFunc` was considered and rejected — see DESIGN.md §3.6.)
214
+ */
215
+ declare const MEASURE_QUANTUM_PX = 0.5;
216
+ /** §3.6 measurement quantum — round to the MEASURE_QUANTUM_PX grid. */
192
217
  declare function quantize(v: number): number;
193
218
  /**
194
219
  * Process-wide fallback measurer for FACTORY-TIME measurement — component
@@ -254,6 +279,16 @@ interface NodeProps {
254
279
  filters?: PropInit<FilterSpec[]>;
255
280
  /** Placement point + transform pivot on the intrinsic box; default 'center'. */
256
281
  anchor?: AnchorSpec;
282
+ /**
283
+ * §3.5 cross-frame raster cache: FORCE this subtree into a group and stamp a
284
+ * cacheKey on its pushGroup, so a backend with the bitmap LRU re-blits an
285
+ * unchanged subtree under a moving parent instead of re-rasterizing it. A
286
+ * pure performance hint — semantics are byte-identical with the cache off
287
+ * (the cache key folds in the inherited device transform, so a stale CTM can
288
+ * never blit). OFF by default: a scene that never sets it emits ZERO extra
289
+ * groups and is byte-identical to before. Best for expensive STATIC subtrees.
290
+ */
291
+ cache?: boolean;
257
292
  }
258
293
  interface BindablePropTarget {
259
294
  bindSource(fn: () => unknown): void;
@@ -286,6 +321,8 @@ declare abstract class Node {
286
321
  readonly anchor: Vec2;
287
322
  /** True only when the author SET an anchor — unset keeps the legacy origin. */
288
323
  readonly hasAnchor: boolean;
324
+ /** §3.5: opt-in cross-frame raster cache. Forces a group + a stamped cacheKey. */
325
+ readonly cache: boolean;
289
326
  parent: Node | null;
290
327
  /** v2 §C.3: participates in hit testing; set implicitly by attaching a listener. */
291
328
  interactive: boolean;
@@ -787,4 +824,4 @@ declare function setLayoutEngine(e: LayoutEngine): void;
787
824
  declare function getLayoutEngine(): LayoutEngine | null;
788
825
  declare function requireLayoutEngine(): LayoutEngine;
789
826
  //#endregion
790
- export { quantize as $, ResolvedSketch as A, validateSketch as B, VideoProps as C, applyToPoint as Ct, roundedRectSegs as D, multiply as Dt, revealSchedule as E, matEquals as Et, hachureLines as F, Node as G, BindablePropTarget as H, resolveSketch as I, resolveAnchor as J, NodeProps as K, roughen as L, SketchValidationError as M, arcLength as N, HachureSpec as O, flatten as P, estimatingMeasurer as Q, sketchStrokes as R, Video as S, Mat2x3 as St, pathFromSegs as T, invert as Tt, EvalContext as U, AnchorSpec as V, HitArea as W, TextMetricsLite as X, TextMeasurer as Y, breakLines as Z, Rect as _, createDisplayListBuilder as _t, LayoutEngineMissingError as a, DisplayListBuilder as at, Text as b, validateFilters as bt, requireLayoutEngine as c, FilterValidationError as ct, Group as d, PathSeg as dt, segmentGraphemes as et, ImageNode as f, Rect$1 as ft, PathProps as g, StrokeStyle as gt, Path as h, ShaderRef as ht, LayoutEngine as i, DisplayList as it, SketchStyle as j, Polyline as k, setLayoutEngine as l, FontSpec as lt, LineBox as m, ResourceId as mt, LayoutChildSpec as n, setDefaultMeasurer as nt, LayoutResult as o, DrawCommand as ot, ImageProps as p, Resource as pt, PropInit as q, LayoutContainerSpec as r, BlendMode as rt, getLayoutEngine as s, FilterSpec as st, LayoutBox as t, segmentWords as tt, Circle as u, Paint as ut, RevealMark as v, filtersToCanvasFilter as vt, WordBox as w, fromTRS as wt, TextProps as x, IDENTITY as xt, ShapeProps as y, glow as yt, validateHachure as z };
827
+ export { estimatingMeasurer as $, ResolvedSketch as A, validateSketch as B, VideoProps as C, Mat2x3 as Ct, roundedRectSegs as D, matEquals as Dt, revealSchedule as E, invert as Et, hachureLines as F, Node as G, BindablePropTarget as H, resolveSketch as I, resolveAnchor as J, NodeProps as K, roughen as L, SketchValidationError as M, arcLength as N, HachureSpec as O, multiply as Ot, flatten as P, breakLines as Q, sketchStrokes as R, Video as S, IDENTITY as St, pathFromSegs as T, fromTRS as Tt, EvalContext as U, AnchorSpec as V, HitArea as W, TextMeasurer as X, MEASURE_QUANTUM_PX as Y, TextMetricsLite as Z, Rect as _, StrokeStyle as _t, LayoutEngineMissingError as a, DisplayList as at, Text as b, glow as bt, requireLayoutEngine as c, FilterSpec as ct, Group as d, Paint as dt, quantize as et, ImageNode as f, PathSeg as ft, PathProps as g, ShaderRef as gt, Path as h, ResourceId as ht, LayoutEngine as i, BlendMode as it, SketchStyle as j, Polyline as k, setLayoutEngine as l, FilterValidationError as lt, LineBox as m, Resource as mt, LayoutChildSpec as n, segmentWords as nt, LayoutResult as o, DisplayListBuilder as ot, ImageProps as p, Rect$1 as pt, PropInit as q, LayoutContainerSpec as r, setDefaultMeasurer as rt, getLayoutEngine as s, DrawCommand as st, LayoutBox as t, segmentGraphemes as tt, Circle as u, FontSpec as ut, RevealMark as v, createDisplayListBuilder as vt, WordBox as w, applyToPoint as wt, TextProps as x, validateFilters as xt, ShapeProps as y, filtersToCanvasFilter as yt, validateHachure as z };
@@ -122,6 +122,28 @@ function glow(color, radius = 16, intensity = 2) {
122
122
  });
123
123
  return layers;
124
124
  }
125
+ /**
126
+ * Resource ids that appear in a draw command — only path/image/video draws
127
+ * carry one. Keeps the cacheKey serializer in lockstep with DrawCommand.
128
+ */
129
+ function commandResourceIds(cmd) {
130
+ switch (cmd.op) {
131
+ case "clip":
132
+ case "fillPath":
133
+ case "strokePath": return [cmd.path];
134
+ case "drawImage": return [cmd.image];
135
+ default: return [];
136
+ }
137
+ }
138
+ /** FNV-1a over a string → an 8-hex-digit stable token (no crypto dep, ESM-safe). */
139
+ function fnv1a(s) {
140
+ let h = 2166136261;
141
+ for (let i = 0; i < s.length; i++) {
142
+ h ^= s.charCodeAt(i);
143
+ h = Math.imul(h, 16777619);
144
+ }
145
+ return (h >>> 0).toString(16).padStart(8, "0");
146
+ }
125
147
  function createDisplayListBuilder(size) {
126
148
  const commands = [];
127
149
  const resources = [];
@@ -140,6 +162,45 @@ function createDisplayListBuilder(size) {
140
162
  interned.set(k, id);
141
163
  return id;
142
164
  },
165
+ mark: () => commands.length,
166
+ cacheKey: (start, end) => {
167
+ if (end <= start) return void 0;
168
+ const localIds = /* @__PURE__ */ new Map();
169
+ const usedResources = [];
170
+ const remap = (id) => {
171
+ let local = localIds.get(id);
172
+ if (local === void 0) {
173
+ local = localIds.size;
174
+ localIds.set(id, local);
175
+ usedResources.push(resources[id]);
176
+ }
177
+ return local;
178
+ };
179
+ const sliceCmds = commands.slice(start, end).map((cmd) => {
180
+ if (commandResourceIds(cmd).length === 0) return cmd;
181
+ if (cmd.op === "drawImage") return {
182
+ ...cmd,
183
+ image: remap(cmd.image)
184
+ };
185
+ return {
186
+ ...cmd,
187
+ path: remap(cmd.path)
188
+ };
189
+ });
190
+ return fnv1a(JSON.stringify({
191
+ c: sliceCmds,
192
+ r: usedResources
193
+ }, (_k, value) => {
194
+ if (value instanceof ArrayBuffer) return `ab:${value.byteLength}`;
195
+ if (ArrayBuffer.isView(value)) return `view:${value.byteLength}`;
196
+ if (typeof value === "function") return void 0;
197
+ return value;
198
+ }));
199
+ },
200
+ patchCacheKey: (i, key) => {
201
+ const cmd = commands[i];
202
+ if (cmd && cmd.op === "pushGroup") cmd.cacheKey = key;
203
+ },
143
204
  finish: () => ({
144
205
  commands,
145
206
  resources,
@@ -149,9 +210,17 @@ function createDisplayListBuilder(size) {
149
210
  }
150
211
  //#endregion
151
212
  //#region src/text.ts
152
- /** §3.6 measurement quantum. */
213
+ /**
214
+ * §3.6 measurement quantum (px). Scene-owned pre-measure quantizes every
215
+ * layout-feeding advance to this grid ONCE, then hands Yoga frozen integers —
216
+ * so sub-pixel measureText drift between Skia/HarfBuzz versions cannot move a
217
+ * whole layout. The single source of truth for the grid; `quantize` rounds to
218
+ * it. (Yoga's `setMeasureFunc` was considered and rejected — see DESIGN.md §3.6.)
219
+ */
220
+ const MEASURE_QUANTUM_PX = .5;
221
+ /** §3.6 measurement quantum — round to the MEASURE_QUANTUM_PX grid. */
153
222
  function quantize(v) {
154
- return Math.round(v * 2) / 2;
223
+ return Math.round(v / MEASURE_QUANTUM_PX) * MEASURE_QUANTUM_PX;
155
224
  }
156
225
  /**
157
226
  * Estimating fallback measurer — used only when no backend has been injected
@@ -282,6 +351,8 @@ var Node = class {
282
351
  anchor;
283
352
  /** True only when the author SET an anchor — unset keeps the legacy origin. */
284
353
  hasAnchor;
354
+ /** §3.5: opt-in cross-frame raster cache. Forces a group + a stamped cacheKey. */
355
+ cache;
285
356
  parent = null;
286
357
  #warnedAnchor = false;
287
358
  /** v2 §C.3: participates in hit testing; set implicitly by attaching a listener. */
@@ -311,6 +382,7 @@ var Node = class {
311
382
  this.filters = initScalar(signal([]), props.filters);
312
383
  this.hasAnchor = props.anchor !== void 0;
313
384
  this.anchor = resolveAnchor(props.anchor ?? "center");
385
+ this.cache = props.cache === true;
314
386
  this.localMatrix = computed(() => {
315
387
  const trs = fromTRS(this.position(), this.rotation(), this.scale());
316
388
  const [sx, sy] = this.anchorShift();
@@ -418,21 +490,33 @@ var Node = class {
418
490
  const local = this.localMatrix();
419
491
  const isIdentity = matEquals(local, IDENTITY);
420
492
  const shader = this.groupShader();
421
- const group = this.requiresGroup() || shader !== void 0;
493
+ const group = this.requiresGroup() || shader !== void 0 || this.cache;
494
+ const wantsKey = this.cache && out.mark !== void 0;
422
495
  out.push({ op: "save" });
423
496
  if (!isIdentity) out.push({
424
497
  op: "transform",
425
498
  m: local
426
499
  });
427
- if (group) out.push({
428
- op: "pushGroup",
429
- opacity,
430
- blend: this.blend(),
431
- filters: this.filters(),
432
- ...shader !== void 0 ? { shader } : {}
433
- });
500
+ let pushIdx = -1;
501
+ if (group) {
502
+ if (wantsKey) pushIdx = out.mark();
503
+ out.push({
504
+ op: "pushGroup",
505
+ opacity,
506
+ blend: this.blend(),
507
+ filters: this.filters(),
508
+ ...shader !== void 0 ? { shader } : {}
509
+ });
510
+ }
511
+ const drawStart = wantsKey ? out.mark() : -1;
434
512
  this.draw(out, ctx);
435
- if (group) out.push({ op: "popGroup" });
513
+ if (group) {
514
+ if (wantsKey) {
515
+ const key = out.cacheKey(drawStart, out.mark());
516
+ if (key !== void 0) out.patchCacheKey(pushIdx, key);
517
+ }
518
+ out.push({ op: "popGroup" });
519
+ }
436
520
  out.push({ op: "restore" });
437
521
  }
438
522
  };
@@ -1740,4 +1824,4 @@ function requireLayoutEngine() {
1740
1824
  return engine;
1741
1825
  }
1742
1826
  //#endregion
1743
- export { segmentGraphemes as A, fromTRS as B, validateSketch as C, estimatingMeasurer as D, breakLines as E, filtersToCanvasFilter as F, matEquals as H, glow as I, validateFilters as L, setDefaultMeasurer as M, FilterValidationError as N, fallbackMeasurer as O, createDisplayListBuilder as P, IDENTITY as R, validateHachure as S, resolveAnchor as T, multiply as U, invert as V, flatten as _, Circle as a, roughen as b, Path as c, Video as d, pathFromSegs as f, arcLength as g, SketchValidationError as h, setLayoutEngine as i, segmentWords as j, quantize as k, Rect as l, roundedRectSegs as m, getLayoutEngine as n, Group as o, revealSchedule as p, requireLayoutEngine as r, ImageNode as s, LayoutEngineMissingError as t, Text as u, hachureLines as v, Node as w, sketchStrokes as x, resolveSketch as y, applyToPoint as z };
1827
+ export { quantize as A, applyToPoint as B, validateSketch as C, breakLines as D, MEASURE_QUANTUM_PX as E, createDisplayListBuilder as F, invert as H, filtersToCanvasFilter as I, glow as L, segmentWords as M, setDefaultMeasurer as N, estimatingMeasurer as O, FilterValidationError as P, validateFilters as R, validateHachure as S, resolveAnchor as T, matEquals as U, fromTRS as V, multiply as W, flatten as _, Circle as a, roughen as b, Path as c, Video as d, pathFromSegs as f, arcLength as g, SketchValidationError as h, setLayoutEngine as i, segmentGraphemes as j, fallbackMeasurer as k, Rect as l, roundedRectSegs as m, getLayoutEngine as n, Group as o, revealSchedule as p, requireLayoutEngine as r, ImageNode as s, LayoutEngineMissingError as t, Text as u, hachureLines as v, Node as w, sketchStrokes as x, resolveSketch as y, IDENTITY as z };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@glissade/scene",
3
- "version": "0.9.1-pre.0",
3
+ "version": "0.10.0-pre.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.9.1-pre.0"
23
+ "@glissade/core": "0.10.0-pre.0"
24
24
  },
25
25
  "repository": {
26
26
  "type": "git",