/**
 * The building itself: walls, floor slabs, ceilings, and the frames and glazing
 * that line the holes in the walls.
 *
 * Everything here comes out of a `Plan` and nothing here reads an `Office`. The
 * wall pass has already happened — a run is a solid piece of wall with its
 * openings taken out of it, and its numbers are already in office-world metres
 * with the level's elevation baked in — so this file is the arithmetic-free half
 * of the job: place a `wallRun` part per run, triangulate a polygon per room,
 * and merge.
 *
 * ### One mesh per wall, and why not fewer
 *
 * Merging every wall on a floor into one buffer would be one draw call instead
 * of forty, and it is the wrong trade. The occlusion fade — the walls between
 * the camera and what you are looking at going translucent, so the floorplan
 * stays readable from outside — swaps a *material* on a whole object, and an
 * object has to be one wall for that to mean anything. Forty extra draw calls is
 * a rounding error next to the ~1,200 objects `parts.ts` was written to
 * collapse; losing the ability to fade one wall is not.
 *
 * Each wall mesh therefore carries its 2-D segment and its top height in
 * `userData.wall`, which is everything the fade needs to decide without walking
 * geometry, and `setGhosted` is the swap. See CONTRACT.md §3, which is where
 * `ghostOf()` landed on the material registry for exactly this.
 *
 * ### Ceilings are a group, not a clip plane
 *
 * Orbit mode hides them wholesale (`shell.ceilings.visible = false`) and that is
 * the entire mechanism. No CSG, no clipping planes, no per-camera cutaway: a
 * dollhouse is a room with its lid off, and a lid is a thing you can take off.
 *
 * ### Stairs are built from the record a walker climbs
 *
 * `ASSET_RESEARCH.md` listed a stair as the catalogue's one missing piece, and
 * `mateo-court` shipped without one: a `STEEL` floor finish the shape of the
 * flight, no treads, with the comment "the kit has no stair asset". The reason
 * it belongs *here* rather than in `src/assets/office/` is the interesting part.
 * A prop is placed by a coordinate somebody typed; a flight of stairs has to
 * agree with the two footprints, the two floor heights and the dog-leg the walk
 * controller actually traverses, and every one of those is already in the
 * resolved `Transition`. Building the treads from that record rather than beside
 * it makes the failure `src/offices/README.md` used to warn about —
 * a staircase nobody can climb — structurally inexpressible: the drawn flight
 * and the walked one are the same list of points.
 */

import * as THREE from "three";
import type { MaterialRegistry, SurfaceRole } from "../assets/materials.ts";
import { MeshBin, parts as sharedParts, type PartBin } from "../assets/parts.ts";
import { TEXTURE_TILE_METRES } from "../assets/textures.ts";
import type {
  LevelPlan,
  Plan,
  ResolvedOpening,
  ResolvedRoom,
  ResolvedTransition,
  WallRun,
} from "./plan.ts";
import type { Outline, Point2 } from "./types.ts";

/** Jamb and head width on an opening's lining, in metres. */
const FRAME_WIDTH = 0.045;
/** How far a lining stands proud of its wall on each face, so it reads as a reveal. */
const FRAME_PROUD = 0.008;
/** Depth of a window's sill board past the wall face, per side. */
const SILL_PROUD = 0.03;

/**
 * The riser height a flight is divided into, in metres.
 *
 * 0.178 m is the middle of a commercial stair and is what makes the step count
 * come out right without a pack ever stating one: `mateo-court`'s 2.5 m flight
 * lands on fourteen risers, which is the number its own comment already claimed.
 * The flight is divided into a whole number of equal risers, never into
 * 0.178 m ones with a short step at the top — an uneven riser is the single most
 * reliable way to make a staircase read as wrong, and it is also how people fall
 * down real ones.
 */
const TARGET_RISER_M = 0.178;

/** Bounds on the division, so an absurd `elevation` cannot emit ten thousand boxes. */
const MIN_FLIGHT_STEPS = 2;
const MAX_FLIGHT_STEPS = 40;

/** Tread slab and riser board thickness, in metres. */
const TREAD_THICKNESS = 0.055;
const RISER_THICKNESS = 0.03;

export interface ShellOptions {
  materials: MaterialRegistry;
  /** Defaults to the shared bin, which is what everything else uses. */
  parts?: PartBin;
  /** Which levels to build. Defaults to every level in the plan. */
  levelIds?: readonly string[];
  /** Line the openings with frames and glaze the windows. Defaults to true. */
  openings?: boolean;
}

