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:
@@ -6,7 +6,8 @@ export const RNG_DOMAINS = Object.freeze([
|
||||
'scenario',
|
||||
'visual',
|
||||
'sound',
|
||||
'manual-sample'
|
||||
'manual-sample',
|
||||
'sample'
|
||||
]);
|
||||
|
||||
export const DATABASE = Object.freeze({
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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.');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user