feat(visual): implement the slice 4d renderer core

The first visual code that draws. Sections 17.4 through 17.14 are implemented
against Format Specification revision 0.8, and the standalone artifact now has a
stage canvas that renders a declared scene.

The renderer is split so that the automated traces of 17.16 can run without a
display, which is what those traces require. visual-engine.js resolves an
exhibit's objects once at their instantiation boundary, composes the
scene-to-device chain, sorts by depth, applies fog and the compositing order,
and emits a **frame plan** in device pixels; visual-canvas2d.js turns a plan
into drawing calls. A plan carries the numeric oracles the traces ask for - a
known scene point at a known display point, a perspective factor, a fog
fraction, a sort order - where a canvas carries only pixels.

visual-math.js implements the affine matrices, the normative local transform of
17.11, the directed arc sweep of 17.9 with its bound checked before
normalization, and section 2 color parsing with the fog and ramp arithmetic of
17.6 and 18.2. Colors outside the four documented forms are ERR_TYPE_MISMATCH:
exact components are what make per-object fog renderer-independent.

visual-geometry.js reduces all fourteen primitives to subpaths of lines and
cubic Beziers, so an affine map carries geometry exactly rather than
approximately. It implements the local extents and anchors of 17.9 - a
rectangle anchored top-left, circular primitives centered - the uniform cardinal
form of catmull-rom with its open-duplication and closed-wrap index rules, the
straight closing segment of a closed bezier spline, and endpoint-parameterized
elliptical arcs.

The composition chain of 19.3 is implemented as written: the fit matrix carries
the contain and cover centering offsets, the camera center is mapped into CSS
coordinates before the translation is formed, perspective acts about the
projection center, and the device-pixel multiplier is applied exactly once and
drops below 1 on a display larger than 4096. Scalars with no axis - stroke
widths, radii, dash lengths - take the single uniform factor g, so a nonuniform
stretch never distorts a stroke.

Depth follows 17.6: effective depth accumulates z and translate.z up the tree,
sortable units order farthest-first with the documented tie-break, a point's own
z projects that point without making it a sortable unit, and an object at or
behind the eye is culled with no diagnostic. Compositing follows 17.12: opacity
multiplies down the tree while layer opacity applies once at the layer, and the
sixteen buffer allocations per frame are granted nearest-first so refusals fall
on the far content, each diagnosed under the 19.5 cadence rather than omitted
silently.

visual-validation.js gains the object tree: the fifteen object types and their
declared fields, no `id` and no `layer` on an object, a declared fill on a
stroke-only primitive, a mask only on a group and only naming its own child,
path legality, spline modes and point counts, gradient stop order, and every
authoring limit of 19.5 that applies to a drawn object.

While implementing V10 I corrected the buffer-accounting rule I had written into
17.12: counting buffers as concurrently live made 19.5's farthest-first shedding
nearly unreachable, since group nesting is already capped at 8. The rule is now
16 allocations per frame, which is the per-frame reading "pass budget" has
carried since 17.5 first used it. The arc-sweep prose is likewise corrected to
normalize the signed delta, so 350 -> 10 clockwise is the 20-degree sweep the
text always claimed rather than 340 the long way round.

test/phase4-renderer.test.mjs adds 22 tests covering traces 1 through 13 of
17.16 plus buffer shedding and the backend's call order. npm test goes from 131
to 153 passing. Traces 15 and 16 remain user-observed, and no procedural system,
automation track, or post-effect executes yet - 4e and 4f own those.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_0162Jb1J36judZNT8fHabGVt
This commit is contained in:
2026-09-06 16:38:44 +00:00
co-authored by Claude Opus 5
parent 699492f844
commit 0af58da89d
16 changed files with 4303 additions and 5 deletions
+651
View File
@@ -0,0 +1,651 @@
// 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 } 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 } from './visual-contract.js';
import { VisualDiagnosticCadence } from './visual-diagnostics.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 = '$') {
if (Array.isArray(raw)) return raw.map((entry, index) => sampleTree(entry, resolver, stream, `${path}[${index}]`));
if (!isRecord(raw)) return raw;
if (isValueSpec(raw)) return resolver.evaluate(raw, stream, path);
const result = {};
for (const key of Object.keys(raw)) result[key] = sampleTree(raw[key], resolver, stream, `${path}.${key}`);
return result;
}
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 vector(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;
if (depth > VISUAL_LIMITS.authoring.groupNesting) {
throw new RuntimeFault('ERR_VISUAL_LIMIT_EXCEEDED', `Group nesting deeper than ${VISUAL_LIMITS.authoring.groupNesting} levels.`, path);
}
const resolved = sampleTree(raw, context.resolver, context.stream, path);
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: vector(transform.translate),
rotation: transform.rotation ?? 0,
skew: vector(transform.skew),
scale: { x: transform.scale?.x ?? 1, y: transform.scale?.y ?? 1 },
origin: vector(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(resolved.children ?? {})) {
node.children.push(instantiateObject(childKey, raw.children[childKey], context, path, depth + 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);
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.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;
this.systems = [];
this.deferred = [];
this.deferredKeys = new Set();
let index = 0;
for (const [id, system] of Object.entries(visuals.systems ?? {})) {
// 19.2: a spawned system is a template. It is validated and counted at
// import and is not instantiated or drawn at activation.
if ((system.lifecycle ?? 'persistent') === 'spawned') {
this.defer(id, 'spawned template, instantiated only by a spawn action (19.2)');
continue;
}
if (system.type !== 'graphic') {
// 18.2-18.5 procedural systems execute in slice 4e. Their schema is
// carried and validated; nothing is drawn for them here.
this.defer(id, `'${system.type}' systems execute in slice 4e (18.2-18.5)`);
continue;
}
const stream = this.rng ? this.rng.stream('visual', `visuals.systems.${id}`) : null;
const context = { resolver, stream, style: DEFAULT_STYLE };
const objects = [];
let objectIndex = 0;
for (const key of Object.keys(system.content ?? {})) {
const node = instantiateObject(key, system.content[key], context, `visuals.systems.${id}.content`, 1);
node.order = objectIndex;
objectIndex += 1;
objects.push(node);
}
this.systems.push({ id, index, layer: system.layer ?? null, visible: system.visible ?? true, objects });
index += 1;
}
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 (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;
const scene = this.visuals.scene ?? {};
const fit = resolveFit(scene, width, height);
const backing = resolveBackingStore(width, height, devicePixelRatio);
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.visuals.camera?.x === undefined) cameraX = center[0];
if (fit.space === 'viewport' && 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 = this.emitNode(unit.node, IDENTITY, 0, 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);
this.cadence.endTick();
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,
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;
for (const node of system.objects) {
units.push({ node, representativeDepth: node.z + node.translateZ, systemIndex: system.index, objectIndex: node.order });
}
}
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;
}
/** 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 };
}
}