/**
 * What a wall mesh knows about itself, stamped on `userData.wall`.
 *
 * The segment is the wall's centreline in plan, which is what an occlusion test
 * wants: a camera-to-target ray crossing this line is looking through this wall.
 * `top` is there so a knee-high partition is never faded — you can see over it,
 * so it is not in the way.
 */
export interface WallInfo {
  wallId: string;
  levelId: string;
  from: Point2;
  to: Point2;
  /** Office-world metres. */
  bottom: number;
  top: number;
  role: SurfaceRole;
}

export interface Shell {
  /** Everything below, as one object to add to a scene. */
  group: THREE.Group;
  walls: THREE.Group;
  floors: THREE.Group;
  /** Hide this to get the dollhouse. */
  ceilings: THREE.Group;
  /**
   * Treads, risers and half landings, one merged mesh per surface.
   *
   * Separate from `floors` because a stair is not a slab and is not hidden with
   * a lid, and separate from `walls` because it must never be ghosted: fading
   * out the way upstairs when the camera happens to be behind it is worse than
   * seeing through it.
   */
  stairs: THREE.Group;
  /** Frames and glazing. Separate because glass must not cast a shadow. */
  openings: THREE.Group;
  /** Every wall mesh, each carrying a `WallInfo` on `userData.wall`. */
  wallMeshes: readonly THREE.Mesh[];
  /** Swap one wall between its own finish and the translucent copy of it. */
  setGhosted(mesh: THREE.Mesh, ghosted: boolean): void;
  dispose(): void;
}

/**
 * How far a slab is lifted per earlier slab it overlaps. See `liftOf`.
 *
 * 4 mm. Big enough to beat the depth buffer's resolution at office range — the
 * near plane is 0.2 m and the camera orbits within about a hundred metres, so a
 * 24-bit buffer resolves far finer than this — and small enough that a step
 * between two floor finishes is not a step anybody can see or trip over.
 */
const SLAB_LIFT = 0.004;

/** Do two outlines' axis-aligned bounding boxes intersect? See `liftOf`. */
function boxesOverlap(a: readonly Point2[], b: readonly Point2[]): boolean {
  const box = (points: readonly Point2[]) => {
    let minX = Infinity;
    let maxX = -Infinity;
    let minZ = Infinity;
    let maxZ = -Infinity;
    for (const p of points) {
      minX = Math.min(minX, p.x);
      maxX = Math.max(maxX, p.x);
      minZ = Math.min(minZ, p.z);
      maxZ = Math.max(maxZ, p.z);
    }
    return { minX, maxX, minZ, maxZ };
  };
  const one = box(a);
  const two = box(b);
  // Touching edge-to-edge is not overlapping: the reference office's rooms abut
  // along shared lines everywhere and must not all be lifted for it.
  return one.minX < two.maxX && two.minX < one.maxX && one.minZ < two.maxZ && two.minZ < one.maxZ;
}

