// 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 }; } }