diff --git a/docs/evidence/phase4/2026-09-06-phase4e-procedural.md b/docs/evidence/phase4/2026-09-06-phase4e-procedural.md new file mode 100644 index 0000000..a479f7c --- /dev/null +++ b/docs/evidence/phase4/2026-09-06-phase4e-procedural.md @@ -0,0 +1,11 @@ +# Slice 4e: procedural execution + +Implemented on September 6, 2026, extending the renderer and procedural changes already present in the workspace. + +`visual-systems.js`, `visual-distributions.js`, `visual-behaviors.js`, `visual-fields.js`, `visual-noise.js`, and `visual-motion.js` implement components, particles, emitters, repeaters, placement, behavior motion, fields, trails/ribbons, and links. The production application advances them on logical ticks. Render frames do not advance simulation or consume procedural streams. + +The nineteen section 18.10 tests in `test/phase4-procedural.test.mjs` pass. Added execution tests check graphic drift, repeated-object motion, morphing geometry, propagated channels, and rendering across different frame rates. This closes a gap in the original procedural work: behavior arithmetic alone did not prove that its results reached draw geometry. Scoped expressions inside component/repeater inputs and distribution configuration sampling now follow their owning instantiation boundary. + +Initial resolved populations cannot exceed their system bounds; emission batches retain the newest capacity-sized population without an unbounded construction loop. Aggregate protection is recorded with 4f. + +Run `node --test --test-isolation=none test/phase4-*.test.mjs`. Display judgment remains separate and unperformed by the agent. diff --git a/src/runtime/constants.js b/src/runtime/constants.js index 1e2c8bd..04dcc80 100644 --- a/src/runtime/constants.js +++ b/src/runtime/constants.js @@ -6,7 +6,8 @@ export const RNG_DOMAINS = Object.freeze([ 'scenario', 'visual', 'sound', - 'manual-sample' + 'manual-sample', + 'sample' ]); export const DATABASE = Object.freeze({ diff --git a/src/runtime/resolution.js b/src/runtime/resolution.js index c55b057..fb00636 100644 --- a/src/runtime/resolution.js +++ b/src/runtime/resolution.js @@ -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(); diff --git a/src/runtime/types.js b/src/runtime/types.js index 52a1520..e1f216d 100644 --- a/src/runtime/types.js +++ b/src/runtime/types.js @@ -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]); diff --git a/src/runtime/validator.js b/src/runtime/validator.js index 7293eb0..51b2cdd 100644 --- a/src/runtime/validator.js +++ b/src/runtime/validator.js @@ -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 }; } diff --git a/src/runtime/visual-behaviors.js b/src/runtime/visual-behaviors.js new file mode 100644 index 0000000..b5751d4 --- /dev/null +++ b/src/runtime/visual-behaviors.js @@ -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); + } + } +} diff --git a/src/runtime/visual-canvas2d.js b/src/runtime/visual-canvas2d.js index 73c6393..3bd63d5 100644 --- a/src/runtime/visual-canvas2d.js +++ b/src/runtime/visual-canvas2d.js @@ -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 ``. */ -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 }; } diff --git a/src/runtime/visual-diagnostics.js b/src/runtime/visual-diagnostics.js index 5645e21..d83d495 100644 --- a/src/runtime/visual-diagnostics.js +++ b/src/runtime/visual-diagnostics.js @@ -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 diff --git a/src/runtime/visual-distributions.js b/src/runtime/visual-distributions.js new file mode 100644 index 0000000..4652cdc --- /dev/null +++ b/src/runtime/visual-distributions.js @@ -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; +} diff --git a/src/runtime/visual-engine.js b/src/runtime/visual-engine.js index fb46af5..0d3bc60 100644 --- a/src/runtime/visual-engine.js +++ b/src/runtime/visual-engine.js @@ -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; diff --git a/src/runtime/visual-fields.js b/src/runtime/visual-fields.js new file mode 100644 index 0000000..ac418cb --- /dev/null +++ b/src/runtime/visual-fields.js @@ -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]; + } +} diff --git a/src/runtime/visual-geometry.js b/src/runtime/visual-geometry.js index f0f9bb8..bbe10be 100644 --- a/src/runtime/visual-geometry.js +++ b/src/runtime/visual-geometry.js @@ -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); diff --git a/src/runtime/visual-lifecycle.js b/src/runtime/visual-lifecycle.js new file mode 100644 index 0000000..93770b5 --- /dev/null +++ b/src/runtime/visual-lifecycle.js @@ -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'); + } +} diff --git a/src/runtime/visual-motion.js b/src/runtime/visual-motion.js new file mode 100644 index 0000000..131a382 --- /dev/null +++ b/src/runtime/visual-motion.js @@ -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); + } +} diff --git a/src/runtime/visual-noise.js b/src/runtime/visual-noise.js new file mode 100644 index 0000000..2539ead --- /dev/null +++ b/src/runtime/visual-noise.js @@ -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]; +} diff --git a/src/runtime/visual-subsystem.js b/src/runtime/visual-subsystem.js index 56b5c03..a0f175b 100644 --- a/src/runtime/visual-subsystem.js +++ b/src/runtime/visual-subsystem.js @@ -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); diff --git a/src/runtime/visual-systems.js b/src/runtime/visual-systems.js new file mode 100644 index 0000000..ad01aec --- /dev/null +++ b/src/runtime/visual-systems.js @@ -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 +// `#`. 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 `#`, 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; +} diff --git a/src/runtime/visual-validation.js b/src/runtime/visual-validation.js index d41a95c..3b0a969 100644 --- a/src/runtime/visual-validation.js +++ b/src/runtime/visual-validation.js @@ -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.'); + } + }); +} diff --git a/test/phase1-runtime.test.mjs b/test/phase1-runtime.test.mjs index 0906eeb..3c5ff9f 100644 --- a/test/phase1-runtime.test.mjs +++ b/test/phase1-runtime.test.mjs @@ -9,7 +9,7 @@ import { Diagnostics } from '../src/runtime/diagnostics.js'; import { LibraryManager } from '../src/runtime/library.js'; import { deriveStreamState, SeededRNG } from '../src/runtime/rng.js'; import { parseAndValidateExhibit } from '../src/runtime/validator.js'; -import { buildStandalone } from '../tools/build-xzbt.mjs'; +import { buildStandalone, bundleRuntime } from '../tools/build-xzbt.mjs'; const fixedSource = readFileSync(resolve('exhibits/minimal-fixed.xzbt'), 'utf8'); const randomSource = readFileSync(resolve('exhibits/minimal-random.xzbt'), 'utf8'); @@ -79,8 +79,34 @@ test('two clean standalone builds are byte-identical and self-contained', () => // A module import *statement*, not the word in a comment: the bundler strips // the former, and prose about import-stage validation is not a leak. assert.doesNotMatch(html, /^[ \t]*import[ \t][^;(\n]*from[ \t]*['"]/m); + // C14: Also catch side-effect imports (import './chunk.js';). + assert.doesNotMatch(html, /^[ \t]*import[ \t]*['"]/m); assert.doesNotMatch(html, /^[ \t]*export[ \t]/m); assert.equal(createHash('sha256').update(firstBytes).digest('hex'), createHash('sha256').update(secondBytes).digest('hex')); const script = html.match(/