Ride: venue-owned camera (directCamera) + sidewalk passerby shot

Camera architecture: a module with directCamera:true drives its own rig
inside update() (analytic f(timeline, seed, framing, venueState)); the
shared rig is ignored for that layer only. ModelLayer prefers own camera,
Show skips shared injection. Ride migrates its full grammar over
(traverse, orbit, car tracking, jumps, clamp); Compositor ride branches
removed. Chamber/stage unchanged on the shared path.

Sidewalk shot: new ride-only 'passerby' plan target (breaths only, never
climax, never twice running) plants a static roadside camera; the module
drives the car +12 to -14 on driveT with the world frozen. Verified
far-pass-away through the Show path; lint + 24 tests green.
This commit is contained in:
Dejvino
2026-09-19 07:34:48 +02:00
parent 4b0f27da9a
commit 822f4b12ef
6 changed files with 306 additions and 79 deletions
+67
View File
@@ -190,6 +190,73 @@ function countMeshes(root) {
// scenes/metadata.json, which Node 20 cannot import without an attribute.
// See tools/venue-check.js --filmstrip for the carryover measurement.
test('ride declares directCamera and drives its own rig deterministically', () => {
const THREE = awaitImportThree();
return THREE.then((T) => {
const rideMod = venues.find((v) => v.name === 'Ride');
assert.ok(rideMod, 'Ride venue not registered');
assert.equal(rideMod.directCamera, true, 'Ride must own its camera rig');
const personality = personalityAt(11);
const identity = generateIdentity(SUMMARY, new Rng(11).fork('identity'), 4);
personality.identity = identity;
const actors = generateActorSet(SUMMARY, new Rng(11).fork('actors'), personality, identity);
const scene = new T.Scene();
const instance = rideMod.build({
scene, seed: 4242, params: {}, actorSpec: actors.monolith,
venueSpec: {
seed: 5, population: { count: 3, spread: 0.6, jitter: 0.2 },
paletteMap: [0, 1, 2, 3],
perSection: [{ light: 0.7, fog: 0.35, popMul: 0.8, reachMul: 0.9 }],
},
THREE: T,
});
const stubCam = () => ({
position: { set(x, y, z) { this.x = x; this.y = y; this.z = z; } },
rotation: {},
look: null,
lookAt(x, y, z) { this.look = [x, y, z]; },
updateMatrixWorld() {},
});
const drive = (time, framing) => {
const cam = stubCam();
rideMod.update({
instance, scene, camera: cam,
timeline: { time, frame: Math.round(time * 60), progress: time / 200 },
features: {}, params: { light: 0.85, speed: 0.7 },
palette: [[1, 0.9, 0.8], [0.5, 0.5, 0.7], [0.3, 0.4, 0.9], [0.9, 0.5, 0.4]],
personality, framing, opacity: 1,
actorSpec: actors.monolith,
venueSpec: instance.venueSpec,
venueState: { light: 0.7, fog: 0.35, popMul: 0.8, reachMul: 0.9 },
THREE: T,
});
const { x, y, z } = cam.position;
return { x, y, z, look: cam.look };
};
const framing = { scale: 1, shift: [0, 0] };
const a = drive(10, framing), b = drive(10, framing);
assert.deepEqual(a, b, 'same inputs must place the camera identically');
const c = drive(40, framing);
const travelled = Math.hypot(c.x - a.x, c.y - a.y, c.z - a.z);
assert.ok(travelled > 0.15, `ride camera barely moves: ${travelled.toFixed(3)} units in 30 s`);
// Stays inside its own box (wider + behind the car, unlike rooms —
// must match the clamp in driveRideCamera).
for (const t of [0, 30, 90, 200, 400]) {
const p = drive(t, framing);
assert.ok(Math.abs(p.x) <= 6.0, `x=${p.x.toFixed(2)} outside the ride box at t=${t}`);
assert.ok(p.z >= -6.0 && p.z <= 6.0, `z=${p.z.toFixed(2)} outside the ride box at t=${t}`);
assert.ok(p.look && p.look.length === 3, 'camera must look somewhere');
}
// A planned mark steers the head: screen vs room framings look apart.
const shot = { pos: [1, 0.5, 4], look: [0, 0.8, -0.8], size: 'close', target: 'screen', dolly: 0.72 };
const roomShot = { pos: [-1, 0.5, 5], look: [0, 0.3, -6], size: 'wide', target: 'room', dolly: 1.3 };
const s1 = drive(60, { scale: 1, shift: [0, 0], venueShot: shot });
const s2 = drive(60, { scale: 1, shift: [0, 0], venueShot: roomShot });
assert.ok(Math.hypot(s1.look[0] - s2.look[0], s1.look[1] - s2.look[1]) > 0.2,
'screen vs room marks must aim the camera apart');
});
});
// Dynamic import indirection so a missing/broken three surfaces as a test
// failure with a name on it rather than a top-level load error for the file.
function awaitImportThree() {