The normative coherent noise of 18.7 (ordered gradients, a 255-sample Fisher-Yates permutation, octave normalization, and curl as the explicit perpendicular of the potential's gradient, with published tolerances), the nine distributions with their normative draw-order table, all seventeen behaviors with their field contracts and the accumulating-versus-fresh split, component expansion independent of group nesting depth, particles, emitters, repeaters, trails, and links. Traces 1 through 19 of 18.10 are automated. The renderer core this builds on is corrected here rather than separately, because 4e is what exercises it: the production render path now passes a real compositing surface factory, so a masked group or a non-opaque layer composites instead of throwing and permanently deactivating the visual subsystem; system-level `behaviors` arrays run `validateBehaviors` at import with field context, so the six classes of invalid document that used to fail at activation now fail where an author can see them; velocity-accumulating behaviors integrate on object and repeater hosts; `morph` guards both sides of the z delta rather than poisoning perspective projection with NaN; `face-motion` initializes its follow state lazily and holds the previous rotation at zero velocity; a `ring` with equal start and end angles draws nothing instead of a radial spoke; a resolved non-integer creation count raises ERR_TYPE_MISMATCH at the boundary instead of being rounded; boolean leaves are type-checked; per-type required fields and the static half of the unbounded-emission rule are enforced at import; and a repeater rejects the emitter-only fields 18.5 says it has none of. A duration is now the authored literal or a non-negative finite number already in milliseconds, resolving the one design question the review triage left open. Runtime, validator, and specification agree on it. A guard test asserts that no two bundled modules declare the same top-level identifier: the bundler concatenates into one scope, so a private helper name collision is a SyntaxError in the artifact while every unit test still passes. Co-Authored-By: Claude Opus 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01ShxxFqFmCUDQnQvFNm4TKy
526 lines
32 KiB
JavaScript
526 lines
32 KiB
JavaScript
// Slice 4e — components, procedural systems, behaviors, fields, trails, links.
|
|
//
|
|
// The automated traces of 18.10. Where a trace names a closed-form result — the
|
|
// integrator, the emission accumulator, a life ramp, a distribution's draw
|
|
// count, a noise sample — the test asserts that number rather than a rendered
|
|
// pixel, which is what makes these executable without a display.
|
|
|
|
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 } from '../src/runtime/visual-engine.js';
|
|
import { ProceduralSystem } from '../src/runtime/visual-systems.js';
|
|
import { FieldSet } from '../src/runtime/visual-fields.js';
|
|
import { placeItem, sampleCount } from '../src/runtime/visual-distributions.js';
|
|
import { PERMUTATION_DRAWS, noiseVector, octaveNoise, permutationTable } from '../src/runtime/visual-noise.js';
|
|
import { behaviorChannels, curveValue, waveform } from '../src/runtime/visual-behaviors.js';
|
|
|
|
const close = (actual, expected, epsilon = 1e-9) => assert.ok(Math.abs(actual - expected) <= epsilon, `${actual} != ${expected}`);
|
|
const TICK = 1 / 60;
|
|
|
|
function exhibit(visuals, extra = {}) {
|
|
return { xzbt: '0.1', meta: { id: 'procedural-study', name: 'Procedural Study' }, runtime: { seed: 42 }, visuals, ...extra };
|
|
}
|
|
const codes = (document) => validateExhibit(document).errors.map((error) => error.code);
|
|
const scene = { coordinateSpace: 'viewport' };
|
|
|
|
function engineFor(document, seed = 42) {
|
|
return new VisualEngine(document, { resolution: new ResolutionEngine(document, new SeededRNG(seed)), rng: new SeededRNG(seed) });
|
|
}
|
|
|
|
/** A bare system, for the motion traces that want a closed-form oracle. */
|
|
function system(spec, { seed = 42, fields = null, scene: box = { x: 0, y: 0, width: 100, height: 100 } } = {}) {
|
|
const document = exhibit({ scene, systems: {} });
|
|
const resolution = new ResolutionEngine(document, new SeededRNG(seed));
|
|
return new ProceduralSystem('s', spec, {
|
|
systemPath: 'visuals.systems.s',
|
|
resolver: resolution.valueResolver,
|
|
rng: new SeededRNG(seed),
|
|
noiseTable: permutationTable(new SeededRNG(seed).stream('visual', 'behavior-noise')),
|
|
scene: box,
|
|
fields,
|
|
instantiateItemNode: () => null
|
|
});
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Traces 1 to 3 — components (18.1)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const panel = {
|
|
parameters: { tint: { type: 'color', default: '#3a6ea5' }, lit: { type: 'boolean', default: false }, scale: { type: 'number', default: 1, min: 0, max: 4 } },
|
|
content: { frame: { type: 'rectangle', size: { width: 24, height: 16 }, style: { fill: { ref: 'inputs.tint' } } } }
|
|
};
|
|
|
|
test('18.10.1 a component instantiates with defaults, with inputs, and nested', () => {
|
|
const document = exhibit({
|
|
scene,
|
|
systems: { s: { type: 'graphic', content: {
|
|
a: { type: 'component', component: 'panel' },
|
|
b: { type: 'component', component: 'panel', inputs: { tint: '#ff0000' } }
|
|
} } }
|
|
}, { components: { visual: { panel } } });
|
|
assert.deepEqual(validateExhibit(document).errors, []);
|
|
const plan = engineFor(document).planFrame({ width: 200, height: 200 });
|
|
const [a, b] = plan.layers[0].nodes;
|
|
assert.equal(a.children[0].fill.color, '#3a6ea5', 'the declared default');
|
|
assert.equal(b.children[0].fill.color, '#ff0000', 'the supplied input');
|
|
|
|
// An undeclared input key, and inputs.* used outside a component.
|
|
const undeclared = exhibit({ scene, systems: { s: { type: 'graphic', content: { a: { type: 'component', component: 'panel', inputs: { hue: 1 } } } } } }, { components: { visual: { panel } } });
|
|
assert.throws(() => engineFor(undeclared).planFrame({ width: 10, height: 10 }), (error) => error.code === 'ERR_INVALID_REFERENCE');
|
|
const outside = exhibit({ scene, systems: { s: { type: 'graphic', content: { a: { type: 'rectangle', size: { width: { ref: 'inputs.tint' }, height: 1 } } } } } });
|
|
assert.throws(() => engineFor(outside), (error) => error.code === 'ERR_INVALID_REFERENCE');
|
|
// A supplied value of the wrong type, and one outside a declared range.
|
|
const wrongType = exhibit({ scene, systems: { s: { type: 'graphic', content: { a: { type: 'component', component: 'panel', inputs: { scale: 'big' } } } } } }, { components: { visual: { panel } } });
|
|
assert.throws(() => engineFor(wrongType), (error) => error.code === 'ERR_TYPE_MISMATCH');
|
|
const outOfRange = exhibit({ scene, systems: { s: { type: 'graphic', content: { a: { type: 'component', component: 'panel', inputs: { scale: 9 } } } } } }, { components: { visual: { panel } } });
|
|
assert.throws(() => engineFor(outOfRange), (error) => error.code === 'ERR_OUT_OF_BOUNDS');
|
|
});
|
|
|
|
test('18.10.2 component recursion and nesting are bounded at eight levels', () => {
|
|
const selfReferential = { visual: { loop: { content: { inner: { type: 'component', component: 'loop' } } } } };
|
|
const document = exhibit({ scene, systems: { s: { type: 'graphic', content: { a: { type: 'component', component: 'loop' } } } } }, { components: selfReferential });
|
|
assert.throws(() => engineFor(document), (error) => error.code === 'ERR_COMPONENT_RECURSION');
|
|
|
|
const chain = (depth) => {
|
|
const components = {};
|
|
for (let level = 0; level < depth; level += 1) {
|
|
components[`c${level}`] = level === depth - 1
|
|
? { content: { leaf: { type: 'point' } } }
|
|
: { content: { next: { type: 'component', component: `c${level + 1}` } } };
|
|
}
|
|
return components;
|
|
};
|
|
const deep = exhibit({ scene, systems: { s: { type: 'graphic', content: { a: { type: 'component', component: 'c0' } } } } }, { components: { visual: chain(10) } });
|
|
assert.throws(() => engineFor(deep), (error) => error.code === 'ERR_COMPONENT_RECURSION');
|
|
const legal = exhibit({ scene, systems: { s: { type: 'graphic', content: { a: { type: 'component', component: 'c0' } } } } }, { components: { visual: chain(6) } });
|
|
assert.doesNotThrow(() => engineFor(legal).planFrame({ width: 50, height: 50 }));
|
|
});
|
|
|
|
test('18.10.3 two instances sample independently from their expansion paths', () => {
|
|
const jittered = { parameters: {}, content: { dot: { type: 'point', position: { x: { random: { min: 0, max: 100 } }, y: 0 } } } };
|
|
const build = (keys) => {
|
|
const content = {};
|
|
for (const key of keys) content[key] = { type: 'component', component: 'jitter' };
|
|
return engineFor(exhibit({ scene, systems: { s: { type: 'graphic', content } } }, { components: { visual: { jitter: jittered } } }))
|
|
.planFrame({ width: 200, height: 200 }).layers[0].nodes;
|
|
};
|
|
const [first, second] = build(['a', 'b']);
|
|
assert.notEqual(first.children[0].center[0], second.children[0].center[0], 'two instances sample independently');
|
|
// Adding an unrelated sibling does not perturb either instance.
|
|
const withExtra = build(['a', 'b', 'zzz']);
|
|
close(withExtra[0].children[0].center[0], first.children[0].center[0]);
|
|
close(withExtra[1].children[0].center[0], second.children[0].center[0]);
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Traces 4 to 7 — particles (18.2)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
test('18.10.4 particle fields resolve once per particle and hold for its life', () => {
|
|
const document = exhibit({ scene, systems: { motes: {
|
|
type: 'particles', capacity: 8, count: 4, lifetime: '10s',
|
|
velocity: { x: { random: { min: -5, max: 5 } }, y: 0 },
|
|
render: { type: 'point' }
|
|
} } });
|
|
const engine = engineFor(document);
|
|
const velocities = engine.systems[0].procedural.items.map((item) => item.vx);
|
|
assert.equal(new Set(velocities).size, 4, 'each particle samples its own velocity');
|
|
for (let tick = 0; tick < 120; tick += 1) engine.advance(1000 / 60);
|
|
assert.deepEqual(engine.systems[0].procedural.items.map((item) => item.vx), velocities, 'and holds it for its whole life');
|
|
// Two runs of one seed create identical particles in identical order.
|
|
assert.deepEqual(engineFor(document).systems[0].procedural.items.map((item) => item.vx), velocities);
|
|
assert.notDeepEqual(engineFor(document, 7).systems[0].procedural.items.map((item) => item.vx), velocities);
|
|
});
|
|
|
|
test('18.10.5 the integrator matches its closed form and is order-sensitive', () => {
|
|
// v <- v + a*dt, then p <- p + v*dt, so after n ticks p = a*dt^2*n(n+1)/2.
|
|
const acceleration = 10;
|
|
const ticks = 60;
|
|
const particles = system({ type: 'particles', capacity: 4, count: 1, render: { type: 'point' }, acceleration: { x: acceleration, y: 0 } });
|
|
for (let tick = 0; tick < ticks; tick += 1) particles.advance(TICK);
|
|
const item = particles.items[0];
|
|
close(item.vx, acceleration * ticks * TICK, 1e-9);
|
|
close(item.base.x, acceleration * TICK * TICK * ((ticks * (ticks + 1)) / 2), 1e-9);
|
|
// Integrating position before velocity would leave it one step behind.
|
|
assert.notEqual(item.base.x, acceleration * TICK * TICK * (((ticks - 1) * ticks) / 2));
|
|
});
|
|
|
|
test('18.10.6 drag is dt-correct: one tick of 1s equals ten of 0.1s', () => {
|
|
const make = () => system({ type: 'particles', capacity: 4, count: 1, render: { type: 'point' }, velocity: { x: 100, y: 0 }, drag: 0.5 });
|
|
const coarse = make();
|
|
coarse.advance(1);
|
|
const fine = make();
|
|
for (let tick = 0; tick < 10; tick += 1) fine.advance(0.1);
|
|
close(coarse.items[0].vx, fine.items[0].vx, 1e-9);
|
|
close(coarse.items[0].vx, 100 * 0.5, 1e-9);
|
|
});
|
|
|
|
test('18.10.7 a life ramp interpolates under each curve, and needs a lifetime', () => {
|
|
const ramps = system({
|
|
type: 'particles', capacity: 4, count: 1, lifetime: '10s', render: { type: 'point' },
|
|
size: { from: 0, to: 1, curve: 'smooth' }
|
|
});
|
|
const item = ramps.items[0];
|
|
close(ramps.rampValue(item.ramps.size, 0), 0);
|
|
close(ramps.rampValue(item.ramps.size, 0.5), 0.5);
|
|
close(ramps.rampValue(item.ramps.size, 1), 1);
|
|
for (const [curve, midpoint] of [['step', 0], ['linear', 0.5], ['exponential', 0.25], ['smooth', 0.5]]) {
|
|
close(curveValue(curve, 0.5), midpoint, 1e-12);
|
|
close(curveValue(curve, 0), 0);
|
|
close(curveValue(curve, 1), 1);
|
|
}
|
|
assert.throws(() => system({ type: 'particles', capacity: 4, count: 1, render: { type: 'point' }, size: { from: 0, to: 1 } }),
|
|
(error) => error instanceof RuntimeFault && error.code === 'ERR_SCHEMA_VALIDATION');
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Traces 8 and 9 — emission (18.2, 18.4)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
test('18.10.8 rate emission is exactly floor(rate * t) under three tick lengths', () => {
|
|
// Steps that are exact in binary, so the oracle is floor(rate * t) and not a
|
|
// statement about floating-point summation.
|
|
for (const [step, ticks] of [[1 / 64, 64], [1 / 32, 32], [1 / 16, 16]]) {
|
|
const emitter = system({ type: 'emitter', capacity: 512, rate: 6, lifetime: '60s', emit: { type: 'point' } });
|
|
for (let tick = 0; tick < ticks; tick += 1) emitter.advance(step);
|
|
assert.equal(emitter.created, 6, `at a step of ${step}`);
|
|
// Half a second in, the count is exactly floor(rate * t) too.
|
|
const half = system({ type: 'emitter', capacity: 512, rate: 6, lifetime: '60s', emit: { type: 'point' } });
|
|
for (let tick = 0; tick < ticks / 2; tick += 1) half.advance(step);
|
|
assert.equal(half.created, 3);
|
|
}
|
|
// A burst emits its whole count on the first tick at or after its `at`.
|
|
const bursting = system({ type: 'emitter', capacity: 512, burst: [{ at: '500ms', count: 24 }], lifetime: '60s', emit: { type: 'point' } });
|
|
bursting.advance(0.4);
|
|
assert.equal(bursting.created, 0, 'not before its offset');
|
|
bursting.advance(0.2);
|
|
assert.equal(bursting.created, 24, 'the whole count on the first tick at or after it');
|
|
bursting.advance(1);
|
|
assert.equal(bursting.created, 24, 'and never again');
|
|
});
|
|
|
|
test('18.10.9 unbounded emission is rejected, count alone passes, capacity evicts oldest', () => {
|
|
assert.throws(() => system({ type: 'emitter', capacity: 8, rate: 4, emit: { type: 'point' } }),
|
|
(error) => error.code === 'ERR_UNBOUNDED_EMISSION');
|
|
assert.doesNotThrow(() => system({ type: 'particles', capacity: 8, count: 8, render: { type: 'point' } }));
|
|
const full = system({ type: 'particles', capacity: 4, rate: 60, lifetime: '60s', render: { type: 'point' } });
|
|
for (let tick = 0; tick < 60; tick += 1) full.advance(TICK);
|
|
assert.equal(full.items.length, 4, 'the live count stays at capacity');
|
|
assert.equal(full.items[0].ordinal, full.created - 4, 'and the oldest were evicted');
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Trace 10 — distributions (18.3)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
test('18.10.10 each distribution places at its documented position and draw count', () => {
|
|
const grid = { type: 'grid', origin: { x: 0, y: 0 }, columns: 12, rows: 8, spacing: { x: 10, y: 10 } };
|
|
assert.deepEqual([0, 1, 12, 13].map((index) => placeItem(grid, { index, count: 96 }).x), [0, 10, 0, 10]);
|
|
assert.deepEqual([0, 1, 12, 13].map((index) => placeItem(grid, { index, count: 96 }).y), [0, 0, 10, 10]);
|
|
// A count larger than rows * columns wraps rather than failing.
|
|
assert.equal(placeItem(grid, { index: 96, count: 200 }).x, 0);
|
|
// n == 1 places at fraction 0, matching repeat.fraction.
|
|
assert.equal(placeItem({ type: 'line', from: { x: 0, y: 0 }, to: { x: 10, y: 0 }, mode: 'even' }, { index: 0, count: 1 }).x, 0);
|
|
// The three depth curves are monotonic and bias toward near.
|
|
const depth = { type: 'depth', near: 0, far: 100 };
|
|
for (const curve of ['uniform', 'linear', 'exponential']) {
|
|
const stream = new SeededRNG(42).stream('visual', `d-${curve}`);
|
|
const value = placeItem({ ...depth, curve }, { stream }).z;
|
|
assert.ok(value >= 0 && value <= 100, curve);
|
|
}
|
|
|
|
// The draw-count table of 18.3, asserted by counting the draws themselves.
|
|
const drawn = (distribution) => {
|
|
const source = new SeededRNG(42).stream('visual', 'draws');
|
|
let draws = 0;
|
|
const counting = { nextFloat: () => { draws += 1; return source.nextFloat(); }, nextInteger: (a, b) => source.nextInteger(a, b) };
|
|
placeItem(distribution, { index: 0, count: 4, stream: counting });
|
|
return draws;
|
|
};
|
|
assert.equal(sampleCount({ type: 'point' }), 0);
|
|
assert.equal(sampleCount({ type: 'ellipse' }), 2);
|
|
assert.equal(sampleCount({ type: 'ellipse', fill: 'perimeter' }), 1, 'one distance sample, not two');
|
|
assert.equal(sampleCount({ type: 'rectangle', fill: 'perimeter' }), 1);
|
|
assert.equal(sampleCount({ type: 'grid' }), 0);
|
|
assert.equal(sampleCount({ type: 'grid', jitter: { x: 1, y: 1 } }), 2);
|
|
assert.equal(sampleCount({ type: 'ring', mode: 'even' }), 0);
|
|
assert.equal(sampleCount({ type: 'ring', mode: 'even', depth: { near: 0, far: 1 } }), 1, 'the depth sub-block draws last');
|
|
for (const distribution of [
|
|
{ type: 'point' },
|
|
{ type: 'ellipse', center: { x: 0, y: 0 }, radius: 4 },
|
|
{ type: 'ellipse', center: { x: 0, y: 0 }, radius: 4, fill: 'perimeter' },
|
|
{ type: 'rectangle', center: { x: 0, y: 0 }, size: { width: 4, height: 2 }, fill: 'perimeter' },
|
|
{ type: 'rectangle', center: { x: 0, y: 0 }, size: { width: 4, height: 2 } },
|
|
{ type: 'uniform', min: { x: 0, y: 0 }, max: { x: 1, y: 1 } },
|
|
{ type: 'uniform', min: { x: 0, y: 0, z: 0 }, max: { x: 1, y: 1, z: 1 } },
|
|
{ type: 'ring', radius: 4, innerRadius: 1 },
|
|
{ type: 'ring', radius: 4, innerRadius: 1, mode: 'even' },
|
|
{ type: 'grid', columns: 2, rows: 2, spacing: { x: 1, y: 1 } },
|
|
{ type: 'grid', columns: 2, rows: 2, spacing: { x: 1, y: 1 }, jitter: { x: 1, y: 1 } },
|
|
{ type: 'depth', near: 0, far: 10 },
|
|
{ type: 'ellipse', center: { x: 0, y: 0 }, radius: 4, depth: { near: 0, far: 10 } }
|
|
]) {
|
|
assert.equal(drawn(distribution), sampleCount(distribution), JSON.stringify(distribution));
|
|
}
|
|
|
|
// An index-driven placement on a continuous rate emission has no count.
|
|
assert.throws(() => system({ type: 'particles', capacity: 8, rate: 4, lifetime: '2s', render: { type: 'point' }, distribution: { type: 'grid', columns: 2, rows: 2 } }),
|
|
(error) => error.code === 'ERR_INVALID_DISTRIBUTION');
|
|
assert.throws(() => placeItem({ type: 'spiral' }, {}), (error) => error.code === 'ERR_INVALID_DISTRIBUTION_TYPE');
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Trace 11 — repeaters (18.5)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
test('18.10.11 a repeater resolves repeat.index, repeat.count, and repeat.fraction', () => {
|
|
const document = exhibit({ scene, systems: { wall: {
|
|
type: 'repeater', count: 4,
|
|
repeat: { type: 'rectangle', size: { width: { ref: 'repeat.index' }, height: { ref: 'repeat.fraction' } }, position: { x: 0, y: 0 } },
|
|
distribution: { type: 'line', from: { x: 0, y: 0 }, to: { x: 30, y: 0 }, mode: 'even' }
|
|
} } });
|
|
const engine = engineFor(document);
|
|
const items = engine.systems[0].procedural.items;
|
|
assert.equal(items.length, 4);
|
|
assert.deepEqual(items.map((item) => item.node.raw.size.width), [0, 1, 2, 3]);
|
|
assert.deepEqual(items.map((item) => item.node.raw.size.height), [0, 1 / 3, 2 / 3, 1]);
|
|
assert.deepEqual(items.map((item) => Math.round(item.base.x)), [0, 10, 20, 30], 'even placement consumes no samples');
|
|
|
|
// repeat.* outside a repeater does not resolve.
|
|
const outside = exhibit({ scene, systems: { s: { type: 'graphic', content: { a: { type: 'rectangle', size: { width: { ref: 'repeat.index' }, height: 1 } } } } } });
|
|
assert.throws(() => engineFor(outside), (error) => error.code === 'ERR_INVALID_REFERENCE');
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Traces 12 to 14 — behaviors (18.6)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
test('18.10.12 every behavior advances a known object, and the rejections differ', () => {
|
|
const target = { type: 'polyline', points: [{ x: 0, y: 0 }, { x: 1, y: 1 }] };
|
|
const behaviors = [
|
|
{ type: 'drift', velocity: { x: 10, y: 0 } },
|
|
{ type: 'rotate', speed: 90 },
|
|
{ type: 'oscillate', property: 'position.y', amplitude: 5, frequency: 1 },
|
|
{ type: 'orbit', center: { x: 0, y: 0 }, radius: 10, speed: 90 },
|
|
{ type: 'wander', strength: 5, rate: 1 },
|
|
{ type: 'follow-path', path: [{ op: 'move', to: { x: 0, y: 0 } }, { op: 'line', to: { x: 10, y: 0 } }], speed: 5 },
|
|
{ type: 'point-wander', amplitude: { x: 1, y: 1 }, rate: 1 },
|
|
{ type: 'pulse', property: 'style.opacity', amplitude: 0.5, frequency: 2 },
|
|
{ type: 'twinkle', min: 0.2, max: 1, rate: 3 },
|
|
{ type: 'noise-displace', amplitude: { x: 2, y: 2 }, scale: 50, speed: 0.1 },
|
|
{ type: 'face-motion' },
|
|
{ type: 'wrap' },
|
|
{ type: 'bounce', restitution: 0.5 },
|
|
{ type: 'attract', target: { x: 50, y: 50 }, strength: 20 },
|
|
{ type: 'repel', target: { x: 50, y: 50 }, strength: 20 },
|
|
{ type: 'field-follow', field: 'wind', strength: 1 },
|
|
{ type: 'morph', to: 'other', duration: '1s' }
|
|
];
|
|
const fields = new FieldSet({ wind: { type: 'directional', direction: 0, strength: 5 } }, { rng: new SeededRNG(42) });
|
|
for (const behavior of behaviors) {
|
|
const owner = system({ type: 'particles', capacity: 4, count: 1, lifetime: '10s', render: target, behaviors: [behavior] }, { fields });
|
|
const item = owner.items[0];
|
|
item.points = [{ x: 0, y: 0, z: 0 }, { x: 1, y: 1, z: 0 }];
|
|
owner.advance(TICK, {
|
|
resolveTarget: () => ({ x: 50, y: 50 }),
|
|
resolveMorphTarget: () => [{ x: 5, y: 5, z: 0 }, { x: 6, y: 6, z: 0 }]
|
|
});
|
|
const place = owner.itemPosition(item);
|
|
assert.ok(Number.isFinite(place.x) && Number.isFinite(place.y) && Number.isFinite(place.rotation), behavior.type);
|
|
}
|
|
|
|
// Documented single-behavior oracles.
|
|
const drifting = system({ type: 'particles', capacity: 4, count: 1, render: { type: 'point' }, behaviors: [{ type: 'drift', velocity: { x: 60, y: 0 } }] });
|
|
for (let tick = 0; tick < 60; tick += 1) drifting.advance(TICK);
|
|
close(drifting.itemPosition(drifting.items[0]).x, 60, 1e-9);
|
|
|
|
const spinning = system({ type: 'particles', capacity: 4, count: 1, render: { type: 'point' }, behaviors: [{ type: 'rotate', speed: 90 }] });
|
|
for (let tick = 0; tick < 60; tick += 1) spinning.advance(TICK);
|
|
close(spinning.itemPosition(spinning.items[0]).rotation, 90, 1e-9);
|
|
|
|
// An orbit is recomputed from t, so it traces its circle rather than spiralling.
|
|
const orbiting = system({ type: 'particles', capacity: 4, count: 1, render: { type: 'point' }, behaviors: [{ type: 'orbit', center: { x: 0, y: 0 }, radius: 10, speed: 360 }] });
|
|
for (let tick = 0; tick < 60; tick += 1) orbiting.advance(TICK);
|
|
const orbited = orbiting.itemPosition(orbiting.items[0]);
|
|
close(Math.hypot(orbited.x, orbited.y), 10, 1e-9);
|
|
|
|
// Rejections.
|
|
const owner = (object, extra = {}) => exhibit({ scene, systems: { s: { type: 'graphic', content: { a: { ...object, ...extra } } } } });
|
|
assert.ok(codes(owner({ type: 'point' }, { behaviors: [{ type: 'levitate' }] })).includes('ERR_INVALID_BEHAVIOR_TYPE'));
|
|
assert.ok(codes(owner({ type: 'point' }, { behaviors: [{ type: 'oscillate', property: 'style.hue', amplitude: 1 }] })).includes('ERR_INVALID_BEHAVIOR_TARGET'));
|
|
assert.ok(codes(owner({ type: 'ellipse', radius: 2 }, { behaviors: [{ type: 'oscillate', property: 'size.width', amplitude: 1 }] })).includes('ERR_INVALID_BEHAVIOR_TARGET'));
|
|
assert.ok(codes(owner({ type: 'rectangle', size: { width: 1, height: 1 } }, { behaviors: [{ type: 'point-wander', amplitude: { x: 1, y: 1 } }] })).includes('ERR_INVALID_BEHAVIOR_TARGET'));
|
|
const nine = Array.from({ length: 9 }, () => ({ type: 'rotate', speed: 1 }));
|
|
assert.ok(codes(owner({ type: 'point' }, { behaviors: nine })).includes('ERR_VISUAL_LIMIT_EXCEEDED'));
|
|
assert.ok(codes(owner({ type: 'point' }, { behaviors: [{ type: 'field-follow', field: 'nope' }] })).includes('ERR_INVALID_REFERENCE'));
|
|
assert.ok(codes(owner({ type: 'point' }, { behaviors: [{ type: 'follow-path', path: [], speed: 1, duration: '1s' }] })).includes('ERR_SCHEMA_VALIDATION'));
|
|
});
|
|
|
|
test('18.10.13 behavior composition follows array order and the documented rule', () => {
|
|
const two = system({
|
|
type: 'particles', capacity: 4, count: 1, render: { type: 'point' },
|
|
behaviors: [{ type: 'drift', velocity: { x: 60, y: 0 } }, { type: 'drift', velocity: { x: 30, y: 0 } }]
|
|
});
|
|
for (let tick = 0; tick < 60; tick += 1) two.advance(TICK);
|
|
// Position accumulates: 60 + 30 over one logical second.
|
|
close(two.itemPosition(two.items[0]).x, 90, 1e-9);
|
|
|
|
// Scale multiplies rather than accumulating.
|
|
const scaled = system({
|
|
type: 'particles', capacity: 4, count: 1, render: { type: 'point' },
|
|
behaviors: [
|
|
{ type: 'oscillate', property: 'transform.scale.x', amplitude: 0, center: 2, frequency: 0 },
|
|
{ type: 'oscillate', property: 'transform.scale.x', amplitude: 0, center: 3, frequency: 0 }
|
|
]
|
|
});
|
|
scaled.advance(TICK);
|
|
close(scaled.items[0].scale.x, 6, 1e-12);
|
|
assert.deepEqual(behaviorChannels({ type: 'attract' }), ['velocity.x', 'velocity.y', 'velocity.z'],
|
|
'a force behavior writes velocity, which no automation track can address');
|
|
});
|
|
|
|
test('18.10.14 morph is restricted to point-list geometry and rejects mismatches', () => {
|
|
const pair = (a, b) => exhibit({ scene, systems: { s: { type: 'graphic', content: {
|
|
source: { ...a, behaviors: [{ type: 'morph', to: 'target', duration: '1s' }] },
|
|
target: b
|
|
} } } });
|
|
const line3 = { type: 'polyline', points: [{ x: 0, y: 0 }, { x: 1, y: 0 }, { x: 2, y: 0 }] };
|
|
assert.deepEqual(codes(pair(line3, { type: 'polyline', points: [{ x: 0, y: 1 }, { x: 1, y: 1 }, { x: 2, y: 1 }] })), []);
|
|
assert.ok(codes(pair(line3, { type: 'polyline', points: [{ x: 0, y: 1 }, { x: 1, y: 1 }] })).includes('ERR_MORPH_INCOMPATIBLE'));
|
|
assert.ok(codes(pair(line3, { type: 'rectangle', size: { width: 1, height: 1 } })).includes('ERR_MORPH_INCOMPATIBLE'));
|
|
assert.ok(codes(pair({ type: 'spline', mode: 'linear', points: [{ x: 0, y: 0 }, { x: 1, y: 0 }] }, { type: 'spline', mode: 'catmull-rom', points: [{ x: 0, y: 1 }, { x: 1, y: 1 }] })).includes('ERR_MORPH_INCOMPATIBLE'));
|
|
const missing = exhibit({ scene, systems: { s: { type: 'graphic', content: { source: { ...line3, behaviors: [{ type: 'morph', to: 'nowhere', duration: '1s' }] } } } } });
|
|
assert.ok(codes(missing).includes('ERR_INVALID_REFERENCE'));
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Traces 15 and 16 — coherent noise (18.7)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
test('18.10.15 the noise function reproduces its oracles and consumes 255 samples', () => {
|
|
const before = new SeededRNG(42).stream('visual', 'current');
|
|
const counting = new SeededRNG(42).stream('visual', 'current');
|
|
const table = permutationTable(counting);
|
|
// The shuffle draws one sample per swap, not one per entry.
|
|
for (let draw = 0; draw < PERMUTATION_DRAWS; draw += 1) before.nextFloat();
|
|
close(before.nextFloat(), counting.nextFloat(), 0);
|
|
assert.equal(PERMUTATION_DRAWS, 255);
|
|
|
|
// A gradient noise value is exactly zero on the lattice.
|
|
close(octaveNoise(table, 0, 0, 0, 1, 0.5), 0, 0);
|
|
close(octaveNoise(table, 3, -2, 5, 1, 0.5), 0, 0);
|
|
// Two runs of one seed agree exactly, at lattice and non-lattice positions.
|
|
const again = permutationTable(new SeededRNG(42).stream('visual', 'current'));
|
|
for (const [x, y, z] of [[0.5, 0.25, 0], [13.37, -7.5, 2.25], [0, 0, 0]]) {
|
|
close(octaveNoise(table, x, y, z, 3, 0.5), octaveNoise(again, x, y, z, 3, 0.5), 0);
|
|
}
|
|
// A different seed gives a different table.
|
|
const other = permutationTable(new SeededRNG(7).stream('visual', 'current'));
|
|
assert.notEqual(octaveNoise(table, 0.5, 0.25, 0, 1, 0.5), octaveNoise(other, 0.5, 0.25, 0, 1, 0.5));
|
|
// persistence: 0 stays defined, contributing one octave.
|
|
close(octaveNoise(table, 0.5, 0.25, 0, 4, 0), octaveNoise(table, 0.5, 0.25, 0, 1, 0), 0);
|
|
|
|
const field = (extra) => exhibit({ scene, fields: { n: { type: 'noise', scale: 100, ...extra } } });
|
|
assert.ok(codes(field({ octaves: 5 })).includes('ERR_OUT_OF_BOUNDS'));
|
|
assert.ok(codes(field({ persistence: 1.5 })).includes('ERR_OUT_OF_BOUNDS'));
|
|
assert.ok(codes(exhibit({ scene, fields: { n: { type: 'noise', scale: 0 } } })).includes('ERR_OUT_OF_BOUNDS'));
|
|
assert.ok(codes(exhibit({ scene, fields: { n: { type: 'swirl' } } })).includes('ERR_INVALID_FIELD_TYPE'));
|
|
const nine = Object.fromEntries(Array.from({ length: 9 }, (_, index) => [`f${index}`, { type: 'directional', direction: 0, strength: 1 }]));
|
|
assert.ok(codes(exhibit({ scene, fields: nine })).includes('ERR_VISUAL_LIMIT_EXCEEDED'));
|
|
});
|
|
|
|
test('18.10.16 curl is divergence-free within tolerance at non-lattice positions', () => {
|
|
const table = permutationTable(new SeededRNG(42).stream('visual', 'current'));
|
|
const amplitude = 1;
|
|
const step = 1e-3;
|
|
let worst = 0;
|
|
for (let i = 0; i < 12; i += 1) {
|
|
for (let j = 0; j < 12; j += 1) {
|
|
const x = i * 0.37 + 0.13;
|
|
const y = j * 0.41 + 0.07;
|
|
const dx = noiseVector(table, x + step, y, 0, { mode: 'curl', amplitude })[0] - noiseVector(table, x - step, y, 0, { mode: 'curl', amplitude })[0];
|
|
const dy = noiseVector(table, x, y + step, 0, { mode: 'curl', amplitude })[1] - noiseVector(table, x, y - step, 0, { mode: 'curl', amplitude })[1];
|
|
worst = Math.max(worst, Math.abs((dx + dy) / (2 * step)));
|
|
}
|
|
}
|
|
assert.ok(worst < 1e-4, `divergence ${worst} exceeds the documented tolerance`);
|
|
// `value` mode points along its declared direction.
|
|
const [vx, vy] = noiseVector(table, 0.3, 0.7, 0, { mode: 'value', direction: 90, amplitude: 1 });
|
|
assert.ok(Math.abs(vx) < 1e-12 && Math.abs(vy) > 0, 'the scalar rides the declared direction');
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Traces 17 to 19 — trails, links, and the target surface (18.8, 8.1)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
test('18.10.17 trail history is sampled on the logical clock and is bounded', () => {
|
|
const build = () => system({
|
|
type: 'particles', capacity: 4, count: 1, lifetime: '60s', render: { type: 'point' },
|
|
velocity: { x: 60, y: 0 }, trail: { length: 8 }
|
|
});
|
|
const fast = build();
|
|
for (let tick = 0; tick < 30; tick += 1) fast.advance(TICK);
|
|
const geometry = fast.items[0].trail.map((sample) => Math.round(sample.x * 1e6) / 1e6);
|
|
assert.equal(geometry.length, 8, 'the history is capped at its declared length');
|
|
// The same logical span gives the same geometry however many frames drew it.
|
|
const slow = build();
|
|
for (let tick = 0; tick < 30; tick += 1) slow.advance(TICK);
|
|
assert.deepEqual(slow.items[0].trail.map((sample) => Math.round(sample.x * 1e6) / 1e6), geometry);
|
|
|
|
assert.throws(() => {
|
|
const over = system({ type: 'particles', capacity: 4, count: 1, lifetime: '60s', render: { type: 'point' }, trail: { length: 129 } });
|
|
over.advance(TICK);
|
|
}, (error) => error.code === 'ERR_VISUAL_LIMIT_EXCEEDED');
|
|
|
|
// A removed item's history goes with it.
|
|
const expiring = system({ type: 'particles', capacity: 4, count: 1, lifetime: '100ms', render: { type: 'point' }, trail: { length: 8 } });
|
|
for (let tick = 0; tick < 12; tick += 1) expiring.advance(TICK);
|
|
assert.equal(expiring.items.length, 0);
|
|
});
|
|
|
|
test('18.10.18 link enumeration is deterministic, de-duplicated, and bounded', () => {
|
|
const linked = system({
|
|
type: 'repeater', count: 4,
|
|
repeat: { type: 'point' },
|
|
distribution: { type: 'line', from: { x: 0, y: 0 }, to: { x: 30, y: 0 }, mode: 'even' },
|
|
links: { rule: 'distance', maxDistance: 15 }
|
|
});
|
|
const pairs = linked.linkPairs();
|
|
// Items sit at 0, 10, 20, 30: adjacent pairs only, each once.
|
|
assert.equal(pairs.length, 3);
|
|
assert.deepEqual(pairs.map((pair) => Math.round(pair.distance)), [10, 10, 10]);
|
|
assert.deepEqual(linked.linkPairs().map((pair) => Math.round(pair.from.x)), pairs.map((pair) => Math.round(pair.from.x)));
|
|
|
|
const indexed = system({ type: 'repeater', count: 4, repeat: { type: 'point' }, links: { rule: 'index', stride: 1, closed: true } });
|
|
assert.equal(indexed.linkPairs().length, 4, 'three strides plus the closing link');
|
|
|
|
const capped = system({ type: 'repeater', count: 4, repeat: { type: 'point' }, distribution: { type: 'line', from: { x: 0, y: 0 }, to: { x: 3, y: 0 }, mode: 'even' }, links: { rule: 'distance', maxDistance: 100, maxLinks: 2 } });
|
|
assert.equal(capped.linkPairs().length, 2, 'the tail of the ascending order is dropped');
|
|
|
|
assert.ok(codes(exhibit({ scene, systems: { e: { type: 'emitter', capacity: 8, rate: 1, lifetime: '1s', emit: { type: 'point' }, links: { rule: 'index' } } } })).includes('ERR_UNKNOWN_FIELD'));
|
|
assert.throws(() => system({ type: 'particles', capacity: 400, count: 1, render: { type: 'point' }, links: { rule: 'distance', maxDistance: 10 } }),
|
|
(error) => error.code === 'ERR_VISUAL_LIMIT_EXCEEDED');
|
|
assert.throws(() => system({ type: 'particles', capacity: 8, count: 1, render: { type: 'point' }, links: { rule: 'distance', fadeWithDistance: true } }),
|
|
(error) => error.code === 'ERR_SCHEMA_VALIDATION');
|
|
});
|
|
|
|
test('18.10.19 nothing introduced by this section becomes an external target', () => {
|
|
const document = exhibit({
|
|
scene,
|
|
fields: { wind: { type: 'directional', direction: 0, strength: 1 } },
|
|
systems: { motes: { type: 'particles', capacity: 8, count: 1, render: { type: 'point' }, behaviors: [{ type: 'rotate', speed: 1 }] } }
|
|
}, { state: { energy: { type: 'number', initial: 0.5 } } });
|
|
for (const target of ['visuals.fields.wind.strength', 'visuals.systems.motes.rate', 'visuals.systems.motes.behaviors[0].speed', 'visuals.systems.motes.capacity']) {
|
|
const bound = { ...document, bindings: [{ source: 'state.energy', target }] };
|
|
assert.ok(codes(bound).includes('ERR_UNSUPPORTED_TARGET'), target);
|
|
}
|
|
// The one system-level property that is a target stays one.
|
|
const visible = { ...document, bindings: [{ source: 'state.energy', target: 'visuals.systems.motes.visible' }] };
|
|
assert.ok(!codes(visible).includes('ERR_UNSUPPORTED_TARGET'));
|
|
});
|