Sprint 13 gate 2.5: two camera fixes — spawn framing, aftermath cloth-swallow

SPAWN FRAMING. The QA pass: "a pole dead-centre through the player on every
single first impression." Measured, not eyeballed — player spawns at (0,6)
looking up the yard, and post p3 stands at (0,7), one metre behind, dead on the
view line. spawnYawFor() sweeps outward from the ideal frame (camera opposite
the bed, so the player stands in front of what they protect) and takes the
first yaw that clears every obstacle. Per YARD, off site data: site_02 spawns
elsewhere with its posts elsewhere, so a hand-tuned constant would frame the
corner block by luck.

Two numbers here are measured and both were wrong first. Clearance 0.6, not 1.1
— my first pass asked 1.1m and produced a WORSE frame than the bug, swinging
105° to stare at a fence, because the greatest distance any obstacle can have
from the camera->head line is its own distance from the player, and p3 is only
1m away, so 1.1 was unreachable by construction. maxOff 90° — a frame turned
more than a quarter-circle off the bed is no longer a frame of the bed, so the
"roomiest" fallback is bounded to the arc. The assert reproduces the 105° from
the real yard and pins that the cap brings it to 68°.

AFTERMATH CLOTH-SWALLOW. "The dead draped sail can swallow the camera whole."
The camera has collided with the house since Sprint 2; the sail was just never
in the list. While up it hangs above head height and nothing notices — but a
FAILED sail lies in the yard at head height, the one moment you most want to
look at it. refreshCameraSolids() adds the cloth to the set, called from both
rebuilds (loadSiteInto makes new world.solids, rigSail makes a new cloth; either
alone strands the camera on a disposed mesh). Its bounding sphere recomputes
every frame, so the raycast reads live geometry. Verified live: camera solids
9 -> 10 the moment a sail rigs, and the cloth is in the set.

Added camera.solids getter so that was checkable at all — "did the sail join
the set" was otherwise a claim with no seam. And hoisted sailView's declaration
with rig/rigging: loadSiteInto's boot-time call reads it before any cloth
exists, which is a TDZ throw (page goes blank) that the same block already
documents for rig/rigging.

selftest 339/0/0 (was 338).
This commit is contained in:
type-two 2026-07-18 01:18:29 +10:00
parent 6e5f4b5222
commit 701403ab1d
3 changed files with 197 additions and 4 deletions

View File

