Files
XZBT/src/runtime/visual-engine.js
T
LabyricornandClaude Opus 5 8b00f5c4a0 feat(visual): implement the slice 4e procedural systems and behaviors
The normative coherent noise of 18.7 (ordered gradients, a 255-sample
Fisher-Yates permutation, octave normalization, and curl as the explicit
perpendicular of the potential's gradient, with published tolerances), the nine
distributions with their normative draw-order table, all seventeen behaviors
with their field contracts and the accumulating-versus-fresh split, component
expansion independent of group nesting depth, particles, emitters, repeaters,
trails, and links. Traces 1 through 19 of 18.10 are automated.

The renderer core this builds on is corrected here rather than separately,
because 4e is what exercises it: the production render path now passes a real
compositing surface factory, so a masked group or a non-opaque layer composites
instead of throwing and permanently deactivating the visual subsystem;
system-level `behaviors` arrays run `validateBehaviors` at import with field
context, so the six classes of invalid document that used to fail at activation
now fail where an author can see them; velocity-accumulating behaviors integrate
on object and repeater hosts; `morph` guards both sides of the z delta rather
than poisoning perspective projection with NaN; `face-motion` initializes its
follow state lazily and holds the previous rotation at zero velocity; a `ring`
with equal start and end angles draws nothing instead of a radial spoke; a
resolved non-integer creation count raises ERR_TYPE_MISMATCH at the boundary
instead of being rounded; boolean leaves are type-checked; per-type required
fields and the static half of the unbounded-emission rule are enforced at
import; and a repeater rejects the emitter-only fields 18.5 says it has none of.

A duration is now the authored literal or a non-negative finite number already
in milliseconds, resolving the one design question the review triage left open.
Runtime, validator, and specification agree on it.

A guard test asserts that no two bundled modules declare the same top-level
identifier: the bundler concatenates into one scope, so a private helper name
collision is a SyntaxError in the artifact while every unit test still passes.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01ShxxFqFmCUDQnQvFNm4TKy
2026-09-06 21:53:13 +00:00

1209 lines
59 KiB
JavaScript

// Slice 4d — the visual renderer core.
//
// The engine is renderer-neutral by construction (17.1): it resolves an
// exhibit's declared visual objects once at their instantiation boundary
// (17.14), composes the scene-to-device chain of 19.3, sorts by depth (17.6),
// applies depth fog and the compositing order of 17.12, and emits a **frame
// plan** — an ordered list of draw operations in device coordinates. A backend
// (visual-canvas2d.js) turns that plan into drawing calls.
//
// The plan is the testable surface. Every automated trace of 17.16 asks for a
// numeric oracle — a known scene point at a known display point, a documented
// 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, parseDuration } from './types.js';
import {
IDENTITY,
apply,
compose,
fogColor,
formatColor,
localMatrix,
multiply,
parseColor,
rotation,
scaling,
translation,
uniformFactor
} from './visual-math.js';
import { boundingBox, primitiveSubpaths } from './visual-geometry.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;
const DEFAULT_STYLE = Object.freeze({
fill: null, stroke: null, strokeWidth: 1, strokeCap: 'butt', strokeJoin: 'miter',
strokeDash: undefined, strokeDashOffset: 0, pointSize: 1, opacity: 1, blend: 'normal',
glow: null, shadow: null, blur: 0, filters: [], clip: null, mask: null
});
// 17.12: inheritance is per field. `opacity` is deliberately absent — it
// *multiplies* with any inherited group opacity rather than replacing it, and
// is accumulated separately as the effective alpha of the compositing order.
const INHERITED_STYLE_FIELDS = Object.freeze([
'fill', 'stroke', 'strokeWidth', 'strokeCap', 'strokeJoin', 'strokeDash', 'strokeDashOffset',
'pointSize', 'blend', 'glow', 'shadow', 'blur', 'filters'
]);
/** 17.12: `line`, `polyline`, `arc`, and `bezier` have no interior. */
const STROKE_ONLY = new Set(['line', 'polyline', 'arc', 'bezier']);
// ---------------------------------------------------------------------------
// Instantiation (17.14)
// ---------------------------------------------------------------------------
function isValueSpec(value) {
if (!isRecord(value)) return false;
if (Object.hasOwn(value, 'ref') || Object.hasOwn(value, 'random') || Object.hasOwn(value, 'choose')) return true;
// A ValueSpec `op` carries `args`; a path command's `op` is a command token.
return Object.hasOwn(value, 'op') && Array.isArray(value.args);
}
/**
* Resolve every ValueSpec in a raw subtree, depth-first in property-document
* 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 = '$', 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)) {
// 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}`, 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 };
for (const field of INHERITED_STYLE_FIELDS) {
if (Object.hasOwn(own, field)) merged[field] = own[field];
}
// `clip` and `mask` are local: a clip intersects with any inherited one and a
// mask names a child of its own group (17.12), so neither inherits downward.
merged.clip = Object.hasOwn(own, 'clip') ? own.clip : null;
merged.mask = Object.hasOwn(own, 'mask') ? own.mask : null;
return merged;
}
function vectorOf(value, fallback = 0) {
return { x: value?.x ?? fallback, y: value?.y ?? fallback, z: value?.z ?? fallback };
}
/** One instantiated visual object: resolved values, its local matrix, its subtree. */
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);
}
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,
path,
type: resolved.type,
raw: resolved,
position: { x: resolved.position?.x ?? 0, y: resolved.position?.y ?? 0 },
z: resolved.z ?? 0,
translateZ: transform.translate?.z ?? 0,
visible: resolved.visible ?? true,
matrix: localMatrix({
position: { x: resolved.position?.x ?? 0, y: resolved.position?.y ?? 0 },
translate: vectorOf(transform.translate),
rotation: transform.rotation ?? 0,
skew: vectorOf(transform.skew),
scale: { x: transform.scale?.x ?? 1, y: transform.scale?.y ?? 1 },
origin: vectorOf(transform.origin)
}),
style: mergeStyle(context.style, resolved.style),
// 17.12: opacity multiplies down the tree rather than being inherited.
ownOpacity: typeof resolved.style?.opacity === 'number' ? resolved.style.opacity : 1,
children: []
};
if (resolved.type === 'group') {
const previousStyle = context.style;
context.style = node.style;
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;
}
// 17.12: a `fill` declared on a stroke-only primitive is a validation error;
// one inherited from an ancestor is ignored for it and still applies to its
// 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;
}
// ---------------------------------------------------------------------------
// Display, fit, and camera (17.4, 19.3)
// ---------------------------------------------------------------------------
/** 19.3: the fit matrix F, carrying the centering offsets contain and cover need. */
export function resolveFit(scene, displayWidth, displayHeight) {
const space = scene?.coordinateSpace ?? 'virtual';
const sceneWidth = space === 'virtual' ? scene.width : space === 'normalized' ? 1 : displayWidth;
const sceneHeight = space === 'virtual' ? scene.height : space === 'normalized' ? 1 : displayHeight;
let sx = 1;
let sy = 1;
if (space !== 'viewport') {
// 17.4 (V17): in `viewport` the fit field takes no default and has no
// mapping effect, so the scale stays 1 whatever was authored.
const fit = scene?.fit ?? 'contain';
if (fit === 'stretch') {
sx = displayWidth / sceneWidth;
sy = displayHeight / sceneHeight;
} else {
const uniform = fit === 'cover'
? Math.max(displayWidth / sceneWidth, displayHeight / sceneHeight)
: Math.min(displayWidth / sceneWidth, displayHeight / sceneHeight);
sx = uniform;
sy = uniform;
}
}
const ox = (displayWidth - sx * sceneWidth) / 2;
const oy = (displayHeight - sy * sceneHeight) / 2;
return { space, sceneWidth, sceneHeight, sx, sy, ox, oy, matrix: multiply(translation(ox, oy), scaling(sx, sy)) };
}
/**
* 19.5 and A3: the device-pixel multiplier is `min(devicePixelRatio, 2)`,
* lowered — below `1` where a display larger than 4096 requires it — until the
* backing store fits `4096 x 4096`.
*/
export function resolveBackingStore(displayWidth, displayHeight, devicePixelRatio) {
const requested = Math.min(devicePixelRatio, RUNTIME.devicePixelRatioCeiling);
const longest = Math.max(displayWidth, displayHeight);
const permitted = longest > 0 ? RUNTIME.backingStoreEdge / longest : requested;
const multiplier = Math.min(requested, permitted);
return {
multiplier,
reduced: multiplier < requested,
below1: multiplier < 1,
width: Math.max(1, Math.round(displayWidth * multiplier)),
height: Math.max(1, Math.round(displayHeight * multiplier))
};
}
/** 19.3: `V = T(c) x S(zoom) x R(-rotation) x T(-c) x T(-p * (q - c))`. */
export function cameraMatrix({ center, zoom, rotationDegrees, cameraCss, parallax }) {
const [cx, cy] = center;
const [qx, qy] = cameraCss;
return compose(
translation(cx, cy),
scaling(zoom, zoom),
rotation(-rotationDegrees),
translation(-cx, -cy),
translation(-parallax * (qx - cx), -parallax * (qy - cy))
);
}
// ---------------------------------------------------------------------------
// The engine
// ---------------------------------------------------------------------------
export class VisualEngine {
constructor(document, { resolution = null, rng = null, diagnostics = null, capabilities = {} } = {}) {
this.document = document;
this.resolution = resolution;
this.rng = rng;
this.capabilities = { conicGradient: true, ...capabilities };
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();
if (this.visuals) this.instantiate();
}
/** 17.14: activation is the instantiation boundary of a persistent system. */
instantiate() {
const visuals = this.visuals;
const declared = visuals.layers ? Object.keys(visuals.layers) : [];
this.layers = declared.length > 0
? declared.map((id) => ({ id, ...visuals.layers[id] }))
: [{ 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();
for (const [id, system] of Object.entries(visuals.systems ?? {})) {
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;
}
defer(id, reason) {
if (this.deferredKeys.has(id)) return;
this.deferredKeys.add(id);
this.deferred.push({ id, reason });
}
cameraValue(field) {
const path = `visuals.camera.${field}`;
if (this.resolution) {
try { return this.resolution.get(path); } catch { /* fall through to the authored default */ }
}
const authored = this.visuals?.camera?.[field];
if (typeof authored === 'number') return authored;
return CAMERA_FIELDS[field].default ?? 0;
}
layerValue(layer, field, fallback) {
if (layer.id && this.resolution) {
try { return this.resolution.get(`visuals.layers.${layer.id}.${field}`); } catch { /* authored value below */ }
}
const authored = layer[field];
return authored === undefined || isRecord(authored) ? fallback : authored;
}
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 */ }
}
return system.visible !== false;
}
/**
* Build one frame's plan. `width` and `height` are the display rectangle in
* CSS pixels; everything in the returned plan is in device pixels.
*/
planFrame({ width, height, devicePixelRatio = 1, logicalMilliseconds = 0 } = {}) {
if (!this.visuals) return null;
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.`);
}
const center = [width / 2, height / 2];
const B = scaling(backing.multiplier, backing.multiplier);
const projection = this.visuals.camera?.projection ?? 'orthographic';
const zoom = this.cameraValue('zoom');
const rotationDegrees = this.cameraValue('rotation');
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.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);
// `stats` is shared by reference: each unit is emitted with a shallow copy
// of this context carrying its own layer view matrix, so per-frame counters
// have to live behind a reference rather than on the copy.
const context = {
fit, backing, B, center, projection, zoom, focalLength, fog,
logicalMilliseconds, buffers: [], stats: { culled: 0 }
};
const layers = [];
for (const layer of this.layers) {
const parallax = this.layerValue(layer, 'parallax', 1);
const view = cameraMatrix({ center, zoom, rotationDegrees, cameraCss, parallax });
const opacity = clamp(this.layerValue(layer, 'opacity', 1), 0, 1);
const visible = this.layerValue(layer, 'visible', true) !== false;
const blend = layer.blend ?? 'normal';
const units = this.sortableUnits(layer);
const nodes = [];
for (const unit of units) {
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.
plan.representativeDepth = unit.representativeDepth;
nodes.push(plan);
}
layers.push({
id: layer.id, opacity, blend, visible, parallax,
// 17.12: a layer whose opacity or blend is not the default needs a buffer.
buffered: opacity !== 1 || blend !== 'normal',
nodes
});
}
this.allocateBuffers(layers, context);
const effects = this.effectPlan(fit, backing, zoom);
return {
background: formatColor(parseColor(scene.background ?? '#000000', '$.visuals.scene.background')),
display: { width, height },
backing,
fit,
camera: { x: cameraX, y: cameraY, zoom, rotation: rotationDegrees, projection, focalLength, cameraCss, center },
fog,
layers, effects, logicalMilliseconds: this.time,
culled: context.stats.culled,
buffers: context.bufferSummary,
deferred: this.deferred,
diagnostics: this.cadence.raised.slice()
};
}
resolveFog(scene) {
const declared = scene.depthFog;
if (!declared) return null;
const near = declared.near ?? 0;
const far = declared.far;
if (!(far > near)) throw new RuntimeFault('ERR_INVALID_RANGE_ORDER', 'depthFog.far must exceed depthFog.near.', '$.visuals.scene.depthFog');
return { color: parseColor(declared.color, '$.visuals.scene.depthFog.color'), near, far, density: declared.density ?? 1 };
}
/**
* 17.6: within a layer the sortable units are each `graphic` system's
* top-level objects and each procedural system as one atomic unit. They sort
* by representative depth descending, ties by system key order then object
* key order, and the sort is stable.
*/
sortableUnits(layer) {
const units = [];
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, releaseFactor: system.instance?.factor ?? 1 });
}
}
return units.sort((a, b) => (b.representativeDepth - a.representativeDepth)
|| (a.systemIndex - b.systemIndex)
|| (a.objectIndex - b.objectIndex));
}
// -------------------------------------------------------------------------
// Emitting one object's plan node (17.6, 17.11, 17.12, 19.3)
// -------------------------------------------------------------------------
emitNode(node, parentMatrix, parentZ, parentAlpha, ctx) {
const zEffective = parentZ + node.z + node.translateZ;
// 17.7/17.10: a hidden object and its descendants are not drawn; their
// behaviors and automation still advance, which is not this pass's concern.
if (node.visible === false) return null;
if (ctx.projection === 'perspective' && zEffective <= -ctx.focalLength) {
// 17.6: at or behind the eye. Culled for the frame, no diagnostic.
ctx.stats.culled += 1;
return null;
}
if (node.type === 'component') {
// Recorded once, not once per frame: a plan is built every frame and the
// deferral is a property of the document, not of the frame.
this.defer(node.path, 'component expansion lands in slice 4e (18.1)');
return null;
}
const matrix = multiply(parentMatrix, node.matrix);
const alpha = parentAlpha * node.ownOpacity;
const k = ctx.projection === 'perspective' ? ctx.focalLength / (ctx.focalLength + zEffective) : 1;
// 19.3: the one uniform factor an axis-less appearance dimension takes.
const g = ctx.zoom * k * Math.sqrt(ctx.fit.sx * ctx.fit.sy);
const scalar = (value) => (value ?? 0) * g * ctx.backing.multiplier;
const project = (px, py, pz = 0) => {
let [x, y] = apply(matrix, px, py);
[x, y] = apply(ctx.fit.matrix, x, y);
[x, y] = apply(ctx.view, x, y);
if (ctx.projection === 'perspective') {
const factor = ctx.focalLength / (ctx.focalLength + zEffective + pz);
x = ctx.center[0] + (x - ctx.center[0]) * factor;
y = ctx.center[1] + (y - ctx.center[1]) * factor;
}
return apply(ctx.B, x, y);
};
const style = node.style;
const fogFraction = ctx.fog
? ctx.fog.density * clamp((zEffective - ctx.fog.near) / (ctx.fog.far - ctx.fog.near), 0, 1)
: 0;
const shade = (value, path) => {
const parsed = parseColor(value, path);
return formatColor(ctx.fog ? fogColor(parsed, ctx.fog.color, fogFraction) : parsed);
};
const plan = {
kind: node.type === 'group' ? 'group' : node.type === 'text' ? 'text' : node.type === 'point' ? 'point' : 'shape',
path: node.path,
type: node.type,
z: zEffective,
perspective: k,
alpha,
blend: style.blend ?? 'normal',
fill: this.buildPaint(style.fill, node, project, scalar, shade, ctx, 'fill'),
stroke: this.buildPaint(style.stroke, node, project, scalar, shade, ctx, 'stroke'),
strokeWidth: scalar(style.strokeWidth ?? 1),
strokeCap: style.strokeCap ?? 'butt',
strokeJoin: style.strokeJoin ?? 'miter',
strokeDash: Array.isArray(style.strokeDash) ? style.strokeDash.map(scalar) : undefined,
strokeDashOffset: scalar(style.strokeDashOffset ?? 0),
glow: style.glow ? { color: shade(style.glow.color, `${node.path}.style.glow.color`), radius: scalar(style.glow.radius), strength: style.glow.strength ?? 1 } : null,
shadow: style.shadow ? {
color: shade(style.shadow.color, `${node.path}.style.shadow.color`),
blurRadius: scalar(style.shadow.blurRadius ?? 0),
offsetX: scalar(style.shadow.offsetX ?? 0),
offsetY: scalar(style.shadow.offsetY ?? 0)
} : null,
blur: scalar(style.blur ?? 0),
filters: Array.isArray(style.filters) ? style.filters.map((entry) => ({ ...entry })) : [],
clip: this.buildClip(style.clip, project),
fogFraction,
children: [],
mask: null
};
if (node.type === 'group') {
const maskKey = style.mask ?? null;
for (const child of node.children) {
const childPlan = this.emitNode(child, matrix, zEffective, alpha, ctx);
if (!childPlan) continue;
// 17.12: the named child is not drawn; its rendered alpha multiplies
// the alpha of the group's remaining children.
if (maskKey !== null && child.key === maskKey) plan.mask = childPlan;
else plan.children.push(childPlan);
}
if (maskKey !== null && plan.mask === null) {
throw new RuntimeFault('ERR_INVALID_REFERENCE', `mask '${maskKey}' names no child of this group.`, node.path);
}
} else if (node.type === 'point') {
plan.center = project(0, 0);
plan.diameter = scalar(style.pointSize ?? 1);
} else if (node.type === 'text') {
// Text is the one primitive drawn by the backend's own text engine, so it
// carries the matrix rather than transformed geometry.
plan.matrix = this.deviceMatrix(matrix, zEffective, ctx);
plan.text = String(node.raw.text ?? '');
if (plan.text.length > VISUAL_LIMITS.authoring.textCharacters) {
throw new RuntimeFault('ERR_OUT_OF_BOUNDS', `Resolved text exceeds ${VISUAL_LIMITS.authoring.textCharacters} characters.`, node.path);
}
plan.font = node.raw.font ?? 'sans-serif';
plan.size = node.raw.size ?? 16;
plan.weight = node.raw.weight ?? 'normal';
plan.italic = node.raw.italic === true;
plan.align = node.raw.align ?? 'left';
plan.baseline = node.raw.baseline ?? 'alphabetic';
plan.letterSpacing = node.raw.letterSpacing ?? 0;
plan.maxWidth = node.raw.maxWidth;
if (plan.maxWidth !== undefined && !(plan.maxWidth > 0)) {
throw new RuntimeFault('ERR_OUT_OF_BOUNDS', 'maxWidth must be above zero.', node.path);
}
} else {
plan.subpaths = node.geometry.map((subpath) => ({
closed: subpath.closed,
start: project(subpath.start[0], subpath.start[1], subpath.start[2] ?? 0),
segments: subpath.segments.map((segment) => segment.type === 'line'
? { type: 'line', to: project(segment.to[0], segment.to[1], segment.to[2] ?? 0) }
: {
type: 'cubic',
c1: project(segment.c1[0], segment.c1[1], segment.c1[2] ?? 0),
c2: project(segment.c2[0], segment.c2[1], segment.c2[2] ?? 0),
to: project(segment.to[0], segment.to[1], segment.to[2] ?? 0)
})
}));
plan.fillRule = node.raw.fillRule ?? 'nonzero';
}
// 17.12: a non-default blend, a mask, a blur, a glow, or any filters entry
// requires an offscreen buffer for the object or group.
plan.needsBuffer = plan.blend !== 'normal' || plan.mask !== null || plan.blur > 0 || plan.glow !== null || plan.filters.length > 0;
if (plan.needsBuffer) ctx.buffers.push({ plan, depth: zEffective, order: ctx.buffers.length });
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;
const perspective = compose(translation(ctx.center[0], ctx.center[1]), scaling(k, k), translation(-ctx.center[0], -ctx.center[1]));
return compose(ctx.B, perspective, ctx.view, ctx.fit.matrix, matrix);
}
buildClip(clip, project) {
if (!isRecord(clip)) return null;
const { shape, x, y, width, height } = clip;
if (shape === 'ellipse') {
const cx = x + width / 2;
const cy = y + height / 2;
return { shape: 'ellipse', center: project(cx, cy), corners: [project(x, y), project(x + width, y), project(x + width, y + height), project(x, y + height)] };
}
return { shape: 'rectangle', corners: [project(x, y), project(x + width, y), project(x + width, y + height), project(x, y + height)] };
}
/**
* 17.12: a paint is a color, a gradient object, or null. Gradient stops are
* fogged individually before the paint is constructed, so a fogged gradient
* keeps its offsets and its shape and loses only its contrast.
*/
buildPaint(paint, node, project, scalar, shade, ctx, role) {
if (paint === null || paint === undefined) return null;
if (typeof paint === 'string') return { type: 'color', color: shade(paint, `${node.path}.style.${role}`) };
if (!isRecord(paint)) throw new RuntimeFault('ERR_TYPE_MISMATCH', 'A paint must be a color, a paint object, or null.', node.path);
const stops = (paint.stops ?? []).map((stop) => ({ offset: stop.offset, color: shade(stop.color, `${node.path}.style.${role}.stops`) }));
if (paint.type === 'linear-gradient') {
return { type: 'linear-gradient', stops, from: project(paint.from?.x ?? 0, paint.from?.y ?? 0), to: project(paint.to?.x ?? 0, paint.to?.y ?? 0) };
}
if (paint.type === 'radial-gradient') {
return {
type: 'radial-gradient', stops,
center: project(paint.center?.x ?? 0, paint.center?.y ?? 0),
radius: scalar(paint.radius ?? 0),
innerRadius: scalar(paint.innerRadius ?? 0)
};
}
if (paint.type === 'conic-gradient') {
const center = { x: paint.center?.x ?? 0, y: paint.center?.y ?? 0 };
const angle = paint.angle ?? 0;
if (this.capabilities.conicGradient) {
return { type: 'conic-gradient', stops, center: project(center.x, center.y), angle };
}
return this.conicFallback(node, stops, center, angle, project, `${node.path}.style.${role}`);
}
throw new RuntimeFault('ERR_SCHEMA_VALIDATION', `Unknown paint type '${paint.type}'.`, node.path);
}
/**
* 17.12: the documented conic fallback. The axis runs from `center` along
* `angle` to the local-space bounding box's boundary; where the center is
* outside the box or the ray never meets it, it runs to the distance of the
* farthest corner, which reduces to the intersection case when one exists.
* The stops and their offsets are preserved exactly.
*/
conicFallback(node, stops, center, angle, project, path) {
const box = boundingBox(node.geometry);
const corners = [[box.x, box.y], [box.x + box.width, box.y], [box.x + box.width, box.y + box.height], [box.x, box.y + box.height]];
const distance = Math.max(...corners.map(([x, y]) => Math.hypot(x - center.x, y - center.y)), 0);
this.cadence.once('WARN_VISUAL_APPROXIMATION', path,
'The renderer has no conic gradient; the documented linear fallback was used with the same stops.');
if (distance === 0) {
// A degenerate box has no axis. A flat first stop is a materially milder
// substitution than omitting the paint.
return { type: 'color', color: stops[0]?.color ?? '#000000', approximated: true };
}
const radians = angle * (Math.PI / 180);
return {
type: 'linear-gradient', stops, approximated: true,
from: project(center.x, center.y),
to: project(center.x + distance * Math.cos(radians), center.y + distance * Math.sin(radians))
};
}
/**
* 17.12 and 19.5: sixteen buffer allocations per frame. Layer buffers are
* allocated first, then object buffers nearest-first, so what runs out is the
* far content and the refusals are taken farthest-first.
*/
allocateBuffers(layers, ctx) {
const layerBuffers = layers.filter((layer) => layer.visible && layer.buffered).length;
let budget = Math.max(0, RUNTIME.offscreenBuffersPerFrame - layerBuffers);
const candidates = ctx.buffers.slice().sort((a, b) => (a.depth - b.depth) || (a.order - b.order));
let granted = 0;
let shed = 0;
for (const candidate of candidates) {
if (budget > 0) {
candidate.plan.buffered = true;
budget -= 1;
granted += 1;
continue;
}
// Refused: the owner draws without its buffer-requiring features. This is
// a diagnosed exception to 17.1, not a silent omission.
const plan = candidate.plan;
plan.buffered = false;
plan.shed = true;
plan.blend = 'normal';
plan.mask = null;
plan.blur = 0;
plan.glow = null;
plan.filters = [];
shed += 1;
this.cadence.shed('WARN_VISUAL_APPROXIMATION', 'offscreen-buffers',
`The frame needs more than ${RUNTIME.offscreenBuffersPerFrame} offscreen buffers; the farthest objects drew without their buffered appearance features.`,
ctx.logicalMilliseconds);
}
ctx.bufferSummary = { limit: RUNTIME.offscreenBuffersPerFrame, layers: layerBuffers, granted, shed };
}
}