/**
 * The few satellites you are actually looking at, drawn as satellites.
 *
 * `satellites.ts` renders the whole catalogue as one `THREE.Points` cloud at a
 * fixed 3.5 screen pixels, and that is the right way to draw six thousand
 * objects: a dot on a dome is a *direction*, which is the only thing about a
 * satellite that survives the projection this engine has to make (read that
 * file's header before this one — the argument for the dome is made there and is
 * not repeated here). What a dot cannot do is say what the object *is*. Every
 * point in that cloud looks like every other point, so a Starlink train reads as
 * a line of specks, and the constellation whose entire visual signature is one
 * enormous solar panel hanging off one side of a flat box reads as nothing at
 * all.
 *
 * So this layer draws geometry for the handful nearest the middle of the view
 * and leaves everything else as points. Two instanced meshes, sixty-four
 * instances, two draw calls, and the dots go on being dots underneath.
 *
 * ### One array, on one side
 *
 * The shape is the point of the whole file, so it is worth being blunt about
 * what it is not. The satellite everybody draws is a cube with two symmetric
 * wings — the Hubble/comsat silhouette that has meant "spacecraft" since the
 * seventies. A Starlink is not that and has never been that. It is a **flat
 * rectangular bus** — flat because sixty of them stack in a fairing like plates,
 * which is the design decision the entire constellation is built on — with a
 * **single solar array** that unrolls from one edge and is two to three times
 * the length of the bus it hangs off. The thing is profoundly lopsided, and that
 * asymmetry is what you would recognise if you could see one.
 *
 * Drawing two symmetric wings here would be worse than drawing nothing, because
 * it would be a confident, legible, wrong answer. The whole reason to promote a
 * dot to a mesh is to say something true about the object.
 *
 * ### Deliberately, enormously, not to scale
 *
 * A Starlink is about ten metres across the deployed array, at a range of
 * roughly 550 km. That is 1.8e-5 radians, near enough four arcseconds — at this
 * scene's 42° field of view over a thousand-pixel canvas, **a fiftieth of a
 * pixel**. There is no honest scale at which this layer draws anything at all.
 *
 * `satellites.ts` already made the same concession in the other direction: its
 * dots are 3.5 px regardless of range, because an object at effectively infinite
 * distance has an apparent size set by the eye and not by the geometry. This
 * file takes that further and says so plainly. `SPAN_FRACTION` puts the drawn
 * satellite at about a degree of arc — twice the moon, roughly twenty pixels,
 * some nine hundred times its true angular size. The number was chosen as the
 * smallest one at which the array-and-bus silhouette is still readable, and it
 * carries exactly as much information as the dome's radius does, which is none.
 *
 * What *is* true is everything angular. The dome falsifies distance and
 * preserves direction, so anything that can be expressed as an angle at the
 * observer can still be right — and two of them are:
 *
 *   - **The attitude.** A Starlink flies nadir-pointing, belly to the ground.
 *     Seen from here that is not the same as "belly toward the observer" except
 *     when it is directly overhead; at the horizon the nadir direction is 67°
 *     off the line of sight and you are looking at the thing nearly edge-on.
 *     `nadirOf` works that angle out from the range the fix already carries, so
 *     one overhead shows you its antenna face and one low in the north-west is
 *     a foreshortened sliver. That difference is free and it is real.
 *   - **The phase.** A satellite has phases for the same reason the moon does,
 *     and it is why a Starlink pass is a dusk-and-dawn event rather than a
 *     midnight one: at local midnight the object is in the earth's shadow, and
 *     at noon the sun is *behind* it from here and you are looking at its dark
 *     side. `phase` below is that geometry, and the array brightening as it
 *     approaches full is the flare people photograph.
 *
 * ### What this layer does not do
 *
 * It does not tell `satellites.ts` to stop drawing dots for the objects it has
 * promoted, and it should not: the dot lands dead centre on the bus, is 3.5 px
 * across against a mesh twenty px across, and reads as the specular glint off
 * the chassis. Suppressing it would cost a coupling between two layers to make
 * the picture slightly worse.
 */

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 — and applied *here*, which is now the whole point
 * of it.
 *
 * `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 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.
 */

/**
 * Ceiling on meshes, and the reason the layer is affordable at all.
 *
 * Sixty-four is well past what is ever in shot at once — a busy Starlink sky
 * over one city is a few hundred objects spread over the whole hemisphere, of
 * which the selection cone below takes maybe a dozen — so in practice the cap
 * never binds and exists to bound the buffers. Both instance buffers are
 * allocated once at this size and `count` is moved, which is the cheap
 * operation; growing an `InstancedMesh` means building a new one.
 */
const MAX_MESHES = 64;

/**
 * The selection cone, in degrees off the camera's own axis: full size inside
 * `SELECT_FULL_DEG`, gone by `SELECT_EDGE_DEG`.
 *
 * Off the view *centre* rather than merely on screen, and that is the whole
 * selection rule: the geometry should be where the user is looking. The scene's
 * field of view is 42° vertical, so 12° is the middle third of the frame at full
 * size and 26° reaches into the corners — a satellite drifting in from the edge
 * has most of the screen width to grow across.
 *
 * Ranking by this angle rather than by range is deliberate. Every object on the
 * dome is at the same radius, so range sorts by where the *camera* is and not by
 * what it is aimed at, and an orbiting camera would see the meshes migrate
 * around the sky for no reason the user could name.
 */
const SELECT_FULL_DEG = 12;
const SELECT_EDGE_DEG = 26;

/**
 * How far from the camera a mesh survives, as a multiple of the dome radius.
 *
 * This is the other half of the fade, and it exists because the camera can get
 * outside the dome — `scene.ts` puts `maxDistance` at 2.0 board spans against a
 * dome at about 0.99, precisely so the constellation can be looked at from
 * above. From out there the near side of the dome is about one radius away and
 * still worth drawing; the far side is three, where a twenty-pixel satellite has
 * become four pixels of noise sitting on top of a dot that says the same thing
 * more clearly. So the far side goes back to being points.
 *
 * At the other end of the zoom the camera is near the origin, every point on the
 * dome is one radius away, and this term is a constant 1 — it never interferes
 * with the case it is not there for.
 */
const RANGE_FULL = 1.15;
const RANGE_EDGE = 2.2;

/**
 * How many slots at the tail of the ranked list fade out, when the cap binds.
 *
 * Belt and braces. The cone fade already means the objects nearest the cut are
 * the ones nearest the cone's edge and therefore already small — but that is a
 * statement about a *typical* sky, and a genuinely dense cone would put sixty-
 * fourth place somewhere near the middle of the screen at full size, popping in
 * and out as the ordering churned. Applied only when there are more candidates
 * than slots, so a sparse sky never sees it.
 */
const RANK_FADE_SLOTS = 8;

/**
 * Tip-to-tip size of a drawn satellite, as a fraction of the dome radius.
 *
 * Since the dome radius is also roughly how far away these things are, this is
 * very nearly the angular size in radians: 0.016 rad is 0.92°, about twenty
 * pixels at this field of view. See the header for why that is nine hundred
 * times too big and why the alternative is a layer that renders nothing.
 */
const SPAN_FRACTION = 0.016;

/**
 * Elevation below which a satellite is not promoted, in degrees.
 *
 * The same number as `HORIZON_FADE_DEG` in `satellites.ts` and for the same
 * reason — an object a degree up is behind the hills and behind more air than it
 * can be seen through — restated because that constant is private too. It has to
 * agree with the dot layer's or the mesh would fade in over a dot that was
 * fading out.
 */
const HORIZON_FADE_DEG = 8;

/**
 * Brightness of a satellite whose lit side is facing entirely away, relative to
 * one at full phase.
 *
 * Not zero, for the reason `satellites.ts` gives for `SHADOW_ALPHA`: the
 * physically honest answer is that you cannot see it, and a layer that draws
 * nothing at noon reads as broken rather than as correct. Higher than that
 * file's 0.16 because a shape has to be legible to be a shape, where a dot only
 * has to be present.
 */
const PHASE_FLOOR = 0.42;

/**
 * Brightness in the earth's umbra. Deliberately `SHADOW_ALPHA` from
 * `satellites.ts`, so that a satellite entering eclipse dims by the same factor
 * whether it is currently a dot or a mesh — the moment the two layers disagree
 * about that is the moment a mesh crossing the terminator visibly steps.
 */
const ECLIPSE_FLOOR = 0.16;

/**
 * The bus. Pale because it is: white thermal blanket and bare aluminium, which
 * is the brightest thing on the spacecraft and most of what a naked-eye pass
 * actually is.
 */
const BUS_COLOR = new THREE.Color(0xd7dde6);

/**
 * The array, unlit and lit.
 *
 * Solar cells are the *darkest* part of any spacecraft — they are built to
 * absorb, and they reflect under a tenth of what hits them — so the array is a
 * near-black silhouette against a daylit sky, which is exactly the read the
 * asymmetry needs. `ARRAY_GLINT` is the other half of the same fact: at high
 * phase the cover glass throws a specular sheet back at the observer and the
 * panel flares steely blue. Interpolated on the cube of the phase so the flare
 * happens in the last part of the approach to full and not gradually across it.
 */
const ARRAY_COLOR = new THREE.Color(0x121a2c);
const ARRAY_GLINT = new THREE.Color(0x9db4d6);

/**
 * The spacecraft, in metres of real spacecraft.
 *
 * Built at true proportions and shrunk by exactly one number (`scale`, below),
 * so the lie about size lives in one place and the shape stays honest. Roughly
 * three metres of bus against eight of array is the ratio that matters; the
 * absolute figures are approximate and nothing downstream reads them as fact.
 *
 * Axes are the local frame every matrix below is built in: **+X is the boom**,
 * along which the array deploys, **+Y is zenith** so that −Y is the nadir face
 * carrying the phased array, and +Z is what is left over.
 */
const BUS_LENGTH = 3.2;
const BUS_DEPTH = 1.6;
const BUS_THICK = 0.28;
const ARRAY_LENGTH = 8.4;
const ARRAY_WIDTH = 1.5;
const ARRAY_THICK = 0.06;
const BOOM_GAP = 0.6;
const BOOM_RADIUS = 0.08;

/** Centre of the array, in the same frame. It hangs off +X and only +X. */
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 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. */
const EARTH_RADIUS_KM = 6371;

/**
 * A direction *toward* the sun in the engine's axes — exactly what
 * `solar.ts`'s `sunDirection` returns, and structurally a `THREE.Vector3`, so a
 * caller holding either can pass it straight in.
 */
export interface SunVector {
  readonly x: number;
  readonly y: number;
  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;
  /**
   * Redraw from the same fix list `SatelliteLayer.update` is given.
   *
   * The camera is a parameter rather than something the layer remembers because
   * the selection is a function of where it is aimed *this frame*, and the sun
   * is a parameter for the same reason `SatelliteCatalogue.fixes` takes a
   * `when`: godmode scrubs the clock, and a layer that quietly called
   * `solarPosition(new Date())` would be the one thing in the scene still
   * pointing its solar panels at yesterday afternoon.
   */
  update(fixes: readonly SatelliteFix[], camera: THREE.Camera, sun: SunVector): void;
  /**
   * How dark the sky is, 0..1 — `nightFactor(sun.elevation)`, the same number
   * `SatelliteLayer` takes and from the same call site.
   *
   * These buses are unlit white boxes (`MeshBasicMaterial({ color: 0xffffff })`)
   * because that is what a sunlit satellite twenty pixels across looks like
   * against a night sky. Under a tone curve, against a *daytime* sky, they are
   * the literal white squares live defect 1 named — the most damaging thing on
   * the board at first load, and drawn at 15:55 with the sun at +44°, where no
   * naked eye would see a satellite at all.
   */
  setSkyDarkness(darkness: number): void;
  setVisible(visible: boolean): void;
  dispose(): void;
}

/**
 * One satellite that got through the filters, with everything the ranking and
 * the draw need.
 *
 * These are pooled and reused rather than built per frame. `update` runs at 60
 * Hz over a few hundred fixes, and a few hundred short-lived objects a frame is
 * twenty thousand a second of pure garbage for a layer whose entire job is to
 * be cheap enough to leave on.
 */
interface Candidate {
  fix: SatelliteFix | null;
  /** 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;
  /** Where on the dome it sits, in scene space. */
  readonly at: THREE.Vector3;
}

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();

  /**
   * `MeshBasicMaterial`, and the scene's lights are deliberately ignored.
   *
   * This is the one decision here that looks like laziness and is not. A
   * `MeshLambertMaterial` would be lit by the city's rig — and that rig is a
   * model of the light *at the ground*, which after sunset is a tenth of an
   * intensity with the sun pushed below the horizon. A Starlink is visible
   * precisely when the ground is dark and the satellite is not, so shading these
   * with the city's sun would black out the constellation at exactly the hour it
   * exists to be looked at, and light it in the middle of the day when it cannot
   * be seen at all. Backwards in both directions.
   *
   * So the shading is computed per instance on the CPU — the phase term in
   * `update` — and written into `instanceColor`, which a basic material
   * multiplies straight into its diffuse. It costs one dot product per drawn
   * satellite, of which there are at most sixty-four, and it is the only shading
   * model in this file that has the satellite's own geometry to work from rather
   * than the city's.
   *
   * `fog: false` for the reason `satellites.ts` states for its points and which
   * is, if anything, stronger for a solid: the city's linear fog reaches its far
   * plane at 2.8 board spans, so a mesh out on the dome would be mixed most of
   * the way to the fog colour and the constellation would dim as the camera
   * pulled back, exactly as more of it came into view. Haze belongs to the
   * twelve kilometres of air a city sits in. This is 550 km above all of it.
   */
  const busMaterial = new THREE.MeshBasicMaterial({ color: 0xffffff, fog: false });

  /**
   * Whether the caller wants this layer, and whether the sky is dark enough for
   * it to be honest. Both have to be true, and they are two different questions:
   * the first is a godmode switch and the second is the hour.
   */
  let wanted = true;
  let skyDarkness = 1;
  const arrayMaterial = new THREE.MeshBasicMaterial({
    color: 0xffffff,
    fog: false,
    /**
     * 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,
  });

  const bus = new THREE.InstancedMesh(busGeometry, busMaterial, MAX_MESHES);
  const array = new THREE.InstancedMesh(arrayGeometry, arrayMaterial, MAX_MESHES);
  bus.name = "starlink-bus";
  array.name = "starlink-array";
  for (const mesh of [bus, array]) {
    /**
     * `InstancedMesh` culls on a bounding sphere it computes **once** from the
     * instance matrices and then caches. Every matrix here is rewritten every
     * frame from a different set of satellites, so that sphere is stale from the
     * second frame onward and culling on it would cull the layer at random. The
     * cost of not culling is two draw calls that were going to happen anyway.
     */
    mesh.frustumCulled = false;
    mesh.count = 0;
    group.add(mesh);
  }

  /**
   * Metres of spacecraft to scene units. The one place the size lie is told.
   */
  const scale = (SPAN_FRACTION * domeRadius) / MODEL_SPAN;

  const pool: Candidate[] = [];

  /**
   * 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<Candidate | undefined>(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();
  const toSat = new THREE.Vector3();
  const radial = new THREE.Vector3();
  const nadir = new THREE.Vector3();
  const zenith = new THREE.Vector3();
  const boom = new THREE.Vector3();
  const third = new THREE.Vector3();
  const perpendicular = new THREE.Vector3();
  const scaleVec = new THREE.Vector3();
  const busMatrix = new THREE.Matrix4();
  const arrayMatrix = new THREE.Matrix4();
  const hinge = new THREE.Matrix4();
  const tint = new THREE.Color();

  function slot(index: number): Candidate {
    const existing = pool[index];
    if (existing !== undefined) return existing;
    const made: Candidate = { fix: null, score: UNUSED_SCORE, fade: 0, at: new THREE.Vector3() };
    pool.push(made);
    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.
   *
   * The same arithmetic as `satellites.ts`'s own `place`, restated because it is
   * a closure in there, and it must stay identical: azimuth is clockwise from
   * north, scene north is −Z and east is +X, which is `sin` on X and `−cos` on Z
   * with no sign fudge anywhere. Get it wrong and the meshes are a mirror image
   * of the dots they are supposed to be sitting on.
   */
  function place(fix: SatelliteFix, into: THREE.Vector3): void {
    const cosEl = Math.cos(fix.elevation);
    into.set(
      Math.sin(fix.azimuth) * cosEl * domeRadius,
      Math.sin(fix.elevation) * domeRadius,
      -Math.cos(fix.azimuth) * cosEl * domeRadius,
    );
  }

  /**
   * Which way is down, from the satellite's point of view, expressed as a
   * direction in the observer's sky.
   *
   * Not `-radial`. That would be "point the belly at the middle of the board",
   * which is right for a satellite at the zenith and increasingly wrong as it
   * descends: the spacecraft's nadir points at the *earth's centre*, and the
   * observer is not the earth's centre. The angle between the two — the nadir
   * angle η, the same one a ground station's link budget is written in — grows
   * to about 67° at the horizon for a 550 km orbit, which is the difference
   * between seeing the antenna face and seeing the edge of the chassis.
   *
   * It falls out of the triangle centre–observer–satellite with no new inputs,
   * because the fix already carries the range. With Re the earth's radius, r the
   * slant range and e the elevation, the satellite's geocentric radius is
   *
   *     Rs² = Re² + r² + 2·Re·r·sin e
   *
   * (law of cosines, the interior angle at the observer being 90° + e), and then
   * the law of sines gives sin η = Re·cos e / Rs directly. At e = 0 and 550 km
   * that is 6371/6921 = 0.92, so η = 67°; at the zenith it is 0 and the belly
   * genuinely does point at the observer.
   *
   * The rotation is in the vertical plane through the satellite, tilted from the
   * line of sight *downward* — away from the zenith — because the sub-satellite
   * point is further from the observer than the observer is from themselves. The
   * cheap check: at e = 45° over the north this returns very nearly straight
   * down with a slight lean back toward the south, which is where the ground
   * under the satellite is relative to the ground under the viewer.
   */
  function nadirOf(fix: SatelliteFix, up: THREE.Vector3, out: THREE.Vector3): void {
    const rs = Math.sqrt(
      EARTH_RADIUS_KM ** 2 +
        fix.rangeKm ** 2 +
        2 * EARTH_RADIUS_KM * fix.rangeKm * Math.sin(fix.elevation),
    );
    const eta =
      rs > 0 ? Math.asin(clamp((EARTH_RADIUS_KM * Math.cos(fix.elevation)) / rs, 0, 1)) : 0;

    // The line of sight, satellite to observer.
    out.copy(up).negate();

    // The downward-pointing unit vector perpendicular to it, in the vertical
    // plane: −Y with its component along the line of sight projected out.
    perpendicular.set(0, -1, 0).addScaledVector(out, out.y);
    const length = perpendicular.length();
    // Zero only when the line of sight is itself vertical — the satellite is at
    // the zenith — where η is zero as well and the answer is already correct.
    if (length < 1e-6) return;

    perpendicular.divideScalar(length);
    out.multiplyScalar(Math.cos(eta)).addScaledVector(perpendicular, Math.sin(eta)).normalize();
  }

  function update(fixes: readonly SatelliteFix[], camera: THREE.Camera, sun: SunVector): void {
    if (!group.visible) return;

    /**
     * Both of these call `updateWorldMatrix` on the way through, which matters:
     * the renderer updates the world matrices during `render`, so a layer
     * ticked before it is looking at last frame's camera. One frame of lag in a
     * *position* is invisible; one frame of lag in a selection rule means the
     * meshes trail the aim during an orbit, which is the artefact this layer
     * would be blamed for.
     */
    camera.getWorldPosition(eye);
    camera.getWorldDirection(forward);

    sunDir.set(sun.x, sun.y, sun.z);
    // A zero sun direction has no meaning and would make every basis below
    // degenerate. Straight up is arbitrary and keeps the geometry well-formed.
    if (sunDir.lengthSq() < 1e-12) sunDir.set(0, 1, 0);
    else sunDir.normalize();

    let found = 0;
    for (const fix of fixes) {
      /**
       * Starlink only, and the file is named for it.
       *
       * This shape is a specific spacecraft, not a generic satellite: a GPS bird
       * is a drum with two wings and the ISS is neither. Drawing a Galileo
       * satellite with a Starlink's single unrolled array would be the same
       * error as the two-symmetric-wings clip-art, only pointed the other way.
       * Every other group stays a dot, which claims nothing.
       */
      if (fix.group !== "starlink") continue;

      // `> 0` rather than `>= 0` and written to fail on NaN, for the reason
      // `SatelliteCatalogue.fixOne` gives: a degenerate element set produces NaN
      // look angles, and a NaN in an instance matrix takes out the whole
      // instanced draw rather than one satellite.
      const elevationDeg = fix.elevation * RAD;
      if (!(elevationDeg > 0)) continue;
      const horizon = Math.min(1, elevationDeg / HORIZON_FADE_DEG);

      const candidate = slot(found);
      place(fix, candidate.at);

      toSat.subVectors(candidate.at, eye);
      const distance = toSat.length();
      // The camera standing exactly on a satellite has no direction to it. It
      // cannot happen from any reachable pose; it costs one compare to make sure
      // it cannot produce a NaN either.
      if (distance < 1e-6) continue;

      const offDeg = Math.acos(clamp(toSat.dot(forward) / distance, -1, 1)) * RAD;
      const aim = falloff(offDeg, SELECT_FULL_DEG, SELECT_EDGE_DEG);
      if (aim <= 0) continue;

      const range = falloff(distance / domeRadius, RANGE_FULL, RANGE_EDGE);
      if (range <= 0) continue;

      candidate.fade = horizon * aim * range;
      candidate.score = offDeg;
      candidate.fix = fix;
      found += 1;
    }

    // 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) {
        stale.fix = null;
        stale.score = UNUSED_SCORE;
      }
    }

    const drawn = rankBest(found);
    for (let i = 0; i < drawn; i++) {
      const candidate = ranked[i];
      const fix = candidate?.fix;
      if (candidate === undefined || !fix) continue;

      radial.copy(candidate.at).normalize();
      nadirOf(fix, radial, nadir);
      zenith.copy(nadir).negate();

      /**
       * Yaw steering, which is what the real spacecraft does and what makes one
       * hinge sufficient.
       *
       * The array has a single degree of freedom — it rotates about the boom —
       * so it can only face the sun if the boom is perpendicular to the sun to
       * begin with. A real satellite achieves that by rotating its whole body
       * about the nadir axis as it goes round the orbit, which costs it nothing
       * because nadir-pointing leaves that rotation free. Choosing the boom as
       * `zenith × sun` is exactly that manoeuvre, solved in closed form: it is
       * perpendicular to the nadir axis, so the bus is still belly-down, and
       * perpendicular to the sun, so the hinge below can then aim the panel
       * dead-on rather than approximately.
       *
       * The cross product collapses only when the sun is straight up from the
       * satellite — the subsolar point — where the hinge angle comes out zero
       * and any perpendicular gives the right answer anyway, which is why the
       * fallback can be arbitrary.
       */
      boom.crossVectors(zenith, sunDir);
      if (boom.lengthSq() < 1e-8) anyPerpendicular(zenith, boom);
      boom.normalize();
      third.crossVectors(boom, zenith);

      /**
       * The hinge. The array's face is local +Y, so after a rotation of θ about
       * the boom it points along cos θ · zenith + sin θ · third, and the θ that
       * lands it on the sun is the arctangent of the sun's components in that
       * plane. Because the boom was chosen perpendicular to the sun, the sun has
       * no component outside the plane and this is exact rather than nearest.
       */
      const theta = Math.atan2(sunDir.dot(third), sunDir.dot(zenith));

      /**
       * The fade is a *scale*, not an opacity, and that is what makes the
       * transition from point to mesh invisible.
       *
       * Opacity was the obvious version and is worse in three ways: a standard
       * material has no per-instance alpha, so it would have taken a shader
       * patch; transparency would have forced `depthWrite: false` and let the
       * bus and the array punch holes in each other; and a half-transparent
       * satellite over a half-bright dot is a muddier picture than either. A
       * mesh scaled to a fifth is *smaller than the dot it is standing on* and
       * simply hides inside it, so the object grows out of its own point and
       * shrinks back into it. `falloff` is a smoothstep, so the size ramp has
       * zero derivative at both ends and there is no moment where it starts.
       *
       * `rank` is the same trick applied to the cap rather than to the cone:
       * the last few slots of a list that has run out of room shrink away, so
       * the object bumped by the sixty-fifth arrival was already tiny when it
       * went. It is 1 whenever the cap is not binding, which is nearly always.
       */
      const rank =
        found > MAX_MESHES ? clamp((MAX_MESHES - i) / RANK_FADE_SLOTS, 0, 1) : 1;
      scaleVec.setScalar(scale * candidate.fade * rank);

      busMatrix.makeBasis(boom, zenith, third).scale(scaleVec).setPosition(candidate.at);
      bus.setMatrixAt(i, busMatrix);

      /**
       * The array rides the same origin and basis as the bus with the hinge
       * rotation inserted, and its offset down the boom is baked into its
       * geometry rather than into this matrix — which is why rotating about the
       * boom pivots the panel about the hinge instead of swinging it around the
       * bus. Same position, same scale, one extra rotation.
       */
      hinge.makeRotationX(theta);
      arrayMatrix
        .makeBasis(boom, zenith, third)
        .multiply(hinge)
        .scale(scaleVec)
        .setPosition(candidate.at);
      array.setMatrixAt(i, arrayMatrix);

      /**
       * Phase, exactly as for the moon: how much of the lit side is turned this
       * 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
       * in shadow.
       *
       * (The sun's direction from 550 km up differs from its direction at the
       * ground by about a thousandth of a degree, so the scene's own vector is
       * used without correction.)
       */
      const phase = clamp(-radial.dot(sunDir), 0, 1);
      const lit = ECLIPSE_FLOOR + (1 - ECLIPSE_FLOOR) * (1 - clamp(fix.shadow, 0, 1));
      const facing = PHASE_FLOOR + (1 - PHASE_FLOOR) * phase;

      tint.copy(BUS_COLOR).multiplyScalar(facing * lit);
      bus.setColorAt(i, tint);
      tint.copy(ARRAY_COLOR).lerp(ARRAY_GLINT, phase ** 3).multiplyScalar(lit);
      array.setColorAt(i, tint);
    }

    bus.count = drawn;
    array.count = drawn;
    bus.instanceMatrix.needsUpdate = true;
    array.instanceMatrix.needsUpdate = true;
    // Allocated lazily by the first `setColorAt`, which on a sky with nothing
    // above the horizon has not happened yet.
    if (bus.instanceColor) bus.instanceColor.needsUpdate = true;
    if (array.instanceColor) array.instanceColor.needsUpdate = true;
  }

  return {
    group,
    update,
    setSkyDarkness(darkness: number) {
      skyDarkness = Math.min(1, Math.max(0, darkness));
      // Whole-group rather than a per-material opacity, because these two
      // materials are opaque by design: making them `transparent` to fade them
      // would buy a sort order and a blend for objects that are never partly
      // visible — they are either in a sky you could see them in or they are not.
      group.visible = wanted && skyDarkness > 0.02;
    },
    /**
     * Unlike `SatelliteLayer.setVisible`, this one also stops the work — see the
     * early return in `update`. The distinction is not an inconsistency: that
     * layer keeps propagating while hidden because its state is a *sweep* that
     * would otherwise resume half a catalogue behind reality. This layer holds
     * no state between frames at all, so a hidden one has nothing to catch up
     * on and the next visible frame is complete.
     */
    setVisible(visible: boolean) {
      wanted = visible;
      group.visible = visible && skyDarkness > 0.02;
    },
    dispose() {
      // The instanced meshes first. `InstancedMesh.dispose()` releases the
      // per-instance matrix and colour buffers, which are the layer's own
      // allocation and are not reached by disposing the geometry they wrap —
      // two `Float32Array`s of 64 instances each, orphaned on the GL context on
      // every city switch until this line existed.
      bus.dispose();
      array.dispose();
      busGeometry.dispose();
      arrayGeometry.dispose();
      busMaterial.dispose();
      arrayMaterial.dispose();
      group.clear();
    },
  };
}

/**
 * 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 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
 * and this way it costs no second matrix.
 */
function buildBus(): THREE.BufferGeometry {
  const chassis = new THREE.BoxGeometry(BUS_LENGTH, BUS_THICK, BUS_DEPTH);

  const antenna = new THREE.BoxGeometry(BUS_LENGTH * 0.78, BUS_THICK * 0.45, BUS_DEPTH * 0.72);
  antenna.translate(0, -BUS_THICK * 0.6, 0);

  const stub = new THREE.CylinderGeometry(BOOM_RADIUS, BOOM_RADIUS, BOOM_GAP * 1.4, 6);
  // `CylinderGeometry` runs along +Y; the boom runs along +X.
  stub.rotateZ(Math.PI / 2);
  stub.translate(BUS_LENGTH / 2 + BOOM_GAP / 2, 0, 0);

  const parts = [chassis, antenna, stub];
  const merged = mergeGeometries(parts);
  for (const part of parts) part.dispose();
  if (merged) return merged;

  // 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);
}

/**
 * The array: one panel, on one side, offset down the boom in its own geometry so
 * that the instance matrix can be a pure rotation about the hinge.
 *
 * A box rather than a plane. A plane would halve the triangles and is the
 * obvious choice for something two centimetres thick at eight metres long — but
 * the edge is what you see during the part of the orbit where the panel is
 * turned away from you, and a zero-thickness panel vanishes completely at
 * exactly that moment. Six centimetres of scene-space thickness is a fiction in
 * the same way the overall size is, and it buys a silhouette that never
 * disappears.
 */
function buildArray(): THREE.BufferGeometry {
  const panel = new THREE.BoxGeometry(ARRAY_LENGTH, ARRAY_THICK, ARRAY_WIDTH);
  panel.translate(ARRAY_CENTRE_X, 0, 0);
  return panel;
}

/**
 * 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
 * anti-pop argument in one function.
 */
function falloff(x: number, full: number, edge: number): number {
  if (x <= full) return 1;
  if (x >= edge) return 0;
  const t = (x - full) / (edge - full);
  return 1 - t * t * (3 - 2 * t);
}

/**
 * Any unit vector perpendicular to `v`, for the one degenerate case where the
 * caller genuinely does not care which. Crossed against whichever world axis `v`
 * is least aligned with, because crossing against a near-parallel axis is how a
 * "just pick one" helper returns a zero vector.
 */
function anyPerpendicular(v: THREE.Vector3, out: THREE.Vector3): void {
  if (Math.abs(v.y) < 0.9) out.set(0, 1, 0).cross(v);
  else out.set(1, 0, 0).cross(v);
  out.normalize();
}

function clamp(x: number, lo: number, hi: number): number {
  return x < lo ? lo : x > hi ? hi : x;
}

/**
 * ---- Numbers a reviewer can check without running anything -----------------
 *
 * On the Bay Area board `boardRadius` is about 0.94 of a 1,003-unit span, so the
 * dome is at 990 units and a drawn satellite is 15.8 of them tip to tip — about
 * one and a half kilometres of city, at a
 * range of roughly 1,000 units, which is 0.92° of arc or some twenty pixels of a
 * 1,000-pixel canvas at this scene's 42° field of view.
 *
 * `nadirOf` at 550 km, checked against the numbers in its own derivation:
 *
 *   elevation 90°   range   550 km   Rs 6921   η  0.0°   belly at the observer
 *   elevation 45°   range   749 km   Rs 6921   η 40.6°   belly nearly straight
 *                                                        down, leaning back
 *                                                        toward the observer
 *   elevation 20°   range 1,294 km   Rs 6921   η 59.9°
 *   elevation  0°   range 2,704 km   Rs 6921   η 67.0°   seen edge-on
 *
 * Over the north those come out as nadir vectors of (0, −1, 0), (0, −0.997,
 * 0.077), (0, −0.984, 0.176) and (0, −0.921, 0.391) — the lean being southward,
 * back over the observer, and reaching 23° off vertical at the horizon.
 *
 * The orientation as a whole holds two invariants that are worth asserting if
 * this ever grows a test: the bus's local −Y lands exactly on the nadir vector
 * (dot 1.0000), and the array's face lands exactly on the sun (dot 1.0000) for
 * every azimuth, elevation and sun position, including the subsolar degeneracy
 * where the boom has to be guessed. The basis is right-handed throughout
 * (determinant +1), so nothing is drawn inside out.
 *
 * `phase` for a satellite at the zenith is `−sin(sun elevation)`: 0 with the sun
 * anywhere above the horizon, 0.5 with it 30° down, 1 at solar midnight — at
 * which point the same satellite is in the earth's shadow and `lit` has taken it
 * to 0.16 anyway. The band where a Starlink is both at high phase and out of
 * eclipse is the hour or so after sunset and before sunrise, which is when
 * anybody has ever seen one.
 */
