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
@@ -0,0 +1,11 @@
# Slice 4e: procedural execution
Implemented on September 6, 2026, extending the renderer and procedural changes already present in the workspace.
`visual-systems.js`, `visual-distributions.js`, `visual-behaviors.js`, `visual-fields.js`, `visual-noise.js`, and `visual-motion.js` implement components, particles, emitters, repeaters, placement, behavior motion, fields, trails/ribbons, and links. The production application advances them on logical ticks. Render frames do not advance simulation or consume procedural streams.
The nineteen section 18.10 tests in `test/phase4-procedural.test.mjs` pass. Added execution tests check graphic drift, repeated-object motion, morphing geometry, propagated channels, and rendering across different frame rates. This closes a gap in the original procedural work: behavior arithmetic alone did not prove that its results reached draw geometry. Scoped expressions inside component/repeater inputs and distribution configuration sampling now follow their owning instantiation boundary.
Initial resolved populations cannot exceed their system bounds; emission batches retain the newest capacity-sized population without an unbounded construction loop. Aggregate protection is recorded with 4f.
Run `node --test --test-isolation=none test/phase4-*.test.mjs`. Display judgment remains separate and unperformed by the agent.
+2 -1
View File
@@ -6,7 +6,8 @@ export const RNG_DOMAINS = Object.freeze([
'scenario',
'visual',
'sound',
'manual-sample'
'manual-sample',
'sample'
]);
export const DATABASE = Object.freeze({
+10
View File
@@ -179,6 +179,16 @@ export class ResolutionEngine {
this.parameters.set(id, valueMatchesType(spec.type, candidate, spec) ? normalizeForSpec(spec, candidate, { clampNumeric: true }) : spec.default);
}
for (const [id, spec] of Object.entries(document.state ?? {})) this.state.set(id, spec.initial);
this.visualEffects = (document.visuals?.effects ?? []).map((effect, index) => {
const stream = this.rng.stream('visual', `effects[${index}]`);
const resolved = { type: effect.type };
for (const [key, value] of Object.entries(effect)) {
if (key === 'type') continue;
resolved[key] = this.valueResolver.evaluate(value, stream, `visuals.effects[${index}].${key}`);
if (POST_EFFECTS[effect.type]?.numeric[key]) this.visualValues.set(`visuals.effects[${index}].${key}`, resolved[key]);
}
return resolved;
});
// Bus ValueSpecs are sampled once, lazily, so references may resolve through
// the same dependency graph as bindings (and cycles are diagnosed there).
this.resolveAll();
+7 -1
View File
@@ -53,7 +53,13 @@ export function easingValue(name, progress) {
export function lerp(from, to, amount) { return from + ((to - from) * amount); }
export function parseDuration(value, path = '$') {
if (typeof value !== 'string') throw new RuntimeFault('ERR_INVALID_DURATION', 'Duration must be a single-unit string.', path);
// C5 (6.1): a value that is already a number is a non-negative count of
// milliseconds and passes through. The literal form remains the authored one.
if (typeof value === 'number') {
if (!Number.isFinite(value) || value < 0) throw new RuntimeFault('ERR_INVALID_DURATION', `Duration ${value} is not a non-negative finite number of milliseconds.`, path);
return value;
}
if (typeof value !== 'string') throw new RuntimeFault('ERR_INVALID_DURATION', 'Duration must be a duration literal or a finite number.', path);
const match = DURATION_PATTERN.exec(value);
if (!match) throw new RuntimeFault('ERR_INVALID_DURATION', `Invalid duration '${value}'.`, path);
const scalar = Number(match[1]);
+2
View File
@@ -1,4 +1,5 @@
import { validateAudioSubsystem } from './audio-graph.js';
import { validateCadenceSubsystem } from './cadence-validation.js';
import { matchVisualTarget } from './visual-contract.js';
import { validateVisualSubsystem } from './visual-validation.js';
import {
@@ -92,6 +93,7 @@ export function validateExhibit(document, filename = 'document.xzbt') {
validateBindings(document, errors);
validateAudioSubsystem(document, errors, { validateValueSpec, pushError });
validateVisualSubsystem(document, errors, { validateValueSpec, pushError });
validateCadenceSubsystem(document, errors, { validateValueSpec, validateCondition, pushError });
return { valid: errors.length === 0, errors, warnings: [], filename };
}
+374
View File
@@ -0,0 +1,374 @@
// Visual Behavior Set 0.1 — the seventeen behaviors of 18.6.
//
// A behavior's *configuration* resolves once at its owning object's
// instantiation boundary; its *effect* varies with logical time. Behaviors
// advance on the fixed tick of 9.1, after the particle integration of 18.2 and
// in array order, and compose by accumulation on position, z, and rotation, by
// multiplication on scale and opacity, and last-writer-wins elsewhere.
//
// The accumulating-versus-fresh division of 18.6 is the load-bearing rule here:
// an orbit recomputed from `t` traces its ellipse, while an orbit added as a
// fresh displacement each tick integrates into an outward spiral.
import { RuntimeFault } from './types.js';
import { DEGREES_TO_RADIANS } from './visual-math.js';
import { noiseOffsets, octaveNoise } from './visual-noise.js';
export const BEHAVIOR_TYPES = Object.freeze([
'drift', 'rotate', 'oscillate', 'orbit', 'wander', 'follow-path', 'point-wander',
'pulse', 'twinkle', 'noise-displace', 'face-motion', 'wrap', 'bounce',
'attract', 'repel', 'field-follow', 'morph'
]);
/** 18.6: the channel set `oscillate`, `pulse`, and `twinkle` may name. */
export const PROPERTY_CHANNELS = Object.freeze([
'position.x', 'position.y', 'z',
'transform.rotation', 'transform.scale.x', 'transform.scale.y',
'style.opacity', 'style.strokeWidth', 'style.pointSize',
'size.width', 'size.height', 'radius'
]);
/**
* 18.6/19.1: each behavior's write set as *scalar* channels, so a behavior and
* an automation track on one channel can be detected as ERR_AUTOMATION_CONFLICT.
* `velocity.*` and `points[*].*` are outside the automatable registry, so the
* behaviors that write only those can never collide with a track.
*/
export function behaviorChannels(behavior) {
switch (behavior?.type) {
case 'drift': case 'noise-displace': return ['position.x', 'position.y', 'z'];
case 'field-follow': return (behavior.mode ?? 'force') === 'direct' ? ['position.x', 'position.y', 'z'] : ['velocity.x', 'velocity.y', 'velocity.z'];
case 'orbit': case 'wander': case 'wrap': return ['position.x', 'position.y'];
case 'rotate': case 'face-motion': return ['transform.rotation'];
case 'follow-path': return behavior.align === true ? ['position.x', 'position.y', 'transform.rotation'] : ['position.x', 'position.y'];
case 'oscillate': case 'pulse': return [behavior.property];
case 'twinkle': return [behavior.property ?? 'style.opacity'];
case 'bounce': case 'attract': case 'repel': return ['velocity.x', 'velocity.y', 'velocity.z'];
case 'point-wander': case 'morph': return ['points[*].x', 'points[*].y', 'points[*].z'];
default: return [];
}
}
// ---------------------------------------------------------------------------
// Waveforms and curves (18.6, 18.2)
// ---------------------------------------------------------------------------
const frac = (value) => value - Math.floor(value);
export function waveform(name, phi) {
switch (name ?? 'sine') {
case 'sine': return Math.sin(2 * Math.PI * phi);
case 'triangle': return 1 - 4 * Math.abs(frac(phi + 0.25) - 0.5);
case 'square': return phi < 0.5 ? 1 : -1;
case 'sawtooth': return 2 * frac(phi + 0.5) - 1;
default: throw new RuntimeFault('ERR_SCHEMA_VALIDATION', `Unknown waveform '${name}'.`);
}
}
/** The four-curve set 18.2 fixes for ramps and 18.6 reuses for `pulse`. */
export function curveValue(name, t) {
const u = Math.max(0, Math.min(1, t));
switch (name ?? 'linear') {
case 'step': return u < 1 ? 0 : 1;
case 'linear': return u;
case 'exponential': return u * u;
case 'smooth': return u * u * (3 - 2 * u);
default: throw new RuntimeFault('ERR_SCHEMA_VALIDATION', `Unknown curve '${name}'.`);
}
}
// ---------------------------------------------------------------------------
// Instances
// ---------------------------------------------------------------------------
/**
* Create one behavior instance. `wander`, `twinkle`, `point-wander`, and
* `noise-displace` each draw exactly one offset pair here and never sample
* again — the whole of their stream consumption (18.6).
*/
/** 18.6: which behaviors accumulate their contribution and which recompute it. */
const ACCUMULATING = new Set(['drift', 'rotate', 'wander', 'bounce', 'attract', 'repel', 'wrap']);
export function isAccumulating(behavior) {
if (behavior?.type === 'field-follow') return (behavior.mode ?? 'force') !== 'direct';
return ACCUMULATING.has(behavior?.type);
}
export function createBehavior(specification, { stream = null, noiseTable = null, path = '$' } = {}) {
if (!BEHAVIOR_TYPES.includes(specification?.type)) {
throw new RuntimeFault('ERR_INVALID_BEHAVIOR_TYPE', `'${specification?.type}' is outside Visual Behavior Set 0.1.`, path);
}
const instance = { spec: specification, type: specification.type, path, noiseTable, state: {}, accumulating: isAccumulating(specification) };
if (['wander', 'twinkle', 'point-wander', 'noise-displace'].includes(specification.type)) {
if (!stream) throw new RuntimeFault('ERR_SCHEMA_VALIDATION', 'A noise behavior requires an owning random stream.', path);
instance.offsets = noiseOffsets(stream);
}
if (specification.type === 'drift') instance.state.velocity = { ...vector(specification.velocity) };
if (specification.type === 'wander') instance.state.velocity = { x: 0, y: 0 };
return instance;
}
function vector(value, fallback = 0) {
return { x: value?.x ?? fallback, y: value?.y ?? fallback, z: value?.z ?? fallback };
}
function bounds(specification, scene) {
const declared = specification.bounds;
if (declared === undefined || declared === 'scene') return scene;
return { x: declared.x ?? 0, y: declared.y ?? 0, width: declared.width ?? 0, height: declared.height ?? 0 };
}
function writeChannel(item, channel, value) {
switch (channel) {
case 'position.x': item.offset.x += value; break;
case 'position.y': item.offset.y += value; break;
case 'z': item.offset.z += value; break;
case 'transform.rotation': item.rotationOffset += value; break;
case 'transform.scale.x': item.scale.x *= value; break;
case 'transform.scale.y': item.scale.y *= value; break;
case 'style.opacity': item.opacityMultiplier *= value; break;
default: item.channels[channel] = value; break;
}
}
/**
* `oscillate`, `pulse`, and `twinkle` write one named channel. Additive
* channels accumulate, multiplicative ones multiply, and the rest are
* last-writer-wins, exactly as 18.6 states.
*/
function writeNamedChannel(item, channel, absolute, additive) {
if (channel === 'transform.scale.x' || channel === 'transform.scale.y' || channel === 'style.opacity') {
writeChannel(item, channel, absolute);
return;
}
if (['position.x', 'position.y', 'z', 'transform.rotation'].includes(channel)) {
writeChannel(item, channel, additive);
return;
}
item.channels[channel] = absolute;
}
/**
* Advance one behavior by `dt` logical seconds at logical time `t`, measured
* from the owning object's instantiation boundary.
*/
export function advanceBehavior(instance, item, { dt, time, env = {} }) {
const spec = instance.spec;
switch (instance.type) {
case 'drift': {
// Accumulating: the contribution grows by velocity * dt, with the
// velocity decaying first.
const damping = spec.damping ?? 0;
const velocity = instance.state.velocity;
const decay = (1 - damping) ** dt;
velocity.x *= decay; velocity.y *= decay; velocity.z *= decay;
item.offset.x += velocity.x * dt;
item.offset.y += velocity.y * dt;
item.offset.z += velocity.z * dt;
break;
}
case 'rotate': {
item.rotationOffset += (spec.speed ?? 0) * dt;
break;
}
case 'oscillate': {
const phi = frac((spec.frequency ?? 1) * time + (spec.phase ?? 0) / 360);
const value = (spec.center ?? 0) + (spec.amplitude ?? 0) * waveform(spec.waveform, phi);
writeNamedChannel(item, spec.property, value, value);
break;
}
case 'orbit': {
// Fresh every tick: an absolute placement expressed as an offset from the
// resolved base position.
const theta = ((spec.speed ?? 0) * time + (spec.phase ?? 0)) * DEGREES_TO_RADIANS;
const [rx, ry] = typeof spec.radius === 'number' ? [spec.radius, spec.radius] : [spec.radius?.x ?? 0, spec.radius?.y ?? 0];
const center = vector(spec.center);
item.offset.x += center.x + rx * Math.cos(theta) - item.base.x;
item.offset.y += center.y + ry * Math.sin(theta) - item.base.y;
break;
}
case 'wander': {
const rate = spec.rate ?? 1;
const scalar = octaveNoise(instance.noiseTable, instance.offsets[0], instance.offsets[1], time * rate, 1, 0.5);
const theta = 360 * scalar * DEGREES_TO_RADIANS;
const strength = spec.strength ?? 0;
const velocity = instance.state.velocity;
velocity.x += strength * Math.cos(theta) * dt;
velocity.y += strength * Math.sin(theta) * dt;
const maxSpeed = spec.maxSpeed;
if (maxSpeed !== undefined) {
const speed = Math.hypot(velocity.x, velocity.y);
if (speed > maxSpeed && speed > 0) {
velocity.x *= maxSpeed / speed;
velocity.y *= maxSpeed / speed;
}
}
item.offset.x += velocity.x * dt;
item.offset.y += velocity.y * dt;
break;
}
case 'follow-path': {
const flat = env.resolvePath?.(spec.path, instance.path);
if (!flat || flat.total === 0) break;
const duration = spec.duration !== undefined ? env.duration(spec.duration) / 1000 : flat.total / Math.max(1e-9, spec.speed ?? 1);
let progress = (spec.offset ?? 0) + (duration === 0 ? 0 : time / duration);
const loop = spec.loop ?? 'once';
if (loop === 'repeat') progress = frac(progress);
else if (loop === 'ping-pong') { const cycle = frac(progress / 2) * 2; progress = cycle <= 1 ? cycle : 2 - cycle; }
else progress = Math.max(0, Math.min(1, progress));
const at = env.alongPath(flat, progress * flat.total);
item.offset.x += at.x - item.base.x;
item.offset.y += at.y - item.base.y;
if (spec.align === true) item.rotationOffset += at.tangent;
break;
}
case 'point-wander': {
if (!Array.isArray(item.points)) throw new RuntimeFault('ERR_INVALID_BEHAVIOR_TARGET', 'point-wander requires an addressable point list.', instance.path);
const rate = spec.rate ?? 1;
const amplitude = vector(spec.amplitude);
const indices = Array.isArray(spec.indices) ? spec.indices : null;
for (let index = 0; index < item.points.length; index += 1) {
if (indices && !indices.includes(index)) continue;
const [o1, o2] = instance.offsets;
item.points[index].x += amplitude.x * octaveNoise(instance.noiseTable, o1 + 64 * index, o2, time * rate, 1, 0.5);
item.points[index].y += amplitude.y * octaveNoise(instance.noiseTable, o1, o2 + 64 * index, time * rate, 1, 0.5);
if (amplitude.z !== 0) item.points[index].z += amplitude.z * octaveNoise(instance.noiseTable, o1 + 64 * index, o2 + 64 * index, time * rate, 1, 0.5);
}
break;
}
case 'pulse': {
const duty = spec.duty ?? 0.5;
const phi = frac((spec.frequency ?? 1) * time);
let envelope = 0;
if (phi < duty / 2) envelope = curveValue(spec.curve ?? 'smooth', (2 * phi) / duty);
else if (phi < duty) envelope = curveValue(spec.curve ?? 'smooth', 2 - (2 * phi) / duty);
const value = (spec.amplitude ?? 0) * envelope;
writeNamedChannel(item, spec.property, 1 + value, value);
break;
}
case 'twinkle': {
const rate = spec.rate ?? 1;
const scalar = octaveNoise(instance.noiseTable, instance.offsets[0], instance.offsets[1], time * rate, 1, 0.5);
const min = spec.min ?? 0;
const max = spec.max ?? 1;
const value = min + (max - min) * ((scalar + 1) / 2);
writeNamedChannel(item, spec.property ?? 'style.opacity', value, value);
break;
}
case 'noise-displace': {
const scale = spec.scale ?? 100;
const speed = spec.speed ?? 0;
const octaves = spec.octaves ?? 1;
const persistence = spec.persistence ?? 0.5;
const amplitude = vector(spec.amplitude);
const px = (item.base.x + item.offset.x) / scale;
const py = (item.base.y + item.offset.y) / scale;
const table = instance.noiseTable;
item.offset.x += amplitude.x * octaveNoise(table, px, py, time * speed, octaves, persistence);
item.offset.y += amplitude.y * octaveNoise(table, px + 137, py + 71, time * speed, octaves, persistence);
if (amplitude.z !== 0) item.offset.z += amplitude.z * octaveNoise(table, px + 271, py + 193, time * speed, octaves, persistence);
break;
}
case 'face-motion': {
const speed = Math.hypot(item.vx, item.vy);
if (speed < 1e-6) {
// 18.6: An object with zero velocity holds its previous rotation.
if (instance.state.rotation !== undefined) {
item.rotationOffset += instance.state.rotation - item.base.rotation;
}
break;
}
const target = Math.atan2(item.vy, item.vx) / DEGREES_TO_RADIANS + (spec.offset ?? 0);
const smoothing = spec.smoothing ?? 0;
// Lazy init: use the current drawn rotation, not the target, on first tick.
const current = instance.state.rotation ?? item.base.rotation;
let delta = ((target - current + 540) % 360) - 180;
const step = 1 - smoothing ** dt;
instance.state.rotation = current + delta * step;
item.rotationOffset += instance.state.rotation - item.base.rotation;
break;
}
case 'wrap': {
const box = bounds(spec, env.scene);
const margin = spec.margin ?? 0;
const x = item.base.x + item.offset.x;
const y = item.base.y + item.offset.y;
if (x < box.x - margin) item.offset.x += box.width + 2 * margin;
else if (x > box.x + box.width + margin) item.offset.x -= box.width + 2 * margin;
if (y < box.y - margin) item.offset.y += box.height + 2 * margin;
else if (y > box.y + box.height + margin) item.offset.y -= box.height + 2 * margin;
break;
}
case 'bounce': {
const box = bounds(spec, env.scene);
const restitution = spec.restitution ?? 1;
const axes = spec.axes ?? 'both';
const x = item.base.x + item.offset.x;
const y = item.base.y + item.offset.y;
if (axes !== 'y' && ((x <= box.x && item.vx < 0) || (x >= box.x + box.width && item.vx > 0))) item.vx = -item.vx * restitution;
if (axes !== 'x' && ((y <= box.y && item.vy < 0) || (y >= box.y + box.height && item.vy > 0))) item.vy = -item.vy * restitution;
break;
}
case 'attract':
case 'repel': {
const target = env.resolveTarget?.(spec.target, instance.path) ?? vector(spec.target);
const dx = target.x - (item.base.x + item.offset.x);
const dy = target.y - (item.base.y + item.offset.y);
const distance = Math.hypot(dx, dy);
const maxDistance = spec.maxDistance;
if (maxDistance !== undefined && distance > maxDistance) break;
const clamped = Math.max(spec.minDistance ?? 1, distance);
const magnitude = (spec.strength ?? 0) * falloffFactor(spec.falloff ?? 'linear', clamped, maxDistance);
const sign = instance.type === 'attract' ? 1 : -1;
if (distance > 0) {
item.vx += sign * magnitude * (dx / distance) * dt;
item.vy += sign * magnitude * (dy / distance) * dt;
}
break;
}
case 'field-follow': {
const field = env.sampleField?.(spec.field, item.base.x + item.offset.x, item.base.y + item.offset.y, time);
if (!field) break;
const strength = spec.strength ?? 1;
const mode = spec.mode ?? 'force';
if (mode === 'velocity') { item.vx = field[0] * strength; item.vy = field[1] * strength; }
// 18.6: `direct` is the mode that does not integrate, so no dt factor.
else if (mode === 'direct') { item.offset.x += field[0] * strength; item.offset.y += field[1] * strength; }
else { item.vx += field[0] * strength * dt; item.vy += field[1] * strength * dt; }
break;
}
case 'morph': {
const target = env.resolveMorphTarget?.(spec.to, instance.path);
if (!target || !Array.isArray(item.points)) break;
const duration = env.duration(spec.duration) / 1000;
let progress = duration === 0 ? 1 : time / duration;
const loop = spec.loop ?? 'once';
if (loop === 'repeat') progress = frac(progress);
else if (loop === 'ping-pong') { const cycle = frac(progress / 2) * 2; progress = cycle <= 1 ? cycle : 2 - cycle; }
else progress = Math.max(0, Math.min(1, progress));
const amount = curveValue(spec.curve ?? 'linear', progress);
for (let index = 0; index < item.points.length && index < target.length; index += 1) {
item.points[index].x += (target[index].x - item.points[index].x) * amount;
item.points[index].y += (target[index].y - item.points[index].y) * amount;
item.points[index].z += ((target[index].z ?? 0) - (item.points[index].z ?? 0)) * amount;
}
break;
}
default:
throw new RuntimeFault('ERR_INVALID_BEHAVIOR_TYPE', `'${instance.type}' is outside Visual Behavior Set 0.1.`, instance.path);
}
}
/** 18.6: the shared falloff vocabulary, also used by the fields of 18.7. */
export function falloffFactor(falloff, distance, maxDistance) {
switch (falloff ?? 'linear') {
case 'none': return 1;
case 'inverse': return 1 / distance;
case 'inverse-square': return 1 / (distance * distance);
case 'linear':
default: {
if (maxDistance === undefined) return 1 / distance;
return Math.max(0, 1 - distance / maxDistance);
}
}
}
+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 };
}
+10 -1
View File
@@ -29,8 +29,8 @@ export class VisualDiagnosticCadence {
shed(code, subject, message, logicalMilliseconds, context = {}) {
const key = code + ' ' + subject;
const state = this.entry(key);
if (!state.seenThisTick) state.consecutive += 1;
state.seenThisTick = true;
state.consecutive += 1;
if (logicalMilliseconds - state.lastRaisedAt < DIAGNOSTIC_CADENCE_MS) return null;
state.lastRaisedAt = logicalMilliseconds;
const sustained = state.consecutive >= SUSTAINED_TICKS;
@@ -50,6 +50,15 @@ export class VisualDiagnosticCadence {
state.lastRaisedAt = -Infinity;
}
}
// P3.1: Cap raised list to prevent unbounded growth over long sessions.
if (this.raised.length > 1024) this.raised.splice(0, this.raised.length - 1024);
}
/** P3.1: Snapshot raised diagnostics and clear the list to prevent unbounded growth. */
snapshot() {
const copy = this.raised.slice();
this.raised.length = 0;
return copy;
}
// A capability warning, not a resource one: 17.12's conic fallback and
+280
View File
@@ -0,0 +1,280 @@
// The nine placement distributions of 18.3.
//
// A distribution answers one question: where does an item start? Its sample
// consumption is normative — an explicit list of draws per distribution and
// mode, in a fixed order — so a fixture's placements are byte-identical across
// renderers and a trace can assert the final stream position.
import { RuntimeFault } from './types.js';
import { DEGREES_TO_RADIANS, directedSweep } from './visual-math.js';
import { pathSubpaths } from './visual-geometry.js';
/** 18.3: the flattening tolerance for arc-length placement, in scene units. */
export const PATH_FLATNESS = 0.1;
function fraction(index, count) {
// The n == 1 convention `repeat.fraction` uses (18.5).
return count <= 1 ? 0 : index / (count - 1);
}
function pointOf(value, fallback = { x: 0, y: 0, z: 0 }) {
return { x: value?.x ?? fallback.x, y: value?.y ?? fallback.y, z: value?.z ?? fallback.z };
}
function distributionRadii(radius) {
return typeof radius === 'number' ? [radius, radius] : [radius?.x ?? 0, radius?.y ?? 0];
}
// ---------------------------------------------------------------------------
// Arc-length flattening for the `path` distribution
// ---------------------------------------------------------------------------
function flattenCubic(from, c1, c2, to, out, depth = 0) {
// Recursive subdivision to within PATH_FLATNESS, which fixes the sampled
// length so two renderers place an item at the same distance.
const dx = to[0] - from[0];
const dy = to[1] - from[1];
const d1 = Math.abs((c1[0] - to[0]) * dy - (c1[1] - to[1]) * dx);
const d2 = Math.abs((c2[0] - to[0]) * dy - (c2[1] - to[1]) * dx);
const flat = (d1 + d2) * (d1 + d2) < PATH_FLATNESS * (dx * dx + dy * dy);
if (flat || depth >= 16) { out.push(to); return; }
const mid = (a, b) => [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2];
const p01 = mid(from, c1);
const p12 = mid(c1, c2);
const p23 = mid(c2, to);
const p012 = mid(p01, p12);
const p123 = mid(p12, p23);
const middle = mid(p012, p123);
flattenCubic(from, p01, p012, middle, out, depth + 1);
flattenCubic(middle, p123, p23, to, out, depth + 1);
}
export function flattenPath(commands, path = '$') {
const subpaths = pathSubpaths(commands, path);
const points = [];
for (const subpath of subpaths) {
let cursor = subpath.start;
if (points.length === 0) points.push(cursor);
for (const segment of subpath.segments) {
if (segment.type === 'line') points.push(segment.to);
else flattenCubic(cursor, segment.c1, segment.c2, segment.to, points);
cursor = segment.to;
}
if (subpath.closed) points.push(subpath.start);
}
const lengths = [0];
for (let index = 1; index < points.length; index += 1) {
lengths.push(lengths[index - 1] + Math.hypot(points[index][0] - points[index - 1][0], points[index][1] - points[index - 1][1]));
}
return { points, lengths, total: lengths.at(-1) ?? 0, closed: subpaths.every((entry) => entry.closed) };
}
export function alongFlattened(flat, distance) {
const { points, lengths, total } = flat;
if (total === 0) return { x: points[0]?.[0] ?? 0, y: points[0]?.[1] ?? 0, tangent: 0 };
const target = Math.max(0, Math.min(total, distance));
let index = 1;
while (index < lengths.length - 1 && lengths[index] < target) index += 1;
const span = lengths[index] - lengths[index - 1];
const t = span === 0 ? 0 : (target - lengths[index - 1]) / span;
const from = points[index - 1];
const to = points[index];
return {
x: from[0] + (to[0] - from[0]) * t,
y: from[1] + (to[1] - from[1]) * t,
tangent: Math.atan2(to[1] - from[1], to[0] - from[0]) / DEGREES_TO_RADIANS
};
}
// ---------------------------------------------------------------------------
// Depth (as a type and as a sub-block)
// ---------------------------------------------------------------------------
/** 18.3: three inverse functions, both non-uniform curves biasing toward `near`. */
export function depthValue(block, u) {
const near = block.near ?? 0;
const far = block.far ?? 0;
if (!(far > near)) throw new RuntimeFault('ERR_INVALID_RANGE_ORDER', 'A depth distribution needs far above near.', '$');
const curve = block.curve ?? 'uniform';
if (curve === 'linear') return near + (far - near) * u * u;
if (curve === 'exponential') return near + (far - near) * ((1 - Math.exp(-3 * u)) / (1 - Math.exp(-3)));
return near + (far - near) * u;
}
// ---------------------------------------------------------------------------
// Placement
// ---------------------------------------------------------------------------
const CONTINUOUS_ILLEGAL = new Set(['grid']);
/**
* 18.3: `even` on `line`, `ring`, or `path`, and the `grid` type itself, place
* item `i` of `n` by index and are ERR_INVALID_DISTRIBUTION on a continuous
* `rate` emission, where `n` is unknown at creation time.
*/
export function requiresKnownCount(distribution) {
if (!distribution) return false;
if (CONTINUOUS_ILLEGAL.has(distribution.type)) return true;
return distribution.mode === 'even' && ['line', 'ring', 'path'].includes(distribution.type);
}
/**
* Place item `index` of `count`. Samples are drawn from `stream` in exactly the
* order the table of 18.3 fixes; an optional `depth` sub-block draws its `u`
* after the host distribution's own samples.
*/
export function placeItem(distribution, { index = 0, count = 1, stream = null, path = '$' } = {}) {
const type = distribution?.type ?? 'point';
const place = { x: 0, y: 0, z: 0, rotation: undefined };
const next = () => {
if (!stream) throw new RuntimeFault('ERR_SCHEMA_VALIDATION', 'This distribution requires an owning random stream.', path);
return stream.nextFloat();
};
switch (type) {
case 'point': {
const at = pointOf(distribution?.at);
place.x = at.x; place.y = at.y; place.z = at.z;
break;
}
case 'uniform': {
const min = pointOf(distribution.min);
const max = pointOf(distribution.max);
if (max.x < min.x || max.y < min.y) throw new RuntimeFault('ERR_INVALID_RANGE_ORDER', 'A uniform box needs max above min.', path);
place.x = min.x + next() * (max.x - min.x);
place.y = min.y + next() * (max.y - min.y);
const carriesZ = distribution.min?.z !== undefined || distribution.max?.z !== undefined;
if (carriesZ) place.z = min.z + next() * (max.z - min.z);
break;
}
case 'line': {
const from = pointOf(distribution.from);
const to = pointOf(distribution.to);
const t = (distribution.mode ?? 'random') === 'even' ? fraction(index, count) : next();
place.x = from.x + (to.x - from.x) * t;
place.y = from.y + (to.y - from.y) * t;
place.z = from.z + (to.z - from.z) * t;
break;
}
case 'rectangle': {
const center = pointOf(distribution.center);
const width = distribution.size?.width ?? 0;
const height = distribution.size?.height ?? 0;
if ((distribution.fill ?? 'area') === 'perimeter') {
// One sample mapped to a distance around the perimeter, clockwise from
// the top-left corner.
const perimeter = 2 * (width + height);
let walk = next() * perimeter;
const left = center.x - width / 2;
const top = center.y - height / 2;
if (walk < width) { place.x = left + walk; place.y = top; break; }
walk -= width;
if (walk < height) { place.x = left + width; place.y = top + walk; break; }
walk -= height;
if (walk < width) { place.x = left + width - walk; place.y = top + height; break; }
walk -= width;
place.x = left; place.y = top + height - walk;
break;
}
place.x = center.x + (next() - 0.5) * width;
place.y = center.y + (next() - 0.5) * height;
break;
}
case 'ellipse': {
const center = pointOf(distribution.center);
const [rx, ry] = distributionRadii(distribution.radius);
const angle = next() * 360 * DEGREES_TO_RADIANS;
if ((distribution.fill ?? 'area') === 'perimeter') {
place.x = center.x + rx * Math.cos(angle);
place.y = center.y + ry * Math.sin(angle);
break;
}
const radial = Math.sqrt(next());
place.x = center.x + rx * radial * Math.cos(angle);
place.y = center.y + ry * radial * Math.sin(angle);
break;
}
case 'ring': {
const center = pointOf(distribution.center);
const outer = distribution.radius ?? 0;
const inner = distribution.innerRadius ?? 0;
if (inner >= outer) throw new RuntimeFault('ERR_INVALID_RANGE_ORDER', 'A ring needs innerRadius below radius.', path);
const startAngle = distribution.startAngle ?? 0;
const endAngle = distribution.endAngle ?? 360;
const { sweep, sign } = directedSweep(startAngle, endAngle, distribution.direction ?? 'clockwise', path);
const full = sweep === 360;
let degrees;
let radial;
if ((distribution.mode ?? 'random') === 'even') {
degrees = startAngle + sweep * sign * (full ? index / Math.max(1, count) : fraction(index, count));
// An even angular placement with a random radius would be neither even
// nor sample-free, so the radius is the mid-annulus.
radial = (inner + outer) / 2;
} else {
degrees = startAngle + sweep * sign * next();
radial = Math.sqrt(inner * inner + next() * (outer * outer - inner * inner));
}
place.x = center.x + radial * Math.cos(degrees * DEGREES_TO_RADIANS);
place.y = center.y + radial * Math.sin(degrees * DEGREES_TO_RADIANS);
break;
}
case 'path': {
const flat = flattenPath(distribution.path, `${path}.path`);
const even = (distribution.mode ?? 'random') === 'even';
const t = even ? (flat.closed ? index / Math.max(1, count) : fraction(index, count)) : next();
const at = alongFlattened(flat, t * flat.total);
place.x = at.x;
place.y = at.y;
if (distribution.align === true) place.rotation = at.tangent;
break;
}
case 'grid': {
const origin = pointOf(distribution.origin);
const columns = distribution.columns ?? 1;
const rows = distribution.rows ?? 1;
const cells = Math.max(1, columns * rows);
const cell = ((index % cells) + cells) % cells;
const column = cell % columns;
const row = Math.floor(cell / columns);
place.x = origin.x + column * (distribution.spacing?.x ?? 0);
place.y = origin.y + row * (distribution.spacing?.y ?? 0);
const jitter = distribution.jitter;
const jx = jitter?.x ?? 0;
const jy = jitter?.y ?? 0;
if (jx !== 0 || jy !== 0) {
place.x += (next() * 2 - 1) * jx;
place.y += (next() * 2 - 1) * jy;
}
break;
}
case 'depth': {
place.z = depthValue(distribution, next());
break;
}
default:
throw new RuntimeFault('ERR_INVALID_DISTRIBUTION_TYPE', `Unknown placement distribution '${type}'.`, path);
}
// 18.3: the optional depth sub-block draws its sample after the host's own.
if (type !== 'depth' && distribution?.depth) {
place.z = depthValue(distribution.depth, next());
}
return place;
}
/** The documented number of samples a placement draws, for the traces of 18.10. */
export function sampleCount(distribution, mode = 'random') {
const type = distribution?.type ?? 'point';
const selected = distribution?.mode ?? mode;
let count = 0;
if (type === 'uniform') count = 2 + ((distribution.min?.z !== undefined || distribution.max?.z !== undefined) ? 1 : 0);
else if (type === 'line') count = selected === 'even' ? 0 : 1;
else if (type === 'rectangle') count = (distribution.fill ?? 'area') === 'perimeter' ? 1 : 2;
else if (type === 'ellipse') count = (distribution.fill ?? 'area') === 'perimeter' ? 1 : 2;
else if (type === 'ring') count = selected === 'even' ? 0 : 2;
else if (type === 'path') count = selected === 'even' ? 0 : 1;
else if (type === 'grid') count = (distribution.jitter?.x ?? 0) !== 0 || (distribution.jitter?.y ?? 0) !== 0 ? 2 : 0;
else if (type === 'depth') count = 1;
if (type !== 'depth' && distribution?.depth) count += 1;
return count;
}
+602 -45
View File
@@ -12,7 +12,7 @@
// perspective factor, a fog fraction, a sort order — and a plan carries those
// as data, where a canvas carries only pixels.
import { RuntimeFault, clamp, isRecord } from './types.js';
import { RuntimeFault, clamp, isRecord, parseDuration } from './types.js';
import {
IDENTITY,
apply,
@@ -28,8 +28,15 @@ import {
uniformFactor
} from './visual-math.js';
import { boundingBox, primitiveSubpaths } from './visual-geometry.js';
import { CAMERA_FIELDS, VISUAL_LIMITS } from './visual-contract.js';
import { CAMERA_FIELDS, VISUAL_LIMITS, POST_EFFECTS, matchVisualTarget } from './visual-contract.js';
import { VisualDiagnosticCadence } from './visual-diagnostics.js';
import { FieldSet } from './visual-fields.js';
import { ProceduralSystem } from './visual-systems.js';
import { behaviorNoiseTable } from './visual-noise.js';
import { initializeVisualMotion, advanceVisualMotion, visualSetPath } from './visual-motion.js';
import { visualLocalTracks, advanceVisualTracks } from './visual-automation.js';
import { VisualInstance } from './visual-lifecycle.js';
const RUNTIME = VISUAL_LIMITS.runtime;
@@ -66,15 +73,116 @@ function isValueSpec(value) {
* order, from one stream — the sampling rule 17.14 shares with 14.4 and 9.3.
* Everything that is not a ValueSpec is copied through unchanged.
*/
export function sampleTree(raw, resolver, stream, path = '$') {
if (Array.isArray(raw)) return raw.map((entry, index) => sampleTree(entry, resolver, stream, `${path}[${index}]`));
export function sampleTree(raw, resolver, stream, path = '$', scope = null) {
if (Array.isArray(raw)) return raw.map((entry, index) => sampleTree(entry, resolver, stream, `${path}[${index}]`, scope));
if (!isRecord(raw)) return raw;
if (isValueSpec(raw)) return resolver.evaluate(raw, stream, path);
if (isValueSpec(raw)) {
// 18.1/18.5: `inputs.*` and `repeat.*` are construct-scoped. They are not
// section 1.3 document namespaces and grant no 8.1 capability, so they are
// resolved here rather than through the document resolver.
const scoped = scopedReference(raw, scope, path);
if (scoped !== undefined) return scoped;
if (scope) {
const substitute = value => {
if (Array.isArray(value)) return value.map(substitute);
if (!isRecord(value)) return value;
const scoped = scopedReference(value, scope, path);
return scoped !== undefined ? scoped : Object.fromEntries(Object.entries(value).map(([key, child]) => [key, substitute(child)]));
};
return resolver.evaluate(substitute(raw), stream, path);
}
return resolver.evaluate(raw, stream, path);
}
const result = {};
for (const key of Object.keys(raw)) result[key] = sampleTree(raw[key], resolver, stream, `${path}.${key}`);
for (const key of Object.keys(raw)) result[key] = sampleTree(raw[key], resolver, stream, `${path}.${key}`, scope);
return result;
}
function scopedReference(spec, scope, path) {
const reference = spec.ref;
if (typeof reference !== 'string') return undefined;
if (reference.startsWith('inputs.')) {
const key = reference.slice('inputs.'.length);
if (!scope?.inputs || !Object.hasOwn(scope.inputs, key)) {
throw new RuntimeFault('ERR_INVALID_REFERENCE', `'${reference}' resolves to no declared input in this scope.`, path);
}
return scope.inputs[key];
}
if (reference.startsWith('repeat.')) {
const key = reference.slice('repeat.'.length);
if (!scope?.repeat || !Object.hasOwn(scope.repeat, key)) {
throw new RuntimeFault('ERR_INVALID_REFERENCE', `'${reference}' is legal only inside a repeater's repeat block.`, path);
}
return scope.repeat[key];
}
return undefined;
}
/**
* 18.1: a `component` object instantiates its sub-assembly in place and behaves
* as a group whose children are the component's `content`. Expanding it into a
* group here is what lets one instantiation path serve both.
*/
export function expandComponent(object, context, path, componentDepth) {
const definition = context.components?.[object.component];
if (!definition) throw new RuntimeFault('ERR_INVALID_REFERENCE', `No visual component '${object.component}' is declared.`, path);
if (componentDepth > VISUAL_LIMITS.authoring.componentNesting) {
throw new RuntimeFault('ERR_COMPONENT_RECURSION', `Component nesting deeper than ${VISUAL_LIMITS.authoring.componentNesting} levels.`, path);
}
if (context.componentTrail.includes(object.component)) {
throw new RuntimeFault('ERR_COMPONENT_RECURSION', `Component '${object.component}' instantiates itself.`, path);
}
const declared = definition.parameters ?? {};
const inputs = {};
for (const key of Object.keys(object.inputs ?? {})) {
if (!Object.hasOwn(declared, key)) throw new RuntimeFault('ERR_INVALID_REFERENCE', `Component '${object.component}' declares no parameter '${key}'.`, `${path}.inputs.${key}`);
}
// Sampled in declaration order, so a component's inputs consume the stream
// reproducibly whatever order the instantiating site wrote them in.
for (const [key, parameter] of Object.entries(declared)) {
const supplied = object.inputs?.[key];
if (supplied === undefined && !Object.hasOwn(parameter, 'default')) {
throw new RuntimeFault('ERR_INVALID_REFERENCE', `Component parameter '${key}' has no default and no supplied value.`, `${path}.inputs.${key}`);
}
inputs[key] = sampleTree(supplied === undefined ? parameter.default : supplied, context.resolver, context.stream, `${path}.inputs.${key}`, context.scope);
if (!inputValueMatches(parameter.type, inputs[key])) {
throw new RuntimeFault('ERR_TYPE_MISMATCH', `Component input '${key}' is not a ${parameter.type}.`, `${path}.inputs.${key}`);
}
if (parameter.type === 'number') {
const below = parameter.min !== undefined && inputs[key] < parameter.min;
const above = parameter.max !== undefined && inputs[key] > parameter.max;
if (below || above) throw new RuntimeFault('ERR_OUT_OF_BOUNDS', `Component input '${key}' is outside its declared range.`, `${path}.inputs.${key}`);
}
}
return {
group: {
type: 'group',
position: object.position,
z: object.z,
transform: mergeTransforms(definition.transform, object.transform),
style: { ...(definition.style ?? {}), ...(object.style ?? {}) },
behaviors: [...(definition.behaviors ?? []), ...(object.behaviors ?? [])],
visible: object.visible,
lifetime: object.lifetime,
children: definition.content
},
inputs,
component: object.component
};
}
function inputValueMatches(type, value) {
if (type === 'number') return typeof value === 'number' && Number.isFinite(value);
if (type === 'boolean') return typeof value === 'boolean';
return typeof value === 'string';
}
function mergeTransforms(componentTransform, objectTransform) {
if (!componentTransform) return objectTransform;
if (!objectTransform) return componentTransform;
return { ...componentTransform, ...objectTransform };
}
function mergeStyle(inherited, own) {
if (!isRecord(own)) return inherited;
const merged = { ...inherited };
@@ -88,7 +196,7 @@ function mergeStyle(inherited, own) {
return merged;
}
function vector(value, fallback = 0) {
function vectorOf(value, fallback = 0) {
return { x: value?.x ?? fallback, y: value?.y ?? fallback, z: value?.z ?? fallback };
}
@@ -96,10 +204,45 @@ function vector(value, fallback = 0) {
function instantiateObject(key, raw, context, parentPath, depth) {
if (!isRecord(raw)) throw new RuntimeFault('ERR_SCHEMA_VALIDATION', 'A visual object must be an object.', parentPath);
const path = parentPath ? `${parentPath}.${key}` : key;
// 18.1: component nesting and group nesting are counted independently, each
// bounded at 8, so the group a component expands into costs no group depth.
const isComponentRoot = context.pendingComponentRoot === true;
context.pendingComponentRoot = false;
if (depth > VISUAL_LIMITS.authoring.groupNesting) {
throw new RuntimeFault('ERR_VISUAL_LIMIT_EXCEEDED', `Group nesting deeper than ${VISUAL_LIMITS.authoring.groupNesting} levels.`, path);
}
const resolved = sampleTree(raw, context.resolver, context.stream, path);
if (raw.type === 'component') {
const expansion = expandComponent(raw, context, path, (context.componentDepth ?? 0) + 1);
const previousScope = context.scope;
const previousTrail = context.componentTrail;
const previousDepth = context.componentDepth ?? 0;
context.scope = { ...(context.scope ?? {}), inputs: expansion.inputs };
context.componentTrail = [...previousTrail, expansion.component];
context.componentDepth = previousDepth + 1;
context.pendingComponentRoot = true;
try {
// 18.1: the expansion path, not the component ID, is the stable instance
// key, so two instances sample independently and reproducibly.
const node = instantiateObject(key, expansion.group, context, parentPath, depth);
node.component = expansion.component;
return node;
} finally {
context.scope = previousScope;
context.componentTrail = previousTrail;
context.componentDepth = previousDepth;
}
}
// 17.14: an object's own fields are sampled in document order; its
// `children` are instantiated after them, whatever position the container key
// holds, because a group's style scope must exist before its descendants can
// inherit it. Sampling the whole subtree here and then instantiating the
// children from the raw tree would consume the stream twice.
const own = {};
for (const key of Object.keys(raw)) {
if (key === 'children') continue;
own[key] = sampleTree(raw[key], context.resolver, context.stream, `${path}.${key}`, context.scope);
}
const resolved = own;
const transform = resolved.transform ?? {};
const node = {
key,
@@ -112,11 +255,11 @@ function instantiateObject(key, raw, context, parentPath, depth) {
visible: resolved.visible ?? true,
matrix: localMatrix({
position: { x: resolved.position?.x ?? 0, y: resolved.position?.y ?? 0 },
translate: vector(transform.translate),
translate: vectorOf(transform.translate),
rotation: transform.rotation ?? 0,
skew: vector(transform.skew),
skew: vectorOf(transform.skew),
scale: { x: transform.scale?.x ?? 1, y: transform.scale?.y ?? 1 },
origin: vector(transform.origin)
origin: vectorOf(transform.origin)
}),
style: mergeStyle(context.style, resolved.style),
// 17.12: opacity multiplies down the tree rather than being inherited.
@@ -126,8 +269,8 @@ function instantiateObject(key, raw, context, parentPath, depth) {
if (resolved.type === 'group') {
const previousStyle = context.style;
context.style = node.style;
for (const childKey of Object.keys(resolved.children ?? {})) {
node.children.push(instantiateObject(childKey, raw.children[childKey], context, path, depth + 1));
for (const childKey of Object.keys(raw.children ?? {})) {
node.children.push(instantiateObject(childKey, raw.children[childKey], context, path, depth + (isComponentRoot ? 0 : 1)));
}
context.style = previousStyle;
}
@@ -136,6 +279,7 @@ function instantiateObject(key, raw, context, parentPath, depth) {
// fillable siblings. Inheritance never turns into a validation error.
if (STROKE_ONLY.has(node.type)) node.style = { ...node.style, fill: null };
node.geometry = primitiveSubpaths(resolved, path);
initializeVisualMotion(node, context);
return node;
}
@@ -215,6 +359,10 @@ export class VisualEngine {
this.cadence = new VisualDiagnosticCadence(diagnostics);
this.visuals = document?.visuals ?? null;
this.systems = [];
this.time = 0;
this.instances = new Map();
this.spawnOrdinals = new Map();
this.unregister = [];
this.layers = [];
this.deferred = [];
this.deferredKeys = new Set();
@@ -230,36 +378,243 @@ export class VisualEngine {
: [{ id: null, implicit: true }];
const resolver = this.resolution?.valueResolver;
// 18.7: the four noise-using behaviors share one exhibit-wide permutation
// table; per-object independence comes from their offset pairs.
this.noiseTable = this.rng ? behaviorNoiseTable(this.rng) : null;
this.fieldSet = new FieldSet(sampleTree(visuals.fields ?? {}, resolver, this.rng?.stream('visual', 'fields')), { rng: this.rng });
this.systems = [];
this.deferred = [];
this.deferredKeys = new Set();
let index = 0;
for (const [id, system] of Object.entries(visuals.systems ?? {})) {
// 19.2: a spawned system is a template. It is validated and counted at
// import and is not instantiated or drawn at activation.
if ((system.lifecycle ?? 'persistent') === 'spawned') {
this.defer(id, 'spawned template, instantiated only by a spawn action (19.2)');
continue;
}
if (system.type !== 'graphic') {
// 18.2-18.5 procedural systems execute in slice 4e. Their schema is
// carried and validated; nothing is drawn for them here.
this.defer(id, `'${system.type}' systems execute in slice 4e (18.2-18.5)`);
continue;
}
const stream = this.rng ? this.rng.stream('visual', `visuals.systems.${id}`) : null;
const context = { resolver, stream, style: DEFAULT_STYLE };
const objects = [];
let objectIndex = 0;
for (const key of Object.keys(system.content ?? {})) {
const node = instantiateObject(key, system.content[key], context, `visuals.systems.${id}.content`, 1);
node.order = objectIndex;
objectIndex += 1;
objects.push(node);
}
this.systems.push({ id, index, layer: system.layer ?? null, visible: system.visible ?? true, objects });
index += 1;
if ((system.lifecycle ?? 'persistent') !== 'spawned') this.systems.push(this.createSystem(id, system, `visuals.systems.${id}`));
}
this.scene = sampleTree(visuals.scene ?? {}, resolver, this.rng?.stream('visual', 'scene'));
this.effects = (this.resolution?.visualEffects ?? visuals.effects ?? []).map((effect, index) => ({
...effect, index
}));
const automationStream = this.rng?.stream('visual', 'automation');
this.tracks = [];
for (const track of visuals.automation ?? []) {
const path = `visuals.${track.target}`;
const match = matchVisualTarget(this.document, path);
if (match && !match.reason && match.stages.automation && this.resolution) {
this.unregister.push(this.resolution.addAutomation(path, track, { startedAt: 0, stream: automationStream }));
} else this.tracks.push(...visualLocalTracks([track], { scene: this.scene, layers: Object.fromEntries(this.layers.map(layer => [layer.id, layer])) },
(value, path) => resolver.evaluate(value, automationStream, path), warning => this.cadence.once(warning.code, warning.path, warning.message)));
}
advanceVisualTracks(this.tracks, 0);
this.enforcePopulations();
return this;
}
createSystem(id, spec, systemPath, scope = null) {
const resolver = this.resolution?.valueResolver;
const stream = this.rng?.stream('visual', systemPath);
const context = this.objectContext(resolver, stream, scope);
const system = { id, index: this.systems.length, spec, layer: spec.layer ?? null, visible: spec.visible ?? true, objects: [], at: this.time };
if (spec.type === 'graphic') {
for (const [key, raw] of Object.entries(spec.content ?? {})) {
const node = instantiateObject(key, raw, context, `${systemPath}.content`, 1);
node.order = system.objects.length; system.objects.push(node);
}
} else {
system.procedural = new ProceduralSystem(id, spec, {
systemPath, resolver, rng: this.rng, noiseTable: this.noiseTable,
scene: this.sceneRectangle(), fields: this.fieldSet,
instantiateItemNode: (template, itemStream, path, itemScope) => instantiateObject('item', template,
this.objectContext(resolver, itemStream, { ...scope, ...itemScope }), path, 1)
});
}
system.tracks = visualLocalTracks(spec.automation, system.procedural ?? system.objects,
(value, path) => sampleTree(value, resolver, stream, path, scope), warning => this.cadence.once(warning.code, `${systemPath}:${warning.path}`, warning.message));
advanceVisualTracks(system.tracks, 0);
const visited = new Set();
for (const node of system.objects) advanceVisualMotion(node, 0, 0, this.sceneRectangle(), this.fieldSet, system.objects, visited);
return system;
}
spawn(template, { inputs = {}, owner = 'root', origin = owner, ownership, lifetime } = {}) {
const spec = this.visuals?.systems?.[template];
if (!spec || spec.lifecycle !== 'spawned') throw new RuntimeFault('ERR_INVALID_REFERENCE', `No spawned template '${template}'.`);
const live = [...this.instances.values()];
const tracks = (this.visuals.automation ?? []).length + this.systems.reduce((n, system) => n + (system.tracks?.length ?? 0), 0);
const points = (this.visuals.automation ?? []).reduce((n, track) => n + track.points.length, 0) + this.systems.reduce((n, system) => n + (system.tracks ?? []).reduce((m, entry) => m + entry.track.points.length, 0), 0);
const key = live.length >= RUNTIME.liveSpawnedInstances ? 'liveSpawnedInstances'
: tracks + (spec.automation?.length ?? 0) > RUNTIME.liveAutomationTracks ? 'liveAutomationTracks'
: points + (spec.automation ?? []).reduce((n, track) => n + track.points.length, 0) > RUNTIME.liveAutomationPoints ? 'liveAutomationPoints' : null;
if (key) { this.cadence.shed('WARN_VISUAL_CEILING', key, `Spawn of '${template}' refused by ${key}.`, this.time); return null; }
const parameters = spec.spawn?.inputs ?? {};
for (const key of Object.keys(inputs)) if (!Object.hasOwn(parameters, key)) throw new RuntimeFault('ERR_INVALID_REFERENCE', `Unknown spawn input '${key}'.`);
const bound = {};
for (const [key, parameter] of Object.entries(parameters)) {
const value = inputs[key] ?? parameter.default;
if (!inputValueMatches(parameter.type, value)) throw new RuntimeFault('ERR_TYPE_MISMATCH', `Invalid spawn input '${key}'.`);
if (typeof value === 'number' && (value < (parameter.min ?? -Infinity) || value > (parameter.max ?? Infinity))) throw new RuntimeFault('ERR_OUT_OF_BOUNDS', `Spawn input '${key}' is out of range.`);
bound[key] = value;
}
if (ownership === 'persistent' && spec.spawn?.ownership !== 'persistent') throw new RuntimeFault('ERR_UNSUPPORTED_TARGET', 'Template does not permit persistent ownership.');
const ordinal = this.spawnOrdinals.get(template) ?? 0;
const id = `instances.${template}#${ordinal}`;
// Substitute only scoped input leaves; preserve procedural values for their own instantiation boundaries.
const substitute = value => Array.isArray(value) ? value.map(substitute) : isRecord(value)
? (typeof value.ref === 'string' && value.ref.startsWith('inputs.') ? bound[value.ref.slice(7)] : Object.fromEntries(Object.entries(value).map(([k, v]) => [k, substitute(v)]))) : value;
const resolvedSpec = substitute(spec);
const system = this.createSystem(template, resolvedSpec, `${template}#${ordinal}`, { inputs: bound });
const stream = this.rng?.stream('visual', `${template}#${ordinal}`);
const duration = (value, fallback) => value === undefined ? fallback : parseDuration(sampleTree(value, this.resolution.valueResolver, stream));
const instance = new VisualInstance({ id, template, system, at: this.time,
lifetime: duration(lifetime ?? resolvedSpec.spawn?.lifetime, null), release: duration(resolvedSpec.spawn?.release, 0),
owner: ownership === 'persistent' ? 'root' : owner, origin, cancelWithScenario: spec.spawn?.cancelWithScenario !== false });
system.instance = instance;
this.spawnOrdinals.set(template, ordinal + 1); this.instances.set(id, instance); this.systems.push(system);
this.enforcePopulations();
return instance;
}
remove(id) {
const instance = this.instances.get(id);
if (!instance) return false;
instance.remove(this.time);
return true;
}
failSystem(system, error) {
if (system.instance && !['FAILED', 'DISPOSED'].includes(system.instance.state)) {
system.instance.transition('FAILED'); this.instances.delete(system.instance.id);
}
system.objects.length = 0;
if (system.procedural) system.procedural.items.length = 0;
system.tracks = [];
this.systems = this.systems.filter(candidate => candidate !== system);
this.cadence.diagnostics?.error?.(error.code ?? 'ERR_RUNTIME', error.message, { section: 'visual', objectId: system.instance?.id ?? system.id });
}
cleanup(owner) {
for (const instance of this.instances.values()) if (instance.owner === owner || (instance.origin === owner && instance.cancelWithScenario)) {
instance.remove(this.time, true); instance.cleanupDeadline = this.time + 5000;
}
}
dispose() {
for (const release of this.unregister) release();
this.unregister = []; this.tracks = [];
for (const instance of this.instances.values()) {
if (instance.state !== 'FINISHED') instance.transition('FINISHED');
instance.transition('DISPOSED');
}
this.instances.clear(); this.systems = []; this.fieldSet?.fields.clear(); this.cadence.clear();
}
enforcePopulations() {
for (const [type, key] of [['particles', 'liveParticles'], ['emitter', 'liveEmittedItems']]) {
const systems = this.systems.map(system => system.procedural).filter(system => system?.type === type);
let total = systems.reduce((n, system) => n + system.items.length, 0);
if (total > RUNTIME[key]) this.cadence.shed('WARN_VISUAL_CEILING', key, `Exceeded ${key}; evicting oldest items in the largest system.`, this.time);
while (total > RUNTIME[key]) {
const largest = systems.reduce((a, b) => a.items.length >= b.items.length ? a : b);
largest.items.shift(); total -= 1;
}
}
const histories = this.systems.flatMap(system => (system.procedural?.items ?? []).map(item => item.trail));
let samples = histories.reduce((n, history) => n + history.length, 0);
if (samples > RUNTIME.trailHistorySamples) this.cadence.shed('WARN_VISUAL_CEILING', 'trailHistorySamples', 'Truncating oldest samples of longest histories.', this.time);
while (samples > RUNTIME.trailHistorySamples) { histories.reduce((a, b) => a.length >= b.length ? a : b).shift(); samples -= 1; }
let links = RUNTIME.linkSegmentsPerTick;
for (const system of this.systems) if (system.procedural) {
const pairs = system.procedural.linkPairs();
if (pairs.length > links) this.cadence.shed('WARN_VISUAL_CEILING', 'linkSegmentsPerTick', 'Dropping the tail of link pair order.', this.time);
system.links = pairs.slice(0, links); links -= system.links.length;
}
}
effectPlan(fit, backing, zoom) {
let passes = 0;
const effects = [];
for (const effect of this.effects ?? []) {
if (effect.enabled === false) continue;
const definition = POST_EFFECTS[effect.type];
if (passes + definition.passes > RUNTIME.postEffectPassesPerFrame) {
this.cadence.shed('WARN_VISUAL_APPROXIMATION', `effect:${effect.index}`, 'Trailing effect skipped by pass ceiling.', this.time); break;
}
const resolved = { ...definition.colors, ...effect };
for (const [parameter, range] of Object.entries(definition.numeric)) {
const value = this.resolution?.get(`visuals.effects[${effect.index}].${parameter}`) ?? effect[parameter] ?? range.default;
resolved[parameter] = clamp(value, range.min ?? -Infinity, range.max ?? Infinity);
}
if (resolved.radius !== undefined && ['blur', 'bloom'].includes(effect.type)) resolved.deviceRadius = resolved.radius * zoom * Math.sqrt(fit.sx * fit.sy) * backing.multiplier;
effects.push(resolved); passes += definition.passes;
}
return effects;
}
objectContext(resolver, stream, scope = null) {
return {
resolver, stream, noiseTable: this.noiseTable, style: DEFAULT_STYLE, scope,
components: this.document?.components?.visual ?? {}, componentTrail: [], componentDepth: 0
};
}
/** The scene rectangle in scene units, for the `scene` bounds of 18.6. */
sceneRectangle() {
const scene = this.visuals?.scene ?? {};
const space = scene.coordinateSpace ?? 'virtual';
if (space === 'normalized') return { x: 0, y: 0, width: 1, height: 1 };
if (space === 'viewport') return { x: 0, y: 0, width: this.display?.width ?? 0, height: this.display?.height ?? 0 };
return { x: 0, y: 0, width: scene.width ?? 0, height: scene.height ?? 0 };
}
/**
* One logical tick (9.1). Procedural systems advance here and nowhere else: a
* frame between two ticks draws the most recent tick's state (18.2).
*/
advance(deltaMilliseconds) {
this.cadence.endTick();
this.time += deltaMilliseconds;
const dt = deltaMilliseconds / 1000;
advanceVisualTracks(this.tracks, this.time);
for (const instance of this.instances.values()) {
if (instance.advance(this.time) === 'forced') {
this.cadence.once('WARN_CLEANUP_FORCED', instance.id, 'Visual cleanup exceeded its deadline.');
if (instance.state !== 'FINISHED') instance.transition('FINISHED');
instance.transition('DISPOSED');
}
if (['DISPOSED', 'FAILED'].includes(instance.state)) {
this.instances.delete(instance.id); this.systems = this.systems.filter(system => system !== instance.system);
}
}
this.fieldSet.evaluations = 0; this.fieldSet.refused = false; this.fieldSet.budget = RUNTIME.fieldEvaluationsPerTick;
const work = [];
for (const system of this.systems) {
try {
advanceVisualTracks(system.tracks, this.time - system.at);
if (system.procedural) {
const procedural = system.procedural;
const env = procedural.advance(dt, { deferItems: true });
for (const item of procedural.items) work.push({ system, procedural, item, env });
}
} catch (error) { this.failSystem(system, error); }
}
work.sort((a, b) => a.procedural.itemPosition(a.item).z - b.procedural.itemPosition(b.item).z);
for (const { system, procedural, item, env } of work) {
if (!this.systems.includes(system)) continue;
try {
procedural.advanceItem(item, dt, procedural.time - item.birth, env);
if (item.node) {
advanceVisualMotion(item.node, dt, procedural.time - item.birth, this.sceneRectangle(), this.fieldSet);
for (const [key, value] of Object.entries(item.channels)) visualSetPath(item.node.raw, key, value);
item.node.style = { ...item.node.style, ...item.node.raw.style };
item.node.geometry = primitiveSubpaths(item.node.raw, item.node.path);
}
} catch (error) { this.failSystem(system, error); }
}
for (const system of this.systems) {
try {
const visited = new Set();
for (const node of system.objects) advanceVisualMotion(node, dt, (this.time - system.at) / 1000, this.sceneRectangle(), this.fieldSet, system.objects, visited);
} catch (error) { this.failSystem(system, error); }
}
if (this.fieldSet.refused) this.cadence.shed('WARN_VISUAL_CEILING', 'fieldEvaluationsPerTick', 'Field budget exhausted; remaining forces are zero.', this.time);
this.enforcePopulations();
return this;
}
@@ -288,6 +643,7 @@ export class VisualEngine {
}
systemVisible(system) {
if (system.instance && !['ACTIVE', 'RELEASING'].includes(system.instance.state)) return false;
if (this.resolution) {
try { return this.resolution.get(`visuals.systems.${system.id}.visible`) !== false; } catch { /* authored value below */ }
}
@@ -300,9 +656,19 @@ export class VisualEngine {
*/
planFrame({ width, height, devicePixelRatio = 1, logicalMilliseconds = 0 } = {}) {
if (!this.visuals) return null;
const scene = this.visuals.scene ?? {};
this.display = { width, height };
for (const system of this.systems) if (system.procedural) system.procedural.scene = this.sceneRectangle();
const scene = this.scene ?? this.visuals.scene ?? {};
const fit = resolveFit(scene, width, height);
const backing = resolveBackingStore(width, height, devicePixelRatio);
if (fit.space === 'viewport' && this.resolution) {
for (const [field, value] of [['x', width / 2], ['y', height / 2]]) {
const path = `visuals.camera.${field}`;
if (this.visuals.camera?.[field] === undefined && this.resolution.visualValues.get(path) !== value) {
this.resolution.visualValues.set(path, value); this.resolution.invalidate();
}
}
}
if (backing.below1) {
this.cadence.once('WARN_VISUAL_APPROXIMATION', `backing-store:${backing.width}x${backing.height}`,
`The display exceeds ${RUNTIME.backingStoreEdge} device pixels, so the frame is rendered at a multiplier of ${backing.multiplier.toFixed(4)} and upsampled.`);
@@ -316,8 +682,8 @@ export class VisualEngine {
const focalLength = clamp(this.cameraValue('focalLength'), CAMERA_FIELDS.focalLength.min, CAMERA_FIELDS.focalLength.max);
let cameraX = this.cameraValue('x');
let cameraY = this.cameraValue('y');
if (fit.space === 'viewport' && this.visuals.camera?.x === undefined) cameraX = center[0];
if (fit.space === 'viewport' && this.visuals.camera?.y === undefined) cameraY = center[1];
if (fit.space === 'viewport' && !this.resolution && this.visuals.camera?.x === undefined) cameraX = center[0];
if (fit.space === 'viewport' && !this.resolution && this.visuals.camera?.y === undefined) cameraY = center[1];
const cameraCss = fit.space === 'viewport' ? [cameraX, cameraY] : apply(fit.matrix, cameraX, cameraY);
const fog = this.resolveFog(scene);
@@ -339,7 +705,9 @@ export class VisualEngine {
const units = this.sortableUnits(layer);
const nodes = [];
for (const unit of units) {
const plan = this.emitNode(unit.node, IDENTITY, 0, 1, { ...context, view, layerId: layer.id });
const plan = unit.system
? this.emitSystem(unit.system, { ...context, view, layerId: layer.id })
: this.emitNode(unit.node, IDENTITY, 0, unit.releaseFactor ?? 1, { ...context, view, layerId: layer.id });
if (!plan) continue;
// The plan node is pushed by reference: buffer allocation runs after
// every layer is emitted and mutates the nodes it refuses.
@@ -355,7 +723,7 @@ export class VisualEngine {
}
this.allocateBuffers(layers, context);
this.cadence.endTick();
const effects = this.effectPlan(fit, backing, zoom);
return {
background: formatColor(parseColor(scene.background ?? '#000000', '$.visuals.scene.background')),
@@ -364,7 +732,7 @@ export class VisualEngine {
fit,
camera: { x: cameraX, y: cameraY, zoom, rotation: rotationDegrees, projection, focalLength, cameraCss, center },
fog,
layers,
layers, effects, logicalMilliseconds: this.time,
culled: context.stats.culled,
buffers: context.bufferSummary,
deferred: this.deferred,
@@ -392,8 +760,12 @@ export class VisualEngine {
for (const system of this.systems) {
if ((system.layer ?? null) !== (layer.id ?? null)) continue;
if (!this.systemVisible(system)) continue;
if (system.procedural) {
units.push({ system, representativeDepth: system.procedural.representativeDepth(), systemIndex: system.index, objectIndex: 0 });
continue;
}
for (const node of system.objects) {
units.push({ node, representativeDepth: node.z + node.translateZ, systemIndex: system.index, objectIndex: node.order });
units.push({ node, representativeDepth: node.z + node.translateZ, systemIndex: system.index, objectIndex: node.order, releaseFactor: system.instance?.factor ?? 1 });
}
}
return units.sort((a, b) => (b.representativeDepth - a.representativeDepth)
@@ -536,6 +908,191 @@ export class VisualEngine {
return plan;
}
// -------------------------------------------------------------------------
// Procedural systems (18.2-18.5, 18.8)
// -------------------------------------------------------------------------
/** Project a scene-space point at a given effective depth into device pixels. */
projectScene(ctx, x, y, zEffective) {
let [px, py] = apply(ctx.fit.matrix, x, y);
[px, py] = apply(ctx.view, px, py);
if (ctx.projection === 'perspective') {
const factor = ctx.focalLength / (ctx.focalLength + zEffective);
px = ctx.center[0] + (px - ctx.center[0]) * factor;
py = ctx.center[1] + (py - ctx.center[1]) * factor;
}
return apply(ctx.B, px, py);
}
sceneScalar(ctx, value, zEffective) {
const k = ctx.projection === 'perspective' ? ctx.focalLength / (ctx.focalLength + zEffective) : 1;
return value * ctx.zoom * k * Math.sqrt(ctx.fit.sx * ctx.fit.sy) * ctx.backing.multiplier;
}
/**
* 17.6: a procedural system draws as one atomic unit. Within it, links draw
* before their items (18.8) and items sort by depth then creation ordinal.
*/
emitSystem(system, ctx) {
const procedural = system.procedural;
const unit = {
kind: 'group', path: procedural.path, type: system.spec?.type ?? procedural.type,
z: procedural.representativeDepth(), perspective: 1, alpha: 1, blend: 'normal',
fill: null, stroke: null, strokeWidth: 0, strokeCap: 'butt', strokeJoin: 'miter',
strokeDash: undefined, strokeDashOffset: 0, glow: null, shadow: null, blur: 0,
filters: [], clip: null, fogFraction: 0, children: [], mask: null,
needsBuffer: false, system: procedural.id, itemCount: procedural.items.length
};
for (const link of system.links ?? procedural.linkPairs()) {
const node = this.emitLink(procedural, link, ctx);
if (node) unit.children.push(node);
}
const ordered = [...procedural.items].sort((a, b) => {
const az = procedural.itemPosition(a).z;
const bz = procedural.itemPosition(b).z;
return (bz - az) || (a.ordinal - b.ordinal);
});
for (const item of ordered) {
const trail = this.emitTrail(procedural, item, ctx);
if (trail) unit.children.push(trail);
const node = this.emitItem(procedural, item, ctx);
if (node) unit.children.push(node);
}
if (system.instance) {
const fade = node => { node.alpha *= system.instance.factor; for (const child of node.children ?? []) fade(child); };
for (const child of unit.children) fade(child);
}
return unit;
}
emitItem(procedural, item, ctx) {
if (!item.node) return null;
const position = procedural.itemPosition(item);
const age = procedural.itemAge(item);
const size = procedural.rampValue(item.ramps.size, age) ?? 1;
const opacity = (procedural.rampValue(item.ramps.opacity, age) ?? 1) * item.opacityMultiplier;
const color = procedural.rampValue(item.ramps.color, age);
// 18.2: `color` replaces the render object's resolved fill, and its stroke
// where that is non-null.
if (color !== undefined && color !== null) {
const text = typeof color === 'string' ? color : formatColor(color);
item.node.style = { ...item.node.style, fill: text, stroke: item.node.style.stroke === null ? null : text };
}
// V29: `size` is a uniform local geometry scale, applied innermost, before
// the render object's own transform.
const matrix = compose(translation(position.x, position.y), rotation(position.rotation), scaling(size * item.scale.x, size * item.scale.y));
const node = this.emitNode(item.node, matrix, position.z, opacity, ctx);
if (!node) return null;
// A `point` and a `text` carry scalar dimensions that a geometry scale
// cannot reach, so `size` multiplies them explicitly.
if (node.kind === 'point') node.diameter *= size;
if (node.kind === 'text') { node.size *= size; node.letterSpacing *= size; }
node.ordinal = item.ordinal;
return node;
}
/** 18.8: a trail is the item's recent positions, drawn under its head. */
emitTrail(procedural, item, ctx) {
const spec = procedural.trailSpec;
if (!spec || item.trail.length < 2) return null;
const style = spec.style ?? item.node?.style ?? DEFAULT_STYLE;
const head = item.trail.at(-1);
const width = spec.width ?? style.strokeWidth ?? 1;
const taper = spec.taper ?? 1;
const fade = spec.fade ?? 1;
const mode = spec.mode ?? 'line';
const samples = item.trail;
const project = (sample) => this.projectScene(ctx, sample.x, sample.y, sample.z ?? 0);
if (mode === 'points') {
return {
kind: 'group', path: `${procedural.path}#${item.ordinal}.trail`, type: 'trail', z: head.z ?? 0,
perspective: 1, alpha: 1, blend: 'normal', fill: null, stroke: null, strokeWidth: 0,
strokeCap: 'butt', strokeJoin: 'miter', glow: null, shadow: null, blur: 0, filters: [],
clip: null, fogFraction: 0, mask: null, needsBuffer: false,
children: samples.map((sample, index) => ({
kind: 'point', path: `${procedural.path}#${item.ordinal}.trail[${index}]`, type: 'point',
z: sample.z ?? 0, perspective: 1,
alpha: fade + (1 - fade) * (index / Math.max(1, samples.length - 1)),
blend: style.blend ?? 'normal',
fill: style.fill ? { type: 'color', color: typeof style.fill === 'string' ? style.fill : '#ffffff' } : null,
stroke: null, strokeWidth: 0, strokeCap: 'butt', strokeJoin: 'miter', glow: null,
shadow: null, blur: 0, filters: [], clip: null, fogFraction: 0, children: [], mask: null,
needsBuffer: false,
center: project(sample), diameter: this.sceneScalar(ctx, style.pointSize ?? 1, sample.z ?? 0)
}))
};
}
const base = {
kind: 'shape', path: `${procedural.path}#${item.ordinal}.trail`, type: 'trail', z: head.z ?? 0,
perspective: 1, alpha: 1, blend: style.blend ?? 'normal',
strokeCap: style.strokeCap ?? 'butt', strokeJoin: style.strokeJoin ?? 'miter',
strokeDash: undefined, strokeDashOffset: 0, glow: null, shadow: null, blur: 0, filters: [],
clip: null, fogFraction: 0, children: [], mask: null, needsBuffer: false, fillRule: 'nonzero'
};
if (mode === 'ribbon') {
// 18.8: a ribbon is the same history buffer drawn with area instead of a
// stroke, its half-width interpolating from the head to `width * taper`.
const left = [];
const right = [];
for (let index = 0; index < samples.length; index += 1) {
const previous = samples[Math.max(0, index - 1)];
const next = samples[Math.min(samples.length - 1, index + 1)];
const angle = Math.atan2(next.y - previous.y, next.x - previous.x);
const fraction = index / Math.max(1, samples.length - 1);
const half = (width * (taper + (1 - taper) * fraction)) / 2;
const nx = -Math.sin(angle) * half;
const ny = Math.cos(angle) * half;
left.push(this.projectScene(ctx, samples[index].x + nx, samples[index].y + ny, samples[index].z ?? 0));
right.push(this.projectScene(ctx, samples[index].x - nx, samples[index].y - ny, samples[index].z ?? 0));
}
const outline = [...left, ...right.reverse()];
return {
...base,
fill: style.fill ? { type: 'color', color: typeof style.fill === 'string' ? style.fill : '#ffffff' } : null,
stroke: null, strokeWidth: 0, alpha: (1 + fade) / 2,
subpaths: [{ closed: true, start: outline[0], segments: outline.slice(1).map((point) => ({ type: 'line', to: point })) }]
};
}
const points = samples.map(project);
return {
...base,
fill: null,
stroke: style.stroke || style.fill ? { type: 'color', color: typeof (style.stroke ?? style.fill) === 'string' ? (style.stroke ?? style.fill) : '#ffffff' } : null,
strokeWidth: this.sceneScalar(ctx, width, head.z ?? 0),
alpha: (1 + fade) / 2,
subpaths: [{ closed: false, start: points[0], segments: points.slice(1).map((point) => ({ type: 'line', to: point })) }]
};
}
/** 18.8: link geometry borrows the item template's resolved style. */
emitLink(procedural, link, ctx) {
const spec = procedural.linksSpec;
const style = spec.style ?? procedural.items[0]?.node?.style ?? DEFAULT_STYLE;
const paint = style.stroke ?? style.fill;
return {
kind: 'shape', path: `${procedural.path}.links`, type: 'link', z: Math.min(link.from.z, link.to.z),
perspective: 1, alpha: link.opacity, blend: style.blend ?? 'normal',
fill: null,
stroke: typeof paint === 'string' ? { type: 'color', color: paint } : null,
strokeWidth: this.sceneScalar(ctx, style.strokeWidth ?? 1, link.from.z),
strokeCap: style.strokeCap ?? 'butt', strokeJoin: style.strokeJoin ?? 'miter',
strokeDash: undefined, strokeDashOffset: 0, glow: null, shadow: null, blur: 0, filters: [],
clip: null, fogFraction: 0, children: [], mask: null, needsBuffer: false, fillRule: 'nonzero',
subpaths: [{
closed: false,
start: this.projectScene(ctx, link.from.x, link.from.y, link.from.z),
segments: [{ type: 'line', to: this.projectScene(ctx, link.to.x, link.to.y, link.to.z) }]
}]
};
}
/** The single affine chain for a constant-depth object: `B x P x V x F x M`. */
deviceMatrix(matrix, zEffective, ctx) {
const k = ctx.projection === 'perspective' ? ctx.focalLength / (ctx.focalLength + zEffective) : 1;
+95
View File
@@ -0,0 +1,95 @@
// Procedural fields (18.7): named vector functions over scene space that other
// systems read. A field draws nothing and depends only on position and logical
// time, so there is no evaluation order to fix and no cycle to detect.
import { RuntimeFault } from './types.js';
import { DEGREES_TO_RADIANS } from './visual-math.js';
import { falloffFactor } from './visual-behaviors.js';
import { noiseVector, permutationTable } from './visual-noise.js';
export class FieldSet {
constructor(declarations = {}, { rng = null } = {}) {
this.fields = new Map();
for (const [id, declaration] of Object.entries(declarations)) {
const entry = { id, spec: declaration, table: null };
if (declaration.type === 'noise') {
if (!rng) throw new RuntimeFault('ERR_SCHEMA_VALIDATION', 'A noise field requires a seeded stream.', `$.visuals.fields.${id}`);
// 18.7: the permutation table is derived at field instantiation from
// the field's own stream, consuming exactly 255 samples.
entry.table = permutationTable(rng.stream('visual', id));
}
this.fields.set(id, entry);
}
this.evaluations = 0;
}
has(id) { return this.fields.has(id); }
/** The field's vector at a scene point at logical time `t` seconds. */
sample(id, x, y, t) {
const entry = this.fields.get(id);
if (!entry) throw new RuntimeFault('ERR_INVALID_REFERENCE', `No visual field '${id}' is declared.`, `$.visuals.fields.${id}`);
const spec = entry.spec;
if (spec.enabled === false) return [0, 0];
const box = spec.bounds;
if (box && (x < box.x || y < box.y || x > box.x + box.width || y > box.y + box.height)) return [0, 0];
if (this.evaluations >= (this.budget ?? Infinity)) { this.refused = true; return [0, 0]; }
this.evaluations += 1;
switch (spec.type) {
case 'directional': {
const radians = (spec.direction ?? 0) * DEGREES_TO_RADIANS;
const strength = spec.strength ?? 0;
return [strength * Math.cos(radians), strength * Math.sin(radians)];
}
case 'noise': {
const region = spec.center && spec.size;
if (region) {
const halfWidth = (spec.size.width ?? 0) / 2;
const halfHeight = (spec.size.height ?? 0) / 2;
if (Math.abs(x - spec.center.x) > halfWidth || Math.abs(y - spec.center.y) > halfHeight) return [0, 0];
}
const scale = spec.scale ?? 100;
return noiseVector(entry.table, x / scale, y / scale, t * (spec.speed ?? 0), {
mode: spec.mode ?? 'curl',
octaves: spec.octaves ?? 1,
persistence: spec.persistence ?? 0.5,
amplitude: spec.amplitude ?? 1,
direction: spec.direction ?? 0
});
}
default: {
const center = { x: spec.center?.x ?? 0, y: spec.center?.y ?? 0 };
const dx = x - center.x;
const dy = y - center.y;
const distance = Math.hypot(dx, dy);
if (spec.maxDistance !== undefined && distance > spec.maxDistance) return [0, 0];
const clamped = Math.max(spec.minDistance ?? 1, distance);
const magnitude = (spec.strength ?? 0) * falloffFactor(spec.falloff ?? 'linear', clamped, spec.maxDistance);
if (distance === 0) return [0, 0];
const ux = dx / distance;
const uy = dy / distance;
switch (spec.type) {
// 18.7: the outward ray, with negative strength pointing inward.
case 'radial': return [magnitude * ux, magnitude * uy];
case 'attractor': return [-magnitude * ux, -magnitude * uy];
case 'repulsor': return [magnitude * ux, magnitude * uy];
// 18.7: counter-clockwise as seen on the display, in y-down space.
case 'vortex': return [magnitude * uy, -magnitude * ux];
default: throw new RuntimeFault('ERR_INVALID_FIELD_TYPE', `Unknown field type '${spec.type}'.`, `$.visuals.fields.${id}`);
}
}
}
}
/** Sum a system's declared fields into one acceleration, in array order. */
sum(ids, x, y, t) {
let ax = 0;
let ay = 0;
for (const id of ids ?? []) {
const [fx, fy] = this.sample(id, x, y, t);
ax += fx;
ay += fy;
}
return [ax, ay];
}
}
+6 -3
View File
@@ -143,10 +143,11 @@ function linearSpline(points, closed) {
/** Endpoint-parameterized elliptical arc, converted to center parameterization. */
function endpointArc(current, from, command, path) {
const [rxRaw, ryRaw] = radiusPair(command.radius, path);
const rx = Math.abs(rxRaw);
const ry = Math.abs(ryRaw);
if (rxRaw <= 0 || ryRaw <= 0) throw new RuntimeFault('ERR_OUT_OF_BOUNDS', 'An arc radius component must be above zero.', path);
const to = point(command.to, path);
if (rx <= 0 || ry <= 0) throw new RuntimeFault('ERR_OUT_OF_BOUNDS', 'An arc radius component must be above zero.', path);
if (from[0] === to[0] && from[1] === to[1]) return;
const rx = rxRaw;
const ry = ryRaw;
const phi = (command.rotation ?? 0) * DEGREES_TO_RADIANS;
const cosPhi = Math.cos(phi);
const sinPhi = Math.sin(phi);
@@ -345,6 +346,8 @@ export function primitiveSubpaths(object, path = '$') {
hole.closed = true;
return inner > 0 ? [outer, hole] : [outer];
}
// C16: §17.9: sweep === 0 draws nothing and raises no diagnostic.
if (sweep === 0) return [];
const endAngle = startDegrees + sweep * sign;
const result = subpath([rx * Math.cos(startDegrees * DEGREES_TO_RADIANS), ry * Math.sin(startDegrees * DEGREES_TO_RADIANS)]);
arcSegments(result, 0, 0, rx, ry, startDegrees, sweep, sign);
+40
View File
@@ -0,0 +1,40 @@
import { RuntimeFault } from './types.js';
const VISUAL_TRANSITIONS = Object.freeze({
CREATED: ['ACTIVE', 'FINISHED', 'FAILED'], ACTIVE: ['RELEASING', 'FINISHED', 'FAILED'],
RELEASING: ['FINISHED', 'FAILED'], FINISHED: ['DISPOSED'], DISPOSED: [], FAILED: []
});
export class VisualInstance {
constructor({ id, template, system, at, lifetime = null, release = 0, owner = 'root', origin = null, cancelWithScenario = true }) {
Object.assign(this, { id, template, system, at, lifetime, release, owner, origin, cancelWithScenario });
this.state = 'CREATED'; this.factor = 1; this.history = ['CREATED'];
}
transition(next) {
if (!VISUAL_TRANSITIONS[this.state].includes(next)) throw new RuntimeFault('ERR_INVALID_STATE', `Cannot transition ${this.state} to ${next}.`);
this.state = next; this.history.push(next);
if (next === 'DISPOSED' || next === 'FAILED') {
this.system.objects.length = 0;
if (this.system.procedural) this.system.procedural.items.length = 0;
this.system.tracks = [];
}
}
remove(now, cleanup = false) {
if (this.state === 'CREATED') { this.transition('FINISHED'); return; }
if (this.state !== 'ACTIVE') return;
this.transition('RELEASING'); this.releaseAt = now;
this.releaseDuration = cleanup ? Math.min(this.release, 5000) : this.release;
if (this.system.procedural) this.system.procedural.releasing = true;
if (this.releaseDuration === 0) this.factor = 0;
}
advance(now) {
if (this.cleanupDeadline !== undefined && now > this.cleanupDeadline && !['DISPOSED', 'FAILED'].includes(this.state)) return 'forced';
if (this.state === 'CREATED') this.transition('ACTIVE');
if (this.state === 'ACTIVE' && this.lifetime !== null && now - this.at >= this.lifetime) { this.remove(now); return; }
if (this.state === 'RELEASING') {
this.factor = this.releaseDuration === 0 ? 0 : Math.max(0, 1 - (now - this.releaseAt) / this.releaseDuration);
if (this.factor === 0) this.transition('FINISHED');
}
if (this.state === 'FINISHED') this.transition('DISPOSED');
}
}
+79
View File
@@ -0,0 +1,79 @@
// Object-local behavior state, shared by graphics and procedural render trees.
import { createBehavior } from './visual-behaviors.js';
import { ProceduralSystem } from './visual-systems.js';
import { alongFlattened, flattenPath } from './visual-distributions.js';
import { localMatrix } from './visual-math.js';
import { primitiveSubpaths } from './visual-geometry.js';
import { parseDuration } from './types.js';
export function visualSetPath(object, path, value) {
const keys = path.replace(/\[(\d+)\]/g, '.$1').split('.');
const last = keys.pop();
for (const key of keys) object = object[key] ??= {};
object[last] = value;
}
export function visualGetPath(object, path) {
return path.replace(/\[(\d+)\]/g, '.$1').split('.').reduce((value, key) => value?.[key], object);
}
export function initializeVisualMotion(node, context) {
node.baseRaw = structuredClone(node.raw);
node.motion = {
base: { x: 0, y: 0, z: 0, rotation: 0 },
acc: { offset: { x: 0, y: 0, z: 0 }, rotation: 0 },
fresh: { offset: { x: 0, y: 0, z: 0 }, rotation: 0 },
scale: { x: 1, y: 1 }, opacityMultiplier: 1, channels: {},
vx: 0, vy: 0, vz: 0, trail: [], points: null,
behaviors: (node.raw.behaviors ?? []).map((spec, index) => createBehavior(spec, {
stream: context.stream, noiseTable: context.noiseTable, path: `${node.path}.behaviors[${index}]`
}))
};
}
export function advanceVisualMotion(node, dt, time, scene, fields, siblings = [node], visited = new Set()) {
if (visited.has(node)) return;
visited.add(node);
for (const behavior of node.baseRaw.behaviors ?? []) if (behavior.type === 'morph') {
const target = siblings.find(sibling => sibling.key === behavior.to);
if (target) advanceVisualMotion(target, dt, time, scene, fields, siblings, visited);
}
const item = node.motion;
const raw = structuredClone(node.baseRaw);
item.base = { x: raw.position?.x ?? 0, y: raw.position?.y ?? 0, z: raw.z ?? 0, rotation: raw.transform?.rotation ?? 0 };
const pointsOf = value => value?.points ?? value?.commands?.filter(command => command.op !== 'close');
item.points = pointsOf(raw) ?? null;
item.channels = {};
ProceduralSystem.prototype.advanceItem.call({ type: 'repeater', sampleTrail() {} }, item, dt, time, {
scene, duration: parseDuration, alongPath: alongFlattened,
resolvePath: key => flattenPath(siblings.find(sibling => sibling.key === key)?.raw.commands ?? key),
resolveMorphTarget: key => pointsOf(siblings.find(sibling => sibling.key === key)?.raw),
sampleField: (id, x, y, t) => fields?.sample(id, x, y, t)
});
// C3: Integrate velocity for graphic-object hosts into accumulated displacement.
item.acc.offset.x += (item.vx ?? 0) * dt;
item.acc.offset.y += (item.vy ?? 0) * dt;
item.acc.offset.z += (item.vz ?? 0) * dt;
for (const [key, value] of Object.entries(item.channels)) visualSetPath(raw, key, value);
raw.position = { x: item.base.x + item.acc.offset.x + item.fresh.offset.x, y: item.base.y + item.acc.offset.y + item.fresh.offset.y };
raw.z = item.base.z + item.acc.offset.z + item.fresh.offset.z;
const transform = raw.transform ??= {};
transform.rotation = item.base.rotation + item.acc.rotation + item.fresh.rotation;
transform.scale = { x: (transform.scale?.x ?? 1) * item.scale.x, y: (transform.scale?.y ?? 1) * item.scale.y };
node.raw = raw;
node.position = raw.position;
node.z = raw.z;
node.translateZ = transform.translate?.z ?? 0;
node.matrix = localMatrix({ ...transform, position: raw.position,
translate: { x: 0, y: 0, ...transform.translate }, origin: { x: 0, y: 0, ...transform.origin }, skew: { x: 0, y: 0, ...transform.skew } });
node.style = { ...node.style, ...raw.style };
if (['line', 'polyline', 'arc', 'bezier'].includes(node.type)) node.style.fill = null;
node.ownOpacity = (raw.style?.opacity ?? 1) * item.opacityMultiplier;
node.geometry = primitiveSubpaths(raw, node.path);
if (raw.lifetime !== undefined && time * 1000 >= parseDuration(raw.lifetime)) node.visible = false;
for (const child of node.children) {
child.style = { ...child.style };
for (const [key, value] of Object.entries(node.style)) if (!['opacity', 'clip', 'mask'].includes(key) && child.baseRaw.style?.[key] === undefined) child.style[key] = value;
advanceVisualMotion(child, dt, time, scene, fields, node.children, visited);
}
}
+131
View File
@@ -0,0 +1,131 @@
// The normative coherent noise of 18.7.
//
// Reproducibility (9.3) promises identical procedural decisions, not identical
// pixels — but a field that drove motion differently on two conforming
// renderers would make a fixture untestable, so the function is fixed as an
// algorithm rather than described. Every constant, the draw count of the
// shuffle, the octave normalization, and the three scalar-to-vector modes are
// exactly as 18.7 states them.
/** The twelve edge-midpoint vectors of a cube, in this order, unnormalized. */
export const GRADIENTS = Object.freeze([
[1, 1, 0], [-1, 1, 0], [1, -1, 0], [-1, -1, 0],
[1, 0, 1], [-1, 0, 1], [1, 0, -1], [-1, 0, -1],
[0, 1, 1], [0, -1, 1], [0, 1, -1], [0, -1, -1]
].map(Object.freeze));
/** The central-difference step for the derivative modes, in noise space. */
export const DERIVATIVE_STEP = 1e-3;
function fade(a) { return a * a * a * (a * (a * 6 - 15) + 10); }
function mix(s, a, b) { return a + s * (b - a); }
/**
* 18.7: a 256-entry permutation table shuffled by Fisher-Yates from the owning
* stream, consuming exactly 255 samples — one per swap, not one per entry — and
* duplicated into a 512-entry tail so the lattice hash needs no wrap arithmetic.
*/
export function permutationTable(stream) {
const p = new Uint8Array(256);
for (let index = 0; index < 256; index += 1) p[index] = index;
for (let index = 255; index >= 1; index -= 1) {
const swap = Math.floor(stream.nextFloat() * (index + 1));
const held = p[index];
p[index] = p[swap];
p[swap] = held;
}
const table = new Uint16Array(512);
for (let index = 0; index < 256; index += 1) {
table[index] = p[index];
table[index + 256] = p[index];
}
return table;
}
/** The number of samples `permutationTable` draws, stated so a trace can assert it. */
export const PERMUTATION_DRAWS = 255;
function gradientDot(hash, dx, dy, dz) {
const g = GRADIENTS[hash % 12];
return g[0] * dx + g[1] * dy + g[2] * dz;
}
/** One octave of three-dimensional gradient noise on the integer lattice. */
export function singleOctave(table, x, y, z) {
const fx = x - Math.floor(x);
const fy = y - Math.floor(y);
const fz = z - Math.floor(z);
const X = Math.floor(x) & 255;
const Y = Math.floor(y) & 255;
const Z = Math.floor(z) & 255;
const u = fade(fx);
const v = fade(fy);
const w = fade(fz);
const A = table[X] + Y;
const AA = table[A & 511] + Z;
const AB = table[(A + 1) & 511] + Z;
const B = table[X + 1] + Y;
const BA = table[B & 511] + Z;
const BB = table[(B + 1) & 511] + Z;
const value = mix(w,
mix(v,
mix(u, gradientDot(table[AA & 511], fx, fy, fz), gradientDot(table[BA & 511], fx - 1, fy, fz)),
mix(u, gradientDot(table[AB & 511], fx, fy - 1, fz), gradientDot(table[BB & 511], fx - 1, fy - 1, fz))),
mix(v,
mix(u, gradientDot(table[(AA + 1) & 511], fx, fy, fz - 1), gradientDot(table[(BA + 1) & 511], fx - 1, fy, fz - 1)),
mix(u, gradientDot(table[(AB + 1) & 511], fx, fy - 1, fz - 1), gradientDot(table[(BB + 1) & 511], fx - 1, fy - 1, fz - 1))));
// A guard, never a shaping step: with these gradients the magnitude does not
// exceed 1.
return Math.max(-1, Math.min(1, value));
}
/**
* 18.7: octaves sum at doubling frequency and `persistence` amplitude decay,
* normalized by the accumulated amplitude so `persistence: 0` stays defined.
*/
export function octaveNoise(table, x, y, z, octaves = 1, persistence = 0.5) {
let total = 0;
let amplitude = 1;
let frequency = 1;
let norm = 0;
for (let octave = 0; octave < octaves; octave += 1) {
total += amplitude * singleOctave(table, x * frequency, y * frequency, z * frequency);
norm += amplitude;
amplitude *= persistence;
frequency *= 2;
}
return norm === 0 ? 0 : total / norm;
}
/**
* 18.7: `curl` is the explicit perpendicular of the gradient of the scalar
* potential, which is divergence-free by construction; `gradient` is that
* gradient; `value` is the scalar along `direction` degrees.
*/
export function noiseVector(table, x, y, z, { mode = 'curl', octaves = 1, persistence = 0.5, amplitude = 1, direction = 0 } = {}) {
if (mode === 'value') {
const scalar = octaveNoise(table, x, y, z, octaves, persistence);
const radians = direction * (Math.PI / 180);
return [amplitude * scalar * Math.cos(radians), amplitude * scalar * Math.sin(radians)];
}
const h = DERIVATIVE_STEP;
const dx = (octaveNoise(table, x + h, y, z, octaves, persistence) - octaveNoise(table, x - h, y, z, octaves, persistence)) / (2 * h);
const dy = (octaveNoise(table, x, y + h, z, octaves, persistence) - octaveNoise(table, x, y - h, z, octaves, persistence)) / (2 * h);
if (mode === 'gradient') return [amplitude * dx, amplitude * dy];
return [amplitude * dy, -amplitude * dx];
}
/**
* The exhibit-wide table the four noise-using behaviors of 18.6 share. Deriving
* 255 samples for every particle would make instantiation quadratic in nothing
* an author asked for; per-object independence comes from the offset pair each
* behavior draws instead.
*/
export function behaviorNoiseTable(rng) {
return permutationTable(rng.stream('visual', 'behavior-noise'));
}
/** 18.7: each noise-using behavior draws one offset pair, uniform in [0, 1024). */
export function noiseOffsets(stream) {
return [stream.nextFloat() * 1024, stream.nextFloat() * 1024];
}
+8
View File
@@ -26,6 +26,7 @@ export class VisualSubsystem {
/** 17.14: activation is the instantiation boundary of every persistent system. */
activate(document, { resolution, rng }) {
this.engine?.dispose();
if (!document?.visuals) {
this.engine = null;
return null;
@@ -35,6 +36,11 @@ export class VisualSubsystem {
return this.engine;
}
/** One logical tick (9.1): procedural systems advance, nothing draws. */
advance(deltaMilliseconds) {
this.engine?.advance(deltaMilliseconds);
}
displayRectangle() {
const canvas = this.canvas;
if (!canvas) return { width: 0, height: 0 };
@@ -53,6 +59,7 @@ export class VisualSubsystem {
if (this.canvas.width !== plan.backing.width) this.canvas.width = plan.backing.width;
if (this.canvas.height !== plan.backing.height) this.canvas.height = plan.backing.height;
renderFrame(this.context, plan, {
warnEffect: (effect, message) => this.engine.cadence.once('WARN_VISUAL_APPROXIMATION', `effect:${effect.index}`, message),
createSurface: (surfaceWidth, surfaceHeight) => {
if (typeof OffscreenCanvas === 'function') return new OffscreenCanvas(surfaceWidth, surfaceHeight);
const surface = globalThis.document?.createElement('canvas');
@@ -68,6 +75,7 @@ export class VisualSubsystem {
}
deactivate() {
this.engine?.dispose();
if (this.context && this.canvas) {
this.context.setTransform(1, 0, 0, 1, 0, 0);
this.context.clearRect(0, 0, this.canvas.width, this.canvas.height);
+474
View File
@@ -0,0 +1,474 @@
// Procedural systems (18.2, 18.4, 18.5) with their trails and links (18.8).
//
// A system owns a bounded population of items. Its fields divide into three
// classes (18.2): system-instantiation values resolved once before any item
// exists, live system channels whose base is resolved there, and per-item
// values resolved at each item's creation from the stream child key
// `<system-path>#<ordinal>`. Motion integrates on the fixed logical tick of
// 9.1 with the normative semi-implicit Euler order, never on the frame.
import { RuntimeFault, isRecord, parseDuration } from './types.js';
import { DEGREES_TO_RADIANS } from './visual-math.js';
import { VISUAL_LIMITS } from './visual-contract.js';
import { advanceBehavior, createBehavior, curveValue } from './visual-behaviors.js';
import { alongFlattened, flattenPath, placeItem, requiresKnownCount } from './visual-distributions.js';
import { mixColor, parseColor } from './visual-math.js';
const SYSTEM_LIMITS = VISUAL_LIMITS.authoring;
/** 18.2: fields resolved once per item, at that item's creation. */
const PER_ITEM_KEYS = new Set(['distribution', 'velocity', 'size', 'rotation', 'angularVelocity', 'opacity', 'color', 'z', 'lifetime', 'align']);
function isLifeRamp(value) {
return isRecord(value) && Object.hasOwn(value, 'from') && Object.hasOwn(value, 'to');
}
function seconds(duration, path) {
return duration === undefined ? null : parseDuration(duration, path) / 1000;
}
export class ProceduralSystem {
constructor(id, spec, options) {
this.id = id;
this.spec = spec;
this.type = spec.type;
this.path = options.systemPath ?? `visuals.systems.${id}`;
this.resolver = options.resolver;
this.rng = options.rng;
this.noiseTable = options.noiseTable;
this.scene = options.scene ?? { x: 0, y: 0, width: 0, height: 0 };
this.fields = options.fields ?? null;
this.instantiateItemNode = options.instantiateItemNode;
this.layer = spec.layer ?? null;
this.time = 0;
this.ordinal = 0;
this.created = 0;
this.accumulator = 0;
this.burstsFired = new Set();
this.items = [];
const systemStream = this.rng ? this.rng.stream('visual', this.path) : null;
this.systemStream = systemStream;
const sample = (value, key) => this.resolver && value !== undefined
? this.resolver.evaluate(value, systemStream, `${this.path}.${key}`)
: value;
// ---- system-instantiation values (18.2) --------------------------------
this.capacity = spec.capacity ?? (this.type === 'emitter' ? 64 : 256);
this.limit = spec.limit;
const resolvedCount = sample(spec.count, 'count') ?? (this.type === 'repeater' ? 0 : 0);
const maximum = this.type === 'repeater' ? SYSTEM_LIMITS.repeaterCount : this.capacity;
if (resolvedCount > maximum) throw new RuntimeFault('ERR_VISUAL_LIMIT_EXCEEDED', 'Resolved initial count exceeds the system bound.', this.path);
// C18: Throw ERR_TYPE_MISMATCH on non-integer resolved count (§18.2).
if (Number.isFinite(resolvedCount) && !Number.isInteger(resolvedCount)) {
throw new RuntimeFault('ERR_TYPE_MISMATCH', `Resolved count ${resolvedCount} is not an integer.`, this.path);
}
this.count = Math.round(resolvedCount);
this.rate = sample(spec.rate, 'rate') ?? 0;
this.bursts = (spec.burst ?? []).map((entry, index) => {
const resolvedBurstCount = sample(entry.count, `burst[${index}].count`) ?? 0;
// C18: Throw ERR_TYPE_MISMATCH on non-integer resolved burst count.
if (Number.isFinite(resolvedBurstCount) && !Number.isInteger(resolvedBurstCount)) {
throw new RuntimeFault('ERR_TYPE_MISMATCH', `Resolved burst count ${resolvedBurstCount} is not an integer.`, `${this.path}.burst[${index}].count`);
}
return {
at: seconds(entry.at, `${this.path}.burst[${index}].at`) ?? 0,
count: Math.round(resolvedBurstCount)
};
});
this.itemLifetime = spec.lifetime;
this.trailSpec = spec.trail ?? null;
this.linksSpec = spec.links ?? null;
this.fieldIds = spec.fields ?? [];
this.distribution = spec.distribution ? sampleBehavior(spec.distribution, this.resolver, systemStream, `${this.path}.distribution`) : undefined;
// ---- live system channels: base resolved here (19.1 drives them) -------
this.position = { x: sample(spec.position?.x, 'position.x') ?? 0, y: sample(spec.position?.y, 'position.y') ?? 0 };
this.acceleration = {
x: sample(spec.acceleration?.x, 'acceleration.x') ?? 0,
y: sample(spec.acceleration?.y, 'acceleration.y') ?? 0,
z: sample(spec.acceleration?.z, 'acceleration.z') ?? 0
};
this.drag = sample(spec.drag, 'drag') ?? 0;
this.validate();
if (this.type === 'repeater') this.createRepeaterCopies();
else for (let index = 0; index < this.count; index += 1) this.createItem(index, this.count);
}
validate() {
if (!Number.isSafeInteger(this.capacity) || this.capacity < 1) throw new RuntimeFault('ERR_VISUAL_LIMIT_EXCEEDED', 'Capacity must be a positive bounded integer.', this.path);
if (!Number.isFinite(this.rate) || this.rate < 0 || this.rate > Number.MAX_SAFE_INTEGER) throw new RuntimeFault('ERR_OUT_OF_BOUNDS', 'Rate exceeds finite safe arithmetic.', this.path);
const maximum = this.type === 'repeater' ? SYSTEM_LIMITS.repeaterCount : this.capacity;
if (!Number.isFinite(this.count) || this.count < 0 || this.count > maximum) throw new RuntimeFault('ERR_VISUAL_LIMIT_EXCEEDED', 'Resolved initial count exceeds the system bound.', this.path);
if (!Number.isFinite(this.drag)) throw new RuntimeFault('ERR_OUT_OF_BOUNDS', 'Drag must be finite.', this.path);
this.drag = Math.max(0, Math.min(1, this.drag));
const emits = this.rate > 0 || this.bursts.length > 0;
// 18.2/18.4: emission with neither a per-item lifetime nor a total limit
// would create items without bound.
if (emits && this.itemLifetime === undefined && this.limit === undefined) {
throw new RuntimeFault('ERR_UNBOUNDED_EMISSION', 'Emission declares neither a lifetime nor a limit.', this.path);
}
if (this.type === 'particles' && this.capacity > SYSTEM_LIMITS.particleCapacity) {
throw new RuntimeFault('ERR_VISUAL_LIMIT_EXCEEDED', `A particle capacity above ${SYSTEM_LIMITS.particleCapacity}.`, this.path);
}
if (this.type === 'emitter' && this.capacity > SYSTEM_LIMITS.emitterCapacity) {
throw new RuntimeFault('ERR_VISUAL_LIMIT_EXCEEDED', `An emitter capacity above ${SYSTEM_LIMITS.emitterCapacity}.`, this.path);
}
if (this.type === 'repeater' && this.count > SYSTEM_LIMITS.repeaterCount) {
throw new RuntimeFault('ERR_VISUAL_LIMIT_EXCEEDED', `A repeater count above ${SYSTEM_LIMITS.repeaterCount}.`, this.path);
}
// 18.3: an index-driven placement needs a known count, so it is illegal on
// a continuous rate emission.
if (this.rate > 0 && requiresKnownCount(this.spec.distribution)) {
throw new RuntimeFault('ERR_INVALID_DISTRIBUTION', 'An index-driven placement has no count on a continuous rate emission.', `${this.path}.distribution`);
}
// 18.8: a pairwise link rule over a statically known population above 256.
const rule = this.linksSpec?.rule ?? 'distance';
if (this.linksSpec && rule !== 'index') {
const population = this.type === 'repeater' ? this.count : this.capacity;
if (population > SYSTEM_LIMITS.pairwiseLinkedPopulation) {
throw new RuntimeFault('ERR_VISUAL_LIMIT_EXCEEDED', `A '${rule}' link rule over a population above ${SYSTEM_LIMITS.pairwiseLinkedPopulation}.`, `${this.path}.links`);
}
}
if (this.linksSpec?.fadeWithDistance === true && this.linksSpec.maxDistance === undefined) {
throw new RuntimeFault('ERR_SCHEMA_VALIDATION', 'fadeWithDistance needs a maxDistance to normalize against.', `${this.path}.links`);
}
}
// -------------------------------------------------------------------------
// Item creation
// -------------------------------------------------------------------------
itemTemplateKey() {
return this.type === 'particles' ? 'render' : this.type === 'emitter' ? 'emit' : 'repeat';
}
/**
* 18.2: per-item fields are sampled in the system's own property-document
* order, from the stream child key `<system-path>#<ordinal>`, so one seed
* creates identical items in identical order however the ticks fall.
*/
createItem(index, count, { burstIndex = null } = {}) {
const ordinal = this.ordinal;
this.ordinal += 1;
this.created += 1;
const stream = this.rng ? this.rng.stream('visual', `${this.path}#${ordinal}`) : null;
const spec = this.spec;
const evaluate = (value, key) => value === undefined ? undefined : this.resolver.evaluate(value, stream, `${this.path}.${key}`);
const templateKey = this.itemTemplateKey();
const item = {
ordinal, index, burstIndex,
birth: this.time,
lifetime: null,
base: { x: this.position.x, y: this.position.y, z: 0, rotation: 0 },
acc: { offset: { x: 0, y: 0, z: 0 }, rotation: 0 },
fresh: { offset: { x: 0, y: 0, z: 0 }, rotation: 0 },
scale: { x: 1, y: 1 },
opacityMultiplier: 1,
channels: {},
vx: 0, vy: 0, vz: 0,
angularVelocity: 0,
size: 1, opacity: 1, color: null,
ramps: {},
behaviors: [],
trail: [],
node: null,
points: null
};
for (const key of Object.keys(spec)) {
if (!PER_ITEM_KEYS.has(key) && key !== templateKey && key !== 'behaviors') continue;
switch (key) {
case 'distribution': {
const place = placeItem(this.distribution, { index, count, stream, path: `${this.path}.distribution` });
item.base.x += place.x;
item.base.y += place.y;
item.base.z += place.z;
if (place.rotation !== undefined) item.base.rotation = place.rotation;
break;
}
case 'velocity': {
item.vx = evaluate(spec.velocity?.x, 'velocity.x') ?? 0;
item.vy = evaluate(spec.velocity?.y, 'velocity.y') ?? 0;
item.vz = evaluate(spec.velocity?.z, 'velocity.z') ?? 0;
break;
}
case 'size': item.ramps.size = this.rampOrValue(spec.size, evaluate, 'size', 1); break;
case 'opacity': item.ramps.opacity = this.rampOrValue(spec.opacity, evaluate, 'opacity', 1); break;
case 'color': item.ramps.color = this.rampOrValue(spec.color, evaluate, 'color', null); break;
case 'rotation': item.base.rotation = evaluate(spec.rotation, 'rotation') ?? item.base.rotation; break;
case 'angularVelocity': item.angularVelocity = evaluate(spec.angularVelocity, 'angularVelocity') ?? 0; break;
case 'z': item.base.z += evaluate(spec.z, 'z') ?? 0; break;
case 'lifetime': item.lifetime = seconds(evaluate(spec.lifetime, 'lifetime'), `${this.path}.lifetime`); break;
case 'align': item.align = spec.align === true; break;
case 'behaviors': {
item.behaviors = (spec.behaviors ?? []).map((behavior, position) => createBehavior(
this.resolver.evaluate ? sampleBehavior(behavior, this.resolver, stream, `${this.path}.behaviors[${position}]`) : behavior,
{ stream, noiseTable: this.noiseTable, path: `${this.path}.behaviors[${position}]` }
));
break;
}
default: {
if (key === templateKey) item.node = this.instantiateItemNode?.(spec[templateKey], stream, `${this.path}.${templateKey}#${ordinal}`, this.repeatScope(index, count));
break;
}
}
}
// 18.2: a life ramp on an item with no lifetime has no normalized age.
for (const [field, ramp] of Object.entries(item.ramps)) {
if (ramp?.isRamp && item.lifetime === null) {
throw new RuntimeFault('ERR_SCHEMA_VALIDATION', `A ${field} life ramp needs a lifetime.`, this.path);
}
}
if (item.align && (item.vx !== 0 || item.vy !== 0)) item.base.rotation = Math.atan2(item.vy, item.vx) / DEGREES_TO_RADIANS;
this.items.push(item);
return item;
}
repeatScope(index, count) {
if (this.type !== 'repeater') return null;
// 18.5: `repeat.*` is construct-scoped and grants no 8.1 capability.
return { repeat: { index, count, fraction: count <= 1 ? 0 : index / (count - 1) } };
}
rampOrValue(value, evaluate, key, fallback) {
if (value === undefined) return { isRamp: false, value: fallback };
if (isLifeRamp(value)) {
return {
isRamp: true,
from: evaluate(value.from, `${key}.from`),
to: evaluate(value.to, `${key}.to`),
curve: value.curve ?? 'linear'
};
}
return { isRamp: false, value: evaluate(value, key) };
}
createRepeaterCopies() {
for (let index = 0; index < this.count; index += 1) this.createItem(index, this.count);
}
// -------------------------------------------------------------------------
// Advance
// -------------------------------------------------------------------------
advance(dt, env = {}) {
this.time += dt;
if (this.type !== 'repeater' && !this.releasing) this.emit(dt);
const fieldEnv = {
...env,
scene: this.scene,
duration: (value) => parseDuration(value, this.path),
alongPath: alongFlattened,
resolvePath: (value) => flattenPath(value, this.path),
sampleField: (id, x, y, t) => this.fields?.sample(id, x, y, t)
};
const survivors = [];
for (const item of this.items) {
const age = this.time - item.birth;
if (item.lifetime !== null && age >= item.lifetime) continue;
if (!env.deferItems) this.advanceItem(item, dt, age, fieldEnv);
survivors.push(item);
}
this.items = survivors;
return fieldEnv;
}
emit(dt) {
const reachedLimit = () => this.limit !== undefined && this.created >= this.limit;
// 18.4: a burst emits its whole count on the first tick at or after its `at`.
this.bursts.forEach((burst, index) => {
if (this.burstsFired.has(index) || this.time < burst.at) return;
this.burstsFired.add(index);
const count = Math.min(burst.count, this.limit === undefined ? burst.count : Math.max(0, this.limit - this.created));
const skipped = Math.max(0, count - this.capacity);
this.ordinal += skipped; this.created += skipped;
for (let position = skipped; position < count; position += 1) {
if (reachedLimit()) return;
this.admit(() => this.createItem(position, burst.count, { burstIndex: index }));
}
});
if (this.rate <= 0) return;
// 18.4: a fractional accumulator, so the cumulative count after t seconds
// is exactly floor(rate * t), with no drift and no tick-alignment term.
this.accumulator += this.rate * dt;
const due = Math.floor(this.accumulator);
this.accumulator -= due;
const count = Math.min(due, this.limit === undefined ? due : Math.max(0, this.limit - this.created));
const skipped = Math.max(0, count - this.capacity);
this.ordinal += skipped; this.created += skipped;
for (let position = skipped; position < count; position += 1) {
if (reachedLimit()) return;
this.admit(() => this.createItem(this.ordinal, 1));
}
}
/** 18.2/18.4: at capacity the oldest live item is evicted and replaced. */
admit(create) {
if (this.items.length >= this.capacity) {
let oldest = 0;
for (let index = 1; index < this.items.length; index += 1) {
if (this.items[index].ordinal < this.items[oldest].ordinal) oldest = index;
}
this.items.splice(oldest, 1);
}
create();
}
advanceItem(item, dt, age, env) {
// Fresh contributions are recomputed every tick; accumulated ones persist.
item.fresh.offset.x = 0; item.fresh.offset.y = 0; item.fresh.offset.z = 0;
item.fresh.rotation = 0;
item.scale.x = 1; item.scale.y = 1;
item.opacityMultiplier = 1;
if (this.type !== 'repeater') {
const [fx, fy] = this.fields ? this.fields.sum(this.fieldIds, item.base.x + item.acc.offset.x, item.base.y + item.acc.offset.y, this.time) : [0, 0];
// 18.2: v <- (v + a*dt) * (1 - drag)^dt, then p <- p + v*dt.
const decay = (1 - this.drag) ** dt;
item.vx = (item.vx + (this.acceleration.x + fx) * dt) * decay;
item.vy = (item.vy + (this.acceleration.y + fy) * dt) * decay;
item.vz = (item.vz + this.acceleration.z * dt) * decay;
item.base.x += item.vx * dt;
item.base.y += item.vy * dt;
item.base.z += item.vz * dt;
item.base.rotation += (item.angularVelocity ?? 0) * dt;
}
for (const instance of item.behaviors) {
const bucket = instance.accumulating ? item.acc : item.fresh;
const view = {
base: item.base, points: item.points, channels: item.channels,
offset: bucket.offset, rotationOffset: 0,
scale: item.scale, opacityMultiplier: 1,
vx: item.vx, vy: item.vy, vz: item.vz
};
advanceBehavior(instance, view, { dt, time: age, env });
bucket.rotation += view.rotationOffset;
item.opacityMultiplier *= view.opacityMultiplier;
item.vx = view.vx; item.vy = view.vy; item.vz = view.vz;
}
this.sampleTrail(item);
}
/** 18.8: history is sampled on the logical clock, never on the frame. */
sampleTrail(item) {
if (!this.trailSpec) return;
const length = this.trailSpec.length ?? 16;
if (length > SYSTEM_LIMITS.trailLength) throw new RuntimeFault('ERR_VISUAL_LIMIT_EXCEEDED', `A trail length above ${SYSTEM_LIMITS.trailLength}.`, `${this.path}.trail`);
const interval = this.trailSpec.interval ? parseDuration(this.trailSpec.interval) / 1000 : null;
const last = item.trail.at(-1);
if (interval !== null && last && this.time - last.t < interval) return;
const position = this.itemPosition(item);
item.trail.push({ x: position.x, y: position.y, z: position.z, t: this.time });
while (item.trail.length > length) item.trail.shift();
}
itemPosition(item) {
return {
x: item.base.x + item.acc.offset.x + item.fresh.offset.x,
y: item.base.y + item.acc.offset.y + item.fresh.offset.y,
z: item.base.z + item.acc.offset.z + item.fresh.offset.z,
rotation: item.base.rotation + item.acc.rotation + item.fresh.rotation
};
}
/** Normalized age drives every life ramp (18.2). */
itemAge(item) {
if (item.lifetime === null || item.lifetime === 0) return 0;
return Math.max(0, Math.min(1, (this.time - item.birth) / item.lifetime));
}
rampValue(ramp, age) {
if (!ramp) return undefined;
if (!ramp.isRamp) return ramp.value;
const amount = curveValue(ramp.curve, age);
if (typeof ramp.from === 'string') return mixColor(parseColor(ramp.from), parseColor(ramp.to), amount);
return ramp.from + (ramp.to - ramp.from) * amount;
}
// -------------------------------------------------------------------------
// Links (18.8)
// -------------------------------------------------------------------------
/**
* 18.8: pairs are enumerated in ascending (lower index, higher index) order
* and de-duplicated, so a pair is drawn once regardless of rule. The live
* ordering is re-indexed densely each tick, so a death closes a chain's gap.
*/
linkPairs() {
if (!this.linksSpec) return [];
const ordered = [...this.items].sort((a, b) => a.ordinal - b.ordinal);
const positions = ordered.map((item) => this.itemPosition(item));
const rule = this.linksSpec.rule ?? 'distance';
const maxDistance = this.linksSpec.maxDistance;
const pairs = new Set();
if (rule === 'index') {
const stride = this.linksSpec.stride ?? 1;
for (let index = 0; index + stride < ordered.length; index += 1) pairs.add(`${index}:${index + stride}`);
if (this.linksSpec.closed === true && ordered.length > 2) pairs.add(`0:${ordered.length - 1}`);
} else if (rule === 'nearest') {
const count = this.linksSpec.count ?? 1;
for (let index = 0; index < ordered.length; index += 1) {
const candidates = [];
for (let other = 0; other < ordered.length; other += 1) {
if (other === index) continue;
const distance = Math.hypot(positions[other].x - positions[index].x, positions[other].y - positions[index].y);
if (maxDistance !== undefined && distance > maxDistance) continue;
candidates.push({ other, distance });
}
// Equidistant neighbours break by ascending creation ordinal, which the
// dense live ordering already expresses.
candidates.sort((a, b) => (a.distance - b.distance) || (a.other - b.other));
for (const candidate of candidates.slice(0, count)) {
const low = Math.min(index, candidate.other);
const high = Math.max(index, candidate.other);
pairs.add(`${low}:${high}`);
}
}
} else {
for (let index = 0; index < ordered.length; index += 1) {
for (let other = index + 1; other < ordered.length; other += 1) {
const distance = Math.hypot(positions[other].x - positions[index].x, positions[other].y - positions[index].y);
if (distance <= (maxDistance ?? Infinity)) pairs.add(`${index}:${other}`);
}
}
}
const sorted = [...pairs].map((key) => key.split(':').map(Number)).sort((a, b) => (a[0] - b[0]) || (a[1] - b[1]));
const maxLinks = this.linksSpec.maxLinks ?? 256;
const kept = sorted.slice(0, maxLinks);
return kept.map(([low, high]) => {
const distance = Math.hypot(positions[high].x - positions[low].x, positions[high].y - positions[low].y);
const fade = this.linksSpec.fadeWithDistance === true && maxDistance ? Math.max(0, 1 - distance / maxDistance) : 1;
return { from: positions[low], to: positions[high], opacity: fade, distance };
});
}
/** 17.6: a procedural system is one atomic sortable unit. */
representativeDepth() {
if (this.items.length === 0) return 0;
let lowest = Infinity;
for (const item of this.items) lowest = Math.min(lowest, this.itemPosition(item).z);
return lowest;
}
}
function sampleBehavior(behavior, resolver, stream, path) {
if (Array.isArray(behavior)) return behavior.map((entry, index) => isRecord(entry) || Array.isArray(entry) ? sampleBehavior(entry, resolver, stream, `${path}[${index}]`) : entry);
const result = {};
for (const key of Object.keys(behavior)) {
const value = behavior[key];
if (isRecord(value) && (Object.hasOwn(value, 'ref') || Object.hasOwn(value, 'random') || Object.hasOwn(value, 'choose') || (Object.hasOwn(value, 'op') && Array.isArray(value.args)))) {
result[key] = resolver.evaluate(value, stream, `${path}.${key}`);
} else if (isRecord(value)) {
result[key] = sampleBehavior(value, resolver, stream, `${path}.${key}`);
} else {
result[key] = value;
}
}
return result;
}
+187 -13
View File
@@ -1,3 +1,4 @@
import { behaviorChannels } from './visual-behaviors.js';
// Structural and semantic validation of the `visuals` block, sections 17-19 at
// Format Specification revision 0.8.
//
@@ -25,6 +26,7 @@ import {
LOOP_MODES,
POST_EFFECTS,
SPAWN_FIELDS,
VISUAL_BEHAVIOR_TYPES,
VISUAL_FIELD_TYPES,
VISUAL_LIMITS,
VISUAL_SYSTEM_TYPES
@@ -181,7 +183,12 @@ function validateLayers(document, layers, errors, { validateValueSpec, pushError
if (!isRecord(layer)) { fail('ERR_SCHEMA_VALIDATION', path, 'Layer must be an object.'); continue; }
for (const field of Object.keys(layer)) if (!LAYER_FIELDS.has(field)) fail('ERR_UNKNOWN_FIELD', `${path}.${field}`, `Unrecognized layer field '${field}'.`);
if (layer.opacity !== undefined) validateValueSpec(document, layer.opacity, `${path}.opacity`, errors);
if (layer.visible !== undefined) validateValueSpec(document, layer.visible, `${path}.visible`, errors);
if (layer.visible !== undefined) {
validateValueSpec(document, layer.visible, `${path}.visible`, errors);
if (typeof layer.visible !== 'boolean' && !isRecord(layer.visible)) {
fail('ERR_TYPE_MISMATCH', `${path}.visible`, 'visible must be a boolean.');
}
}
if (layer.blend !== undefined && !BLEND_MODES.includes(layer.blend)) fail('ERR_SCHEMA_VALIDATION', `${path}.blend`, `Unsupported blend mode '${layer.blend}'.`);
if (layer.parallax !== undefined && !Number.isFinite(layer.parallax)) fail('ERR_TYPE_MISMATCH', `${path}.parallax`, 'parallax must be a finite number.');
}
@@ -229,7 +236,12 @@ function validateEffects(document, effects, errors, { validateValueSpec, pushErr
fail('ERR_UNKNOWN_FIELD', `${path}.${field}`, `Effect '${entry.type}' declares no parameter '${field}'.`);
}
}
if (entry.enabled !== undefined) validateValueSpec(document, entry.enabled, `${path}.enabled`, errors);
if (entry.enabled !== undefined) {
validateValueSpec(document, entry.enabled, `${path}.enabled`, errors);
if (typeof entry.enabled !== 'boolean' && !isRecord(entry.enabled)) {
fail('ERR_TYPE_MISMATCH', `${path}.enabled`, 'enabled must be a boolean.');
}
}
for (const [parameter, range] of Object.entries(definition.numeric)) {
const value = entry[parameter];
if (value === undefined) continue;
@@ -262,7 +274,12 @@ function validateFields(document, fields, errors, { validateValueSpec, pushError
if (!VISUAL_FIELD_TYPES.includes(field.type)) { fail('ERR_INVALID_FIELD_TYPE', `${path}.type`, `Unsupported field type '${field.type}'.`); continue; }
const allowed = new Set([...FIELD_COMMON, ...FIELD_TYPE_FIELDS[field.type]]);
for (const key of Object.keys(field)) if (!allowed.has(key)) fail('ERR_UNKNOWN_FIELD', `${path}.${key}`, `Field type '${field.type}' declares no '${key}'.`);
if (field.enabled !== undefined) validateValueSpec(document, field.enabled, `${path}.enabled`, errors);
if (field.enabled !== undefined) {
validateValueSpec(document, field.enabled, `${path}.enabled`, errors);
if (typeof field.enabled !== 'boolean' && !isRecord(field.enabled)) {
fail('ERR_TYPE_MISMATCH', `${path}.enabled`, 'enabled must be a boolean.');
}
}
if (field.type === 'noise') {
const mode = field.mode ?? 'curl';
if (!['curl', 'gradient', 'value'].includes(mode)) fail('ERR_SCHEMA_VALIDATION', `${path}.mode`, `Unsupported noise mode '${field.mode}'.`);
@@ -301,6 +318,10 @@ function validateSystems(document, systems, layers, fields, errors, { validateVa
}
if (system.visible !== undefined) validateValueSpec(document, system.visible, `${path}.visible`, errors);
// C11: Type-check boolean leaves (§2: no coercion).
if (system.visible !== undefined && typeof system.visible !== 'boolean' && !isRecord(system.visible)) {
fail('ERR_TYPE_MISMATCH', `${path}.visible`, 'visible must be a boolean.');
}
const lifecycle = system.lifecycle ?? 'persistent';
if (!LIFECYCLE_MODES.includes(lifecycle)) fail('ERR_SCHEMA_VALIDATION', `${path}.lifecycle`, `Unsupported lifecycle '${system.lifecycle}'.`);
@@ -315,15 +336,50 @@ function validateSystems(document, systems, layers, fields, errors, { validateVa
fail('ERR_UNKNOWN_FIELD', `${path}.lifetime`, `A '${system.type}' system declares no lifetime; a spawned instance's duration is spawn.lifetime.`);
}
validateSpawn(document, system, lifecycle, path, errors, { validateValueSpec, pushError });
// C9: Enforce per-type required fields at import (§18.2/18.4).
if (system.type === 'particles' && system.render === undefined) {
fail('ERR_SCHEMA_VALIDATION', `${path}.render`, "A 'particles' system requires 'render'.");
}
if (system.type === 'emitter' && system.emit === undefined) {
fail('ERR_SCHEMA_VALIDATION', `${path}.emit`, "An 'emitter' system requires 'emit'.");
}
// C9: Enforce the static half of the unbounded-emission rule at import (§18.2/18.4).
const emitsStatically = (typeof system.rate === 'number' ? system.rate > 0 : system.rate !== undefined) || (Array.isArray(system.burst) && system.burst.length > 0);
if ((system.type === 'particles' || system.type === 'emitter') && emitsStatically && system.lifetime === undefined && system.limit === undefined) {
fail('ERR_UNBOUNDED_EMISSION', path, 'Emission declares neither a lifetime nor a limit.');
}
if (system.automation !== undefined && !Array.isArray(system.automation)) fail('ERR_SCHEMA_VALIDATION', `${path}.automation`, 'automation must be an array.');
// 18.8: an emitter's population changes continuously, so its link set
// would have to be rebuilt every tick at the cost the ceiling prevents.
if (system.type === 'emitter' && system.links !== undefined) {
fail('ERR_UNKNOWN_FIELD', `${path}.links`, 'An emitter declares no links.');
}
if (system.type === 'graphic') {
for (const field of ['render', 'emit', 'repeat', 'capacity', 'count', 'rate', 'burst', 'limit', 'distribution', 'velocity', 'acceleration', 'drag', 'trail', 'links']) {
if (system[field] !== undefined) fail('ERR_UNKNOWN_FIELD', `${path}.${field}`, `A graphic system declares no '${field}'.`);
}
}
if (system.type === 'repeater') {
// C8: Reject emitter/particle-only fields on repeaters (§18.5).
for (const field of ['rate', 'burst', 'limit', 'capacity', 'trail']) {
if (system[field] !== undefined) fail('ERR_UNKNOWN_FIELD', `${path}.${field}`, `A repeater system declares no '${field}'.`);
}
}
if (Array.isArray(system.behaviors) && system.behaviors.length > AUTHORING.behaviorsPerObject) {
fail('ERR_VISUAL_LIMIT_EXCEEDED', `${path}.behaviors`, `At most ${AUTHORING.behaviorsPerObject} behaviors.`);
}
if (Array.isArray(system.behaviors)) {
// C2: Run semantic validation on system-level behavior arrays at import.
validateBehaviors(document, system, path, errors, { validateValueSpec, pushError }, { fields });
}
// The drawn object tree. 18.2, 18.4, and 18.5 each say the owning system
// owns the item's placement, so those fields are unknown on the template.
if (system.type === 'graphic') validateVisualObjectMap(document, system.content, `${path}.content`, errors, { validateValueSpec, pushError });
if (system.type === 'particles' && system.render !== undefined) validateVisualObject(document, system.render, `${path}.render`, errors, { validateValueSpec, pushError }, { ownedByOwner: ['position', 'z', 'visible'] });
if (system.type === 'emitter' && system.emit !== undefined) validateVisualObject(document, system.emit, `${path}.emit`, errors, { validateValueSpec, pushError }, { ownedByOwner: ['position', 'z', 'visible'] });
if (system.type === 'repeater' && system.repeat !== undefined) validateVisualObject(document, system.repeat, `${path}.repeat`, errors, { validateValueSpec, pushError }, { ownedByOwner: ['position', 'z'] });
if (system.type === 'graphic') validateVisualObjectMap(document, system.content, `${path}.content`, errors, { validateValueSpec, pushError }, { fields });
if (system.type === 'particles' && system.render !== undefined) validateVisualObject(document, system.render, `${path}.render`, errors, { validateValueSpec, pushError }, { ownedByOwner: ['position', 'z', 'visible'], fields });
if (system.type === 'emitter' && system.emit !== undefined) validateVisualObject(document, system.emit, `${path}.emit`, errors, { validateValueSpec, pushError }, { ownedByOwner: ['position', 'z', 'visible'], fields });
if (system.type === 'repeater' && system.repeat !== undefined) validateVisualObject(document, system.repeat, `${path}.repeat`, errors, { validateValueSpec, pushError }, { ownedByOwner: ['position', 'z'], fields });
if (Array.isArray(system.fields)) {
if (system.fields.length > AUTHORING.referencedFieldsPerSystem) fail('ERR_VISUAL_LIMIT_EXCEEDED', `${path}.fields`, `A system may reference at most ${AUTHORING.referencedFieldsPerSystem} fields.`);
system.fields.forEach((reference, index) => {
@@ -345,7 +401,12 @@ function validateSpawn(document, system, lifecycle, path, errors, { validateValu
}
for (const field of ['lifetime', 'release']) {
const value = spawn[field];
if (value !== undefined && (typeof value !== 'string' || !DURATION_PATTERN.test(value))) fail('ERR_INVALID_DURATION', `${path}.spawn.${field}`, `spawn.${field} must be a duration literal.`);
if (value === undefined) continue;
// C5 (6.1): a duration is a literal string, or a non-negative finite number
// already expressed in milliseconds. Both forms reach `parseDuration`.
const literal = typeof value === 'string' && DURATION_PATTERN.test(value);
const milliseconds = typeof value === 'number' && Number.isFinite(value) && value >= 0;
if (!literal && !milliseconds) fail('ERR_INVALID_DURATION', `${path}.spawn.${field}`, `spawn.${field} must be a duration literal or a non-negative number of milliseconds.`);
}
if (spawn.ownership !== undefined && spawn.ownership !== 'persistent') fail('ERR_SCHEMA_VALIDATION', `${path}.spawn.ownership`, "spawn.ownership accepts only 'persistent'.");
if (spawn.cancelWithScenario !== undefined) {
@@ -405,7 +466,7 @@ function validateAutomationArray(document, tracks, path, scope, errors, helpers)
// so the strictly-increasing rule stays decidable at import.
if (typeof point.at !== 'string' || !DURATION_PATTERN.test(point.at)) fail('ERR_INVALID_DURATION', `${pointPath}.at`, 'Point at must be a duration literal.');
else {
const milliseconds = durationMilliseconds(point.at);
const milliseconds = trackPointMilliseconds(point.at);
if (milliseconds <= previous) fail('ERR_INVALID_RANGE_ORDER', `${pointPath}.at`, 'Automation point times must be strictly increasing.');
previous = milliseconds;
}
@@ -417,12 +478,23 @@ function validateAutomationArray(document, tracks, path, scope, errors, helpers)
const resolution = resolveAutomationTarget(track.target, scope);
if (resolution.code) fail(resolution.code, `${location}.target`, resolution.message);
else if (written.has(resolution.key)) fail('ERR_AUTOMATION_CONFLICT', `${location}.target`, `More than one track controls '${track.target}'.`);
else written.add(resolution.key);
else {
written.add(resolution.key);
if (scope.system?.type === 'graphic') {
const parts = track.target.split('.');
let object = scope.system.content?.[parts.shift()];
while (object?.children?.[parts[0]]) object = object.children[parts.shift()];
const property = parts.join('.');
if ((object?.behaviors ?? []).some(behavior => behaviorChannels(behavior).some(channel => channel === property || (channel === 'points[*].x' && /^points\[\d+\]\.x$/.test(property)) || (channel === 'points[*].y' && /^points\[\d+\]\.y$/.test(property))))) {
fail('ERR_AUTOMATION_CONFLICT', `${location}.target`, 'A behavior and automation write the same channel.');
}
}
}
});
return { tracks: tracks.length, points };
}
function durationMilliseconds(value) {
function trackPointMilliseconds(value) {
const [, scalar, unit] = DURATION_PATTERN.exec(value);
return Number(scalar) * { ms: 1, s: 1000, m: 60_000, h: 3_600_000 }[unit];
}
@@ -555,7 +627,9 @@ export function validateVisualObjectMap(document, container, path, errors, helpe
if (Object.keys(container).length === 0) return pushError(errors, 'ERR_SCHEMA_VALIDATION', path, 'An empty object container is not a container.');
for (const [key, object] of Object.entries(container)) {
if (!ID_PATTERN.test(key)) pushError(errors, 'ERR_INVALID_ID', `${path}.${key}`, `Object key '${key}' is invalid.`);
validateVisualObject(document, object, `${path}.${key}`, errors, helpers, { ...options, depth: options.depth ?? 1 });
// 18.6: a `morph` and a `follow-path` name a *sibling* key, so the
// container travels with the object being validated.
validateVisualObject(document, object, `${path}.${key}`, errors, helpers, { ...options, depth: options.depth ?? 1, siblings: container });
}
}
@@ -622,9 +696,14 @@ export function validateVisualObject(document, object, path, errors, helpers, op
}
validateVisualStyle(document, object, type, path, errors, helpers);
// C11: Type-check boolean leaves for visual objects.
if (object.visible !== undefined && typeof object.visible !== 'boolean' && !isRecord(object.visible)) {
fail('ERR_TYPE_MISMATCH', `${path}.visible`, 'visible must be a boolean.');
}
validateBehaviors(document, object, path, errors, helpers, options);
if (type === 'group') {
validateVisualObjectMap(document, object.children, `${path}.children`, errors, helpers, { ...options, depth: depth + 1, ownedByOwner: [] });
validateVisualObjectMap(document, object.children, `${path}.children`, errors, helpers, { ...options, depth: depth + 1, ownedByOwner: [], siblings: object.children });
if (object.style?.mask !== undefined && object.style.mask !== null) {
if (!isRecord(object.children) || !Object.hasOwn(object.children, object.style.mask)) {
fail('ERR_INVALID_REFERENCE', `${path}.style.mask`, `mask '${object.style.mask}' names no child of this group.`);
@@ -720,3 +799,98 @@ function validatePathCommands(commands, path, errors, { pushError }) {
}
});
}
// ---------------------------------------------------------------------------
// Behaviors (18.6)
// ---------------------------------------------------------------------------
/** 18.6: the channels `oscillate`, `pulse`, and `twinkle` may name. */
const BEHAVIOR_PROPERTY_CHANNELS = new Set([
'position.x', 'position.y', 'z',
'transform.rotation', 'transform.scale.x', 'transform.scale.y',
'style.opacity', 'style.strokeWidth', 'style.pointSize',
'size.width', 'size.height', 'radius'
]);
/** Which primitives actually have each conditional channel (18.6). */
const CHANNEL_OWNERS = Object.freeze({
'size.width': new Set(['rectangle', 'rounded-rectangle']),
'size.height': new Set(['rectangle', 'rounded-rectangle']),
radius: new Set(['ellipse', 'arc', 'ring', 'rounded-rectangle']),
'style.pointSize': new Set(['point'])
});
/** 18.6: `point-wander` needs an owner with an addressable point list. */
const POINT_LIST_TYPES = new Set(['spline', 'polyline', 'polygon']);
/** 18.6/V15: morph interpolates a point list, so only these types participate. */
const MORPH_TYPES = new Set(['polyline', 'polygon', 'spline']);
const MORPH_PATH_OPS = new Set(['move', 'line', 'close']);
function pathOps(object) {
return (object.commands ?? []).map((command) => command?.op);
}
function morphPointCount(object) {
if (object.type === 'path') return pathOps(object).filter((op) => op !== 'close').length;
return (object.points ?? []).length;
}
export function validateBehaviors(document, owner, path, errors, helpers, options = {}) {
const { pushError } = helpers;
const fail = (code, location, message) => pushError(errors, code, location, message);
const behaviors = owner.behaviors;
if (!Array.isArray(behaviors)) return;
behaviors.forEach((behavior, index) => {
const location = `${path}.behaviors[${index}]`;
if (!isRecord(behavior)) return fail('ERR_SCHEMA_VALIDATION', location, 'A behavior must be an object.');
if (!VISUAL_BEHAVIOR_TYPES.includes(behavior.type)) {
return fail('ERR_INVALID_BEHAVIOR_TYPE', `${location}.type`, `'${behavior.type}' is outside Visual Behavior Set 0.1.`);
}
if (['oscillate', 'pulse', 'twinkle'].includes(behavior.type)) {
const channel = behavior.property ?? (behavior.type === 'twinkle' ? 'style.opacity' : undefined);
if (channel === undefined) fail('ERR_SCHEMA_VALIDATION', `${location}.property`, `A '${behavior.type}' behavior requires a property.`);
else if (!BEHAVIOR_PROPERTY_CHANNELS.has(channel)) fail('ERR_INVALID_BEHAVIOR_TARGET', `${location}.property`, `'${channel}' is outside the behavior channel set.`);
else {
const owners = CHANNEL_OWNERS[channel];
if (owners && !owners.has(owner.type)) fail('ERR_INVALID_BEHAVIOR_TARGET', `${location}.property`, `A '${owner.type}' has no '${channel}'.`);
}
}
if (behavior.type === 'point-wander' && !POINT_LIST_TYPES.has(owner.type)) {
fail('ERR_INVALID_BEHAVIOR_TARGET', location, `point-wander requires a point list; a '${owner.type}' has none.`);
}
if (behavior.type === 'follow-path') {
const hasSpeed = behavior.speed !== undefined;
const hasDuration = behavior.duration !== undefined;
if (hasSpeed === hasDuration) fail('ERR_SCHEMA_VALIDATION', location, 'follow-path requires exactly one of speed and duration.');
}
if (behavior.type === 'field-follow') {
if (!options.fields?.has(behavior.field)) fail('ERR_INVALID_REFERENCE', `${location}.field`, `Field '${behavior.field}' is not declared.`);
}
if (behavior.type === 'morph') {
const siblings = options.siblings ?? {};
const target = siblings[behavior.to];
if (!isRecord(target)) fail('ERR_INVALID_REFERENCE', `${location}.to`, `morph target '${behavior.to}' names no sibling.`);
else if (!MORPH_TYPES.has(owner.type) || !MORPH_TYPES.has(target.type)) {
fail('ERR_MORPH_INCOMPATIBLE', location, 'morph is defined only between point-list geometries.');
} else if (owner.type !== target.type) {
fail('ERR_MORPH_INCOMPATIBLE', location, 'morph requires the same primitive type.');
} else if (morphPointCount(owner) !== morphPointCount(target)) {
fail('ERR_MORPH_INCOMPATIBLE', location, 'morph requires the same point count.');
} else if (owner.type === 'spline' && (owner.mode ?? 'catmull-rom') !== (target.mode ?? 'catmull-rom')) {
fail('ERR_MORPH_INCOMPATIBLE', location, 'morph requires the same spline mode.');
} else if (owner.type === 'path') {
const ours = pathOps(owner);
const theirs = pathOps(target);
if (ours.some((op) => !MORPH_PATH_OPS.has(op)) || theirs.some((op) => !MORPH_PATH_OPS.has(op))) {
fail('ERR_MORPH_INCOMPATIBLE', location, 'a curved path command carries control points a point list cannot express.');
} else if (ours.join(',') !== theirs.join(',')) {
fail('ERR_MORPH_INCOMPATIBLE', location, 'morph requires an identical path command sequence.');
}
}
// 18.6/V15: target points are read live, so a mutual pair has no order.
const back = (target?.behaviors ?? []).some((entry) => entry?.type === 'morph' && siblings[entry.to] === owner);
if (back) fail('ERR_CYCLIC_DEPENDENCY', location, 'two objects cannot morph onto each other.');
}
});
}
+27 -1
View File
@@ -9,7 +9,7 @@ import { Diagnostics } from '../src/runtime/diagnostics.js';
import { LibraryManager } from '../src/runtime/library.js';
import { deriveStreamState, SeededRNG } from '../src/runtime/rng.js';
import { parseAndValidateExhibit } from '../src/runtime/validator.js';
import { buildStandalone } from '../tools/build-xzbt.mjs';
import { buildStandalone, bundleRuntime } from '../tools/build-xzbt.mjs';
const fixedSource = readFileSync(resolve('exhibits/minimal-fixed.xzbt'), 'utf8');
const randomSource = readFileSync(resolve('exhibits/minimal-random.xzbt'), 'utf8');
@@ -79,8 +79,34 @@ test('two clean standalone builds are byte-identical and self-contained', () =>
// A module import *statement*, not the word in a comment: the bundler strips
// the former, and prose about import-stage validation is not a leak.
assert.doesNotMatch(html, /^[ \t]*import[ \t][^;(\n]*from[ \t]*['"]/m);
// C14: Also catch side-effect imports (import './chunk.js';).
assert.doesNotMatch(html, /^[ \t]*import[ \t]*['"]/m);
assert.doesNotMatch(html, /^[ \t]*export[ \t]/m);
assert.equal(createHash('sha256').update(firstBytes).digest('hex'), createHash('sha256').update(secondBytes).digest('hex'));
const script = html.match(/<script>([\s\S]*)<\/script>/)[1];
assert.doesNotThrow(() => new Function(script));
});
test('no two bundled modules declare the same top-level identifier', () => {
// The bundler strips imports and exports and concatenates into one scope, so
// two modules that each declare a private helper of the same name produce a
// SyntaxError in the artifact while every unit test still passes.
const sources = bundleRuntime();
const declarations = /^(?:const|let|class|function)\s+([A-Za-z_$][\w$]*)/gm;
const perModule = sources.split(/^\/\* (src\/runtime\/[\w.-]+) \*\/$/m);
const seen = new Map();
const collisions = [];
for (let index = 1; index < perModule.length; index += 2) {
const module = perModule[index];
const body = perModule[index + 1] ?? '';
const names = new Set();
let match;
declarations.lastIndex = 0;
while ((match = declarations.exec(body)) !== null) names.add(match[1]);
for (const name of names) {
if (seen.has(name)) collisions.push(`${name}: ${seen.get(name)} and ${module}`);
else seen.set(name, module);
}
}
assert.deepEqual(collisions, []);
});
+525
View File
@@ -0,0 +1,525 @@
// Slice 4e — components, procedural systems, behaviors, fields, trails, links.
//
// The automated traces of 18.10. Where a trace names a closed-form result — the
// integrator, the emission accumulator, a life ramp, a distribution's draw
// count, a noise sample — the test asserts that number rather than a rendered
// pixel, which is what makes these executable without a display.
import assert from 'node:assert/strict';
import test from 'node:test';
import { ResolutionEngine } from '../src/runtime/resolution.js';
import { SeededRNG } from '../src/runtime/rng.js';
import { RuntimeFault } from '../src/runtime/types.js';
import { validateExhibit } from '../src/runtime/validator.js';
import { VisualEngine } from '../src/runtime/visual-engine.js';
import { ProceduralSystem } from '../src/runtime/visual-systems.js';
import { FieldSet } from '../src/runtime/visual-fields.js';
import { placeItem, sampleCount } from '../src/runtime/visual-distributions.js';
import { PERMUTATION_DRAWS, noiseVector, octaveNoise, permutationTable } from '../src/runtime/visual-noise.js';
import { behaviorChannels, curveValue, waveform } from '../src/runtime/visual-behaviors.js';
const close = (actual, expected, epsilon = 1e-9) => assert.ok(Math.abs(actual - expected) <= epsilon, `${actual} != ${expected}`);
const TICK = 1 / 60;
function exhibit(visuals, extra = {}) {
return { xzbt: '0.1', meta: { id: 'procedural-study', name: 'Procedural Study' }, runtime: { seed: 42 }, visuals, ...extra };
}
const codes = (document) => validateExhibit(document).errors.map((error) => error.code);
const scene = { coordinateSpace: 'viewport' };
function engineFor(document, seed = 42) {
return new VisualEngine(document, { resolution: new ResolutionEngine(document, new SeededRNG(seed)), rng: new SeededRNG(seed) });
}
/** A bare system, for the motion traces that want a closed-form oracle. */
function system(spec, { seed = 42, fields = null, scene: box = { x: 0, y: 0, width: 100, height: 100 } } = {}) {
const document = exhibit({ scene, systems: {} });
const resolution = new ResolutionEngine(document, new SeededRNG(seed));
return new ProceduralSystem('s', spec, {
systemPath: 'visuals.systems.s',
resolver: resolution.valueResolver,
rng: new SeededRNG(seed),
noiseTable: permutationTable(new SeededRNG(seed).stream('visual', 'behavior-noise')),
scene: box,
fields,
instantiateItemNode: () => null
});
}
// ---------------------------------------------------------------------------
// Traces 1 to 3 — components (18.1)
// ---------------------------------------------------------------------------
const panel = {
parameters: { tint: { type: 'color', default: '#3a6ea5' }, lit: { type: 'boolean', default: false }, scale: { type: 'number', default: 1, min: 0, max: 4 } },
content: { frame: { type: 'rectangle', size: { width: 24, height: 16 }, style: { fill: { ref: 'inputs.tint' } } } }
};
test('18.10.1 a component instantiates with defaults, with inputs, and nested', () => {
const document = exhibit({
scene,
systems: { s: { type: 'graphic', content: {
a: { type: 'component', component: 'panel' },
b: { type: 'component', component: 'panel', inputs: { tint: '#ff0000' } }
} } }
}, { components: { visual: { panel } } });
assert.deepEqual(validateExhibit(document).errors, []);
const plan = engineFor(document).planFrame({ width: 200, height: 200 });
const [a, b] = plan.layers[0].nodes;
assert.equal(a.children[0].fill.color, '#3a6ea5', 'the declared default');
assert.equal(b.children[0].fill.color, '#ff0000', 'the supplied input');
// An undeclared input key, and inputs.* used outside a component.
const undeclared = exhibit({ scene, systems: { s: { type: 'graphic', content: { a: { type: 'component', component: 'panel', inputs: { hue: 1 } } } } } }, { components: { visual: { panel } } });
assert.throws(() => engineFor(undeclared).planFrame({ width: 10, height: 10 }), (error) => error.code === 'ERR_INVALID_REFERENCE');
const outside = exhibit({ scene, systems: { s: { type: 'graphic', content: { a: { type: 'rectangle', size: { width: { ref: 'inputs.tint' }, height: 1 } } } } } });
assert.throws(() => engineFor(outside), (error) => error.code === 'ERR_INVALID_REFERENCE');
// A supplied value of the wrong type, and one outside a declared range.
const wrongType = exhibit({ scene, systems: { s: { type: 'graphic', content: { a: { type: 'component', component: 'panel', inputs: { scale: 'big' } } } } } }, { components: { visual: { panel } } });
assert.throws(() => engineFor(wrongType), (error) => error.code === 'ERR_TYPE_MISMATCH');
const outOfRange = exhibit({ scene, systems: { s: { type: 'graphic', content: { a: { type: 'component', component: 'panel', inputs: { scale: 9 } } } } } }, { components: { visual: { panel } } });
assert.throws(() => engineFor(outOfRange), (error) => error.code === 'ERR_OUT_OF_BOUNDS');
});
test('18.10.2 component recursion and nesting are bounded at eight levels', () => {
const selfReferential = { visual: { loop: { content: { inner: { type: 'component', component: 'loop' } } } } };
const document = exhibit({ scene, systems: { s: { type: 'graphic', content: { a: { type: 'component', component: 'loop' } } } } }, { components: selfReferential });
assert.throws(() => engineFor(document), (error) => error.code === 'ERR_COMPONENT_RECURSION');
const chain = (depth) => {
const components = {};
for (let level = 0; level < depth; level += 1) {
components[`c${level}`] = level === depth - 1
? { content: { leaf: { type: 'point' } } }
: { content: { next: { type: 'component', component: `c${level + 1}` } } };
}
return components;
};
const deep = exhibit({ scene, systems: { s: { type: 'graphic', content: { a: { type: 'component', component: 'c0' } } } } }, { components: { visual: chain(10) } });
assert.throws(() => engineFor(deep), (error) => error.code === 'ERR_COMPONENT_RECURSION');
const legal = exhibit({ scene, systems: { s: { type: 'graphic', content: { a: { type: 'component', component: 'c0' } } } } }, { components: { visual: chain(6) } });
assert.doesNotThrow(() => engineFor(legal).planFrame({ width: 50, height: 50 }));
});
test('18.10.3 two instances sample independently from their expansion paths', () => {
const jittered = { parameters: {}, content: { dot: { type: 'point', position: { x: { random: { min: 0, max: 100 } }, y: 0 } } } };
const build = (keys) => {
const content = {};
for (const key of keys) content[key] = { type: 'component', component: 'jitter' };
return engineFor(exhibit({ scene, systems: { s: { type: 'graphic', content } } }, { components: { visual: { jitter: jittered } } }))
.planFrame({ width: 200, height: 200 }).layers[0].nodes;
};
const [first, second] = build(['a', 'b']);
assert.notEqual(first.children[0].center[0], second.children[0].center[0], 'two instances sample independently');
// Adding an unrelated sibling does not perturb either instance.
const withExtra = build(['a', 'b', 'zzz']);
close(withExtra[0].children[0].center[0], first.children[0].center[0]);
close(withExtra[1].children[0].center[0], second.children[0].center[0]);
});
// ---------------------------------------------------------------------------
// Traces 4 to 7 — particles (18.2)
// ---------------------------------------------------------------------------
test('18.10.4 particle fields resolve once per particle and hold for its life', () => {
const document = exhibit({ scene, systems: { motes: {
type: 'particles', capacity: 8, count: 4, lifetime: '10s',
velocity: { x: { random: { min: -5, max: 5 } }, y: 0 },
render: { type: 'point' }
} } });
const engine = engineFor(document);
const velocities = engine.systems[0].procedural.items.map((item) => item.vx);
assert.equal(new Set(velocities).size, 4, 'each particle samples its own velocity');
for (let tick = 0; tick < 120; tick += 1) engine.advance(1000 / 60);
assert.deepEqual(engine.systems[0].procedural.items.map((item) => item.vx), velocities, 'and holds it for its whole life');
// Two runs of one seed create identical particles in identical order.
assert.deepEqual(engineFor(document).systems[0].procedural.items.map((item) => item.vx), velocities);
assert.notDeepEqual(engineFor(document, 7).systems[0].procedural.items.map((item) => item.vx), velocities);
});
test('18.10.5 the integrator matches its closed form and is order-sensitive', () => {
// v <- v + a*dt, then p <- p + v*dt, so after n ticks p = a*dt^2*n(n+1)/2.
const acceleration = 10;
const ticks = 60;
const particles = system({ type: 'particles', capacity: 4, count: 1, render: { type: 'point' }, acceleration: { x: acceleration, y: 0 } });
for (let tick = 0; tick < ticks; tick += 1) particles.advance(TICK);
const item = particles.items[0];
close(item.vx, acceleration * ticks * TICK, 1e-9);
close(item.base.x, acceleration * TICK * TICK * ((ticks * (ticks + 1)) / 2), 1e-9);
// Integrating position before velocity would leave it one step behind.
assert.notEqual(item.base.x, acceleration * TICK * TICK * (((ticks - 1) * ticks) / 2));
});
test('18.10.6 drag is dt-correct: one tick of 1s equals ten of 0.1s', () => {
const make = () => system({ type: 'particles', capacity: 4, count: 1, render: { type: 'point' }, velocity: { x: 100, y: 0 }, drag: 0.5 });
const coarse = make();
coarse.advance(1);
const fine = make();
for (let tick = 0; tick < 10; tick += 1) fine.advance(0.1);
close(coarse.items[0].vx, fine.items[0].vx, 1e-9);
close(coarse.items[0].vx, 100 * 0.5, 1e-9);
});
test('18.10.7 a life ramp interpolates under each curve, and needs a lifetime', () => {
const ramps = system({
type: 'particles', capacity: 4, count: 1, lifetime: '10s', render: { type: 'point' },
size: { from: 0, to: 1, curve: 'smooth' }
});
const item = ramps.items[0];
close(ramps.rampValue(item.ramps.size, 0), 0);
close(ramps.rampValue(item.ramps.size, 0.5), 0.5);
close(ramps.rampValue(item.ramps.size, 1), 1);
for (const [curve, midpoint] of [['step', 0], ['linear', 0.5], ['exponential', 0.25], ['smooth', 0.5]]) {
close(curveValue(curve, 0.5), midpoint, 1e-12);
close(curveValue(curve, 0), 0);
close(curveValue(curve, 1), 1);
}
assert.throws(() => system({ type: 'particles', capacity: 4, count: 1, render: { type: 'point' }, size: { from: 0, to: 1 } }),
(error) => error instanceof RuntimeFault && error.code === 'ERR_SCHEMA_VALIDATION');
});
// ---------------------------------------------------------------------------
// Traces 8 and 9 — emission (18.2, 18.4)
// ---------------------------------------------------------------------------
test('18.10.8 rate emission is exactly floor(rate * t) under three tick lengths', () => {
// Steps that are exact in binary, so the oracle is floor(rate * t) and not a
// statement about floating-point summation.
for (const [step, ticks] of [[1 / 64, 64], [1 / 32, 32], [1 / 16, 16]]) {
const emitter = system({ type: 'emitter', capacity: 512, rate: 6, lifetime: '60s', emit: { type: 'point' } });
for (let tick = 0; tick < ticks; tick += 1) emitter.advance(step);
assert.equal(emitter.created, 6, `at a step of ${step}`);
// Half a second in, the count is exactly floor(rate * t) too.
const half = system({ type: 'emitter', capacity: 512, rate: 6, lifetime: '60s', emit: { type: 'point' } });
for (let tick = 0; tick < ticks / 2; tick += 1) half.advance(step);
assert.equal(half.created, 3);
}
// A burst emits its whole count on the first tick at or after its `at`.
const bursting = system({ type: 'emitter', capacity: 512, burst: [{ at: '500ms', count: 24 }], lifetime: '60s', emit: { type: 'point' } });
bursting.advance(0.4);
assert.equal(bursting.created, 0, 'not before its offset');
bursting.advance(0.2);
assert.equal(bursting.created, 24, 'the whole count on the first tick at or after it');
bursting.advance(1);
assert.equal(bursting.created, 24, 'and never again');
});
test('18.10.9 unbounded emission is rejected, count alone passes, capacity evicts oldest', () => {
assert.throws(() => system({ type: 'emitter', capacity: 8, rate: 4, emit: { type: 'point' } }),
(error) => error.code === 'ERR_UNBOUNDED_EMISSION');
assert.doesNotThrow(() => system({ type: 'particles', capacity: 8, count: 8, render: { type: 'point' } }));
const full = system({ type: 'particles', capacity: 4, rate: 60, lifetime: '60s', render: { type: 'point' } });
for (let tick = 0; tick < 60; tick += 1) full.advance(TICK);
assert.equal(full.items.length, 4, 'the live count stays at capacity');
assert.equal(full.items[0].ordinal, full.created - 4, 'and the oldest were evicted');
});
// ---------------------------------------------------------------------------
// Trace 10 — distributions (18.3)
// ---------------------------------------------------------------------------
test('18.10.10 each distribution places at its documented position and draw count', () => {
const grid = { type: 'grid', origin: { x: 0, y: 0 }, columns: 12, rows: 8, spacing: { x: 10, y: 10 } };
assert.deepEqual([0, 1, 12, 13].map((index) => placeItem(grid, { index, count: 96 }).x), [0, 10, 0, 10]);
assert.deepEqual([0, 1, 12, 13].map((index) => placeItem(grid, { index, count: 96 }).y), [0, 0, 10, 10]);
// A count larger than rows * columns wraps rather than failing.
assert.equal(placeItem(grid, { index: 96, count: 200 }).x, 0);
// n == 1 places at fraction 0, matching repeat.fraction.
assert.equal(placeItem({ type: 'line', from: { x: 0, y: 0 }, to: { x: 10, y: 0 }, mode: 'even' }, { index: 0, count: 1 }).x, 0);
// The three depth curves are monotonic and bias toward near.
const depth = { type: 'depth', near: 0, far: 100 };
for (const curve of ['uniform', 'linear', 'exponential']) {
const stream = new SeededRNG(42).stream('visual', `d-${curve}`);
const value = placeItem({ ...depth, curve }, { stream }).z;
assert.ok(value >= 0 && value <= 100, curve);
}
// The draw-count table of 18.3, asserted by counting the draws themselves.
const drawn = (distribution) => {
const source = new SeededRNG(42).stream('visual', 'draws');
let draws = 0;
const counting = { nextFloat: () => { draws += 1; return source.nextFloat(); }, nextInteger: (a, b) => source.nextInteger(a, b) };
placeItem(distribution, { index: 0, count: 4, stream: counting });
return draws;
};
assert.equal(sampleCount({ type: 'point' }), 0);
assert.equal(sampleCount({ type: 'ellipse' }), 2);
assert.equal(sampleCount({ type: 'ellipse', fill: 'perimeter' }), 1, 'one distance sample, not two');
assert.equal(sampleCount({ type: 'rectangle', fill: 'perimeter' }), 1);
assert.equal(sampleCount({ type: 'grid' }), 0);
assert.equal(sampleCount({ type: 'grid', jitter: { x: 1, y: 1 } }), 2);
assert.equal(sampleCount({ type: 'ring', mode: 'even' }), 0);
assert.equal(sampleCount({ type: 'ring', mode: 'even', depth: { near: 0, far: 1 } }), 1, 'the depth sub-block draws last');
for (const distribution of [
{ type: 'point' },
{ type: 'ellipse', center: { x: 0, y: 0 }, radius: 4 },
{ type: 'ellipse', center: { x: 0, y: 0 }, radius: 4, fill: 'perimeter' },
{ type: 'rectangle', center: { x: 0, y: 0 }, size: { width: 4, height: 2 }, fill: 'perimeter' },
{ type: 'rectangle', center: { x: 0, y: 0 }, size: { width: 4, height: 2 } },
{ type: 'uniform', min: { x: 0, y: 0 }, max: { x: 1, y: 1 } },
{ type: 'uniform', min: { x: 0, y: 0, z: 0 }, max: { x: 1, y: 1, z: 1 } },
{ type: 'ring', radius: 4, innerRadius: 1 },
{ type: 'ring', radius: 4, innerRadius: 1, mode: 'even' },
{ type: 'grid', columns: 2, rows: 2, spacing: { x: 1, y: 1 } },
{ type: 'grid', columns: 2, rows: 2, spacing: { x: 1, y: 1 }, jitter: { x: 1, y: 1 } },
{ type: 'depth', near: 0, far: 10 },
{ type: 'ellipse', center: { x: 0, y: 0 }, radius: 4, depth: { near: 0, far: 10 } }
]) {
assert.equal(drawn(distribution), sampleCount(distribution), JSON.stringify(distribution));
}
// An index-driven placement on a continuous rate emission has no count.
assert.throws(() => system({ type: 'particles', capacity: 8, rate: 4, lifetime: '2s', render: { type: 'point' }, distribution: { type: 'grid', columns: 2, rows: 2 } }),
(error) => error.code === 'ERR_INVALID_DISTRIBUTION');
assert.throws(() => placeItem({ type: 'spiral' }, {}), (error) => error.code === 'ERR_INVALID_DISTRIBUTION_TYPE');
});
// ---------------------------------------------------------------------------
// Trace 11 — repeaters (18.5)
// ---------------------------------------------------------------------------
test('18.10.11 a repeater resolves repeat.index, repeat.count, and repeat.fraction', () => {
const document = exhibit({ scene, systems: { wall: {
type: 'repeater', count: 4,
repeat: { type: 'rectangle', size: { width: { ref: 'repeat.index' }, height: { ref: 'repeat.fraction' } }, position: { x: 0, y: 0 } },
distribution: { type: 'line', from: { x: 0, y: 0 }, to: { x: 30, y: 0 }, mode: 'even' }
} } });
const engine = engineFor(document);
const items = engine.systems[0].procedural.items;
assert.equal(items.length, 4);
assert.deepEqual(items.map((item) => item.node.raw.size.width), [0, 1, 2, 3]);
assert.deepEqual(items.map((item) => item.node.raw.size.height), [0, 1 / 3, 2 / 3, 1]);
assert.deepEqual(items.map((item) => Math.round(item.base.x)), [0, 10, 20, 30], 'even placement consumes no samples');
// repeat.* outside a repeater does not resolve.
const outside = exhibit({ scene, systems: { s: { type: 'graphic', content: { a: { type: 'rectangle', size: { width: { ref: 'repeat.index' }, height: 1 } } } } } });
assert.throws(() => engineFor(outside), (error) => error.code === 'ERR_INVALID_REFERENCE');
});
// ---------------------------------------------------------------------------
// Traces 12 to 14 — behaviors (18.6)
// ---------------------------------------------------------------------------
test('18.10.12 every behavior advances a known object, and the rejections differ', () => {
const target = { type: 'polyline', points: [{ x: 0, y: 0 }, { x: 1, y: 1 }] };
const behaviors = [
{ type: 'drift', velocity: { x: 10, y: 0 } },
{ type: 'rotate', speed: 90 },
{ type: 'oscillate', property: 'position.y', amplitude: 5, frequency: 1 },
{ type: 'orbit', center: { x: 0, y: 0 }, radius: 10, speed: 90 },
{ type: 'wander', strength: 5, rate: 1 },
{ type: 'follow-path', path: [{ op: 'move', to: { x: 0, y: 0 } }, { op: 'line', to: { x: 10, y: 0 } }], speed: 5 },
{ type: 'point-wander', amplitude: { x: 1, y: 1 }, rate: 1 },
{ type: 'pulse', property: 'style.opacity', amplitude: 0.5, frequency: 2 },
{ type: 'twinkle', min: 0.2, max: 1, rate: 3 },
{ type: 'noise-displace', amplitude: { x: 2, y: 2 }, scale: 50, speed: 0.1 },
{ type: 'face-motion' },
{ type: 'wrap' },
{ type: 'bounce', restitution: 0.5 },
{ type: 'attract', target: { x: 50, y: 50 }, strength: 20 },
{ type: 'repel', target: { x: 50, y: 50 }, strength: 20 },
{ type: 'field-follow', field: 'wind', strength: 1 },
{ type: 'morph', to: 'other', duration: '1s' }
];
const fields = new FieldSet({ wind: { type: 'directional', direction: 0, strength: 5 } }, { rng: new SeededRNG(42) });
for (const behavior of behaviors) {
const owner = system({ type: 'particles', capacity: 4, count: 1, lifetime: '10s', render: target, behaviors: [behavior] }, { fields });
const item = owner.items[0];
item.points = [{ x: 0, y: 0, z: 0 }, { x: 1, y: 1, z: 0 }];
owner.advance(TICK, {
resolveTarget: () => ({ x: 50, y: 50 }),
resolveMorphTarget: () => [{ x: 5, y: 5, z: 0 }, { x: 6, y: 6, z: 0 }]
});
const place = owner.itemPosition(item);
assert.ok(Number.isFinite(place.x) && Number.isFinite(place.y) && Number.isFinite(place.rotation), behavior.type);
}
// Documented single-behavior oracles.
const drifting = system({ type: 'particles', capacity: 4, count: 1, render: { type: 'point' }, behaviors: [{ type: 'drift', velocity: { x: 60, y: 0 } }] });
for (let tick = 0; tick < 60; tick += 1) drifting.advance(TICK);
close(drifting.itemPosition(drifting.items[0]).x, 60, 1e-9);
const spinning = system({ type: 'particles', capacity: 4, count: 1, render: { type: 'point' }, behaviors: [{ type: 'rotate', speed: 90 }] });
for (let tick = 0; tick < 60; tick += 1) spinning.advance(TICK);
close(spinning.itemPosition(spinning.items[0]).rotation, 90, 1e-9);
// An orbit is recomputed from t, so it traces its circle rather than spiralling.
const orbiting = system({ type: 'particles', capacity: 4, count: 1, render: { type: 'point' }, behaviors: [{ type: 'orbit', center: { x: 0, y: 0 }, radius: 10, speed: 360 }] });
for (let tick = 0; tick < 60; tick += 1) orbiting.advance(TICK);
const orbited = orbiting.itemPosition(orbiting.items[0]);
close(Math.hypot(orbited.x, orbited.y), 10, 1e-9);
// Rejections.
const owner = (object, extra = {}) => exhibit({ scene, systems: { s: { type: 'graphic', content: { a: { ...object, ...extra } } } } });
assert.ok(codes(owner({ type: 'point' }, { behaviors: [{ type: 'levitate' }] })).includes('ERR_INVALID_BEHAVIOR_TYPE'));
assert.ok(codes(owner({ type: 'point' }, { behaviors: [{ type: 'oscillate', property: 'style.hue', amplitude: 1 }] })).includes('ERR_INVALID_BEHAVIOR_TARGET'));
assert.ok(codes(owner({ type: 'ellipse', radius: 2 }, { behaviors: [{ type: 'oscillate', property: 'size.width', amplitude: 1 }] })).includes('ERR_INVALID_BEHAVIOR_TARGET'));
assert.ok(codes(owner({ type: 'rectangle', size: { width: 1, height: 1 } }, { behaviors: [{ type: 'point-wander', amplitude: { x: 1, y: 1 } }] })).includes('ERR_INVALID_BEHAVIOR_TARGET'));
const nine = Array.from({ length: 9 }, () => ({ type: 'rotate', speed: 1 }));
assert.ok(codes(owner({ type: 'point' }, { behaviors: nine })).includes('ERR_VISUAL_LIMIT_EXCEEDED'));
assert.ok(codes(owner({ type: 'point' }, { behaviors: [{ type: 'field-follow', field: 'nope' }] })).includes('ERR_INVALID_REFERENCE'));
assert.ok(codes(owner({ type: 'point' }, { behaviors: [{ type: 'follow-path', path: [], speed: 1, duration: '1s' }] })).includes('ERR_SCHEMA_VALIDATION'));
});
test('18.10.13 behavior composition follows array order and the documented rule', () => {
const two = system({
type: 'particles', capacity: 4, count: 1, render: { type: 'point' },
behaviors: [{ type: 'drift', velocity: { x: 60, y: 0 } }, { type: 'drift', velocity: { x: 30, y: 0 } }]
});
for (let tick = 0; tick < 60; tick += 1) two.advance(TICK);
// Position accumulates: 60 + 30 over one logical second.
close(two.itemPosition(two.items[0]).x, 90, 1e-9);
// Scale multiplies rather than accumulating.
const scaled = system({
type: 'particles', capacity: 4, count: 1, render: { type: 'point' },
behaviors: [
{ type: 'oscillate', property: 'transform.scale.x', amplitude: 0, center: 2, frequency: 0 },
{ type: 'oscillate', property: 'transform.scale.x', amplitude: 0, center: 3, frequency: 0 }
]
});
scaled.advance(TICK);
close(scaled.items[0].scale.x, 6, 1e-12);
assert.deepEqual(behaviorChannels({ type: 'attract' }), ['velocity.x', 'velocity.y', 'velocity.z'],
'a force behavior writes velocity, which no automation track can address');
});
test('18.10.14 morph is restricted to point-list geometry and rejects mismatches', () => {
const pair = (a, b) => exhibit({ scene, systems: { s: { type: 'graphic', content: {
source: { ...a, behaviors: [{ type: 'morph', to: 'target', duration: '1s' }] },
target: b
} } } });
const line3 = { type: 'polyline', points: [{ x: 0, y: 0 }, { x: 1, y: 0 }, { x: 2, y: 0 }] };
assert.deepEqual(codes(pair(line3, { type: 'polyline', points: [{ x: 0, y: 1 }, { x: 1, y: 1 }, { x: 2, y: 1 }] })), []);
assert.ok(codes(pair(line3, { type: 'polyline', points: [{ x: 0, y: 1 }, { x: 1, y: 1 }] })).includes('ERR_MORPH_INCOMPATIBLE'));
assert.ok(codes(pair(line3, { type: 'rectangle', size: { width: 1, height: 1 } })).includes('ERR_MORPH_INCOMPATIBLE'));
assert.ok(codes(pair({ type: 'spline', mode: 'linear', points: [{ x: 0, y: 0 }, { x: 1, y: 0 }] }, { type: 'spline', mode: 'catmull-rom', points: [{ x: 0, y: 1 }, { x: 1, y: 1 }] })).includes('ERR_MORPH_INCOMPATIBLE'));
const missing = exhibit({ scene, systems: { s: { type: 'graphic', content: { source: { ...line3, behaviors: [{ type: 'morph', to: 'nowhere', duration: '1s' }] } } } } });
assert.ok(codes(missing).includes('ERR_INVALID_REFERENCE'));
});
// ---------------------------------------------------------------------------
// Traces 15 and 16 — coherent noise (18.7)
// ---------------------------------------------------------------------------
test('18.10.15 the noise function reproduces its oracles and consumes 255 samples', () => {
const before = new SeededRNG(42).stream('visual', 'current');
const counting = new SeededRNG(42).stream('visual', 'current');
const table = permutationTable(counting);
// The shuffle draws one sample per swap, not one per entry.
for (let draw = 0; draw < PERMUTATION_DRAWS; draw += 1) before.nextFloat();
close(before.nextFloat(), counting.nextFloat(), 0);
assert.equal(PERMUTATION_DRAWS, 255);
// A gradient noise value is exactly zero on the lattice.
close(octaveNoise(table, 0, 0, 0, 1, 0.5), 0, 0);
close(octaveNoise(table, 3, -2, 5, 1, 0.5), 0, 0);
// Two runs of one seed agree exactly, at lattice and non-lattice positions.
const again = permutationTable(new SeededRNG(42).stream('visual', 'current'));
for (const [x, y, z] of [[0.5, 0.25, 0], [13.37, -7.5, 2.25], [0, 0, 0]]) {
close(octaveNoise(table, x, y, z, 3, 0.5), octaveNoise(again, x, y, z, 3, 0.5), 0);
}
// A different seed gives a different table.
const other = permutationTable(new SeededRNG(7).stream('visual', 'current'));
assert.notEqual(octaveNoise(table, 0.5, 0.25, 0, 1, 0.5), octaveNoise(other, 0.5, 0.25, 0, 1, 0.5));
// persistence: 0 stays defined, contributing one octave.
close(octaveNoise(table, 0.5, 0.25, 0, 4, 0), octaveNoise(table, 0.5, 0.25, 0, 1, 0), 0);
const field = (extra) => exhibit({ scene, fields: { n: { type: 'noise', scale: 100, ...extra } } });
assert.ok(codes(field({ octaves: 5 })).includes('ERR_OUT_OF_BOUNDS'));
assert.ok(codes(field({ persistence: 1.5 })).includes('ERR_OUT_OF_BOUNDS'));
assert.ok(codes(exhibit({ scene, fields: { n: { type: 'noise', scale: 0 } } })).includes('ERR_OUT_OF_BOUNDS'));
assert.ok(codes(exhibit({ scene, fields: { n: { type: 'swirl' } } })).includes('ERR_INVALID_FIELD_TYPE'));
const nine = Object.fromEntries(Array.from({ length: 9 }, (_, index) => [`f${index}`, { type: 'directional', direction: 0, strength: 1 }]));
assert.ok(codes(exhibit({ scene, fields: nine })).includes('ERR_VISUAL_LIMIT_EXCEEDED'));
});
test('18.10.16 curl is divergence-free within tolerance at non-lattice positions', () => {
const table = permutationTable(new SeededRNG(42).stream('visual', 'current'));
const amplitude = 1;
const step = 1e-3;
let worst = 0;
for (let i = 0; i < 12; i += 1) {
for (let j = 0; j < 12; j += 1) {
const x = i * 0.37 + 0.13;
const y = j * 0.41 + 0.07;
const dx = noiseVector(table, x + step, y, 0, { mode: 'curl', amplitude })[0] - noiseVector(table, x - step, y, 0, { mode: 'curl', amplitude })[0];
const dy = noiseVector(table, x, y + step, 0, { mode: 'curl', amplitude })[1] - noiseVector(table, x, y - step, 0, { mode: 'curl', amplitude })[1];
worst = Math.max(worst, Math.abs((dx + dy) / (2 * step)));
}
}
assert.ok(worst < 1e-4, `divergence ${worst} exceeds the documented tolerance`);
// `value` mode points along its declared direction.
const [vx, vy] = noiseVector(table, 0.3, 0.7, 0, { mode: 'value', direction: 90, amplitude: 1 });
assert.ok(Math.abs(vx) < 1e-12 && Math.abs(vy) > 0, 'the scalar rides the declared direction');
});
// ---------------------------------------------------------------------------
// Traces 17 to 19 — trails, links, and the target surface (18.8, 8.1)
// ---------------------------------------------------------------------------
test('18.10.17 trail history is sampled on the logical clock and is bounded', () => {
const build = () => system({
type: 'particles', capacity: 4, count: 1, lifetime: '60s', render: { type: 'point' },
velocity: { x: 60, y: 0 }, trail: { length: 8 }
});
const fast = build();
for (let tick = 0; tick < 30; tick += 1) fast.advance(TICK);
const geometry = fast.items[0].trail.map((sample) => Math.round(sample.x * 1e6) / 1e6);
assert.equal(geometry.length, 8, 'the history is capped at its declared length');
// The same logical span gives the same geometry however many frames drew it.
const slow = build();
for (let tick = 0; tick < 30; tick += 1) slow.advance(TICK);
assert.deepEqual(slow.items[0].trail.map((sample) => Math.round(sample.x * 1e6) / 1e6), geometry);
assert.throws(() => {
const over = system({ type: 'particles', capacity: 4, count: 1, lifetime: '60s', render: { type: 'point' }, trail: { length: 129 } });
over.advance(TICK);
}, (error) => error.code === 'ERR_VISUAL_LIMIT_EXCEEDED');
// A removed item's history goes with it.
const expiring = system({ type: 'particles', capacity: 4, count: 1, lifetime: '100ms', render: { type: 'point' }, trail: { length: 8 } });
for (let tick = 0; tick < 12; tick += 1) expiring.advance(TICK);
assert.equal(expiring.items.length, 0);
});
test('18.10.18 link enumeration is deterministic, de-duplicated, and bounded', () => {
const linked = system({
type: 'repeater', count: 4,
repeat: { type: 'point' },
distribution: { type: 'line', from: { x: 0, y: 0 }, to: { x: 30, y: 0 }, mode: 'even' },
links: { rule: 'distance', maxDistance: 15 }
});
const pairs = linked.linkPairs();
// Items sit at 0, 10, 20, 30: adjacent pairs only, each once.
assert.equal(pairs.length, 3);
assert.deepEqual(pairs.map((pair) => Math.round(pair.distance)), [10, 10, 10]);
assert.deepEqual(linked.linkPairs().map((pair) => Math.round(pair.from.x)), pairs.map((pair) => Math.round(pair.from.x)));
const indexed = system({ type: 'repeater', count: 4, repeat: { type: 'point' }, links: { rule: 'index', stride: 1, closed: true } });
assert.equal(indexed.linkPairs().length, 4, 'three strides plus the closing link');
const capped = system({ type: 'repeater', count: 4, repeat: { type: 'point' }, distribution: { type: 'line', from: { x: 0, y: 0 }, to: { x: 3, y: 0 }, mode: 'even' }, links: { rule: 'distance', maxDistance: 100, maxLinks: 2 } });
assert.equal(capped.linkPairs().length, 2, 'the tail of the ascending order is dropped');
assert.ok(codes(exhibit({ scene, systems: { e: { type: 'emitter', capacity: 8, rate: 1, lifetime: '1s', emit: { type: 'point' }, links: { rule: 'index' } } } })).includes('ERR_UNKNOWN_FIELD'));
assert.throws(() => system({ type: 'particles', capacity: 400, count: 1, render: { type: 'point' }, links: { rule: 'distance', maxDistance: 10 } }),
(error) => error.code === 'ERR_VISUAL_LIMIT_EXCEEDED');
assert.throws(() => system({ type: 'particles', capacity: 8, count: 1, render: { type: 'point' }, links: { rule: 'distance', fadeWithDistance: true } }),
(error) => error.code === 'ERR_SCHEMA_VALIDATION');
});
test('18.10.19 nothing introduced by this section becomes an external target', () => {
const document = exhibit({
scene,
fields: { wind: { type: 'directional', direction: 0, strength: 1 } },
systems: { motes: { type: 'particles', capacity: 8, count: 1, render: { type: 'point' }, behaviors: [{ type: 'rotate', speed: 1 }] } }
}, { state: { energy: { type: 'number', initial: 0.5 } } });
for (const target of ['visuals.fields.wind.strength', 'visuals.systems.motes.rate', 'visuals.systems.motes.behaviors[0].speed', 'visuals.systems.motes.capacity']) {
const bound = { ...document, bindings: [{ source: 'state.energy', target }] };
assert.ok(codes(bound).includes('ERR_UNSUPPORTED_TARGET'), target);
}
// The one system-level property that is a target stays one.
const visible = { ...document, bindings: [{ source: 'state.energy', target: 'visuals.systems.motes.visible' }] };
assert.ok(!codes(visible).includes('ERR_UNSUPPORTED_TARGET'));
});