@glissade/scene 0.59.0-pre.1 → 0.60.0-pre.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/describe.js +1 -1
- package/dist/diagnostics.d.ts +55 -3
- package/dist/diagnostics.js +440 -4
- package/package.json +2 -2
package/dist/describe.js
CHANGED
|
@@ -23,7 +23,7 @@ import { easings, listValueTypes } from "@glissade/core";
|
|
|
23
23
|
* never pulled onto the base embed path — a scene that never calls `describe()`
|
|
24
24
|
* pays zero bytes for it.
|
|
25
25
|
*/
|
|
26
|
-
const RAW_VERSION = "0.
|
|
26
|
+
const RAW_VERSION = "0.60.0-pre.0";
|
|
27
27
|
const PACKAGE_VERSION = RAW_VERSION.includes("GLISSADE_".concat("VERSION")) ? "0.0.0-dev" : RAW_VERSION;
|
|
28
28
|
/**
|
|
29
29
|
* Parse the documented positional-arg count from a helper `usage` string — the
|
package/dist/diagnostics.d.ts
CHANGED
|
@@ -178,9 +178,17 @@ type DiagnosticSeverity = 'error' | 'warning' | 'info';
|
|
|
178
178
|
* a built Scene structurally cannot contain a duplicate id, so validateScene
|
|
179
179
|
* never reaches this case. Kept for the shared contract / `gs parity` surface.
|
|
180
180
|
*/
|
|
181
|
-
type DiagnosticCode = 'UNKNOWN_TARGET' | 'ID_COLLISION' | 'OFF_CANVAS' | 'YOGA_CHILD_POSITION' | 'MEASURER_FALLBACK';
|
|
181
|
+
type DiagnosticCode = 'UNKNOWN_TARGET' | 'ID_COLLISION' | 'OFF_CANVAS' | 'YOGA_CHILD_POSITION' | 'MEASURER_FALLBACK' | 'TEXT_OVERFLOW' | 'OCCLUSION';
|
|
182
|
+
/**
|
|
183
|
+
* 0.60: which enforcement SURFACE produced a diagnostic — distinguishes a CERTAIN
|
|
184
|
+
* static fact (`validateScene`) from a HEURISTIC rendered judgment (`critique`,
|
|
185
|
+
* which CAN false-positive) from a perceptual parity result (`parity`). Additive,
|
|
186
|
+
* optional (a pre-0.60 consumer ignores it). Certification reads it to keep "zero
|
|
187
|
+
* static errors" and "zero critique false-positives" as distinct claims.
|
|
188
|
+
*/
|
|
189
|
+
type DiagnosticSource = 'validateScene' | 'critique' | 'parity';
|
|
182
190
|
/** One diagnostic. The `{schemaVersion, code, severity, message, node?, track?}`
|
|
183
|
-
* core shape is PINNED; future fields are ADDITIVE only. */
|
|
191
|
+
* core shape is PINNED; future fields (`source`/`detail`, 0.60) are ADDITIVE only. */
|
|
184
192
|
interface SceneDiagnostic {
|
|
185
193
|
schemaVersion: typeof DIAGNOSTIC_SCHEMA_VERSION;
|
|
186
194
|
code: DiagnosticCode;
|
|
@@ -190,6 +198,11 @@ interface SceneDiagnostic {
|
|
|
190
198
|
node?: string;
|
|
191
199
|
/** The track target string the diagnostic concerns, when applicable. */
|
|
192
200
|
track?: string;
|
|
201
|
+
/** 0.60: the enforcement surface that produced this diagnostic. */
|
|
202
|
+
source?: DiagnosticSource;
|
|
203
|
+
/** 0.60: structured evidence (e.g. `{ measured, threshold }` for a critique
|
|
204
|
+
* code), so a consumer digs into the numbers without parsing the message. */
|
|
205
|
+
detail?: Record<string, unknown>;
|
|
193
206
|
}
|
|
194
207
|
/** `validateScene` result — the CLI-lint `{ hasErrors, diagnostics }` shape,
|
|
195
208
|
* plus a top-level `schemaVersion`. */
|
|
@@ -261,4 +274,43 @@ interface InstancePropState {
|
|
|
261
274
|
*/
|
|
262
275
|
declare function instanceProps(node: Node): InstancePropState[];
|
|
263
276
|
//#endregion
|
|
264
|
-
|
|
277
|
+
//#region src/critique.d.ts
|
|
278
|
+
interface CritiqueOptions {
|
|
279
|
+
/**
|
|
280
|
+
* frames-per-second for the sampling grid. Default: the timeline's own fps,
|
|
281
|
+
* else 60 (aligning critique verdicts with what `gs render` produces). Kept an
|
|
282
|
+
* override for tooling; a fixed INTEGER-frame grid is the determinism contract.
|
|
283
|
+
*/
|
|
284
|
+
fps?: number;
|
|
285
|
+
}
|
|
286
|
+
interface CritiqueResult {
|
|
287
|
+
schemaVersion: typeof DIAGNOSTIC_SCHEMA_VERSION;
|
|
288
|
+
/** true iff any diagnostic has severity `error` (always a static error — the
|
|
289
|
+
* rendered pass emits only warnings/info). */
|
|
290
|
+
hasErrors: boolean;
|
|
291
|
+
/** FLAT merged, CANONICALLY-SORTED diagnostics (static + rendered). */
|
|
292
|
+
diagnostics: SceneDiagnostic[];
|
|
293
|
+
/** true when the rendered pass was skipped because static validation errored. */
|
|
294
|
+
renderedSkipped: boolean;
|
|
295
|
+
/** why the rendered pass was skipped (present iff `renderedSkipped`). */
|
|
296
|
+
renderedSkipReason?: string;
|
|
297
|
+
/** how many integer-frame grid samples the rendered pass took (0 if skipped). */
|
|
298
|
+
sampledFrames: number;
|
|
299
|
+
}
|
|
300
|
+
/**
|
|
301
|
+
* Rendered-geometric diagnostics for `(scene, timeline)`. Runs `validateScene`
|
|
302
|
+
* first; short-circuits the rendered pass on any static error. Otherwise binds the
|
|
303
|
+
* scene, samples a fixed integer-frame grid, and emits OFF_CANVAS / TEXT_OVERFLOW
|
|
304
|
+
* / OCCLUSION where a span check fires. PURE READ — never changes evaluate() or a
|
|
305
|
+
* render path; canonically-sorted (frame, then code, then node-id) output.
|
|
306
|
+
*/
|
|
307
|
+
declare function critique(scene: Scene, timeline: Timeline, opts?: CritiqueOptions): CritiqueResult;
|
|
308
|
+
/**
|
|
309
|
+
* CANONICAL SORT — by frame (detail.frame, undefined last), then code, then
|
|
310
|
+
* node-id. An unordered diagnostic array is golden-unstable; this is the HARD emit
|
|
311
|
+
* requirement (assert sort-invariance: shuffle-then-sort ≡ emitted order). Stable
|
|
312
|
+
* for equal keys (the static-then-rendered concat order is deterministic).
|
|
313
|
+
*/
|
|
314
|
+
declare function sortDiagnostics(diags: SceneDiagnostic[]): SceneDiagnostic[];
|
|
315
|
+
//#endregion
|
|
316
|
+
export { type CacheColdResult, type CommandDelta, type CritiqueOptions, type CritiqueResult, DIAGNOSTIC_SCHEMA_VERSION, DL_SNAPSHOT_VERSION, type DiagnosticCode, type DiagnosticSeverity, type DiagnosticSource, type DisplayDiff, type DlSnapshot, DlSnapshotError, type FieldChange, type FontByteLoader, type InstancePropState, type SceneDiagnostic, type ValidateSceneFontsOptions, type ValidateSceneResult, auditCacheCold, collapseReplacer, collectLocalizedTextUsages, collectTextUsages, critique, diffDisplayLists, formatDisplayDiff, instanceProps, levenshtein, nearestId, parseDisplaySnapshot, resolveAt, serializeDisplayList, sortDiagnostics, validateScene, validateSceneFonts };
|
package/dist/diagnostics.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import { I as isEstimatingMeasurer, J as collapseReplacer, P as estimatingMeasurer, W as createDisplayListBuilder, r as Group, s as Text } from "./nodes.js";
|
|
2
|
-
import { a as evaluate } from "./scene.js";
|
|
3
|
-
import {
|
|
1
|
+
import { I as isEstimatingMeasurer, J as collapseReplacer, P as estimatingMeasurer, W as createDisplayListBuilder, Y as IDENTITY, et as multiply, r as Group, s as Text } from "./nodes.js";
|
|
2
|
+
import { a as evaluate, r as bindScene } from "./scene.js";
|
|
3
|
+
import { emitWithIds } from "./identity.js";
|
|
4
|
+
import { buildFontRegistry, compileTimeline, evaluateAt, parseCmap, parseColor, untracked, validateFonts } from "@glissade/core";
|
|
4
5
|
//#region src/displayDiff.ts
|
|
5
6
|
/** A flat, stable JSON value for one command with its resource ids INLINED to content. */
|
|
6
7
|
function commandView(cmd, resources) {
|
|
@@ -383,6 +384,7 @@ function validateScene(scene, doc) {
|
|
|
383
384
|
code,
|
|
384
385
|
severity,
|
|
385
386
|
message,
|
|
387
|
+
source: "validateScene",
|
|
386
388
|
...extra?.node !== void 0 ? { node: extra.node } : {},
|
|
387
389
|
...extra?.track !== void 0 ? { track: extra.track } : {}
|
|
388
390
|
});
|
|
@@ -475,4 +477,438 @@ function instanceProps(node) {
|
|
|
475
477
|
});
|
|
476
478
|
}
|
|
477
479
|
//#endregion
|
|
478
|
-
|
|
480
|
+
//#region src/critique.ts
|
|
481
|
+
function growBounds(b, x, y) {
|
|
482
|
+
if (!b) return {
|
|
483
|
+
minX: x,
|
|
484
|
+
minY: y,
|
|
485
|
+
maxX: x,
|
|
486
|
+
maxY: y
|
|
487
|
+
};
|
|
488
|
+
if (x < b.minX) b.minX = x;
|
|
489
|
+
if (y < b.minY) b.minY = y;
|
|
490
|
+
if (x > b.maxX) b.maxX = x;
|
|
491
|
+
if (y > b.maxY) b.maxY = y;
|
|
492
|
+
return b;
|
|
493
|
+
}
|
|
494
|
+
/** Local-space rect → device-space box under `m`, growing `into`. */
|
|
495
|
+
function accumulateRect(into, m, x0, y0, x1, y1) {
|
|
496
|
+
let b = into;
|
|
497
|
+
for (const [x, y] of [
|
|
498
|
+
[x0, y0],
|
|
499
|
+
[x1, y0],
|
|
500
|
+
[x0, y1],
|
|
501
|
+
[x1, y1]
|
|
502
|
+
]) b = growBounds(b, m[0] * x + m[2] * y + m[4], m[1] * x + m[3] * y + m[5]);
|
|
503
|
+
return b;
|
|
504
|
+
}
|
|
505
|
+
/** Control-point bounding box of a path — curves/rotated ellipses stay inside. */
|
|
506
|
+
function segsBounds(segs) {
|
|
507
|
+
let b = null;
|
|
508
|
+
const pt = (x, y) => {
|
|
509
|
+
b = growBounds(b, x, y);
|
|
510
|
+
};
|
|
511
|
+
for (const seg of segs) switch (seg[0]) {
|
|
512
|
+
case "M":
|
|
513
|
+
case "L":
|
|
514
|
+
pt(seg[1], seg[2]);
|
|
515
|
+
break;
|
|
516
|
+
case "C":
|
|
517
|
+
pt(seg[1], seg[2]);
|
|
518
|
+
pt(seg[3], seg[4]);
|
|
519
|
+
pt(seg[5], seg[6]);
|
|
520
|
+
break;
|
|
521
|
+
case "Q":
|
|
522
|
+
pt(seg[1], seg[2]);
|
|
523
|
+
pt(seg[3], seg[4]);
|
|
524
|
+
break;
|
|
525
|
+
case "E": {
|
|
526
|
+
const r = Math.max(seg[3], seg[4]);
|
|
527
|
+
pt(seg[1] - r, seg[2] - r);
|
|
528
|
+
pt(seg[1] + r, seg[2] + r);
|
|
529
|
+
break;
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
return b;
|
|
533
|
+
}
|
|
534
|
+
function pathResourceBounds(resources, id) {
|
|
535
|
+
const res = resources[id];
|
|
536
|
+
return res && res.kind === "path" ? segsBounds(res.segs) : null;
|
|
537
|
+
}
|
|
538
|
+
/** True when two boxes overlap the open frame [0,0,w,h] (bbox ∩ frame ≠ ∅). */
|
|
539
|
+
function intersectsFrame(b, w, h) {
|
|
540
|
+
return b.maxX > 0 && b.minX < w && b.maxY > 0 && b.minY < h;
|
|
541
|
+
}
|
|
542
|
+
/** True when `b` is FULLY outside the frame (off one edge entirely). */
|
|
543
|
+
function fullyOutsideFrame(b, w, h) {
|
|
544
|
+
return b.maxX <= 0 || b.minX >= w || b.maxY <= 0 || b.minY >= h;
|
|
545
|
+
}
|
|
546
|
+
/** True when `outer` fully contains `inner` (bbox containment). */
|
|
547
|
+
function contains(outer, inner) {
|
|
548
|
+
return outer.minX <= inner.minX && outer.minY <= inner.minY && outer.maxX >= inner.maxX && outer.maxY >= inner.maxY;
|
|
549
|
+
}
|
|
550
|
+
/**
|
|
551
|
+
* Walk one DisplayList (+ its id stream) maintaining a transform stack and a group
|
|
552
|
+
* stack, unioning each command's device-space bbox per node id. Solid-opaque fills
|
|
553
|
+
* under a fully-opaque group stack are recorded as occluders. COPIES the golden-
|
|
554
|
+
* tested save/restore/transform/pushGroup/popGroup discipline from raster2d.
|
|
555
|
+
*/
|
|
556
|
+
function walkFrame(list, ids, measurer) {
|
|
557
|
+
const nodes = /* @__PURE__ */ new Map();
|
|
558
|
+
const occluders = [];
|
|
559
|
+
let mat = IDENTITY;
|
|
560
|
+
const matStack = [];
|
|
561
|
+
let nonOpaqueGroupDepth = 0;
|
|
562
|
+
const groupOpaque = [];
|
|
563
|
+
const attribute = (id, box, order) => {
|
|
564
|
+
if (id === void 0 || box === null) return;
|
|
565
|
+
const existing = nodes.get(id);
|
|
566
|
+
if (existing) {
|
|
567
|
+
existing.bounds = accumulateRect(existing.bounds, IDENTITY, box.minX, box.minY, box.maxX, box.maxY);
|
|
568
|
+
if (order > existing.maxOrder) existing.maxOrder = order;
|
|
569
|
+
} else nodes.set(id, {
|
|
570
|
+
bounds: { ...box },
|
|
571
|
+
maxOrder: order,
|
|
572
|
+
texts: []
|
|
573
|
+
});
|
|
574
|
+
};
|
|
575
|
+
const commands = list.commands;
|
|
576
|
+
for (let ci = 0; ci < commands.length; ci++) {
|
|
577
|
+
const cmd = commands[ci];
|
|
578
|
+
const id = ids[ci];
|
|
579
|
+
switch (cmd.op) {
|
|
580
|
+
case "save":
|
|
581
|
+
matStack.push(mat);
|
|
582
|
+
break;
|
|
583
|
+
case "restore":
|
|
584
|
+
mat = matStack.pop() ?? mat;
|
|
585
|
+
break;
|
|
586
|
+
case "transform":
|
|
587
|
+
mat = multiply(mat, cmd.m);
|
|
588
|
+
break;
|
|
589
|
+
case "clip": break;
|
|
590
|
+
case "fillPath": {
|
|
591
|
+
const pb = pathResourceBounds(list.resources, cmd.path);
|
|
592
|
+
if (!pb) break;
|
|
593
|
+
const box = accumulateRect(null, mat, pb.minX, pb.minY, pb.maxX, pb.maxY);
|
|
594
|
+
attribute(id, box, ci);
|
|
595
|
+
if (box && nonOpaqueGroupDepth === 0 && isOpaqueSolid(cmd.paint)) occluders.push({
|
|
596
|
+
bounds: box,
|
|
597
|
+
order: ci,
|
|
598
|
+
id
|
|
599
|
+
});
|
|
600
|
+
break;
|
|
601
|
+
}
|
|
602
|
+
case "strokePath": {
|
|
603
|
+
const pb = pathResourceBounds(list.resources, cmd.path);
|
|
604
|
+
if (!pb) break;
|
|
605
|
+
const o = cmd.stroke.width * ((cmd.stroke.join ?? "miter") === "miter" ? 5 : 1);
|
|
606
|
+
attribute(id, accumulateRect(null, mat, pb.minX - o, pb.minY - o, pb.maxX + o, pb.maxY + o), ci);
|
|
607
|
+
break;
|
|
608
|
+
}
|
|
609
|
+
case "fillText": {
|
|
610
|
+
let width = 0;
|
|
611
|
+
try {
|
|
612
|
+
width = measurer.measureText(cmd.text, cmd.font).width;
|
|
613
|
+
} catch {
|
|
614
|
+
width = cmd.text.length * cmd.font.size * .6;
|
|
615
|
+
}
|
|
616
|
+
const align = cmd.align ?? "left";
|
|
617
|
+
const x0 = align === "center" ? cmd.x - width / 2 : align === "right" ? cmd.x - width : cmd.x;
|
|
618
|
+
const m = cmd.font.size;
|
|
619
|
+
attribute(id, accumulateRect(null, mat, x0 - m, cmd.y - 1.5 * m, x0 + width + m, cmd.y + .75 * m), ci);
|
|
620
|
+
if (id !== void 0) nodes.get(id).texts.push({
|
|
621
|
+
text: cmd.text,
|
|
622
|
+
font: cmd.font
|
|
623
|
+
});
|
|
624
|
+
break;
|
|
625
|
+
}
|
|
626
|
+
case "drawImage": {
|
|
627
|
+
const { x, y, w: dw, h: dh } = cmd.dst;
|
|
628
|
+
attribute(id, accumulateRect(null, mat, x, y, x + dw, y + dh), ci);
|
|
629
|
+
break;
|
|
630
|
+
}
|
|
631
|
+
case "pushGroup": {
|
|
632
|
+
const opaque = cmd.opacity >= 1 && cmd.blend === "source-over" && cmd.filters.length === 0 && cmd.matte === void 0;
|
|
633
|
+
groupOpaque.push(opaque);
|
|
634
|
+
if (!opaque) nonOpaqueGroupDepth++;
|
|
635
|
+
break;
|
|
636
|
+
}
|
|
637
|
+
case "popGroup":
|
|
638
|
+
if (groupOpaque.pop() === false) nonOpaqueGroupDepth--;
|
|
639
|
+
break;
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
return {
|
|
643
|
+
nodes,
|
|
644
|
+
occluders
|
|
645
|
+
};
|
|
646
|
+
}
|
|
647
|
+
/** Is a paint a SOLID opaque color (alpha ≥ ~0.98)? Conservative: gradient/mesh
|
|
648
|
+
* and any un-parseable color count as NOT opaque (false-negatives are fine, a
|
|
649
|
+
* false cover is not). */
|
|
650
|
+
function isOpaqueSolid(paint) {
|
|
651
|
+
if (!paint || paint.kind !== "color") return false;
|
|
652
|
+
try {
|
|
653
|
+
return parseColor(paint.color).a >= .98;
|
|
654
|
+
} catch {
|
|
655
|
+
return false;
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
/**
|
|
659
|
+
* Rendered-geometric diagnostics for `(scene, timeline)`. Runs `validateScene`
|
|
660
|
+
* first; short-circuits the rendered pass on any static error. Otherwise binds the
|
|
661
|
+
* scene, samples a fixed integer-frame grid, and emits OFF_CANVAS / TEXT_OVERFLOW
|
|
662
|
+
* / OCCLUSION where a span check fires. PURE READ — never changes evaluate() or a
|
|
663
|
+
* render path; canonically-sorted (frame, then code, then node-id) output.
|
|
664
|
+
*/
|
|
665
|
+
function critique(scene, timeline, opts = {}) {
|
|
666
|
+
const staticRes = validateScene(scene, timeline);
|
|
667
|
+
const staticDiags = staticRes.diagnostics;
|
|
668
|
+
if (staticRes.hasErrors) return {
|
|
669
|
+
schemaVersion: 1,
|
|
670
|
+
hasErrors: true,
|
|
671
|
+
diagnostics: sortDiagnostics(staticDiags.slice()),
|
|
672
|
+
renderedSkipped: true,
|
|
673
|
+
renderedSkipReason: "fix static errors first — the scene can’t bind, so rendered geometry would be garbage.",
|
|
674
|
+
sampledFrames: 0
|
|
675
|
+
};
|
|
676
|
+
bindScene(scene, timeline);
|
|
677
|
+
const { w, h } = scene.size;
|
|
678
|
+
const measurer = scene.textMeasurer;
|
|
679
|
+
const estimating = isEstimatingMeasurer(measurer);
|
|
680
|
+
const fps = opts.fps ?? timeline.fps ?? 60;
|
|
681
|
+
const duration = compileTimeline(timeline).duration;
|
|
682
|
+
const lastFrame = Math.max(0, Math.floor(duration * fps));
|
|
683
|
+
const agg = /* @__PURE__ */ new Map();
|
|
684
|
+
const ensure = (id) => {
|
|
685
|
+
let a = agg.get(id);
|
|
686
|
+
if (!a) {
|
|
687
|
+
a = {
|
|
688
|
+
onStage: 0,
|
|
689
|
+
offCanvas: 0,
|
|
690
|
+
occluded: 0,
|
|
691
|
+
lastFrame: -1,
|
|
692
|
+
lastT: 0,
|
|
693
|
+
lastBounds: {
|
|
694
|
+
minX: 0,
|
|
695
|
+
minY: 0,
|
|
696
|
+
maxX: 0,
|
|
697
|
+
maxY: 0
|
|
698
|
+
},
|
|
699
|
+
occluderId: void 0,
|
|
700
|
+
occluderBounds: null,
|
|
701
|
+
lastTextFrameT: 0,
|
|
702
|
+
lastTexts: []
|
|
703
|
+
};
|
|
704
|
+
agg.set(id, a);
|
|
705
|
+
}
|
|
706
|
+
return a;
|
|
707
|
+
};
|
|
708
|
+
let sampledFrames = 0;
|
|
709
|
+
for (let i = 0; i <= lastFrame; i++) {
|
|
710
|
+
const t = i / fps;
|
|
711
|
+
const { displayList, ids } = emitWithIds(scene, timeline, t);
|
|
712
|
+
const frame = walkFrame(displayList, ids, measurer);
|
|
713
|
+
sampledFrames++;
|
|
714
|
+
for (const [id, fn] of frame.nodes) {
|
|
715
|
+
const a = ensure(id);
|
|
716
|
+
a.onStage++;
|
|
717
|
+
a.lastFrame = i;
|
|
718
|
+
a.lastT = t;
|
|
719
|
+
a.lastBounds = fn.bounds;
|
|
720
|
+
if (fn.texts.length > 0) {
|
|
721
|
+
a.lastTextFrameT = t;
|
|
722
|
+
a.lastTexts = fn.texts;
|
|
723
|
+
}
|
|
724
|
+
if (fullyOutsideFrame(fn.bounds, w, h)) a.offCanvas++;
|
|
725
|
+
if (intersectsFrame(fn.bounds, w, h)) {
|
|
726
|
+
let cover;
|
|
727
|
+
for (const occ of frame.occluders) if (occ.order > fn.maxOrder && contains(occ.bounds, fn.bounds)) {
|
|
728
|
+
cover = occ;
|
|
729
|
+
break;
|
|
730
|
+
}
|
|
731
|
+
if (cover) {
|
|
732
|
+
a.occluded++;
|
|
733
|
+
a.occluderId = cover.id;
|
|
734
|
+
a.occluderBounds = cover.bounds;
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
const rendered = [];
|
|
740
|
+
const offCanvasIds = /* @__PURE__ */ new Set();
|
|
741
|
+
for (const [id, a] of agg) if (a.onStage > 0 && a.offCanvas === a.onStage) offCanvasIds.add(id);
|
|
742
|
+
const reportedOffCanvas = /* @__PURE__ */ new Set();
|
|
743
|
+
for (const id of offCanvasIds) {
|
|
744
|
+
if (hasFlaggedAncestor(scene, id, offCanvasIds)) continue;
|
|
745
|
+
reportedOffCanvas.add(id);
|
|
746
|
+
const a = agg.get(id);
|
|
747
|
+
rendered.push(offCanvasDiagnostic(scene, id, a, w, h));
|
|
748
|
+
}
|
|
749
|
+
for (const [id, a] of agg) {
|
|
750
|
+
if (a.lastTexts.length === 0) continue;
|
|
751
|
+
const node = scene.nodes.get(id);
|
|
752
|
+
if (!node || node.describeType !== "Text") continue;
|
|
753
|
+
const width = numberAt(scene, `${id}/width`, a.lastTextFrameT);
|
|
754
|
+
if (width === void 0 || width <= 0) continue;
|
|
755
|
+
let widest = 0;
|
|
756
|
+
for (const run of a.lastTexts) {
|
|
757
|
+
let mw = 0;
|
|
758
|
+
try {
|
|
759
|
+
mw = measurer.measureText(run.text, run.font).width;
|
|
760
|
+
} catch {
|
|
761
|
+
mw = 0;
|
|
762
|
+
}
|
|
763
|
+
if (mw > widest) widest = mw;
|
|
764
|
+
}
|
|
765
|
+
const over = widest - width;
|
|
766
|
+
if (over <= .5) continue;
|
|
767
|
+
rendered.push(textOverflowDiagnostic(id, widest, width, over, estimating));
|
|
768
|
+
}
|
|
769
|
+
for (const [id, a] of agg) {
|
|
770
|
+
if (a.onStage === 0 || a.occluded !== a.onStage) continue;
|
|
771
|
+
if (reportedOffCanvas.has(id) || offCanvasIds.has(id)) continue;
|
|
772
|
+
const node = scene.nodes.get(id);
|
|
773
|
+
if (!node || node instanceof Group) continue;
|
|
774
|
+
rendered.push(occlusionDiagnostic(scene, id, a));
|
|
775
|
+
}
|
|
776
|
+
return {
|
|
777
|
+
schemaVersion: 1,
|
|
778
|
+
hasErrors: false,
|
|
779
|
+
diagnostics: sortDiagnostics([...staticDiags, ...rendered]),
|
|
780
|
+
renderedSkipped: false,
|
|
781
|
+
sampledFrames
|
|
782
|
+
};
|
|
783
|
+
}
|
|
784
|
+
function offCanvasDiagnostic(scene, id, a, w, h) {
|
|
785
|
+
const b = a.lastBounds;
|
|
786
|
+
const bw = b.maxX - b.minX;
|
|
787
|
+
const bh = b.maxY - b.minY;
|
|
788
|
+
let dir = "off-frame";
|
|
789
|
+
let need = "";
|
|
790
|
+
const pos = vec2At(scene, `${id}/position`, a.lastT);
|
|
791
|
+
const posStr = pos ? ` position [${round(pos[0])}, ${round(pos[1])}]` : "";
|
|
792
|
+
if (b.maxX <= 0) {
|
|
793
|
+
dir = `off the LEFT by ${round(-b.maxX)}px`;
|
|
794
|
+
need = `; a center-anchored box needs x ≥ ~${round(bw / 2)} to sit on-frame`;
|
|
795
|
+
} else if (b.minX >= w) {
|
|
796
|
+
dir = `off the RIGHT by ${round(b.minX - w)}px`;
|
|
797
|
+
need = `; a center-anchored box needs x ≤ ~${round(w - bw / 2)} to sit on-frame`;
|
|
798
|
+
} else if (b.maxY <= 0) {
|
|
799
|
+
dir = `off the TOP by ${round(-b.maxY)}px`;
|
|
800
|
+
need = `; a center-anchored box needs y ≥ ~${round(bh / 2)} to sit on-frame`;
|
|
801
|
+
} else if (b.minY >= h) {
|
|
802
|
+
dir = `off the BOTTOM by ${round(b.minY - h)}px`;
|
|
803
|
+
need = `; a center-anchored box needs y ≤ ~${round(h - bh / 2)} to sit on-frame`;
|
|
804
|
+
}
|
|
805
|
+
return {
|
|
806
|
+
schemaVersion: 1,
|
|
807
|
+
code: "OFF_CANVAS",
|
|
808
|
+
severity: "warning",
|
|
809
|
+
source: "critique",
|
|
810
|
+
node: id,
|
|
811
|
+
message: `node '${id}' renders fully outside the ${w}×${h} frame (bbox x:[${round(b.minX)},${round(b.maxX)}] y:[${round(b.minY)},${round(b.maxY)}], ${dir}) for its whole on-stage lifetime.${posStr}. Adjust its position/anchor to bring the box on-frame${need}.`,
|
|
812
|
+
detail: {
|
|
813
|
+
frame: a.lastFrame,
|
|
814
|
+
bounds: {
|
|
815
|
+
minX: round(b.minX),
|
|
816
|
+
minY: round(b.minY),
|
|
817
|
+
maxX: round(b.maxX),
|
|
818
|
+
maxY: round(b.maxY)
|
|
819
|
+
},
|
|
820
|
+
size: {
|
|
821
|
+
w,
|
|
822
|
+
h
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
};
|
|
826
|
+
}
|
|
827
|
+
function textOverflowDiagnostic(id, measured, threshold, over, estimating) {
|
|
828
|
+
const base = `text of node '${id}' overflows its box by ${round(over)}px (needs ${round(measured)}px, box width ${round(threshold)}px). Reduce fontSize, widen width, or wrap it with fitText({ maxW: ${round(threshold)} }).`;
|
|
829
|
+
return {
|
|
830
|
+
schemaVersion: 1,
|
|
831
|
+
code: "TEXT_OVERFLOW",
|
|
832
|
+
severity: estimating ? "info" : "warning",
|
|
833
|
+
source: "critique",
|
|
834
|
+
node: id,
|
|
835
|
+
message: estimating ? `${base} (metrics ESTIMATED — no real text measurer injected; verify with the real backend measurer.)` : base,
|
|
836
|
+
detail: {
|
|
837
|
+
measured: round(measured),
|
|
838
|
+
threshold: round(threshold),
|
|
839
|
+
overflowPx: round(over),
|
|
840
|
+
estimated: estimating
|
|
841
|
+
}
|
|
842
|
+
};
|
|
843
|
+
}
|
|
844
|
+
function occlusionDiagnostic(scene, id, a) {
|
|
845
|
+
const occ = a.occluderId !== void 0 ? `node '${a.occluderId}'` : "an opaque layer painted above it";
|
|
846
|
+
const pos = vec2At(scene, `${id}/position`, a.lastT);
|
|
847
|
+
return {
|
|
848
|
+
schemaVersion: 1,
|
|
849
|
+
code: "OCCLUSION",
|
|
850
|
+
severity: "warning",
|
|
851
|
+
source: "critique",
|
|
852
|
+
node: id,
|
|
853
|
+
message: `node '${id}'${pos ? ` (position [${round(pos[0])}, ${round(pos[1])}])` : ""} is fully covered by ${occ} (0 visible px) for its whole on-stage lifetime. ${a.occluderId !== void 0 ? `raise '${id}' above '${a.occluderId}' (higher zIndex / paint order) OR move it outside '${a.occluderId}'’s bounds` : "raise its zIndex above the covering layer OR move it out from under the cover"}.`,
|
|
854
|
+
detail: {
|
|
855
|
+
frame: a.lastFrame,
|
|
856
|
+
...a.occluderId !== void 0 ? { occluder: a.occluderId } : {},
|
|
857
|
+
...a.occluderBounds ? { occluderBounds: {
|
|
858
|
+
minX: round(a.occluderBounds.minX),
|
|
859
|
+
minY: round(a.occluderBounds.minY),
|
|
860
|
+
maxX: round(a.occluderBounds.maxX),
|
|
861
|
+
maxY: round(a.occluderBounds.maxY)
|
|
862
|
+
} } : {}
|
|
863
|
+
}
|
|
864
|
+
};
|
|
865
|
+
}
|
|
866
|
+
/** Walk `id`'s ided ancestor chain; true if any ancestor is in `flagged`. */
|
|
867
|
+
function hasFlaggedAncestor(scene, id, flagged) {
|
|
868
|
+
let p = scene.nodes.get(id)?.parent ?? null;
|
|
869
|
+
while (p) {
|
|
870
|
+
if (p.id !== void 0 && flagged.has(p.id)) return true;
|
|
871
|
+
p = p.parent;
|
|
872
|
+
}
|
|
873
|
+
return false;
|
|
874
|
+
}
|
|
875
|
+
function numberAt(scene, target, t) {
|
|
876
|
+
const v = resolveAt(scene, target, t);
|
|
877
|
+
return typeof v === "number" && Number.isFinite(v) ? v : void 0;
|
|
878
|
+
}
|
|
879
|
+
function vec2At(scene, target, t) {
|
|
880
|
+
const v = resolveAt(scene, target, t);
|
|
881
|
+
return Array.isArray(v) && v.length >= 2 && typeof v[0] === "number" && typeof v[1] === "number" ? [v[0], v[1]] : void 0;
|
|
882
|
+
}
|
|
883
|
+
function round(n) {
|
|
884
|
+
return Math.round(n * 100) / 100;
|
|
885
|
+
}
|
|
886
|
+
/**
|
|
887
|
+
* CANONICAL SORT — by frame (detail.frame, undefined last), then code, then
|
|
888
|
+
* node-id. An unordered diagnostic array is golden-unstable; this is the HARD emit
|
|
889
|
+
* requirement (assert sort-invariance: shuffle-then-sort ≡ emitted order). Stable
|
|
890
|
+
* for equal keys (the static-then-rendered concat order is deterministic).
|
|
891
|
+
*/
|
|
892
|
+
function sortDiagnostics(diags) {
|
|
893
|
+
const frameOf = (d) => {
|
|
894
|
+
const f = d.detail?.frame;
|
|
895
|
+
return typeof f === "number" ? f : Number.POSITIVE_INFINITY;
|
|
896
|
+
};
|
|
897
|
+
return diags.map((d, i) => [d, i]).sort((A, B) => {
|
|
898
|
+
const [a, ai] = A;
|
|
899
|
+
const [b, bi] = B;
|
|
900
|
+
const fa = frameOf(a);
|
|
901
|
+
const fb = frameOf(b);
|
|
902
|
+
if (fa !== fb) return fa - fb;
|
|
903
|
+
if (a.code !== b.code) return a.code < b.code ? -1 : 1;
|
|
904
|
+
const na = a.node ?? "";
|
|
905
|
+
const nb = b.node ?? "";
|
|
906
|
+
if (na !== nb) return na < nb ? -1 : 1;
|
|
907
|
+
const ta = a.track ?? "";
|
|
908
|
+
const tb = b.track ?? "";
|
|
909
|
+
if (ta !== tb) return ta < tb ? -1 : 1;
|
|
910
|
+
return ai - bi;
|
|
911
|
+
}).map(([d]) => d);
|
|
912
|
+
}
|
|
913
|
+
//#endregion
|
|
914
|
+
export { DIAGNOSTIC_SCHEMA_VERSION, DL_SNAPSHOT_VERSION, DlSnapshotError, auditCacheCold, collapseReplacer, collectLocalizedTextUsages, collectTextUsages, critique, diffDisplayLists, formatDisplayDiff, instanceProps, levenshtein, nearestId, parseDisplaySnapshot, resolveAt, serializeDisplayList, sortDiagnostics, validateScene, validateSceneFonts };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@glissade/scene",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.60.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
|
"engines": {
|
|
@@ -81,7 +81,7 @@
|
|
|
81
81
|
],
|
|
82
82
|
"dependencies": {
|
|
83
83
|
"yoga-layout": "^3.2.1",
|
|
84
|
-
"@glissade/core": "0.
|
|
84
|
+
"@glissade/core": "0.60.0-pre.0"
|
|
85
85
|
},
|
|
86
86
|
"repository": {
|
|
87
87
|
"type": "git",
|