export function createShell(plan: Plan, options: ShellOptions): Shell {
  const { materials } = options;
  const parts = options.parts ?? sharedParts;
  const drawOpenings = options.openings ?? true;

  const group = new THREE.Group();
  group.name = "shell";
  const walls = new THREE.Group();
  walls.name = "walls";
  const floors = new THREE.Group();
  floors.name = "floors";
  const ceilings = new THREE.Group();
  ceilings.name = "ceilings";
  const openings = new THREE.Group();
  openings.name = "openings";
  const stairs = new THREE.Group();
  stairs.name = "stairs";
  group.add(walls, floors, ceilings, openings, stairs);

  const wallMeshes: THREE.Mesh[] = [];
  // Every geometry this file makes is a merge or a triangulation it owns
  // outright, so disposal is a list rather than a traversal. The materials
  // belong to the registry and are emphatically not ours to dispose.
  const owned: THREE.BufferGeometry[] = [];

  const levels = options.levelIds
    ? options.levelIds.map((id) => plan.level(id)).filter((l): l is LevelPlan => l !== null)
    : plan.levels;

  // Frames and glazing are merged across the whole shell rather than per level:
  // nothing ever fades or hides one on its own, so there is no reason to pay for
  // the addressability.
  const frameBin = new MeshBin();
  const glassBin = new MeshBin();

  for (const level of levels) {
    const holesByWall = groupBy(level.openings, (o) => o.wallId);
    for (const [wallId, runs] of groupBy(level.runs, (r) => r.wallId)) {
      buildWall(level.id, wallId, runs, holesByWall.get(wallId) ?? []);
    }
    for (const room of level.rooms) {
      buildFloor(room, liftOf(room, level.rooms));
      buildCeiling(room);
    }
    if (drawOpenings) {
      for (const opening of level.openings) lineOpening(opening);
    }
  }

  // A transition is built with its lower storey, so a shell restricted to one
  // level draws the flight rising out of it rather than nothing at all.
  const built = new Set(levels.map((level) => level.id));
  for (const transition of plan.transitions) {
    if (transition.kind !== "stair") continue;
    if (!built.has(transition.lower.levelId)) continue;
    buildStair(transition);
  }

  if (drawOpenings) {
    for (const mesh of frameBin.build("openings").children) openings.add(mesh);
    // Glass casts no shadow and receives none. A shadow-casting pane makes a
    // window read as a solid panel, which is the one thing a window must not do.
    for (const mesh of glassBin
      .build("glazing", { castShadow: false, receiveShadow: false })
      .children) {
      // Drawn after the opaque shell, since the material writes no depth and
      // cannot sort itself against the room behind it.
      mesh.renderOrder = 1;
      openings.add(mesh);
    }
    for (const mesh of openings.children) {
      const geo = (mesh as THREE.Mesh).geometry;
      if (geo) owned.push(geo);
    }
  }

  /**
   * One flight — or one dog-leg — as treads, risers and half landings.
   *
   * The path is the resolved transition's own: the same points the crossing in
   * `officeWalker` interpolates over, in office-world metres with both floor
   * heights already in them. A leg that climbs is a flight; a leg that does not
   * is a landing, and gets one slab.
   *
   * The **top tread of every flight is not drawn**, and that is the one detail
   * worth knowing. A flight always arrives at something that already has a
   * surface — a half landing, or the floor of the storey above — and drawing a
   * tread there puts two coplanar slabs at the same height, which is a z-fight
   * and which storey wins is the GPU's business. So a flight of fourteen risers
   * draws fourteen riser boards and thirteen treads, and the fourteenth surface
   * is the thing it lands on. That is also what a real stair is.
   */
  function buildStair(transition: ResolvedTransition): void {
    const material = materials.forSurface(transition.surface, "plaster");
    const bin = new MeshBin();
    let drew = false;

    for (let index = 1; index < transition.path.length; index += 1) {
      const from = transition.path[index - 1]!;
      const to = transition.path[index]!;
      const dx = to.x - from.x;
      const dz = to.z - from.z;
      const run = Math.hypot(dx, dz);
      if (run < 1e-4) continue;
      const ux = dx / run;
      const uz = dz / run;
      // The same convention `splitWall` uses: a part's local +X at yaw φ points
      // along (cos φ, −sin φ).
      const yaw = Math.atan2(-uz, ux) + 0;
      const rise = to.y - from.y;

      if (rise <= 1e-4) {
        // A half landing. One slab, the width of the flight, spanning the leg.
        bin.box(material, {
          x: from.x + ux * (run / 2),
          y: from.y - TREAD_THICKNESS,
          z: from.z + uz * (run / 2),
          yaw,
          size: [run + transition.width, TREAD_THICKNESS, transition.width],
        });
        drew = true;
        continue;
      }

      const steps = Math.min(
        MAX_FLIGHT_STEPS,
        Math.max(MIN_FLIGHT_STEPS, Math.round(rise / TARGET_RISER_M)),
      );
      const riser = rise / steps;
      const going = run / steps;
      for (let step = 0; step < steps; step += 1) {
        const foot = step * going;
        // The vertical face, at the leading edge of the step it climbs to.
        bin.box(material, {
          x: from.x + ux * foot,
          y: from.y + riser * step,
          z: from.z + uz * foot,
          yaw,
          size: [RISER_THICKNESS, riser, transition.width],
        });
        // The tread. The last one is the landing above, which already exists.
        if (step === steps - 1) continue;
        bin.box(material, {
          x: from.x + ux * (foot + going / 2),
          y: from.y + riser * (step + 1) - TREAD_THICKNESS,
          z: from.z + uz * (foot + going / 2),
          yaw,
          size: [going, TREAD_THICKNESS, transition.width],
        });
      }
      drew = true;
    }

    if (!drew) return;
    for (const child of bin.build(`stair:${transition.id}`).children) {
      const mesh = child as THREE.Mesh;
      mesh.userData.transitionId = transition.id;
      owned.push(mesh.geometry);
      stairs.add(mesh);
    }
  }

  function buildWall(
    levelId: string,
    wallId: string,
    runs: WallRun[],
    holes: readonly ResolvedOpening[],
  ): void {
    const first = runs[0];
    if (!first) return;
    // Every run of a wall carries the same surface — it is resolved from the
    // wall, or from the level, and never per run — so a wall is one material and
    // therefore one mesh. The loop below still handles a group of them, because
    // a `Shell` that silently drew three quarters of a wall would be worse than
    // one that drew an unexpected extra mesh.
    const role = materials.resolve(first.surface, "plaster");
    const material = materials.get(role);

    const bin = new MeshBin();
    let bottom = Infinity;
    let top = -Infinity;
    for (const run of runs) {
      const height = run.top - run.bottom;
      if (height <= 0) continue;
      bin.add(parts.wallRun(run.length, height, run.thickness), material, {
        x: run.center.x,
        y: run.bottom,
        z: run.center.z,
        yaw: run.yaw,
      });
      bottom = Math.min(bottom, run.bottom);
      top = Math.max(top, run.top);
    }
    if (!Number.isFinite(top)) return;

    const info: WallInfo = {
      wallId,
      levelId,
      ...extentOf([...runs, ...holes]),
      bottom,
      top,
      role,
    };

    for (const child of [...bin.build(`wall:${wallId}`).children]) {
      const mesh = child as THREE.Mesh;
      mesh.userData.wall = info;
      owned.push(mesh.geometry);
      wallMeshes.push(mesh);
      walls.add(mesh);
    }
  }

  /**
   * How far to lift a slab so it does not fight the ones it overlaps.
   *
   * The format explicitly permits overlapping rooms and resolves *later* ones
   * first (`types.ts` on `Room.outline`, `Plan.roomAt`), so "a slab on top of
   * another slab" is legal and is the natural way to author a hangar: one
   * concrete floor with a carpeted meeting box and a timber galley laid on it.
   * The reference office avoids it by notching every room around its neighbours,
   * which works when the rooms tile the plate and cannot work at all when they
   * are islands in the middle of it — a rectangle with holes in it is not a
   * simple polygon.
   *
   * Two coplanar slabs at the same `y` is a z-fight, and which one wins is the
   * GPU's business. So a room that overlaps earlier rooms is lifted by a hair
   * per earlier room it overlaps, which makes the depth test agree with the
   * ordering the format already documents.
   *
   * **A pack whose rooms do not overlap is lifted by nothing**, which is why
   * this counts overlaps rather than simply using the room's index: indexing
   * would raise the reference office's fifteenth room by a centimetre and a half
   * for no reason at all.
   *
   * Bounding boxes rather than true polygon intersection, deliberately. It is
   * conservative in the safe direction — two rooms whose boxes touch but whose
   * outlines do not get a lift they did not need, which is invisible — and it is
   * a handful of comparisons rather than a clipping library.
   */
  function liftOf(room: ResolvedRoom, rooms: readonly ResolvedRoom[]): number {
    let overlaps = 0;
    for (const other of rooms) {
      if (other === room) break;
      if (Math.abs(other.y - room.y) > 1e-6) continue;
      if (boxesOverlap(other.outline, room.outline)) overlaps += 1;
    }
    return overlaps * SLAB_LIFT;
  }

  function buildFloor(room: ResolvedRoom, lift: number): void {
    const geometry = slabGeometry(room.outline, room.y + lift, true);
    if (!geometry) return;
    owned.push(geometry);
    const mesh = new THREE.Mesh(geometry, materials.forSurface(room.floor, "carpet"));
    mesh.name = `floor:${room.id}`;
    mesh.receiveShadow = true;
    // A floor slab casts nothing — there is nothing under it, and asking the
    // shadow camera to render the largest polygon in the office for no result is
    // a straight waste of its budget.
    mesh.castShadow = false;
    mesh.userData.roomId = room.id;
    floors.add(mesh);
  }

  function buildCeiling(room: ResolvedRoom): void {
    const ceiling = room.ceiling;
    if (!ceiling) return;
    const geometry = slabGeometry(room.outline, ceiling.height, false);
    if (!geometry) return;
    owned.push(geometry);
    const mesh = new THREE.Mesh(geometry, materials.forSurface(ceiling.surface, "ceilingTile"));
    mesh.name = `ceiling:${room.id}`;
    // A ceiling that casts a shadow puts the whole room in shade, because the
    // rig's sun is above it. The room is lit by the rig, not through the slab.
    mesh.castShadow = false;
    mesh.receiveShadow = true;
    mesh.userData.roomId = room.id;
    ceilings.add(mesh);
  }

  /**
   * The lining of one hole: two jambs and a head, a sill board under a window,
   * and a pane in it.
   *
   * A door gets a frame and no leaf. A leaf either stands open — and then it is
   * a prop in the way of the dollhouse view — or stands shut, and then the room
   * behind it is invisible from every angle. The collider already has the gap;
   * the eye should have it too.
   */
  function lineOpening(opening: ResolvedOpening): void {
    const height = opening.head - opening.sill;
    if (height <= 0 || opening.width <= 0) return;

    // Windows are trimmed in the glazing frame's finish, doors and arches in the
    // door's. Same geometry, and the difference is the one a joiner would make.
    const trim = materials.get(opening.kind === "window" ? "glazingFrame" : "doorLeaf");
    const depth = opening.thickness + FRAME_PROUD * 2;
    const half = opening.width / 2;

    for (const side of [-1, 1]) {
      const at = along(opening.center, opening.yaw, side * (half - FRAME_WIDTH / 2));
      frameBin.add(parts.box(), trim, {
        x: at.x,
        y: opening.sill,
        z: at.z,
        size: [FRAME_WIDTH, height, depth],
        yaw: opening.yaw,
      });
    }
    frameBin.add(parts.box(), trim, {
      x: opening.center.x,
      y: opening.head - FRAME_WIDTH,
      z: opening.center.z,
      size: [opening.width, FRAME_WIDTH, depth],
      yaw: opening.yaw,
    });

    if (opening.kind !== "window") return;

    frameBin.add(parts.box(), trim, {
      x: opening.center.x,
      y: opening.sill - 0.03,
      z: opening.center.z,
      size: [opening.width + FRAME_WIDTH, 0.03, opening.thickness + SILL_PROUD * 2],
      yaw: opening.yaw,
    });
    glassBin.add(parts.box(), materials.get("glazing"), {
      x: opening.center.x,
      y: opening.sill + 0.005,
      z: opening.center.z,
      size: [opening.width - FRAME_WIDTH, height - FRAME_WIDTH, 0.012],
      yaw: opening.yaw,
    });
  }

  return {
    group,
    walls,
    floors,
    ceilings,
    stairs,
    openings,
    wallMeshes,
    setGhosted(mesh, ghosted) {
      if (Boolean(mesh.userData.ghosted) === ghosted) return;
      const info = mesh.userData.wall as WallInfo | undefined;
      if (!info) return;
      mesh.userData.ghosted = ghosted;
      mesh.material = ghosted ? materials.ghostOf(info.role) : materials.get(info.role);
      // A ghost that still casts a solid shadow gives itself away instantly.
      mesh.castShadow = !ghosted;
    },
    dispose() {
      for (const geo of owned) geo.dispose();
      owned.length = 0;
      wallMeshes.length = 0;
      group.clear();
      walls.clear();
      floors.clear();
      ceilings.clear();
      openings.clear();
    },
  };
}

