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
This commit is contained in:
2026-09-06 21:53:13 +00:00
co-authored by Claude Opus 5
parent 0af58da89d
commit 8b00f5c4a0
20 changed files with 2928 additions and 74 deletions
+58 -9
View File
@@ -7,6 +7,8 @@
// 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'
@@ -85,6 +87,33 @@ function applyClip(context, clip) {
*/
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';
@@ -157,25 +186,44 @@ function drawNode(context, node, surface) {
}
}
// 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.
// 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;
context.shadowOffsetX = 0;
// 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 = node.glow.color;
context.fillStyle = 'rgba(0,0,0,1)';
context.fill();
} else if (node.kind !== 'text') {
} 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);
context.strokeStyle = node.glow.color;
context.lineWidth = Math.max(node.strokeWidth, 1);
context.stroke();
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();
}
@@ -213,7 +261,7 @@ export class SurfacePool {
* an offscreen canvas; in a browser that is `new OffscreenCanvas(w, h)` or a
* detached `<canvas>`.
*/
export function renderFrame(context, plan, { createSurface } = {}) {
export function renderFrame(context, plan, { createSurface, warnEffect } = {}) {
const { width, height } = plan.backing;
context.setTransform(1, 0, 0, 1, 0, 0);
context.globalAlpha = 1;
@@ -238,5 +286,6 @@ export function renderFrame(context, plan, { createSurface } = {}) {
surfaces.release(target);
}
}
renderVisualEffects(context, plan, warnEffect);
return { allocated: surfaces?.allocated ?? 0 };
}