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
1209 lines
59 KiB
JavaScript
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 };
|
|
}
|
|
|
|
}
|