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
494 lines
28 KiB
JavaScript
494 lines
28 KiB
JavaScript
// Slice 4d — renderer core.
|
|
//
|
|
// These are the automated traces of 17.16: coordinate spaces and fit, the
|
|
// primitive set, the normative transform order, group composition, depth
|
|
// sorting, perspective, depth fog, style inheritance, the authoring limits, the
|
|
// conic fallback, path legality, masking, and once-at-instantiation resolution.
|
|
// Each asserts a numeric oracle against the frame plan rather than a pixel,
|
|
// which is what makes them executable without a display (17.16).
|
|
|
|
import assert from 'node:assert/strict';
|
|
import test from 'node:test';
|
|
import { ResolutionEngine } from '../src/runtime/resolution.js';
|
|
import { SeededRNG } from '../src/runtime/rng.js';
|
|
import { RuntimeFault } from '../src/runtime/types.js';
|
|
import { validateExhibit } from '../src/runtime/validator.js';
|
|
import { VisualEngine, resolveBackingStore, resolveFit, sampleTree } from '../src/runtime/visual-engine.js';
|
|
import { primitiveSubpaths } from '../src/runtime/visual-geometry.js';
|
|
import { parseColor } from '../src/runtime/visual-math.js';
|
|
import { renderFrame } from '../src/runtime/visual-canvas2d.js';
|
|
|
|
const close = (actual, expected, epsilon = 1e-9) => assert.ok(Math.abs(actual - expected) <= epsilon, `${actual} != ${expected}`);
|
|
const closePoint = (actual, expected, epsilon = 1e-9) => {
|
|
close(actual[0], expected[0], epsilon);
|
|
close(actual[1], expected[1], epsilon);
|
|
};
|
|
|
|
function exhibit(visuals, overrides = {}) {
|
|
return { xzbt: '0.1', meta: { id: 'render-study', name: 'Render Study' }, runtime: { seed: 42 }, visuals, ...overrides };
|
|
}
|
|
|
|
function build(visuals, overrides = {}) {
|
|
const document = exhibit(visuals, overrides);
|
|
const resolution = new ResolutionEngine(document, new SeededRNG(42));
|
|
return new VisualEngine(document, { resolution, rng: new SeededRNG(42), ...overrides.engine });
|
|
}
|
|
|
|
const scene = { coordinateSpace: 'virtual', width: 1600, height: 900 };
|
|
const graphic = (content, extra = {}) => ({ type: 'graphic', content, ...extra });
|
|
const codes = (document) => validateExhibit(document).errors.map((error) => error.code);
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Trace 1 — coordinate spaces and fit (17.4, 19.3)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
test('17.16.1 each coordinate space maps a known point under each fit mode', () => {
|
|
// virtual, contain, matched aspect: a pure uniform scale with no letterbox.
|
|
const exact = resolveFit({ coordinateSpace: 'virtual', width: 1600, height: 900, fit: 'contain' }, 1920, 1080);
|
|
close(exact.sx, 1.2); close(exact.ox, 0); close(exact.oy, 0);
|
|
|
|
// contain into a square display letterboxes on the short axis.
|
|
const contain = resolveFit({ coordinateSpace: 'virtual', width: 1600, height: 900, fit: 'contain' }, 1000, 1000);
|
|
close(contain.sx, 0.625); close(contain.ox, 0); close(contain.oy, 218.75);
|
|
|
|
// cover crops instead: the offsets go negative.
|
|
const cover = resolveFit({ coordinateSpace: 'virtual', width: 1600, height: 900, fit: 'cover' }, 1000, 1000);
|
|
close(cover.sx, 1000 / 900); close(cover.oy, 0);
|
|
close(cover.ox, (1000 - (1000 / 900) * 1600) / 2);
|
|
assert.ok(cover.ox < 0, 'cover crops the long axis');
|
|
|
|
// stretch scales each axis independently and changes the aspect ratio.
|
|
const stretch = resolveFit({ coordinateSpace: 'virtual', width: 1600, height: 900, fit: 'stretch' }, 1000, 1000);
|
|
close(stretch.sx, 0.625); close(stretch.sy, 1000 / 900); close(stretch.ox, 0); close(stretch.oy, 0);
|
|
|
|
// normalized is the unit square under the same rules.
|
|
const normalized = resolveFit({ coordinateSpace: 'normalized' }, 1600, 900);
|
|
close(normalized.sx, 900); close(normalized.ox, 350);
|
|
|
|
// viewport has no intrinsic size, so it never scales and takes no fit default.
|
|
const viewport = resolveFit({ coordinateSpace: 'viewport' }, 1280, 720);
|
|
close(viewport.sx, 1); close(viewport.sy, 1); close(viewport.ox, 0); close(viewport.oy, 0);
|
|
});
|
|
|
|
test('17.16.1 a scene point lands at the documented display point', () => {
|
|
const engine = build({ scene: { ...scene, fit: 'contain' }, systems: { s: graphic({ mark: { type: 'point', position: { x: 800, y: 450 } } }) } });
|
|
const plan = engine.planFrame({ width: 1000, height: 1000 });
|
|
// Scene center, letterboxed: (800, 450) -> 0.625 * (800, 450) + (0, 218.75).
|
|
closePoint(plan.layers[0].nodes[0].center, [500, 500]);
|
|
});
|
|
|
|
test('19.5 the device-pixel multiplier is clamped, and drops below 1 on a large display (A3)', () => {
|
|
assert.equal(resolveBackingStore(1920, 1080, 3).multiplier, 2, 'clamped to the ceiling');
|
|
assert.equal(resolveBackingStore(1920, 1080, 1).multiplier, 1);
|
|
const large = resolveBackingStore(5000, 1000, 2);
|
|
assert.ok(large.below1 && large.width === 4096, 'the backing-store cap wins over the multiplier floor');
|
|
assert.equal(resolveBackingStore(800, 600, 0.75).multiplier, 0.75, 'a reported ratio below 1 is kept');
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Trace 2 — the primitive set (17.9)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
test('17.16.2 every primitive instantiates, and the two rejections differ', () => {
|
|
const minimal = {
|
|
point: {}, line: { to: { x: 4, y: 0 } }, polyline: { points: [{ x: 0, y: 0 }, { x: 4, y: 4 }] },
|
|
polygon: { points: [{ x: 0, y: 0 }, { x: 4, y: 0 }, { x: 2, y: 4 }] },
|
|
rectangle: { size: { width: 4, height: 2 } },
|
|
'rounded-rectangle': { size: { width: 8, height: 6 }, radius: 2 },
|
|
ellipse: { radius: 3 }, arc: { radius: 3, startAngle: 0, endAngle: 90 },
|
|
ring: { radius: 6, innerRadius: 3 },
|
|
path: { commands: [{ op: 'move', to: { x: 0, y: 0 } }, { op: 'line', to: { x: 3, y: 3 } }] },
|
|
bezier: { c1: { x: 1, y: 0 }, c2: { x: 2, y: 3 }, to: { x: 3, y: 3 } },
|
|
spline: { points: [{ x: 0, y: 0 }, { x: 3, y: 3 }, { x: 6, y: 0 }] },
|
|
text: { text: 'label' },
|
|
group: { children: { inner: { type: 'point' } } }
|
|
};
|
|
const content = {};
|
|
for (const [type, fields] of Object.entries(minimal)) content[`k${type.replace(/-/g, '')}`] = { type, ...fields };
|
|
const document = exhibit({ scene, systems: { s: graphic(content) } });
|
|
assert.deepEqual(validateExhibit(document).errors, [], 'every minimal primitive validates');
|
|
const engine = build({ scene, systems: { s: graphic(content) } });
|
|
const plan = engine.planFrame({ width: 1600, height: 900 });
|
|
assert.equal(plan.layers[0].nodes.length, Object.keys(minimal).length);
|
|
|
|
// An unknown type and an unknown property on a known type are different codes.
|
|
assert.ok(codes(exhibit({ scene, systems: { s: graphic({ a: { type: 'planet', radius: 2 } }) } })).includes('ERR_INVALID_PRIMITIVE_TYPE'));
|
|
assert.ok(codes(exhibit({ scene, systems: { s: graphic({ a: { type: 'ellipse', radius: 2, size: { width: 1, height: 1 } } }) } })).includes('ERR_UNKNOWN_FIELD'));
|
|
// 17.8: an object carries no id, and 17.10: layer is a system property.
|
|
assert.ok(codes(exhibit({ scene, systems: { s: graphic({ a: { type: 'point', id: 'a' } }) } })).includes('ERR_UNKNOWN_FIELD'));
|
|
assert.ok(codes(exhibit({ scene, systems: { s: graphic({ a: { type: 'point', layer: 'near' } }) } })).includes('ERR_UNKNOWN_FIELD'));
|
|
});
|
|
|
|
test('17.9 local extents and anchors follow the documented table (V4)', () => {
|
|
// A rectangle is anchored by its top-left corner; circular primitives are centered.
|
|
assert.deepEqual(primitiveSubpaths({ type: 'rectangle', size: { width: 10, height: 4 } })[0].start, [0, 0]);
|
|
const ellipse = primitiveSubpaths({ type: 'ellipse', radius: 5 })[0];
|
|
closePoint(ellipse.start, [5, 0]);
|
|
// A ring's angles default to a full turn, which is two subpaths with a hole.
|
|
assert.equal(primitiveSubpaths({ type: 'ring', radius: 10, innerRadius: 4 }).length, 2);
|
|
// A closed bezier spline closes with a straight line, not an invented curve.
|
|
const closedBezier = primitiveSubpaths({ type: 'spline', mode: 'bezier', closed: true, points: [{ x: 0, y: 0 }, { x: 1, y: 1 }, { x: 2, y: 1 }, { x: 3, y: 0 }] })[0];
|
|
assert.equal(closedBezier.segments.at(-1).type, 'line');
|
|
});
|
|
|
|
test('17.9 the arc sweep bound is checked before normalization (V4)', () => {
|
|
// 350 -> 10 clockwise is a 20-degree sweep through zero, not 340 the long way.
|
|
const short = primitiveSubpaths({ type: 'arc', radius: 10, startAngle: 350, endAngle: 10 })[0];
|
|
assert.equal(short.segments.length, 1, 'a 20-degree sweep is one Bezier piece');
|
|
assert.throws(() => primitiveSubpaths({ type: 'arc', radius: 10, startAngle: 0, endAngle: 720 }),
|
|
(error) => error instanceof RuntimeFault && error.code === 'ERR_OUT_OF_BOUNDS');
|
|
assert.ok(codes(exhibit({ scene, systems: { s: graphic({ a: { type: 'arc', radius: 10, startAngle: 0, endAngle: 720 } }) } })).includes('ERR_OUT_OF_BOUNDS'));
|
|
// A zero sweep draws nothing and raises no diagnostic.
|
|
assert.equal(primitiveSubpaths({ type: 'arc', radius: 10, startAngle: 45, endAngle: 45 })[0].segments.length, 0);
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Traces 3 and 4 — transforms and groups (17.11)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
test('17.16.3 the transform composition order is normative where it matters', () => {
|
|
const engine = build({
|
|
scene: { coordinateSpace: 'viewport' },
|
|
systems: { s: graphic({ mark: { type: 'point', position: { x: 10, y: 5 }, transform: { rotation: 90, scale: { x: 2, y: 1 } } } }) }
|
|
});
|
|
const plan = engine.planFrame({ width: 100, height: 100 });
|
|
// Scale, then skew, then rotate about origin, then translate: (1,0) is not a
|
|
// point of a `point`, so the origin itself lands at position exactly.
|
|
closePoint(plan.layers[0].nodes[0].center, [10, 5]);
|
|
|
|
// A rotated rectangle's corner is the case where the order is observable.
|
|
const rotated = build({
|
|
scene: { coordinateSpace: 'viewport' },
|
|
systems: { s: graphic({ box: { type: 'rectangle', size: { width: 4, height: 2 }, transform: { rotation: 90, scale: { x: 2, y: 1 } } } }) }
|
|
}).planFrame({ width: 100, height: 100 });
|
|
// Local (4, 0) -> scale (8, 0) -> rotate 90 (positive toward +y) -> (0, 8).
|
|
closePoint(rotated.layers[0].nodes[0].subpaths[0].segments[0].to, [0, 8]);
|
|
// Reversing the two stages would give (0, 4) scaled to (0, 4): a different point.
|
|
assert.notDeepEqual(rotated.layers[0].nodes[0].subpaths[0].segments[0].to, [0, 4]);
|
|
});
|
|
|
|
test('17.16.4 a nested group composes matrices and depth, and 9 levels is a limit', () => {
|
|
const engine = build({
|
|
scene: { coordinateSpace: 'viewport' },
|
|
systems: {
|
|
s: graphic({
|
|
outer: {
|
|
type: 'group', position: { x: 100, y: 0 }, z: 5,
|
|
children: { inner: { type: 'point', position: { x: 10, y: 3 }, z: 3 } }
|
|
}
|
|
})
|
|
}
|
|
});
|
|
const plan = engine.planFrame({ width: 400, height: 400 });
|
|
const group = plan.layers[0].nodes[0];
|
|
closePoint(group.children[0].center, [110, 3], 1e-9);
|
|
assert.equal(group.children[0].z, 8, 'z_parent + z_local');
|
|
|
|
const nest = (depth) => {
|
|
let node = { type: 'point' };
|
|
for (let level = 0; level < depth; level += 1) node = { type: 'group', children: { g: node } };
|
|
return node;
|
|
};
|
|
assert.ok(codes(exhibit({ scene, systems: { s: graphic({ root: nest(9) }) } })).includes('ERR_VISUAL_LIMIT_EXCEEDED'));
|
|
assert.deepEqual(codes(exhibit({ scene, systems: { s: graphic({ root: nest(7) }) } })), []);
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Traces 5 and 6 — depth (17.6)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
test('17.16.5 depth sorting draws greater z first and is stable in document order', () => {
|
|
const engine = build({
|
|
scene, systems: {
|
|
a: graphic({ near: { type: 'point', z: 0 }, mid: { type: 'point', z: 10 } }),
|
|
b: graphic({ far: { type: 'point', z: 100 }, alsoMid: { type: 'point', z: 10 } })
|
|
}
|
|
});
|
|
const order = () => engine.planFrame({ width: 1600, height: 900 }).layers[0].nodes.map((node) => node.path.split('.').pop());
|
|
const first = order();
|
|
assert.deepEqual(first, ['far', 'mid', 'alsoMid', 'near']);
|
|
// Equal z keeps system key order then object key order, on every frame.
|
|
assert.deepEqual(order(), first);
|
|
assert.deepEqual(order(), first);
|
|
});
|
|
|
|
test('17.16.6 perspective scales by focalLength / (focalLength + z), and culls at the eye', () => {
|
|
const make = (projection) => build({
|
|
scene: { coordinateSpace: 'viewport' },
|
|
camera: { projection, focalLength: 1000, x: 200, y: 200 },
|
|
systems: { s: graphic({ front: { type: 'point', z: 0 }, back: { type: 'point', z: 1000 }, close: { type: 'point', z: -500 } }) }
|
|
}).planFrame({ width: 400, height: 400 });
|
|
|
|
const perspective = make('perspective');
|
|
const factors = Object.fromEntries(perspective.layers[0].nodes.map((node) => [node.path.split('.').pop(), node.perspective]));
|
|
close(factors.front, 1);
|
|
close(factors.back, 0.5);
|
|
close(factors.close, 2);
|
|
|
|
const orthographic = make('orthographic');
|
|
for (const node of orthographic.layers[0].nodes) close(node.perspective, 1, 0);
|
|
// Sorting, parallax, and fog still see z under orthographic.
|
|
assert.deepEqual(orthographic.layers[0].nodes.map((node) => node.z), [1000, 0, -500]);
|
|
|
|
// An object at or behind the eye is culled for the frame, with no diagnostic.
|
|
const culled = build({
|
|
scene: { coordinateSpace: 'viewport' },
|
|
camera: { projection: 'perspective', focalLength: 1000 },
|
|
systems: { s: graphic({ eye: { type: 'point', z: -1000 }, behind: { type: 'point', z: -1200 }, ok: { type: 'point', z: -999 } }) }
|
|
}).planFrame({ width: 400, height: 400 });
|
|
assert.equal(culled.culled, 2);
|
|
assert.equal(culled.layers[0].nodes.length, 1);
|
|
assert.deepEqual(culled.diagnostics, []);
|
|
});
|
|
|
|
test('17.16.7 depth fog blends by its documented fraction, and far <= near is a range error', () => {
|
|
const engine = build({
|
|
scene: { ...scene, depthFog: { color: '#000000', near: 0, far: 100, density: 1 } },
|
|
systems: { s: graphic({ a: { type: 'rectangle', size: { width: 1, height: 1 }, z: 0, style: { fill: '#ffffff' } },
|
|
b: { type: 'rectangle', size: { width: 1, height: 1 }, z: 50, style: { fill: '#ffffff' } },
|
|
c: { type: 'rectangle', size: { width: 1, height: 1 }, z: 200, style: { fill: '#ffffff' } } }) }
|
|
});
|
|
const nodes = Object.fromEntries(engine.planFrame({ width: 1600, height: 900 }).layers[0].nodes.map((node) => [node.path.split('.').pop(), node]));
|
|
close(nodes.a.fogFraction, 0);
|
|
close(nodes.b.fogFraction, 0.5);
|
|
close(nodes.c.fogFraction, 1);
|
|
assert.equal(nodes.a.fill.color, '#ffffff');
|
|
assert.equal(nodes.b.fill.color, '#808080', '255 + (0 - 255) * 0.5');
|
|
assert.equal(nodes.c.fill.color, '#000000');
|
|
|
|
assert.ok(codes(exhibit({ scene: { ...scene, depthFog: { color: '#000', near: 100, far: 100 } } })).includes('ERR_INVALID_RANGE_ORDER'));
|
|
});
|
|
|
|
test('17.6 fog keeps alpha and fogs gradient stops before the paint is built (V18)', () => {
|
|
const engine = build({
|
|
scene: { ...scene, depthFog: { color: '#000000', near: 0, far: 100, density: 1 } },
|
|
systems: { s: graphic({ a: {
|
|
type: 'rectangle', size: { width: 10, height: 10 }, z: 50,
|
|
style: { fill: { type: 'linear-gradient', stops: [{ offset: 0, color: '#ffffff80' }, { offset: 1, color: '#ff0000' }], from: { x: 0, y: 0 }, to: { x: 10, y: 0 } } }
|
|
} }) }
|
|
});
|
|
const paint = engine.planFrame({ width: 1600, height: 900 }).layers[0].nodes[0].fill;
|
|
assert.equal(paint.type, 'linear-gradient');
|
|
assert.deepEqual(paint.stops.map((stop) => stop.offset), [0, 1], 'offsets are preserved exactly');
|
|
// Alpha survives the fog; the components are halved toward the fog color.
|
|
assert.equal(parseColor(paint.stops[0].color).a, parseColor('#ffffff80').a);
|
|
assert.equal(paint.stops[1].color, '#800000');
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Traces 8 to 12 — appearance, limits, fallback, paths, masking (17.12, 17.13)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
test('17.16.8 style inheritance is per field, and a stroke-only fill is a declaration error', () => {
|
|
const engine = build({
|
|
scene: { coordinateSpace: 'viewport' },
|
|
systems: { s: graphic({ g: {
|
|
type: 'group', style: { fill: '#ff0000', stroke: '#00ff00', strokeWidth: 4 },
|
|
children: {
|
|
box: { type: 'rectangle', size: { width: 2, height: 2 }, style: { fill: '#0000ff' } },
|
|
edge: { type: 'line', to: { x: 5, y: 0 } }
|
|
}
|
|
} }) }
|
|
});
|
|
const [box, edge] = engine.planFrame({ width: 100, height: 100 }).layers[0].nodes[0].children;
|
|
assert.equal(box.fill.color, '#0000ff', 'the child redeclares fill');
|
|
assert.equal(box.stroke.color, '#00ff00', 'and keeps the ancestor stroke');
|
|
close(box.strokeWidth, 4);
|
|
// 17.12: an inherited fill is ignored for a stroke-only primitive rather than
|
|
// becoming a validation error.
|
|
assert.equal(edge.fill, null);
|
|
assert.equal(edge.stroke.color, '#00ff00');
|
|
// A declared one is an error.
|
|
assert.ok(codes(exhibit({ scene, systems: { s: graphic({ e: { type: 'line', to: { x: 1, y: 1 }, style: { fill: '#fff' } } }) } })).includes('ERR_UNKNOWN_FIELD'));
|
|
});
|
|
|
|
test('17.12 opacity multiplies down the tree and layer opacity is not applied twice (V10)', () => {
|
|
const engine = build({
|
|
scene: { coordinateSpace: 'viewport' },
|
|
layers: { only: { opacity: 0.5 } },
|
|
systems: { s: graphic({ g: {
|
|
type: 'group', style: { opacity: 0.5 },
|
|
children: { a: { type: 'point', style: { opacity: 0.4 } }, b: { type: 'point' } }
|
|
} }, { layer: 'only' }) }
|
|
});
|
|
const plan = engine.planFrame({ width: 100, height: 100 });
|
|
const [a, b] = plan.layers[0].nodes[0].children;
|
|
close(a.alpha, 0.2, 1e-12);
|
|
close(b.alpha, 0.5, 1e-12);
|
|
// The layer's own opacity is carried on the layer, once.
|
|
close(plan.layers[0].opacity, 0.5);
|
|
assert.ok(plan.layers[0].buffered, 'a non-default layer opacity needs a buffer');
|
|
});
|
|
|
|
test('17.16.9 each authoring limit rejects one past its maximum and accepts the maximum', () => {
|
|
const points = (count) => Array.from({ length: count }, (_, index) => ({ x: index, y: 0 }));
|
|
const stops = (count) => Array.from({ length: count }, (_, index) => ({ offset: index / (count - 1), color: '#ffffff' }));
|
|
const commands = (count) => [{ op: 'move', to: { x: 0, y: 0 } }, ...Array.from({ length: count - 1 }, () => ({ op: 'line', to: { x: 1, y: 1 } }))];
|
|
const filters = (count) => Array.from({ length: count }, () => ({ type: 'invert', amount: 1 }));
|
|
const cases = [
|
|
[{ type: 'polygon', points: points(513) }, { type: 'polygon', points: points(512) }],
|
|
[{ type: 'spline', points: points(257) }, { type: 'spline', points: points(256) }],
|
|
[{ type: 'path', commands: commands(513) }, { type: 'path', commands: commands(512) }],
|
|
[{ type: 'rectangle', size: { width: 1, height: 1 }, style: { fill: { type: 'linear-gradient', from: { x: 0, y: 0 }, to: { x: 1, y: 0 }, stops: stops(17) } } },
|
|
{ type: 'rectangle', size: { width: 1, height: 1 }, style: { fill: { type: 'linear-gradient', from: { x: 0, y: 0 }, to: { x: 1, y: 0 }, stops: stops(16) } } }],
|
|
[{ type: 'point', style: { filters: filters(5) } }, { type: 'point', style: { filters: filters(4) } }]
|
|
];
|
|
for (const [over, at] of cases) {
|
|
assert.ok(codes(exhibit({ scene, systems: { s: graphic({ a: over }) } })).includes('ERR_VISUAL_LIMIT_EXCEEDED'), JSON.stringify(over.type));
|
|
assert.deepEqual(codes(exhibit({ scene, systems: { s: graphic({ a: at }) } })), [], JSON.stringify(at.type));
|
|
}
|
|
});
|
|
|
|
test('17.16.10 the conic fallback keeps its stops and warns once per paint instance', () => {
|
|
const document = exhibit({
|
|
scene: { coordinateSpace: 'viewport' },
|
|
systems: { s: graphic({ disc: {
|
|
type: 'ellipse', radius: 20,
|
|
style: { fill: { type: 'conic-gradient', center: { x: 0, y: 0 }, angle: 0, stops: [{ offset: 0, color: '#ff0000' }, { offset: 1, color: '#0000ff' }] } }
|
|
} }) }
|
|
});
|
|
const engine = new VisualEngine(document, {
|
|
resolution: new ResolutionEngine(document, new SeededRNG(42)),
|
|
rng: new SeededRNG(42),
|
|
capabilities: { conicGradient: false }
|
|
});
|
|
let plan = engine.planFrame({ width: 100, height: 100 });
|
|
const paint = plan.layers[0].nodes[0].fill;
|
|
assert.equal(paint.type, 'linear-gradient');
|
|
assert.ok(paint.approximated);
|
|
assert.deepEqual(paint.stops.map((stop) => stop.color), ['#ff0000', '#0000ff'], 'the same stops, at the same offsets');
|
|
assert.equal(engine.cadence.raised.length, 1);
|
|
for (let frame = 0; frame < 120; frame += 1) plan = engine.planFrame({ width: 100, height: 100 });
|
|
assert.equal(engine.cadence.raised.length, 1, 'once per paint instance, never once per frame');
|
|
assert.equal(engine.cadence.raised[0].code, 'WARN_VISUAL_APPROXIMATION');
|
|
|
|
// A renderer that does have conic gradients emits the real paint and no warning.
|
|
const capable = build({ scene: { coordinateSpace: 'viewport' }, systems: document.visuals.systems });
|
|
assert.equal(capable.planFrame({ width: 100, height: 100 }).layers[0].nodes[0].fill.type, 'conic-gradient');
|
|
assert.equal(capable.cadence.raised.length, 0);
|
|
});
|
|
|
|
test('17.16.11 path legality, arc radii, and bezier point counts', () => {
|
|
const path = (commands) => exhibit({ scene, systems: { s: graphic({ a: { type: 'path', commands } }) } });
|
|
assert.ok(codes(path([{ op: 'line', to: { x: 1, y: 1 } }])).includes('ERR_INVALID_PATH'));
|
|
assert.ok(codes(path([{ op: 'move', to: { x: 0, y: 0 } }, { op: 'close' }, { op: 'close' }])).includes('ERR_INVALID_PATH'));
|
|
// A zero-radius arc fails validation rather than becoming a line.
|
|
assert.ok(codes(path([{ op: 'move', to: { x: 0, y: 0 } }, { op: 'arc', radius: 0, to: { x: 1, y: 1 } }])).includes('ERR_OUT_OF_BOUNDS'));
|
|
assert.ok(codes(exhibit({ scene, systems: { s: graphic({ a: { type: 'spline', mode: 'bezier', points: [{ x: 0, y: 0 }, { x: 1, y: 1 }, { x: 2, y: 2 }] } }) } })).includes('ERR_SCHEMA_VALIDATION'));
|
|
assert.deepEqual(codes(exhibit({ scene, systems: { s: graphic({ a: { type: 'spline', mode: 'bezier', points: [{ x: 0, y: 0 }, { x: 1, y: 1 }, { x: 2, y: 2 }, { x: 3, y: 3 }] } }) } })), []);
|
|
});
|
|
|
|
test('17.16.12 a mask names a child of its own group and that child is not drawn', () => {
|
|
const engine = build({
|
|
scene: { coordinateSpace: 'viewport' },
|
|
systems: { s: graphic({ g: {
|
|
type: 'group', style: { mask: 'cut' },
|
|
children: { shown: { type: 'point' }, cut: { type: 'ellipse', radius: 4 } }
|
|
} }) }
|
|
});
|
|
const group = engine.planFrame({ width: 100, height: 100 }).layers[0].nodes[0];
|
|
assert.equal(group.children.length, 1);
|
|
assert.equal(group.children[0].path.endsWith('shown'), true);
|
|
assert.ok(group.mask && group.mask.path.endsWith('cut'));
|
|
assert.ok(group.needsBuffer, 'a mask requires an offscreen buffer');
|
|
|
|
assert.ok(codes(exhibit({ scene, systems: { s: graphic({ g: { type: 'group', style: { mask: 'missing' }, children: { a: { type: 'point' } } } }) } })).includes('ERR_INVALID_REFERENCE'));
|
|
assert.ok(codes(exhibit({ scene, systems: { s: graphic({ a: { type: 'point', style: { mask: 'x' } } }) } })).includes('ERR_UNKNOWN_FIELD'));
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Trace 13 — once-at-instantiation resolution (17.14, 9.3)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
test('17.16.13 visual fields resolve once and rendering consumes no procedural stream', () => {
|
|
const visuals = {
|
|
scene: { coordinateSpace: 'viewport' },
|
|
systems: { s: graphic({ mark: { type: 'point', position: { x: { random: { min: 0, max: 100 } }, y: 50 } } }) }
|
|
};
|
|
const document = exhibit(visuals);
|
|
let streamRequests = 0;
|
|
const rng = new SeededRNG(42);
|
|
const counting = { stream: (domain, key) => { streamRequests += 1; return rng.stream(domain, key); } };
|
|
const engine = new VisualEngine(document, { resolution: new ResolutionEngine(document, new SeededRNG(42)), rng: counting });
|
|
const afterInstantiation = streamRequests;
|
|
assert.ok(afterInstantiation > 0, 'instantiation samples once');
|
|
|
|
const first = engine.planFrame({ width: 200, height: 200 }).layers[0].nodes[0].center[0];
|
|
for (let frame = 0; frame < 600; frame += 1) {
|
|
const value = engine.planFrame({ width: 200, height: 200 }).layers[0].nodes[0].center[0];
|
|
assert.equal(value, first, 'a resolved position holds across every frame');
|
|
}
|
|
assert.equal(streamRequests, afterInstantiation, 'rendering 600 frames requests no stream');
|
|
|
|
// Two runs of one seed resolve identically.
|
|
const second = new VisualEngine(document, { resolution: new ResolutionEngine(document, new SeededRNG(42)), rng: new SeededRNG(42) });
|
|
assert.equal(second.planFrame({ width: 200, height: 200 }).layers[0].nodes[0].center[0], first);
|
|
// A different seed does not.
|
|
const other = new VisualEngine(document, { resolution: new ResolutionEngine(document, new SeededRNG(7)), rng: new SeededRNG(7) });
|
|
assert.notEqual(other.planFrame({ width: 200, height: 200 }).layers[0].nodes[0].center[0], first);
|
|
});
|
|
|
|
test('17.14 sampling is depth-first in property-document order', () => {
|
|
const resolver = { evaluate: (spec, stream, path) => path };
|
|
assert.deepEqual(sampleTree({ b: { random: {} }, a: { nested: { random: {} } } }, resolver, null, '$'),
|
|
{ b: '$.b', a: { nested: '$.a.nested' } });
|
|
// A path command's `op` is a command token, not a ValueSpec operator.
|
|
assert.deepEqual(sampleTree({ commands: [{ op: 'move', to: { x: 1, y: 2 } }] }, resolver, null, '$'),
|
|
{ commands: [{ op: 'move', to: { x: 1, y: 2 } }] });
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Compositing, buffers, and the Canvas 2D backend (17.12, 19.5)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
test('19.5 buffer refusals are taken farthest first and are diagnosed, not silent (V10)', () => {
|
|
const content = {};
|
|
for (let index = 0; index < 20; index += 1) {
|
|
content[`o${index}`] = { type: 'rectangle', size: { width: 1, height: 1 }, z: index, style: { blur: 2 } };
|
|
}
|
|
const engine = build({ scene: { coordinateSpace: 'viewport' }, systems: { s: graphic(content) } });
|
|
const plan = engine.planFrame({ width: 200, height: 200 });
|
|
assert.equal(plan.buffers.limit, 16);
|
|
assert.equal(plan.buffers.granted, 16);
|
|
assert.equal(plan.buffers.shed, 4);
|
|
const shed = plan.layers[0].nodes.filter((node) => node.shed).map((node) => node.z);
|
|
assert.deepEqual(shed.sort((a, b) => a - b), [16, 17, 18, 19], 'the four farthest lost their buffers');
|
|
for (const node of plan.layers[0].nodes.filter((entry) => entry.shed)) {
|
|
assert.equal(node.blur, 0, 'a refused object draws without its buffered features');
|
|
}
|
|
assert.equal(plan.diagnostics[0].code, 'WARN_VISUAL_APPROXIMATION');
|
|
assert.equal(plan.diagnostics.length, 1, 'one diagnostic per key per second, not one per shed item');
|
|
});
|
|
|
|
test('the Canvas 2D backend issues the compositing order of 17.12', () => {
|
|
const calls = [];
|
|
const record = (name) => (...args) => calls.push([name, ...args]);
|
|
const context = {
|
|
canvas: { width: 200, height: 200 },
|
|
save: record('save'), restore: record('restore'), beginPath: record('beginPath'),
|
|
moveTo: record('moveTo'), lineTo: record('lineTo'), bezierCurveTo: record('bezierCurveTo'),
|
|
closePath: record('closePath'), fill: record('fill'), stroke: record('stroke'),
|
|
arc: record('arc'), ellipse: record('ellipse'), clip: record('clip'), fillRect: record('fillRect'),
|
|
setTransform: record('setTransform'), setLineDash: record('setLineDash'),
|
|
fillText: record('fillText'), strokeText: record('strokeText'), drawImage: record('drawImage'),
|
|
createLinearGradient: () => ({ addColorStop: record('addColorStop') })
|
|
};
|
|
const engine = build({
|
|
scene: { coordinateSpace: 'viewport' },
|
|
systems: { s: graphic({
|
|
box: { type: 'rectangle', size: { width: 10, height: 10 }, style: { fill: '#ffffff', stroke: '#ff0000', strokeWidth: 2 } },
|
|
label: { type: 'text', text: 'HI', size: 12, style: { fill: '#ffffff' } }
|
|
}) }
|
|
});
|
|
const plan = engine.planFrame({ width: 200, height: 200, devicePixelRatio: 2 });
|
|
renderFrame(context, plan);
|
|
const names = calls.map((call) => call[0]);
|
|
assert.ok(names.includes('fillRect'), 'the background is cleared to scene.background');
|
|
assert.ok(names.includes('moveTo') && names.includes('lineTo') && names.includes('fill') && names.includes('stroke'));
|
|
assert.ok(names.includes('fillText'), 'text draws through the backend text engine');
|
|
// The stroke width reaches the backend already in device pixels: 2 * dpr,
|
|
// with no canvas transform to distort it.
|
|
close(plan.layers[0].nodes[0].strokeWidth, 4, 1e-12);
|
|
close(context.lineWidth, 4, 1e-12);
|
|
});
|