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:
@@ -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 };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user