Shadows land on the building, and the sky layers stop repeating themselves
**Shadows were the right size and pointed at nothing.** Last round fixed the missing `updateProjectionMatrix()`, so the frustum finally became the size every caller asks for — but nothing aimed it, and `sun.target` sits at the world origin. A pack's origin is the **north-west corner of its slab**, so for lumbridge-hq the box was off-centre by half the building: 14.4 m of a 48 m plate, about a third of the floor, fell outside the frustum and neither cast nor received. Invisible while three's broken ±5 default made shadows useless everywhere; obvious the moment they started working. `SceneKitOptions` takes a `shadowTarget` now, both callers pass one, and the light's target is added to the scene — which is the part that actually matters, because `LightShadow.updateMatrices` reads `target.matrixWorld` and an unparented `Object3D` is never reached by the traversal that updates it. The sun is also placed relative to the target rather than the origin, so light-to-target is exactly `sunDistance` for every direction, which is the invariant each caller's `shadowNear`/`shadowFar` were chosen against. **`flights.ts` could not be tested, and that is why it was untested.** It used a TypeScript parameter property — the one piece of TS syntax that *emits code* rather than annotating a type — so Node's type stripping refused the whole module. The bundler never cared, so nobody found out until the first `node --test` file tried to import it. The module carrying the worst bug this project has shipped was, by construction, the one module that could not have a test. It has eleven now, including one that fails if the live-aircraft repeat-skip is removed. **Robots are on the plan panel** — a turned marker with a bow for heading, in the one hue left that is neither the people-blue nor the camera-amber. Review findings cleared across the four new sky/robot modules: a real 24 mm void at the ankle and an 8 mm hole through each forearm, a per-frame allocation in the robot heading picker, a per-frame sort in the starlink ranking, `uTime` growing unbounded until the cloud breath quantises, and `DAY_REFERENCE`'s derivation which did not reproduce. `createStarlinkMeshLayer` now takes a **board** radius — the same unit its sibling takes — instead of a dome radius with nothing in the types to tell them apart. That is the exact confusion that has already caused one real bug here. `DOME_RADIUS_FACTOR` has one owner and is imported, not copied: the points and the meshes must be on the same dome or a satellite that grows geometry also jumps. Several comments were wrong rather than merely stale — a fabricated claim about `Object3D.clone`, a fabricated attribution to `Plan`, an inverted `DoubleSide` argument, a triangle ledger citing a function that no longer exists, and a defensive-call rationale that contradicted the paragraph above it. In a codebase where the comments are the design record, those are defects. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -83,7 +83,15 @@ import { Plan, type Depth, type PlanOptions } from "./plan.ts";
|
||||
import { createPresenceLayer, type PresenceLayer, type PresencePalette } from "./presence.ts";
|
||||
import { createShell, type Shell, type WallInfo } from "./shell.ts";
|
||||
import { createLuminaires, type Luminaires, type Walker } from "./luminaires.ts";
|
||||
import { createRobotLayer, type RobotLayer, type RobotSpec } from "./robots.ts";
|
||||
import {
|
||||
createRobotLayer,
|
||||
type RobotLayer,
|
||||
type RobotSpec,
|
||||
type RobotView,
|
||||
} from "./robots.ts";
|
||||
|
||||
/** Shared empty, so a pack with no robots does not allocate one per call. */
|
||||
const NO_ROBOTS: readonly RobotView[] = [];
|
||||
import type { Office, Point2, Presence, Viewpoint } from "./types.ts";
|
||||
|
||||
// Re-exported so a caller can name the tier it is asking for without importing
|
||||
@@ -260,6 +268,15 @@ export interface OfficeScene extends StageScene {
|
||||
* Cheap; call it every frame. An empty list is the normal state.
|
||||
*/
|
||||
setWalkers(walkers: readonly Walker[]): void;
|
||||
/**
|
||||
* The robots walking about the floor, live. Empty when the pack asked for
|
||||
* none.
|
||||
*
|
||||
* The **same array** every call, holding vectors the layer mutates in place —
|
||||
* take the reference once and read it, rather than polling for a snapshot.
|
||||
* The plan panel and the ceiling lights both consume it that way.
|
||||
*/
|
||||
robots(): readonly RobotView[];
|
||||
}
|
||||
|
||||
export function createOfficeScene(office: Office, options: OfficeSceneOptions): OfficeScene {
|
||||
@@ -320,6 +337,21 @@ export function createOfficeScene(office: Office, options: OfficeSceneOptions):
|
||||
maxPolarAngle: Math.PI / 2.04,
|
||||
dampingFactor: 0.08,
|
||||
shadowExtent: Math.max(8, span * 0.7),
|
||||
/**
|
||||
* The middle of the floor plate, which is nowhere near the origin.
|
||||
*
|
||||
* A pack's origin is the **north-west corner of its slab** — that is the
|
||||
* frame `interiors/types.ts` defines and every pack is authored in. So a
|
||||
* shadow box centred on the origin puts half of itself outside the building
|
||||
* to the west and north: for `lumbridge-hq`, 14.4 m of a 48 m plate, about a
|
||||
* third of the floor, fell outside the frustum and neither cast nor received
|
||||
* a shadow. Invisible while three's default ±5 box made shadows useless
|
||||
* everywhere, and obvious the moment they started working.
|
||||
*
|
||||
* `y = 0` deliberately: the slab is the receiving surface, and moving the
|
||||
* target up and down only slides the box along the light's view axis.
|
||||
*/
|
||||
shadowTarget: new THREE.Vector3(plan.bounds.center.x, 0, plan.bounds.center.z),
|
||||
shadowMapSize: 2048,
|
||||
shadowNear: 0.5,
|
||||
shadowFar: span * 4,
|
||||
@@ -745,6 +777,7 @@ export function createOfficeScene(office: Office, options: OfficeSceneOptions):
|
||||
luminaires.setSolarElevation(degrees);
|
||||
},
|
||||
houseLevel: () => luminaires.houseLevel(),
|
||||
robots: () => robots?.robots() ?? NO_ROBOTS,
|
||||
setWalkers(walkers) {
|
||||
luminaires.setWalkers(walkers);
|
||||
},
|
||||
|
||||
+48
-8
@@ -23,7 +23,7 @@
|
||||
* meshes instead. If a floor needs the budget back, drop the count — the
|
||||
* cost is exactly linear in it, and a pack that only wants a robot standing
|
||||
* somewhere should place the `tera:robot.optimus` asset, which is two.
|
||||
* - **One set of geometry, 7.6k triangles, however many robots there are.**
|
||||
* - **One set of geometry, 7.1k triangles, however many robots there are.**
|
||||
* `buildOptimus` runs once and every figure after the first is a
|
||||
* `cloneOptimus`, which shares every buffer and both materials.
|
||||
* - **About 30 µs per tick for the crowd**, measured over thirty simulated
|
||||
@@ -162,9 +162,21 @@ import type { Point2 } from "./types.ts";
|
||||
const CRUISE = 1.2;
|
||||
|
||||
/**
|
||||
* How wide a robot is to the collider. Its shoulders are 0.35 m across, so 0.28
|
||||
* leaves about 100 mm of air on each side — enough that it does not scrape
|
||||
* through a doorway, tight enough that it fits through one.
|
||||
* How wide a robot is to the collider.
|
||||
*
|
||||
* The number to size this against is not the shoulder span. `optimus.ts` puts
|
||||
* the shoulder *pivots* 0.35 m apart and this comment used to quote that, which
|
||||
* made 0.28 look like a radius with 100 mm of slack in it. Measured off the
|
||||
* built figure, a standing Optimus is 0.520 m across at its widest — the
|
||||
* shoulder drums, with the splayed hands 4 mm inside them — so 0.56 is 20 mm of
|
||||
* slack a side, not 100.
|
||||
*
|
||||
* That is still the right answer, because a doorway `Plan` calls passable is
|
||||
* 0.9 m and 0.56 goes through one with room to turn in it. But it is the number
|
||||
* to think with if anything about the arms, the shoulders or the stance
|
||||
* changes, and there is far less room in it than the old comment implied: the
|
||||
* figure is within 40 mm of its own collider, so a wider robot silently starts
|
||||
* clipping door frames rather than failing.
|
||||
*/
|
||||
const RADIUS = 0.28;
|
||||
|
||||
@@ -487,6 +499,16 @@ export interface RobotLayer {
|
||||
dispose(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* A direction a robot may walk in, and the clearance the probe settled for to
|
||||
* find it. There is exactly one of these per layer and it is scratch — see
|
||||
* `chooseHeading`.
|
||||
*/
|
||||
interface Heading {
|
||||
heading: number;
|
||||
clearance: number;
|
||||
}
|
||||
|
||||
/** Everything about one robot that changes. */
|
||||
interface Robot {
|
||||
view: RobotView;
|
||||
@@ -533,9 +555,12 @@ export function createRobotLayer(plan: Plan, options: RobotLayerOptions): RobotL
|
||||
const views: RobotView[] = [];
|
||||
|
||||
// Scratch, reused every frame. Four robots at sixty frames is 240 chances a
|
||||
// second to allocate a `Point2` for nothing.
|
||||
// second to allocate a `Point2` for nothing. `chosen` is the same discipline
|
||||
// applied to the one place it had been forgotten; see `chooseHeading`, which
|
||||
// is the only thing allowed to write to it.
|
||||
const from: Point2 = { x: 0, z: 0 };
|
||||
const to: Point2 = { x: 0, z: 0 };
|
||||
const chosen: Heading = { heading: 0, clearance: 0 };
|
||||
|
||||
/**
|
||||
* A level's rooms with a running area total, so a candidate point can be
|
||||
@@ -861,17 +886,32 @@ export function createRobotLayer(plan: Plan, options: RobotLayerOptions): RobotL
|
||||
*
|
||||
* Falls back to `want` itself when everything is blocked, so the caller still
|
||||
* turns toward where it wanted to go and simply does not move.
|
||||
*
|
||||
* **The returned object is `chosen`, every time.** This used to be a fresh
|
||||
* `{ heading, clearance }` per call, which is once a frame for every robot
|
||||
* that is moving — the same allocation-per-frame this file goes out of its
|
||||
* way to avoid in `from`, `to` and `RobotLayer.robots`, and it is odd that
|
||||
* one survived where those did not. It is scratch now, and it is safe
|
||||
* *because* of how it is used: `step` reads both fields on the line after the
|
||||
* call and never keeps the reference. Anything that wants to hold on to a
|
||||
* choice — comparing this frame's against last frame's, say — has to copy the
|
||||
* two numbers out, or it will find that both of them changed underneath it on
|
||||
* the next robot's turn.
|
||||
*/
|
||||
function chooseHeading(robot: Robot, want: number): { heading: number; clearance: number } {
|
||||
function chooseHeading(robot: Robot, want: number): Heading {
|
||||
for (const relief of PROBE_RELIEF) {
|
||||
const clearance = radius * relief;
|
||||
for (const offset of PROBE_TURNS) {
|
||||
if (clearAhead(robot, want + offset, clearance)) {
|
||||
return { heading: want + offset, clearance };
|
||||
chosen.heading = want + offset;
|
||||
chosen.clearance = clearance;
|
||||
return chosen;
|
||||
}
|
||||
}
|
||||
}
|
||||
return { heading: want, clearance: radius };
|
||||
chosen.heading = want;
|
||||
chosen.clearance = radius;
|
||||
return chosen;
|
||||
}
|
||||
|
||||
// ---- Step ---------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user