diff --git a/XZBT.html b/XZBT.html
index f21f0ae..52fa0f5 100644
--- a/XZBT.html
+++ b/XZBT.html
@@ -117,6 +117,16 @@ main { display: grid; grid-template-columns: minmax(260px, 340px) 1fr; min-heigh
padding: 0.35rem 0.55rem;
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 0.4rem;
+}
+
+.stage-canvas {
+ display: block;
+ width: 100%;
+ aspect-ratio: 16 / 9;
+ margin: 0 0 1rem;
+ border-radius: 6px;
+ background: #000;
+ border: 1px solid rgba(255, 255, 255, 0.08);
}
@@ -139,8 +149,9 @@ main { display: grid; grid-template-columns: minmax(260px, 340px) 1fr; min-heigh
Runtime readySelect an exhibit
Import declarative XZBT 0.1 documents, then activate one from the library. Exhibits run without adding subject-specific code to this runtime.
Active exhibit
+
- Exhibit ID
- Resolved seed
- Deterministic visual-stream preview
-
The common grammar resolves stored parameters, mutable state, signals, bindings, actions, and temporary overrides. The audio engine realizes declared graphs; the visual engine attaches in a later phase.
+
The common grammar resolves stored parameters, mutable state, signals, bindings, actions, and temporary overrides. The audio engine realizes declared graphs, and the visual engine draws declared scenes, layers, primitives, and transforms; procedural systems, automation, and post-effects attach in later slices.
Audio
@@ -2323,6 +2334,661 @@ function postEffectPassCount(effects) {
return effects.reduce((total, entry) => total + (POST_EFFECTS[entry?.type]?.passes ?? 0), 0);
}
+/* src/runtime/visual-diagnostics.js */
+// The single visual diagnostic cadence of 19.5.
+//
+// Every resource diagnostic is keyed by (code, subject) and raised at most once
+// per logical second. A key counts consecutive shedding ticks; on reaching 120
+// the next raise for that key carries a `sustained` detail, which does not
+// bypass the rate limit and does not mint a second code. One tick with no shed
+// on a key resets both the counter and the rate limit, so a recovery followed
+// by a new overload reports promptly instead of being suppressed.
+
+
+const SUSTAINED_TICKS = VISUAL_LIMITS.runtime.sustainedShedTicks;
+
+class VisualDiagnosticCadence {
+ constructor(diagnostics, { section = 'visual' } = {}) {
+ this.diagnostics = diagnostics;
+ this.section = section;
+ this.keys = new Map();
+ this.raised = [];
+ }
+
+ entry(key) {
+ if (!this.keys.has(key)) this.keys.set(key, { lastRaisedAt: -Infinity, consecutive: 0, seenThisTick: false });
+ return this.keys.get(key);
+ }
+
+ // Report one shed. Returns the diagnostic raised, or null when the key's
+ // per-second slot is still occupied.
+ shed(code, subject, message, logicalMilliseconds, context = {}) {
+ const key = code + ' ' + subject;
+ const state = this.entry(key);
+ state.seenThisTick = true;
+ state.consecutive += 1;
+ if (logicalMilliseconds - state.lastRaisedAt < DIAGNOSTIC_CADENCE_MS) return null;
+ state.lastRaisedAt = logicalMilliseconds;
+ const sustained = state.consecutive >= SUSTAINED_TICKS;
+ const text = sustained ? message + ' Sustained for ' + state.consecutive + ' consecutive ticks.' : message;
+ const record = { code, subject, message: text, sustained, at: logicalMilliseconds, ...context };
+ this.raised.push(record);
+ this.diagnostics?.warn(code, text, { section: this.section, objectId: subject, ...context });
+ return record;
+ }
+
+ // Close a tick: any key that did not shed resets its counter and its slot.
+ endTick() {
+ for (const state of this.keys.values()) {
+ if (state.seenThisTick) state.seenThisTick = false;
+ else if (state.consecutive !== 0) {
+ state.consecutive = 0;
+ state.lastRaisedAt = -Infinity;
+ }
+ }
+ }
+
+ // A capability warning, not a resource one: 17.12's conic fallback and
+ // 19.4's reduced-resolution blur report a renderer fact once per instance for
+ // the instance's life, and are deliberately outside the per-second cadence.
+ once(code, subject, message, context = {}) {
+ const key = 'once ' + code + ' ' + subject;
+ if (this.keys.has(key)) return null;
+ this.keys.set(key, { lastRaisedAt: Infinity, consecutive: 0, seenThisTick: false });
+ const record = { code, subject, message, sustained: false, at: null, ...context };
+ this.raised.push(record);
+ this.diagnostics?.warn(code, message, { section: this.section, objectId: subject, ...context });
+ return record;
+ }
+
+ clear() { this.keys.clear(); this.raised.length = 0; }
+}
+
+/* src/runtime/visual-math.js */
+// 2D affine matrices, angle conventions, and color arithmetic for the visual
+// subsystem (sections 17.2, 17.6, 17.11, 19.3 of the Format Specification at
+// revision 0.8).
+//
+// Matrices are the six-element affine form [a, b, c, d, e, f]:
+//
+// x' = a * x + c * y + e
+// y' = b * x + d * y + f
+//
+// `multiply(m, n)` composes so that `n` is applied to the point first, which is
+// the right-to-left reading the transform model of 17.11 and the camera chain
+// of 19.3 are both written in.
+
+
+const IDENTITY = Object.freeze([1, 0, 0, 1, 0, 0]);
+
+const DEGREES_TO_RADIANS = Math.PI / 180;
+
+function multiply(m, n) {
+ return [
+ m[0] * n[0] + m[2] * n[1],
+ m[1] * n[0] + m[3] * n[1],
+ m[0] * n[2] + m[2] * n[3],
+ m[1] * n[2] + m[3] * n[3],
+ m[0] * n[4] + m[2] * n[5] + m[4],
+ m[1] * n[4] + m[3] * n[5] + m[5]
+ ];
+}
+
+function compose(...matrices) {
+ return matrices.reduce((total, matrix) => multiply(total, matrix), IDENTITY);
+}
+
+function translation(tx, ty) { return [1, 0, 0, 1, tx, ty]; }
+function scaling(sx, sy) { return [sx, 0, 0, sy, 0, 0]; }
+
+/** 17.2: degrees, `0` along `+x`, positive angles turning toward `+y`. */
+function rotation(degrees) {
+ const radians = degrees * DEGREES_TO_RADIANS;
+ const cos = Math.cos(radians);
+ const sin = Math.sin(radians);
+ return [cos, sin, -sin, cos, 0, 0];
+}
+
+/** 17.11: `skew` is authored in degrees on each axis. */
+function skewing(xDegrees, yDegrees) {
+ return [1, Math.tan(yDegrees * DEGREES_TO_RADIANS), Math.tan(xDegrees * DEGREES_TO_RADIANS), 1, 0, 0];
+}
+
+function apply(matrix, x, y) {
+ return [matrix[0] * x + matrix[2] * y + matrix[4], matrix[1] * x + matrix[3] * y + matrix[5]];
+}
+
+/** The uniform factor an axis-less appearance dimension takes (19.3). */
+function uniformFactor(matrix) {
+ return Math.sqrt(Math.abs(matrix[0] * matrix[3] - matrix[1] * matrix[2]));
+}
+
+/**
+ * 17.11: `M_local = T(position + translate) x T(origin) x R x K x S x T(-origin)`.
+ * The order is normative even where a particular object would not notice.
+ */
+function localMatrix({ position = { x: 0, y: 0 }, translate = { x: 0, y: 0 }, rotation: angle = 0, skew = { x: 0, y: 0 }, scale = { x: 1, y: 1 }, origin = { x: 0, y: 0 } } = {}) {
+ return compose(
+ translation(position.x + translate.x, position.y + translate.y),
+ translation(origin.x, origin.y),
+ rotation(angle),
+ skewing(skew.x, skew.y),
+ scaling(scale.x, scale.y),
+ translation(-origin.x, -origin.y)
+ );
+}
+
+/**
+ * 17.9: the directed sweep of an `arc` or `ring`. The `360` bound is checked on
+ * the authored numbers before any normalization, so `0 -> 720` is rejected
+ * rather than folded into a full turn.
+ */
+function directedSweep(startAngle, endAngle, direction = 'clockwise', path = '$') {
+ const delta = direction === 'counter-clockwise' ? startAngle - endAngle : endAngle - startAngle;
+ if (!Number.isFinite(delta)) throw new RuntimeFault('ERR_TYPE_MISMATCH', 'Arc angles must be finite.', path);
+ if (Math.abs(delta) > 360) throw new RuntimeFault('ERR_OUT_OF_BOUNDS', 'An arc sweep may not exceed 360 degrees.', path);
+ const sign = direction === 'counter-clockwise' ? -1 : 1;
+ if (delta === 0) return { sweep: 0, sign };
+ // Normalize the *signed* delta, so 350 -> 10 clockwise is a 20-degree sweep
+ // through zero rather than a 340-degree one the long way round.
+ const magnitude = Math.abs(delta) === 360 ? 360 : ((delta % 360) + 360) % 360;
+ return { sweep: magnitude, sign };
+}
+
+// ---------------------------------------------------------------------------
+// Color (section 2: `#rgb`, `#rrggbb`, `#rrggbbaa`, or a CSS color keyword)
+// ---------------------------------------------------------------------------
+
+const KEYWORDS = Object.freeze({
+ aliceblue: '#f0f8ff', antiquewhite: '#faebd7', aqua: '#00ffff', aquamarine: '#7fffd4', azure: '#f0ffff',
+ beige: '#f5f5dc', bisque: '#ffe4c4', black: '#000000', blanchedalmond: '#ffebcd', blue: '#0000ff',
+ blueviolet: '#8a2be2', brown: '#a52a2a', burlywood: '#deb887', cadetblue: '#5f9ea0', chartreuse: '#7fff00',
+ chocolate: '#d2691e', coral: '#ff7f50', cornflowerblue: '#6495ed', cornsilk: '#fff8dc', crimson: '#dc143c',
+ cyan: '#00ffff', darkblue: '#00008b', darkcyan: '#008b8b', darkgoldenrod: '#b8860b', darkgray: '#a9a9a9',
+ darkgreen: '#006400', darkgrey: '#a9a9a9', darkkhaki: '#bdb76b', darkmagenta: '#8b008b', darkolivegreen: '#556b2f',
+ darkorange: '#ff8c00', darkorchid: '#9932cc', darkred: '#8b0000', darksalmon: '#e9967a', darkseagreen: '#8fbc8f',
+ darkslateblue: '#483d8b', darkslategray: '#2f4f4f', darkslategrey: '#2f4f4f', darkturquoise: '#00ced1',
+ darkviolet: '#9400d3', deeppink: '#ff1493', deepskyblue: '#00bfff', dimgray: '#696969', dimgrey: '#696969',
+ dodgerblue: '#1e90ff', firebrick: '#b22222', floralwhite: '#fffaf0', forestgreen: '#228b22', fuchsia: '#ff00ff',
+ gainsboro: '#dcdcdc', ghostwhite: '#f8f8ff', gold: '#ffd700', goldenrod: '#daa520', gray: '#808080',
+ green: '#008000', greenyellow: '#adff2f', grey: '#808080', honeydew: '#f0fff0', hotpink: '#ff69b4',
+ indianred: '#cd5c5c', indigo: '#4b0082', ivory: '#fffff0', khaki: '#f0e68c', lavender: '#e6e6fa',
+ lavenderblush: '#fff0f5', lawngreen: '#7cfc00', lemonchiffon: '#fffacd', lightblue: '#add8e6', lightcoral: '#f08080',
+ lightcyan: '#e0ffff', lightgoldenrodyellow: '#fafad2', lightgray: '#d3d3d3', lightgreen: '#90ee90',
+ lightgrey: '#d3d3d3', lightpink: '#ffb6c1', lightsalmon: '#ffa07a', lightseagreen: '#20b2aa', lightskyblue: '#87cefa',
+ lightslategray: '#778899', lightslategrey: '#778899', lightsteelblue: '#b0c4de', lightyellow: '#ffffe0',
+ lime: '#00ff00', limegreen: '#32cd32', linen: '#faf0e6', magenta: '#ff00ff', maroon: '#800000',
+ mediumaquamarine: '#66cdaa', mediumblue: '#0000cd', mediumorchid: '#ba55d3', mediumpurple: '#9370db',
+ mediumseagreen: '#3cb371', mediumslateblue: '#7b68ee', mediumspringgreen: '#00fa9a', mediumturquoise: '#48d1cc',
+ mediumvioletred: '#c71585', midnightblue: '#191970', mintcream: '#f5fffa', mistyrose: '#ffe4e1', moccasin: '#ffe4b5',
+ navajowhite: '#ffdead', navy: '#000080', oldlace: '#fdf5e6', olive: '#808000', olivedrab: '#6b8e23',
+ orange: '#ffa500', orangered: '#ff4500', orchid: '#da70d6', palegoldenrod: '#eee8aa', palegreen: '#98fb98',
+ paleturquoise: '#afeeee', palevioletred: '#db7093', papayawhip: '#ffefd5', peachpuff: '#ffdab9', peru: '#cd853f',
+ pink: '#ffc0cb', plum: '#dda0dd', powderblue: '#b0e0e6', purple: '#800080', rebeccapurple: '#663399',
+ red: '#ff0000', rosybrown: '#bc8f8f', royalblue: '#4169e1', saddlebrown: '#8b4513', salmon: '#fa8072',
+ sandybrown: '#f4a460', seagreen: '#2e8b57', seashell: '#fff5ee', sienna: '#a0522d', silver: '#c0c0c0',
+ skyblue: '#87ceeb', slateblue: '#6a5acd', slategray: '#708090', slategrey: '#708090', snow: '#fffafa',
+ springgreen: '#00ff7f', steelblue: '#4682b4', tan: '#d2b48c', teal: '#008080', thistle: '#d8bfd8',
+ tomato: '#ff6347', transparent: '#00000000', turquoise: '#40e0d0', violet: '#ee82ee', wheat: '#f5deb3',
+ white: '#ffffff', whitesmoke: '#f5f5f5', yellow: '#ffff00', yellowgreen: '#9acd32'
+});
+
+const HEX3 = /^#([0-9a-f])([0-9a-f])([0-9a-f])$/i;
+const HEX6 = /^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i;
+const HEX8 = /^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i;
+
+/**
+ * Parse a section 2 `color` into `{ r, g, b, a }` with components on `0..255`
+ * and alpha on `0..1`. Anything outside the four documented forms is
+ * ERR_TYPE_MISMATCH: the strict coercion ban of section 2 leaves no room for
+ * guessing, and depth fog (17.6) needs exact components to be renderer-independent.
+ */
+function parseColor(value, path = '$') {
+ if (typeof value !== 'string' || value.length === 0) {
+ throw new RuntimeFault('ERR_TYPE_MISMATCH', `Expected a color, received ${typeof value}.`, path);
+ }
+ const text = KEYWORDS[value.toLowerCase()] ?? value;
+ let match = HEX8.exec(text);
+ if (match) return { r: parseInt(match[1], 16), g: parseInt(match[2], 16), b: parseInt(match[3], 16), a: parseInt(match[4], 16) / 255 };
+ match = HEX6.exec(text);
+ if (match) return { r: parseInt(match[1], 16), g: parseInt(match[2], 16), b: parseInt(match[3], 16), a: 1 };
+ match = HEX3.exec(text);
+ if (match) return { r: parseInt(match[1] + match[1], 16), g: parseInt(match[2] + match[2], 16), b: parseInt(match[3] + match[3], 16), a: 1 };
+ throw new RuntimeFault('ERR_TYPE_MISMATCH', `'${value}' is not a #rgb, #rrggbb, #rrggbbaa, or CSS keyword color.`, path);
+}
+
+function isColorLiteral(value) {
+ try { parseColor(value); return true; } catch { return false; }
+}
+
+function formatColor({ r, g, b, a }) {
+ const byte = (component) => Math.max(0, Math.min(255, Math.round(component))).toString(16).padStart(2, '0');
+ const hex = `#${byte(r)}${byte(g)}${byte(b)}`;
+ return a >= 1 ? hex : `${hex}${byte(a * 255)}`;
+}
+
+/**
+ * 17.6: depth fog blends per component in non-premultiplied sRGB with no
+ * linearization step. Alpha is never fogged, and the fog color's own alpha is
+ * ignored, so an object keeps exactly the transparency it was authored with.
+ */
+function fogColor(color, fog, fraction) {
+ if (fraction <= 0) return color;
+ const f = Math.min(1, fraction);
+ return {
+ r: color.r + (fog.r - color.r) * f,
+ g: color.g + (fog.g - color.g) * f,
+ b: color.b + (fog.b - color.b) * f,
+ a: color.a
+ };
+}
+
+/** 18.2: a `color` life ramp interpolates in sRGB component space including alpha. */
+function mixColor(from, to, amount) {
+ return {
+ r: from.r + (to.r - from.r) * amount,
+ g: from.g + (to.g - from.g) * amount,
+ b: from.b + (to.b - from.b) * amount,
+ a: from.a + (to.a - from.a) * amount
+ };
+}
+
+/* src/runtime/visual-geometry.js */
+// Local-space geometry for Visual Primitive Set 0.1 (17.9, 17.13).
+//
+// Every primitive is reduced to the same shape: a list of subpaths whose
+// segments are straight lines and cubic Beziers. Curves are represented rather
+// than flattened, because an affine map carries a cubic Bezier to a cubic
+// Bezier exactly, which is what lets the engine transform geometry through the
+// full composition chain of 19.3 without approximating it.
+//
+// Local extents and anchors follow the table of 17.9: a rectangle's top-left
+// corner sits at the origin, circular primitives are centered on it, and a
+// path's commands carry their own local coordinates.
+
+
+
+/** The circular Bezier constant: 4/3 * tan(pi/8). */
+const KAPPA = 0.5522847498307936;
+
+function subpath(start) {
+ return { start, segments: [], closed: false };
+}
+
+function lineTo(current, to) {
+ current.segments.push({ type: 'line', to });
+}
+
+function cubicTo(current, c1, c2, to) {
+ current.segments.push({ type: 'cubic', c1, c2, to });
+}
+
+/** One elliptical arc segment, center-parameterized, as cubic Beziers. */
+function arcSegments(current, cx, cy, rx, ry, startDegrees, sweepDegrees, sign, rotationDegrees = 0) {
+ if (sweepDegrees === 0) return;
+ const pieces = Math.max(1, Math.ceil(sweepDegrees / 90));
+ const step = (sweepDegrees / pieces) * sign * DEGREES_TO_RADIANS;
+ const phi = rotationDegrees * DEGREES_TO_RADIANS;
+ const cosPhi = Math.cos(phi);
+ const sinPhi = Math.sin(phi);
+ const place = (angle, radial) => {
+ const x = radial ? rx * Math.cos(angle) : -rx * Math.sin(angle);
+ const y = radial ? ry * Math.sin(angle) : ry * Math.cos(angle);
+ const px = x * cosPhi - y * sinPhi;
+ const py = x * sinPhi + y * cosPhi;
+ return radial ? [cx + px, cy + py] : [px, py];
+ };
+ let theta = startDegrees * DEGREES_TO_RADIANS;
+ for (let piece = 0; piece < pieces; piece += 1) {
+ const next = theta + step;
+ const alpha = (4 / 3) * Math.tan((next - theta) / 4);
+ const p0 = place(theta, true);
+ const p1 = place(next, true);
+ const d0 = place(theta, false);
+ const d1 = place(next, false);
+ cubicTo(current, [p0[0] + alpha * d0[0], p0[1] + alpha * d0[1]], [p1[0] - alpha * d1[0], p1[1] - alpha * d1[1]], p1);
+ theta = next;
+ }
+}
+
+function ellipseSubpath(cx, cy, rx, ry) {
+ const path = subpath([cx + rx, cy]);
+ arcSegments(path, cx, cy, rx, ry, 0, 360, 1);
+ path.closed = true;
+ return path;
+}
+
+function radiusPair(radius, path) {
+ if (typeof radius === 'number') return [radius, radius];
+ if (radius && typeof radius === 'object') return [radius.x ?? 0, radius.y ?? 0];
+ throw new RuntimeFault('ERR_SCHEMA_VALIDATION', 'radius must be a number or {x, y}.', path);
+}
+
+// Points carry three components. A point's own `z` is not a sortable depth
+// (17.6); it is carried so the engine can project each point with its own
+// perspective factor, which is the one effect 17.6 gives it.
+function point(value, path) {
+ if (!value || typeof value !== 'object') throw new RuntimeFault('ERR_SCHEMA_VALIDATION', 'Expected a point.', path);
+ return [value.x ?? 0, value.y ?? 0, value.z ?? 0];
+}
+
+// ---------------------------------------------------------------------------
+// Splines (17.13)
+// ---------------------------------------------------------------------------
+
+/**
+ * 17.13: `catmull-rom` is a uniform cardinal spline. For the segment between
+ * `p1` and `p2` with neighbours `p0` and `p3`, tangents are
+ * `m1 = tension * (p2 - p0)` and `m2 = tension * (p3 - p1)`, and the cubic
+ * Hermite form converts to the Bezier control points below.
+ */
+function catmullRomSegments(points, closed, tension) {
+ const count = points.length;
+ const at = (index) => {
+ if (closed) return points[((index % count) + count) % count];
+ return points[Math.max(0, Math.min(count - 1, index))];
+ };
+ const path = subpath(points[0].slice(0, 3));
+ const last = closed ? count : count - 1;
+ for (let index = 0; index < last; index += 1) {
+ const p0 = at(index - 1);
+ const p1 = at(index);
+ const p2 = at(index + 1);
+ const p3 = at(index + 2);
+ const m1 = [0, 1, 2].map((axis) => tension * ((p2[axis] ?? 0) - (p0[axis] ?? 0)));
+ const m2 = [0, 1, 2].map((axis) => tension * ((p3[axis] ?? 0) - (p1[axis] ?? 0)));
+ cubicTo(path,
+ [0, 1, 2].map((axis) => (p1[axis] ?? 0) + m1[axis] / 3),
+ [0, 1, 2].map((axis) => (p2[axis] ?? 0) - m2[axis] / 3),
+ [(p2[0] ?? 0), (p2[1] ?? 0), (p2[2] ?? 0)]);
+ }
+ path.closed = closed;
+ return path;
+}
+
+function bezierSpline(points, closed, path) {
+ if ((points.length - 1) % 3 !== 0 || points.length < 4) {
+ throw new RuntimeFault('ERR_SCHEMA_VALIDATION', 'A bezier-mode spline needs 3n + 1 points.', path);
+ }
+ const result = subpath(points[0].slice(0, 3));
+ for (let index = 1; index + 2 < points.length; index += 3) {
+ cubicTo(result, points[index].slice(0, 3), points[index + 1].slice(0, 3), points[index + 2].slice(0, 3));
+ }
+ // 17.13: a closed bezier spline closes with a straight line; the point list
+ // carries no control points for a closing segment and inventing two would be
+ // authoring geometry the author did not write.
+ if (closed) {
+ lineTo(result, points[0].slice(0, 3));
+ result.closed = true;
+ }
+ return result;
+}
+
+function linearSpline(points, closed) {
+ const result = subpath(points[0].slice(0, 3));
+ for (let index = 1; index < points.length; index += 1) lineTo(result, points[index].slice(0, 3));
+ if (closed) result.closed = true;
+ return result;
+}
+
+// ---------------------------------------------------------------------------
+// Path commands (17.13)
+// ---------------------------------------------------------------------------
+
+/** Endpoint-parameterized elliptical arc, converted to center parameterization. */
+function endpointArc(current, from, command, path) {
+ const [rxRaw, ryRaw] = radiusPair(command.radius, path);
+ const rx = Math.abs(rxRaw);
+ const ry = Math.abs(ryRaw);
+ const to = point(command.to, path);
+ if (rx <= 0 || ry <= 0) throw new RuntimeFault('ERR_OUT_OF_BOUNDS', 'An arc radius component must be above zero.', path);
+ const phi = (command.rotation ?? 0) * DEGREES_TO_RADIANS;
+ const cosPhi = Math.cos(phi);
+ const sinPhi = Math.sin(phi);
+ const dx = (from[0] - to[0]) / 2;
+ const dy = (from[1] - to[1]) / 2;
+ const x1 = cosPhi * dx + sinPhi * dy;
+ const y1 = -sinPhi * dx + cosPhi * dy;
+ let rxs = rx * rx;
+ let rys = ry * ry;
+ const lambda = (x1 * x1) / rxs + (y1 * y1) / rys;
+ let scaledRx = rx;
+ let scaledRy = ry;
+ if (lambda > 1) {
+ const factor = Math.sqrt(lambda);
+ scaledRx = rx * factor;
+ scaledRy = ry * factor;
+ rxs = scaledRx * scaledRx;
+ rys = scaledRy * scaledRy;
+ }
+ const numerator = Math.max(0, rxs * rys - rxs * y1 * y1 - rys * x1 * x1);
+ const denominator = rxs * y1 * y1 + rys * x1 * x1;
+ const coefficient = (command.largeArc === !!command.sweep ? -1 : 1) * Math.sqrt(denominator === 0 ? 0 : numerator / denominator);
+ const cx1 = coefficient * ((scaledRx * y1) / scaledRy);
+ const cy1 = coefficient * (-(scaledRy * x1) / scaledRx);
+ const cx = cosPhi * cx1 - sinPhi * cy1 + (from[0] + to[0]) / 2;
+ const cy = sinPhi * cx1 + cosPhi * cy1 + (from[1] + to[1]) / 2;
+ const angle = (ux, uy, vx, vy) => {
+ const dot = ux * vx + uy * vy;
+ const length = Math.hypot(ux, uy) * Math.hypot(vx, vy);
+ const sign = ux * vy - uy * vx < 0 ? -1 : 1;
+ return sign * Math.acos(Math.max(-1, Math.min(1, dot / (length || 1))));
+ };
+ const start = angle(1, 0, (x1 - cx1) / scaledRx, (y1 - cy1) / scaledRy);
+ let delta = angle((x1 - cx1) / scaledRx, (y1 - cy1) / scaledRy, (-x1 - cx1) / scaledRx, (-y1 - cy1) / scaledRy);
+ if (!command.sweep && delta > 0) delta -= 2 * Math.PI;
+ if (command.sweep && delta < 0) delta += 2 * Math.PI;
+ arcSegments(current, cx, cy, scaledRx, scaledRy, start / DEGREES_TO_RADIANS, Math.abs(delta) / DEGREES_TO_RADIANS, Math.sign(delta) || 1, command.rotation ?? 0);
+}
+
+function pathSubpaths(commands, path = '$') {
+ if (!Array.isArray(commands) || commands.length === 0) throw new RuntimeFault('ERR_INVALID_PATH', 'A path needs at least one command.', path);
+ if (commands[0].op !== 'move') throw new RuntimeFault('ERR_INVALID_PATH', 'A path must begin with a move command.', path);
+ const subpaths = [];
+ let current = null;
+ let cursor = [0, 0];
+ let previousWasClose = false;
+ for (const command of commands) {
+ switch (command.op) {
+ case 'move': {
+ cursor = point(command.to, path);
+ current = subpath(cursor);
+ subpaths.push(current);
+ previousWasClose = false;
+ break;
+ }
+ case 'line': {
+ cursor = point(command.to, path);
+ lineTo(current, cursor);
+ previousWasClose = false;
+ break;
+ }
+ case 'quadratic': {
+ const control = point(command.c, path);
+ const to = point(command.to, path);
+ // Degree-elevate the quadratic so every stored curve is a cubic.
+ cubicTo(current,
+ [cursor[0] + (2 / 3) * (control[0] - cursor[0]), cursor[1] + (2 / 3) * (control[1] - cursor[1])],
+ [to[0] + (2 / 3) * (control[0] - to[0]), to[1] + (2 / 3) * (control[1] - to[1])],
+ to);
+ cursor = to;
+ previousWasClose = false;
+ break;
+ }
+ case 'cubic': {
+ const to = point(command.to, path);
+ cubicTo(current, point(command.c1, path), point(command.c2, path), to);
+ cursor = to;
+ previousWasClose = false;
+ break;
+ }
+ case 'arc': {
+ endpointArc(current, cursor, command, path);
+ cursor = point(command.to, path);
+ previousWasClose = false;
+ break;
+ }
+ case 'close': {
+ if (current === null || current.closed || previousWasClose) throw new RuntimeFault('ERR_INVALID_PATH', 'close has no open subpath.', path);
+ current.closed = true;
+ cursor = current.start.slice();
+ previousWasClose = true;
+ break;
+ }
+ default:
+ throw new RuntimeFault('ERR_INVALID_PATH', `Unknown path command '${command.op}'.`, path);
+ }
+ }
+ return subpaths;
+}
+
+// ---------------------------------------------------------------------------
+// Primitives
+// ---------------------------------------------------------------------------
+
+/**
+ * Build the local-space subpaths of one visual object. `point`, `text`,
+ * `group`, and `component` have no path geometry and return an empty list;
+ * the engine draws them by their own rules.
+ */
+function primitiveSubpaths(object, path = '$') {
+ switch (object.type) {
+ case 'point':
+ case 'text':
+ case 'group':
+ case 'component':
+ return [];
+ case 'line': {
+ const result = subpath([0, 0]);
+ lineTo(result, point(object.to, path));
+ return [result];
+ }
+ case 'bezier': {
+ const result = subpath([0, 0]);
+ cubicTo(result, point(object.c1, path), point(object.c2, path), point(object.to, path));
+ return [result];
+ }
+ case 'polyline':
+ case 'polygon': {
+ const points = (object.points ?? []).map((entry) => point(entry, path));
+ if (points.length < (object.type === 'polygon' ? 3 : 2)) throw new RuntimeFault('ERR_SCHEMA_VALIDATION', `${object.type} needs more points.`, path);
+ const result = linearSpline(points, object.type === 'polygon');
+ return [result];
+ }
+ case 'rectangle': {
+ const width = object.size?.width ?? 0;
+ const height = object.size?.height ?? 0;
+ const result = subpath([0, 0]);
+ lineTo(result, [width, 0]);
+ lineTo(result, [width, height]);
+ lineTo(result, [0, height]);
+ result.closed = true;
+ return [result];
+ }
+ case 'rounded-rectangle': {
+ const width = object.size?.width ?? 0;
+ const height = object.size?.height ?? 0;
+ const limit = Math.min(width, height) / 2;
+ const raw = object.radius;
+ const corners = typeof raw === 'number'
+ ? { topLeft: raw, topRight: raw, bottomRight: raw, bottomLeft: raw }
+ : { topLeft: raw?.topLeft ?? 0, topRight: raw?.topRight ?? 0, bottomRight: raw?.bottomRight ?? 0, bottomLeft: raw?.bottomLeft ?? 0 };
+ const clampRadius = (value) => Math.max(0, Math.min(limit, value));
+ const tl = clampRadius(corners.topLeft);
+ const tr = clampRadius(corners.topRight);
+ const br = clampRadius(corners.bottomRight);
+ const bl = clampRadius(corners.bottomLeft);
+ const result = subpath([tl, 0]);
+ lineTo(result, [width - tr, 0]);
+ if (tr > 0) cubicTo(result, [width - tr + tr * KAPPA, 0], [width, tr - tr * KAPPA], [width, tr]);
+ lineTo(result, [width, height - br]);
+ if (br > 0) cubicTo(result, [width, height - br + br * KAPPA], [width - br + br * KAPPA, height], [width - br, height]);
+ lineTo(result, [bl, height]);
+ if (bl > 0) cubicTo(result, [bl - bl * KAPPA, height], [0, height - bl + bl * KAPPA], [0, height - bl]);
+ lineTo(result, [0, tl]);
+ if (tl > 0) cubicTo(result, [0, tl - tl * KAPPA], [tl - tl * KAPPA, 0], [tl, 0]);
+ result.closed = true;
+ return [result];
+ }
+ case 'ellipse': {
+ const [rx, ry] = radiusPair(object.radius, path);
+ return [ellipseSubpath(0, 0, rx, ry)];
+ }
+ case 'arc': {
+ const [rx, ry] = radiusPair(object.radius, path);
+ const { sweep, sign } = directedSweep(object.startAngle ?? 0, object.endAngle ?? 0, object.direction ?? 'clockwise', path);
+ const startDegrees = object.startAngle ?? 0;
+ const result = subpath([rx * Math.cos(startDegrees * DEGREES_TO_RADIANS), ry * Math.sin(startDegrees * DEGREES_TO_RADIANS)]);
+ arcSegments(result, 0, 0, rx, ry, startDegrees, sweep, sign);
+ return [result];
+ }
+ case 'ring': {
+ const [rx, ry] = radiusPair(object.radius, path);
+ const inner = object.innerRadius ?? 0;
+ if (inner >= Math.min(rx, ry)) throw new RuntimeFault('ERR_INVALID_RANGE_ORDER', 'innerRadius must be less than radius.', path);
+ const startDegrees = object.startAngle ?? 0;
+ const endDegrees = object.endAngle ?? 360;
+ const { sweep, sign } = directedSweep(startDegrees, endDegrees, object.direction ?? 'clockwise', path);
+ const scaleInnerX = inner;
+ const scaleInnerY = inner * (ry / (rx || 1));
+ if (sweep === 360) {
+ // A full annulus is two closed rings; the even-odd or nonzero rule of
+ // the fill leaves the hole, so the inner ring runs the other way.
+ const outer = ellipseSubpath(0, 0, rx, ry);
+ const hole = subpath([scaleInnerX, 0]);
+ arcSegments(hole, 0, 0, scaleInnerX, scaleInnerY, 0, 360, -1);
+ hole.closed = true;
+ return inner > 0 ? [outer, hole] : [outer];
+ }
+ const endAngle = startDegrees + sweep * sign;
+ const result = subpath([rx * Math.cos(startDegrees * DEGREES_TO_RADIANS), ry * Math.sin(startDegrees * DEGREES_TO_RADIANS)]);
+ arcSegments(result, 0, 0, rx, ry, startDegrees, sweep, sign);
+ lineTo(result, [scaleInnerX * Math.cos(endAngle * DEGREES_TO_RADIANS), scaleInnerY * Math.sin(endAngle * DEGREES_TO_RADIANS)]);
+ arcSegments(result, 0, 0, scaleInnerX, scaleInnerY, endAngle, sweep, -sign);
+ result.closed = true;
+ return [result];
+ }
+ case 'path':
+ return pathSubpaths(object.commands, path);
+ case 'spline': {
+ const points = (object.points ?? []).map((entry) => point(entry, path));
+ if (points.length < 2) throw new RuntimeFault('ERR_SCHEMA_VALIDATION', 'A spline needs at least 2 points.', path);
+ const mode = object.mode ?? 'catmull-rom';
+ if (mode === 'linear') return [linearSpline(points, object.closed === true)];
+ if (mode === 'bezier') return [bezierSpline(points, object.closed === true, path)];
+ return [catmullRomSegments(points, object.closed === true, object.tension ?? 0.5)];
+ }
+ default:
+ throw new RuntimeFault('ERR_INVALID_PRIMITIVE_TYPE', `Unknown visual primitive '${object.type}'.`, path);
+ }
+}
+
+/** Every point a subpath list touches, for bounding-box work (17.12 fallback axis). */
+function subpathPoints(subpaths) {
+ const points = [];
+ for (const path of subpaths) {
+ points.push(path.start);
+ for (const segment of path.segments) {
+ if (segment.type === 'cubic') points.push(segment.c1, segment.c2);
+ points.push(segment.to);
+ }
+ }
+ return points;
+}
+
+function boundingBox(subpaths) {
+ const points = subpathPoints(subpaths);
+ if (points.length === 0) return { x: 0, y: 0, width: 0, height: 0 };
+ let minX = Infinity; let minY = Infinity; let maxX = -Infinity; let maxY = -Infinity;
+ for (const [x, y] of points) {
+ if (x < minX) minX = x;
+ if (y < minY) minY = y;
+ if (x > maxX) maxX = x;
+ if (y > maxY) maxY = y;
+ }
+ return { x: minX, y: minY, width: maxX - minX, height: maxY - minY };
+}
+
/* src/runtime/visual-validation.js */
// Structural and semantic validation of the `visuals` block, sections 17-19 at
// Format Specification revision 0.8.
@@ -2626,6 +3292,13 @@ function validateSystems(document, systems, layers, fields, errors, { validateVa
validateSpawn(document, system, lifecycle, path, errors, { validateValueSpec, pushError });
if (system.automation !== undefined && !Array.isArray(system.automation)) fail('ERR_SCHEMA_VALIDATION', `${path}.automation`, 'automation must be an array.');
+
+ // The drawn object tree. 18.2, 18.4, and 18.5 each say the owning system
+ // owns the item's placement, so those fields are unknown on the template.
+ if (system.type === 'graphic') validateVisualObjectMap(document, system.content, `${path}.content`, errors, { validateValueSpec, pushError });
+ if (system.type === 'particles' && system.render !== undefined) validateVisualObject(document, system.render, `${path}.render`, errors, { validateValueSpec, pushError }, { ownedByOwner: ['position', 'z', 'visible'] });
+ if (system.type === 'emitter' && system.emit !== undefined) validateVisualObject(document, system.emit, `${path}.emit`, errors, { validateValueSpec, pushError }, { ownedByOwner: ['position', 'z', 'visible'] });
+ if (system.type === 'repeater' && system.repeat !== undefined) validateVisualObject(document, system.repeat, `${path}.repeat`, errors, { validateValueSpec, pushError }, { ownedByOwner: ['position', 'z'] });
if (Array.isArray(system.fields)) {
if (system.fields.length > AUTHORING.referencedFieldsPerSystem) fail('ERR_VISUAL_LIMIT_EXCEEDED', `${path}.fields`, `A system may reference at most ${AUTHORING.referencedFieldsPerSystem} fields.`);
system.fields.forEach((reference, index) => {
@@ -2809,6 +3482,220 @@ function resolveGraphicTarget(target, system) {
return { code: 'ERR_INVALID_REFERENCE', message: `'${target}' does not name an object in this system's content.` };
}
+// ---------------------------------------------------------------------------
+// Visual objects (17.9, 17.10, 17.12, 17.13)
+// ---------------------------------------------------------------------------
+
+const COMMON_OBJECT_FIELDS = ['type', 'position', 'z', 'transform', 'style', 'behaviors', 'visible', 'lifetime'];
+
+/** Type-specific geometry fields, per the tables of 17.9 and 17.13. */
+const OBJECT_TYPE_FIELDS = Object.freeze({
+ point: [],
+ line: ['to'],
+ polyline: ['points'],
+ polygon: ['points'],
+ rectangle: ['size'],
+ 'rounded-rectangle': ['size', 'radius'],
+ ellipse: ['radius'],
+ arc: ['radius', 'startAngle', 'endAngle', 'direction'],
+ ring: ['radius', 'innerRadius', 'startAngle', 'endAngle', 'direction'],
+ path: ['commands', 'fillRule'],
+ bezier: ['c1', 'c2', 'to'],
+ spline: ['points', 'mode', 'closed', 'tension', 'fillRule'],
+ text: ['text', 'font', 'size', 'weight', 'italic', 'align', 'baseline', 'letterSpacing', 'maxWidth'],
+ group: ['children'],
+ component: ['component', 'inputs']
+});
+
+const REQUIRED_GEOMETRY = Object.freeze({
+ line: ['to'], polyline: ['points'], polygon: ['points'], rectangle: ['size'],
+ 'rounded-rectangle': ['size', 'radius'], ellipse: ['radius'], arc: ['radius'], ring: ['radius'],
+ path: ['commands'], bezier: ['c1', 'c2', 'to'], spline: ['points'], text: ['text'],
+ group: ['children'], component: ['component']
+});
+
+/** 17.12: these four primitives have no interior. */
+const STROKE_ONLY_TYPES = new Set(['line', 'polyline', 'arc', 'bezier']);
+
+const POINT_BOUNDS = Object.freeze({ polyline: [2, 512], polygon: [3, 512], spline: [2, 256] });
+
+const STYLE_FIELDS = new Set(['fill', 'stroke', 'strokeWidth', 'strokeCap', 'strokeJoin', 'strokeDash',
+ 'strokeDashOffset', 'pointSize', 'opacity', 'blend', 'glow', 'shadow', 'blur', 'filters', 'clip', 'mask']);
+const TRANSFORM_FIELDS = new Set(['translate', 'rotation', 'scale', 'skew', 'origin']);
+const PATH_OPS = new Set(['move', 'line', 'quadratic', 'cubic', 'arc', 'close']);
+
+function validateVisualObjectMap(document, container, path, errors, helpers, options = {}) {
+ const { pushError } = helpers;
+ if (!isRecord(container)) return pushError(errors, 'ERR_SCHEMA_VALIDATION', path, 'A visual object container must be an object.');
+ if (Object.keys(container).length === 0) return pushError(errors, 'ERR_SCHEMA_VALIDATION', path, 'An empty object container is not a container.');
+ for (const [key, object] of Object.entries(container)) {
+ if (!ID_PATTERN.test(key)) pushError(errors, 'ERR_INVALID_ID', `${path}.${key}`, `Object key '${key}' is invalid.`);
+ validateVisualObject(document, object, `${path}.${key}`, errors, helpers, { ...options, depth: options.depth ?? 1 });
+ }
+}
+
+function validateVisualObject(document, object, path, errors, helpers, options = {}) {
+ const { validateValueSpec, pushError } = helpers;
+ const fail = (code, location, message) => pushError(errors, code, location, message);
+ if (!isRecord(object)) return fail('ERR_SCHEMA_VALIDATION', path, 'A visual object must be an object.');
+ const type = object.type;
+ if (!Object.hasOwn(OBJECT_TYPE_FIELDS, type)) return fail('ERR_INVALID_PRIMITIVE_TYPE', `${path}.type`, `'${type}' is outside Visual Primitive Set 0.1 and the component object type.`);
+
+ const depth = options.depth ?? 1;
+ if (depth > AUTHORING.groupNesting) fail('ERR_VISUAL_LIMIT_EXCEEDED', path, `Group nesting deeper than ${AUTHORING.groupNesting} levels.`);
+
+ // 17.8: a visual object carries no `id`; its container key is its identity.
+ // 17.10: `layer` is a system property, never an object one.
+ const allowed = new Set([...COMMON_OBJECT_FIELDS, ...OBJECT_TYPE_FIELDS[type]]);
+ const owned = new Set(options.ownedByOwner ?? []);
+ for (const field of Object.keys(object)) {
+ if (!allowed.has(field)) fail('ERR_UNKNOWN_FIELD', `${path}.${field}`, `A '${type}' object declares no '${field}'.`);
+ else if (owned.has(field)) fail('ERR_UNKNOWN_FIELD', `${path}.${field}`, `The owning system owns '${field}'; the drawn object does not declare it.`);
+ }
+ for (const field of REQUIRED_GEOMETRY[type] ?? []) {
+ if (object[field] === undefined) fail('ERR_SCHEMA_VALIDATION', `${path}.${field}`, `A '${type}' object requires '${field}'.`);
+ }
+
+ if (object.behaviors !== undefined) {
+ if (!Array.isArray(object.behaviors)) fail('ERR_SCHEMA_VALIDATION', `${path}.behaviors`, 'behaviors must be an array.');
+ else if (object.behaviors.length > AUTHORING.behaviorsPerObject) fail('ERR_VISUAL_LIMIT_EXCEEDED', `${path}.behaviors`, `At most ${AUTHORING.behaviorsPerObject} behaviors per object.`);
+ }
+ if (object.transform !== undefined) {
+ if (!isRecord(object.transform)) fail('ERR_SCHEMA_VALIDATION', `${path}.transform`, 'transform must be an object.');
+ else for (const field of Object.keys(object.transform)) if (!TRANSFORM_FIELDS.has(field)) fail('ERR_UNKNOWN_FIELD', `${path}.transform.${field}`, `Unrecognized transform field '${field}'.`);
+ }
+
+ const bounds = POINT_BOUNDS[type];
+ if (bounds && object.points !== undefined) {
+ if (!Array.isArray(object.points)) fail('ERR_SCHEMA_VALIDATION', `${path}.points`, 'points must be an array.');
+ else if (object.points.length < bounds[0]) fail('ERR_SCHEMA_VALIDATION', `${path}.points`, `A '${type}' needs at least ${bounds[0]} points.`);
+ else if (object.points.length > bounds[1]) fail('ERR_VISUAL_LIMIT_EXCEEDED', `${path}.points`, `At most ${bounds[1]} points.`);
+ }
+ if (type === 'spline') {
+ const mode = object.mode ?? 'catmull-rom';
+ if (!['catmull-rom', 'bezier', 'linear'].includes(mode)) fail('ERR_SCHEMA_VALIDATION', `${path}.mode`, `Unsupported spline mode '${mode}'.`);
+ // 17.13: `tension` is catmull-rom only.
+ if (object.tension !== undefined && mode !== 'catmull-rom') fail('ERR_UNKNOWN_FIELD', `${path}.tension`, "tension is declared only under the 'catmull-rom' mode.");
+ if (mode === 'bezier' && Array.isArray(object.points) && (object.points.length - 1) % 3 !== 0) {
+ fail('ERR_SCHEMA_VALIDATION', `${path}.points`, 'A bezier-mode spline needs 3n + 1 points.');
+ }
+ }
+ if (type === 'ring' && Number.isFinite(object.innerRadius) && Number.isFinite(object.radius) && object.innerRadius >= object.radius) {
+ fail('ERR_INVALID_RANGE_ORDER', `${path}.innerRadius`, 'innerRadius must be less than radius.');
+ }
+ if ((type === 'arc' || type === 'ring') && object.direction !== undefined && !['clockwise', 'counter-clockwise'].includes(object.direction)) {
+ fail('ERR_SCHEMA_VALIDATION', `${path}.direction`, `Unsupported direction '${object.direction}'.`);
+ }
+ if ((type === 'arc' || type === 'ring') && Number.isFinite(object.startAngle) && Number.isFinite(object.endAngle)) {
+ const delta = (object.direction === 'counter-clockwise' ? object.startAngle - object.endAngle : object.endAngle - object.startAngle);
+ // 17.9: the 360 bound is checked on the authored numbers, before normalization.
+ if (Math.abs(delta) > 360) fail('ERR_OUT_OF_BOUNDS', `${path}.endAngle`, 'An arc sweep may not exceed 360 degrees.');
+ }
+ if (type === 'path') validatePathCommands(object.commands, `${path}.commands`, errors, helpers);
+ if (type === 'text' && typeof object.font === 'string' && !['sans-serif', 'serif', 'monospace'].includes(object.font)) {
+ fail('ERR_SCHEMA_VALIDATION', `${path}.font`, "font is limited to 'sans-serif', 'serif', and 'monospace' (13.3).");
+ }
+
+ validateVisualStyle(document, object, type, path, errors, helpers);
+
+ if (type === 'group') {
+ validateVisualObjectMap(document, object.children, `${path}.children`, errors, helpers, { ...options, depth: depth + 1, ownedByOwner: [] });
+ if (object.style?.mask !== undefined && object.style.mask !== null) {
+ if (!isRecord(object.children) || !Object.hasOwn(object.children, object.style.mask)) {
+ fail('ERR_INVALID_REFERENCE', `${path}.style.mask`, `mask '${object.style.mask}' names no child of this group.`);
+ }
+ }
+ }
+}
+
+function validateVisualStyle(document, object, type, path, errors, helpers) {
+ const { validateValueSpec, pushError } = helpers;
+ const fail = (code, location, message) => pushError(errors, code, location, message);
+ const style = object.style;
+ if (style === undefined) return;
+ if (!isRecord(style)) return fail('ERR_SCHEMA_VALIDATION', `${path}.style`, 'style must be an object.');
+ for (const field of Object.keys(style)) if (!STYLE_FIELDS.has(field)) fail('ERR_UNKNOWN_FIELD', `${path}.style.${field}`, `Unrecognized style field '${field}'.`);
+ // 17.12: a `fill` *declared* on a stroke-only primitive is an error; one
+ // *inherited* is ignored for it, which is a runtime rule, not a validation one.
+ if (STROKE_ONLY_TYPES.has(type) && Object.hasOwn(style, 'fill')) {
+ fail('ERR_UNKNOWN_FIELD', `${path}.style.fill`, `A '${type}' has no interior, so it declares no fill.`);
+ }
+ // 17.12: `mask` is legal only on a group.
+ if (Object.hasOwn(style, 'mask') && type !== 'group') fail('ERR_UNKNOWN_FIELD', `${path}.style.mask`, 'mask is declared only on a group.');
+ if (style.blend !== undefined && !BLEND_MODES.includes(style.blend)) fail('ERR_SCHEMA_VALIDATION', `${path}.style.blend`, `Unsupported blend mode '${style.blend}'.`);
+ if (style.strokeCap !== undefined && !['butt', 'round', 'square'].includes(style.strokeCap)) fail('ERR_SCHEMA_VALIDATION', `${path}.style.strokeCap`, `Unsupported strokeCap '${style.strokeCap}'.`);
+ if (style.strokeJoin !== undefined && !['miter', 'round', 'bevel'].includes(style.strokeJoin)) fail('ERR_SCHEMA_VALIDATION', `${path}.style.strokeJoin`, `Unsupported strokeJoin '${style.strokeJoin}'.`);
+ if (style.strokeDash !== undefined) {
+ if (!Array.isArray(style.strokeDash) || style.strokeDash.length < 1) fail('ERR_SCHEMA_VALIDATION', `${path}.style.strokeDash`, 'strokeDash must be a non-empty array.');
+ else if (style.strokeDash.length > AUTHORING.strokeDashEntries) fail('ERR_VISUAL_LIMIT_EXCEEDED', `${path}.style.strokeDash`, `At most ${AUTHORING.strokeDashEntries} strokeDash entries.`);
+ }
+ if (style.filters !== undefined) {
+ if (!Array.isArray(style.filters)) fail('ERR_SCHEMA_VALIDATION', `${path}.style.filters`, 'filters must be an array.');
+ else if (style.filters.length > AUTHORING.filtersPerObject) fail('ERR_VISUAL_LIMIT_EXCEEDED', `${path}.style.filters`, `At most ${AUTHORING.filtersPerObject} filters per object.`);
+ else style.filters.forEach((entry, index) => {
+ if (!isRecord(entry) || !FILTER_TYPES.includes(entry.type)) fail('ERR_SCHEMA_VALIDATION', `${path}.style.filters[${index}].type`, 'Unsupported filter type.');
+ });
+ }
+ for (const role of ['fill', 'stroke']) validatePaint(document, style[role], `${path}.style.${role}`, errors, helpers);
+ for (const role of ['glow', 'shadow']) {
+ const entry = style[role];
+ if (entry === undefined || entry === null) continue;
+ if (!isRecord(entry)) fail('ERR_SCHEMA_VALIDATION', `${path}.style.${role}`, `${role} must be an object or null.`);
+ else if (entry.color !== undefined) validateValueSpec(document, entry.color, `${path}.style.${role}.color`, errors);
+ }
+ if (isRecord(style.clip) && !['rectangle', 'ellipse'].includes(style.clip.shape)) {
+ fail('ERR_SCHEMA_VALIDATION', `${path}.style.clip.shape`, "A clip shape is 'rectangle' or 'ellipse'.");
+ }
+}
+
+function validatePaint(document, paint, path, errors, helpers) {
+ const { validateValueSpec, pushError } = helpers;
+ if (paint === undefined || paint === null) return;
+ if (!isRecord(paint) || Object.hasOwn(paint, 'ref') || Object.hasOwn(paint, 'random') || Object.hasOwn(paint, 'choose') || Object.hasOwn(paint, 'op')) {
+ // 17.12 (V5): a color leaf is a ValueSpec; it never returns a paint object.
+ return validateValueSpec(document, paint, path, errors);
+ }
+ const fail = (code, location, message) => pushError(errors, code, location, message);
+ if (!['linear-gradient', 'radial-gradient', 'conic-gradient'].includes(paint.type)) {
+ return fail('ERR_SCHEMA_VALIDATION', `${path}.type`, `Unsupported paint type '${paint.type}'.`);
+ }
+ if (!Array.isArray(paint.stops) || paint.stops.length < 2) return fail('ERR_SCHEMA_VALIDATION', `${path}.stops`, 'A gradient needs at least 2 stops.');
+ if (paint.stops.length > AUTHORING.gradientStops) fail('ERR_VISUAL_LIMIT_EXCEEDED', `${path}.stops`, `At most ${AUTHORING.gradientStops} gradient stops.`);
+ let previous = -Infinity;
+ paint.stops.forEach((stop, index) => {
+ if (!isRecord(stop) || !Number.isFinite(stop.offset)) return fail('ERR_SCHEMA_VALIDATION', `${path}.stops[${index}]`, 'A stop needs a numeric offset and a color.');
+ if (stop.offset < 0 || stop.offset > 1) fail('ERR_OUT_OF_BOUNDS', `${path}.stops[${index}].offset`, 'A stop offset is between 0 and 1.');
+ if (stop.offset <= previous) fail('ERR_INVALID_RANGE_ORDER', `${path}.stops[${index}].offset`, 'Stop offsets must be strictly increasing.');
+ previous = stop.offset;
+ validateValueSpec(document, stop.color, `${path}.stops[${index}].color`, errors);
+ });
+}
+
+function validatePathCommands(commands, path, errors, { pushError }) {
+ const fail = (code, location, message) => pushError(errors, code, location, message);
+ if (!Array.isArray(commands) || commands.length === 0) return fail('ERR_SCHEMA_VALIDATION', path, 'A path needs at least one command.');
+ if (commands.length > AUTHORING.pathCommands) fail('ERR_VISUAL_LIMIT_EXCEEDED', path, `At most ${AUTHORING.pathCommands} path commands.`);
+ if (!isRecord(commands[0]) || commands[0].op !== 'move') fail('ERR_INVALID_PATH', `${path}[0]`, 'A path must begin with a move command.');
+ let open = false;
+ commands.forEach((command, index) => {
+ if (!isRecord(command) || !PATH_OPS.has(command.op)) return fail('ERR_INVALID_PATH', `${path}[${index}].op`, `Unknown path command '${command?.op}'.`);
+ if (command.op === 'move') open = true;
+ else if (command.op === 'close') {
+ if (!open) fail('ERR_INVALID_PATH', `${path}[${index}]`, 'close has no open subpath.');
+ open = false;
+ }
+ if (command.op === 'arc') {
+ const radius = command.radius;
+ const components = typeof radius === 'number' ? [radius] : [radius?.x, radius?.y];
+ // 17.13: a zero or negative radius component is ERR_OUT_OF_BOUNDS. It is
+ // not silently degraded to a line.
+ if (components.some((value) => !Number.isFinite(value) || value <= 0)) {
+ fail('ERR_OUT_OF_BOUNDS', `${path}[${index}].radius`, 'An arc radius component must be above zero.');
+ }
+ }
+ });
+}
+
/* src/runtime/validator.js */
function issue(code, path, message, context = {}) {
return { code, path, message, ...context };
@@ -3683,6 +4570,968 @@ class ResolutionEngine {
}
}
+/* src/runtime/visual-engine.js */
+// Slice 4d — the visual renderer core.
+//
+// The engine is renderer-neutral by construction (17.1): it resolves an
+// exhibit's declared visual objects once at their instantiation boundary
+// (17.14), composes the scene-to-device chain of 19.3, sorts by depth (17.6),
+// applies depth fog and the compositing order of 17.12, and emits a **frame
+// plan** — an ordered list of draw operations in device coordinates. A backend
+// (visual-canvas2d.js) turns that plan into drawing calls.
+//
+// The plan is the testable surface. Every automated trace of 17.16 asks for a
+// numeric oracle — a known scene point at a known display point, a documented
+// perspective factor, a fog fraction, a sort order — and a plan carries those
+// as data, where a canvas carries only pixels.
+
+
+
+
+
+
+const RUNTIME = VISUAL_LIMITS.runtime;
+
+const DEFAULT_STYLE = Object.freeze({
+ fill: null, stroke: null, strokeWidth: 1, strokeCap: 'butt', strokeJoin: 'miter',
+ strokeDash: undefined, strokeDashOffset: 0, pointSize: 1, opacity: 1, blend: 'normal',
+ glow: null, shadow: null, blur: 0, filters: [], clip: null, mask: null
+});
+
+// 17.12: inheritance is per field. `opacity` is deliberately absent — it
+// *multiplies* with any inherited group opacity rather than replacing it, and
+// is accumulated separately as the effective alpha of the compositing order.
+const INHERITED_STYLE_FIELDS = Object.freeze([
+ 'fill', 'stroke', 'strokeWidth', 'strokeCap', 'strokeJoin', 'strokeDash', 'strokeDashOffset',
+ 'pointSize', 'blend', 'glow', 'shadow', 'blur', 'filters'
+]);
+
+/** 17.12: `line`, `polyline`, `arc`, and `bezier` have no interior. */
+const STROKE_ONLY = new Set(['line', 'polyline', 'arc', 'bezier']);
+
+// ---------------------------------------------------------------------------
+// Instantiation (17.14)
+// ---------------------------------------------------------------------------
+
+function isValueSpec(value) {
+ if (!isRecord(value)) return false;
+ if (Object.hasOwn(value, 'ref') || Object.hasOwn(value, 'random') || Object.hasOwn(value, 'choose')) return true;
+ // A ValueSpec `op` carries `args`; a path command's `op` is a command token.
+ return Object.hasOwn(value, 'op') && Array.isArray(value.args);
+}
+
+/**
+ * Resolve every ValueSpec in a raw subtree, depth-first in property-document
+ * order, from one stream — the sampling rule 17.14 shares with 14.4 and 9.3.
+ * Everything that is not a ValueSpec is copied through unchanged.
+ */
+function sampleTree(raw, resolver, stream, path = '$') {
+ if (Array.isArray(raw)) return raw.map((entry, index) => sampleTree(entry, resolver, stream, `${path}[${index}]`));
+ if (!isRecord(raw)) return raw;
+ if (isValueSpec(raw)) return resolver.evaluate(raw, stream, path);
+ const result = {};
+ for (const key of Object.keys(raw)) result[key] = sampleTree(raw[key], resolver, stream, `${path}.${key}`);
+ return result;
+}
+
+function mergeStyle(inherited, own) {
+ if (!isRecord(own)) return inherited;
+ const merged = { ...inherited };
+ for (const field of INHERITED_STYLE_FIELDS) {
+ if (Object.hasOwn(own, field)) merged[field] = own[field];
+ }
+ // `clip` and `mask` are local: a clip intersects with any inherited one and a
+ // mask names a child of its own group (17.12), so neither inherits downward.
+ merged.clip = Object.hasOwn(own, 'clip') ? own.clip : null;
+ merged.mask = Object.hasOwn(own, 'mask') ? own.mask : null;
+ return merged;
+}
+
+function vector(value, fallback = 0) {
+ return { x: value?.x ?? fallback, y: value?.y ?? fallback, z: value?.z ?? fallback };
+}
+
+/** One instantiated visual object: resolved values, its local matrix, its subtree. */
+function instantiateObject(key, raw, context, parentPath, depth) {
+ if (!isRecord(raw)) throw new RuntimeFault('ERR_SCHEMA_VALIDATION', 'A visual object must be an object.', parentPath);
+ const path = parentPath ? `${parentPath}.${key}` : key;
+ if (depth > VISUAL_LIMITS.authoring.groupNesting) {
+ throw new RuntimeFault('ERR_VISUAL_LIMIT_EXCEEDED', `Group nesting deeper than ${VISUAL_LIMITS.authoring.groupNesting} levels.`, path);
+ }
+ const resolved = sampleTree(raw, context.resolver, context.stream, path);
+ const transform = resolved.transform ?? {};
+ const node = {
+ key,
+ path,
+ type: resolved.type,
+ raw: resolved,
+ position: { x: resolved.position?.x ?? 0, y: resolved.position?.y ?? 0 },
+ z: resolved.z ?? 0,
+ translateZ: transform.translate?.z ?? 0,
+ visible: resolved.visible ?? true,
+ matrix: localMatrix({
+ position: { x: resolved.position?.x ?? 0, y: resolved.position?.y ?? 0 },
+ translate: vector(transform.translate),
+ rotation: transform.rotation ?? 0,
+ skew: vector(transform.skew),
+ scale: { x: transform.scale?.x ?? 1, y: transform.scale?.y ?? 1 },
+ origin: vector(transform.origin)
+ }),
+ style: mergeStyle(context.style, resolved.style),
+ // 17.12: opacity multiplies down the tree rather than being inherited.
+ ownOpacity: typeof resolved.style?.opacity === 'number' ? resolved.style.opacity : 1,
+ children: []
+ };
+ if (resolved.type === 'group') {
+ const previousStyle = context.style;
+ context.style = node.style;
+ for (const childKey of Object.keys(resolved.children ?? {})) {
+ node.children.push(instantiateObject(childKey, raw.children[childKey], context, path, depth + 1));
+ }
+ context.style = previousStyle;
+ }
+ // 17.12: a `fill` declared on a stroke-only primitive is a validation error;
+ // one inherited from an ancestor is ignored for it and still applies to its
+ // fillable siblings. Inheritance never turns into a validation error.
+ if (STROKE_ONLY.has(node.type)) node.style = { ...node.style, fill: null };
+ node.geometry = primitiveSubpaths(resolved, path);
+ return node;
+}
+
+// ---------------------------------------------------------------------------
+// Display, fit, and camera (17.4, 19.3)
+// ---------------------------------------------------------------------------
+
+/** 19.3: the fit matrix F, carrying the centering offsets contain and cover need. */
+function resolveFit(scene, displayWidth, displayHeight) {
+ const space = scene?.coordinateSpace ?? 'virtual';
+ const sceneWidth = space === 'virtual' ? scene.width : space === 'normalized' ? 1 : displayWidth;
+ const sceneHeight = space === 'virtual' ? scene.height : space === 'normalized' ? 1 : displayHeight;
+ let sx = 1;
+ let sy = 1;
+ if (space !== 'viewport') {
+ // 17.4 (V17): in `viewport` the fit field takes no default and has no
+ // mapping effect, so the scale stays 1 whatever was authored.
+ const fit = scene?.fit ?? 'contain';
+ if (fit === 'stretch') {
+ sx = displayWidth / sceneWidth;
+ sy = displayHeight / sceneHeight;
+ } else {
+ const uniform = fit === 'cover'
+ ? Math.max(displayWidth / sceneWidth, displayHeight / sceneHeight)
+ : Math.min(displayWidth / sceneWidth, displayHeight / sceneHeight);
+ sx = uniform;
+ sy = uniform;
+ }
+ }
+ const ox = (displayWidth - sx * sceneWidth) / 2;
+ const oy = (displayHeight - sy * sceneHeight) / 2;
+ return { space, sceneWidth, sceneHeight, sx, sy, ox, oy, matrix: multiply(translation(ox, oy), scaling(sx, sy)) };
+}
+
+/**
+ * 19.5 and A3: the device-pixel multiplier is `min(devicePixelRatio, 2)`,
+ * lowered — below `1` where a display larger than 4096 requires it — until the
+ * backing store fits `4096 x 4096`.
+ */
+function resolveBackingStore(displayWidth, displayHeight, devicePixelRatio) {
+ const requested = Math.min(devicePixelRatio, RUNTIME.devicePixelRatioCeiling);
+ const longest = Math.max(displayWidth, displayHeight);
+ const permitted = longest > 0 ? RUNTIME.backingStoreEdge / longest : requested;
+ const multiplier = Math.min(requested, permitted);
+ return {
+ multiplier,
+ reduced: multiplier < requested,
+ below1: multiplier < 1,
+ width: Math.max(1, Math.round(displayWidth * multiplier)),
+ height: Math.max(1, Math.round(displayHeight * multiplier))
+ };
+}
+
+/** 19.3: `V = T(c) x S(zoom) x R(-rotation) x T(-c) x T(-p * (q - c))`. */
+function cameraMatrix({ center, zoom, rotationDegrees, cameraCss, parallax }) {
+ const [cx, cy] = center;
+ const [qx, qy] = cameraCss;
+ return compose(
+ translation(cx, cy),
+ scaling(zoom, zoom),
+ rotation(-rotationDegrees),
+ translation(-cx, -cy),
+ translation(-parallax * (qx - cx), -parallax * (qy - cy))
+ );
+}
+
+// ---------------------------------------------------------------------------
+// The engine
+// ---------------------------------------------------------------------------
+
+class VisualEngine {
+ constructor(document, { resolution = null, rng = null, diagnostics = null, capabilities = {} } = {}) {
+ this.document = document;
+ this.resolution = resolution;
+ this.rng = rng;
+ this.capabilities = { conicGradient: true, ...capabilities };
+ this.cadence = new VisualDiagnosticCadence(diagnostics);
+ this.visuals = document?.visuals ?? null;
+ this.systems = [];
+ this.layers = [];
+ this.deferred = [];
+ this.deferredKeys = new Set();
+ if (this.visuals) this.instantiate();
+ }
+
+ /** 17.14: activation is the instantiation boundary of a persistent system. */
+ instantiate() {
+ const visuals = this.visuals;
+ const declared = visuals.layers ? Object.keys(visuals.layers) : [];
+ this.layers = declared.length > 0
+ ? declared.map((id) => ({ id, ...visuals.layers[id] }))
+ : [{ id: null, implicit: true }];
+
+ const resolver = this.resolution?.valueResolver;
+ this.systems = [];
+ this.deferred = [];
+ this.deferredKeys = new Set();
+ let index = 0;
+ for (const [id, system] of Object.entries(visuals.systems ?? {})) {
+ // 19.2: a spawned system is a template. It is validated and counted at
+ // import and is not instantiated or drawn at activation.
+ if ((system.lifecycle ?? 'persistent') === 'spawned') {
+ this.defer(id, 'spawned template, instantiated only by a spawn action (19.2)');
+ continue;
+ }
+ if (system.type !== 'graphic') {
+ // 18.2-18.5 procedural systems execute in slice 4e. Their schema is
+ // carried and validated; nothing is drawn for them here.
+ this.defer(id, `'${system.type}' systems execute in slice 4e (18.2-18.5)`);
+ continue;
+ }
+ const stream = this.rng ? this.rng.stream('visual', `visuals.systems.${id}`) : null;
+ const context = { resolver, stream, style: DEFAULT_STYLE };
+ const objects = [];
+ let objectIndex = 0;
+ for (const key of Object.keys(system.content ?? {})) {
+ const node = instantiateObject(key, system.content[key], context, `visuals.systems.${id}.content`, 1);
+ node.order = objectIndex;
+ objectIndex += 1;
+ objects.push(node);
+ }
+ this.systems.push({ id, index, layer: system.layer ?? null, visible: system.visible ?? true, objects });
+ index += 1;
+ }
+ return this;
+ }
+
+ defer(id, reason) {
+ if (this.deferredKeys.has(id)) return;
+ this.deferredKeys.add(id);
+ this.deferred.push({ id, reason });
+ }
+
+ cameraValue(field) {
+ const path = `visuals.camera.${field}`;
+ if (this.resolution) {
+ try { return this.resolution.get(path); } catch { /* fall through to the authored default */ }
+ }
+ const authored = this.visuals?.camera?.[field];
+ if (typeof authored === 'number') return authored;
+ return CAMERA_FIELDS[field].default ?? 0;
+ }
+
+ layerValue(layer, field, fallback) {
+ if (layer.id && this.resolution) {
+ try { return this.resolution.get(`visuals.layers.${layer.id}.${field}`); } catch { /* authored value below */ }
+ }
+ const authored = layer[field];
+ return authored === undefined || isRecord(authored) ? fallback : authored;
+ }
+
+ systemVisible(system) {
+ if (this.resolution) {
+ try { return this.resolution.get(`visuals.systems.${system.id}.visible`) !== false; } catch { /* authored value below */ }
+ }
+ return system.visible !== false;
+ }
+
+ /**
+ * Build one frame's plan. `width` and `height` are the display rectangle in
+ * CSS pixels; everything in the returned plan is in device pixels.
+ */
+ planFrame({ width, height, devicePixelRatio = 1, logicalMilliseconds = 0 } = {}) {
+ if (!this.visuals) return null;
+ const scene = this.visuals.scene ?? {};
+ const fit = resolveFit(scene, width, height);
+ const backing = resolveBackingStore(width, height, devicePixelRatio);
+ if (backing.below1) {
+ this.cadence.once('WARN_VISUAL_APPROXIMATION', `backing-store:${backing.width}x${backing.height}`,
+ `The display exceeds ${RUNTIME.backingStoreEdge} device pixels, so the frame is rendered at a multiplier of ${backing.multiplier.toFixed(4)} and upsampled.`);
+ }
+ const center = [width / 2, height / 2];
+ const B = scaling(backing.multiplier, backing.multiplier);
+
+ const projection = this.visuals.camera?.projection ?? 'orthographic';
+ const zoom = this.cameraValue('zoom');
+ const rotationDegrees = this.cameraValue('rotation');
+ const focalLength = clamp(this.cameraValue('focalLength'), CAMERA_FIELDS.focalLength.min, CAMERA_FIELDS.focalLength.max);
+ let cameraX = this.cameraValue('x');
+ let cameraY = this.cameraValue('y');
+ if (fit.space === 'viewport' && this.visuals.camera?.x === undefined) cameraX = center[0];
+ if (fit.space === 'viewport' && this.visuals.camera?.y === undefined) cameraY = center[1];
+ const cameraCss = fit.space === 'viewport' ? [cameraX, cameraY] : apply(fit.matrix, cameraX, cameraY);
+
+ const fog = this.resolveFog(scene);
+ // `stats` is shared by reference: each unit is emitted with a shallow copy
+ // of this context carrying its own layer view matrix, so per-frame counters
+ // have to live behind a reference rather than on the copy.
+ const context = {
+ fit, backing, B, center, projection, zoom, focalLength, fog,
+ logicalMilliseconds, buffers: [], stats: { culled: 0 }
+ };
+
+ const layers = [];
+ for (const layer of this.layers) {
+ const parallax = this.layerValue(layer, 'parallax', 1);
+ const view = cameraMatrix({ center, zoom, rotationDegrees, cameraCss, parallax });
+ const opacity = clamp(this.layerValue(layer, 'opacity', 1), 0, 1);
+ const visible = this.layerValue(layer, 'visible', true) !== false;
+ const blend = layer.blend ?? 'normal';
+ const units = this.sortableUnits(layer);
+ const nodes = [];
+ for (const unit of units) {
+ const plan = this.emitNode(unit.node, IDENTITY, 0, 1, { ...context, view, layerId: layer.id });
+ if (!plan) continue;
+ // The plan node is pushed by reference: buffer allocation runs after
+ // every layer is emitted and mutates the nodes it refuses.
+ plan.representativeDepth = unit.representativeDepth;
+ nodes.push(plan);
+ }
+ layers.push({
+ id: layer.id, opacity, blend, visible, parallax,
+ // 17.12: a layer whose opacity or blend is not the default needs a buffer.
+ buffered: opacity !== 1 || blend !== 'normal',
+ nodes
+ });
+ }
+
+ this.allocateBuffers(layers, context);
+ this.cadence.endTick();
+
+ return {
+ background: formatColor(parseColor(scene.background ?? '#000000', '$.visuals.scene.background')),
+ display: { width, height },
+ backing,
+ fit,
+ camera: { x: cameraX, y: cameraY, zoom, rotation: rotationDegrees, projection, focalLength, cameraCss, center },
+ fog,
+ layers,
+ culled: context.stats.culled,
+ buffers: context.bufferSummary,
+ deferred: this.deferred,
+ diagnostics: this.cadence.raised.slice()
+ };
+ }
+
+ resolveFog(scene) {
+ const declared = scene.depthFog;
+ if (!declared) return null;
+ const near = declared.near ?? 0;
+ const far = declared.far;
+ if (!(far > near)) throw new RuntimeFault('ERR_INVALID_RANGE_ORDER', 'depthFog.far must exceed depthFog.near.', '$.visuals.scene.depthFog');
+ return { color: parseColor(declared.color, '$.visuals.scene.depthFog.color'), near, far, density: declared.density ?? 1 };
+ }
+
+ /**
+ * 17.6: within a layer the sortable units are each `graphic` system's
+ * top-level objects and each procedural system as one atomic unit. They sort
+ * by representative depth descending, ties by system key order then object
+ * key order, and the sort is stable.
+ */
+ sortableUnits(layer) {
+ const units = [];
+ for (const system of this.systems) {
+ if ((system.layer ?? null) !== (layer.id ?? null)) continue;
+ if (!this.systemVisible(system)) continue;
+ for (const node of system.objects) {
+ units.push({ node, representativeDepth: node.z + node.translateZ, systemIndex: system.index, objectIndex: node.order });
+ }
+ }
+ return units.sort((a, b) => (b.representativeDepth - a.representativeDepth)
+ || (a.systemIndex - b.systemIndex)
+ || (a.objectIndex - b.objectIndex));
+ }
+
+ // -------------------------------------------------------------------------
+ // Emitting one object's plan node (17.6, 17.11, 17.12, 19.3)
+ // -------------------------------------------------------------------------
+
+ emitNode(node, parentMatrix, parentZ, parentAlpha, ctx) {
+ const zEffective = parentZ + node.z + node.translateZ;
+ // 17.7/17.10: a hidden object and its descendants are not drawn; their
+ // behaviors and automation still advance, which is not this pass's concern.
+ if (node.visible === false) return null;
+ if (ctx.projection === 'perspective' && zEffective <= -ctx.focalLength) {
+ // 17.6: at or behind the eye. Culled for the frame, no diagnostic.
+ ctx.stats.culled += 1;
+ return null;
+ }
+ if (node.type === 'component') {
+ // Recorded once, not once per frame: a plan is built every frame and the
+ // deferral is a property of the document, not of the frame.
+ this.defer(node.path, 'component expansion lands in slice 4e (18.1)');
+ return null;
+ }
+ const matrix = multiply(parentMatrix, node.matrix);
+ const alpha = parentAlpha * node.ownOpacity;
+ const k = ctx.projection === 'perspective' ? ctx.focalLength / (ctx.focalLength + zEffective) : 1;
+ // 19.3: the one uniform factor an axis-less appearance dimension takes.
+ const g = ctx.zoom * k * Math.sqrt(ctx.fit.sx * ctx.fit.sy);
+ const scalar = (value) => (value ?? 0) * g * ctx.backing.multiplier;
+ const project = (px, py, pz = 0) => {
+ let [x, y] = apply(matrix, px, py);
+ [x, y] = apply(ctx.fit.matrix, x, y);
+ [x, y] = apply(ctx.view, x, y);
+ if (ctx.projection === 'perspective') {
+ const factor = ctx.focalLength / (ctx.focalLength + zEffective + pz);
+ x = ctx.center[0] + (x - ctx.center[0]) * factor;
+ y = ctx.center[1] + (y - ctx.center[1]) * factor;
+ }
+ return apply(ctx.B, x, y);
+ };
+
+ const style = node.style;
+ const fogFraction = ctx.fog
+ ? ctx.fog.density * clamp((zEffective - ctx.fog.near) / (ctx.fog.far - ctx.fog.near), 0, 1)
+ : 0;
+ const shade = (value, path) => {
+ const parsed = parseColor(value, path);
+ return formatColor(ctx.fog ? fogColor(parsed, ctx.fog.color, fogFraction) : parsed);
+ };
+
+ const plan = {
+ kind: node.type === 'group' ? 'group' : node.type === 'text' ? 'text' : node.type === 'point' ? 'point' : 'shape',
+ path: node.path,
+ type: node.type,
+ z: zEffective,
+ perspective: k,
+ alpha,
+ blend: style.blend ?? 'normal',
+ fill: this.buildPaint(style.fill, node, project, scalar, shade, ctx, 'fill'),
+ stroke: this.buildPaint(style.stroke, node, project, scalar, shade, ctx, 'stroke'),
+ strokeWidth: scalar(style.strokeWidth ?? 1),
+ strokeCap: style.strokeCap ?? 'butt',
+ strokeJoin: style.strokeJoin ?? 'miter',
+ strokeDash: Array.isArray(style.strokeDash) ? style.strokeDash.map(scalar) : undefined,
+ strokeDashOffset: scalar(style.strokeDashOffset ?? 0),
+ glow: style.glow ? { color: shade(style.glow.color, `${node.path}.style.glow.color`), radius: scalar(style.glow.radius), strength: style.glow.strength ?? 1 } : null,
+ shadow: style.shadow ? {
+ color: shade(style.shadow.color, `${node.path}.style.shadow.color`),
+ blurRadius: scalar(style.shadow.blurRadius ?? 0),
+ offsetX: scalar(style.shadow.offsetX ?? 0),
+ offsetY: scalar(style.shadow.offsetY ?? 0)
+ } : null,
+ blur: scalar(style.blur ?? 0),
+ filters: Array.isArray(style.filters) ? style.filters.map((entry) => ({ ...entry })) : [],
+ clip: this.buildClip(style.clip, project),
+ fogFraction,
+ children: [],
+ mask: null
+ };
+
+ if (node.type === 'group') {
+ const maskKey = style.mask ?? null;
+ for (const child of node.children) {
+ const childPlan = this.emitNode(child, matrix, zEffective, alpha, ctx);
+ if (!childPlan) continue;
+ // 17.12: the named child is not drawn; its rendered alpha multiplies
+ // the alpha of the group's remaining children.
+ if (maskKey !== null && child.key === maskKey) plan.mask = childPlan;
+ else plan.children.push(childPlan);
+ }
+ if (maskKey !== null && plan.mask === null) {
+ throw new RuntimeFault('ERR_INVALID_REFERENCE', `mask '${maskKey}' names no child of this group.`, node.path);
+ }
+ } else if (node.type === 'point') {
+ plan.center = project(0, 0);
+ plan.diameter = scalar(style.pointSize ?? 1);
+ } else if (node.type === 'text') {
+ // Text is the one primitive drawn by the backend's own text engine, so it
+ // carries the matrix rather than transformed geometry.
+ plan.matrix = this.deviceMatrix(matrix, zEffective, ctx);
+ plan.text = String(node.raw.text ?? '');
+ if (plan.text.length > VISUAL_LIMITS.authoring.textCharacters) {
+ throw new RuntimeFault('ERR_OUT_OF_BOUNDS', `Resolved text exceeds ${VISUAL_LIMITS.authoring.textCharacters} characters.`, node.path);
+ }
+ plan.font = node.raw.font ?? 'sans-serif';
+ plan.size = node.raw.size ?? 16;
+ plan.weight = node.raw.weight ?? 'normal';
+ plan.italic = node.raw.italic === true;
+ plan.align = node.raw.align ?? 'left';
+ plan.baseline = node.raw.baseline ?? 'alphabetic';
+ plan.letterSpacing = node.raw.letterSpacing ?? 0;
+ plan.maxWidth = node.raw.maxWidth;
+ if (plan.maxWidth !== undefined && !(plan.maxWidth > 0)) {
+ throw new RuntimeFault('ERR_OUT_OF_BOUNDS', 'maxWidth must be above zero.', node.path);
+ }
+ } else {
+ plan.subpaths = node.geometry.map((subpath) => ({
+ closed: subpath.closed,
+ start: project(subpath.start[0], subpath.start[1], subpath.start[2] ?? 0),
+ segments: subpath.segments.map((segment) => segment.type === 'line'
+ ? { type: 'line', to: project(segment.to[0], segment.to[1], segment.to[2] ?? 0) }
+ : {
+ type: 'cubic',
+ c1: project(segment.c1[0], segment.c1[1], segment.c1[2] ?? 0),
+ c2: project(segment.c2[0], segment.c2[1], segment.c2[2] ?? 0),
+ to: project(segment.to[0], segment.to[1], segment.to[2] ?? 0)
+ })
+ }));
+ plan.fillRule = node.raw.fillRule ?? 'nonzero';
+ }
+
+ // 17.12: a non-default blend, a mask, a blur, a glow, or any filters entry
+ // requires an offscreen buffer for the object or group.
+ plan.needsBuffer = plan.blend !== 'normal' || plan.mask !== null || plan.blur > 0 || plan.glow !== null || plan.filters.length > 0;
+ if (plan.needsBuffer) ctx.buffers.push({ plan, depth: zEffective, order: ctx.buffers.length });
+ return plan;
+ }
+
+ /** The single affine chain for a constant-depth object: `B x P x V x F x M`. */
+ deviceMatrix(matrix, zEffective, ctx) {
+ const k = ctx.projection === 'perspective' ? ctx.focalLength / (ctx.focalLength + zEffective) : 1;
+ const perspective = compose(translation(ctx.center[0], ctx.center[1]), scaling(k, k), translation(-ctx.center[0], -ctx.center[1]));
+ return compose(ctx.B, perspective, ctx.view, ctx.fit.matrix, matrix);
+ }
+
+ buildClip(clip, project) {
+ if (!isRecord(clip)) return null;
+ const { shape, x, y, width, height } = clip;
+ if (shape === 'ellipse') {
+ const cx = x + width / 2;
+ const cy = y + height / 2;
+ return { shape: 'ellipse', center: project(cx, cy), corners: [project(x, y), project(x + width, y), project(x + width, y + height), project(x, y + height)] };
+ }
+ return { shape: 'rectangle', corners: [project(x, y), project(x + width, y), project(x + width, y + height), project(x, y + height)] };
+ }
+
+ /**
+ * 17.12: a paint is a color, a gradient object, or null. Gradient stops are
+ * fogged individually before the paint is constructed, so a fogged gradient
+ * keeps its offsets and its shape and loses only its contrast.
+ */
+ buildPaint(paint, node, project, scalar, shade, ctx, role) {
+ if (paint === null || paint === undefined) return null;
+ if (typeof paint === 'string') return { type: 'color', color: shade(paint, `${node.path}.style.${role}`) };
+ if (!isRecord(paint)) throw new RuntimeFault('ERR_TYPE_MISMATCH', 'A paint must be a color, a paint object, or null.', node.path);
+ const stops = (paint.stops ?? []).map((stop) => ({ offset: stop.offset, color: shade(stop.color, `${node.path}.style.${role}.stops`) }));
+ if (paint.type === 'linear-gradient') {
+ return { type: 'linear-gradient', stops, from: project(paint.from?.x ?? 0, paint.from?.y ?? 0), to: project(paint.to?.x ?? 0, paint.to?.y ?? 0) };
+ }
+ if (paint.type === 'radial-gradient') {
+ return {
+ type: 'radial-gradient', stops,
+ center: project(paint.center?.x ?? 0, paint.center?.y ?? 0),
+ radius: scalar(paint.radius ?? 0),
+ innerRadius: scalar(paint.innerRadius ?? 0)
+ };
+ }
+ if (paint.type === 'conic-gradient') {
+ const center = { x: paint.center?.x ?? 0, y: paint.center?.y ?? 0 };
+ const angle = paint.angle ?? 0;
+ if (this.capabilities.conicGradient) {
+ return { type: 'conic-gradient', stops, center: project(center.x, center.y), angle };
+ }
+ return this.conicFallback(node, stops, center, angle, project, `${node.path}.style.${role}`);
+ }
+ throw new RuntimeFault('ERR_SCHEMA_VALIDATION', `Unknown paint type '${paint.type}'.`, node.path);
+ }
+
+ /**
+ * 17.12: the documented conic fallback. The axis runs from `center` along
+ * `angle` to the local-space bounding box's boundary; where the center is
+ * outside the box or the ray never meets it, it runs to the distance of the
+ * farthest corner, which reduces to the intersection case when one exists.
+ * The stops and their offsets are preserved exactly.
+ */
+ conicFallback(node, stops, center, angle, project, path) {
+ const box = boundingBox(node.geometry);
+ const corners = [[box.x, box.y], [box.x + box.width, box.y], [box.x + box.width, box.y + box.height], [box.x, box.y + box.height]];
+ const distance = Math.max(...corners.map(([x, y]) => Math.hypot(x - center.x, y - center.y)), 0);
+ this.cadence.once('WARN_VISUAL_APPROXIMATION', path,
+ 'The renderer has no conic gradient; the documented linear fallback was used with the same stops.');
+ if (distance === 0) {
+ // A degenerate box has no axis. A flat first stop is a materially milder
+ // substitution than omitting the paint.
+ return { type: 'color', color: stops[0]?.color ?? '#000000', approximated: true };
+ }
+ const radians = angle * (Math.PI / 180);
+ return {
+ type: 'linear-gradient', stops, approximated: true,
+ from: project(center.x, center.y),
+ to: project(center.x + distance * Math.cos(radians), center.y + distance * Math.sin(radians))
+ };
+ }
+
+ /**
+ * 17.12 and 19.5: sixteen buffer allocations per frame. Layer buffers are
+ * allocated first, then object buffers nearest-first, so what runs out is the
+ * far content and the refusals are taken farthest-first.
+ */
+ allocateBuffers(layers, ctx) {
+ const layerBuffers = layers.filter((layer) => layer.visible && layer.buffered).length;
+ let budget = Math.max(0, RUNTIME.offscreenBuffersPerFrame - layerBuffers);
+ const candidates = ctx.buffers.slice().sort((a, b) => (a.depth - b.depth) || (a.order - b.order));
+ let granted = 0;
+ let shed = 0;
+ for (const candidate of candidates) {
+ if (budget > 0) {
+ candidate.plan.buffered = true;
+ budget -= 1;
+ granted += 1;
+ continue;
+ }
+ // Refused: the owner draws without its buffer-requiring features. This is
+ // a diagnosed exception to 17.1, not a silent omission.
+ const plan = candidate.plan;
+ plan.buffered = false;
+ plan.shed = true;
+ plan.blend = 'normal';
+ plan.mask = null;
+ plan.blur = 0;
+ plan.glow = null;
+ plan.filters = [];
+ shed += 1;
+ this.cadence.shed('WARN_VISUAL_APPROXIMATION', 'offscreen-buffers',
+ `The frame needs more than ${RUNTIME.offscreenBuffersPerFrame} offscreen buffers; the farthest objects drew without their buffered appearance features.`,
+ ctx.logicalMilliseconds);
+ }
+ ctx.bufferSummary = { limit: RUNTIME.offscreenBuffersPerFrame, layers: layerBuffers, granted, shed };
+ }
+
+}
+
+/* src/runtime/visual-canvas2d.js */
+// Canvas 2D backend for the frame plans of visual-engine.js.
+//
+// XZBT 0.1 realizes the renderer-neutral schema of 17.1 with Canvas 2D (PRD
+// 69). The plan arrives in device pixels with every scalar already converted by
+// the uniform factor of 19.3, so this module sets no transform for geometry: it
+// strokes with an explicit device-pixel `lineWidth`, which is what keeps a
+// stroke from being distorted by a nonuniform `stretch` fit. Text is the one
+// exception and carries its own matrix.
+
+const BLEND_OPERATIONS = Object.freeze({
+ normal: 'source-over', add: 'lighter', screen: 'screen', multiply: 'multiply',
+ overlay: 'overlay', lighten: 'lighten', darken: 'darken', difference: 'difference'
+});
+
+const FILTER_UNITS = Object.freeze({
+ brightness: '', contrast: '', saturate: '', 'hue-rotate': 'deg', grayscale: '', sepia: '', invert: ''
+});
+
+function tracePath(context, node) {
+ context.beginPath();
+ for (const subpath of node.subpaths ?? []) {
+ context.moveTo(subpath.start[0], subpath.start[1]);
+ for (const segment of subpath.segments) {
+ if (segment.type === 'line') context.lineTo(segment.to[0], segment.to[1]);
+ else context.bezierCurveTo(segment.c1[0], segment.c1[1], segment.c2[0], segment.c2[1], segment.to[0], segment.to[1]);
+ }
+ if (subpath.closed) context.closePath();
+ }
+}
+
+function buildPaint(context, paint) {
+ if (!paint) return null;
+ if (paint.type === 'color') return paint.color;
+ let gradient;
+ if (paint.type === 'linear-gradient') {
+ gradient = context.createLinearGradient(paint.from[0], paint.from[1], paint.to[0], paint.to[1]);
+ } else if (paint.type === 'radial-gradient') {
+ gradient = context.createRadialGradient(paint.center[0], paint.center[1], paint.innerRadius ?? 0, paint.center[0], paint.center[1], paint.radius);
+ } else if (paint.type === 'conic-gradient') {
+ if (typeof context.createConicGradient !== 'function') return paint.stops[0]?.color ?? null;
+ gradient = context.createConicGradient((paint.angle ?? 0) * (Math.PI / 180), paint.center[0], paint.center[1]);
+ } else {
+ return null;
+ }
+ for (const stop of paint.stops ?? []) gradient.addColorStop(stop.offset, stop.color);
+ return gradient;
+}
+
+function filterString(node) {
+ const parts = [];
+ for (const entry of node.filters ?? []) {
+ const unit = FILTER_UNITS[entry.type] ?? '';
+ parts.push(`${entry.type}(${entry.amount ?? 0}${unit})`);
+ }
+ if (node.blur > 0) parts.push(`blur(${node.blur}px)`);
+ return parts.join(' ');
+}
+
+function applyClip(context, clip) {
+ if (!clip) return;
+ context.beginPath();
+ const [a, b, c, d] = clip.corners;
+ if (clip.shape === 'ellipse') {
+ // The clip rectangle's device corners describe the ellipse's bounding
+ // parallelogram; its axes are the half-diagonals of that shape.
+ const cx = clip.center[0];
+ const cy = clip.center[1];
+ const rx = Math.hypot(b[0] - a[0], b[1] - a[1]) / 2;
+ const ry = Math.hypot(d[0] - a[0], d[1] - a[1]) / 2;
+ const angle = Math.atan2(b[1] - a[1], b[0] - a[0]);
+ context.ellipse(cx, cy, rx, ry, angle, 0, Math.PI * 2);
+ } else {
+ context.moveTo(a[0], a[1]);
+ context.lineTo(b[0], b[1]);
+ context.lineTo(c[0], c[1]);
+ context.lineTo(d[0], d[1]);
+ context.closePath();
+ }
+ context.clip();
+}
+
+/**
+ * The compositing order of 17.12, stage for stage: shadow, geometry, glow,
+ * blur, filters, alpha, clip and mask, blend.
+ */
+function drawNode(context, node, surface) {
+ if (node.alpha === 0) return;
+ context.save();
+ context.globalAlpha = node.alpha;
+ context.globalCompositeOperation = BLEND_OPERATIONS[node.blend] ?? 'source-over';
+ applyClip(context, node.clip);
+ const filters = filterString(node);
+ if (filters && 'filter' in context) context.filter = filters;
+
+ if (node.kind === 'group') {
+ if (node.mask) {
+ const masked = surface.create();
+ for (const child of node.children) drawNode(masked.context, child, surface);
+ masked.context.save();
+ masked.context.globalCompositeOperation = 'destination-in';
+ drawNode(masked.context, node.mask, surface);
+ masked.context.restore();
+ context.drawImage(masked.canvas, 0, 0);
+ surface.release(masked);
+ } else {
+ for (const child of node.children) drawNode(context, child, surface);
+ }
+ context.restore();
+ return;
+ }
+
+ if (node.shadow) {
+ context.shadowColor = node.shadow.color;
+ context.shadowBlur = node.shadow.blurRadius;
+ context.shadowOffsetX = node.shadow.offsetX;
+ context.shadowOffsetY = node.shadow.offsetY;
+ }
+
+ if (node.kind === 'point') {
+ context.beginPath();
+ context.arc(node.center[0], node.center[1], node.diameter / 2, 0, Math.PI * 2);
+ if (node.fill) { context.fillStyle = buildPaint(context, node.fill); context.fill(); }
+ if (node.stroke && node.strokeWidth > 0) {
+ context.strokeStyle = buildPaint(context, node.stroke);
+ context.lineWidth = node.strokeWidth;
+ context.stroke();
+ }
+ } else if (node.kind === 'text') {
+ const matrix = node.matrix;
+ context.setTransform(matrix[0], matrix[1], matrix[2], matrix[3], matrix[4], matrix[5]);
+ context.font = `${node.italic ? 'italic ' : ''}${node.weight === 'bold' ? 'bold ' : ''}${node.size}px ${node.font}`;
+ context.textAlign = node.align === 'center' ? 'center' : node.align === 'right' ? 'right' : 'left';
+ context.textBaseline = node.baseline;
+ if ('letterSpacing' in context) context.letterSpacing = `${node.letterSpacing}px`;
+ // 17.13: `maxWidth` condenses horizontally about the align anchor and never
+ // wraps or truncates. Canvas 2D's own maxWidth argument does exactly that.
+ if (node.fill) { context.fillStyle = buildPaint(context, node.fill); context.fillText(node.text, 0, 0, node.maxWidth); }
+ if (node.stroke && node.strokeWidth > 0) {
+ context.strokeStyle = buildPaint(context, node.stroke);
+ context.lineWidth = node.strokeWidth;
+ context.strokeText(node.text, 0, 0, node.maxWidth);
+ }
+ } else {
+ tracePath(context, node);
+ if (node.fill) {
+ context.fillStyle = buildPaint(context, node.fill);
+ context.fill(node.fillRule === 'evenodd' ? 'evenodd' : 'nonzero');
+ }
+ if (node.stroke && node.strokeWidth > 0) {
+ context.strokeStyle = buildPaint(context, node.stroke);
+ context.lineWidth = node.strokeWidth;
+ context.lineCap = node.strokeCap;
+ context.lineJoin = node.strokeJoin;
+ if (node.strokeDash) context.setLineDash(node.strokeDash);
+ context.lineDashOffset = node.strokeDashOffset;
+ context.stroke();
+ }
+ }
+
+ // 17.12: glow is added around the drawn result. Canvas 2D expresses it as a
+ // zero-offset shadow, which is the documented realization, not a substitution.
+ if (node.glow && node.glow.strength > 0) {
+ context.save();
+ context.globalAlpha = node.alpha * node.glow.strength;
+ context.shadowColor = node.glow.color;
+ context.shadowBlur = node.glow.radius;
+ context.shadowOffsetX = 0;
+ context.shadowOffsetY = 0;
+ if (node.kind === 'point') {
+ context.beginPath();
+ context.arc(node.center[0], node.center[1], node.diameter / 2, 0, Math.PI * 2);
+ context.fillStyle = node.glow.color;
+ context.fill();
+ } else if (node.kind !== 'text') {
+ tracePath(context, node);
+ context.strokeStyle = node.glow.color;
+ context.lineWidth = Math.max(node.strokeWidth, 1);
+ context.stroke();
+ }
+ context.restore();
+ }
+
+ context.restore();
+}
+
+/** A pool of offscreen surfaces for buffered layers, masks, and groups. */
+class SurfacePool {
+ constructor(factory, width, height) {
+ this.factory = factory;
+ this.width = width;
+ this.height = height;
+ this.free = [];
+ this.allocated = 0;
+ }
+
+ create() {
+ const reused = this.free.pop();
+ if (reused) {
+ reused.context.setTransform(1, 0, 0, 1, 0, 0);
+ reused.context.clearRect(0, 0, this.width, this.height);
+ return reused;
+ }
+ this.allocated += 1;
+ const canvas = this.factory(this.width, this.height);
+ return { canvas, context: canvas.getContext('2d') };
+ }
+
+ release(surface) { this.free.push(surface); }
+}
+
+/**
+ * Render one frame plan into a Canvas 2D context. `createSurface(w, h)` returns
+ * an offscreen canvas; in a browser that is `new OffscreenCanvas(w, h)` or a
+ * detached `