feat(visual): implement the slice 4d renderer core

The first visual code that draws. Sections 17.4 through 17.14 are implemented
against Format Specification revision 0.8, and the standalone artifact now has a
stage canvas that renders a declared scene.

The renderer is split so that the automated traces of 17.16 can run without a
display, which is what those traces require. visual-engine.js resolves an
exhibit's objects once at their instantiation boundary, composes the
scene-to-device chain, sorts by depth, applies fog and the compositing order,
and emits a **frame plan** in device pixels; visual-canvas2d.js turns a plan
into drawing calls. A plan carries the numeric oracles the traces ask for - a
known scene point at a known display point, a perspective factor, a fog
fraction, a sort order - where a canvas carries only pixels.

visual-math.js implements the affine matrices, the normative local transform of
17.11, the directed arc sweep of 17.9 with its bound checked before
normalization, and section 2 color parsing with the fog and ramp arithmetic of
17.6 and 18.2. Colors outside the four documented forms are ERR_TYPE_MISMATCH:
exact components are what make per-object fog renderer-independent.

visual-geometry.js reduces all fourteen primitives to subpaths of lines and
cubic Beziers, so an affine map carries geometry exactly rather than
approximately. It implements the local extents and anchors of 17.9 - a
rectangle anchored top-left, circular primitives centered - the uniform cardinal
form of catmull-rom with its open-duplication and closed-wrap index rules, the
straight closing segment of a closed bezier spline, and endpoint-parameterized
elliptical arcs.

The composition chain of 19.3 is implemented as written: the fit matrix carries
the contain and cover centering offsets, the camera center is mapped into CSS
coordinates before the translation is formed, perspective acts about the
projection center, and the device-pixel multiplier is applied exactly once and
drops below 1 on a display larger than 4096. Scalars with no axis - stroke
widths, radii, dash lengths - take the single uniform factor g, so a nonuniform
stretch never distorts a stroke.

Depth follows 17.6: effective depth accumulates z and translate.z up the tree,
sortable units order farthest-first with the documented tie-break, a point's own
z projects that point without making it a sortable unit, and an object at or
behind the eye is culled with no diagnostic. Compositing follows 17.12: opacity
multiplies down the tree while layer opacity applies once at the layer, and the
sixteen buffer allocations per frame are granted nearest-first so refusals fall
on the far content, each diagnosed under the 19.5 cadence rather than omitted
silently.

visual-validation.js gains the object tree: the fifteen object types and their
declared fields, no `id` and no `layer` on an object, a declared fill on a
stroke-only primitive, a mask only on a group and only naming its own child,
path legality, spline modes and point counts, gradient stop order, and every
authoring limit of 19.5 that applies to a drawn object.

While implementing V10 I corrected the buffer-accounting rule I had written into
17.12: counting buffers as concurrently live made 19.5's farthest-first shedding
nearly unreachable, since group nesting is already capped at 8. The rule is now
16 allocations per frame, which is the per-frame reading "pass budget" has
carried since 17.5 first used it. The arc-sweep prose is likewise corrected to
normalize the signed delta, so 350 -> 10 clockwise is the 20-degree sweep the
text always claimed rather than 340 the long way round.

test/phase4-renderer.test.mjs adds 22 tests covering traces 1 through 13 of
17.16 plus buffer shedding and the backend's call order. npm test goes from 131
to 153 passing. Traces 15 and 16 remain user-observed, and no procedural system,
automation track, or post-effect executes yet - 4e and 4f own those.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_0162Jb1J36judZNT8fHabGVt
This commit is contained in:
2026-09-06 16:38:44 +00:00
co-authored by Claude Opus 5
parent 699492f844
commit 0af58da89d
16 changed files with 4303 additions and 5 deletions
+242
View File
@@ -0,0 +1,242 @@
// 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. */
export 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 `<canvas>`.
*/
export function renderFrame(context, plan, { createSurface } = {}) {
const { width, height } = plan.backing;
context.setTransform(1, 0, 0, 1, 0, 0);
context.globalAlpha = 1;
context.globalCompositeOperation = 'source-over';
if ('filter' in context) context.filter = 'none';
context.fillStyle = plan.background;
context.fillRect(0, 0, width, height);
const surfaces = createSurface ? new SurfacePool(createSurface, width, height) : null;
for (const layer of plan.layers) {
if (!layer.visible || layer.opacity === 0) continue;
const buffered = layer.buffered && surfaces !== null;
const target = buffered ? surfaces.create() : null;
const layerContext = buffered ? target.context : context;
for (const node of layer.nodes) drawNode(layerContext, node, surfaces ?? { create: () => target, release: () => {} });
if (buffered) {
context.save();
context.globalAlpha = layer.opacity;
context.globalCompositeOperation = BLEND_OPERATIONS[layer.blend] ?? 'source-over';
context.drawImage(target.canvas, 0, 0);
context.restore();
surfaces.release(target);
}
}
return { allocated: surfaces?.allocated ?? 0 };
}