// ---- Geometry -------------------------------------------------------------

/**
 * A room's polygon as a flat slab at `y`, facing up for a floor and down for a
 * ceiling.
 *
 * It is a surface and not a box. Nothing is ever underneath a floor or above a
 * ceiling in an office, and the only place the missing thickness would show is
 * the outer edge of the building seen from below, which the orbit limits do not
 * let you get to.
 *
 * **UVs are the room's own world coordinates in metres**, not a 0..1 unwrap.
 * Carpet in one room therefore lines up with carpet in the room next door
 * exactly as laid carpet does, and a 3 m booth and a 30 m floor plate show the
 * same size of loop. `parts.metricQuad` does this for rectangles; a room is a
 * polygon, which is why this lives here.
 */
function slabGeometry(outline: Outline, y: number, up: boolean): THREE.BufferGeometry | null {
  const count = outline.length;
  if (count < 3) return null;

  const contour = outline.map((p) => new THREE.Vector2(p.x, p.z));
  const faces = THREE.ShapeUtils.triangulateShape(contour, []);
  if (faces.length === 0) return null;

  const position = new Float32Array(count * 3);
  const normal = new Float32Array(count * 3);
  const uv = new Float32Array(count * 2);
  const ny = up ? 1 : -1;
  for (let i = 0; i < count; i++) {
    const p = outline[i];
    if (!p) continue;
    position[i * 3] = p.x;
    position[i * 3 + 1] = y;
    position[i * 3 + 2] = p.z;
    normal[i * 3 + 1] = ny;
    uv[i * 2] = p.x / TEXTURE_TILE_METRES;
    uv[i * 2 + 1] = p.z / TEXTURE_TILE_METRES;
  }

  // `Plan` hands over a known winding, but the triangulator's output order is
  // its own business and a back-facing floor is invisible rather than wrong-
  // looking. Each triangle is oriented from its own cross product, which costs
  // three subtractions and cannot be got wrong by a later change of convention.
  const index: number[] = [];
  for (const face of faces) {
    const a = face[0];
    const b = face[1];
    const c = face[2];
    if (a === undefined || b === undefined || c === undefined) continue;
    const pa = outline[a];
    const pb = outline[b];
    const pc = outline[c];
    if (!pa || !pb || !pc) continue;
    const facing = (pb.z - pa.z) * (pc.x - pa.x) - (pb.x - pa.x) * (pc.z - pa.z);
    if (facing * ny > 0) index.push(a, b, c);
    else index.push(a, c, b);
  }
  if (index.length === 0) return null;

  const geometry = new THREE.BufferGeometry();
  geometry.setAttribute("position", new THREE.BufferAttribute(position, 3));
  geometry.setAttribute("normal", new THREE.BufferAttribute(normal, 3));
  geometry.setAttribute("uv", new THREE.BufferAttribute(uv, 2));
  geometry.setIndex(index);
  return geometry;
}