@ -11,6 +11,100 @@
import * as THREE from '../vendor/three.module.js';
/**
* Perpendicular distance in XZ from point `p` to the segment ab. The camera
* cares about the whole segment, not the endpoints: a post the camera stands
* clear of can still be planted squarely between it and the player's head.
*/
function distToSegmentXZ(p, a, b) {
const abx = b.x - a.x, abz = b.z - a.z;
const len2 = abx * abx + abz * abz;
let t = len2 ? ((p.x - a.x) * abx + (p.z - a.z) * abz) / len2 : 0;
t = t < 0 ? 0 : t > 1 ? 1 : t;
return Math.hypot(p.x - (a.x + abx * t), p.z - (a.z + abz * t));
}
/**
* The opening yaw: point the camera so the first frame is player + what matters,
* with nothing skewered through the middle of it. (SPRINT13 gate 2.5.)
*
* The QA pass put it plainly: "the boot camera puts a pole dead-centre through
* the player on every single first impression". Measured rather than eyeballed
* the player spawns at (0,6) on backyard_01 looking up the yard at yaw 0, which
* parks the camera near z10.4, and post p3 stands at (0,7). Dead between the
* camera and the head, every boot, on a public URL.
*
* Two things this is NOT:
* · not a magic yaw. A hand-picked angle is one site's answer, and sites are
* data now site_02 spawns somewhere else with its posts somewhere else, and
* a constant tuned against the backyard would frame the corner block by luck.
* · not the camera's existing collision. That pulls IN to the first solid in
* the way, so a post behind the player trades a skewer for a shoulder-filling
* close-up. Both are the bad frame; this picks a different angle instead.
*
* Sweeps outward from the ideal (camera opposite `lookAt`, so the player stands
* in front of the thing they're here to protect) and takes the FIRST yaw with
* real clearance nearest the ideal wins, so the framing gives up as little as
* it can.
*
* TWO NUMBERS HERE ARE MEASURED, not chosen, and both were wrong first:
*
* `clearance` 0.6 my first pass asked for 1.1 m and produced a WORSE frame
* than the bug it fixed: the camera swung 105° off the garden and stared at a
* fence. Reason, measured in the running game: p3 stands at (0,7) and the player
* spawns at (0,6), so the post is **1.0 m away** and the greatest perpendicular
* distance any obstacle can ever have from the camerahead line is its own
* distance from the player. 1.1 was unreachable by construction. The sweep
* dutifully tried all 24 candidates, failed every one, and fell back to
* "roomiest", which is a rule that knows nothing about the garden. Clearance is
* `sin(off) × 1.0 m` here, so 0.6 buys ~37° of turn and leaves the bed 23° off
* centre in a 62° FOV: in frame, next to the player, no pole through the head.
*
* `maxOff` 90° the fallback's bound, and the lesson from the same failure. A
* frame that has turned more than a quarter-circle off the bed is no longer a
* frame of the bed, so it cannot be the "best available" answer no matter how
* roomy it is. Past this, a pole in shot is the lesser evil, and the sweep says
* so by returning the roomiest angle WITHIN the arc rather than outside it.
*
* Never throws, never returns NaN: a yard boxed in on every side still gets its
* best available frame.
*
* @param {{x,z}} player
* @param {{x,z}} lookAt what the frame should be about the garden bed
* @param {{x,z}[]} obstacles vertical things, by XZ: posts, trunks, structures
* @param {object} [opts]
* @returns {number} yaw in radians
*/
export function spawnYawFor(player, lookAt, obstacles = [], opts = {}) {
const distance = opts.distance ?? DEFAULTS.distance;
const clearance = opts.clearance ?? 0.6;
const maxOff = opts.maxOff ?? Math.PI / 2;
const steps = opts.steps ?? 24;
const ideal = Math.atan2(player.x - lookAt.x, player.z - lookAt.z);
const clearAt = (yaw) => {
const cam = { x: player.x + Math.sin(yaw) * distance, z: player.z + Math.cos(yaw) * distance };
let clear = Infinity;
for (const o of obstacles) clear = Math.min(clear, distToSegmentXZ(o, player, cam));
return clear;
};
let best = ideal, bestClear = clearAt(ideal);
if (bestClear >= clearance) return ideal;
// Alternate outward — +7.5°, 7.5°, +15° … — so "first acceptable" also means
// "least turned away from the bed". Bounded by maxOff: see above.
for (let i = 1; i <= steps; i++) {
const mag = (Math.ceil(i / 2) * maxOff) / Math.ceil(steps / 2);
if (mag > maxOff) break;
const yaw = ideal + (i % 2 ? mag : -mag);
const clear = clearAt(yaw);
if (clear >= clearance) return yaw;
if (clear > bestClear) { bestClear = clear; best = yaw; }
}
return best;
}
/** Stand-off kept between the camera and whatever it collided with. */
const WALL_MARGIN = 0.25;
/** Absolute floor on camera-to-head distance. Above the 0.1 near plane. */
@ -108,6 +202,14 @@ export function createCameraRig(domElement, opts = {}) {
*/
setSolids(list) { solids = list ?? []; },
/**
* What the camera is currently colliding against. A read-only view (spread,
* so a caller can't mutate the live array). SPRINT13: added so the
* aftermath cloth-swallow fix is observable "did the dead sail actually
* join the solid set" was otherwise a claim with no way to check it.
*/
get solids() { return [...solids]; },
/** @param {(x:number, z:number) => number} fn Pass world.heightAt. */
setGround(fn) { groundAt = fn ?? (() => -Infinity); },

View File

@ -16,7 +16,7 @@
import * as THREE from '../vendor/three.module.js';
import { FIXED_DT, PHASES, STORM_LEN, HARDWARE, SPARE_COST, Emitter } from './contracts.js';
import { createWorld, loadSite } from './world.js';
import { createCameraRig } from './camera.js';
import { createCameraRig, spawnYawFor } from './camera.js';
import { loadStorm, createWind } from './weather.js';
import { SailRig, createSailView } from './sail.js';
import { createPlayer } from './player.js';
@ -507,6 +507,13 @@ export async function boot(opts = {}) {
// temporal-dead-zone throw. Boot builds them against the same fresh world.
let rig;
let rigging;
// Same reason, same trap (SPRINT13): loadSiteInto now refreshes the camera's
// solid set, and that reads sailView — on a first call that happens at boot,
// before any cloth exists. Declared up here with the others so it reads
// `undefined` (no cloth yet: use the yard's solids) instead of throwing TDZ
// from a line whose only crime was being honest about what the camera needs.
// Caught by the page going blank, which is the loudest a TDZ ever gets.
let sailView = null;
const cameraRig = createCameraRig(canvas);
const interact = new Interact();
@ -533,7 +540,7 @@ export async function boot(opts = {}) {
await world.dress();
currentSite = siteName;
cameraRig.setSolids(world.solids);
refreshCameraSolids();
cameraRig.setGround(world.heightAt);
// Lane C: local wind effects are per-yard. A venturi (site_02's screaming
// gap) and tree shelters both re-register here; empty/absent is a no-op,
@ -547,6 +554,23 @@ export async function boot(opts = {}) {
// forecast, never mid-storm, so a clean rebuild is honest and cheap.
player = await createPlayer(scene, world, cameraRig, { wind, interact });
// SPRINT13 gate 2.5 — the opening frame, per YARD rather than per game.
// Done here because the spawn is here: the player and the yard are rebuilt
// together, so the frame that introduces them is a property of the site, not
// a constant. site_02 spawns somewhere else with its posts somewhere else,
// and a yaw hand-tuned against the backyard would frame the corner block by
// luck. Obstacles come from the site's own data — every vertical thing a
// pole-through-the-head could be.
cameraRig.yaw = spawnYawFor(
player.pos,
{ x: world.gardenBed.x, z: world.gardenBed.z },
[
...(siteDef.posts ?? []),
...(siteDef.trees ?? []),
...(siteDef.structures ?? []),
].map((o) => ({ x: o.x, z: o.z })),
);
// Re-point everything that captured the old anchor set. Done HERE, right
// after the rebuild, rather than in the caller — a caller-side `if (switched)`
// was fragile (it broke the moment a debug path had already advanced
@ -578,7 +602,28 @@ export async function boot(opts = {}) {
// --- 3. sail ------------------------------------------------------------
rig = new SailRig({ anchors: world.anchors });
let sailView = null;
/**
* What the camera may not pass through: the yard's solids, PLUS the cloth.
*
* SPRINT13 gate 2.5 "in aftermath the dead draped sail can swallow the
* camera whole" (QA pass). The camera has collided with the house since
* Sprint 2 for exactly this reason, and the sail was simply never in the list:
* while it is up it hangs above head height and nothing notices, but a sail
* that has FAILED lies in the yard at head height, which is the one moment the
* player most wants to look at it.
*
* Called from both rebuilds, because they invalidate the list independently:
* loadSiteInto() makes new world.solids, rigSail() makes a new cloth. Either
* one alone leaves the camera holding a mesh that was disposed.
*
* The cloth's bounding sphere is recomputed in sailView.update() every frame,
* so the raycast reads live geometry rather than the shape it had at rig time.
* Cheap: the default grid is 10x10, so ~162 triangles against a whole house.
*/
function refreshCameraSolids() {
cameraRig.setSolids(sailView ? [...world.solids, sailView] : world.solids);
}
/**
* Attach the cloth across 4 anchors and (re)build its view.
@ -602,6 +647,7 @@ export async function boot(opts = {}) {
}
sailView = await createSailView(rig);
scene.add(sailView);
refreshCameraSolids(); // the new cloth; the one it replaced is disposed
wireYardActions(interact, { sailRig: rig, world });
return sailView;
}

View File

@ -7,7 +7,7 @@ import * as THREE from '../../vendor/three.module.js';
import { ANCHOR_TYPE, FIXED_DT, PHASES, STORM_LEN, YARD, checkContract, createStubWind } from '../contracts.js';
import { createWindField } from '../weather.core.js';
import { createWorld, heightAt, loadSite, validateSite } from '../world.js';
import { createCameraRig } from '../camera.js';
import { createCameraRig, spawnYawFor } from '../camera.js';
import {
CALM_STORM, accumulate, canPlayHere, createGame, createWindRouter, enterCommits,
stormsToPreload, verdictFor,
@ -131,6 +131,51 @@ export default async function run(t) {
assertEq(canPlayHere(() => ({})), true, 'a browser that answers nothing: let them in');
});
t.test('the spawn frame points at the garden with no pole through the player', () => {
// The QA pass: "the boot camera puts a pole dead-centre through the player on
// every single first impression." Measured, not eyeballed — these are the
// real backyard_01 numbers that produced the bug.
const player = { x: 0, z: 6 };
const bed = { x: 1, z: 2 };
// No obstacles: the ideal frame is the camera OPPOSITE the bed, so the player
// stands in front of what they're protecting. The view direction (from camera
// through the player) must point at the bed.
const clean = spawnYawFor(player, bed, []);
const viewOff = (yaw) => {
const view = { x: -Math.sin(yaw), z: -Math.cos(yaw) };
const tb = { x: bed.x - player.x, z: bed.z - player.z };
const l = Math.hypot(tb.x, tb.z);
return Math.acos(Math.max(-1, Math.min(1, (view.x * tb.x + view.z * tb.z) / (l || 1)))) * 180 / Math.PI;
};
assertLess(viewOff(clean), 1, 'with nothing in the way, the frame looks straight at the bed');
// The bug: p3 stands at (0,7), one metre behind the spawn, dead on the ideal
// view line. The fix must TURN to clear it — and still keep the bed in a 62°
// FOV (< 31° off centre).
const withPole = spawnYawFor(player, bed, [{ x: 0, z: 7 }]);
const cam = { x: player.x + Math.sin(withPole) * 4.5, z: player.z + Math.cos(withPole) * 4.5 };
const abx = cam.x - player.x, abz = cam.z - player.z, l2 = abx * abx + abz * abz;
let t = ((0 - player.x) * abx + (7 - player.z) * abz) / l2; t = Math.max(0, Math.min(1, t));
const poleClear = Math.hypot(0 - (player.x + abx * t), 7 - (player.z + abz * t));
assert(poleClear > 0.5, `the pole is off the view line (${poleClear.toFixed(2)}m), not through the head`);
assertLess(viewOff(withPole), 31, 'and the bed is still in frame after the turn');
// The failure mode I actually shipped first, reproduced from the real yard.
// With the full backyard_01 obstacle set and a clearance the geometry cannot
// meet, the sweep falls back to "roomiest" — and UNBOUNDED, roomiest swung
// 105° off the garden to stare at a fence (the exact number the QA pass would
// have seen). maxOff caps the fallback to the 90° arc, and this config brings
// it back to 68°. Without the cap this assert goes red at 105°.
const yardObstacles = [
{ x: -4.5, z: 5.5 }, { x: 4, z: 6 }, { x: 0, z: 7 }, { x: -3.2, z: -1.2 },
{ x: -9, z: 2 }, { x: 8, z: -2 },
];
const unreachable = spawnYawFor(player, bed, yardObstacles, { clearance: 5 });
assert(Number.isFinite(unreachable), 'an impossible clearance still yields a finite yaw, never NaN');
assertLess(viewOff(unreachable), 91, 'and never turns its back on the bed — the cap holds (105° without it)');
});
// --- the week (SPRINT8 gate 1) -------------------------------------------
t.test('the week is five escalating nights, each a storm and a site', () => {