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:
+298
-1
@@ -58,6 +58,33 @@ export interface OfficePlanHoverInfo {
|
||||
level: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* One robot walking about the building, as this widget needs it.
|
||||
*
|
||||
* Structural, and deliberately *not* `RobotView` imported from
|
||||
* `interiors/robots.ts` — the same call `luminaires.ts` makes with its `Walker`,
|
||||
* and made here for a stronger reason. This file is drawn from a `Plan` and
|
||||
* nothing else; a type import from the robot layer would tie the widget's public
|
||||
* contract to a module it otherwise has no business knowing exists, and the next
|
||||
* thing that walks about a floor would have to be a robot to be drawable. Two
|
||||
* fields is the whole of what a mark on a floor plan needs. A `RobotView`
|
||||
* satisfies this as it stands and nothing has to be adapted.
|
||||
*
|
||||
* The robot's own `id` is read nowhere, on purpose. `drawOccupied` sets out why
|
||||
* the plan answers "is anybody there" rather than "who" even for people, and a
|
||||
* robot is further down that road again — `robots.ts` is explicit that a robot is
|
||||
* nobody, so there is not even a name to decline to print.
|
||||
*/
|
||||
export interface PlanRobot {
|
||||
/** Which storey it is on. It is drawn only while that storey is the one shown. */
|
||||
levelId: string;
|
||||
/**
|
||||
* Office-world metres, at its feet. **Live**: whoever owns the robot mutates
|
||||
* this vector in place every frame. This file reads it and never writes it.
|
||||
*/
|
||||
position: THREE.Vector3;
|
||||
}
|
||||
|
||||
export interface OfficeMinimapOptions {
|
||||
/** The resolved office. The same `Plan` the scene was built from, or the drawing lies. */
|
||||
plan: Plan;
|
||||
@@ -98,6 +125,26 @@ export interface OfficeMinimap {
|
||||
* spot would turn a private id into a public coordinate.
|
||||
*/
|
||||
setPresence(people: readonly Presence[]): void;
|
||||
/**
|
||||
* The robots walking about the building, so the plan shows them moving.
|
||||
*
|
||||
* Shaped like `setPresence` — the caller hands over the domain objects and the
|
||||
* widget does its own resolving, rather than the caller pre-chewing them into
|
||||
* pixels — with one difference that comes out of the data and not out of
|
||||
* taste. Presence arrives from a poll every few seconds and each answer is a
|
||||
* *snapshot*, so `setPresence` does its work when it is called. The robot layer
|
||||
* publishes a stable array of vectors it mutates in place, so this is called
|
||||
* **once**, with that array, and every frame afterwards is read straight out of
|
||||
* it by `tick`. That is the same handshake `officeScene` already makes with
|
||||
* `luminaires.setWalkers`, and it is what lets the plan show something moving
|
||||
* at sixty hertz without anybody allocating anything.
|
||||
*
|
||||
* Calling it every frame is harmless — it costs one reference compare — so a
|
||||
* caller that would rather push than be read is not punished for it. Handing
|
||||
* over a *different* array drops the old one, and the new robots have no
|
||||
* heading until they have taken a step.
|
||||
*/
|
||||
setRobots(robots: readonly PlanRobot[]): void;
|
||||
/** Call from the stage tick. Cheap by construction — see the file header. */
|
||||
tick(): void;
|
||||
/** Re-do the backing store at the current size and re-rasterise the plan. */
|
||||
@@ -132,6 +179,15 @@ const MIN_PROP_M = 0.35;
|
||||
/** Props standing above head height are fittings, not furniture. See `drawProps`. */
|
||||
const MAX_PROP_ELEVATION_M = 1.6;
|
||||
|
||||
/**
|
||||
* The empty robot list, shared and frozen by convention.
|
||||
*
|
||||
* Module-level so that an office with no robots — which is every pack that does
|
||||
* not ask for them, and the default — never allocates for the feature at all.
|
||||
* What it pays instead is one `length === 0` test per frame in three functions.
|
||||
*/
|
||||
const NO_ROBOTS: readonly PlanRobot[] = [];
|
||||
|
||||
export function createOfficeMinimap(options: OfficeMinimapOptions): OfficeMinimap {
|
||||
const { plan, camera, controls } = options;
|
||||
const registry = options.registry ?? kit;
|
||||
@@ -219,6 +275,37 @@ export function createOfficeMinimap(options: OfficeMinimapOptions): OfficeMinima
|
||||
let occupiedPx = new Float64Array(0);
|
||||
/** Seat id -> label, for the hover readout. Every seat in the building, not just this storey. */
|
||||
let peopleBySeat = new Map<string, string>();
|
||||
/**
|
||||
* The robots, live. The array belongs to whoever called `setRobots` and its
|
||||
* contents change underneath this file between one draw and the next.
|
||||
*/
|
||||
let robotList: readonly PlanRobot[] = NO_ROBOTS;
|
||||
/**
|
||||
* Where each robot was as of the last draw — office metres, x then z — and the
|
||||
* unit direction it was last seen travelling in, again x then z. Two flat
|
||||
* arrays rather than an array of objects, for the reason every other buffer in
|
||||
* this file is flat: the draw loop may not allocate and may not chase pointers.
|
||||
*
|
||||
* **The heading is derived here rather than published by the layer**, which
|
||||
* looks like a gap and is not one. A `RobotView` carries a position and no yaw;
|
||||
* the layer knows its yaw perfectly well and simply does not hand it out, and
|
||||
* asking it to would be a change to a contract that three other callers read.
|
||||
* Differencing two positions recovers the heading to better than a pixel: the
|
||||
* layer advances a robot *exactly* along its own yaw — `x -= sin(yaw) · ds`,
|
||||
* `z -= cos(yaw) · ds` — so the step between two draws **is** the yaw, one
|
||||
* redraw stale, which at this widget's 30 Hz ceiling and the layer's 2.2 rad/s
|
||||
* turn rate is under four degrees. Four degrees on a mark five pixels long is
|
||||
* not visible.
|
||||
*
|
||||
* The one case where the derived heading and the rig's yaw genuinely part
|
||||
* company is a robot rotating while barely moving — yielding to another robot,
|
||||
* or pivoting into a doorway with its pace scaled to nearly nothing. Then this
|
||||
* keeps pointing the way the machine last actually went, which is the better
|
||||
* answer for a plan: a plan records what happened on the floor, not what a
|
||||
* transform is doing this instant.
|
||||
*/
|
||||
let robotLast = new Float64Array(0);
|
||||
let robotDir = new Float64Array(0);
|
||||
|
||||
// Laid-out geometry. Flat arrays and paths of device pixels, rebuilt on resize
|
||||
// and on a change of storey, so the draw loop reads numbers and never projects.
|
||||
@@ -744,6 +831,102 @@ export function createOfficeMinimap(options: OfficeMinimapOptions): OfficeMinima
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A robot, as a turned chassis with a bow on the front.
|
||||
*
|
||||
* **The shape carries this, not the colour.** `drawOccupied` has already
|
||||
* established that a hue is a guess at three device pixels, and it is right; a
|
||||
* robot drawn as a differently-tinted dot is a person to anybody who has not
|
||||
* been told otherwise, and this widget has no legend to tell them with. So the
|
||||
* marker is built out of the one channel that survives at five pixels —
|
||||
* silhouette — and the plan's silhouettes are a small closed vocabulary:
|
||||
*
|
||||
* - a **circle** is somebody: an occupied desk, or a viewpoint pin;
|
||||
* - an **axis-aligned rectangle** is the building or its furniture, drawn
|
||||
* once into the raster and never moving again;
|
||||
* - a **notched amber chevron** is the camera, and there is exactly one.
|
||||
*
|
||||
* A robot is therefore a *turned* rectangle with a point on the front. Hard
|
||||
* corners, so it reads machined rather than grown. Wider across than it is
|
||||
* deep, so the turn is visible at all and the thing has shoulders. Convex,
|
||||
* unnotched, cool and about 60% of the linear size of the chevron, so it is
|
||||
* never mistaken for the camera — which is still this widget's first job.
|
||||
*
|
||||
* A plain square was the first attempt and is useless twice over: four-fold
|
||||
* symmetry means turning it conveys nothing, so the heading has to be a second
|
||||
* mark stuck on the outside, and a square sitting unturned among the desks is a
|
||||
* desk. A detached tick ahead of the body was the second attempt, and two
|
||||
* pixels of ink with a gap in front of them reads as dirt on the screen rather
|
||||
* than as a nose. Folding the point into the body path costs no extra ink, no
|
||||
* extra fill, and cannot come adrift from the thing it belongs to.
|
||||
*
|
||||
* The colour is a mint green — the third hue on the drawing, after the
|
||||
* people-blue and the camera-amber, and the last one this plan will get. Green
|
||||
* is the furthest free hue from both of them; it is the brightest mark per unit
|
||||
* of ink on a near-black ground, because luminance lives mostly in the green
|
||||
* channel, which is what something moving among a hundred static grey
|
||||
* rectangles wants; and it is already the colour a viewer reads as a machine
|
||||
* that is running. Its riskiest confusion is with the camera's amber, since
|
||||
* red-green colour blindness pulls both toward yellow — which is precisely the
|
||||
* pair separated by silhouette and by size above, and is why the shape had to
|
||||
* do the work first and the hue second.
|
||||
*/
|
||||
function drawRobots(ctx: Ctx) {
|
||||
if (robotList.length === 0 || !level) return;
|
||||
// Half the beam, the distance from the middle to the transom, and the point
|
||||
// out in front of it. A touch smaller than the occupied dot on purpose: there
|
||||
// are only ever a few of these, they are the only thing on the plan that
|
||||
// moves, and a moving mark of a given size already shouts louder than a still
|
||||
// one.
|
||||
const half = 2.5 * dpr;
|
||||
const rear = 1.7 * dpr;
|
||||
const bow = 2.3 * dpr;
|
||||
ctx.lineWidth = dpr;
|
||||
ctx.fillStyle = theme.robot;
|
||||
ctx.strokeStyle = theme.robotEdge;
|
||||
for (let i = 0; i < robotList.length; i++) {
|
||||
const robot = robotList[i];
|
||||
// The level test is the whole of the storey handling, and it is per-draw
|
||||
// rather than laid out like `occupiedPx` because a robot moves and a seat
|
||||
// does not: there is nothing to cache that would still be true next frame.
|
||||
if (!robot || robot.levelId !== level.id) continue;
|
||||
const x = toPxX(robot.position.x);
|
||||
const y = toPxY(robot.position.z);
|
||||
// A direction in office metres is already a direction on the drawing —
|
||||
// `toPxX` and `toPxY` are the same positive scale on both axes with no
|
||||
// negation anywhere, which the header explains at length. `drawCamera`
|
||||
// leans on the same fact and the two would break together if the plan were
|
||||
// ever mirrored.
|
||||
const fx = robotDir[i * 2] ?? 0;
|
||||
const fy = robotDir[i * 2 + 1] ?? 0;
|
||||
// Both zero only before a robot's first step: `recordRobots` writes a unit
|
||||
// vector or nothing at all.
|
||||
const known = fx !== 0 || fy !== 0;
|
||||
const nx = known ? fx : 0;
|
||||
const ny = known ? fy : 1;
|
||||
// Starboard, from forward. Same derivation as the camera chevron's.
|
||||
const sx = -ny;
|
||||
const sy = nx;
|
||||
// With no heading yet the body is drawn as a square and keeps its bow: a
|
||||
// rectangle turned some arbitrary way is a claim about which way a machine
|
||||
// is pointing, and this is the one state — a robot that has not moved since
|
||||
// it was handed over — where there is honestly nothing to claim.
|
||||
const back = known ? rear : half;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x - nx * back - sx * half, y - ny * back - sy * half);
|
||||
ctx.lineTo(x + nx * back - sx * half, y + ny * back - sy * half);
|
||||
if (known) ctx.lineTo(x + nx * (back + bow), y + ny * (back + bow));
|
||||
ctx.lineTo(x + nx * back + sx * half, y + ny * back + sy * half);
|
||||
ctx.lineTo(x - nx * back + sx * half, y - ny * back + sy * half);
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
// The ground colour, hairline, exactly as an occupied desk gets: a machine
|
||||
// crossing a desk bank has to keep its outline against the furniture it is
|
||||
// walking over, and the fill alone does not manage it.
|
||||
ctx.stroke();
|
||||
}
|
||||
}
|
||||
|
||||
function drawPing(ctx: Ctx, now: number) {
|
||||
if (pinging === 0) return;
|
||||
const t = (now - pinging) / PING_MS;
|
||||
@@ -772,6 +955,11 @@ export function createOfficeMinimap(options: OfficeMinimapOptions): OfficeMinima
|
||||
drawFootprint(ctx);
|
||||
drawOccupied(ctx);
|
||||
drawViewpoints(ctx);
|
||||
// Over the furniture, the desks and the viewpoint pins, and under the
|
||||
// crosshair and the camera. A robot standing on a viewpoint is the thing you
|
||||
// want to see; the camera is the thing you want to see over everything, and
|
||||
// that has been the order here since the widget was one function.
|
||||
drawRobots(ctx);
|
||||
crosshair(ctx, toPxX(controls.target.x), toPxY(controls.target.z), theme.target, 5 * dpr);
|
||||
drawCamera(ctx);
|
||||
if (pendingX >= 0) crosshair(ctx, pendingX, pendingY, theme.pending, 7 * dpr);
|
||||
@@ -806,6 +994,71 @@ export function createOfficeMinimap(options: OfficeMinimapOptions): OfficeMinima
|
||||
lastAspect = camera.aspect;
|
||||
}
|
||||
|
||||
/**
|
||||
* True when a robot on the storey being drawn has moved since the last draw.
|
||||
*
|
||||
* Split from `recordRobots` exactly as `cameraMoved` is split from
|
||||
* `recordCamera`, and compared exactly rather than with an epsilon for the
|
||||
* reason given there and one of its own: a robot eases into its destination
|
||||
* over the last 0.9 m, so its final frames are fractions of a millimetre, and
|
||||
* any tolerance worth having would strand the marker short of where the figure
|
||||
* in the scene is standing.
|
||||
*
|
||||
* **Only the storey being drawn counts.** A robot pacing about a mezzanine
|
||||
* nobody is looking at must not hold this widget open at thirty frames a second
|
||||
* for the whole session, drawing nothing, which is exactly what it would do if
|
||||
* this looked at all of them.
|
||||
*/
|
||||
function robotsMoved(): boolean {
|
||||
if (robotList.length === 0 || !level) return false;
|
||||
for (let i = 0; i < robotList.length; i++) {
|
||||
const robot = robotList[i];
|
||||
if (!robot || robot.levelId !== level.id) continue;
|
||||
if (robot.position.x !== robotLast[i * 2]) return true;
|
||||
if (robot.position.z !== robotLast[i * 2 + 1]) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Take the positions this draw is about to use, and turn the step since the
|
||||
* last one into a heading.
|
||||
*
|
||||
* Every robot and not only the visible ones, unlike `robotsMoved`. The
|
||||
* alternative is that a robot on another storey keeps whatever position it had
|
||||
* when that storey was last on screen, and the first frame after changing
|
||||
* floors derives its heading from a stride several metres long taken minutes
|
||||
* ago — a marker confidently pointing across the building. A handful of robots
|
||||
* is a handful of subtractions; being clever here would cost more to explain
|
||||
* than to skip.
|
||||
*
|
||||
* A zero step leaves the heading alone rather than clearing it. That is what
|
||||
* lets a robot that has stopped keep facing the way it arrived instead of
|
||||
* losing its nose every time it pauses for a few seconds, which is most of the
|
||||
* time — and the figure in the scene does exactly the same thing, because the
|
||||
* rig's yaw is not reset when it halts either.
|
||||
*/
|
||||
function recordRobots() {
|
||||
for (let i = 0; i < robotList.length; i++) {
|
||||
const robot = robotList[i];
|
||||
if (!robot) continue;
|
||||
const x = robot.position.x;
|
||||
const z = robot.position.z;
|
||||
// NaN on the first pass after `setRobots`, which is deliberate and is why
|
||||
// `robotLast` is filled with it: `NaN > 1e-6` is false, so the first draw
|
||||
// records a position and claims no heading from it.
|
||||
const dx = x - (robotLast[i * 2] ?? NaN);
|
||||
const dz = z - (robotLast[i * 2 + 1] ?? NaN);
|
||||
const step = Math.hypot(dx, dz);
|
||||
if (step > 1e-6) {
|
||||
robotDir[i * 2] = dx / step;
|
||||
robotDir[i * 2 + 1] = dz / step;
|
||||
}
|
||||
robotLast[i * 2] = x;
|
||||
robotLast[i * 2 + 1] = z;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Interaction ------------------------------------------------------------
|
||||
|
||||
/**
|
||||
@@ -1082,6 +1335,27 @@ export function createOfficeMinimap(options: OfficeMinimapOptions): OfficeMinima
|
||||
dirty = true;
|
||||
},
|
||||
|
||||
setRobots(next) {
|
||||
// In the intended wiring this is the same array object every time, so the
|
||||
// common path is a reference compare and a return. That is not a
|
||||
// micro-optimisation: marking the widget dirty on every call would defeat
|
||||
// the bail-out in `tick` outright and pin the panel at its full redraw rate
|
||||
// in an office where nothing whatsoever is moving.
|
||||
if (next === robotList) return;
|
||||
robotList = next;
|
||||
robotLast = new Float64Array(next.length * 2);
|
||||
// NaN, not the zero a fresh `Float64Array` comes with. Zero is a perfectly
|
||||
// ordinary coordinate — plenty of packs put the corner of a floor plate
|
||||
// near the origin — so a zeroed previous position makes the first step look
|
||||
// like a stride from the origin to wherever the robot actually is, and
|
||||
// every robot spends its first frame pointing away from the middle of the
|
||||
// building. NaN makes that first difference no difference at all, which is
|
||||
// the truth: nothing is known yet about where this machine came from.
|
||||
robotLast.fill(NaN);
|
||||
robotDir = new Float64Array(next.length * 2);
|
||||
dirty = true;
|
||||
},
|
||||
|
||||
tick() {
|
||||
if (!ready || !viewCtx) return;
|
||||
const now = performance.now();
|
||||
@@ -1097,10 +1371,16 @@ export function createOfficeMinimap(options: OfficeMinimapOptions): OfficeMinima
|
||||
renderStatic();
|
||||
dirty = true;
|
||||
}
|
||||
if (!dirty && pinging === 0 && !cameraMoved()) return;
|
||||
// `robotsMoved` last of the three, because it is the only one that walks a
|
||||
// list, and an office with no robots settles it on a length compare.
|
||||
if (!dirty && pinging === 0 && !cameraMoved() && !robotsMoved()) return;
|
||||
lastDraw = now;
|
||||
dirty = false;
|
||||
recordCamera();
|
||||
// Before `draw`, not after: the headings this frame's markers are turned by
|
||||
// are derived from the step that has just been taken, so recording after
|
||||
// drawing would render every robot one frame behind its own nose.
|
||||
recordRobots();
|
||||
draw(now);
|
||||
},
|
||||
|
||||
@@ -1120,6 +1400,11 @@ export function createOfficeMinimap(options: OfficeMinimapOptions): OfficeMinima
|
||||
ready = false;
|
||||
roomPaths = [];
|
||||
labels = [];
|
||||
// Back to the shared empty. The robot list is somebody else's live array
|
||||
// and it is the one thing this widget holds that outlives it — a disposed
|
||||
// panel keeping a reference to a disposed scene's robots is how a torn-down
|
||||
// office stays reachable from a DOM node nobody can see any more.
|
||||
robotList = NO_ROBOTS;
|
||||
canvas.remove();
|
||||
},
|
||||
};
|
||||
@@ -1152,6 +1437,8 @@ interface Theme {
|
||||
labelHalo: string;
|
||||
occupied: string;
|
||||
occupiedEdge: string;
|
||||
robot: string;
|
||||
robotEdge: string;
|
||||
frame: string;
|
||||
footprintFill: string;
|
||||
footprintStroke: string;
|
||||
@@ -1211,6 +1498,16 @@ function buildTheme(): Theme {
|
||||
// which is still this widget's first job.
|
||||
occupied: rgba(rgbOf(0x8ec3e8), 0.95),
|
||||
occupiedEdge: rgba(rgbOf(0x0a0d11), 0.7),
|
||||
// The only green on the plan, and the only mark on it that moves. The full
|
||||
// argument for a hue of its own rather than a second blue is at `drawRobots`,
|
||||
// and the short version is that the silhouette is what says "machine" and the
|
||||
// colour only has to stay out of the way of the people and of the camera.
|
||||
robot: rgba(rgbOf(0x5fd9a6), 0.95),
|
||||
// The ground colour behind it, exactly as an occupied desk gets. Written out
|
||||
// again rather than sharing `occupiedEdge`: the two are the same value today
|
||||
// and they are not the same decision, and a plan that changed how it rims its
|
||||
// people because somebody adjusted its robots would be a small mystery.
|
||||
robotEdge: rgba(rgbOf(0x0a0d11), 0.7),
|
||||
frame: rgba(rgbOf(0x9fb4c6), 0.3),
|
||||
// Faint, for the reason the city widget's is faint: on the whole-floor view
|
||||
// the footprint covers most of the widget, and a fill that is a hint over
|
||||
|
||||
Reference in New Issue
Block a user