Files
XZBT/src/runtime/visual-canvas2d.js
T
LabyricornandClaude Opus 5 8b00f5c4a0 feat(visual): implement the slice 4e procedural systems and behaviors
The normative coherent noise of 18.7 (ordered gradients, a 255-sample
Fisher-Yates permutation, octave normalization, and curl as the explicit
perpendicular of the potential's gradient, with published tolerances), the nine
distributions with their normative draw-order table, all seventeen behaviors
with their field contracts and the accumulating-versus-fresh split, component
expansion independent of group nesting depth, particles, emitters, repeaters,
trails, and links. Traces 1 through 19 of 18.10 are automated.

The renderer core this builds on is corrected here rather than separately,
because 4e is what exercises it: the production render path now passes a real
compositing surface factory, so a masked group or a non-opaque layer composites
instead of throwing and permanently deactivating the visual subsystem;
system-level `behaviors` arrays run `validateBehaviors` at import with field
context, so the six classes of invalid document that used to fail at activation
now fail where an author can see them; velocity-accumulating behaviors integrate
on object and repeater hosts; `morph` guards both sides of the z delta rather
than poisoning perspective projection with NaN; `face-motion` initializes its
follow state lazily and holds the previous rotation at zero velocity; a `ring`
with equal start and end angles draws nothing instead of a radial spoke; a
resolved non-integer creation count raises ERR_TYPE_MISMATCH at the boundary
instead of being rounded; boolean leaves are type-checked; per-type required
fields and the static half of the unbounded-emission rule are enforced at
import; and a repeater rejects the emitter-only fields 18.5 says it has none of.

A duration is now the authored literal or a non-negative finite number already
in milliseconds, resolving the one design question the review triage left open.
Runtime, validator, and specification agree on it.

A guard test asserts that no two bundled modules declare the same top-level
identifier: the bundler concatenates into one scope, so a private helper name
collision is a SyntaxError in the artifact while every unit test still passes.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01ShxxFqFmCUDQnQvFNm4TKy
2026-09-06 21:53:13 +00:00

292 lines
11 KiB
JavaScript

// 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.
import { renderVisualEffects } from './visual-effects.js';
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;
// B1: When node.buffered is true, rasterize into a pool surface with default
// state, then composite once with the node's alpha/blend (§17.12 stage order).
if (node.buffered && surface && typeof surface.create === 'function') {
const target = surface.create();
if (target?.context) {
target.context.save();
target.context.globalAlpha = 1;
target.context.globalCompositeOperation = 'source-over';
const unbuffered = { ...node, buffered: false, alpha: 1, blend: 'normal', clip: null };
drawNode(target.context, unbuffered, surface);
target.context.restore();
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;
context.drawImage(target.canvas, 0, 0);
context.restore();
surface.release(target);
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 drawn *around* the result, not over it. We offset the
// geometry far off-screen so only the blurred shadow (the halo) appears.
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;
// Offset geometry far away; counter-offset the shadow so it lands in place.
const glowShift = 1e5;
context.shadowOffsetX = -glowShift;
context.shadowOffsetY = 0;
context.translate(glowShift, 0);
if (node.kind === 'point') {
context.beginPath();
context.arc(node.center[0], node.center[1], node.diameter / 2, 0, Math.PI * 2);
context.fillStyle = 'rgba(0,0,0,1)';
context.fill();
} else if (node.kind === 'text') {
const matrix = node.matrix;
context.setTransform(matrix[0], matrix[1], matrix[2], matrix[3], matrix[4] + glowShift, matrix[5]);
context.shadowOffsetX = -glowShift;
context.shadowOffsetY = 0;
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;
context.fillStyle = 'rgba(0,0,0,1)';
context.fillText(node.text, 0, 0, node.maxWidth);
} else {
tracePath(context, node);
if (node.fill) {
context.fillStyle = 'rgba(0,0,0,1)';
context.fill(node.fillRule === 'evenodd' ? 'evenodd' : 'nonzero');
}
if (node.stroke && node.strokeWidth > 0) {
context.strokeStyle = 'rgba(0,0,0,1)';
context.lineWidth = node.strokeWidth;
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, warnEffect } = {}) {
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);
}
}
renderVisualEffects(context, plan, warnEffect);
return { allocated: surfaces?.allocated ?? 0 };
}