@glissade/scene 0.59.0 → 0.60.0-pre.1
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.d.ts +10 -2
- package/dist/describe.js +36 -1
- package/dist/diagnostics.d.ts +69 -3
- package/dist/diagnostics.js +450 -4
- package/package.json +2 -2
package/dist/describe.d.ts
CHANGED
|
@@ -153,8 +153,16 @@ interface DescribedHelper {
|
|
|
153
153
|
interface SurfaceEntry {
|
|
154
154
|
/** The export name — also the `window.glissade.<name>` global on the IIFE. */
|
|
155
155
|
name: string;
|
|
156
|
-
/**
|
|
157
|
-
|
|
156
|
+
/**
|
|
157
|
+
* `'value'` = a runtime binding on the bundle (a class / function / object);
|
|
158
|
+
* `'type'` = a TS type-only name that erases at runtime (opaque, referenced by
|
|
159
|
+
* signatures); `'diagnostic'` = a runtime AUTHORING-DIAGNOSTIC function
|
|
160
|
+
* (`critique`/`validateScene`/`resolveAt`/`instanceProps`, 0.60) — a real
|
|
161
|
+
* `window.glissade.<name>` callable that is PERCEPTION/self-check tooling, not
|
|
162
|
+
* scene-building surface. An agent BUILDING a scene filters `kind !== 'diagnostic'`;
|
|
163
|
+
* an agent CRITIQUING a rendered scene filters `kind === 'diagnostic'`.
|
|
164
|
+
*/
|
|
165
|
+
kind: 'value' | 'type' | 'diagnostic';
|
|
158
166
|
/** `true` when it is reachable as `window.glissade.<name>` on the single-file IIFE bundle. */
|
|
159
167
|
iife: boolean;
|
|
160
168
|
/** How to consume it: `'constructor'` needs `new`, `'function'` is a plain call, `'object'` is a value namespace (e.g. `easings`), `'type'` is type-only. */
|
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.1";
|
|
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
|
|
@@ -182,6 +182,34 @@ const SURFACE_EXTRA = [
|
|
|
182
182
|
/** Value exports that are runtime OBJECTS (not callable): the easing registry. */
|
|
183
183
|
const SURFACE_VALUE_OBJECTS = ["easings"];
|
|
184
184
|
/**
|
|
185
|
+
* The 0.60 machine-readable AUTHORING-DIAGNOSTIC functions on `window.glissade`
|
|
186
|
+
* (the `@glissade/scene/diagnostics` subpath, IIFE-re-exported off the base embed):
|
|
187
|
+
* `critique` (rendered geometry), `validateScene` (static structure), `resolveAt`
|
|
188
|
+
* (the truthful read primitive), `instanceProps` (instance-bound state). Marked
|
|
189
|
+
* `kind: 'diagnostic'` so a consumer can PARTITION the surface — build-a-scene
|
|
190
|
+
* tooling filters them OUT, render-critique/perception tooling filters them IN —
|
|
191
|
+
* instead of them being invisible (previously exempt-internal, discoverable only by
|
|
192
|
+
* reading the bundle). `arity` = the documented required positional-arg count.
|
|
193
|
+
*/
|
|
194
|
+
const SURFACE_DIAGNOSTICS = [
|
|
195
|
+
{
|
|
196
|
+
name: "critique",
|
|
197
|
+
arity: 2
|
|
198
|
+
},
|
|
199
|
+
{
|
|
200
|
+
name: "validateScene",
|
|
201
|
+
arity: 2
|
|
202
|
+
},
|
|
203
|
+
{
|
|
204
|
+
name: "resolveAt",
|
|
205
|
+
arity: 3
|
|
206
|
+
},
|
|
207
|
+
{
|
|
208
|
+
name: "instanceProps",
|
|
209
|
+
arity: 1
|
|
210
|
+
}
|
|
211
|
+
];
|
|
212
|
+
/**
|
|
185
213
|
* The opaque, type-ONLY names the API surface references (they erase at runtime —
|
|
186
214
|
* `window.glissade.Paint` is `undefined`). `gs types --global` emits a best-effort
|
|
187
215
|
* alias per name; `gs describe --lint` guards they stay type-only (a type surfaced
|
|
@@ -235,6 +263,13 @@ function buildSurface() {
|
|
|
235
263
|
iife: true,
|
|
236
264
|
form: "object"
|
|
237
265
|
});
|
|
266
|
+
for (const d of SURFACE_DIAGNOSTICS) out.push({
|
|
267
|
+
name: d.name,
|
|
268
|
+
kind: "diagnostic",
|
|
269
|
+
iife: true,
|
|
270
|
+
form: "function",
|
|
271
|
+
arity: d.arity
|
|
272
|
+
});
|
|
238
273
|
for (const name of SURFACE_TYPE_ONLY) out.push({
|
|
239
274
|
name,
|
|
240
275
|
kind: "type",
|
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,57 @@ 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
|
+
* Author-declared INTENTIONALLY off-stage node ids — the OFF_CANVAS opt-out.
|
|
287
|
+
* A node is exempt from OFF_CANVAS iff its id is in this list OR ANY of its
|
|
288
|
+
* ancestors' ids is (SUBTREE match): list the parked GROUP id
|
|
289
|
+
* (`'sd1-drawer'`) and its whole subtree — current children AND any it later
|
|
290
|
+
* gains — is suppressed, while sibling groups stay fully checked. This lets an
|
|
291
|
+
* author silence the true-positive-but-intentional off-stage art (wing-parked
|
|
292
|
+
* drawers, hidden placeholder cards) without muting OFF_CANVAS wholesale. A
|
|
293
|
+
* PURE emission filter — determinism-neutral (it never changes the sampled
|
|
294
|
+
* geometry, only which off-frame nodes are reported). Same param-seam shape as
|
|
295
|
+
* a future `safeAreas`; a per-node `offstage:true` marker is a planned
|
|
296
|
+
* fast-follow, not this mechanism.
|
|
297
|
+
*/
|
|
298
|
+
offstage?: readonly string[];
|
|
299
|
+
}
|
|
300
|
+
interface CritiqueResult {
|
|
301
|
+
schemaVersion: typeof DIAGNOSTIC_SCHEMA_VERSION;
|
|
302
|
+
/** true iff any diagnostic has severity `error` (always a static error — the
|
|
303
|
+
* rendered pass emits only warnings/info). */
|
|
304
|
+
hasErrors: boolean;
|
|
305
|
+
/** FLAT merged, CANONICALLY-SORTED diagnostics (static + rendered). */
|
|
306
|
+
diagnostics: SceneDiagnostic[];
|
|
307
|
+
/** true when the rendered pass was skipped because static validation errored. */
|
|
308
|
+
renderedSkipped: boolean;
|
|
309
|
+
/** why the rendered pass was skipped (present iff `renderedSkipped`). */
|
|
310
|
+
renderedSkipReason?: string;
|
|
311
|
+
/** how many integer-frame grid samples the rendered pass took (0 if skipped). */
|
|
312
|
+
sampledFrames: number;
|
|
313
|
+
}
|
|
314
|
+
/**
|
|
315
|
+
* Rendered-geometric diagnostics for `(scene, timeline)`. Runs `validateScene`
|
|
316
|
+
* first; short-circuits the rendered pass on any static error. Otherwise binds the
|
|
317
|
+
* scene, samples a fixed integer-frame grid, and emits OFF_CANVAS / TEXT_OVERFLOW
|
|
318
|
+
* / OCCLUSION where a span check fires. PURE READ — never changes evaluate() or a
|
|
319
|
+
* render path; canonically-sorted (frame, then code, then node-id) output.
|
|
320
|
+
*/
|
|
321
|
+
declare function critique(scene: Scene, timeline: Timeline, opts?: CritiqueOptions): CritiqueResult;
|
|
322
|
+
/**
|
|
323
|
+
* CANONICAL SORT — by frame (detail.frame, undefined last), then code, then
|
|
324
|
+
* node-id. An unordered diagnostic array is golden-unstable; this is the HARD emit
|
|
325
|
+
* requirement (assert sort-invariance: shuffle-then-sort ≡ emitted order). Stable
|
|
326
|
+
* for equal keys (the static-then-rendered concat order is deterministic).
|
|
327
|
+
*/
|
|
328
|
+
declare function sortDiagnostics(diags: SceneDiagnostic[]): SceneDiagnostic[];
|
|
329
|
+
//#endregion
|
|
330
|
+
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, R as quantize, 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,448 @@ 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 offstageSet = new Set(opts.offstage ?? []);
|
|
741
|
+
const isOffstage = (id) => offstageSet.size > 0 && (offstageSet.has(id) || hasFlaggedAncestor(scene, id, offstageSet));
|
|
742
|
+
const offCanvasIds = /* @__PURE__ */ new Set();
|
|
743
|
+
for (const [id, a] of agg) if (a.onStage > 0 && a.offCanvas === a.onStage && !isOffstage(id)) offCanvasIds.add(id);
|
|
744
|
+
const reportedOffCanvas = /* @__PURE__ */ new Set();
|
|
745
|
+
for (const id of offCanvasIds) {
|
|
746
|
+
if (hasFlaggedAncestor(scene, id, offCanvasIds)) continue;
|
|
747
|
+
reportedOffCanvas.add(id);
|
|
748
|
+
const a = agg.get(id);
|
|
749
|
+
rendered.push(offCanvasDiagnostic(scene, id, a, w, h));
|
|
750
|
+
}
|
|
751
|
+
for (const [id, a] of agg) {
|
|
752
|
+
if (a.lastTexts.length === 0) continue;
|
|
753
|
+
const node = scene.nodes.get(id);
|
|
754
|
+
if (!(node instanceof Text)) continue;
|
|
755
|
+
const width = numberAt(scene, `${id}/width`, a.lastTextFrameT);
|
|
756
|
+
if (width !== void 0 && width > 0) {
|
|
757
|
+
let widest = 0;
|
|
758
|
+
for (const run of a.lastTexts) {
|
|
759
|
+
let mw = 0;
|
|
760
|
+
try {
|
|
761
|
+
mw = measurer.measureText(run.text, run.font).width;
|
|
762
|
+
} catch {
|
|
763
|
+
mw = 0;
|
|
764
|
+
}
|
|
765
|
+
if (mw > widest) widest = mw;
|
|
766
|
+
}
|
|
767
|
+
const over = widest - width;
|
|
768
|
+
if (over > .5) rendered.push(textOverflowDiagnostic(id, "width", widest, width, over, estimating));
|
|
769
|
+
}
|
|
770
|
+
const boxH = node.box?.h;
|
|
771
|
+
if (boxH !== void 0 && boxH > 0) {
|
|
772
|
+
const fontSize = a.lastTexts[0].font.size;
|
|
773
|
+
const blockH = quantize(fontSize * node.lineHeight) * a.lastTexts.length;
|
|
774
|
+
const overH = blockH - boxH;
|
|
775
|
+
if (overH > .5) rendered.push(textOverflowDiagnostic(id, "height", blockH, boxH, overH, estimating));
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
for (const [id, a] of agg) {
|
|
779
|
+
if (a.onStage === 0 || a.occluded !== a.onStage) continue;
|
|
780
|
+
if (reportedOffCanvas.has(id) || offCanvasIds.has(id)) continue;
|
|
781
|
+
const node = scene.nodes.get(id);
|
|
782
|
+
if (!node || node instanceof Group) continue;
|
|
783
|
+
rendered.push(occlusionDiagnostic(scene, id, a));
|
|
784
|
+
}
|
|
785
|
+
return {
|
|
786
|
+
schemaVersion: 1,
|
|
787
|
+
hasErrors: false,
|
|
788
|
+
diagnostics: sortDiagnostics([...staticDiags, ...rendered]),
|
|
789
|
+
renderedSkipped: false,
|
|
790
|
+
sampledFrames
|
|
791
|
+
};
|
|
792
|
+
}
|
|
793
|
+
function offCanvasDiagnostic(scene, id, a, w, h) {
|
|
794
|
+
const b = a.lastBounds;
|
|
795
|
+
const bw = b.maxX - b.minX;
|
|
796
|
+
const bh = b.maxY - b.minY;
|
|
797
|
+
let dir = "off-frame";
|
|
798
|
+
let need = "";
|
|
799
|
+
const pos = vec2At(scene, `${id}/position`, a.lastT);
|
|
800
|
+
const posStr = pos ? ` position [${round(pos[0])}, ${round(pos[1])}]` : "";
|
|
801
|
+
if (b.maxX <= 0) {
|
|
802
|
+
dir = `off the LEFT by ${round(-b.maxX)}px`;
|
|
803
|
+
need = `; a center-anchored box needs x ≥ ~${round(bw / 2)} to sit on-frame`;
|
|
804
|
+
} else if (b.minX >= w) {
|
|
805
|
+
dir = `off the RIGHT by ${round(b.minX - w)}px`;
|
|
806
|
+
need = `; a center-anchored box needs x ≤ ~${round(w - bw / 2)} to sit on-frame`;
|
|
807
|
+
} else if (b.maxY <= 0) {
|
|
808
|
+
dir = `off the TOP by ${round(-b.maxY)}px`;
|
|
809
|
+
need = `; a center-anchored box needs y ≥ ~${round(bh / 2)} to sit on-frame`;
|
|
810
|
+
} else if (b.minY >= h) {
|
|
811
|
+
dir = `off the BOTTOM by ${round(b.minY - h)}px`;
|
|
812
|
+
need = `; a center-anchored box needs y ≤ ~${round(h - bh / 2)} to sit on-frame`;
|
|
813
|
+
}
|
|
814
|
+
return {
|
|
815
|
+
schemaVersion: 1,
|
|
816
|
+
code: "OFF_CANVAS",
|
|
817
|
+
severity: "warning",
|
|
818
|
+
source: "critique",
|
|
819
|
+
node: id,
|
|
820
|
+
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}.`,
|
|
821
|
+
detail: {
|
|
822
|
+
frame: a.lastFrame,
|
|
823
|
+
bounds: {
|
|
824
|
+
minX: round(b.minX),
|
|
825
|
+
minY: round(b.minY),
|
|
826
|
+
maxX: round(b.maxX),
|
|
827
|
+
maxY: round(b.maxY)
|
|
828
|
+
},
|
|
829
|
+
size: {
|
|
830
|
+
w,
|
|
831
|
+
h
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
};
|
|
835
|
+
}
|
|
836
|
+
function textOverflowDiagnostic(id, dimension, measured, threshold, over, estimating) {
|
|
837
|
+
const base = dimension === "width" ? `text of node '${id}' overflows its box WIDTH 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)} }).` : `text of node '${id}' overflows its box HEIGHT by ${round(over)}px (wrapped block ${round(measured)}px tall, box height ${round(threshold)}px). Reduce fontSize, increase the box height, or shorten the text.`;
|
|
838
|
+
return {
|
|
839
|
+
schemaVersion: 1,
|
|
840
|
+
code: "TEXT_OVERFLOW",
|
|
841
|
+
severity: estimating ? "info" : "warning",
|
|
842
|
+
source: "critique",
|
|
843
|
+
node: id,
|
|
844
|
+
message: estimating ? `${base} (metrics ESTIMATED — no real text measurer injected; verify with the real backend measurer.)` : base,
|
|
845
|
+
detail: {
|
|
846
|
+
dimension,
|
|
847
|
+
measured: round(measured),
|
|
848
|
+
threshold: round(threshold),
|
|
849
|
+
overflowPx: round(over),
|
|
850
|
+
estimated: estimating
|
|
851
|
+
}
|
|
852
|
+
};
|
|
853
|
+
}
|
|
854
|
+
function occlusionDiagnostic(scene, id, a) {
|
|
855
|
+
const occ = a.occluderId !== void 0 ? `node '${a.occluderId}'` : "an opaque layer painted above it";
|
|
856
|
+
const pos = vec2At(scene, `${id}/position`, a.lastT);
|
|
857
|
+
return {
|
|
858
|
+
schemaVersion: 1,
|
|
859
|
+
code: "OCCLUSION",
|
|
860
|
+
severity: "warning",
|
|
861
|
+
source: "critique",
|
|
862
|
+
node: id,
|
|
863
|
+
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"}.`,
|
|
864
|
+
detail: {
|
|
865
|
+
frame: a.lastFrame,
|
|
866
|
+
...a.occluderId !== void 0 ? { occluder: a.occluderId } : {},
|
|
867
|
+
...a.occluderBounds ? { occluderBounds: {
|
|
868
|
+
minX: round(a.occluderBounds.minX),
|
|
869
|
+
minY: round(a.occluderBounds.minY),
|
|
870
|
+
maxX: round(a.occluderBounds.maxX),
|
|
871
|
+
maxY: round(a.occluderBounds.maxY)
|
|
872
|
+
} } : {}
|
|
873
|
+
}
|
|
874
|
+
};
|
|
875
|
+
}
|
|
876
|
+
/** Walk `id`'s ided ancestor chain; true if any ancestor is in `flagged`. */
|
|
877
|
+
function hasFlaggedAncestor(scene, id, flagged) {
|
|
878
|
+
let p = scene.nodes.get(id)?.parent ?? null;
|
|
879
|
+
while (p) {
|
|
880
|
+
if (p.id !== void 0 && flagged.has(p.id)) return true;
|
|
881
|
+
p = p.parent;
|
|
882
|
+
}
|
|
883
|
+
return false;
|
|
884
|
+
}
|
|
885
|
+
function numberAt(scene, target, t) {
|
|
886
|
+
const v = resolveAt(scene, target, t);
|
|
887
|
+
return typeof v === "number" && Number.isFinite(v) ? v : void 0;
|
|
888
|
+
}
|
|
889
|
+
function vec2At(scene, target, t) {
|
|
890
|
+
const v = resolveAt(scene, target, t);
|
|
891
|
+
return Array.isArray(v) && v.length >= 2 && typeof v[0] === "number" && typeof v[1] === "number" ? [v[0], v[1]] : void 0;
|
|
892
|
+
}
|
|
893
|
+
function round(n) {
|
|
894
|
+
return Math.round(n * 100) / 100;
|
|
895
|
+
}
|
|
896
|
+
/**
|
|
897
|
+
* CANONICAL SORT — by frame (detail.frame, undefined last), then code, then
|
|
898
|
+
* node-id. An unordered diagnostic array is golden-unstable; this is the HARD emit
|
|
899
|
+
* requirement (assert sort-invariance: shuffle-then-sort ≡ emitted order). Stable
|
|
900
|
+
* for equal keys (the static-then-rendered concat order is deterministic).
|
|
901
|
+
*/
|
|
902
|
+
function sortDiagnostics(diags) {
|
|
903
|
+
const frameOf = (d) => {
|
|
904
|
+
const f = d.detail?.frame;
|
|
905
|
+
return typeof f === "number" ? f : Number.POSITIVE_INFINITY;
|
|
906
|
+
};
|
|
907
|
+
return diags.map((d, i) => [d, i]).sort((A, B) => {
|
|
908
|
+
const [a, ai] = A;
|
|
909
|
+
const [b, bi] = B;
|
|
910
|
+
const fa = frameOf(a);
|
|
911
|
+
const fb = frameOf(b);
|
|
912
|
+
if (fa !== fb) return fa - fb;
|
|
913
|
+
if (a.code !== b.code) return a.code < b.code ? -1 : 1;
|
|
914
|
+
const na = a.node ?? "";
|
|
915
|
+
const nb = b.node ?? "";
|
|
916
|
+
if (na !== nb) return na < nb ? -1 : 1;
|
|
917
|
+
const ta = a.track ?? "";
|
|
918
|
+
const tb = b.track ?? "";
|
|
919
|
+
if (ta !== tb) return ta < tb ? -1 : 1;
|
|
920
|
+
return ai - bi;
|
|
921
|
+
}).map(([d]) => d);
|
|
922
|
+
}
|
|
923
|
+
//#endregion
|
|
924
|
+
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.1",
|
|
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.1"
|
|
85
85
|
},
|
|
86
86
|
"repository": {
|
|
87
87
|
"type": "git",
|