From 51979feea07624f64c039c44df94e2f0ee7781c6 Mon Sep 17 00:00:00 2001 From: Kartios Date: Fri, 7 Aug 2026 02:50:25 -0700 Subject: [PATCH] Shadows land on the building, and the sky layers stop repeating themselves MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **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) --- src/assets/office/optimus.ts | 434 +++++++++++++++++++++---- src/engine/aircraftGeometry.ts | 94 ++++-- src/engine/clouds.ts | 78 ++++- src/engine/flights.ts | 25 +- src/engine/officeMinimap.ts | 299 ++++++++++++++++- src/engine/satellites.ts | 2 +- src/engine/scene.ts | 27 +- src/engine/scenekit.ts | 85 ++++- src/engine/starlinkMesh.ts | 207 ++++++++++-- src/interiors/officeScene.ts | 35 +- src/interiors/robots.ts | 56 +++- src/main.ts | 14 +- src/test/flights.test.ts | 570 +++++++++++++++++++++++++++++++++ 13 files changed, 1760 insertions(+), 166 deletions(-) create mode 100644 src/test/flights.test.ts diff --git a/src/assets/office/optimus.ts b/src/assets/office/optimus.ts index 89bf436..bd9aa7b 100644 --- a/src/assets/office/optimus.ts +++ b/src/assets/office/optimus.ts @@ -28,11 +28,21 @@ * thing turns into a mannequin. * 3. **The visor.** A smooth black panel filling the front of a small pale head, * with no features on it at all. Two eyes, a mouth line, a "friendly" curve — - * any of them and it stops being Optimus. + * any of them and it stops being Optimus. It has to *follow the head*: a flat + * plate on a rounded shell overhangs at its corners, and a face with the + * corners of its visor hanging in the air beside it is the single most + * conspicuous thing this figure can get wrong. See `emitHead`. * 4. **Pale shells over a dark frame.** Every limb is a light shell that stops * short of the joint, with dark structure showing in the gap. That gap is * what makes it read as a machine rather than as a white plastic doll, and it - * costs nothing but a few millimetres of geometry. + * costs nothing but a few millimetres of geometry. The ankle, knee, hip, + * wrist and elbow all have one; the shoulder has a dark drum showing past the + * edge of its cap instead, for a draw-call reason written out at the drum. + * Whatever else changes, do not let two pale parts meet each other — and do + * not let them merely *touch*, either. Every join in this file overlaps by a + * few millimetres, because parts that share a face z-fight along it and parts + * that stop at the same plane by arithmetic eventually stop a millimetre + * short instead. Both of those have happened here. * 5. The knee actuator, the shoulder caps, the five-fingered hands. Detail, not * silhouette. Present because they are cheap, not because they are load- * bearing. @@ -66,8 +76,9 @@ * what Optimus actually looks like — flat-sided shells with softened edges, * not tubes. * - **`frame` is drawn with `box()` and `cylinder()` and nothing else.** Both - * indexed. This is why the visor is a flat box and not a rounded one: it is - * drawn in the dark material, so it may not be an extrusion. + * indexed. This is why the visor is five flat boxes fitted round the head's + * corner rather than one moulded shell: it is drawn in the dark material, so + * it may not be an extrusion, so the curve has to be faceted by hand. * * Add a part and put it in the material whose primitive class it already * belongs to. If you cannot, you want the *other* material, and it will usually @@ -112,7 +123,13 @@ * * ### What it costs * - * Eighteen meshes and about 7,600 triangles per figure. `buildOptimus` is + * Eighteen meshes and about 7,100 triangles per figure. The mesh count is the + * one that matters and it is fixed by the eleven bins and the two materials; + * the triangles are almost all `roundedBox`, which is 236 of them whatever size + * it is drawn at and however tight its bevel. That flat rate is worth knowing + * before adding shell detail: a 14 mm finger and a 340 mm chest cost the same, + * which is why the fingers were a quarter of the figure until somebody counted + * (see `DIGITS`). `buildOptimus` is * meant to be called **once**; `cloneOptimus` gives you another figure sharing * every geometry and both materials, which is how four robots cost four times * the draw calls but one times the memory. Seventy-two draw calls for a crowd of @@ -289,9 +306,16 @@ function emitPelvis(bin: MeshBin, P: PartBin, s: Skin): void { // Straddles the hip line: down to 0.855 m and up to the waist at 1.05 m. The // width is what the slim waist above it is measured against. bin.add(P.roundedBox(0.07), s.shell, { y: -0.065, size: [0.28, 0.195, 0.19] }); - // The axle runs right through and out both sides, so there is dark structure - // visible in the gap between the pelvis shell and the top of each thigh. - barrel(bin, P, s.frame, { x: 0, y: 0, z: 0 }, 0.115, 2 * OPTIMUS.hipHalf + 0.05); + // The axle has to be longer than the pelvis is wide or none of it is visible, + // and for a long time none of it was: at `2·hipHalf + 0.05` it stopped at + // ±0.11 inside a shell that reaches ±0.14, and the comment on this line + // claimed it "runs right through and out both sides" on the strength of + // nobody having measured it. `+ 0.14` puts it at ±0.155: 15 mm of dark disc + // clear of the pelvis and 2.5 mm clear of the outer face of each thigh, which + // is the gap rule 4 in the header is about and the one the hip did not have. + // Shorten it again and the hip goes back to one unbroken pale mass from the + // waist to the knee, which is what a mannequin looks like. + barrel(bin, P, s.frame, { x: 0, y: 0, z: 0 }, 0.115, 2 * OPTIMUS.hipHalf + 0.14); } /** @@ -340,40 +364,205 @@ function emitTorso(bin: MeshBin, P: PartBin, s: Skin): void { y: shoulderY - 0.078, size: [0.13, 0.156, 0.15], }); - // The ring the arm turns in, poking out beyond the cap. - barrel(bin, P, s.frame, { x: side * OPTIMUS.shoulderHalf, y: shoulderY, z: 0 }, 0.092, 0.15); + // The drum the arm turns in. 0.135 across and 0.17 long against a cap that + // is 0.13 wide, 0.156 tall and 0.15 deep, and every one of those numbers is + // chosen against the cap's: the drum is buried inside the cap in Y and Z + // and clears it only in X, so what shows is 20 mm of dark disc past the + // outboard face of the cap and, inboard, a nub of the same 20 mm in the + // 37 mm between the top of the chest and the top of the drum. The nub is + // wanted — it is the only thing between the neck and the shoulder — but it + // is the reason the drum's diameter is not free: take it past 0.16 and it + // breaks out under the cap as well, and the shoulder turns into a dark + // wheel with a pale plate stuck on it. + // + // That disc is the *only* dark structure at the shoulder, and it is here + // rather than on the arm for a reason worth stating, because the obvious + // fix looks better and costs more than it is worth. Rule 4 in the header + // wants a pale shell stopping short of the joint with dark showing in the + // gap; the knee and the elbow both do it. Doing it at the shoulder means a + // dark part in the *shoulder* bin, which today draws in `paper` alone — so + // it would go from one mesh to two, twice, on every figure: 20 meshes a + // robot and 80 draw calls for a crowd of four instead of 72. Eight draw + // calls for a 24 mm band of dark under a cap that already reads as a + // separate piece is not the trade. A fatter drum is free. + barrel(bin, P, s.frame, { x: side * OPTIMUS.shoulderHalf, y: shoulderY, z: 0 }, 0.135, 0.17); } } +/** + * The head shell's front-right corner, as fractions of its half-width and + * half-depth, walking round from the middle of the face toward the temple. + * + * These are **measured off the built shell**, by casting rays at it, not + * derived. They have to be, because the surface they describe is the offset + * outline of a quadratic-Bezier corner that `ExtrudeGeometry` then bevels, and + * writing that arithmetic out here would be a worse lie than a measurement: + * it would look authoritative and it would be a re-implementation of three.js + * internals. The visor is fitted to *these* numbers, so `x: 1` really is the + * widest point of the head and `z: 1` really is the flat front of the face. + * + * **They are only valid for `roundedBox(0.08)`.** The radius is what sets where + * the flat front stops — at 0.08 it stops 53% of the way out, and the corner + * from there to the temple is a four-segment polyline, of which the visor uses + * the first two. Change the head's bevel and these are wrong, and the symptom + * is a visor whose outer corners hang in the air beside the head rather than + * an error. Re-measure it: cast a ray down +Z at the shell at a series of x, + * at a y inside the shell's straight middle band, and read off where it hits. + * (An earlier visor was three hand-placed boxes and its outer pieces stood + * 19 mm proud of a 165 mm-wide head, which is what that failure looks like.) + * + * Scaling with the head rather than in metres so that the two survive being + * resized together; they do **not** survive being reproportioned, because a + * corner is not similar to itself under a non-uniform scale. + */ +const FACE_FLAT = { x: 0.5312, z: 1.0 } as const; +const FACE_MID = { x: 0.7146, z: 0.9738 } as const; +const FACE_TEMPLE = { x: 0.8766, z: 0.8766 } as const; + +/** Head shell, metres. Named because the visor is fitted to all three. */ +const HEAD_SIZE = { width: 0.165, height: 0.215, depth: 0.185 } as const; + +/** + * How far the visor's outer face stands off the shell it is fitted to, and how + * thick the panel is. + * + * 1.5 mm is deliberately below anything that can resolve — at ten metres it is + * a seventh of a pixel — and it is not there to be seen. It is there so the + * sign can never go the other way. A visor flush with the shell z-fights it + * along the whole seam; a visor a millimetre *inside* it disappears in patches + * as the figure turns, which reads as a face flickering on and off and is by + * some distance the ugliest thing this asset has ever done. + */ +const VISOR_PROUD = 0.0015; +const VISOR_THICK = 0.016; + +/** + * One panel of the visor, laid flat on the chord between two points of the + * head's front corner and pushed out until its outer face clears the shell. + * + * `from` and `to` are `FACE_*` fractions on the robot's right; `side` mirrors + * the whole thing for the left. Two things in here are easy to get wrong and + * neither of them shows up as an error: + * + * - **The chord is the surface, not an approximation of it.** The shell's + * corner really is a polyline in this band — it is an extruded polygon, not + * a curve — so a panel laid on one of its edges is parallel to the shell for + * that edge's whole length and stands off it by exactly `VISOR_PROUD`. This + * is the entire reason the corner is stored as measured points instead of as + * a radius: fit an arc to it instead and the panel touches at two places and + * sinks in between, which is the flickering failure `VISOR_PROUD` describes. + * - **The box is pushed *in* by half its thickness, not out.** A box's front is + * its −Z face, so its centre has to sit `VISOR_THICK / 2` behind where the + * front is wanted. Add instead of subtract and the panel floats 16 mm off the + * head — which is very close to what the version before this one did. + * + * `seam` lengthens the panel at its inner end only. The inner end runs under + * the piece before it, which is what stops a hairline of shell showing through + * the joint between two panels at a grazing angle; the outer end is left exact, + * because past it the shell falls away and any overhang there is a ledge on the + * one edge of the visor anybody can see. + */ +function emitVisorPanel( + bin: MeshBin, + P: PartBin, + s: Skin, + side: number, + from: { x: number; z: number }, + to: { x: number; z: number }, + visorY: number, + visorHeight: number, + seam: number, +): void { + const halfX = HEAD_SIZE.width / 2; + const halfZ = HEAD_SIZE.depth / 2; + const x0 = from.x * halfX; + const z0 = -from.z * halfZ; + const x1 = to.x * halfX; + const z1 = -to.z * halfZ; + + const dx = x1 - x0; + const dz = z1 - z0; + const span = Math.hypot(dx, dz); + // Outward normal of the chord: rotate its direction a quarter turn so it + // points away from the head. On the robot's right that is front-and-right. + const nx = dz / span; + const nz = -dx / span; + // Midpoint, dragged back along the chord by half the seam so the panel grows + // inward only, then offset along the normal to put its front face proud. + const offset = VISOR_PROUD - VISOR_THICK / 2; + const cx = (x0 + x1) / 2 - (dx / span) * (seam / 2) + nx * offset; + const cz = (z0 + z1) / 2 - (dz / span) * (seam / 2) + nz * offset; + + bin.add(P.box(), s.frame, { + x: side * cx, + y: visorY, + z: cz, + size: [span + seam, visorHeight, VISOR_THICK], + // A box's front is −Z, which a yaw of θ sends to (−sin θ, −cos θ); solving + // that for the normal is the whole of this line. Mirrored for the left, + // because a mirrored normal is a negated yaw. + yaw: side * Math.atan2(-nx, -nz), + }); +} + /** * The head: a small pale shell and a black visor. Drawn in the head frame, whose * origin is the top of the neck. * - * The visor is a plain `box` because the dark material is the indexed one — see - * the primitive-class rule in the header, which is why the one part of this - * figure most deserving of a soft edge does not get one. + * The visor is five plain `box`es because the dark material is the indexed one + * — see the primitive-class rule in the header, which is why the one part of + * this figure most deserving of a smooth curve is a faceted approximation of + * one. A flat plate across the middle of the face, then two panels a side + * following the shell's corner out to the temple, each sitting `VISOR_PROUD` + * off the surface it is laid on. Five boxes is 60 triangles; the head shell + * beside them is 236. * - * It sits about 3 mm *into* the flat middle of the face and about 1.5 mm *proud* - * of the curve at the edges, because the shell's corners round away in Z while - * the visor stays flat. That is not a compromise, it is the effect: a panel let - * into a face and wrapping round the sides of it. Sunk flush all the way across - * — which was the first version — it reads as a black rectangle painted on. The - * two small yawed side pieces finish the wrap. + * **The visor tracks the shell, it does not overhang it.** That is the property + * to preserve if anything here is touched, and the reason the corner is stored + * as measured points at all. The version before this one was three hand-placed + * boxes with a hand-picked yaw: its flat middle plate stood 3.7 mm proud at the + * corners where it left the flat of the face, and its two side pieces stood + * **19 mm** proud of a head 165 mm wide, which is a face wearing goggles a size + * too big. The comment beside them said 1.5 mm, and had said so since before + * the shell's bevel was retuned from 0.12–0.22 to 0.055–0.09 and moved every + * number it was describing. Measurements that are not re-taken are guesses with + * a decimal point on them. + * + * At five to fifteen metres a robot's head is fifteen to forty-five pixels + * tall. Nothing about the panel's *surface* survives that, and the only thing + * that does is its outline against the pale shell. So the outline is the thing + * that is built accurately and the rest is not built at all. + * + * It wraps as far as `FACE_TEMPLE` and stops, leaving about 10 mm of pale shell + * either side in a front view. Wrapping further gets a black head; stopping at + * the flat front gets a rectangle painted on a box. */ function emitHead(bin: MeshBin, P: PartBin, s: Skin): void { - const base = OPTIMUS.height - OPTIMUS.headY - 0.215; - bin.add(P.roundedBox(0.08), s.shell, { y: base, size: [0.165, 0.215, 0.185] }); + const base = OPTIMUS.height - OPTIMUS.headY - HEAD_SIZE.height; + bin.add(P.roundedBox(0.08), s.shell, { + y: base, + size: [HEAD_SIZE.width, HEAD_SIZE.height, HEAD_SIZE.depth], + }); + + // Kept inside the shell's straight middle band — `roundedBox(0.08)` rounds + // the top and bottom 17 mm of a 215 mm head away in Y, and `FACE_*` describes + // the cross-section between those. Push the visor into either and it starts + // sinking into a surface that is no longer where the measurements say. + const visorY = base + 0.078; + const visorHeight = 0.082; + const seam = 0.005; + + // The flat middle of the face, stopping exactly where the shell stops being + // flat so the first wrap panel meets it edge to edge rather than crossing it. + bin.add(P.box(), s.frame, { + y: visorY, + z: -HEAD_SIZE.depth / 2 - VISOR_PROUD + VISOR_THICK / 2, + size: [FACE_FLAT.x * HEAD_SIZE.width, visorHeight, VISOR_THICK], + }); - const visorY = base + 0.082; - bin.add(P.box(), s.frame, { y: visorY, z: -0.079, size: [0.132, 0.075, 0.02] }); for (const side of [-1, 1]) { - bin.add(P.box(), s.frame, { - x: side * 0.072, - y: visorY, - z: -0.062, - size: [0.05, 0.072, 0.018], - yaw: side * 0.62, - }); + emitVisorPanel(bin, P, s, side, FACE_FLAT, FACE_MID, visorY, visorHeight, seam); + emitVisorPanel(bin, P, s, side, FACE_MID, FACE_TEMPLE, visorY, visorHeight, seam); } } @@ -392,41 +581,112 @@ function emitThigh(bin: MeshBin, P: PartBin, s: Skin): void { * The actuator is the one piece of detail on the legs that is worth its * geometry: a dark barrel across the front of the knee is the single most * recognisable thing about an Optimus leg, and it is one cylinder. + * + * ### The ankle is built from the foot upward, and that is not a style choice + * + * Every part below the knee used to be placed off `drop`, and the ankle block + * came out at 0.090–0.120 m above the floor while the foot shell ended at + * 0.066 — a 24 mm band of nothing, with the whole leg's weight visibly resting + * on air, and `OPTIMUS.ankleY` at 0.085 sitting in the middle of the void it is + * supposed to name. `robots.ts` measures the entire gait pendulum to that + * landmark, so the one height on this figure that had to have geometry at it + * was the one that had none. + * + * So the ankle is anchored to the top of the foot, which is a surface, rather + * than to a landmark, which is a number. It reaches 7 mm down into the foot + * shell and 13 mm up into the shin shell — overlapping at both ends on purpose, + * because two parts that merely touch share a face and z-fight along it, and a + * flickering seam is worse than a joint that is a few millimetres thicker than + * it needs to be. What is left visible is 39 mm of dark between a pale foot and + * a pale shin, which is rule 4 in the header and is what an ankle is for. */ function emitShin(bin: MeshBin, P: PartBin, s: Skin): void { const drop = OPTIMUS.kneeY - OPTIMUS.ankleY; barrel(bin, P, s.frame, { x: 0, y: 0, z: -0.012 }, 0.118, 0.125); bin.add(P.roundedBox(0.065), s.shell, { y: -drop + 0.02, size: [0.1, drop - 0.05, 0.118] }); - // The ankle, dark, in the gap the shin shell leaves above the foot. - bin.add(P.box(), s.frame, { y: -drop + 0.005, size: [0.072, 0.03, 0.085] }); // The foot. The sole is a separate dark slab so the robot has something to // stand on that is not the same colour as its shins — a monochrome foot // dissolves into a pale floor. const sole = -OPTIMUS.kneeY; + const footTop = sole + 0.066; bin.add(P.box(), s.frame, { y: sole, z: -0.035, size: [0.098, 0.014, 0.25] }); bin.add(P.roundedBox(0.06), s.shell, { y: sole + 0.014, z: -0.035, size: [0.106, 0.052, 0.243], }); -} -/** An upper arm. Drawn in the shoulder frame; the cap is on the torso. */ -function emitUpperArm(bin: MeshBin, P: PartBin, s: Skin): void { - const drop = OPTIMUS.shoulderY - OPTIMUS.elbowY; - bin.add(P.roundedBox(0.07), s.shell, { y: -drop + 0.048, size: [0.088, drop - 0.098, 0.098] }); + // The ankle, dark, filling everything between the two of them. Narrower than + // both in X and Z, so the gap reads as a joint rather than as a change of + // colour partway up a column — but only just. It is 12 mm narrower than the + // shin and 18 mm narrower than the foot, and both of those were 26 and 32 at + // one point, which left a 16 mm slot each side of the ankle that you could + // see the floor through. Narrow enough to read as a waisted joint, wide + // enough not to be a window: the two constraints point opposite ways and + // this is where they meet. + bin.add(P.box(), s.frame, { y: footTop - 0.007, size: [0.088, 0.059, 0.096] }); } /** - * Forearm, wrist and a five-fingered hand. Drawn in the elbow frame. + * An upper arm. Drawn in the shoulder frame; the cap and the joint drum are + * both on the torso, for the reasons given at each of them. * - * The fingers are pale rather than dark, with only the knuckle bar in the frame - * material. At ten metres a hand reads from its outline, so what matters is that - * there are five of something and that they are separate — not what colour the - * gaps between them are, which is a decision worth about forty triangles of - * detail nobody will ever resolve. + * One shell, and it reaches 17 mm *into* the elbow barrel below it rather than + * stopping level with it. At `-drop + 0.048` the shell ended at 1.168 m and the + * barrel reached 1.167 — a one-millimetre ring of daylight all the way round + * the elbow, which does not read as a gap at any distance but does catch the + * background for a frame at a time as the arm swings, and chasing that kind of + * flicker back to a millimetre of arithmetic costs an afternoon. + */ +function emitUpperArm(bin: MeshBin, P: PartBin, s: Skin): void { + const drop = OPTIMUS.shoulderY - OPTIMUS.elbowY; + bin.add(P.roundedBox(0.07), s.shell, { y: -drop + 0.03, size: [0.088, drop - 0.08, 0.098] }); +} + +/** + * The digits of one hand, medial first. `medial` is the offset **toward the + * body**, not an X coordinate, so this table is the same for both hands and the + * one sign flip that makes a left hand a left hand happens where it is placed + * — the same discipline the thumb has always had, extended to the fingers so + * that the two cannot disagree about which way is inward. + * + * ### Three, not four, and why the count is the wrong question + * + * The previous version drew four identical 14 mm slabs in a row with 3 mm + * between them, on the argument that what a hand needs at ten metres is five of + * something, separate. Half of that is right and the expensive half is not. At + * ten metres this figure is about 170 pixels tall, so a 14 mm finger is 1.4 of + * them and a 3 mm gap is a third of one: **the separations were never once + * resolved on screen.** What is resolved is the shape the tips make, and four + * equal slabs make a straight line across the bottom of the hand — a comb, not + * a hand. So the gaps are not what the geometry is for; the *stagger* is. + * + * Three digits of different lengths give the curved tip line a hand has, and + * cost three of these rather than four. That matters more than it looks: + * `roundedBox` is 236 triangles whatever size it is drawn at, so the four + * fingers alone were 944 triangles a hand and the two hands together were a + * quarter of the entire figure — a quarter of the budget spent below the + * resolution limit. Dropping one digit a hand is 6% off the whole robot. + * + * `curl` is a rotation about +X applied at the *tip*, because a placement + * rotates a part about its own base and these are placed tip-down. The base is + * therefore solved for: it is the knuckle, less the rotated length. Getting + * that backwards pivots each finger about its knuckle-end and fans the hand out + * like a card trick. + */ +const DIGITS = [ + { medial: 0.022, width: 0.016, depth: 0.024, length: 0.068, curl: 0.26 }, + { medial: 0.002, width: 0.018, depth: 0.025, length: 0.076, curl: 0.3 }, + { medial: -0.021, width: 0.024, depth: 0.024, length: 0.064, curl: 0.34 }, +]; + +/** + * Forearm, wrist and hand. Drawn in the elbow frame. + * + * The digits are pale rather than dark, with only the wrist block in the frame + * material — see `DIGITS` for what a hand is actually made of at this distance. * * This is the one emitter that has to know which side it is on, because a thumb * is the only part of the figure that is not left-right symmetric. It goes @@ -434,29 +694,54 @@ function emitUpperArm(bin: MeshBin, P: PartBin, s: Skin): void { * means `side` flips its sign. Drawing both thumbs at a fixed `+x`, as the first * version did, gives a robot with two right hands and a bounding box 27 mm wider * on one side than the other. + * + * **Nothing here butts up against anything.** The wrist block straddles + * `OPTIMUS.wristY` and overlaps the forearm shell by 6 mm; the palm reaches + * 6 mm up into the wrist block; the digits reach 6 mm up into the palm. All + * three of those were exact abutments and two of them were exactly coplanar, + * which is a z-fight rather than a join — and the third was not an abutment at + * all but an 8 mm hole between the bottom of the forearm shell at 0.885 m and + * the top of the wrist block at 0.877, straight through the arm. Overlap costs + * nothing: these are merged into one buffer and the interior faces are never + * seen from outside. */ function emitForearm(bin: MeshBin, P: PartBin, s: Skin, side: number): void { const drop = OPTIMUS.elbowY - OPTIMUS.wristY; barrel(bin, P, s.frame, { x: 0, y: 0, z: 0 }, 0.094, 0.088); bin.add(P.roundedBox(0.07), s.shell, { y: -drop + 0.02, size: [0.08, drop - 0.055, 0.088] }); - bin.add(P.box(), s.frame, { y: -drop - 0.012, size: [0.062, 0.024, 0.07] }); + // Wider than the palm below it and narrower than the forearm above, in that + // order. At 0.062 it was narrower than both, which put a 6 mm slot of + // daylight down each side of the wrist between two parts that are meant to + // be joined; a joint that reads as a taper cannot do that. + bin.add(P.box(), s.frame, { y: -drop - 0.016, size: [0.072, 0.042, 0.07] }); - // Palm, then four fingers and a thumb set off to the side and turned in. - const palmTop = -drop - 0.012; - bin.add(P.roundedBox(0.075), s.shell, { y: palmTop - 0.078, size: [0.068, 0.078, 0.032] }); - for (let i = 0; i < 4; i++) { + const palmTop = -drop - 0.01; + bin.add(P.roundedBox(0.075), s.shell, { y: palmTop - 0.082, size: [0.068, 0.082, 0.032] }); + + const knuckle = palmTop - 0.076; + for (const digit of DIGITS) { bin.add(P.roundedBox(0.08), s.shell, { - x: (i - 1.5) * 0.017, - y: palmTop - 0.148, - size: [0.014, 0.07, 0.024], + x: -side * digit.medial, + y: knuckle - digit.length * Math.cos(digit.curl), + z: -digit.length * Math.sin(digit.curl), + size: [digit.width, digit.length, digit.depth], + pitch: digit.curl, }); } + + // The thumb, also placed tip-first, and the only part of the figure that + // needs two rotations: it runs up and *outward* from a tip set 12 mm medial + // of the palm's edge, and forward of the palm's face, so the pad ends up + // opposing the fingers rather than lying alongside them. The old one was a + // single roll about its own tip, which swung the knuckle 23 mm clear of the + // hand and left the thumb attached to the palm at one corner. bin.add(P.roundedBox(0.08), s.shell, { - x: -side * 0.03, - y: palmTop - 0.088, - z: -0.02, - size: [0.017, 0.056, 0.026], - roll: side * 0.5, + x: -side * 0.046, + y: palmTop - 0.078, + z: -0.03, + size: [0.019, 0.058, 0.026], + pitch: 0.33, + roll: -side * 0.28, }); } @@ -558,12 +843,27 @@ export function buildOptimus(ctx: AssetContext): OptimusRig { /** * Resolve the joints of a rig root by name. * - * Exported because `Object3D.clone(true)` copies names but hands back plain - * `Object3D`s with no idea which of them is a knee, so a clone has to be - * re-resolved. Throws rather than returning null: a root with no `kneeL` in it - * is not a rig, and the caller has nothing useful to do about that at runtime. + * This used to be exported, on the stated grounds that `Object3D.clone(true)` + * "hands back plain `Object3D`s" and so a caller holding a clone would need to + * re-resolve it. **That is not true of three.js and never was.** `clone` is + * `new this.constructor().copy(this, recursive)` (`three/src/core/Object3D.js`), + * so cloning a `Group` gives a `Group`, and a cloned rig is structurally + * identical to the original down to the class of every node. Nothing outside + * this file ever called it — `robots.ts` takes its joints from the `OptimusRig` + * that `cloneOptimus` hands back — so the export was justified by a wrong claim + * and used by nobody, and it is now private. + * + * The function itself is still needed, for the reason the false one was + * standing in front of: `clone` copies the *tree*, and `OptimusJoints` is a + * flat record of references **into** the original tree. Those references are + * not part of the tree and are not cloned with it, so a clone's joints have to + * be found again, and the names are the only thing that survives the copy to + * find them by. + * + * Throws rather than returning null: a root with no `kneeL` in it is not a rig, + * and the caller has nothing useful to do about that at runtime. */ -export function optimusJoints(root: THREE.Object3D): OptimusJoints { +function optimusJoints(root: THREE.Object3D): OptimusJoints { const found = {} as Record<(typeof JOINT_NAMES)[number], THREE.Group>; for (const name of JOINT_NAMES) { const object = root.getObjectByName(name); @@ -692,10 +992,14 @@ export const robotOptimus = defineAsset({ footprint(p) { const scale = p.height / OPTIMUS.height; - // Measured off the built figure rather than guessed: 0.534 m across the - // splayed hands and 0.279 m from toe to heel, rounded up. The arms are the - // widest part of a standing humanoid and the feet are the deepest — not the - // shoulders and not the chest, which is what you would reach for. + // Measured off the built figure rather than guessed, and measured off the + // *baked vertices* rather than off `Box3.setFromObject`, which inflates the + // box of anything rotated and had this at 0.534 for a while: 0.520 m across + // and 0.279 m from toe to heel, rounded up. The feet are the deepest part, + // not the chest, which is what you would reach for. The width is a near tie + // between the shoulder drums at 0.520 and the hands at 0.516 — the arms of + // a standing humanoid are as wide as its shoulders, which is the other + // thing you would get wrong — so widening either one moves this number. return { width: 0.55 * scale, depth: 0.3 * scale, height: p.height, clearance: 0.35 }; }, diff --git a/src/engine/aircraftGeometry.ts b/src/engine/aircraftGeometry.ts index cf018c7..d5e4e46 100644 --- a/src/engine/aircraftGeometry.ts +++ b/src/engine/aircraftGeometry.ts @@ -1,13 +1,20 @@ /** * The aircraft over the city, as an aircraft. * - * `flights.ts` has drawn traffic as a dart since the layer existed — a five-sided - * cone with a crossbar for a wing — and the dart was the right first answer, - * because the only thing a speck over a city has to communicate is *which way it - * is going*. It is the wrong last answer for one reason: the sky is the part of - * this scene a person looks at on purpose. Buildings do not move. A dozen darts - * crossing a coastline at three altitudes are the only thing on the board with - * anything happening to it, and they are worth more than eleven triangles. + * `flights.ts` drew traffic as a dart from the day the layer existed until this + * file replaced it — a five-sided capped cone with a box for a wing and a smaller + * one for a tailplane, 34 triangles the shape of an arrowhead — and the dart was + * the right first answer, because the only thing a speck over a city has to + * communicate is *which way it is going*. It is the wrong last answer for one + * reason: the sky is the part of this scene a person looks at on purpose. + * Buildings do not move. A dozen darts crossing a coastline at three altitudes + * are the only thing on the board with anything happening to it, and they are + * worth more than an arrowhead. + * + * (`dartGeometry` is gone from `flights.ts` rather than kept as an option — this + * file is the only aircraft on the board now. Its numbers are quoted below from + * the commit that removed it, because a ledger measured against nothing is not a + * ledger.) * * So: a swept-wing airliner, seen from where it is actually seen from. * @@ -86,9 +93,18 @@ * engine pylons 2 × 4 * nacelles 2 × 24 6 sides, both ends capped * - * Nine times the dart's eleven, at 400 aircraft is 40k triangles — under one - * frame's worth of the terrain mesh, and the vertex work is not what is - * expensive about 400 objects anyway. The parts that were considered and cut for + * That is **2.9 times the dart**, not the order of magnitude an eyeballed + * comparison of the two source files suggests. The dart's 34 are easy to + * undercount because two thirds of them are in parts nobody thinks of as + * geometry: `ConeGeometry(0.09, 0.42, 5)` is 5 side triangles *and* a 5-triangle + * cap, and each of its two crossbars is a `BoxGeometry`, which is 12 triangles + * whatever size it is drawn at — a 0.44 × 0.016 crossbar spends eight of its + * twelve on four edge-on slivers nobody would think to count. 10 + 12 + 12 is + * how a "five-sided cone with a crossbar" comes to 34. + * + * At 400 aircraft the 100 comes to 40k triangles — under one frame's worth of the + * terrain mesh, and the vertex work is not what is expensive about 400 objects + * anyway. The parts that were considered and cut for * costing more than they show: winglets (edge-on from the only angle that * matters), an engine fan face (a 0.03-unit disc), windows and a livery stripe * (they need vertex colours or a texture, and the material here is a shared @@ -177,11 +193,24 @@ const TAIL_UPSWEEP = 0.09; * picks exactly one of them for any viewpoint: from above the top face is * front-facing and the bottom is culled, from below the reverse. They are * never both rasterised, so there is nothing to fight. - * - A `DoubleSide` material would light the underside with the *upper* normal - * (three.js flips it for backfaces, but only in the shader, and only for the - * lighting term — which then makes the belly of the wing exactly as bright as - * the sunlit top). Two real faces with two real normals give a dark - * underside, which is what an aeroplane looks like. + * - It is not that `DoubleSide` would shade the belly wrongly. It would not: + * `normal_fragment_begin` multiplies the interpolated normal by + * `gl_FrontFacing ? 1.0 : -1.0` before the lighting runs, so a single sheet + * under a `DoubleSide` material gets a genuinely downward normal on its + * underside and comes out dark, the same as the second face here does. That + * argument used to be written the other way round in this comment and it was + * simply false; anyone testing it would have found the flag works and + * deleted 14 triangles for the wrong reason. + * - The real objection is *whose flag it is*. `side` lives on the material, the + * material belongs to `flights.ts`, and there is one of them per altitude + * band shared across the wings, the fuselage and the nacelles. Turning + * culling off to save seven sheets their second face also turns it off for + * three closed bodies of revolution, whose interiors are then rasterised on + * every aircraft on the board for nothing — and it makes a geometry that is + * only correct under one particular material, which is the sort of coupling + * that survives right up until somebody reuses this shape somewhere else. + * Seven extra pairs of triangles buy a mesh that is right under any material + * anybody points at it. * * The corners are given in order round the polygon and must be **planar** — * every quad in this file is, because each one's y varies linearly with x @@ -193,10 +222,10 @@ const TAIL_UPSWEEP = 0.09; * which is what makes mirroring a wing safe. * * UVs are emitted, and nothing samples them. They are here because - * `mergeGeometries` refuses — returns `null`, silently, for the whole aircraft — - * if the geometries handed to it do not all carry the *same set* of attributes. - * `ConeGeometry` and `CylinderGeometry` bring position, normal and uv, so these - * must too. + * `mergeGeometries` refuses — `console.error`s the offending index and returns + * `null` for the whole aircraft — if the geometries handed to it do not all + * carry the *same set* of attributes. `ConeGeometry` and `CylinderGeometry` + * bring position, normal and uv, so these must too. */ type Point = readonly [number, number, number]; type Quad = readonly [Point, Point, Point, Point]; @@ -460,12 +489,29 @@ export function airlinerGeometry(): THREE.BufferGeometry { * `mergeGeometries` returns null when its inputs disagree — a different set of * attributes, or some indexed and some not. Everything here is built to agree * (see `aerofoil`), so this is unreachable until somebody adds a part and - * forgets a uv, at which point they get an aeroplane-shaped nothing on every - * board and no error anywhere. A plain cone is a bad aeroplane and a much - * better failure: it still points where the aircraft is going, which is the - * one thing this layer exists to say. + * forgets a uv — and they will not be left guessing when they do. Three's + * merge `console.error`s the index of the geometry it choked on and names the + * attribute that is missing, which is most of a fix; what it does not do is + * throw. The failure arrives as a `null` this function's own signature does not + * allow it to pass on, so this branch is what makes that signature true, and + * without it the alternative is not an exception but an empty sky over a + * console nobody has open. + * + * So: a plain cone, which is a bad aeroplane and a much better failure. It + * still points where the aircraft is going, which is the one thing this layer + * exists to say. + * + * **0.09 rather than `RADIUS`.** The cone stands in for the whole aircraft and + * not for its fuselage, so the fuselage radius is exactly the wrong number to + * reach for: at `RADIUS` this is a 0.42-long, 0.06-wide needle where the shape + * it replaces was 0.44 across the wings, and at a board span out a needle is + * sub-pixel in every direction but one, i.e. gone. 0.09 is what the dart's own + * fallback used and it is the width the *silhouette* needs — 0.18 across + * against 0.42 long, the same arrowhead proportion the dart had. A degraded + * aeroplane has to still be findable, or the degradation is indistinguishable + * from the failure it is covering for. */ - const fallback = new THREE.ConeGeometry(RADIUS, NOSE_TIP_Z - TAIL_TIP_Z, SIDES); + const fallback = new THREE.ConeGeometry(0.09, NOSE_TIP_Z - TAIL_TIP_Z, SIDES); fallback.rotateX(Math.PI / 2); fallback.name = "airliner:fallback"; return fallback; diff --git a/src/engine/clouds.ts b/src/engine/clouds.ts index 5fe560c..c55847f 100644 --- a/src/engine/clouds.ts +++ b/src/engine/clouds.ts @@ -299,15 +299,65 @@ const PUFF_GROWTH_CLEAR = 0.7; const PUFF_GROWTH_OVERCAST = 1.75; /** - * Hemisphere luminance taken as "full daylight" when normalising the deck's own - * brightness. + * Hemisphere luminance times hemisphere intensity, taken as "full daylight" when + * normalising the deck's own brightness. * - * `atmosphere.ts`'s noon rig is `hemiSky 0xdcecf7` at intensity 1.05, whose - * Rec.709 luminance in the linear working space is 0.88. Overcast noon lands - * fractionally above it and gets clamped, which is the intended behaviour: the - * top of an overcast deck at midday is as bright as anything ever gets. + * The number is the product `setLighting` actually divides by, evaluated on the + * brightest rig `atmosphere.ts` can hand over: its last keyframe, `elevation: + * 65`, which is `hemiSky 0xe6f2fb` at `hemiIntensity 1.10`. `setHex` reads that + * literal as sRGB and converts it into the linear working space — three's + * `ColorManagement` is on and nothing in this repo turns it off — giving Rec.709 + * luminance 0.873, and 0.873 × 1.10 = 0.960. + * + * It has to be the *top* of the keyframe run and not a stop partway up it. This + * read 0.88, which is near the `elevation: 25` stop (0xdcecf7 at 1.05, product + * 0.860) and below every rig above about 30° of sun — so the entire middle of + * every day divided out to `day = 1` and rendered its cloud tops at one + * brightness, with the sun's own climb surviving only in `uKey`'s modelling + * term. Taking the reference off the highest stop puts the clamp where the + * clamp belongs: at the brightest light the sky ever has. + * + * Overcast noon still lands above it and still gets clamped, which is the + * intended behaviour rather than a rounding accident — `applyCloud` lifts + * `hemiIntensity` by 18% at full cover, so the same rig arrives at 1.13 of this + * — and the top of an overcast deck at midday is as bright as anything ever + * gets. Note that the lift is the only way over the line now: a clear high sun + * lands exactly on it, which is what "full daylight" was always supposed to mean. */ -const DAY_REFERENCE = 0.88; +const DAY_REFERENCE = 0.96; + +/** + * The per-puff breath: how fast it runs, in radians a second, and the period + * `uTime` is wrapped to so that it can keep running. + * + * `uTime` was the raw elapsed-seconds accumulator, which is correct for an + * afternoon and wrong for a deployment. It is uploaded into a `float` uniform, + * and float32 near 1e6 — eleven and a half days of uptime, which a page left + * open on a wall display reaches without anybody meaning it to — has an ulp of + * 0.0625 s. The breath advances `BOIL_RATE` radians a second, 0.0035 of them in + * a 60 Hz frame, so past that point the argument of the sine can only change + * every fourth or fifth frame: a smooth swell turns into a staircase, on every + * puff in the sky at once, and nothing in the code looks any different. It is + * the same loss of precision the drift accumulators in `tick` are already + * wrapped against, arriving through the one door that was left open. + * + * The wrap has to be a whole number of cycles or it trades a slow quantisation + * for a visible jump once a period, so the period is exactly 2π/rate — one + * cycle, 29.9 s — and `sin` is continuous across it by construction rather than + * by tuning. + * + * The rate is interpolated into the vertex shader from this constant rather than + * written out in both places, because the two agreeing is the *only* reason the + * wrap is invisible: change the literal in the shader alone and every puff in + * the sky steps by up to a seventh of its own radius, together, once every + * period — which is a great deal more noticeable than the staircase this was + * fixing. `toFixed` rather than plain + * interpolation because GLSL ES has no implicit int-to-float conversion, so a + * rate that happened to be integral would emit `1` and fail to compile the + * program — at which point the whole layer is gone, not just its breath. + */ +const BOIL_RATE = 0.21; +const BOIL_PERIOD = (Math.PI * 2) / BOIL_RATE; /** Edge of the puff texture, in texels. Only the alpha channel carries anything. */ const PUFF_TEXTURE_SIZE = 256; @@ -579,6 +629,7 @@ export function createCloudLayer(world: World, options: CloudLayerOptions = {}): let targetCover = 0; let cover = 0; let visible = true; + /** Feeds `uTime` and nothing else, which is why `tick` may wrap it freely. */ let elapsed = 0; let windKph = DEFAULT_WIND_KPH; @@ -807,7 +858,11 @@ export function createCloudLayer(world: World, options: CloudLayerOptions = {}): }, tick(dt) { - elapsed += dt; + // Wrapped on the breath's own period, so the uniform stays small enough to + // keep its precision in float32 and the sine does not notice. See + // `BOIL_PERIOD`; `posMod` rather than `%` so a pathological `dt` cannot + // walk it negative. + elapsed = posMod(elapsed + dt, BOIL_PERIOD); sortAge += dt; uniforms.uTime!.value = elapsed; @@ -1160,8 +1215,11 @@ void main() { // A slow breath, a fifteenth of a radius, on a per-puff phase. Small enough // that nobody sees a puff pulse and large enough that the field is never - // completely still even with no wind reported. - float boil = 1.0 + 0.07 * sin(uTime * 0.21 + aFade.z * 6.2831853); + // completely still even with no wind reported. The rate is interpolated in + // from BOIL_RATE, which is also what uTime's wrap period is derived from: the + // two are one number and have to stay one number. (No backticks in this + // string, ever — it is a template literal and they close it.) + float boil = 1.0 + 0.07 * sin(uTime * ${BOIL_RATE.toFixed(6)} + aFade.z * 6.2831853); float radius = aShape.x * uGrow * boil * present; vec2 corner = position.xy; diff --git a/src/engine/flights.ts b/src/engine/flights.ts index 5c965ab..b8662bb 100644 --- a/src/engine/flights.ts +++ b/src/engine/flights.ts @@ -355,10 +355,27 @@ export class AdsbFlights implements FlightSource { private held: Aircraft[] = []; private heldAt = 0; - constructor( - private readonly endpoint: string, - private readonly region: SkyRegion, - ) {} + private readonly endpoint: string; + private readonly region: SkyRegion; + + /** + * Fields assigned in the body rather than declared as parameter properties. + * + * That is not a style preference. A parameter property is the one piece of + * TypeScript syntax that *emits code* — it is a hidden assignment, not a type + * annotation — so Node's type stripping refuses the whole module with + * `ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX`. The bundler never cared, so this went + * unnoticed until the first `node --test` file tried to import this layer and + * discovered it could not: the module with the worst bug this project has + * shipped was, by construction, the one module that could not be tested. + * + * The server has run under type stripping from the start and so has always + * been written this way; the browser engine simply never had to be. + */ + constructor(endpoint: string, region: SkyRegion) { + this.endpoint = endpoint; + this.region = region; + } async poll(): Promise { const { lat, lng } = this.region.center; diff --git a/src/engine/officeMinimap.ts b/src/engine/officeMinimap.ts index 3f9c110..9c38ebb 100644 --- a/src/engine/officeMinimap.ts +++ b/src/engine/officeMinimap.ts @@ -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(); + /** + * 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 diff --git a/src/engine/satellites.ts b/src/engine/satellites.ts index 9b2a02f..78b0036 100644 --- a/src/engine/satellites.ts +++ b/src/engine/satellites.ts @@ -339,7 +339,7 @@ export interface SatelliteLayer { * The band where the constellation was both unclipped and unfogged did not * overlap the band where it fitted on screen at all. */ -const DOME_RADIUS_FACTOR = 1.05; +export const DOME_RADIUS_FACTOR = 1.05; /** * How large a dot is drawn, in **pixels**, at any camera distance. diff --git a/src/engine/scene.ts b/src/engine/scene.ts index 4a44807..10ed6e2 100644 --- a/src/engine/scene.ts +++ b/src/engine/scene.ts @@ -34,11 +34,7 @@ import { createFlightLayer, type FlightLayer } from "./flights.ts"; import { createCloudLayer, type CloudLayer } from "./clouds.ts"; import { createMarkerLayer, type MarkerLayer } from "./markers.ts"; import { solarPosition, sunDirection } from "./solar.ts"; -import { - createStarlinkMeshLayer, - DOME_RADIUS_FACTOR, - type StarlinkMeshLayer, -} from "./starlinkMesh.ts"; +import { createStarlinkMeshLayer, type StarlinkMeshLayer } from "./starlinkMesh.ts"; import { createSatelliteLayer, type SatelliteCatalogue, @@ -265,6 +261,16 @@ export async function createScene( */ maxDistance: boardSpan * 2.0, shadowExtent: boardSpan * 0.75, + /** + * The middle of the board, which is **not** the origin. + * + * Scene space is centred on `city.center` — the city — and the Bay Area + * board runs forty kilometres down the peninsula from there, so a shadow + * box centred on the origin spends half itself on empty ocean and leaves the + * far end of the peninsula outside the frustum entirely. `shadowExtent` + * sizes the box and says nothing about where it is; this says where. + */ + shadowTarget: new THREE.Vector3((westX + eastX) / 2, 0, (northZ + southZ) / 2), shadowFar: boardSpan * 2.2, }); // Held, because the cloud layer needs the same opening rig the kit just got — @@ -308,11 +314,12 @@ export async function createScene( if (options.satellites) { satelliteLayer = createSatelliteLayer(boardRadius); scene.add(satelliteLayer.group); - // The same dome the points are on, so a satellite that grows geometry does - // not also jump. `DOME_RADIUS_FACTOR` is exported for exactly this: the two - // layers must agree, and the only safe way for them to agree is to be - // multiplying the same number by the same constant. - starlinkMeshes = createStarlinkMeshLayer(boardRadius * DOME_RADIUS_FACTOR); + // Board radius, the same unit `createSatelliteLayer` takes above — the + // mesh layer applies the dome factor itself. It used to take the *dome* + // radius while its sibling took the *board* radius, with nothing in the + // types to tell them apart, which is precisely the confusion that has + // already produced one real bug in this file. + starlinkMeshes = createStarlinkMeshLayer({ boardRadius }); scene.add(starlinkMeshes.group); } diff --git a/src/engine/scenekit.ts b/src/engine/scenekit.ts index 78fb408..d4c34e7 100644 --- a/src/engine/scenekit.ts +++ b/src/engine/scenekit.ts @@ -90,10 +90,37 @@ export interface SceneKitOptions { shadowFar?: number; shadowBias?: number; /** - * How far along its direction the sun is placed. A `LightingState` carries a - * unit direction and no distance, because distance is a fact about the scale - * of the scene — 94 m per unit outdoors, 1 m per unit indoors — and not about - * where the sun is. + * What the shadow box is centred on, in scene units. Defaults to the origin, + * which is almost never where the thing being lit actually is. + * + * A `shadowExtent` says how *big* the box is; it says nothing about where. + * three centres a directional light's shadow camera on `light.target`, and a + * fresh `DirectionalLight` targets a brand-new `Object3D` sitting at the + * world origin — so without this every caller got a correctly-sized box in + * the wrong place, and the two consumers here both have their origin off to + * one side of what they want lit: + * + * - An office pack's origin is the **north-west corner of its slab**, not its + * middle. `lumbridge-hq` is 48 x 18 m against `shadowExtent: max(8, span * + * 0.7)` = ±33.6 m, so a box on the origin covered x ∈ [-33.6, 33.6] of a + * building occupying x ∈ [0, 48]: the eastern 14.4 m — call it a third of + * the floor plate — fell outside the frustum entirely and neither cast a + * shadow nor received one. Half the box was spent on the empty ground west + * of the building. + * - Scene space for a city is centred on `city.center`, and the comment on + * `boardRadius` in `scene.ts` already records that the Bay Area board runs + * forty kilometres down the peninsula from there. Same failure, one order + * of magnitude up. + * + * Pass the centre of what you want shadowed: `plan.bounds.center` for an + * office, the mid-point of the projected board for a city. + */ + shadowTarget?: THREE.Vector3; + /** + * How far along its direction the sun is placed, **from `shadowTarget`**. A + * `LightingState` carries a unit direction and no distance, because distance + * is a fact about the scale of the scene — 94 m per unit outdoors, 1 m per + * unit indoors — and not about where the sun is. */ sunDistance?: number; /** Flight rate, in fractions of the flight per second. */ @@ -263,9 +290,33 @@ export function createSceneKit(options: SceneKitOptions): SceneKit { * house, somewhere near the origin. The office asks for ±34 m and got ±5 m. */ sun.shadow.camera.updateProjectionMatrix(); + + /** + * One update now, so a reader that recomputes for itself sees the right pose. + * + * This is **not** what makes the shadow correct — the paragraph above is: the + * target has to be *in the scene* so `Object3D.updateMatrixWorld`'s traversal + * reaches it, and `WebGLRenderer.render` runs that traversal before + * `shadowMap.render()` on every frame. That is the whole mechanism. + * + * What this line buys is narrower and worth being honest about. Nothing that + * reads `sun.shadow.camera` before the first render learns anything from it — + * three only writes that camera's pose inside `LightShadow.updateMatrices`, + * which runs during a render. It matters to a reader that recomputes from the + * target itself: a `DirectionalLightHelper`, or a manual + * `sun.shadow.updateMatrices(sun)` in a capture pass. + */ + const shadowTarget = new THREE.Vector3(); + if (options.shadowTarget) shadowTarget.copy(options.shadowTarget); + sun.target.position.copy(shadowTarget); + const hemisphere = new THREE.HemisphereLight(0xffffff, 0x808080, 1); const ambient = new THREE.AmbientLight(0xffffff, 0.3); - scene.add(sun, hemisphere, ambient); + scene.add(sun, sun.target, hemisphere, ambient); + // Belt and braces for anything that reads the shadow camera before the first + // render — a capture pass, a debug helper — where the renderer's own + // traversal has not happened yet. After that, the traversal owns it. + sun.target.updateMatrixWorld(); const sunDirection = new THREE.Vector3(); let sky: THREE.Texture | null = null; @@ -278,7 +329,24 @@ export function createSceneKit(options: SceneKitOptions): SceneKit { // A zero direction would put the sun inside the ground and black the scene // out; leaving it where it was is the kinder failure. if (sunDirection.lengthSq() > 0) { - sun.position.copy(sunDirection.normalize().multiplyScalar(sunDistance)); + /* + * `sunDistance` out from the **target**, not from the origin. + * + * A directional light's position is not physical — the shading only reads + * `position - target` as a direction — but the shadow camera *is* placed + * at it, and its `near`/`far` are measured from there along the view + * axis. Off the origin those two facts fight: `officeScene.ts` asks for + * `sunDistance: max(24, span * 1.4)`, so on a 12 m studio the sun sits + * 24 units from the origin while the slab centre it is aimed at can be + * 8 m away in some other direction — a light that is beside or behind the + * building rather than above it, with the near plane cutting into the + * geometry it is supposed to be shadowing. + * + * Anchoring to the target makes light-to-target exactly `sunDistance` + * whatever the direction, which is the invariant every caller's + * `shadowNear`/`shadowFar` was picked against. + */ + sun.position.copy(sunDirection.normalize().multiplyScalar(sunDistance)).add(shadowTarget); } sun.color.setHex(state.sun.color); sun.intensity = state.sun.intensity; @@ -531,7 +599,10 @@ export function createSceneKit(options: SceneKitOptions): SceneKit { dom.style.cursor = ""; picking = null; controls.dispose(); - scene.remove(sun, hemisphere, ambient); + // `sun.target` was added as a scene child in its own right, so removing + // the light does not take it with it — a kit torn down and rebuilt would + // otherwise leave one empty Object3D in the scene per cycle. + scene.remove(sun, sun.target, hemisphere, ambient); sun.dispose(); hemisphere.dispose(); ambient.dispose(); diff --git a/src/engine/starlinkMesh.ts b/src/engine/starlinkMesh.ts index 6a099f4..1b3cf2b 100644 --- a/src/engine/starlinkMesh.ts +++ b/src/engine/starlinkMesh.ts @@ -76,24 +76,47 @@ */ import * as THREE from "three"; +/** + * The dome radius factor is imported, never restated. + * + * The points and the meshes have to be on the **same** dome — a satellite that + * grows geometry must not also jump — and the only way for two modules to agree + * on a number is for one of them not to have a copy of it. `satellites.ts` owns + * the dome; this multiplies by what it says. + */ +import { DOME_RADIUS_FACTOR } from "./satellites.ts"; import { mergeGeometries } from "three/examples/jsm/utils/BufferGeometryUtils.js"; import type { SatelliteFix } from "./satellites.ts"; const RAD = 180 / Math.PI; /** - * The dome factor, restated. + * The dome factor, restated — and applied *here*, which is now the whole point + * of it. * - * `satellites.ts` keeps `DOME_RADIUS_FACTOR = 1.05` private, and the meshes have + * `satellites.ts` owns `DOME_RADIUS_FACTOR` and exports it, and the meshes have * to land on **exactly** the shell the dots are on — not a similar one. Put them * on different radii and the two layers agree only when the camera is at the * scene origin; anywhere else the mesh separates from its own dot by parallax, * which reads as a rendering fault rather than as a rounding error. * - * This is a mirror and is meant to stop being one: see the wiring note. Export - * the constant from `satellites.ts`, import it here, and delete this. + * This used to be exported so that `scene.ts` could do the multiplication on the + * way in, on the theory that a shared constant is what makes two layers agree. + * It is not: `createSatelliteLayer` takes a board radius, this layer took a + * *dome* radius, both are a bare `number`, and the only thing standing between + * the two units was a caller remembering which of them it was holding. That is + * the same shape of mistake that once put this constellation's dome *inside its + * own city* — a radius mistaken for a span, recorded at length in + * `DOME_RADIUS_FACTOR`'s note in `satellites.ts` — and it cost a rendering-bug + * hunt to find, because a sky on the wrong radius still looks like a sky. Now both + * entry points take the board radius and each multiplies for itself, so there is + * no unit to get wrong at the call site and no reason for anything outside this + * file to know this number exists. + * + * It is still a duplicate and still meant to stop being one: export the constant + * from `satellites.ts`, import it here, and delete this declaration. Until + * somebody does, the two have to be changed together. */ -export const DOME_RADIUS_FACTOR = 1.05; /** * Ceiling on meshes, and the reason the layer is affordable at all. @@ -244,7 +267,11 @@ const ARRAY_CENTRE_X = BUS_LENGTH / 2 + BOOM_GAP + ARRAY_LENGTH / 2; /** Tip of the array to the far edge of the bus — what `SPAN_FRACTION` scales. */ const MODEL_SPAN = BUS_LENGTH + BOOM_GAP + ARRAY_LENGTH; -/** Sentinel for an unused candidate slot. Finite, so the comparator is total. */ +/** + * Sentinel for an unused candidate slot. Finite rather than `Infinity`, so a + * slot that ever did reach the ranking would sort to the back of it instead of + * poisoning an arithmetic comparison. + */ const UNUSED_SCORE = 1e9; /** Earth's mean radius, for the nadir angle. Sphere is plenty at one degree. */ @@ -261,6 +288,25 @@ export interface SunVector { readonly z: number; } +/** + * What the layer needs to exist, which is one number — passed as a *named* field + * and not as a positional argument, deliberately. + * + * The number is `scene.ts`'s `boardRadius`: how far the board reaches from the + * scene origin, exactly as `createSatelliteLayer` takes it, so the dots and the + * meshes are derived from one measurement by one constant. Two radii are in play + * inside this file and they differ by 5% — small enough that a mesh on the wrong + * one still draws, still looks like a satellite, and only separates from its own + * dot once the camera leaves the origin, which is the kind of bug that survives + * a screenshot. A positional `number` cannot tell the two apart. A field named + * `boardRadius` can, and a call site that was passing the other one stops + * compiling instead of quietly drawing a second, slightly larger sky. + */ +export interface StarlinkMeshOptions { + /** How far the board reaches from the scene origin. Not the board's width. */ + readonly boardRadius: number; +} + export interface StarlinkMeshLayer { group: THREE.Group; /** @@ -289,7 +335,7 @@ export interface StarlinkMeshLayer { */ interface Candidate { fix: SatelliteFix | null; - /** Degrees off the camera's axis. Ascending; `UNUSED_SCORE` sorts to the end. */ + /** Degrees off the camera's axis. Lower ranks first; see `rankBest`. */ score: number; /** 0 to 1. Drives the scale, which is how a mesh grows out of its own dot. */ fade: number; @@ -297,10 +343,15 @@ interface Candidate { readonly at: THREE.Vector3; } -export function createStarlinkMeshLayer(domeRadius: number): StarlinkMeshLayer { +export function createStarlinkMeshLayer(options: StarlinkMeshOptions): StarlinkMeshLayer { const group = new THREE.Group(); group.name = "starlink-meshes"; + // The shell everything below is placed on and scaled against. Computed once, + // from the board radius, by the same constant `satellites.ts` uses on the same + // input — which is the whole of the agreement between the two layers. + const domeRadius = options.boardRadius * DOME_RADIUS_FACTOR; + const busGeometry = buildBus(); const arrayGeometry = buildArray(); @@ -335,11 +386,25 @@ export function createStarlinkMeshLayer(domeRadius: number): StarlinkMeshLayer { color: 0xffffff, fog: false, /** - * The array is a flat panel edge-on for part of every orbit, and a - * back-faced panel disappears entirely at the moment it is most - * foreshortened. It has two sides in reality — cells one way, substrate the - * other — and drawing both is a hundred and forty-four extra triangles - * across the whole layer. + * Culling off. Not extra geometry — `side` is a rasteriser state and emits + * no triangles at all, so the arithmetic this comment used to carry ("a + * hundred and forty-four extra triangles across the whole layer") was + * measuring something that does not exist. What it costs is fill: the far + * faces of a box that would otherwise have been discarded before shading. + * For sixty-four objects twenty pixels across, under a `MeshBasicMaterial` + * that shades both faces the same flat instance colour, that is unmeasurable + * and invisible in both directions. + * + * Which is the honest status of this flag today: `buildArray` returns a + * closed `BoxGeometry`, and a closed body never shows its interior whether + * you cull or not. It is here for the case that geometry is one refactor + * from becoming — the panel is two centimetres thick on eight metres and the + * standing temptation is to demote it to a plane, at which point a + * front-side material makes it *vanish* for the half of every orbit it is + * turned away from you, which is exactly the half where its edge is the only + * thing telling you the satellite is not a dot. Keeping the flag costs + * nothing and removes the trap. Do not delete it because the box makes it + * redundant; delete it only along with the box. */ side: THREE.DoubleSide, }); @@ -368,7 +433,20 @@ export function createStarlinkMeshLayer(domeRadius: number): StarlinkMeshLayer { const pool: Candidate[] = []; - // Scratch, all of it. Nothing in `update` allocates. + /** + * This frame's best `MAX_MESHES` candidates, ascending by score — references + * into `pool`, never copies. Allocated once here; `rankBest` refills the front + * of it every frame and nothing ever reads past what that returns. + */ + const ranked: (Candidate | undefined)[] = new Array(MAX_MESHES); + + // Scratch, all of it. Once the pool has reached its high-water mark — a second + // or two after the first pass rises — `update` allocates nothing whatever, and + // that claim is only true because the ranking is `rankBest` and not + // `pool.sort`: V8's sort copies the array into a work buffer on every call, so + // a comparator-based sort of a few hundred entries is a few hundred words of + // garbage sixty times a second, from the one layer whose entire argument for + // existing is that it is cheap enough to leave on. const eye = new THREE.Vector3(); const forward = new THREE.Vector3(); const sunDir = new THREE.Vector3(); @@ -393,6 +471,55 @@ export function createStarlinkMeshLayer(domeRadius: number): StarlinkMeshLayer { return made; } + /** + * Fill `ranked` with the lowest-scoring `min(found, MAX_MESHES)` of + * `pool[0..found)`, ascending, and return how many that was. + * + * A bounded insertion rather than a sort, for two reasons and not for speed on + * a typical sky. The first is the allocation above. The second is that a sort + * answers a question nobody asked: the draw loop reads the first `MAX_MESHES` + * entries and the rest is work whose result is thrown away, which on a dense + * pass over a Starlink train is most of the list. This walks the candidates + * once, rejects anything worse than the current sixty-fourth on a single + * compare, and only pays the shift when a candidate genuinely belongs in the + * window — so the cost tracks the size of the *window*, which is fixed, rather + * than the size of the sky, which is not. + * + * The worst case is a pool of exactly `MAX_MESHES` arriving in descending + * order, which is a full insertion sort: about two thousand pointer writes on + * a 64-entry array, once a frame, and still no allocation. The best case — the + * ordinary one, a dozen objects near the view centre — is a dozen compares. + * + * Order matters within the window as well as at its edge: `rank` in the draw + * loop fades the last few slots out, so "sixty-fourth" has to mean the + * sixty-fourth *best* and not merely one of the sixty-four. + */ + function rankBest(found: number): number { + const keep = Math.min(found, MAX_MESHES); + let held = 0; + for (let i = 0; i < found; i++) { + const candidate = pool[i]; + if (candidate === undefined) continue; + if (held === keep) { + const worst = ranked[keep - 1]; + if (worst !== undefined && candidate.score >= worst.score) continue; + // The one being displaced falls off the end of the window; dropping the + // count here is what keeps the shift below in bounds. + held -= 1; + } + let j = held; + while (j > 0) { + const above = ranked[j - 1]; + if (above !== undefined && above.score <= candidate.score) break; + ranked[j] = above; + j -= 1; + } + ranked[j] = candidate; + held += 1; + } + return held; + } + /** * Azimuth and elevation to a point on the dome. * @@ -529,8 +656,12 @@ export function createStarlinkMeshLayer(domeRadius: number): StarlinkMeshLayer { found += 1; } - // Release the rest of the pool so the sort puts them past the end. The - // objects are kept; only their claim on a slot is dropped. + // Release the tail of the pool. Nothing reads past `found` any more — the + // ranking walks `[0, found)` and the pool is never reordered — so this is no + // longer load-bearing for the selection; it is here so that a slot left over + // from a busy pass does not keep last frame's `SatelliteFix` alive for the + // lifetime of the layer. The objects themselves are kept, as always: only + // their claim on a slot is dropped. for (let i = found; i < pool.length; i++) { const stale = pool[i]; if (stale !== undefined) { @@ -538,11 +669,10 @@ export function createStarlinkMeshLayer(domeRadius: number): StarlinkMeshLayer { stale.score = UNUSED_SCORE; } } - pool.sort(byScore); - const drawn = Math.min(found, MAX_MESHES); + const drawn = rankBest(found); for (let i = 0; i < drawn; i++) { - const candidate = pool[i]; + const candidate = ranked[i]; const fix = candidate?.fix; if (candidate === undefined || !fix) continue; @@ -626,9 +756,17 @@ export function createStarlinkMeshLayer(domeRadius: number): StarlinkMeshLayer { /** * Phase, exactly as for the moon: how much of the lit side is turned this - * way. `radial` points from the earth to the satellite, so `−radial` is - * near enough the direction from the satellite to the observer, and its - * dot with the sun is the cosine of the phase angle. Positive when the sun + * way. `radial` is the satellite's position on the dome normalised, and + * the dome is centred on the **observer** — so it points from the observer + * to the satellite, the line of sight outward, and `−radial` is the + * direction from the satellite back to the observer exactly rather than + * approximately. (It is emphatically *not* the geocentric radial, the + * earth's centre to the satellite: those two differ by the nadir angle η, + * which reaches 67° at the horizon and is the entire subject of `nadirOf` + * above. Using one where the other belongs is how the attitude and the + * phase would end up disagreeing about where the satellite is.) + * + * Its dot with the sun is the cosine of the phase angle. Positive when the sun * is below the observer's horizon and the object is still in daylight, * which is the entire observing window for a Starlink pass; zero at noon, * when the sun is behind it from here and the side facing down is the side @@ -693,9 +831,14 @@ export function createStarlinkMeshLayer(domeRadius: number): StarlinkMeshLayer { * The bus: a flat slab, the phased-array antenna stepped out of its underside, * and the boom stub the panel deploys along. * - * The antenna step is worth its four triangles because the slab alone is a - * shape with no side to it — the whole read of "belly pointing down" comes from - * being able to see which face is which at an oblique angle. The boom is in the + * The antenna step is worth its twelve triangles — it is a `BoxGeometry`, and a + * box is twelve however thin it is drawn; the four this comment used to claim + * were the count of the one face you can see — because the slab alone is a + * shape with no side to it, and the whole read of "belly pointing down" comes + * from being able to see which face is which at an oblique angle. That puts the + * bus at 48 triangles (12 chassis, 12 antenna, 24 for the six-sided capped stub) + * against the array's 12, so a drawn satellite is 60 and the whole layer at its + * sixty-four-instance ceiling is 3,840. The boom is in the * bus rather than the array partly because it is structure rather than panel and * takes the pale material, and partly because a cylinder lying along the hinge * axis is invariant under the hinge rotation, so it looks identical either way @@ -717,10 +860,11 @@ function buildBus(): THREE.BufferGeometry { for (const part of parts) part.dispose(); if (merged) return merged; - // The same non-null dance as `flights.ts`'s `dartGeometry`, for the same - // reason: three primitives out of the same library cannot disagree about their - // attributes, the signature permits it anyway, and a plain slab is a better - // failure than a missing layer. + // The same non-null dance as `aircraftGeometry.ts`'s `airlinerGeometry` — it + // was `flights.ts`'s `dartGeometry` when this was written, and that function no + // longer exists — for the same reason: three primitives out of the same + // library cannot disagree about their attributes, the signature permits it + // anyway, and a plain slab is a better failure than a missing layer. return new THREE.BoxGeometry(BUS_LENGTH, BUS_THICK, BUS_DEPTH); } @@ -742,11 +886,6 @@ function buildArray(): THREE.BufferGeometry { return panel; } -/** Ascending by angle off the view centre; released slots sort to the back. */ -function byScore(a: Candidate, b: Candidate): number { - return a.score - b.score; -} - /** * 1 at or below `full`, 0 at or above `edge`, smoothstepped between — so both * ends of every ramp in this file arrive with zero slope, which is the whole diff --git a/src/interiors/officeScene.ts b/src/interiors/officeScene.ts index f3e1cc2..1e2267d 100644 --- a/src/interiors/officeScene.ts +++ b/src/interiors/officeScene.ts @@ -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); }, diff --git a/src/interiors/robots.ts b/src/interiors/robots.ts index 9e1b4aa..0f5da64 100644 --- a/src/interiors/robots.ts +++ b/src/interiors/robots.ts @@ -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 --------------------------------------------------------------- diff --git a/src/main.ts b/src/main.ts index 92a851f..575eb7f 100644 --- a/src/main.ts +++ b/src/main.ts @@ -911,7 +911,7 @@ function buildOfficePlan( create: typeof import("./engine/officeMinimap.ts").createOfficeMinimap, scene: OfficeScene, ): OfficeMinimap { - return create({ + const panel = create({ plan: scene.plan, camera: scene.camera, controls: scene.controls, @@ -963,6 +963,18 @@ function buildOfficePlan( minimapReadout.textContent = `${where}${level}${room}${person}`; }, }); + + /** + * Once, not per frame. + * + * `scene.robots()` hands back a stable array of vectors the robot layer + * mutates in place, so the plan reads this frame's positions through a + * reference taken here — the same handshake `officeScene` already makes with + * `luminaires.setWalkers`. Polling it every frame would allocate nothing and + * would imply the array were a snapshot, which it is not. + */ + panel.setRobots(scene.robots()); + return panel; } /** diff --git a/src/test/flights.test.ts b/src/test/flights.test.ts new file mode 100644 index 0000000..4a39b3d --- /dev/null +++ b/src/test/flights.test.ts @@ -0,0 +1,570 @@ +/** + * `createFlightLayer`: the difference between a snapshot and an observation. + * + * This layer is handed a list of aircraft on the source's timer and has to turn + * that into continuous motion, a trail, and a decision about when something has + * stopped existing. Every bug it has ever had has been one of those three + * confusing the others, and none of them threw: the layer rendered perfectly and + * lied. The worst of them shipped — live ADS-B traffic could not move or grow a + * trail **at all** — and it survived because a still frame of a broken sky and a + * still frame of a correct one are the same picture. + * + * ## What is actually asserted here + * + * The layer exports `group`, `update`, `tick` and `dispose` and nothing else, so + * everything below is observed through the scene graph rather than by reaching + * into `tracks`: + * + * - **Trail length** comes from `flight-trails`'s draw range. `rebuildTrails` + * writes two vertices per segment and one segment per retained observation + * bar the newest, so `drawRange.count / 2` *is* the number of history points + * minus one, for every track being drawn. That is the number the regression + * destroyed, and it is readable without a GL context. + * - **The aircraft's position** is `mesh.position`, which `tick` copies from + * the interpolated head. Reading it a few seconds after an observation is + * how the measured `span` — a private field — becomes observable: a head + * halfway between two positions can only mean the layer believes the leg + * takes twice as long as it has so far had. + * - **Existence** is a mesh's membership of `group`; **being drawn** is + * `mesh.visible`. The two are deliberately different things in this layer + * and the tests keep them different. + * + * ## Time + * + * `nowSeconds()` reads `performance.now()`, and several of the intervals that + * matter here are tens of seconds long — `TRACK_GRACE_SECONDS` alone is 32. A + * test that waited them out would take a minute and would still be racing the + * clock it was waiting on. So the global `performance.now` is replaced with a + * counter for the duration of this file and moved by `at()`. That is legitimate + * rather than a cheat: the layer's only input from the clock is that one call, + * it reads it fresh every time, and every interval under test is defined in + * terms of it. Nothing else in the file touches wall time. + * + * ## The constants are copied, on purpose + * + * `TRAIL_POINTS`, `MAX_TRACKS` and `TRACK_GRACE_SECONDS` are module-private in + * `flights.ts` and are restated below rather than exported for the tests. That + * is the right way round: a test that imports the constant it is checking + * against asserts only that the code is self-consistent, and would follow a + * typo straight into production. These are the numbers the *comments* in + * `flights.ts` argue for, written out again, so that changing one there without + * meaning to fails here loudly. + */ + +import assert from "node:assert/strict"; +import { after, before, describe, it } from "node:test"; +import * as THREE from "three"; +import SAN_FRANCISCO from "../cities/sf.ts"; +import { createFlightLayer, type FlightLayer } from "../engine/flights.ts"; +import type { Aircraft } from "../engine/types.ts"; +import { World } from "../engine/world.ts"; + +// ---- The clock ------------------------------------------------------------- + +let clockMs = 0; +const realNow = performance.now; + +before(() => { + performance.now = () => clockMs; +}); +after(() => { + performance.now = realNow; +}); + +/** Put the layer's clock at this many seconds. Absolute, not a delta. */ +function at(seconds: number): void { + clockMs = seconds * 1000; +} + +// ---- The board ------------------------------------------------------------- + +/** + * A real `World` over the real San Francisco pack, and **not** a hand-made city. + * + * The constructor is arithmetic — `lngScale`, `metresPerUnit`, `lngSquash` — and + * `World`'s own header states that `project` and `metres` work the instant it + * returns. Nothing here calls `ready()`, `groundAt` or anything else that would + * touch the heightfield, so the half-million-sample build never happens and this + * costs nothing beyond parsing the pack. + * + * Using the shipped pack rather than inventing a city matters for exactly one + * reason, and it is the reason this file exists: `JUMP_UNITS_PER_SECOND = 8` is + * calibrated against *this board's* ~94 m per scene unit, and so are the + * distances every test below feeds in. A toy city with a round `latScale` would + * make the teleport tests pass or fail for arithmetic that no deployment runs. + */ +const world = new World(SAN_FRANCISCO); + +/** Mirrors of `flights.ts`'s private constants. See the header. */ +const TRAIL_POINTS = 72; +const MAX_TRACKS = 192; +const TRACK_GRACE_SECONDS = 32; + +/** + * What a live feed's refresh actually costs, in seconds. + * + * The number the whole regression is about: `HttpFlights` is polled at 1 Hz and + * the server caches for 5–15 s, so this is how far apart two *distinct* + * positions arrive while the poll interval stays at 1. + */ +const REFRESH = 10; +const POLL = 1; + +interface Fixture { + layer: FlightLayer; + /** Retained observations minus one, per drawn track, summed. See the header. */ + segments(): number; + /** The trail's vertex buffer, sliced to what is actually drawn. */ + drawnPositions(): Float32Array; + /** Aircraft meshes in the group, in the order their tracks were created. */ + meshes(): THREE.Mesh[]; +} + +function fixture(): Fixture { + const layer = createFlightLayer(world); + const line = layer.group.getObjectByName("flight-trails") as THREE.LineSegments; + assert.ok(line, "the layer no longer has a trail line to read"); + const position = line.geometry.attributes.position as THREE.BufferAttribute; + return { + layer, + segments: () => line.geometry.drawRange.count / 2, + drawnPositions: () => + (position.array as Float32Array).subarray(0, line.geometry.drawRange.count * 3), + // `type` rather than `instanceof`: the trail is a `LineSegments`, which is a + // `Line` and not a `Mesh`, so this is exactly the aircraft and nothing else. + meshes: () => layer.group.children.filter((c): c is THREE.Mesh => c.type === "Mesh"), + }; +} + +/** An airliner at cruise, eastbound. Altitude and heading are rarely the point. */ +function jet(id: string, lat: number, lng: number, altitude = 9000): Aircraft { + return { id, callsign: id.toUpperCase(), lat, lng, altitude, heading: 90 }; +} + +/** Assert a scene position, with a tolerance the `Float32Array` can meet. */ +function assertNear(actual: number, expected: number, what: string): void { + assert.ok( + Math.abs(actual - expected) < 0.01, + `${what}: ${actual.toFixed(4)} is not ${expected.toFixed(4)}`, + ); +} + +// ---- The regression -------------------------------------------------------- + +/** + * A frozen snapshot polled faster than it refreshes. + * + * This is the shape of every live deployment: `update` is called once a second + * with a list that only changes every ten. Before the repeat-skip in `update`, + * each of those nine identical lists was recorded as a fresh observation, which + * made `span` the poll interval instead of the refresh interval — and then the + * tenth call, the one carrying a real ten seconds of flying, was measured + * against a one-second span, tripped the teleport guard, and **wiped the track's + * entire history**. Every refresh. For every aircraft. Forever. + * + * So the trace below is not a stress case, it is the normal case, and the + * numbers are chosen so that the broken code and the correct code disagree by + * more than a margin: 0.025° of longitude on this board is ~23 scene units, + * which is 2.3 units per second across a real refresh (comfortably under the + * ceiling of 8) and 23 units per second across a poll (comfortably over it). + */ +describe("a source that repeats itself between refreshes", () => { + const LAT = 37.62; + const LNG0 = -122.38; + /** ~23 scene units, i.e. an airliner's ten seconds. */ + const LEG = 0.025; + + /** Poll at 1 Hz from `from` to `until`, handing back the same aircraft. */ + function holdSnapshot(f: Fixture, a: Aircraft, from: number, until: number) { + for (let t = from; t < until; t += POLL) { + at(t); + f.layer.update([a]); + } + } + + it("keeps the history it has instead of wiping it on every refresh", () => { + const f = fixture(); + + at(0); + f.layer.update([jet("aal1", LAT, LNG0)]); + assert.equal(f.segments(), 0, "one observation is a point, not a trail"); + + holdSnapshot(f, jet("aal1", LAT, LNG0), POLL, REFRESH); + assert.equal(f.segments(), 0, "a repeated position must not become a second sample"); + + at(REFRESH); + f.layer.update([jet("aal1", LAT, LNG0 + LEG)]); + assert.equal(f.segments(), 1, "the first real leg"); + + holdSnapshot(f, jet("aal1", LAT, LNG0 + LEG), REFRESH + POLL, REFRESH * 2); + assert.equal(f.segments(), 1, "the leg survived nine more repeats of its own end point"); + + at(REFRESH * 2); + f.layer.update([jet("aal1", LAT, LNG0 + LEG * 2)]); + + /** + * Two legs, which is the entire claim. The old code reached this line with + * an empty history and a draw range of zero: the step from `LNG0 + LEG` to + * `LNG0 + LEG * 2` was measured against the one-second gap to the last + * *repeat* rather than the ten-second gap to the last real position, came + * out at ~23 units per second against a ceiling of 8, and took the + * `track.samples.length = 0` branch. Remove the repeat-skip in `update` and + * this assertion reads `0` — as does every one above it that expects a leg. + */ + assert.equal(f.segments(), 2, "the trail was wiped by a refresh"); + }); + + /** + * The other half of the same bug, and the half a user would describe: the + * aircraft did not move. It sat still for ten seconds and jumped. + * + * `span` is private, so it is read here through its only consequence — where + * the head is. Halfway between the two positions, five seconds after an + * observation, can only mean the layer is spreading the leg over the full + * refresh. With `span` mismeasured as one second the head is pinned at the + * newest sample from the first frame onward (and, in the old code, had no + * trail behind it either). + */ + it("spreads a refresh's worth of movement across the whole refresh", () => { + const f = fixture(); + at(0); + f.layer.update([jet("aal1", LAT, LNG0)]); + holdSnapshot(f, jet("aal1", LAT, LNG0), POLL, REFRESH); + at(REFRESH); + f.layer.update([jet("aal1", LAT, LNG0 + LEG)]); + + const [mesh] = f.meshes(); + assert.ok(mesh, "the aircraft has no mesh"); + + const [x0] = world.project(LAT, LNG0); + const [x1] = world.project(LAT, LNG0 + LEG); + + // At the instant of an observation the aircraft is at the *previous* one. + // That is the deliberate one-interval lag: the layer interpolates between + // the last two observations rather than extrapolating past the newest, so + // nothing ever overshoots and snaps back when a feed stutters. + assertNear(mesh.position.x, x0, "the leg should start where the last one ended"); + + at(REFRESH + REFRESH / 2); + f.layer.tick(); + assertNear(mesh.position.x, (x0 + x1) / 2, "the aircraft is not halfway along its leg"); + + // And it arrives rather than overshooting: `tick` clamps, so polling late + // parks the aircraft on the observation instead of flying it past. + at(REFRESH * 3); + f.layer.tick(); + assertNear(mesh.position.x, x1, "the aircraft overshot the observation it was heading for"); + }); +}); + +// ---- The guard the repeat-skip works alongside ----------------------------- + +/** + * The teleport check still has to fire, and the case it exists for is real: a + * `SimulatedFlights` route reaching the end of its leg reappears at the start, + * which on this board is several hundred scene units between two consecutive + * polls. Drawn, it is a bright line straight across San Francisco. + * + * This is the test that stops the fix above from being "delete the guard". The + * repeat-skip changed *what* `span` measures; it must not have changed what + * counts as impossible. + */ +describe("a simulator route wrapping", () => { + it("still clears the history rather than drawing a line across the map", () => { + const f = fixture(); + const lng = -122.42; + // Three observations up the peninsula: ~9 units a leg, ~0.9 units a second. + at(0); + f.layer.update([jet("sim-1", 37.60, lng)]); + at(REFRESH); + f.layer.update([jet("sim-1", 37.608, lng)]); + at(REFRESH * 2); + f.layer.update([jet("sim-1", 37.616, lng)]); + assert.equal(f.segments(), 2, "the track should have two legs before it wraps"); + + // The leg ends and the route restarts at its origin: 0.4° of latitude is + // ~472 scene units, i.e. ~47 units a second against a ceiling of 8. + at(REFRESH * 3); + f.layer.update([jet("sim-1", 37.216, lng)]); + + assert.equal(f.segments(), 0, "a wrapped route dragged its old trail across the board"); + + // The aircraft itself survives — it is the *history* that belonged to a + // different part of the leg, not the track. + const [mesh] = f.meshes(); + assert.ok(mesh, "the wrap deleted the aircraft as well as its trail"); + const [, z] = world.project(37.216, lng); + assertNear(mesh.position.z, z, "the aircraft did not restart at the head of its route"); + }); + + /** + * And a wrap is not a repeat, which is the interaction worth pinning: the + * repeat-skip runs first, so a guard that only ever saw distinct positions + * would be dead code if `samePosition` were ever loosened into a "did it move + * much" test. It is not, and this is what would notice. + */ + it("is not mistaken for the source repeating itself", () => { + const f = fixture(); + at(0); + f.layer.update([jet("sim-1", 37.60, -122.42)]); + at(REFRESH); + f.layer.update([jet("sim-1", 37.60, -122.42)]); + assert.equal(f.segments(), 0, "an unmoved aircraft has nothing to draw"); + at(REFRESH * 2); + f.layer.update([jet("sim-1", 37.20, -122.42)]); + assert.equal(f.segments(), 0, "the wrap was recorded as a leg"); + }); +}); + +// ---- Targets that go quiet ------------------------------------------------- + +/** + * `TRACK_GRACE_SECONDS`, from both ends. + * + * An ADS-B receiver losing line of sight for one refresh is routine, and the + * layer used to answer it by deleting the track — throwing away up to + * `TRAIL_SECONDS` of history to survive a gap of one. What makes the grace worth + * having is not that the mesh stays in the group, it is that the history does, + * so the two tests below are "does it come back with its trail" and "does it + * ever actually leave". + */ +describe("an aircraft missing from a snapshot", () => { + const OTHER = "ual2"; + const LOST = "swa9"; + + /** Two aircraft, both with a leg behind them, at t = 0 and t = REFRESH. */ + function pair(f: Fixture) { + at(0); + f.layer.update([jet(OTHER, 37.70, -122.40), jet(LOST, 37.50, -122.30)]); + at(REFRESH); + f.layer.update([jet(OTHER, 37.70, -122.375), jet(LOST, 37.50, -122.275)]); + assert.equal(f.meshes().length, 2); + assert.equal(f.segments(), 2, "one leg each"); + } + + it("survives a gap shorter than the grace period", () => { + const f = fixture(); + pair(f); + + // Gone from every snapshot from here on. The first one is what sets + // `missingSince`, so the clock that matters starts at 2 × REFRESH. + const lostAt = REFRESH * 2; + at(lostAt); + f.layer.update([jet(OTHER, 37.70, -122.35)]); + at(lostAt + TRACK_GRACE_SECONDS - 1); + f.layer.update([jet(OTHER, 37.70, -122.325)]); + + assert.equal(f.meshes().length, 2, "a target one second inside the grace period was dropped"); + }); + + it("is forgotten once the grace period is past", () => { + const f = fixture(); + pair(f); + const lostAt = REFRESH * 2; + at(lostAt); + f.layer.update([jet(OTHER, 37.70, -122.35)]); + + const gone = f.meshes()[1]; + assert.ok(gone, "the second aircraft has no mesh to lose"); + + at(lostAt + TRACK_GRACE_SECONDS + 1); + f.layer.update([jet(OTHER, 37.70, -122.325)]); + + assert.equal(f.meshes().length, 1, "a target well past the grace period is still here"); + assert.equal(gone.parent, null, "the mesh was dropped from `tracks` but left in the scene"); + }); + + /** + * The point of holding the track at all: a target that comes back inside the + * window **resumes**. Rebuilding is the failure this replaced, and it is + * invisible in a screenshot — the aircraft is in the right place either way, + * it is just dragging a stub instead of the minute of history it had. + */ + it("resumes its trail rather than rebuilding it", () => { + const f = fixture(); + pair(f); + const lostAt = REFRESH * 2; + at(lostAt); + f.layer.update([jet(OTHER, 37.70, -122.35)]); + + // Back after 21 s away, having flown on: ~0.028° of longitude is ~26 units, + // spread over a span the layer clamps to `MAX_SPAN`, so ~0.9 units a second + // and nothing like a teleport. + at(lostAt + 21); + f.layer.update([jet(OTHER, 37.70, -122.325), jet(LOST, 37.50, -122.247)]); + + /** + * Five legs across the two aircraft: three for the one that never left + * (four observations), and two for the one that came back — its original + * leg, still there, plus the long one it flew while nobody could hear it. + * + * A rebuilt track is what this number is really measuring. Delete the grace + * period and the returning aircraft arrives as a brand-new track with one + * observation and no trail at all, and this reads 3. + */ + assert.equal(f.segments(), 5, "the returning aircraft rebuilt its trail from nothing"); + }); +}); + +// ---- The frozen ghost ------------------------------------------------------ + +/** + * Holding a track is not the same as going on drawing the aeroplane. + * + * With the grace period in and this half missing, a target that genuinely left + * the feed hung in the air at full opacity, trail attached, for thirty-two + * seconds — indistinguishable from an aircraft that had stopped flying. The + * godmode traffic dial made it unmissable: 400 fabricated aircraft turned down + * to zero left 400 darts nailed to the sky. + * + * The fix is gated on having run out of interpolation rather than on being + * missing, which is what this pair of assertions is really about: the *first* + * one is the one that would catch an over-eager fix, because hiding a target the + * instant it is absent makes every aircraft blink on a single dropped refresh. + */ +describe("an aircraft that has left the feed", () => { + const LAT = 37.66; + const LNG = -122.30; + const LEG = 0.02; + + function departing(): { f: Fixture; mesh: THREE.Mesh; x0: number; x1: number } { + const f = fixture(); + at(0); + f.layer.update([jet("dal4", LAT, LNG)]); + at(REFRESH); + f.layer.update([jet("dal4", LAT, LNG + LEG)]); + const [mesh] = f.meshes(); + assert.ok(mesh); + const [x0] = world.project(LAT, LNG); + const [x1] = world.project(LAT, LNG + LEG); + return { f, mesh, x0, x1 }; + } + + it("keeps flying to where it was last seen, without blinking", () => { + const { f, mesh, x0, x1 } = departing(); + + at(REFRESH + 1); + f.layer.update([]); // the snapshot it is missing from + assert.equal(mesh.visible, true, "one absent snapshot must not make an aircraft blink"); + + at(REFRESH + REFRESH / 2); + f.layer.tick(); + assert.equal(mesh.visible, true, "hidden while it was still arriving"); + assertNear(mesh.position.x, (x0 + x1) / 2, "a missing aircraft stopped moving early"); + assert.equal(f.segments(), 1, "its trail should still be drawn while it is"); + }); + + it("stops being drawn once it has finished arriving", () => { + const { f, mesh } = departing(); + + at(REFRESH + 1); + f.layer.update([]); + + // Past the end of the leg — `span` is REFRESH and the newest observation was + // at REFRESH — but still well inside the grace period, so nothing has been + // deleted and this is purely about what is drawn. + at(REFRESH * 3); + f.layer.tick(); + + assert.equal(mesh.visible, false, "a target that left the feed is frozen in mid-air"); + assert.equal(f.segments(), 0, "its trail is still being drawn under a hidden aircraft"); + assert.equal(f.meshes().length, 1, "the track itself should be held, not deleted"); + assert.notEqual(mesh.parent, null, "the mesh left the group before its grace ran out"); + }); +}); + +// ---- The ceiling ----------------------------------------------------------- + +describe("more aircraft than the trail buffer was sized for", () => { + /** + * `MAX_TRACKS` was declared and then referenced only by the buffer sizing, so + * `tracks` grew without limit and `rebuildTrails` ran off the end of the + * vertex array — which stopped being theoretical the moment the godmode dial + * could put four hundred aircraft in the sky. + * + * Note which half is dropped. Every aircraft keeps its dart; what the ones + * past the ceiling lose is the trail, because a sky missing eight trails reads + * as a sky, and a sky missing eight aeroplanes reads as a bug. + */ + it("gives every aircraft a dart and the first MAX_TRACKS of them a trail", () => { + const f = fixture(); + const count = MAX_TRACKS + 8; + const flock = (dLng: number) => + Array.from({ length: count }, (_, i) => + jet(`ac${i}`, 37.4 + (i % 20) * 0.01, -122.6 + Math.floor(i / 20) * 0.01 + dLng), + ); + + at(0); + f.layer.update(flock(0)); + at(REFRESH); + f.layer.update(flock(0.02)); + + assert.equal(f.meshes().length, count, "aircraft past the ceiling lost their dart, not their trail"); + // One leg each, so the segment count is the number of tracks being drawn. + assert.equal(f.segments(), MAX_TRACKS, `${count} tracks should draw ${MAX_TRACKS} trails`); + }); + + /** + * A track longer than the buffer remembers loses its **oldest** end. + * + * The trail is written tail-first, so the natural way to write this loop — + * stop when the buffer is full — drops the segments nearest the aircraft, and + * that is the worst possible end to lose: a streak left floating in open air + * with no aeroplane attached to it reads as a rendering fault rather than as a + * shortened trail. + * + * The same ordering governs `trim`, which is the path that is actually + * reachable here — see the note below the test — so the property is asserted + * where it bites: after more observations than `TRAIL_POINTS`, the drawn range + * still *ends* on the aircraft, and what it no longer contains is the + * beginning of the flight. + * + * Two-second polls rather than ten, so that the count limit is what binds and + * not `TRAIL_SECONDS`: 92 observations at 2 s is 184 s of history against a + * 240 s ceiling, so every sample dropped below is dropped for being old in + * *rank*, which is the thing under test. + */ + it("drops the beginning of a long flight and keeps the end attached to the aircraft", () => { + const f = fixture(); + const observations = TRAIL_POINTS + 20; + const lat = 37.74; + const lng0 = -122.5; + // ~1.9 scene units per step, i.e. under a unit a second. Nothing near a jump. + const step = 0.002; + const gap = 2; + + for (let i = 0; i < observations; i += 1) { + at(i * gap); + f.layer.update([jet("nrt7", lat, lng0 + i * step)]); + } + + assert.equal( + f.segments(), + TRAIL_POINTS - 1, + "the trail should saturate at the retained-sample count, not keep growing", + ); + + const drawn = f.drawnPositions(); + const [mesh] = f.meshes(); + assert.ok(mesh); + + // The last vertex written is the interpolated head, which is the aircraft. + const lastX = drawn[drawn.length - 3]; + assert.ok(lastX !== undefined, "nothing was drawn"); + assertNear(lastX, mesh.position.x, "the trail does not reach the aircraft"); + + // The first is the oldest observation still retained — number 20, not + // number 0 — which is what "loses the oldest, not the newest" means when + // read off the buffer. + const oldestKept = observations - TRAIL_POINTS; + const [xKept] = world.project(lat, lng0 + oldestKept * step); + const [xStart] = world.project(lat, lng0); + const firstX = drawn[0]; + assert.ok(firstX !== undefined); + assertNear(firstX, xKept, "the trail starts somewhere other than its oldest retained sample"); + assert.ok( + Math.abs(firstX - xStart) > 1, + "the trail still reaches back to the start of the flight, so nothing was trimmed", + ); + }); +});