/** A point `d` metres along a wall of yaw `yaw` from its centre. */
function along(center: Point2, yaw: number, d: number): Point2 {
  // A run's mesh lies along its local +X, which for yaw φ points at
  // (cos φ, -sin φ) — the same derivation `Plan` uses to place its runs. The
  // `+ 0` normalises IEEE negative zero for the same reason `Plan` does it: a
  // north-south wall otherwise reports an x of `-0`, which renders identically
  // and looks like a bug in every diff.
  return { x: center.x + Math.cos(yaw) * d + 0, z: center.z - Math.sin(yaw) * d + 0 };
}

/** Anything that knows where it sits along its wall. Runs and openings both do. */
interface Interval {
  center: Point2;
  yaw: number;
  start: number;
  end: number;
}

/**
 * The endpoints of the wall a set of runs and openings came from.
 *
 * A run knows where its own centre is and how far along the wall it starts and
 * ends, which is enough to recover the wall's origin and therefore both of its
 * ends. Doing it this way rather than reading `Wall.from`/`Wall.to` off the pack
 * means the segment stamped on the mesh is the segment that was actually drawn,
 * and a wall `Plan` repaired stays consistent with itself.
 *
 * The openings are in the list because a full-height door at the very end of a
 * wall leaves no run out there — no apron under it, no lintel over it — and the
 * segment would come up short by the width of the door.
 */
function extentOf(intervals: readonly Interval[]): { from: Point2; to: Point2 } {
  const first = intervals[0];
  if (!first) return { from: { x: 0, z: 0 }, to: { x: 0, z: 0 } };
  const mid = (first.start + first.end) / 2;
  const origin = along(first.center, first.yaw, -mid);
  let start = first.start;
  let end = first.end;
  for (const interval of intervals) {
    start = Math.min(start, interval.start);
    end = Math.max(end, interval.end);
  }
  return {
    from: along(origin, first.yaw, start),
    to: along(origin, first.yaw, end),
  };
}

function groupBy<T, K>(items: readonly T[], key: (item: T) => K): Map<K, T[]> {
  const out = new Map<K, T[]>();
  for (const item of items) {
    const k = key(item);
    const list = out.get(k);
    if (list) list.push(item);
    else out.set(k, [item]);
  }
  return out;
}
