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
292 lines
11 KiB
JavaScript
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 };
|
|
}
|