Back to Big Rig Rush
SOURCE / PINNED RELEASE

Made of little things.

Big Rig Rush

Release
ec9b6549f752…
Author-recorded commit
74b729258b1a…
License
LICENSE
Author’s source reference
nostr://npub1sj2vketepqw9v6fmge5fdzuj7atj9sv6qv2jss05n2z6rl64lcfqumdsnt/wss%3A%2F%2Fgit.napplet.soy%2F/n-0a99f0cae40

Archive hash verified: 900e22810340ccb1…. The source-to-build association is the author’s claim; it has not been independently rebuilt.

src/game/city.ts
import {
  angleDiff,
  boxCorners,
  clamp,
  convexHull,
  cubicPoints,
  makePath,
  offsetPoints,
  pointAt,
  slicePath,
  smoothCurve,
  tracePath,
  type Path,
  type Vec,
} from './geometry.js';
import { drawCar, makeLook, shade, type CarModel } from './vehicles.js';
import { expansionFeatures, expansionNetwork, expansionScenery, type Builder, type InfraSpec } from './expansion.js';

export type District =
  | 'downtown'
  | 'uptown'
  | 'hood'
  | 'port'
  | 'highway'
  | 'country'
  | 'village'
  | 'industrial'
  | 'docks'
  | 'factory'
  | 'rail'
  | 'suburb'
  | 'commercial'
  | 'military'
  | 'rural'
  | 'town';
export type LaneKind = 'street' | 'connector' | 'highway' | 'ramp';

export interface Lane extends Path {
  id: number;
  kind: LaneKind;
  speed: number;
  next: number[];
  left: number;
  right: number;
  turn: boolean;
  district: District;
  /** Layer (0 ground, 1 elevated) before and after `breakS`. */
  layer0: number;
  layer1: number;
  breakS: number;
}

export interface Item {
  x0: number;
  y0: number;
  x1: number;
  y1: number;
  draw(ctx: CanvasRenderingContext2D): void;
}

export interface Spot {
  x: number;
  y: number;
  angle: number;
  district: District;
}

export interface WalkEnd {
  walk: number;
  atStart: boolean;
  x: number;
  y: number;
  links: { walk: number; atStart: boolean }[];
  cross: { walk: number; atStart: boolean } | null;
}

export interface Walk extends Path {
  id: number;
  district: District;
  ends: [WalkEnd, WalkEnd];
}

/** The whole map: the original city in the north-west corner, and the country around it. */
export const MAP_W = 16400;
export const MAP_H = 29200;
/** The original city's corner of the map. */
export const CORE_W = 5200;
export const CORE_H = 9200;
export const GRASS = '#5f9447';

export const L_GROUND = 0;
export const L_WALK = 1;
export const L_ROAD = 2;
export const L_MARK = 3;
export const L_OBJECT = 4;
export const L_CANOPY = 5;
export const L_DECK = 6;
export const L_DECKTOP = 7;

export const SURF_GRASS = 0;
export const SURF_ROAD = 1;
export const SURF_SIDEWALK = 2;
export const SURF_DIRT = 3;
export const SURF_WATER = 4;

export function laneLayer(lane: Lane, s: number): number {
  return s < lane.breakS ? lane.layer0 : lane.layer1;
}

const CELL = 8;
const GW = Math.ceil(MAP_W / CELL);
const GH = Math.ceil(MAP_H / CELL);
const HASH = 128;
const OCC_ROAD = 1;
const OCC_BUILT = 2;
const OCC_DECK = 4;
const OCC_WATER = 8;
const WATER = '#3f86ad';
const SAND = '#d9c99a';

const rand = Math.random;
const range = (a: number, b: number) => a + Math.random() * (b - a);
const randInt = (n: number) => Math.floor(Math.random() * n);
const pick = <T>(items: readonly T[]): T => items[randInt(items.length)];

interface Spec {
  lanes: number;
  laneW: number;
  median: number;
  medianType: 'none' | 'grass';
  sidewalk: number;
  speed: number;
  crosswalk: boolean;
  center: 'yellow' | 'none';
  oneWay: boolean;
}

const SPECS = {
  street: { lanes: 1, laneW: 14, median: 0, medianType: 'none', sidewalk: 9, speed: 55, crosswalk: true, center: 'yellow', oneWay: false },
  avenue: { lanes: 2, laneW: 15, median: 0, medianType: 'none', sidewalk: 11, speed: 62, crosswalk: true, center: 'yellow', oneWay: false },
  boulevard: { lanes: 1, laneW: 15, median: 16, medianType: 'grass', sidewalk: 10, speed: 62, crosswalk: true, center: 'none', oneWay: false },
  expressway: { lanes: 2, laneW: 16, median: 18, medianType: 'grass', sidewalk: 0, speed: 112, crosswalk: false, center: 'none', oneWay: false },
  industrial: { lanes: 1, laneW: 18, median: 0, medianType: 'none', sidewalk: 6, speed: 58, crosswalk: false, center: 'yellow', oneWay: false },
  country: { lanes: 1, laneW: 14, median: 0, medianType: 'none', sidewalk: 0, speed: 72, crosswalk: false, center: 'yellow', oneWay: false },
  drive: { lanes: 1, laneW: 13, median: 0, medianType: 'none', sidewalk: 7, speed: 45, crosswalk: false, center: 'none', oneWay: false },
  ramp: { lanes: 1, laneW: 16, median: 0, medianType: 'none', sidewalk: 0, speed: 88, crosswalk: false, center: 'none', oneWay: true },
  spur: { lanes: 2, laneW: 16, median: 0, medianType: 'none', sidewalk: 0, speed: 150, crosswalk: false, center: 'none', oneWay: true },
} satisfies Record<string, Spec>;
type SpecName = keyof typeof SPECS;

interface Style {
  asphalt: string;
  sidewalk: string;
  curb: string;
  markAlpha: number;
  worn: boolean;
}

const STYLES: Record<District, Style> = {
  downtown: { asphalt: '#3a3b45', sidewalk: '#a7a195', curb: '#d3ccbc', markAlpha: 1, worn: false },
  uptown: { asphalt: '#45474f', sidewalk: '#d9d2c2', curb: '#f1ebdc', markAlpha: 1, worn: false },
  hood: { asphalt: '#3d3a37', sidewalk: '#8e877a', curb: '#a39b8b', markAlpha: 0.5, worn: true },
  port: { asphalt: '#403f3d', sidewalk: '#94918a', curb: '#aaa69c', markAlpha: 0.8, worn: true },
  highway: { asphalt: '#37383f', sidewalk: '#9a9488', curb: '#c8c2b4', markAlpha: 1, worn: false },
  country: { asphalt: '#3f3e3a', sidewalk: '#8c8466', curb: '#a39b80', markAlpha: 0.85, worn: true },
  village: { asphalt: '#45454a', sidewalk: '#cfc6b2', curb: '#ece4d0', markAlpha: 1, worn: false },
  industrial: { asphalt: '#3c3c3e', sidewalk: '#8f8c84', curb: '#a7a398', markAlpha: 0.75, worn: true },
  docks: { asphalt: '#3e3d3b', sidewalk: '#96938b', curb: '#aba79d', markAlpha: 0.8, worn: true },
  factory: { asphalt: '#3a3a3c', sidewalk: '#8b8880', curb: '#a39f94', markAlpha: 0.7, worn: true },
  rail: { asphalt: '#3d3c3a', sidewalk: '#8f8a7e', curb: '#a6a092', markAlpha: 0.7, worn: true },
  suburb: { asphalt: '#47484f', sidewalk: '#d6cfbf', curb: '#ede7d8', markAlpha: 1, worn: false },
  commercial: { asphalt: '#3b3c45', sidewalk: '#b3ad9f', curb: '#d6cfbf', markAlpha: 1, worn: false },
  military: { asphalt: '#3f413a', sidewalk: '#8e8d80', curb: '#a6a492', markAlpha: 1, worn: false },
  rural: { asphalt: '#403f3a', sidewalk: '#8c8466', curb: '#a39b80', markAlpha: 0.75, worn: true },
  town: { asphalt: '#45454a', sidewalk: '#cfc6b2', curb: '#ece4d0', markAlpha: 1, worn: false },
};

/** Which of the original districts' buildings each district borrows (military has its own). */
export const LOOK: Record<District, District> = {
  downtown: 'downtown',
  uptown: 'uptown',
  hood: 'hood',
  port: 'port',
  highway: 'highway',
  country: 'country',
  village: 'village',
  industrial: 'industrial',
  docks: 'port',
  factory: 'industrial',
  rail: 'industrial',
  suburb: 'uptown',
  commercial: 'downtown',
  military: 'military',
  rural: 'country',
  town: 'village',
};

interface RNode {
  id: number;
  x: number;
  y: number;
  district: District;
  arms: Arm[];
  radius: number;
  joint: boolean;
}

interface Arm {
  road: Road;
  angle: number;
  in: number[];
  out: number[];
}

interface Road {
  id: number;
  a: RNode | null;
  b: RNode | null;
  ctrl: Vec[];
  spec: Spec;
  name: SpecName;
  district: District;
  halfW: number;
  pts: Vec[];
  fwd: number[];
  back: number[];
  layer0: number;
  layer1: number;
  breakFrac: number;
}

export type Material = 'concrete' | 'barrier' | 'shore' | 'wood';

export type StationKind = 'hospital' | 'police' | 'fire' | 'tow' | 'works';

/** A hospital, police station, fire station, tow depot or public works yard, with a fixed fleet. */
export interface Station {
  kind: StationKind;
  x: number;
  y: number;
  angle: number;
  w: number;
  d: number;
  units: number;
  district: District;
  /** Its building: if that's destroyed, the station's fleet is out of action until it's rebuilt. */
  structure?: Structure;
}

export interface Venue {
  kind: 'market' | 'party' | 'rave';
  x: number;
  y: number;
  r: number;
  angle: number;
  district: District;
}

interface Collider {
  ax: number;
  ay: number;
  bx: number;
  by: number;
  r: number;
  layer: number;
  mat: Material;
  /** The structure this wall belongs to, or -1. */
  owner: number;
}

/** A building (or other solid block) that can be smashed: wood gives way easily, stone takes a beating. */
export interface Structure {
  id: number;
  x: number;
  y: number;
  angle: number;
  w: number;
  d: number;
  district: District;
  mat: 'wood' | 'stone';
  hp: number;
  maxHp: number;
  destroyed: boolean;
  /** An ordinary building (not a fence post, tank or pile). */
  building: boolean;
  /** A tree (w is its canopy diameter): blasts and gunfire fell it. */
  tree?: boolean;
  /** Seconds of fire left (0 or undefined: not burning). */
  burning?: number;
  /** A station's building (hospital, police, fire, tow, works). */
  station?: boolean;
  /** Game time it came down (for the collapse animation), and which way it fell. */
  fellAt?: number;
  fallAngle?: number;
  /** How long it's been burning (a building that burns long enough comes down). */
  burnTime?: number;
  /** Full of fuel or explosives (tanks, tank cars, aircraft, ammo bunkers): it goes up when it comes down. */
  fuel?: number;
}

/** Traffic lights at a junction: each approach lane belongs to one of two phases. */
export interface Signal {
  x: number;
  y: number;
  /** Seconds into the cycle at time zero, so junctions don't all change together. */
  offset: number;
  approaches: { lane: number; phase: number; x: number; y: number; angle: number }[];
}

/** Districts built of timber (houses, barns, shops); everywhere else is brick, stone and concrete. */
const WOODEN = new Set<District>(['hood', 'village', 'country', 'suburb', 'rural', 'town']);

function strokeLine(ctx: CanvasRenderingContext2D, pts: Vec[], color: string, width: number, dash: number[] = [], closed = false): void {
  if (pts.length < 2) return;
  tracePath(ctx, pts, closed);
  ctx.strokeStyle = color;
  ctx.lineWidth = width;
  ctx.setLineDash(dash);
  ctx.stroke();
  ctx.setLineDash([]);
}

function bboxOf(pts: Vec[], pad: number): [number, number, number, number] {
  let x0 = Infinity;
  let y0 = Infinity;
  let x1 = -Infinity;
  let y1 = -Infinity;
  for (const p of pts) {
    x0 = Math.min(x0, p.x);
    y0 = Math.min(y0, p.y);
    x1 = Math.max(x1, p.x);
    y1 = Math.max(y1, p.y);
  }
  return [x0 - pad, y0 - pad, x1 + pad, y1 + pad];
}

function fillPoly(ctx: CanvasRenderingContext2D, pts: Vec[], color: string): void {
  ctx.fillStyle = color;
  ctx.beginPath();
  ctx.moveTo(pts[0].x, pts[0].y);
  for (const p of pts.slice(1)) ctx.lineTo(p.x, p.y);
  ctx.closePath();
  ctx.fill();
}

/** Arc-length position where a polyline crosses the horizontal line y. */
function sAtY(pts: Vec[], y: number): number {
  const path = makePath(pts);
  for (let i = 1; i < pts.length; i++) {
    const a = pts[i - 1];
    const b = pts[i];
    if ((a.y - y) * (b.y - y) <= 0 && a.y !== b.y) return path.cum[i - 1] + ((y - a.y) / (b.y - a.y)) * (path.cum[i] - path.cum[i - 1]);
  }
  return y < pts[0].y ? 0 : path.len;
}

function sAtX(pts: Vec[], x: number): number {
  let s = 0;
  for (let i = 1; i < pts.length; i++) {
    const a = pts[i - 1];
    const b = pts[i];
    const seg = Math.hypot(b.x - a.x, b.y - a.y);
    if ((a.x - x) * (b.x - x) <= 0 && a.x !== b.x) return s + seg * ((x - a.x) / (b.x - a.x));
    s += seg;
  }
  return s;
}

function nearestIndex(pts: Vec[], p: Vec): number {
  let best = 0;
  let bestD = Infinity;
  for (let i = 0; i < pts.length; i++) {
    const d = (pts[i].x - p.x) ** 2 + (pts[i].y - p.y) ** 2;
    if (d < bestD) {
      bestD = d;
      best = i;
    }
  }
  return best;
}

function tangentAt(pts: Vec[], i: number): Vec {
  const a = pts[Math.max(0, i - 1)];
  const b = pts[Math.min(pts.length - 1, i + 1)];
  const l = Math.hypot(b.x - a.x, b.y - a.y) || 1;
  return { x: (b.x - a.x) / l, y: (b.y - a.y) / l };
}

/** Arc position on `curve` closest to any point of `other`. */
function crossingS(curve: Vec[], other: Vec[]): number {
  let best = 0;
  let bestD = Infinity;
  const path = makePath(curve);
  for (let i = 0; i < curve.length; i++) {
    for (let k = 0; k < other.length; k += 3) {
      const d = (curve[i].x - other[k].x) ** 2 + (curve[i].y - other[k].y) ** 2;
      if (d < bestD) {
        bestD = d;
        best = path.cum[i];
      }
    }
  }
  return best;
}

const COUNTRY_RECTS: [number, number, number, number][] = [
  [0, 0, 1460, 1720],
  [1660, 0, 2560, 1450],
  [1600, 3560, 4900, 4400],
  [0, 1950, 290, 4400],
  [0, 3880, 1600, 4400],
  [0, 4300, 5200, 6800],
  [0, 6800, 3000, 9200],
  [3000, 8900, 5200, 9200],
];

export const DISTRICT_RECTS: [District, number, number, number, number][] = [
  ['downtown', 1880, 1940, 3230, 3110],
  ['uptown', 2560, 330, 4600, 1460],
  ['hood', 280, 2540, 1380, 3870],
  ['port', 3560, 2990, 4880, 4120],
  ['village', 1650, 5050, 2470, 5630],
  ['industrial', 3000, 6880, 5150, 8900],
  ['docks', 7760, 250, 11000, 4900],
  ['factory', 11350, 250, 16150, 5150],
  ['rail', 5500, 5250, 16150, 7450],
  ['commercial', 9000, 7850, 16150, 9900],
  ['suburb', 5500, 7850, 8650, 14600],
  ['military', 9150, 10150, 16150, 16300],
  ['town', 6250, 20750, 7750, 21950],
];

export class City {
  readonly lanes: Lane[] = [];
  readonly walks: Walk[] = [];
  readonly layers: Item[][] = [[], [], [], [], [], [], [], []];
  readonly pickupSpots: Spot[] = [];
  readonly dropSpots: Spot[] = [];
  readonly venues: Venue[] = [];
  readonly stations: Station[] = [];
  /** Every ordinary building's footprint (for mob hits and repair crews). */
  /** Everything solid that can be smashed, and the subset that are ordinary buildings. */
  readonly structures: Structure[] = [];
  readonly buildings: Structure[] = [];
  /** Traffic lights, and which signal (and phase) controls each lane's stop line. */
  readonly signals: Signal[] = [];
  readonly laneSignal = new Map<number, { signal: Signal; phase: number }>();
  /** Where tow trucks take the wrecks. */
  junkyard: Vec | null = null;
  /** The structure hit by the deepest contact in the last resolveCircle call. */
  lastStructure: Structure | null = null;
  /** While a building is being laid out: its district (null for stations, which can't be destroyed). */
  private structDistrict: District | null = null;
  /** Which stations each district still needs. */
  private readonly stationPlan: Partial<Record<District, StationKind[]>> = {
    downtown: ['hospital', 'police', 'fire', 'tow', 'works'],
    hood: ['police', 'fire', 'works', 'hospital', 'tow'],
    uptown: ['fire', 'police', 'hospital', 'works', 'tow'],
    village: ['hospital', 'police', 'fire', 'works', 'tow'],
    industrial: ['fire', 'tow', 'police', 'works'],
    port: ['tow', 'works', 'fire'],
    country: ['tow', 'fire'],
    docks: ['fire', 'tow', 'works', 'police'],
    factory: ['fire', 'works', 'tow', 'hospital'],
    rail: ['tow', 'works'],
    suburb: ['fire', 'police', 'hospital', 'works', 'tow'],
    commercial: ['police', 'hospital', 'fire', 'tow', 'works'],
    military: ['fire', 'hospital', 'police', 'works'],
    town: ['hospital', 'police', 'fire', 'works', 'tow'],
    rural: ['tow', 'fire', 'works', 'tow', 'fire'],
  };
  /** Street lamps, lit at night. */
  readonly lights: Vec[] = [];
  /** Crop fields and orchards, where farmers work. */
  readonly fields: { x: number; y: number; w: number; h: number; kind: 'crop' | 'orchard' }[] = [];
  /** Construction sites: centre, angle and size. */
  readonly sites: { x: number; y: number; angle: number; w: number; d: number }[] = [];
  /** Factory yards with forklift routes and workers. */
  readonly yards: { x: number; y: number; angle: number; w: number; d: number }[] = [];
  /** Smokestacks and cooling towers that puff smoke or steam. */
  readonly stacks: { x: number; y: number; steam: boolean }[] = [];
  /** Fenced fields where cows graze. */
  readonly pastures: { x: number; y: number; w: number; h: number }[] = [];
  readonly countryCurves: Vec[][] = [];
  readonly surface = new Uint8Array(GW * GH);
  private readonly occ = new Uint8Array(GW * GH);
  private readonly deckMask = new Uint8Array(GW * GH);
  private readonly rampMask = new Uint8Array(GW * GH);
  private readonly spawnLanes: number[] = [];
  private readonly spawnCum: number[] = [];
  private readonly nodes: RNode[] = [];
  private readonly roads: Road[] = [];
  private readonly colliders: Collider[] = [];
  private readonly hash = new Map<number, number[]>();
  private readonly mark: number[] = [];
  private stamp = 0;
  /** Material of the deepest contact found by the last resolveCircle call. */
  lastMaterial: Material = 'concrete';
  private pendingOff: { from: number; road: Road }[] = [];
  private pendingOn: { to: number; road: Road }[] = [];
  private freewayCenter: Vec[] = [];

  constructor() {
    this.buildScenery();
    this.buildNetwork();
    this.finalizeRoads();
    this.paintRoads();
    this.placePiers();
    this.buildConnectors();
    this.placeSignals();
    this.paintMarkings();
    this.buildWalks();
    this.placeLots();
    expansionFeatures(this.builder());
    this.placeVenues();
    this.plantFields();
    this.plantForests();
    this.sprinkleTrees();
    for (const lane of this.lanes) {
      if (lane.kind === 'connector') continue;
      this.spawnLanes.push(lane.id);
      this.spawnCum.push((this.spawnCum[this.spawnCum.length - 1] ?? 0) + lane.len);
    }
  }

  /** The city's critical infrastructure: power, water, comms, City Hall, shelters. */
  readonly infra: InfraSpec[] = [];

  /** Extra woods for plantForests (the backcountry's). */
  private readonly extraForests: [number, number, number, number, number][] = [];

  /** The city's building tools, handed to the expansion (see expansion.ts). */
  private builder(): Builder {
    return {
      add: (layer, x0, y0, x1, y1, draw) => this.add(layer, x0, y0, x1, y1, draw),
      node: (x, y, d) => this.node(x, y, d),
      road: (a, b, kind, d, ctrl) => this.road(a as RNode, b as RNode, kind, d, ctrl),
      chain: (curve, positions, kind, d, ends = []) => this.chain(curve, positions, kind, d, ends as [number, RNode][]),
      grid: (xs, ys, d, kindAt, skip) => this.grid(xs, ys, d, kindAt, skip),
      country: (ctrl, a, b, d) => {
        const curve = smoothCurve([{ x: a.x, y: a.y }, ...ctrl, { x: b.x, y: b.y }], 8);
        this.countryCurves.push(curve);
        const len = makePath(curve).len;
        this.chain(curve, [0, len], 'country', d, [
          [0, a as RNode],
          [len, b as RNode],
        ]);
      },
      special: (d, corners, draw, opts) => this.special(d, corners, draw, opts),
      lake: (cx, cy, rx, ry) => this.lake(cx, cy, rx, ry),
      reserve: (x0, y0, x1, y1) => this.stampRect(this.occ, x0, y0, x1 - x0, y1 - y0, OCC_BUILT, true),
      isFree: (x, y, r) => {
        for (let dy = -r; dy <= r; dy += CELL) {
          for (let dx = -r; dx <= r; dx += CELL) {
            const j = this.cellIndex(x + dx, y + dy);
            if (j < 0 || this.occ[j] !== 0 || this.surface[j] === SURF_WATER || this.surface[j] === SURF_ROAD) return false;
          }
        }
        return true;
      },
      claimBox: (cx, cy, angle, hw, hd) => {
        if (!this.lotFree(boxCorners(cx, cy, angle, hw, hd), cx, cy, angle, hw, hd)) return false;
        this.claim(cx, cy, angle, hw, hd);
        return true;
      },
      forest: (x0, y0, x1, y1, density) => this.extraForests.push([x0, y0, x1, y1, density]),
      ground: (x0, y0, x1, y1, surface) => this.stampRect(this.surface, x0, y0, x1 - x0, y1 - y0, surface === 'road' ? SURF_ROAD : surface === 'dirt' ? SURF_DIRT : SURF_SIDEWALK),
      stacks: this.stacks,
      curvePath: (ctrl, step) => smoothCurve(ctrl, step),
      pathLength: (pts) => makePath(pts).len,
      sAtY: (pts, y) => sAtY(pts, y),
      sAtX: (pts, x) => sAtX(pts, x),
      layers: { ground: L_GROUND, walk: L_WALK, object: L_OBJECT, canopy: L_CANOPY },
      infra: this.infra,
      buildings: this.buildings,
    };
  }

  // --- Queries.

  districtAt(x: number, y: number): District {
    for (const [d, x0, y0, x1, y1] of DISTRICT_RECTS) if (x >= x0 && x <= x1 && y >= y0 && y <= y1) return d;
    for (const [x0, y0, x1, y1] of COUNTRY_RECTS) if (x >= x0 && x <= x1 && y >= y0 && y <= y1) return 'country';
    // Beyond the old city: open country, farms and forest.
    if (x > CORE_W || y > CORE_H) return 'rural';
    return 'highway';
  }

  private cellIndex(x: number, y: number): number {
    const cx = Math.floor(x / CELL);
    const cy = Math.floor(y / CELL);
    if (cx < 0 || cy < 0 || cx >= GW || cy >= GH) return -1;
    return cy * GW + cx;
  }

  surfaceAt(x: number, y: number): number {
    const i = this.cellIndex(x, y);
    return i < 0 ? SURF_GRASS : this.surface[i];
  }

  onDeck(x: number, y: number): boolean {
    const i = this.cellIndex(x, y);
    return i >= 0 && this.deckMask[i] === 1;
  }

  onRampMiddle(x: number, y: number): boolean {
    const i = this.cellIndex(x, y);
    return i >= 0 && this.rampMask[i] === 1;
  }

  randomSpawn(): { lane: number; s: number } {
    const total = this.spawnCum[this.spawnCum.length - 1];
    const target = rand() * total;
    let lo = 0;
    let hi = this.spawnCum.length - 1;
    while (lo < hi) {
      const mid = (lo + hi) >> 1;
      if (this.spawnCum[mid] < target) lo = mid + 1;
      else hi = mid;
    }
    const lane = this.lanes[this.spawnLanes[lo]];
    return { lane: lane.id, s: rand() * lane.len };
  }

  /** Push needed to move a circle out of everything solid on `layer`. */
  resolveCircle(x: number, y: number, r: number, layer: number): Vec {
    let px = 0;
    let py = 0;
    let deepest = 0;
    this.lastStructure = null;
    this.stamp++;
    for (let cy = Math.floor((y - r) / HASH); cy <= Math.floor((y + r) / HASH); cy++) {
      for (let cx = Math.floor((x - r) / HASH); cx <= Math.floor((x + r) / HASH); cx++) {
        const cell = this.hash.get((cy + 10) * 1000 + cx + 10);
        if (!cell) continue;
        for (const id of cell) {
          if (this.mark[id] === this.stamp) continue;
          this.mark[id] = this.stamp;
          const s = this.colliders[id];
          if (s.layer !== layer) continue;
          if (s.owner >= 0 && this.structures[s.owner].destroyed) continue;
          const qx = x + px;
          const qy = y + py;
          const vx = s.bx - s.ax;
          const vy = s.by - s.ay;
          const t = clamp(((qx - s.ax) * vx + (qy - s.ay) * vy) / (vx * vx + vy * vy || 1), 0, 1);
          const dx = qx - (s.ax + vx * t);
          const dy = qy - (s.ay + vy * t);
          const reach = r + s.r;
          const d = Math.hypot(dx, dy);
          if (d >= reach) continue;
          if (d < 1e-6) {
            const l = Math.hypot(vx, vy) || 1;
            px += (-vy / l) * reach;
            py += (vx / l) * reach;
            continue;
          }
          px += (dx / d) * (reach - d);
          py += (dy / d) * (reach - d);
          if (reach - d > deepest) {
            deepest = reach - d;
            this.lastMaterial = s.mat;
            this.lastStructure = s.owner >= 0 ? this.structures[s.owner] : null;
          }
        }
      }
    }
    return { x: px, y: py };
  }

  // --- Low-level builders.

  private add(layer: number, x0: number, y0: number, x1: number, y1: number, draw: (ctx: CanvasRenderingContext2D) => void): void {
    this.layers[layer].push({ x0, y0, x1, y1, draw });
  }

  private addLane(pts: Vec[], kind: LaneKind, speed: number, district: District, turn = false, layers: [number, number, number] = [0, 0, 0]): number {
    const id = this.lanes.length;
    const path = makePath(pts);
    this.lanes.push({ ...path, id, kind, speed, next: [], left: -1, right: -1, turn, district, layer0: layers[0], layer1: layers[1], breakS: layers[2] * path.len });
    return id;
  }

  private solidSeg(a: Vec, b: Vec, r: number, layer: number, mat?: Material): void {
    const id = this.colliders.length;
    this.colliders.push({ ax: a.x, ay: a.y, bx: b.x, by: b.y, r, layer, mat: mat ?? (r >= 1.5 ? 'barrier' : 'concrete'), owner: this.solidOwner });
    for (let cy = Math.floor((Math.min(a.y, b.y) - r) / HASH); cy <= Math.floor((Math.max(a.y, b.y) + r) / HASH); cy++) {
      for (let cx = Math.floor((Math.min(a.x, b.x) - r) / HASH); cx <= Math.floor((Math.max(a.x, b.x) + r) / HASH); cx++) {
        const key = (cy + 10) * 1000 + cx + 10;
        let cell = this.hash.get(key);
        if (!cell) this.hash.set(key, (cell = []));
        cell.push(id);
      }
    }
  }

  private solidLine(pts: Vec[], r: number, layer: number, closed = false, mat?: Material): void {
    const n = closed ? pts.length : pts.length - 1;
    for (let i = 0; i < n; i++) this.solidSeg(pts[i], pts[(i + 1) % pts.length], r, layer, mat);
  }

  private solidOwner = -1;

  private solidPoly(corners: Vec[], layer = 0): void {
    const district = this.structDistrict;
    if (!district || layer !== 0) {
      this.solidLine(corners, 0.5, layer, true);
      return;
    }
    // Part of a building: register it as a structure that can be smashed.
    const [c0, c1, c2] = corners;
    const w = Math.hypot(c1.x - c0.x, c1.y - c0.y);
    const d = Math.hypot(c2.x - c1.x, c2.y - c1.y);
    const mat = WOODEN.has(district) ? 'wood' : 'stone';
    const area = w * d;
    const hp = mat === 'wood' ? 40 + area * 0.04 : 180 + area * 0.12;
    const st: Structure = {
      id: this.structures.length,
      x: corners.reduce((a, p) => a + p.x, 0) / corners.length,
      y: corners.reduce((a, p) => a + p.y, 0) / corners.length,
      angle: Math.atan2(c1.y - c0.y, c1.x - c0.x),
      w,
      d,
      district,
      mat,
      hp,
      maxHp: hp,
      destroyed: false,
      building: area > 250,
    };
    this.structures.push(st);
    if (st.building) this.buildings.push(st);
    this.solidOwner = st.id;
    this.solidLine(corners, 0.5, layer, true, mat === 'wood' ? 'wood' : 'concrete');
    this.solidOwner = -1;
  }

  /** Knock a structure about. Returns true if that brought it down. */
  damageStructure(st: Structure, amount: number): boolean {
    if (st.destroyed || amount <= 0) return false;
    st.hp -= amount;
    if (st.hp > 0) return false;
    st.destroyed = true;
    return true;
  }

  /** Put a collapsed structure back up (the repair crews). */
  rebuild(st: Structure): void {
    st.destroyed = false;
    st.hp = st.maxHp;
  }

  /** Structures with any part within r of a point. */
  private structureGrid: Map<number, Structure[]> | null = null;

  /** Structures with any part within r of a point (set `all` to include ones already down). */
  structuresNear(x: number, y: number, r: number, all = false): Structure[] {
    if (!this.structureGrid) {
      this.structureGrid = new Map();
      for (const st of this.structures) {
        const key = Math.floor(st.y / 64) * 1000 + Math.floor(st.x / 64);
        const cell = this.structureGrid.get(key);
        if (cell) cell.push(st);
        else this.structureGrid.set(key, [st]);
      }
    }
    // Buildings are up to ~90 across, so look a cell further than the radius.
    const out: Structure[] = [];
    const reach = r + 50;
    for (let cy = Math.floor((y - reach) / 64); cy <= Math.floor((y + reach) / 64); cy++) {
      for (let cx = Math.floor((x - reach) / 64); cx <= Math.floor((x + reach) / 64); cx++) {
        const cell = this.structureGrid.get(cy * 1000 + cx);
        if (!cell) continue;
        for (const st of cell) if ((all || !st.destroyed) && Math.hypot(st.x - x, st.y - y) < r + Math.max(st.w, st.d) / 2) out.push(st);
      }
    }
    return out;
  }

  private stampCircle(grid: Uint8Array, x: number, y: number, r: number, value: number, or = false): void {
    for (let cy = Math.floor((y - r) / CELL); cy <= Math.floor((y + r) / CELL); cy++) {
      for (let cx = Math.floor((x - r) / CELL); cx <= Math.floor((x + r) / CELL); cx++) {
        if (cx < 0 || cy < 0 || cx >= GW || cy >= GH) continue;
        const dx = cx * CELL + CELL / 2 - x;
        const dy = cy * CELL + CELL / 2 - y;
        if (dx * dx + dy * dy > r * r) continue;
        const i = cy * GW + cx;
        grid[i] = or ? grid[i] | value : value;
      }
    }
  }

  private stampPath(grid: Uint8Array, pts: Vec[], r: number, value: number, or = false): void {
    const path = makePath(pts);
    for (let s = 0; s <= path.len; s += CELL * 0.75) {
      const p = pointAt(path, s);
      this.stampCircle(grid, p.x, p.y, r, value, or);
    }
  }

  private stampRect(grid: Uint8Array, x: number, y: number, w: number, h: number, value: number, or = false): void {
    for (let cy = Math.floor(y / CELL); cy <= Math.floor((y + h) / CELL); cy++) {
      for (let cx = Math.floor(x / CELL); cx <= Math.floor((x + w) / CELL); cx++) {
        if (cx < 0 || cy < 0 || cx >= GW || cy >= GH) continue;
        const i = cy * GW + cx;
        grid[i] = or ? grid[i] | value : value;
      }
    }
  }

  private tree(x: number, y: number, r: number, tone: 'park' | 'fancy' | 'scrappy' | 'pine' = 'park'): void {
    this.structures.push({ id: this.structures.length, x, y, angle: 0, w: r * 2, d: r * 2, district: 'country', mat: 'wood', hp: 10, maxHp: 10, destroyed: false, building: false, tree: true });
    const [dark, light] =
      tone === 'fancy' ? ['#2a6538', '#7cc35f'] : tone === 'scrappy' ? ['#4d6b33', '#93ab5b'] : tone === 'pine' ? ['#1f4d2c', '#3f7f45'] : ['#2f6a35', '#6db454'];
    const blobs = Array.from({ length: 3 }, () => ({ a: rand() * Math.PI * 2, d: r * range(0.25, 0.5), s: r * range(0.35, 0.5) }));
    this.add(L_CANOPY, x - r, y - r, x + r * 1.5, y + r * 1.6, (ctx) => {
      ctx.fillStyle = 'rgba(0, 0, 0, 0.26)';
      ctx.beginPath();
      ctx.ellipse(x + r * 0.35, y + r * 0.5, r, r * 0.95, 0, 0, Math.PI * 2);
      ctx.fill();
      const g = ctx.createRadialGradient(x - r * 0.35, y - r * 0.35, r * 0.1, x, y, r);
      g.addColorStop(0, light);
      g.addColorStop(1, dark);
      ctx.fillStyle = g;
      ctx.beginPath();
      ctx.arc(x, y, r, 0, Math.PI * 2);
      ctx.fill();
      ctx.fillStyle = 'rgba(255, 255, 255, 0.1)';
      for (const b of blobs) {
        ctx.beginPath();
        ctx.arc(x + Math.cos(b.a) * b.d - r * 0.15, y + Math.sin(b.a) * b.d - r * 0.15, b.s, 0, Math.PI * 2);
        ctx.fill();
      }
    });
  }

  // --- Scenery: grass texture, district grounds, the bay, parks, farms.

  private buildScenery(): void {
    for (let gy = 0; gy < MAP_H; gy += 256) {
      for (let gx = 0; gx < MAP_W; gx += 256) {
        const tufts = Array.from({ length: 46 }, () => ({ x: gx + rand() * 256, y: gy + rand() * 256, light: rand() < 0.5 }));
        this.add(L_GROUND, gx, gy, gx + 258, gy + 258, (ctx) => {
          ctx.lineWidth = 0.8;
          ctx.lineCap = 'round';
          for (const t of tufts) {
            ctx.strokeStyle = t.light ? 'rgba(160, 210, 110, 0.35)' : 'rgba(30, 70, 30, 0.3)';
            ctx.beginPath();
            ctx.moveTo(t.x, t.y);
            ctx.lineTo(t.x - 1, t.y - 2.5);
            ctx.moveTo(t.x + 1, t.y);
            ctx.lineTo(t.x + 1.8, t.y - 2.2);
            ctx.stroke();
          }
          ctx.lineCap = 'butt';
        });
      }
    }

    const pads: [District, string][] = [
      ['downtown', '#8f8a80'],
      ['hood', '#86795c'],
      ['port', '#8a8984'],
    ];
    for (const [d, color] of pads) {
      const [, x0, y0, x1, y1] = DISTRICT_RECTS.find((r) => r[0] === d)!;
      const patches = d === 'hood' ? Array.from({ length: 140 }, () => ({ x: range(x0, x1), y: range(y0, y1), r: range(6, 22) })) : [];
      this.add(L_GROUND, x0, y0, x1, y1, (ctx) => {
        ctx.fillStyle = color;
        ctx.beginPath();
        ctx.roundRect(x0, y0, x1 - x0, y1 - y0, 30);
        ctx.fill();
        ctx.fillStyle = 'rgba(95, 140, 70, 0.55)';
        for (const p of patches) {
          ctx.beginPath();
          ctx.arc(p.x, p.y, p.r, 0, Math.PI * 2);
          ctx.fill();
        }
      });
      this.stampRect(this.surface, x0, y0, x1 - x0, y1 - y0, d === 'hood' ? SURF_DIRT : SURF_SIDEWALK);
    }

    // The bay: the old city's waterfront on its west shore, round the south end, and the
    // docks' quays along its east shore.
    const coast = smoothCurve(
      [
        { x: 4930, y: -50 },
        { x: 4940, y: 900 },
        { x: 4900, y: 1900 },
        { x: 4910, y: 2900 },
        { x: 4920, y: 4150 },
        { x: 5000, y: 4400 },
        { x: 5180, y: 4560 },
        { x: 5600, y: 4700 },
        { x: 6300, y: 4860 },
        { x: 7000, y: 4920 },
        { x: 7480, y: 4800 },
        { x: 7690, y: 4460 },
        { x: 7700, y: 3500 },
        { x: 7700, y: 2400 },
        { x: 7700, y: 1300 },
        { x: 7700, y: 300 },
        { x: 7700, y: -50 },
      ],
      12,
    );
    const waterPoly = coast;
    this.add(L_GROUND, 4800, -100, 7800, 5000, (ctx) => {
      ctx.lineJoin = 'round';
      strokeLine(ctx, coast, SAND, 40);
      fillPoly(ctx, waterPoly, WATER);
      ctx.strokeStyle = 'rgba(255, 255, 255, 0.22)';
      ctx.lineWidth = 1.2;
      for (let i = 0; i < coast.length; i += 9) {
        const p = coast[i];
        ctx.beginPath();
        ctx.moveTo(p.x + 30, p.y + 14);
        ctx.quadraticCurveTo(p.x + 38, p.y + 10, p.x + 46, p.y + 14);
        ctx.stroke();
      }
    });
    for (const p of coast) {
      this.stampCircle(this.occ, p.x, p.y, 24, OCC_WATER, true);
    }
    // The country beyond the old city.
    expansionScenery(this.builder());
    // Rasterise the water polygon at grid resolution.
    const mask = document.createElement('canvas');
    mask.width = GW;
    mask.height = GH;
    const mctx = mask.getContext('2d', { willReadFrequently: true })!;
    mctx.scale(1 / CELL, 1 / CELL);
    fillPoly(mctx, waterPoly, '#000');
    const data = mctx.getImageData(0, 0, GW, GH).data;
    for (let i = 0; i < GW * GH; i++) {
      if (data[i * 4 + 3] > 128) {
        this.surface[i] = SURF_WATER;
        this.occ[i] |= OCC_WATER;
      }
    }
    for (const layer of [0, 1]) {
      this.solidLine(offsetPoints(coast, -6), 4, layer, false, 'shore');
      this.solidLine([{ x: 0, y: 0 }, { x: MAP_W, y: 0 }, { x: MAP_W, y: MAP_H }, { x: 0, y: MAP_H }], 6, layer, true);
    }

    // Lakeside park in the north-west, Mirror Lake in the south and the quarry.
    this.lake(720, 860, 380, 250);
    this.lake(3300, 5300, 250, 160);
    this.quarry(3000, 6420, 190);
    for (let i = 0; i < 170; i++) {
      const a = rand() * Math.PI * 2;
      const d = range(1.15, 2.1);
      this.tree(720 + Math.cos(a) * 380 * d, 860 + Math.sin(a) * 250 * d, range(7, 13), rand() < 0.4 ? 'pine' : 'park');
    }
    // Farm fields to the south.
    for (let i = 0; i < 6; i++) {
      const fx = 1700 + i * 300;
      const fy = 3720;
      const color = pick(['#b9a35a', '#8fae4f', '#c9b36a', '#7d9f45']);
      this.add(L_GROUND, fx, fy, fx + 280, fy + 360, (ctx) => {
        ctx.fillStyle = color;
        ctx.fillRect(fx, fy, 280, 360);
        ctx.strokeStyle = 'rgba(0, 0, 0, 0.12)';
        ctx.lineWidth = 2;
        ctx.beginPath();
        for (let r = 6; r < 280; r += 9) {
          ctx.moveTo(fx + r, fy + 4);
          ctx.lineTo(fx + r, fy + 356);
        }
        ctx.stroke();
      });
      this.stampRect(this.occ, fx, fy, 280, 360, OCC_BUILT, true);
    }
  }

  private quarry(cx: number, cy: number, r: number): void {
    const rings = 5;
    this.add(L_GROUND, cx - r - 30, cy - r - 30, cx + r + 30, cy + r + 30, (ctx) => {
      ctx.fillStyle = '#a89a80';
      ctx.beginPath();
      ctx.ellipse(cx, cy, r + 26, r * 0.8 + 26, 0.2, 0, Math.PI * 2);
      ctx.fill();
      for (let i = 0; i < rings; i++) {
        const k = 1 - i / rings;
        ctx.fillStyle = ['#9a8f7c', '#8a806e', '#7b7262', '#6c6456', '#5d574b'][i];
        ctx.beginPath();
        ctx.ellipse(cx + i * 6, cy + i * 4, r * k, r * 0.8 * k, 0.2, 0, Math.PI * 2);
        ctx.fill();
        ctx.strokeStyle = 'rgba(255, 255, 255, 0.12)';
        ctx.lineWidth = 1.2;
        ctx.stroke();
      }
      ctx.fillStyle = '#4f6e7a';
      ctx.beginPath();
      ctx.ellipse(cx + 30, cy + 22, r * 0.18, r * 0.12, 0.2, 0, Math.PI * 2);
      ctx.fill();
    });
    this.stampCircle(this.surface, cx, cy, r, SURF_DIRT);
  }

  private lake(cx: number, cy: number, rx: number, ry: number): void {
    this.add(L_GROUND, cx - rx - 40, cy - ry - 40, cx + rx + 40, cy + ry + 40, (ctx) => {
      ctx.strokeStyle = '#cdb98c';
      ctx.lineWidth = 7;
      ctx.beginPath();
      ctx.ellipse(cx, cy, rx + 30, ry + 30, 0, 0, Math.PI * 2);
      ctx.stroke();
      ctx.fillStyle = SAND;
      ctx.beginPath();
      ctx.ellipse(cx, cy, rx + 8, ry + 8, 0, 0, Math.PI * 2);
      ctx.fill();
      const g = ctx.createRadialGradient(cx, cy, 30, cx, cy, rx);
      g.addColorStop(0, '#2f6f95');
      g.addColorStop(1, WATER);
      ctx.fillStyle = g;
      ctx.beginPath();
      ctx.ellipse(cx, cy, rx, ry, 0, 0, Math.PI * 2);
      ctx.fill();
    });
    for (let a = 0; a < Math.PI * 2; a += 0.05) {
      this.stampCircle(this.surface, cx + Math.cos(a) * rx * 0.5, cy + Math.sin(a) * ry * 0.5, Math.min(rx, ry) * 0.55, SURF_WATER);
    }
    this.stampRect(this.occ, cx - rx - 10, cy - ry - 10, (rx + 10) * 2, (ry + 10) * 2, OCC_WATER, true);
    const ring: Vec[] = [];
    for (let a = 0; a < Math.PI * 2; a += 0.08) ring.push({ x: cx + Math.cos(a) * (rx - 4), y: cy + Math.sin(a) * (ry - 4) });
    this.solidLine(ring, 2, 0, true, 'shore');
  }

  /** Dense woods with solid trunks, grouped into one painted item per map chunk. */
  private plantForests(): void {
    const regions: [number, number, number, number, number][] = [
      [20, 20, 1450, 1700, 0.85],
      [1680, 20, 2540, 1420, 0.55],
      [20, 1960, 280, 4300, 0.7],
      [3600, 3600, 4800, 3980, 0.25],
      [20, 4300, 1450, 6780, 0.8],
      [4250, 5900, 5180, 6780, 0.75],
      [1700, 5800, 2600, 6780, 0.5],
      [20, 7900, 1300, 9180, 0.7],
      [1700, 8600, 3000, 9180, 0.6],
      [3800, 4500, 5180, 4900, 0.45],
      ...this.extraForests,
    ];
    const cells = new Map<number, { x: number; y: number; r: number; tone: number }[]>();
    for (const [x0, y0, x1, y1, density] of regions) {
      for (let y = y0; y < y1; y += 17) {
        for (let x = x0; x < x1; x += 17) {
          const px = x + range(-6, 6);
          const py = y + range(-6, 6);
          // Patchy woods: a cheap noise field thins the trees into clearings.
          const n = Math.sin(px * 0.011) * Math.cos(py * 0.013) + Math.sin((px + py) * 0.005);
          if (rand() > density * (0.55 + n * 0.45)) continue;
          let clear = true;
          for (let dy = -2; dy <= 2 && clear; dy++) {
            for (let dx = -2; dx <= 2 && clear; dx++) {
              const j = this.cellIndex(px + dx * CELL, py + dy * CELL);
              if (j < 0 || this.occ[j] !== 0 || this.surface[j] === SURF_WATER || this.surface[j] === SURF_ROAD) clear = false;
            }
          }
          if (!clear) continue;
          const r = range(6, 11);
          // Each trunk is a (very weak) structure, so it can be blown down.
          this.structures.push({ id: this.structures.length, x: px, y: py, angle: 0, w: r * 2, d: r * 2, district: 'country', mat: 'wood', hp: 12, maxHp: 12, destroyed: false, building: false, tree: true });
          this.solidOwner = this.structures.length - 1;
          this.solidSeg({ x: px, y: py }, { x: px + 0.01, y: py }, 2.4, 0, 'wood');
          this.solidOwner = -1;
          const key = Math.floor(py / 256) * 1000 + Math.floor(px / 256);
          const list = cells.get(key) ?? [];
          list.push({ x: px, y: py, r, tone: rand() });
          cells.set(key, list);
          const i = this.cellIndex(px, py);
          if (i >= 0) this.occ[i] |= OCC_BUILT;
        }
      }
    }
    for (const trees of cells.values()) {
      const [x0, y0, x1, y1] = bboxOf(trees, 14);
      this.add(L_CANOPY, x0, y0, x1 + 8, y1 + 10, (ctx) => {
        ctx.fillStyle = 'rgba(0, 0, 0, 0.28)';
        for (const t of trees) {
          ctx.beginPath();
          ctx.ellipse(t.x + t.r * 0.4, t.y + t.r * 0.5, t.r, t.r * 0.9, 0, 0, Math.PI * 2);
          ctx.fill();
        }
        for (const t of trees) {
          const pine = t.tone < 0.45;
          const g = ctx.createRadialGradient(t.x - t.r * 0.35, t.y - t.r * 0.35, t.r * 0.1, t.x, t.y, t.r);
          g.addColorStop(0, pine ? '#3f7f45' : t.tone < 0.8 ? '#6db454' : '#8fbf4a');
          g.addColorStop(1, pine ? '#1b4527' : '#2a5f30');
          ctx.fillStyle = g;
          ctx.beginPath();
          if (pine) {
            for (let k = 0; k < 8; k++) {
              const a = (k / 8) * Math.PI * 2;
              const rr = k % 2 ? t.r * 0.7 : t.r;
              ctx.lineTo(t.x + Math.cos(a) * rr, t.y + Math.sin(a) * rr);
            }
            ctx.closePath();
          } else {
            ctx.arc(t.x, t.y, t.r, 0, Math.PI * 2);
          }
          ctx.fill();
        }
      });
    }
  }

  /** Patchwork farmland: crops, orchards, pastures and hay. */
  private plantFields(): void {
    // The old city's farms, then the backcountry's bigger, emptier ones.
    const areas: { x0: number; y0: number; x1: number; y1: number; sx: number; sy: number; district: District; skip: number }[] = [
      { x0: 320, y0: 3950, x1: 4850, y1: 9100, sx: 240, sy: 190, district: 'country', skip: 0.25 },
      { x0: 200, y0: 9300, x1: 16200, y1: 29000, sx: 300, sy: 240, district: 'rural', skip: 0.35 },
    ];
    for (const area of areas)
    for (let y = area.y0; y < area.y1; y += area.sy) {
      for (let x = area.x0; x < area.x1; x += area.sx) {
        const w = range(area.sx * 0.7, area.sx * 0.94);
        const h = range(area.sy * 0.68, area.sy * 0.92);
        const fx = x + range(0, area.sx - w);
        const fy = y + range(0, area.sy - h);
        const cx = fx + w / 2;
        const cy = fy + h / 2;
        if (this.districtAt(cx, cy) !== area.district || rand() < area.skip) continue;
        if (area.district === 'rural') {
          // Leave the backcountry's woods to the trees, and gather the farms into clusters.
          if (this.extraForests.some(([x0, y0, x1, y1]) => cx > x0 && cx < x1 && cy > y0 && cy < y1)) continue;
          const n = Math.sin(cx * 0.0011 + 1.3) * Math.cos(cy * 0.0009) + Math.sin((cx - cy) * 0.0006);
          if (n < -0.25 || rand() > 0.55 + n * 0.4) continue;
        }
        if (!this.lotFree(boxCorners(cx, cy, 0, w / 2, h / 2), cx, cy, 0, w / 2, h / 2)) continue;
        this.claim(cx, cy, 0, w / 2, h / 2);
        const roll = rand();
        if (roll < 0.22) {
          this.pastures.push({ x: fx, y: fy, w, h });
          this.add(L_GROUND, fx - 2, fy - 2, fx + w + 2, fy + h + 2, (ctx) => {
            ctx.fillStyle = '#79b257';
            ctx.fillRect(fx, fy, w, h);
            ctx.strokeStyle = '#8a6a44';
            ctx.lineWidth = 1;
            ctx.strokeRect(fx, fy, w, h);
            ctx.fillStyle = '#8a6a44';
            for (let i = 0; i < w; i += 12) {
              ctx.fillRect(fx + i - 0.6, fy - 0.6, 1.2, 1.2);
              ctx.fillRect(fx + i - 0.6, fy + h - 0.6, 1.2, 1.2);
            }
          });
          // Fences keep the cows in (and the truck out, mostly).
          this.solidLine([{ x: fx, y: fy }, { x: fx + w, y: fy }, { x: fx + w, y: fy + h }, { x: fx, y: fy + h }], 0.6, 0, true, 'wood');
        } else if (roll < 0.42) {
          this.fields.push({ x: fx, y: fy, w, h, kind: 'orchard' });
          const trees: Vec[] = [];
          for (let ty = fy + 10; ty < fy + h - 6; ty += 16) for (let tx = fx + 10; tx < fx + w - 6; tx += 16) trees.push({ x: tx + range(-1, 1), y: ty + range(-1, 1) });
          const fruit = pick(['#e74c3c', '#f39c12', '#f4d03f']);
          this.add(L_CANOPY, fx - 4, fy - 4, fx + w + 8, fy + h + 8, (ctx) => {
            ctx.fillStyle = '#6a9a48';
            ctx.fillRect(fx, fy, w, h);
            for (const t of trees) {
              ctx.fillStyle = 'rgba(0, 0, 0, 0.22)';
              ctx.beginPath();
              ctx.arc(t.x + 2, t.y + 2.5, 5, 0, Math.PI * 2);
              ctx.fill();
              ctx.fillStyle = '#3f7f3a';
              ctx.beginPath();
              ctx.arc(t.x, t.y, 5, 0, Math.PI * 2);
              ctx.fill();
              ctx.fillStyle = fruit;
              ctx.fillRect(t.x - 2, t.y - 1, 1, 1);
              ctx.fillRect(t.x + 1.5, t.y + 1, 1, 1);
            }
          });
        } else {
          this.fields.push({ x: fx, y: fy, w, h, kind: 'crop' });
          const crop = pick(['#b9a35a', '#8fae4f', '#c9b36a', '#7d9f45', '#9b7b52', '#d9c65a']);
          const sunflower = rand() < 0.15;
          const bales = rand() < 0.3 ? Array.from({ length: 6 }, () => ({ x: fx + range(10, w - 10), y: fy + range(10, h - 10) })) : [];
          const vertical = rand() < 0.5;
          this.add(L_GROUND, fx, fy, fx + w, fy + h, (ctx) => {
            ctx.fillStyle = sunflower ? '#5d8a3a' : crop;
            ctx.fillRect(fx, fy, w, h);
            ctx.strokeStyle = sunflower ? '#f4d03f' : 'rgba(0, 0, 0, 0.12)';
            ctx.lineWidth = sunflower ? 1.6 : 2;
            ctx.setLineDash(sunflower ? [1.6, 2.4] : []);
            ctx.beginPath();
            if (vertical) {
              for (let r = 6; r < w; r += 9) {
                ctx.moveTo(fx + r, fy + 4);
                ctx.lineTo(fx + r, fy + h - 4);
              }
            } else {
              for (let r = 6; r < h; r += 9) {
                ctx.moveTo(fx + 4, fy + r);
                ctx.lineTo(fx + w - 4, fy + r);
              }
            }
            ctx.stroke();
            ctx.setLineDash([]);
            for (const b of bales) {
              ctx.fillStyle = 'rgba(0, 0, 0, 0.25)';
              ctx.beginPath();
              ctx.arc(b.x + 1.5, b.y + 2, 3, 0, Math.PI * 2);
              ctx.fill();
              ctx.fillStyle = '#e2c25a';
              ctx.beginPath();
              ctx.arc(b.x, b.y, 3, 0, Math.PI * 2);
              ctx.fill();
            }
          });
          for (const b of bales) this.solidSeg(b, { x: b.x + 0.01, y: b.y }, 3, 0, 'wood');
        }
      }
    }
  }

  /** Farmer's markets and wild parties: open spots the game fills with stalls and crowds. */
  private placeVenues(): void {
    const tryPlace = (kind: Venue['kind'], district: District, x0: number, y0: number, x1: number, y1: number, r: number): boolean => {
      for (let i = 0; i < 1500; i++) {
        const x = range(x0, x1);
        const y = range(y0, y1);
        const corners = boxCorners(x, y, 0, r, r);
        if (!this.lotFree(corners, x, y, 0, r, r)) continue;
        this.claim(x, y, 0, r, r);
        const venue = { kind, x, y, r, angle: rand() * Math.PI, district };
        this.venues.push(venue);
        this.add(L_OBJECT, x - r - 4, y - r - 4, x + r + 4, y + r + 4, (ctx) => {
          ctx.fillStyle = kind === 'market' ? '#a89470' : kind === 'rave' ? '#3a3631' : '#6aa84f';
          ctx.beginPath();
          ctx.roundRect(x - r, y - r, r * 2, r * 2, 10);
          ctx.fill();
          if (kind === 'party') {
            ctx.fillStyle = '#3fb4dc';
            ctx.beginPath();
            ctx.roundRect(x - r * 0.5, y + r * 0.2, r, r * 0.55, 4);
            ctx.fill();
          }
        });
        return true;
      }
      return false;
    };
    // Markets on the farm road and downtown; parties in uptown and the hood.
    const farmCurve = this.countryCurves[2];
    if (farmCurve) {
      const p = farmCurve[Math.floor(farmCurve.length * 0.3)];
      tryPlace('market', 'country', p.x - 140, p.y - 140, p.x + 140, p.y + 140, 42);
    }
    tryPlace('market', 'downtown', 1900, 1950, 3200, 3100, 30) || tryPlace('market', 'downtown', 1700, 1900, 3300, 3200, 24);
    tryPlace('party', 'uptown', 2600, 400, 4500, 1400, 36);
    tryPlace('party', 'hood', 300, 2560, 1360, 3860, 26) || tryPlace('party', 'hood', 290, 2550, 1380, 3880, 18);
    tryPlace('party', 'village', 1600, 5000, 2500, 5700, 20) || tryPlace('party', 'village', 1550, 4950, 2550, 5750, 15);
    tryPlace('rave', 'port', 3600, 3000, 4850, 4100, 42) || tryPlace('rave', 'port', 3600, 3000, 4850, 4100, 30);
    tryPlace('rave', 'country', 250, 1150, 1300, 1650, 48);
    tryPlace('market', 'village', 1600, 5000, 2500, 5700, 20) || tryPlace('market', 'village', 1550, 4950, 2550, 5750, 16);
    tryPlace('party', 'suburb', 5600, 8000, 8600, 14500, 30);
    tryPlace('party', 'suburb', 5600, 8000, 8600, 14500, 24);
    tryPlace('market', 'town', 6250, 20750, 7750, 21950, 22);
    tryPlace('rave', 'rail', 5700, 6900, 11000, 7250, 40);
    tryPlace('rave', 'docks', 8000, 400, 10900, 4800, 36);
  }

  private sprinkleTrees(): void {
    let placed = 0;
    for (let i = 0; i < 30000 && placed < 3200; i++) {
      const x = range(20, MAP_W - 20);
      const y = range(20, MAP_H - 20);
      let clear = true;
      for (let dy = -2; dy <= 2 && clear; dy++) {
        for (let dx = -2; dx <= 2 && clear; dx++) {
          const j = this.cellIndex(x + dx * CELL, y + dy * CELL);
          if (j < 0 || this.occ[j] !== 0 || this.surface[j] === SURF_WATER || this.surface[j] === SURF_ROAD) clear = false;
        }
      }
      if (!clear) continue;
      const district = this.districtAt(x, y);
      this.tree(x, y, range(6, 12), district === 'uptown' ? 'fancy' : district === 'hood' ? 'scrappy' : rand() < 0.3 ? 'pine' : 'park');
      placed++;
    }
  }

  // --- Road network topology.

  private node(x: number, y: number, district: District): RNode {
    const n: RNode = { id: this.nodes.length, x, y, district, arms: [], radius: 0, joint: false };
    this.nodes.push(n);
    return n;
  }

  private road(a: RNode | null, b: RNode | null, name: SpecName, district: District, ctrl?: Vec[], layers: [number, number, number] = [0, 0, 0]): Road {
    const spec = SPECS[name];
    const halfW = spec.median / 2 + spec.lanes * spec.laneW * (spec.oneWay ? 0.5 : 1) + (name === 'expressway' ? 4 : 0);
    const r: Road = {
      id: this.roads.length,
      a,
      b,
      ctrl: ctrl ?? [
        { x: a!.x, y: a!.y },
        { x: b!.x, y: b!.y },
      ],
      spec,
      name,
      district,
      halfW,
      pts: [],
      fwd: [],
      back: [],
      layer0: layers[0],
      layer1: layers[1],
      breakFrac: layers[2],
    };
    this.roads.push(r);
    return r;
  }

  /** Nodes along a curve at the given arc positions, joined by roads. Returns a lookup by position. */
  private chain(curve: Vec[], positions: number[], name: SpecName, district: District, existing: [number, RNode][] = []): (s: number) => RNode {
    const path = makePath(curve);
    const sorted = [...new Set(positions.map((s) => clamp(s, 0, path.len)))].sort((a, b) => a - b);
    const nodes = sorted.map((s) => {
      const known = existing.find(([es]) => Math.abs(es - s) < 1);
      if (known) return known[1];
      const p = pointAt(path, s);
      return this.node(p.x, p.y, district);
    });
    for (let i = 0; i < nodes.length - 1; i++) {
      const piece = slicePath(curve, sorted[i], sorted[i + 1]);
      piece[0] = { x: nodes[i].x, y: nodes[i].y };
      piece[piece.length - 1] = { x: nodes[i + 1].x, y: nodes[i + 1].y };
      const mid = this.districtAt((nodes[i].x + nodes[i + 1].x) / 2, (nodes[i].y + nodes[i + 1].y) / 2);
      this.road(nodes[i], nodes[i + 1], name, mid === 'highway' ? district : mid, piece);
    }
    return (s: number) => {
      let best = 0;
      for (let i = 1; i < sorted.length; i++) if (Math.abs(sorted[i] - s) < Math.abs(sorted[best] - s)) best = i;
      return nodes[best];
    };
  }

  private grid(xs: number[], ys: number[], district: District, kindAt: (vertical: boolean, index: number) => SpecName, skip = 0): RNode[][] {
    const nodes = xs.map((x) => ys.map((y) => this.node(x, y, district)));
    const degree = new Map<RNode, number>();
    const links: [RNode, RNode, SpecName][] = [];
    for (let j = 0; j < ys.length; j++) for (let i = 0; i < xs.length - 1; i++) links.push([nodes[i][j], nodes[i + 1][j], kindAt(false, j)]);
    for (let i = 0; i < xs.length; i++) for (let j = 0; j < ys.length - 1; j++) links.push([nodes[i][j], nodes[i][j + 1], kindAt(true, i)]);
    for (const [a, b] of links) {
      degree.set(a, (degree.get(a) ?? 0) + 1);
      degree.set(b, (degree.get(b) ?? 0) + 1);
    }
    for (const [a, b, kind] of links) {
      const interior = Math.min(a.x, b.x) > xs[0] && Math.max(a.x, b.x) < xs[xs.length - 1] && Math.min(a.y, b.y) > ys[0] && Math.max(a.y, b.y) < ys[ys.length - 1];
      if (skip && interior && rand() < skip && degree.get(a)! > 3 && degree.get(b)! > 3) {
        degree.set(a, degree.get(a)! - 1);
        degree.set(b, degree.get(b)! - 1);
        continue;
      }
      this.road(a, b, kind, district);
    }
    return nodes;
  }

  private buildNetwork(): void {
    // Interstate 90 runs west to east across the whole map.
    const i90 = smoothCurve(
      [
        { x: 30, y: 1900 },
        { x: 600, y: 1880 },
        { x: 1300, y: 1740 },
        { x: 2000, y: 1620 },
        { x: 2700, y: 1600 },
        { x: 3400, y: 1640 },
        { x: 4100, y: 1560 },
        { x: 4600, y: 1490 },
        { x: 4870, y: 1470 },
      ],
      6,
    );
    this.freewayCenter = i90;

    // Route 9: a divided expressway running north-south at ground level.
    const r9 = smoothCurve(
      [
        { x: 1500, y: 10 },
        { x: 1520, y: 600 },
        { x: 1600, y: 1300 },
        { x: 1620, y: 1760 },
        { x: 1600, y: 2300 },
        { x: 1560, y: 3000 },
        { x: 1520, y: 3700 },
        { x: 1500, y: 4260 },
        { x: 1470, y: 4800 },
        { x: 1560, y: 5300 },
        { x: 1520, y: 6000 },
        { x: 1450, y: 6790 },
        { x: 1520, y: 7600 },
        { x: 1460, y: 8400 },
        { x: 1500, y: 9190 },
      ],
      8,
    );
    const r9Len = makePath(r9).len;
    const r9Cross = crossingS(r9, i90);
    const r9Ys = [350, 900, 2130, 2390, 2650, 2920, 2720, 3080, 3440, 3800, 4700, 5220, 5460, 6200, 7000, 7700, 8400];
    const r9Node = this.chain(r9, [0, r9Cross - 175, r9Cross + 175, ...r9Ys.map((y) => sAtY(r9, y)), r9Len], 'expressway', 'highway');
    const r9At = (y: number) => r9Node(sAtY(r9, y));

    // Downtown: a dense grid with three avenues each way.
    const dx = [1950, 2080, 2210, 2350, 2480, 2600, 2740, 2880, 3010, 3150];
    const dy = [2000, 2130, 2260, 2390, 2520, 2650, 2790, 2920, 3050];
    const down = this.grid(dx, dy, 'downtown', (_vertical, i) => ([2, 5, 8].includes(i) ? 'avenue' : 'street'));
    for (const j of [1, 3, 5, 7]) this.road(r9At(dy[j]), down[0][j], j === 5 ? 'avenue' : 'street', 'downtown');
    const southAve = smoothCurve([{ x: dx[2], y: dy[8] }, { x: 2160, y: 3260 }, { x: 1950, y: 3420 }, r9At(3440)], 8);
    this.road(down[2][8], r9At(3440), 'avenue', 'downtown', southAve);

    // Grand Avenue runs north from downtown, under I-90, into uptown.
    const grand = smoothCurve([{ x: dx[5], y: dy[0] }, { x: 2605, y: 1600 }, { x: 2640, y: 1250 }], 8);
    const gLen = makePath(grand).len;
    const gCross = crossingS(grand, i90);
    const uptownGate = this.node(2640, 1250, 'uptown');
    const grandNode = this.chain(grand, [0, gCross - 175, gCross + 175, gLen], 'avenue', 'highway', [
      [0, down[5][0]],
      [gLen, uptownGate],
    ]);

    // Uptown: a curving boulevard with cul-de-sac drives off it.
    const boulevard = smoothCurve(
      [
        { x: 2640, y: 1250 },
        { x: 2760, y: 980 },
        { x: 3050, y: 760 },
        { x: 3450, y: 640 },
        { x: 3850, y: 700 },
        { x: 4150, y: 900 },
        { x: 4200, y: 1100 },
        { x: 4070, y: 1250 },
      ],
      8,
    );
    const bPath = makePath(boulevard);
    const branchS: number[] = [];
    for (let s = 230; s < bPath.len - 200; s += range(200, 260)) branchS.push(s);
    const harborTop = this.node(4070, 1250, 'uptown');
    const bNode = this.chain(boulevard, [0, ...branchS, bPath.len], 'boulevard', 'uptown', [
      [0, uptownGate],
      [bPath.len, harborTop],
    ]);
    let side = 1;
    for (const s of branchS) {
      const n = bNode(s);
      const i = nearestIndex(boulevard, n);
      const t = tangentAt(boulevard, i);
      const nrm = { x: -t.y * side, y: t.x * side };
      side = -side;
      const len = range(200, 320);
      const bend = range(-0.5, 0.5);
      const end = { x: n.x + nrm.x * len + t.x * len * bend * 0.6, y: n.y + nrm.y * len + t.y * len * bend * 0.6 };
      if (end.y < 140 || end.y > 1330 || end.x < 2560 || end.x > 4600) continue;
      if (Math.min(...i90.filter((_, k) => k % 6 === 0).map((p) => Math.hypot(p.x - end.x, p.y - end.y))) < 260) continue;
      const curve = smoothCurve([n, { x: n.x + nrm.x * len * 0.45 + t.x * len * bend * 0.2, y: n.y + nrm.y * len * 0.45 + t.y * len * bend * 0.2 }, end], 8);
      this.road(n, this.node(end.x, end.y, 'uptown'), 'drive', 'uptown', curve);
    }

    // The port: wide industrial streets on a coarse grid.
    const px = [3700, 4000, 4300, 4600];
    const py = [3050, 3350, 3650, 3950];
    const port = this.grid(px, py, 'port', () => 'industrial');

    // Harbor Road: from the port north, under I-90, to uptown.
    const harbor = smoothCurve([{ x: 4000, y: 3050 }, { x: 4010, y: 2300 }, { x: 4060, y: 1560 }, { x: 4070, y: 1250 }], 8);
    const hLen = makePath(harbor).len;
    const hCross = crossingS(harbor, i90);
    const hRow = sAtY(harbor, dy[5]);
    const harborNode = this.chain(harbor, [0, hRow, hCross - 175, hCross + 175, hLen], 'avenue', 'highway', [
      [0, port[1][0]],
      [hLen, harborTop],
    ]);
    this.road(down[9][5], harborNode(hRow), 'avenue', 'downtown');
    this.road(down[9][8], port[0][0], 'avenue', 'downtown');

    // The hood: a tight, irregular grid of narrow streets.
    const hx = [350, 470, 590, 710, 830, 950, 1070, 1190, 1310];
    const hy = [2600, 2720, 2840, 2960, 3080, 3200, 3320, 3440, 3560, 3680, 3800];
    const hood = this.grid(hx, hy, 'hood', (vertical, i) => ((vertical && i === 4) || (!vertical && i === 5) ? 'street' : 'drive'), 0.14);
    for (const j of [1, 4, 7, 10]) this.road(hood[8][j], r9At(hy[j]), 'street', 'hood');

    // Country roads: winding two-lane blacktop through the forest and the farms.
    const country = (ctrl: Vec[], a: RNode, b: RNode, stops: number[] = []) => {
      const curve = smoothCurve([{ x: a.x, y: a.y }, ...ctrl, { x: b.x, y: b.y }], 8);
      this.countryCurves.push(curve);
      const len = makePath(curve).len;
      return this.chain(curve, [0, ...stops.map((f) => f * len), len], 'country', 'country', [
        [0, a],
        [len, b],
      ]);
    };
    // Old Mill Road: from Route 9 west through the woods, round the lake and down to the hood.
    const mill = country(
      [
        { x: 1250, y: 330 },
        { x: 980, y: 260 },
        { x: 640, y: 420 },
        { x: 300, y: 360 },
        { x: 170, y: 760 },
        { x: 260, y: 1180 },
        { x: 170, y: 1600 },
        { x: 330, y: 2100 },
        { x: 250, y: 2400 },
      ],
      r9At(350),
      hood[0][0],
      [0.35],
    );
    // Ridge Road: from Route 9 up over the hills to the uptown gate.
    country(
      [
        { x: 1850, y: 830 },
        { x: 2080, y: 1060 },
        { x: 2350, y: 1120 },
      ],
      r9At(900),
      uptownGate,
    );
    // Farm Road: across the fields from Route 9 to the port.
    const farm = country(
      [
        { x: 1800, y: 3660 },
        { x: 2400, y: 3700 },
        { x: 3000, y: 3620 },
        { x: 3450, y: 3820 },
      ],
      r9At(3800),
      port[0][3],
      [0.45],
    );
    // A lane down to the lake, and a farm lane south off Farm Road.
    const millMid = mill(makePath(this.countryCurves[0]).len * 0.35);
    this.road(millMid, this.node(720, 560, 'country'), 'country', 'country', smoothCurve([millMid, { x: 760, y: 500 }, { x: 720, y: 560 }], 8));
    const farmMid = farm(makePath(this.countryCurves[2]).len * 0.45);
    const orchardJct = this.node(farmMid.x + 40, 4700, 'country');
    this.road(farmMid, orchardJct, 'country', 'country', smoothCurve([farmMid, { x: farmMid.x + 30, y: 3950 }, { x: farmMid.x + 50, y: 4350 }, orchardJct], 8));

    // Millbrook: a little village of cottages and shops on Route 9.
    const vx = [1700, 1820, 1940, 2060, 2180, 2300, 2420];
    const vy = [5100, 5220, 5340, 5460, 5580];
    const village = this.grid(vx, vy, 'village', (vertical, i) => ((vertical && i === 3) || (!vertical && i === 2) ? 'avenue' : 'street'), 0.1);
    for (const j of [1, 3]) this.road(r9At(vy[j]), village[0][j], 'street', 'village');
    // Orchard Road: from Route 9 through the orchards, round Mirror Lake to Millbrook.
    country([{ x: 2000, y: 4640 }], r9At(4700), orchardJct);
    country(
      [
        { x: 3300, y: 4800 },
        { x: 4000, y: 4700 },
        { x: 4600, y: 4950 },
        { x: 4650, y: 5500 },
        { x: 4300, y: 5900 },
        { x: 3600, y: 6050 },
        { x: 2900, y: 5700 },
      ],
      orchardJct,
      village[6][2],
    );
    // Quarry Road and Pine Hollow Road.
    country([{ x: 2200, y: 5900 }, { x: 2600, y: 6200 }], village[3][4], this.node(2860, 6330, 'country'));
    country(
      [
        { x: 380, y: 4300 },
        { x: 250, y: 4900 },
        { x: 420, y: 5500 },
        { x: 800, y: 6100 },
        { x: 1200, y: 6250 },
      ],
      hood[0][10],
      r9At(6200),
    );

    // The industrial park: factories, tank farms, rail and scrap on a big grid.
    const ix = [3200, 3550, 3900, 4250, 4600, 4950];
    const iy = [7000, 7350, 7700, 8050, 8400, 8750];
    const ind = this.grid(ix, iy, 'industrial', () => 'industrial', 0.12);
    // Route 12 links Route 9 to the park; Factory Road comes down from Millbrook.
    this.road(r9At(7700), ind[0][2], 'expressway', 'highway');
    country([{ x: 2700, y: 5900 }, { x: 2600, y: 6600 }, { x: 3100, y: 6880 }], village[6][4], ind[1][0]);
    // County Road 7: a long loop through the south-western farms and woods.
    country(
      [
        { x: 1000, y: 8550 },
        { x: 520, y: 8350 },
        { x: 300, y: 7750 },
        { x: 560, y: 7150 },
      ],
      r9At(8400),
      r9At(7000),
    );

    // The country beyond: Route 9 runs on south, and the new districts hang off it.
    expansionNetwork(this.builder(), { r9End: r9Node(r9Len), port: port[3][3], industrial: ind[5][2] });

    this.buildFreeway(
      i90,
      [
        { north: r9Node(r9Cross - 175), south: r9Node(r9Cross + 175) },
        { north: grandNode(gCross + 175), south: grandNode(gCross - 175) },
        { north: harborNode(hCross + 175), south: harborNode(hCross - 175) },
      ],
      port[0][0],
    );
  }

  // --- The interstate: elevated, three lanes each way, diamond interchanges and a spur.

  private buildFreeway(center: Vec[], diamonds: { north: RNode; south: RNode }[], spurEnd: RNode): void {
    const n = center.length;
    const offs = [16, 32, 48];
    const westCenter = [...center].reverse();
    const eb = offs.map((o) => offsetPoints(center, o));
    const wb = offs.map((o) => offsetPoints(westCenter, o));
    const D = 50;
    type Cut = { idx: number; off?: Road; on?: Road };
    const ebCuts: Cut[] = [];
    const wbCuts: Cut[] = [];
    const ramps: { pts: Vec[]; off: boolean }[] = [];
    const gaps: [boolean[], boolean[]] = [new Array<boolean>(n).fill(false), new Array<boolean>(n).fill(false)];

    const rampRoad = (pts: Vec[], node: RNode, off: boolean): Road => {
      ramps.push({ pts, off });
      return this.road(off ? null : node, off ? node : null, 'ramp', 'highway', pts, off ? [1, 0, 0.5] : [0, 1, 0.5]);
    };
    const curveTo = (p0: Vec, t0: Vec, p3: Vec, t3: Vec) => {
      const k = Math.hypot(p3.x - p0.x, p3.y - p0.y) * 0.45;
      return cubicPoints(p0, { x: p0.x + t0.x * k, y: p0.y + t0.y * k }, { x: p3.x - t3.x * k, y: p3.y - t3.y * k }, p3, 28);
    };
    const mark = (gap: boolean[], from: number, to: number) => {
      for (let i = Math.min(from, to); i <= Math.max(from, to); i++) gap[clamp(i, 0, n - 1)] = true;
    };

    for (const dm of diamonds) {
      const cross = nearestIndex(center, { x: (dm.north.x + dm.south.x) / 2, y: (dm.north.y + dm.south.y) / 2 });
      const tan = tangentAt(center, cross);
      const back = { x: -tan.x, y: -tan.y };
      const ebOff = cross - D;
      const ebOn = cross + D;
      ebCuts.push(
        { idx: ebOff, off: rampRoad(curveTo(eb[2][ebOff], tangentAt(eb[2], ebOff), dm.south, tan), dm.south, true) },
        { idx: ebOn, on: rampRoad(curveTo(dm.south, tan, eb[2][ebOn], tangentAt(eb[2], ebOn)), dm.south, false) },
      );
      const wCross = n - 1 - cross;
      const wbOff = wCross - D;
      const wbOn = wCross + D;
      wbCuts.push(
        { idx: wbOff, off: rampRoad(curveTo(wb[2][wbOff], tangentAt(wb[2], wbOff), dm.north, back), dm.north, true) },
        { idx: wbOn, on: rampRoad(curveTo(dm.north, back, wb[2][wbOn], tangentAt(wb[2], wbOn)), dm.north, false) },
      );
      mark(gaps[0], ebOff - 3, ebOff + D - 8);
      mark(gaps[0], ebOn + 3, ebOn - D + 8);
      mark(gaps[1], n - 1 - (wbOff - 3), n - 1 - (wbOff + D - 8));
      mark(gaps[1], n - 1 - (wbOn + 3), n - 1 - (wbOn - D + 8));
    }

    // I-190 spur: leaves eastbound I-90 and runs south-east to the port.
    const spurDiv = nearestIndex(center, { x: 3020, y: 1620 });
    const spurMerge = nearestIndex(center, { x: 3480, y: 1640 });
    const spurBase = smoothCurve(
      [
        { x: 3260, y: 1800 },
        { x: 3360, y: 2050 },
        { x: 3440, y: 2400 },
        { x: 3560, y: 2760 },
        { x: spurEnd.x, y: spurEnd.y - 60 },
        { x: spurEnd.x, y: spurEnd.y },
      ],
      8,
    );
    const sbCenter = offsetPoints(spurBase, 18);
    const nbCenter = offsetPoints([...spurBase].reverse(), 18);
    const divTan = tangentAt(eb[2], spurDiv);
    const divStart = { x: eb[2][spurDiv].x - divTan.y * 8, y: eb[2][spurDiv].y + divTan.x * 8 };
    const sbPts = [...curveTo(divStart, divTan, sbCenter[0], tangentAt(sbCenter, 0)), ...sbCenter.slice(1)];
    const mTan = tangentAt(eb[2], spurMerge);
    const mergeEnd = { x: eb[2][spurMerge].x - mTan.y * 8, y: eb[2][spurMerge].y + mTan.x * 8 };
    const nbPts = [...nbCenter, ...curveTo(nbCenter[nbCenter.length - 1], tangentAt(nbCenter, nbCenter.length - 1), mergeEnd, mTan).slice(1)];
    const sb = this.road(null, spurEnd, 'spur', 'highway', sbPts, [1, 0, 0.86]);
    const nb = this.road(spurEnd, null, 'spur', 'highway', nbPts, [0, 1, 0.14]);
    ebCuts.push({ idx: spurDiv, off: sb }, { idx: spurMerge, on: nb });
    mark(gaps[0], spurDiv - 3, spurDiv + 40);
    mark(gaps[0], spurMerge + 3, spurMerge - 40);

    // Lanes, split at every ramp so parallel lanes share segments.
    const carriage = (lanePts: Vec[][], cuts: Cut[]) => {
      const idxs = [...new Set([0, ...cuts.map((c) => c.idx), n - 1])].sort((a, b) => a - b);
      const segs: number[][] = [];
      for (let k = 0; k < idxs.length - 1; k++) {
        const ids = lanePts.map((pts) => this.addLane(pts.slice(idxs[k], idxs[k + 1] + 1), 'highway', 175, 'highway', false, [1, 1, 0]));
        ids.forEach((id, i) => {
          if (i > 0) this.lanes[id].left = ids[i - 1];
          if (i < ids.length - 1) this.lanes[id].right = ids[i + 1];
        });
        segs.push(ids);
      }
      for (let k = 0; k < segs.length - 1; k++) {
        segs[k].forEach((id, i) => this.lanes[id].next.push(segs[k + 1][i]));
        for (const c of cuts) {
          if (c.idx !== idxs[k + 1]) continue;
          if (c.off) this.pendingOff.push({ from: segs[k][2], road: c.off });
          if (c.on) this.pendingOn.push({ to: segs[k + 1][2], road: c.on });
        }
      }
    };
    carriage(eb, ebCuts);
    carriage(wb, wbCuts);

    // Collision: barriers on both edges and the median, with gaps at ramps.
    const edgeRuns = (pts: Vec[], gap: boolean[], reverse: boolean) => {
      const runs: Vec[][] = [];
      let run: Vec[] = [];
      for (let i = 0; i < n; i++) {
        if (!gap[reverse ? n - 1 - i : i]) run.push(pts[i]);
        else if (run.length) {
          runs.push(run);
          run = [];
        }
      }
      if (run.length) runs.push(run);
      return runs.filter((r) => r.length > 1);
    };
    const barrierRuns = [...edgeRuns(offsetPoints(center, 69), gaps[0], false), ...edgeRuns(offsetPoints(westCenter, 69), gaps[1], true), center];
    for (const run of barrierRuns) this.solidLine(run, 2, 1);
    for (const end of [0, n - 1]) {
      const t = tangentAt(center, end);
      this.solidSeg({ x: center[end].x + t.y * 70, y: center[end].y - t.x * 70 }, { x: center[end].x - t.y * 70, y: center[end].y + t.x * 70 }, 3, 1);
    }
    this.stampPath(this.deckMask, center, 70, 1);
    this.stampPath(this.occ, center, 92, OCC_DECK, true);

    const [x0, y0, x1, y1] = bboxOf(center, 90);
    this.add(L_MARK, x0, y0, x1 + 20, y1 + 30, (ctx) => {
      ctx.lineJoin = 'round';
      strokeLine(ctx, center.map((p) => ({ x: p.x + 12, y: p.y + 18 })), 'rgba(0, 0, 0, 0.3)', 140);
    });
    this.add(L_DECK, x0, y0, x1, y1, (ctx) => {
      ctx.lineJoin = 'round';
      strokeLine(ctx, center, '#8d8a82', 144);
      strokeLine(ctx, center, '#36373e', 136);
      strokeLine(ctx, offsetPoints(center, 6), '#e8b83a', 1);
      strokeLine(ctx, offsetPoints(center, -6), '#e8b83a', 1);
      for (const c of [center, westCenter]) {
        strokeLine(ctx, offsetPoints(c, 24), '#e6e2d6', 1, [9, 11]);
        strokeLine(ctx, offsetPoints(c, 40), '#e6e2d6', 1, [9, 11]);
        strokeLine(ctx, offsetPoints(c, 57), '#e6e2d6', 1);
      }
    });
    this.add(L_DECKTOP, x0, y0, x1, y1, (ctx) => {
      ctx.lineJoin = 'round';
      for (const run of barrierRuns) {
        strokeLine(ctx, run, '#7d7a72', 4.2);
        strokeLine(ctx, run, '#d4d0c6', 2.8);
        strokeLine(ctx, run, 'rgba(255, 255, 255, 0.6)', 0.6);
      }
      ctx.fillStyle = '#5c5f66';
      for (let i = 20; i < n; i += 40) {
        ctx.beginPath();
        ctx.arc(center[i].x, center[i].y, 1.6, 0, Math.PI * 2);
        ctx.fill();
      }
    });
    // Exit signs on gantries just before each off-ramp.
    for (const c of [...ebCuts, ...wbCuts]) {
      if (!c.off || c.off.name !== 'ramp') continue;
      const west = wbCuts.includes(c);
      const idx = west ? n - 1 - (c.idx - 16) : c.idx - 16;
      if (idx < 0 || idx >= n) continue;
      const t = tangentAt(center, idx);
      const p = center[idx];
      const nx = -t.y * (west ? -1 : 1);
      const ny = t.x * (west ? -1 : 1);
      const a = { x: p.x, y: p.y };
      const b = { x: p.x + nx * 70, y: p.y + ny * 70 };
      const m = { x: p.x + nx * 50, y: p.y + ny * 50 };
      const ang = Math.atan2(ny, nx);
      this.add(L_DECKTOP, Math.min(a.x, b.x) - 20, Math.min(a.y, b.y) - 20, Math.max(a.x, b.x) + 20, Math.max(a.y, b.y) + 20, (ctx) => {
        strokeLine(ctx, [{ x: a.x + 3, y: a.y + 4 }, { x: b.x + 3, y: b.y + 4 }], 'rgba(0, 0, 0, 0.25)', 5);
        strokeLine(ctx, [a, b], '#8d9096', 2);
        ctx.save();
        ctx.translate(m.x, m.y);
        ctx.rotate(ang);
        ctx.fillStyle = '#1f6a46';
        ctx.strokeStyle = '#f4ecd8';
        ctx.lineWidth = 0.8;
        ctx.beginPath();
        ctx.roundRect(-11, -5, 22, 10, 1.5);
        ctx.fill();
        ctx.stroke();
        ctx.fillStyle = '#f4ecd8';
        ctx.font = '700 4px system-ui, sans-serif';
        ctx.textAlign = 'center';
        ctx.textBaseline = 'middle';
        ctx.fillText('EXIT', 0, 0.3);
        ctx.restore();
      });
    }

    for (const r of ramps) this.paintRamp(r.pts, r.off);
    this.paintSpur(sbPts, 0.86, true);
    this.paintSpur(nbPts, 0.14, false);
  }

  private placePiers(): void {
    const center = this.freewayCenter;
    for (let i = 10; i < center.length; i += 22) {
      const p = center[i];
      const idx = this.cellIndex(p.x, p.y);
      if (idx < 0 || this.surface[idx] === SURF_ROAD || this.surface[idx] === SURF_WATER || this.surface[idx] === SURF_SIDEWALK) continue;
      const t = tangentAt(center, i);
      const corners = boxCorners(p.x, p.y, Math.atan2(t.y, t.x) + Math.PI / 2, 40, 4);
      this.add(L_OBJECT, p.x - 44, p.y - 44, p.x + 44, p.y + 44, (ctx) => fillPoly(ctx, corners, '#9e9a90'));
      this.solidPoly(corners, 0);
    }
  }

  private paintRamp(pts: Vec[], off: boolean): void {
    const len = makePath(pts).len;
    const high = off ? slicePath(pts, 0, len * 0.56) : slicePath(pts, len * 0.44, len);
    const low = off ? slicePath(pts, len * 0.48, len) : slicePath(pts, 0, len * 0.52);
    this.stampPath(this.deckMask, high, 11, 1);
    this.stampPath(this.rampMask, slicePath(pts, len * 0.42, len * 0.58), 12, 1);
    this.stampPath(this.surface, low, 11, SURF_ROAD);
    this.stampPath(this.occ, pts, 20, OCC_ROAD, true);
    const [x0, y0, x1, y1] = bboxOf(pts, 30);
    this.add(L_ROAD, x0, y0, x1, y1, (ctx) => {
      ctx.lineCap = 'round';
      strokeLine(ctx, low, '#37383f', 22);
      ctx.lineCap = 'butt';
      strokeLine(ctx, offsetPoints(low, 9.5), '#e6e2d6', 1);
      strokeLine(ctx, offsetPoints(low, -9.5), '#e6e2d6', 1);
    });
    this.add(L_MARK, x0, y0, x1 + 20, y1 + 30, (ctx) => {
      strokeLine(ctx, high.map((p) => ({ x: p.x + 8, y: p.y + 12 })), 'rgba(0, 0, 0, 0.25)', 24);
    });
    const outer = offsetPoints(high, 12);
    const innerFull = offsetPoints(high, -12);
    const inner = off ? innerFull.slice(Math.floor(innerFull.length * 0.55)) : innerFull.slice(0, Math.ceil(innerFull.length * 0.45));
    const outerRun = off ? outer.slice(Math.floor(outer.length * 0.12)) : outer.slice(0, Math.ceil(outer.length * 0.88));
    this.solidLine(outerRun, 1.5, 1);
    if (inner.length > 1) this.solidLine(inner, 1.5, 1);
    this.add(L_DECK, x0, y0, x1, y1, (ctx) => {
      ctx.lineCap = 'round';
      strokeLine(ctx, high, '#8d8a82', 26);
      strokeLine(ctx, high, '#36373e', 21);
      ctx.lineCap = 'butt';
    });
    this.add(L_DECKTOP, x0, y0, x1, y1, (ctx) => {
      for (const b of [outerRun, inner]) {
        if (b.length < 2) continue;
        strokeLine(ctx, b, '#7d7a72', 3.4);
        strokeLine(ctx, b, '#d4d0c6', 2.2);
      }
    });
  }

  private paintSpur(pts: Vec[], breakFrac: number, highFirst: boolean): void {
    const len = makePath(pts).len;
    const high = highFirst ? slicePath(pts, 0, len * (breakFrac + 0.04)) : slicePath(pts, len * (breakFrac - 0.04), len);
    const low = highFirst ? slicePath(pts, len * (breakFrac - 0.02), len) : slicePath(pts, 0, len * (breakFrac + 0.02));
    this.stampPath(this.deckMask, high, 18, 1);
    this.stampPath(this.rampMask, slicePath(pts, len * (breakFrac - 0.03), len * (breakFrac + 0.03)), 18, 1);
    this.stampPath(this.surface, low, 18, SURF_ROAD);
    this.stampPath(this.occ, pts, 34, OCC_ROAD | OCC_DECK, true);
    const [x0, y0, x1, y1] = bboxOf(pts, 40);
    this.add(L_ROAD, x0, y0, x1, y1, (ctx) => {
      ctx.lineJoin = 'round';
      strokeLine(ctx, low, '#37383f', 36);
      strokeLine(ctx, low, '#e6e2d6', 1, [9, 11]);
    });
    this.add(L_MARK, x0, y0, x1 + 20, y1 + 30, (ctx) => {
      ctx.lineJoin = 'round';
      strokeLine(ctx, high.map((p) => ({ x: p.x + 10, y: p.y + 15 })), 'rgba(0, 0, 0, 0.28)', 40);
    });
    const outer = offsetPoints(high, 19);
    const trim = Math.floor(outer.length * 0.12);
    const outerRun = highFirst ? outer.slice(trim) : outer.slice(0, outer.length - trim);
    this.solidLine(outerRun, 1.5, 1);
    this.add(L_DECK, x0, y0, x1, y1, (ctx) => {
      ctx.lineJoin = 'round';
      strokeLine(ctx, high, '#8d8a82', 40);
      strokeLine(ctx, high, '#36373e', 34);
      strokeLine(ctx, high, '#e6e2d6', 1, [9, 11]);
    });
    this.add(L_DECKTOP, x0, y0, x1, y1, (ctx) => {
      ctx.lineJoin = 'round';
      strokeLine(ctx, outerRun, '#7d7a72', 3.4);
      strokeLine(ctx, outerRun, '#d4d0c6', 2.2);
    });
  }

  // --- Turning topology into lanes.

  private finalizeRoads(): void {
    const armsAt = new Map<RNode, { road: Road; atStart: boolean }[]>();
    for (const r of this.roads) {
      if (r.a) armsAt.set(r.a, [...(armsAt.get(r.a) ?? []), { road: r, atStart: true }]);
      if (r.b) armsAt.set(r.b, [...(armsAt.get(r.b) ?? []), { road: r, atStart: false }]);
    }
    for (const node of this.nodes) {
      const arms = armsAt.get(node) ?? [];
      const angles = arms.map(({ road, atStart }) => {
        const pts = road.ctrl;
        const p = atStart ? pts[0] : pts[pts.length - 1];
        const q = atStart ? pts[Math.min(2, pts.length - 1)] : pts[Math.max(0, pts.length - 3)];
        return Math.atan2(q.y - p.y, q.x - p.x);
      });
      if (arms.length === 2 && Math.abs(angleDiff(angles[0], angles[1])) > 2.7 && arms[0].road.name === arms[1].road.name) {
        node.joint = true;
        node.radius = 0;
      } else if (arms.length === 1) {
        node.radius = 26;
      } else {
        node.radius = Math.max(0, ...arms.map((a) => a.road.halfW)) + 4;
      }
    }
    for (const r of this.roads) {
      const len = makePath(r.ctrl).len;
      const s0 = r.a ? r.a.radius : 0;
      const s1 = len - (r.b ? r.b.radius : 0);
      r.pts = slicePath(r.ctrl, s0, Math.max(s0 + 1, s1));
      const spec = r.spec;
      const offsets = spec.oneWay
        ? Array.from({ length: spec.lanes }, (_, i) => (i - (spec.lanes - 1) / 2) * spec.laneW)
        : Array.from({ length: spec.lanes }, (_, i) => spec.median / 2 + spec.laneW * (i + 0.5));
      const kind: LaneKind = r.name === 'ramp' ? 'ramp' : r.name === 'spur' ? 'highway' : 'street';
      r.fwd = offsets.map((o) => this.addLane(offsetPoints(r.pts, o), kind, spec.speed, r.district, false, [r.layer0, r.layer1, r.breakFrac]));
      if (!spec.oneWay) r.back = offsets.map((o) => this.addLane(offsetPoints([...r.pts].reverse(), o), kind, spec.speed, r.district));
      for (const group of [r.fwd, r.back]) {
        group.forEach((id, i) => {
          if (i > 0) this.lanes[id].left = group[i - 1];
          if (i < group.length - 1) this.lanes[id].right = group[i + 1];
        });
      }
      const p = r.pts;
      const m = p.length - 1;
      if (r.a) r.a.arms.push({ road: r, angle: Math.atan2(p[1].y - p[0].y, p[1].x - p[0].x), out: r.fwd, in: r.back });
      if (r.b) r.b.arms.push({ road: r, angle: Math.atan2(p[m - 1].y - p[m].y, p[m - 1].x - p[m].x), out: r.back, in: r.fwd });
      // Delivery spots on the curb lane of ordinary streets.
      if (r.district !== 'highway' && !spec.oneWay && r.name !== 'expressway' && makePath(p).len > 70) {
        for (const group of [r.fwd, r.back]) {
          const lane = this.lanes[group[group.length - 1]];
          const a = pointAt(lane, lane.len / 2);
          const b = pointAt(lane, lane.len / 2 + 2);
          const spot = { x: a.x, y: a.y, angle: Math.atan2(b.y - a.y, b.x - a.x), district: r.district };
          this.dropSpots.push(spot);
          if (['downtown', 'industrial', 'port', 'country', 'docks', 'factory', 'rail', 'rural', 'commercial'].includes(r.district)) this.pickupSpots.push(spot);
        }
      }
    }
    for (const { from, road } of this.pendingOff) this.lanes[from].next.push(...road.fwd);
    for (const { to, road } of this.pendingOn) for (const id of road.fwd) this.lanes[id].next = [to];
  }

  /** Traffic lights at every town junction where three or more streets meet. */
  private placeSignals(): void {
    for (const node of this.nodes) {
      if (node.joint || node.arms.length < 3 || node.district === 'country' || node.district === 'rural' || node.district === 'highway') continue;
      const ins = node.arms.flatMap((arm) => arm.in.map((id) => ({ arm, lane: this.lanes[id] })));
      if (!ins.length || ins.some(({ lane }) => lane.kind !== 'street' || lane.layer1 !== 0)) continue;
      const ref = node.arms[0].angle;
      const signal: Signal = { x: node.x, y: node.y, offset: rand() * 30, approaches: [] };
      for (const { arm, lane } of ins) {
        const end = lane.pts[lane.pts.length - 1];
        const prev = lane.pts[Math.max(0, lane.pts.length - 2)];
        const a = Math.atan2(end.y - prev.y, end.x - prev.x);
        const phase = Math.abs(Math.cos(arm.angle - ref)) > 0.7 ? 0 : 1;
        // The signal head stands at the curb on the driver's right, just short of the junction.
        signal.approaches.push({ lane: lane.id, phase, x: end.x - Math.sin(a) * 6 - Math.cos(a) * 2, y: end.y + Math.cos(a) * 6 - Math.sin(a) * 2, angle: a });
        this.laneSignal.set(lane.id, { signal, phase });
      }
      this.signals.push(signal);
    }
  }

  private buildConnectors(): void {
    for (const node of this.nodes) {
      if (node.joint) {
        const [a, b] = node.arms;
        a.in.forEach((id, i) => this.lanes[id].next.push(b.out[Math.min(i, b.out.length - 1)]));
        b.in.forEach((id, i) => this.lanes[id].next.push(a.out[Math.min(i, a.out.length - 1)]));
        continue;
      }
      for (const armA of node.arms) {
        if (!armA.in.length) continue;
        const hin = armA.angle + Math.PI;
        for (const armB of node.arms) {
          if (!armB.out.length) continue;
          const d = angleDiff(armB.angle, hin);
          const uTurn = armB === armA || Math.abs(d) > 2.6;
          if (uTurn && node.arms.length > 1) continue;
          const kind = uTurn ? 'uturn' : Math.abs(d) < 0.6 ? 'straight' : d > 0 ? 'right' : 'left';
          let pairs: [number, number][];
          if (kind === 'straight') {
            pairs = armA.in.length === 1 ? armB.out.map((_, o) => [0, o] as [number, number]) : armA.in.map((_, i) => [i, Math.min(i, armB.out.length - 1)] as [number, number]);
          } else if (kind === 'right') {
            pairs = [[armA.in.length - 1, armB.out.length - 1]];
          } else {
            pairs = [[0, 0]];
          }
          for (const [i, o] of pairs) this.connect(this.lanes[armA.in[i]], this.lanes[armB.out[o]], kind, node);
        }
        armA.in.forEach((id, i) => {
          const lane = this.lanes[id];
          if (lane.next.length) return;
          for (const armB of node.arms) {
            if (armB === armA || !armB.out.length) continue;
            this.connect(lane, this.lanes[armB.out[Math.min(i, armB.out.length - 1)]], 'left', node);
          }
        });
      }
    }
  }

  private connect(inLane: Lane, outLane: Lane, kind: 'straight' | 'right' | 'left' | 'uturn', node: RNode): void {
    const p0 = inLane.pts[inLane.pts.length - 1];
    const p2 = outLane.pts[0];
    const a = inLane.pts[Math.max(0, inLane.pts.length - 2)];
    const b = outLane.pts[Math.min(1, outLane.pts.length - 1)];
    const h0 = Math.atan2(p0.y - a.y, p0.x - a.x);
    const h1 = Math.atan2(b.y - p2.y, b.x - p2.x);
    const dist = Math.hypot(p2.x - p0.x, p2.y - p0.y);
    const k = kind === 'uturn' ? 34 : dist * 0.45;
    const pts =
      kind === 'straight' && dist < 1
        ? [p0, p2]
        : cubicPoints(p0, { x: p0.x + Math.cos(h0) * k, y: p0.y + Math.sin(h0) * k }, { x: p2.x - Math.cos(h1) * k, y: p2.y - Math.sin(h1) * k }, p2, 12);
    const speed = kind === 'straight' ? inLane.speed : kind === 'uturn' ? 22 : 34;
    const id = this.addLane(pts, 'connector', speed, node.district, kind !== 'straight');
    this.lanes[id].next = [outLane.id];
    inLane.next.push(id);
  }

  // --- Painting ground roads.

  private paintRoads(): void {
    const ground = this.roads.filter((r) => r.name !== 'ramp' && r.name !== 'spur');
    for (const r of ground) {
      const st = STYLES[r.district];
      const w = r.halfW;
      if (r.spec.sidewalk) this.stampPath(this.surface, r.ctrl, w + r.spec.sidewalk, SURF_SIDEWALK);
      this.stampPath(this.occ, r.ctrl, w + r.spec.sidewalk + 2, OCC_ROAD, true);
      const [x0, y0, x1, y1] = bboxOf(r.ctrl, w + r.spec.sidewalk + 4);
      this.add(L_WALK, x0, y0, x1, y1, (ctx) => {
        ctx.lineJoin = 'round';
        if (r.spec.sidewalk) {
          strokeLine(ctx, r.ctrl, st.sidewalk, (w + r.spec.sidewalk) * 2);
          strokeLine(ctx, r.ctrl, st.curb, w * 2 + 2.4);
        } else {
          strokeLine(ctx, r.ctrl, r.name === 'country' ? '#a8996f' : '#7d8a5a', w * 2 + 6);
        }
      });
      this.add(L_ROAD, x0, y0, x1, y1, (ctx) => {
        ctx.lineJoin = 'round';
        strokeLine(ctx, r.ctrl, st.asphalt, w * 2);
      });
    }
    for (const r of ground) this.stampPath(this.surface, r.ctrl, r.halfW, SURF_ROAD);
    // Street lamps along town roads and Route 9.
    for (const r of ground) {
      if (r.district === 'country' || r.district === 'rural' || (r.district === 'highway' && r.name !== 'expressway')) continue;
      const path = makePath(r.ctrl);
      const off = r.halfW + Math.max(2, r.spec.sidewalk * 0.6);
      const poles: Vec[] = [];
      for (let s = 30; s < path.len - 20; s += 75) {
        const p = pointAt(path, s);
        const q = pointAt(path, s + 1);
        const l = Math.hypot(q.x - p.x, q.y - p.y) || 1;
        const side = (Math.floor(s / 75) % 2) * 2 - 1;
        const pole = { x: p.x - ((q.y - p.y) / l) * off * side, y: p.y + ((q.x - p.x) / l) * off * side };
        poles.push(pole);
        this.lights.push(pole);
      }
      if (!poles.length) continue;
      const [x0, y0, x1, y1] = bboxOf(poles, 3);
      this.add(L_OBJECT, x0, y0, x1, y1, (ctx) => {
        ctx.fillStyle = '#5c5f66';
        for (const p of poles) {
          ctx.beginPath();
          ctx.arc(p.x, p.y, 1.3, 0, Math.PI * 2);
          ctx.fill();
        }
      });
    }
    for (const node of this.nodes) {
      const roads = ground.filter((r) => r.a === node || r.b === node);
      if (!roads.length || node.joint) continue;
      const st = STYLES[roads[0].district];
      const walk = Math.max(...roads.map((r) => r.spec.sidewalk));
      const cul = node.arms.length === 1 || roads.length === 1;
      const rr = cul ? 26 : Math.max(...roads.map((r) => r.halfW));
      const x = node.x;
      const y = node.y;
      this.stampCircle(this.surface, x, y, rr + walk, SURF_SIDEWALK);
      this.stampCircle(this.surface, x, y, rr, SURF_ROAD);
      this.stampCircle(this.occ, x, y, rr + walk + 2, OCC_ROAD, true);
      if (walk) {
        this.add(L_WALK, x - rr - walk, y - rr - walk, x + rr + walk, y + rr + walk, (ctx) => {
          ctx.fillStyle = st.sidewalk;
          ctx.beginPath();
          ctx.arc(x, y, rr + walk, 0, Math.PI * 2);
          ctx.fill();
        });
      }
      this.add(L_ROAD, x - rr, y - rr, x + rr, y + rr, (ctx) => {
        ctx.fillStyle = st.asphalt;
        ctx.beginPath();
        ctx.arc(x, y, rr, 0, Math.PI * 2);
        ctx.fill();
      });
      if (cul && roads.length === 1 && roads[0].name === 'drive') this.tree(x, y, 7, 'fancy');
    }
  }

  private paintMarkings(): void {
    for (const r of this.roads) {
      if (r.name === 'ramp' || r.name === 'spur') continue;
      const st = STYLES[r.district];
      const spec = r.spec;
      const pts = r.pts;
      const path = makePath(pts);
      const len = path.len;
      if (len < 4) continue;
      const w = r.halfW;
      const [x0, y0, x1, y1] = bboxOf(pts, w + 4);
      const potholes = st.worn
        ? Array.from({ length: Math.floor(len / 90) + randInt(2) }, () => {
            const p = pointAt(path, range(10, Math.max(11, len - 10)));
            return { x: p.x + range(-w * 0.7, w * 0.7), y: p.y + range(-w * 0.7, w * 0.7), r: range(1.6, 3.6), a: rand() * 3 };
          })
        : [];
      if (spec.medianType === 'grass' && r.name === 'boulevard') {
        for (let s = 20; s < len - 20; s += 34) {
          const t = pointAt(path, s);
          this.tree(t.x, t.y, 6.5, 'fancy');
        }
      }
      const ends = [
        { node: r.a, at: 0 },
        { node: r.b, at: len },
      ].filter((e) => e.node && !e.node.joint && e.node.arms.length > 1);
      this.add(L_MARK, x0, y0, x1, y1, (ctx) => {
        ctx.lineJoin = 'round';
        if (spec.median) {
          strokeLine(ctx, pts, st.curb, spec.median + 1.6);
          strokeLine(ctx, pts, '#5f9447', spec.median - 1);
        }
        ctx.globalAlpha = st.markAlpha;
        if (spec.center === 'yellow') {
          strokeLine(ctx, offsetPoints(pts, -1.3), '#e8b83a', 0.9);
          strokeLine(ctx, offsetPoints(pts, 1.3), '#e8b83a', 0.9);
        }
        for (let i = 1; i < spec.lanes; i++) {
          const o = spec.median / 2 + spec.laneW * i;
          strokeLine(ctx, offsetPoints(pts, o), '#e6e2d6', 0.9, [6, 7]);
          strokeLine(ctx, offsetPoints(pts, -o), '#e6e2d6', 0.9, [6, 7]);
        }
        if (r.name === 'expressway') {
          strokeLine(ctx, offsetPoints(pts, w - 2), '#e6e2d6', 1);
          strokeLine(ctx, offsetPoints(pts, -w + 2), '#e6e2d6', 1);
        }
        for (const e of ends) {
          const p = pointAt(path, e.at === 0 ? 1 : len - 1);
          const q = pointAt(path, e.at === 0 ? 4 : len - 4);
          const ang = Math.atan2(q.y - p.y, q.x - p.x);
          const nx = -Math.sin(ang);
          const ny = Math.cos(ang);
          const fx = Math.cos(ang);
          const fy = Math.sin(ang);
          if (spec.crosswalk) {
            ctx.fillStyle = '#e6e2d6';
            for (let o = -w + 2; o < w - 2; o += 4.6) {
              ctx.beginPath();
              ctx.moveTo(p.x + nx * o, p.y + ny * o);
              ctx.lineTo(p.x + nx * (o + 2.4), p.y + ny * (o + 2.4));
              ctx.lineTo(p.x + nx * (o + 2.4) + fx * 7, p.y + ny * (o + 2.4) + fy * 7);
              ctx.lineTo(p.x + nx * o + fx * 7, p.y + ny * o + fy * 7);
              ctx.fill();
            }
          }
          const sb = spec.crosswalk ? 10 : 2;
          const side = e.at === 0 ? -1 : 1;
          strokeLine(
            ctx,
            [
              { x: p.x + fx * sb + nx * side * (spec.median / 2 + 0.5), y: p.y + fy * sb + ny * side * (spec.median / 2 + 0.5) },
              { x: p.x + fx * sb + nx * side * (w - 1), y: p.y + fy * sb + ny * side * (w - 1) },
            ],
            '#e6e2d6',
            1.4,
          );
        }
        ctx.globalAlpha = 1;
        for (const h of potholes) {
          ctx.fillStyle = '#26252a';
          ctx.beginPath();
          ctx.ellipse(h.x, h.y, h.r, h.r * 0.7, h.a, 0, Math.PI * 2);
          ctx.fill();
        }
      });
    }
  }

  // --- Sidewalk network for pedestrians.

  private buildWalks(): void {
    const endsAt = new Map<RNode, WalkEnd[]>();
    for (const r of this.roads) {
      if (!r.spec.sidewalk || r.name === 'ramp' || r.name === 'spur') continue;
      const off = r.halfW + r.spec.sidewalk / 2;
      const ids: number[] = [];
      for (const side of [1, -1]) {
        const pts = offsetPoints(r.pts, off * side);
        if (pts.length < 2 || makePath(pts).len < 6) continue;
        const id = this.walks.length;
        const mk = (atStart: boolean): WalkEnd => {
          const p = atStart ? pts[0] : pts[pts.length - 1];
          return { walk: id, atStart, x: p.x, y: p.y, links: [], cross: null };
        };
        const walk: Walk = { ...makePath(pts), id, district: r.district, ends: [mk(true), mk(false)] };
        this.walks.push(walk);
        ids.push(id);
        if (r.a) endsAt.set(r.a, [...(endsAt.get(r.a) ?? []), walk.ends[0]]);
        if (r.b) endsAt.set(r.b, [...(endsAt.get(r.b) ?? []), walk.ends[1]]);
      }
      if (ids.length === 2) {
        for (const k of [0, 1] as const) {
          this.walks[ids[0]].ends[k].cross = { walk: ids[1], atStart: k === 0 };
          this.walks[ids[1]].ends[k].cross = { walk: ids[0], atStart: k === 0 };
        }
      }
    }
    for (const [node, ends] of endsAt) {
      for (const e of ends) {
        const reach = node.radius * 1.9 + 24;
        const near = ends.filter((o) => o !== e && o.walk !== e.cross?.walk && Math.hypot(o.x - e.x, o.y - e.y) < reach);
        near.sort((a, b) => Math.hypot(a.x - e.x, a.y - e.y) - Math.hypot(b.x - e.x, b.y - e.y));
        e.links = near.slice(0, 2).map((o) => ({ walk: o.walk, atStart: o.atStart }));
      }
    }
  }

  // --- Buildings along every street.

  private lotFree(corners: Vec[], cx: number, cy: number, angle: number, hw: number, hd: number): boolean {
    if (!corners.every((p) => p.x > 10 && p.y > 10 && p.x < MAP_W - 10 && p.y < MAP_H - 10)) return false;
    const c = Math.cos(angle);
    const s = Math.sin(angle);
    for (let u = -hw; u <= hw + 0.01; u += Math.max(4, hw / 3)) {
      for (let v = -hd; v <= hd + 0.01; v += Math.max(4, hd / 3)) {
        const i = this.cellIndex(cx + c * u - s * v, cy + s * u + c * v);
        if (i < 0 || this.occ[i] !== 0) return false;
      }
    }
    return true;
  }

  private claim(cx: number, cy: number, angle: number, hw: number, hd: number): void {
    const c = Math.cos(angle);
    const s = Math.sin(angle);
    for (let u = -hw - 2; u <= hw + 2; u += 3) {
      for (let v = -hd - 2; v <= hd + 2; v += 3) {
        const i = this.cellIndex(cx + c * u - s * v, cy + s * u + c * v);
        if (i >= 0) this.occ[i] |= OCC_BUILT;
      }
    }
  }

  private placeLots(): void {
    const config: Record<District, { f: [number, number]; d: [number, number]; setback: number } | null> = {
      downtown: { f: [34, 74], d: [38, 66], setback: 1 },
      uptown: { f: [74, 112], d: [64, 92], setback: 14 },
      hood: { f: [26, 38], d: [28, 40], setback: 4 },
      port: { f: [100, 180], d: [76, 130], setback: 10 },
      highway: null,
      country: { f: [80, 130], d: [70, 110], setback: 22 },
      village: { f: [30, 48], d: [30, 44], setback: 5 },
      industrial: { f: [110, 200], d: [90, 150], setback: 12 },
      docks: { f: [120, 200], d: [80, 140], setback: 10 },
      factory: { f: [140, 240], d: [110, 180], setback: 14 },
      rail: { f: [110, 180], d: [70, 110], setback: 10 },
      suburb: { f: [52, 74], d: [48, 66], setback: 10 },
      commercial: { f: [90, 180], d: [70, 140], setback: 30 },
      military: { f: [70, 140], d: [44, 90], setback: 16 },
      rural: { f: [80, 130], d: [70, 110], setback: 22 },
      town: { f: [30, 48], d: [30, 44], setback: 5 },
    };
    for (const r of this.roads) {
      const cfg = config[r.district];
      if (!cfg || r.name === 'ramp' || r.name === 'spur' || r.name === 'expressway') continue;
      const path = makePath(r.pts);
      for (const side of [1, -1]) {
        let s = 4;
        while (s < path.len - 8) {
          const f = range(cfg.f[0], cfg.f[1]);
          const d = range(cfg.d[0], cfg.d[1]);
          const mid = s + f / 2;
          if (mid > path.len - 4) break;
          const p = pointAt(path, mid);
          const q1 = pointAt(path, Math.min(path.len, mid + 2));
          const q0 = pointAt(path, Math.max(0, mid - 2));
          let ang = Math.atan2(q1.y - q0.y, q1.x - q0.x);
          if (side < 0) ang += Math.PI;
          const nx = -Math.sin(ang);
          const ny = Math.cos(ang);
          const off = r.halfW + r.spec.sidewalk + cfg.setback + d / 2 + 1;
          const cx = p.x + nx * off;
          const cy = p.y + ny * off;
          const corners = boxCorners(cx, cy, ang, f / 2, d / 2);
          // Farmsteads are few and far between; out in the far country fewer still.
          if ((r.district === 'country' && rand() < 0.7) || (r.district === 'rural' && rand() < 0.9)) {
            s += r.district === 'rural' ? 160 : 60;
            continue;
          }
          if (this.lotFree(corners, cx, cy, ang, f / 2, d / 2)) {
            this.claim(cx, cy, ang, f / 2, d / 2);
            this.building(r.district, cx, cy, ang, f, d, corners);
            s += f + (r.district === 'downtown' ? 3 : r.district === 'hood' ? 2 : r.district === 'commercial' ? 30 : 8);
          } else {
            s += 8;
          }
        }
      }
    }
  }

  private building(district: District, cx: number, cy: number, angle: number, w: number, d: number, corners: Vec[]): void {
    this.structDistrict = district;
    this.lookDistrict = district;
    this.buildingInner(LOOK[district], cx, cy, angle, w, d, corners);
    this.structDistrict = null;
  }

  /** The real district of the building being laid out (it may borrow another's look). */
  private lookDistrict: District = 'downtown';

  /**
   * A one-off destructible structure (a crane, a ship, a hangar, a rail car): registered
   * like a building in `district`, drawn by `draw` at the object layer.
   */
  special(district: District, corners: Vec[], draw: (ctx: CanvasRenderingContext2D) => void, opts: { mat?: 'wood' | 'stone'; hp?: number; fuel?: number; building?: boolean; layer?: number } = {}): Structure {
    this.structDistrict = district;
    this.solidPoly(corners);
    this.structDistrict = null;
    const st = this.structures[this.structures.length - 1];
    if (opts.mat) st.mat = opts.mat;
    if (opts.hp) st.hp = st.maxHp = opts.hp;
    if (opts.fuel) st.fuel = opts.fuel;
    if (opts.building !== undefined) {
      st.building = opts.building;
      const i = this.buildings.indexOf(st);
      if (!opts.building && i >= 0) this.buildings.splice(i, 1);
      else if (opts.building && i < 0) this.buildings.push(st);
    }
    const [x0, y0, x1, y1] = bboxOf(corners, 30);
    this.add(opts.layer ?? L_OBJECT, x0, y0, x1, y1, draw);
    return st;
  }

  private buildingInner(district: District, cx: number, cy: number, angle: number, w: number, d: number, corners: Vec[]): void {
    const [x0, y0, x1, y1] = bboxOf(corners, 24);
    const inLocal = (ctx: CanvasRenderingContext2D, fn: () => void) => {
      ctx.save();
      ctx.translate(cx, cy);
      ctx.rotate(angle);
      fn();
      ctx.restore();
    };
    const shadow = (ctx: CanvasRenderingContext2D, height: number, pts = corners) => {
      const dx = Math.min(20, height * 0.8);
      const dy = dx * 1.15;
      fillPoly(ctx, convexHull([...pts, ...pts.map((p) => ({ x: p.x + dx, y: p.y + dy }))]), 'rgba(0, 0, 0, 0.3)');
    };
    const sub = (r: { x: number; y: number; w: number; h: number }) =>
      boxCorners(
        cx + Math.cos(angle) * (r.x + r.w / 2) - Math.sin(angle) * (r.y + r.h / 2),
        cy + Math.sin(angle) * (r.x + r.w / 2) + Math.cos(angle) * (r.y + r.h / 2),
        angle,
        r.w / 2,
        r.h / 2,
      );
    const hw = w / 2;
    const hd = d / 2;

    // Stations are planned by the real district (which may borrow another's look).
    const plan = this.stationPlan[this.lookDistrict];
    const big = district === 'hood' || district === 'village' ? w >= 34 && d >= 30 : w >= 44 && d >= 36;
    if (plan?.length && big && rand() < 0.4) {
      const kind = plan.shift()!;
      this.stations.push({ kind, x: cx, y: cy, angle, w, d, units: 2, district: this.lookDistrict });
      // Stations can be destroyed too (their fleets are out until the building's rebuilt).
      this.solidPoly(corners);
      const stStruct = this.structures[this.structures.length - 1];
      stStruct.station = true;
      stStruct.mat = 'stone';
      stStruct.hp = stStruct.maxHp = stStruct.maxHp * 1.5;
      this.stations[this.stations.length - 1].structure = stStruct;
      this.structDistrict = null;
      const roofColor = { hospital: '#f2f2ee', police: '#2c4a7a', fire: '#a5332a', tow: '#6f6a62', works: '#c27a1e' }[kind];
      const label = { hospital: 'HOSPITAL', police: 'POLICE', fire: 'FIRE DEPT', tow: 'ROADSIDE', works: 'PUBLIC WORKS' }[kind];
      this.add(L_OBJECT, x0, y0, x1, y1, (ctx) => {
        shadow(ctx, kind === 'hospital' ? 30 : 12);
        inLocal(ctx, () => {
          ctx.fillStyle = roofColor;
          ctx.fillRect(-hw, -hd, w, d);
          ctx.strokeStyle = 'rgba(0, 0, 0, 0.25)';
          ctx.lineWidth = 0.8;
          ctx.strokeRect(-hw + 2, -hd + 2, w - 4, d - 4);
          const r = Math.min(hw, hd) * 0.45;
          if (kind === 'hospital') {
            // Rooftop helipad for the medevac helicopter, and a red cross.
            ctx.fillStyle = '#4a4d52';
            ctx.beginPath();
            ctx.arc(0, 0, 11, 0, Math.PI * 2);
            ctx.fill();
            ctx.strokeStyle = '#f4f1ea';
            ctx.lineWidth = 0.8;
            ctx.stroke();
            ctx.fillStyle = '#f4f1ea';
            ctx.fillRect(-3.5, -4, 1.4, 8);
            ctx.fillRect(2.1, -4, 1.4, 8);
            ctx.fillRect(-3.5, -0.7, 7, 1.4);
            ctx.fillStyle = '#d93a3a';
            const cx2 = hw - 9;
            const cy2 = -hd + 9;
            ctx.fillRect(cx2 - 1.5, cy2 - 5, 3, 10);
            ctx.fillRect(cx2 - 5, cy2 - 1.5, 10, 3);
          } else if (kind === 'works') {
            // Salt dome, pipe stacks and a hazard-striped apron.
            ctx.fillStyle = '#d9d2c2';
            ctx.beginPath();
            ctx.arc(-hw * 0.4, -hd * 0.2, r * 0.9, 0, Math.PI * 2);
            ctx.fill();
            ctx.fillStyle = '#5c5f66';
            for (let i = 0; i < 4; i++) ctx.fillRect(hw * 0.15, -hd * 0.5 + i * 3, hw * 0.6, 2);
            ctx.fillStyle = '#ffcc00';
            for (let x = -hw + 2; x < hw - 2; x += 4) ctx.fillRect(x, hd - 3, 2, 2);
          } else if (kind === 'police') {
            ctx.fillStyle = '#f4f1ea';
            ctx.beginPath();
            for (let i = 0; i < 10; i++) {
              const rr = i % 2 ? r * 0.45 : r;
              const a = (i / 10) * Math.PI * 2 - Math.PI / 2;
              ctx.lineTo(Math.cos(a) * rr, Math.sin(a) * rr);
            }
            ctx.closePath();
            ctx.fill();
          } else if (kind === 'fire') {
            ctx.fillStyle = '#ffcc33';
            ctx.beginPath();
            ctx.arc(0, 0, r * 0.7, 0, Math.PI * 2);
            ctx.fill();
            ctx.fillStyle = '#a5332a';
            ctx.fillRect(-r * 0.15, -r * 0.6, r * 0.3, r * 1.2);
            ctx.fillRect(-r * 0.6, -r * 0.15, r * 1.2, r * 0.3);
          }
          // Garage doors on the street side, and a name sign.
          ctx.fillStyle = 'rgba(0, 0, 0, 0.35)';
          for (let x = -hw + 4; x < hw - 8; x += 10) ctx.fillRect(x, -hd, 8, 2);
          ctx.fillStyle = kind === 'tow' ? '#ffcc00' : '#ffffff';
          ctx.font = `800 ${Math.min(6, w / 9)}px system-ui, sans-serif`;
          ctx.textAlign = 'center';
          ctx.textBaseline = 'middle';
          ctx.fillText(label, 0, hd - 5);
        });
      });
      return;
    }

    if ((district === 'downtown' || district === 'village' || district === 'industrial') && rand() < (district === 'industrial' ? 0.12 : 0.08)) {
      // A construction site: dirt, a foundation, rebar and stacked beams.
      this.sites.push({ x: cx, y: cy, angle, w, d });
      const beams = Array.from({ length: 3 }, () => ({ x: range(-hw + 6, hw - 18), y: range(hd * 0.3, hd - 8) }));
      const fw = w * 0.6;
      const fd = d * 0.5;
      this.solidPoly(sub({ x: -fw / 2, y: -hd + 6, w: fw, h: 2 }));
      this.add(L_OBJECT, x0, y0, x1, y1, (ctx) =>
        inLocal(ctx, () => {
          ctx.fillStyle = '#9b8463';
          ctx.fillRect(-hw, -hd, w, d);
          ctx.fillStyle = '#b8b2a6';
          ctx.fillRect(-fw / 2, -hd + 6, fw, fd);
          ctx.strokeStyle = '#7d4a2a';
          ctx.lineWidth = 0.4;
          ctx.beginPath();
          for (let x = -fw / 2 + 3; x < fw / 2; x += 4) {
            ctx.moveTo(x, -hd + 7);
            ctx.lineTo(x, -hd + 5 + fd);
          }
          for (let y = -hd + 9; y < -hd + 6 + fd; y += 4) {
            ctx.moveTo(-fw / 2 + 1, y);
            ctx.lineTo(fw / 2 - 1, y);
          }
          ctx.stroke();
          for (const b of beams) {
            ctx.fillStyle = '#c0392b';
            ctx.fillRect(b.x, b.y, 14, 1.4);
            ctx.fillRect(b.x, b.y + 2, 14, 1.4);
          }
          ctx.fillStyle = '#2e86c1';
          ctx.fillRect(hw - 8, hd - 10, 4, 4);
          ctx.strokeStyle = '#ff8a00';
          ctx.lineWidth = 0.7;
          ctx.setLineDash([2, 1.4]);
          ctx.strokeRect(-hw + 0.5, -hd + 0.5, w - 1, d - 1);
          ctx.setLineDash([]);
        }),
      );
      return;
    }

    if (district === 'industrial') {
      const roll = rand();
      const steel = pick(['#8d9096', '#7a8088', '#9a9578', '#6f7a83']);
      if (roll < 0.35) {
        // Factory with a sawtooth roof and smokestacks.
        this.solidPoly(corners);
        const stackPts = Array.from({ length: 1 + randInt(2) }, (_, i) => ({ x: -hw + 10 + i * 14, y: hd - 10 }));
        for (const sp of stackPts) {
          const c = sub({ x: sp.x - 4, y: sp.y - 4, w: 8, h: 8 });
          this.stacks.push({ x: (c[0].x + c[2].x) / 2, y: (c[0].y + c[2].y) / 2, steam: false });
        }
        this.add(L_OBJECT, x0, y0, x1, y1, (ctx) => {
          shadow(ctx, 18);
          inLocal(ctx, () => {
            ctx.fillStyle = steel;
            ctx.fillRect(-hw, -hd, w, d);
            for (let x = -hw; x < hw; x += 10) {
              ctx.fillStyle = shade(steel, 0.18);
              ctx.fillRect(x, -hd, 6, d);
              ctx.fillStyle = 'rgba(140, 190, 230, 0.45)';
              ctx.fillRect(x + 6, -hd, 1.6, d);
            }
            for (const sp of stackPts) {
              ctx.fillStyle = '#4a4a50';
              ctx.beginPath();
              ctx.arc(sp.x, sp.y, 4, 0, Math.PI * 2);
              ctx.fill();
              ctx.strokeStyle = '#c0392b';
              ctx.lineWidth = 1;
              ctx.stroke();
              ctx.fillStyle = '#1b1b1f';
              ctx.beginPath();
              ctx.arc(sp.x, sp.y, 2.4, 0, Math.PI * 2);
              ctx.fill();
            }
          });
        });
        return;
      }
      if (roll < 0.5) {
        // Tank farm.
        const tanks = Array.from({ length: 2 + randInt(3) }, (_, i) => ({ x: -hw + 18 + i * 30, y: range(-hd * 0.3, hd * 0.3), r: range(10, 14) })).filter((t) => t.x + t.r < hw);
        for (const t of tanks) this.solidPoly(sub({ x: t.x - t.r * 0.8, y: t.y - t.r * 0.8, w: t.r * 1.6, h: t.r * 1.6 }));
        this.add(L_OBJECT, x0, y0, x1, y1, (ctx) =>
          inLocal(ctx, () => {
            ctx.fillStyle = '#8a8984';
            ctx.fillRect(-hw, -hd, w, d);
            for (const t of tanks) {
              ctx.fillStyle = 'rgba(0, 0, 0, 0.3)';
              ctx.beginPath();
              ctx.arc(t.x + 5, t.y + 6, t.r, 0, Math.PI * 2);
              ctx.fill();
              ctx.fillStyle = '#e8e6e0';
              ctx.beginPath();
              ctx.arc(t.x, t.y, t.r, 0, Math.PI * 2);
              ctx.fill();
              ctx.strokeStyle = '#b5b2a8';
              ctx.lineWidth = 0.8;
              ctx.beginPath();
              ctx.arc(t.x, t.y, t.r * 0.65, 0, Math.PI * 2);
              ctx.stroke();
            }
          }),
        );
        return;
      }
      if (roll < 0.62) {
        // Scrapyard: piles of crushed cars (and where the tow trucks bring the wrecks).
        this.junkyard ??= { x: cx, y: cy };
        const piles = Array.from({ length: 5 }, () => ({ x: range(-hw + 12, hw - 12), y: range(-hd + 12, hd - 12), r: range(8, 13), colors: Array.from({ length: 5 }, () => pick(['#8a4a3a', '#5e7b8a', '#7a6a55', '#3d7dd8', '#b33a3a', '#6b6f3a'])) }));
        for (const pl of piles) this.solidPoly(sub({ x: pl.x - pl.r * 0.7, y: pl.y - pl.r * 0.7, w: pl.r * 1.4, h: pl.r * 1.4 }));
        this.add(L_OBJECT, x0, y0, x1, y1, (ctx) =>
          inLocal(ctx, () => {
            ctx.fillStyle = '#7d6e58';
            ctx.fillRect(-hw, -hd, w, d);
            for (const pl of piles) {
              pl.colors.forEach((c, i) => {
                ctx.save();
                ctx.translate(pl.x + Math.cos(i * 1.3) * pl.r * 0.4, pl.y + Math.sin(i * 1.9) * pl.r * 0.4);
                ctx.rotate(i * 0.7);
                ctx.fillStyle = c;
                ctx.fillRect(-5, -3, 10, 6);
                ctx.fillStyle = 'rgba(0, 0, 0, 0.3)';
                ctx.fillRect(-5, 1, 10, 2);
                ctx.restore();
              });
            }
            ctx.strokeStyle = '#9aa0a6';
            ctx.lineWidth = 0.6;
            ctx.setLineDash([1.6, 1]);
            ctx.strokeRect(-hw + 0.5, -hd + 0.5, w - 1, d - 1);
            ctx.setLineDash([]);
          }),
        );
        return;
      }
      if (roll < 0.72) {
        // Rail yard with boxcars.
        const tracks = [-hd * 0.45, 0, hd * 0.45];
        const cars = tracks.flatMap((ty) => Array.from({ length: Math.floor((w - 10) / 26) }, (_, i) => ({ x: -hw + 6 + i * 26, y: ty, show: rand() < 0.7, color: pick(['#8a3b2e', '#2471a3', '#5d6d7e', '#ca6f1e', '#3f5e3a']) })));
        for (const c of cars) if (c.show) this.solidPoly(sub({ x: c.x, y: c.y - 4.5, w: 22, h: 9 }));
        this.add(L_OBJECT, x0, y0, x1, y1, (ctx) =>
          inLocal(ctx, () => {
            ctx.fillStyle = '#7b7466';
            ctx.fillRect(-hw, -hd, w, d);
            for (const ty of tracks) {
              ctx.fillStyle = '#5a4632';
              for (let x = -hw; x < hw; x += 3) ctx.fillRect(x, ty - 4, 1.2, 8);
              ctx.fillStyle = '#b5b8bd';
              ctx.fillRect(-hw, ty - 2.6, w, 0.6);
              ctx.fillRect(-hw, ty + 2, w, 0.6);
            }
            for (const c of cars) {
              if (!c.show) continue;
              ctx.fillStyle = 'rgba(0, 0, 0, 0.3)';
              ctx.fillRect(c.x + 1.5, c.y - 3, 22, 9);
              ctx.fillStyle = c.color;
              ctx.fillRect(c.x, c.y - 4.5, 22, 9);
              ctx.strokeStyle = 'rgba(0, 0, 0, 0.3)';
              ctx.lineWidth = 0.5;
              ctx.strokeRect(c.x, c.y - 4.5, 22, 9);
            }
          }),
        );
        return;
      }
      if (roll < 0.8 && w > 120 && d > 110) {
        // Power plant cooling tower.
        const r = Math.min(hw, hd) * 0.7;
        this.stacks.push({ x: cx, y: cy, steam: true });
        this.solidPoly(boxCorners(cx, cy, angle, r * 0.75, r * 0.75));
        this.add(L_OBJECT, x0 - 20, y0 - 20, x1 + 20, y1 + 20, (ctx) =>
          inLocal(ctx, () => {
            ctx.fillStyle = '#8a8984';
            ctx.fillRect(-hw, -hd, w, d);
            ctx.fillStyle = 'rgba(0, 0, 0, 0.3)';
            ctx.beginPath();
            ctx.arc(14, 16, r, 0, Math.PI * 2);
            ctx.fill();
            const g = ctx.createRadialGradient(-r * 0.3, -r * 0.3, r * 0.2, 0, 0, r);
            g.addColorStop(0, '#e2dfd8');
            g.addColorStop(1, '#a9a59c');
            ctx.fillStyle = g;
            ctx.beginPath();
            ctx.arc(0, 0, r, 0, Math.PI * 2);
            ctx.fill();
            ctx.fillStyle = '#5f5d58';
            ctx.beginPath();
            ctx.arc(0, 0, r * 0.62, 0, Math.PI * 2);
            ctx.fill();
          }),
        );
        return;
      }
      // Open yard with pallets: forklifts and workers move about here.
      this.yards.push({ x: cx, y: cy, angle, w, d });
      const pallets = Array.from({ length: 10 }, () => ({ x: range(-hw + 6, hw - 10), y: range(-hd + 6, hd - 10), c: pick(['#c8a26a', '#8a6a44', '#2471a3', '#d4ac0d']) }));
      this.add(L_OBJECT, x0, y0, x1, y1, (ctx) =>
        inLocal(ctx, () => {
          ctx.fillStyle = '#7a7872';
          ctx.fillRect(-hw, -hd, w, d);
          ctx.strokeStyle = 'rgba(255, 210, 0, 0.7)';
          ctx.lineWidth = 0.6;
          ctx.strokeRect(-hw + 4, -hd + 4, w - 8, d - 8);
          for (const pl of pallets) {
            ctx.fillStyle = pl.c;
            ctx.fillRect(pl.x, pl.y, 5, 5);
          }
        }),
      );
      return;
    }

    if (district === 'downtown') {
      const roll = rand();
      if (roll < 0.1) {
        const per = Math.max(1, Math.floor((w - 4) / 12));
        const cars = Array.from({ length: per * 2 }, (_, k) => ({
          k,
          show: rand() < 0.65,
          look: makeLook(pick<CarModel>(['sedan', 'suv', 'taxi']), pick(['#3d7dd8', '#e6e6e6', '#2e2f38', '#b33a3a', '#f2c230']), '#888', false),
        }));
        this.add(L_OBJECT, x0, y0, x1, y1, (ctx) =>
          inLocal(ctx, () => {
            ctx.fillStyle = '#4a4b55';
            ctx.fillRect(-hw, -hd, w, d);
            ctx.strokeStyle = 'rgba(255, 255, 255, 0.7)';
            ctx.lineWidth = 0.6;
            ctx.beginPath();
            for (let k = 0; k <= per; k++) {
              ctx.moveTo(-hw + 2 + k * 12, -hd + 2);
              ctx.lineTo(-hw + 2 + k * 12, -hd + 20);
              ctx.moveTo(-hw + 2 + k * 12, hd - 2);
              ctx.lineTo(-hw + 2 + k * 12, hd - 20);
            }
            ctx.stroke();
            for (const c of cars) {
              if (!c.show) continue;
              const top = c.k < per;
              drawCar(ctx, c.look, -hw + 8 + (c.k % per) * 12, top ? -hd + 11 : hd - 11, top ? Math.PI / 2 : -Math.PI / 2);
            }
          }),
        );
        return;
      }
      if (roll < 0.16) {
        this.add(L_OBJECT, x0, y0, x1, y1, (ctx) =>
          inLocal(ctx, () => {
            ctx.fillStyle = '#cfc6b4';
            ctx.fillRect(-hw, -hd, w, d);
            ctx.fillStyle = '#9e9686';
            ctx.beginPath();
            ctx.arc(0, 0, Math.min(hw, hd) * 0.45, 0, Math.PI * 2);
            ctx.fill();
            ctx.fillStyle = WATER;
            ctx.beginPath();
            ctx.arc(0, 0, Math.min(hw, hd) * 0.36, 0, Math.PI * 2);
            ctx.fill();
          }),
        );
        this.solidPoly(boxCorners(cx, cy, angle, Math.min(hw, hd) * 0.45, Math.min(hw, hd) * 0.45));
        this.tree(cx + Math.cos(angle) * hw * 0.7, cy + Math.sin(angle) * hw * 0.7, 7);
        return;
      }
      const height = range(20, 80);
      const color = pick(['#5d6b7a', '#6f8796', '#4f5d6e', '#8a96a3', '#7b7f8c', '#3f5568', '#9b8f80', '#6a6e78', '#a08f7a']);
      const hvac = Array.from({ length: 1 + randInt(3) }, () => ({ x: range(-hw + 5, hw - 12), y: range(-hd + 5, hd - 11), w: range(6, 9), h: range(5, 7) }));
      const helipad = w > 46 && d > 46 && rand() < 0.35;
      const garden = rand() < 0.18;
      const glass = rand() < 0.3;
      this.solidPoly(corners);
      this.add(L_OBJECT, x0, y0, x1, y1, (ctx) => {
        shadow(ctx, height);
        inLocal(ctx, () => {
          const g = ctx.createLinearGradient(-hw, -hd, hw, hd);
          g.addColorStop(0, shade(color, 0.18));
          g.addColorStop(1, shade(color, -0.18));
          ctx.fillStyle = g;
          ctx.fillRect(-hw, -hd, w, d);
          ctx.strokeStyle = 'rgba(0, 0, 0, 0.22)';
          ctx.lineWidth = 0.8;
          ctx.strokeRect(-hw + 2.5, -hd + 2.5, w - 5, d - 5);
          if (glass) {
            ctx.fillStyle = 'rgba(140, 190, 230, 0.5)';
            for (let sx = -hw + 6; sx < hw - 8; sx += 6) ctx.fillRect(sx, -2, 4, 4);
          }
          if (garden) {
            ctx.fillStyle = '#5d9b4a';
            ctx.beginPath();
            ctx.roundRect(-hw + 5, -hd + 5, Math.min(22, w - 10), Math.min(14, d - 10), 3);
            ctx.fill();
          }
          if (helipad) {
            ctx.fillStyle = '#3a3d44';
            ctx.beginPath();
            ctx.arc(0, 0, 12, 0, Math.PI * 2);
            ctx.fill();
            ctx.strokeStyle = '#ffcc33';
            ctx.lineWidth = 1;
            ctx.stroke();
            ctx.fillStyle = '#fff';
            ctx.font = '700 11px system-ui, sans-serif';
            ctx.textAlign = 'center';
            ctx.textBaseline = 'middle';
            ctx.fillText('H', 0, 0.5);
          } else {
            for (const h of hvac) {
              ctx.fillStyle = '#d3d7da';
              ctx.fillRect(h.x, h.y, h.w, h.h);
              ctx.fillStyle = '#6a7076';
              ctx.beginPath();
              ctx.arc(h.x + h.w / 2, h.y + h.h / 2, Math.min(h.w, h.h) * 0.3, 0, Math.PI * 2);
              ctx.fill();
            }
          }
        });
      });
      return;
    }

    if (district === 'uptown') {
      const mw = w * range(0.5, 0.62);
      const md = d * range(0.42, 0.55);
      const main = { x: -hw + w * 0.12, y: -hd + d * 0.12, w: mw, h: md };
      const wing = { x: main.x + mw * 0.5, y: main.y + md - 2, w: mw * 0.5, h: md * 0.5 };
      const poolW = Math.min(30, hw - (main.x + mw) - 6);
      const pool = poolW > 14 ? { x: main.x + mw + 6, y: main.y + 6, w: poolW, h: Math.min(18, md - 4) } : null;
      const roof = pick(['#a4553a', '#7b4a3a', '#5c6670', '#8e6a4e', '#b07a50']);
      const car = rand() < 0.6 ? makeLook(pick<CarModel>(['sports', 'suv']), pick(['#111318', '#f2f2f2', '#c0c4ca', '#7a1f2b', '#d9b25a']), '#888', false) : null;
      const mainC = sub(main);
      const wingC = sub(wing);
      this.solidPoly(mainC);
      this.solidPoly(wingC);
      if (pool) this.solidPoly(sub(pool));
      this.add(L_OBJECT, x0 - 14, y0 - 14, x1 + 14, y1 + 14, (ctx) => {
        inLocal(ctx, () => {
          ctx.fillStyle = '#6aa84f';
          ctx.fillRect(-hw, -hd - 14, w, d + 14);
          ctx.strokeStyle = '#2f5e2c';
          ctx.lineWidth = 3;
          ctx.beginPath();
          ctx.roundRect(-hw + 2, -hd + 2, w - 4, d - 4, 4);
          ctx.stroke();
          ctx.fillStyle = '#cfcac0';
          ctx.fillRect(main.x + mw * 0.2, -hd - 14, 9, main.y + hd + 14);
          if (car) drawCar(ctx, car, main.x + mw * 0.2 + 4.5, -hd - 4, -Math.PI / 2);
          if (pool) {
            ctx.fillStyle = '#f2efe6';
            ctx.beginPath();
            ctx.roundRect(pool.x - 2.5, pool.y - 2.5, pool.w + 5, pool.h + 5, 4);
            ctx.fill();
            ctx.fillStyle = '#3fb4dc';
            ctx.beginPath();
            ctx.roundRect(pool.x, pool.y, pool.w, pool.h, 3);
            ctx.fill();
          }
        });
        shadow(ctx, 9, mainC);
        shadow(ctx, 7, wingC);
        inLocal(ctx, () => {
          this.hipRoof(ctx, wing, shade(roof, -0.06));
          this.hipRoof(ctx, main, roof);
        });
      });
      const tp = sub({ x: hw - 18, y: hd - 18, w: 4, h: 4 })[0];
      this.tree(tp.x, tp.y, range(8, 11), 'fancy');
      return;
    }

    if (district === 'hood') {
      const roof = pick(['#6d5a4e', '#7d6b5a', '#5a5f66', '#8a6f52', '#6e4c40', '#59636b']);
      if (rand() < 0.65) {
        const house = { x: -hw + 3, y: -hd + 3, w: w - 6 - range(0, 6), h: d - 6 - range(0, 6) };
        const hc = sub(house);
        this.solidPoly(hc);
        const patches = Array.from({ length: 1 + randInt(3) }, () => ({ x: house.x + range(2, house.w - 9), y: house.y + range(2, house.h - 8), w: range(4, 8), h: range(3, 6), light: rand() < 0.5 }));
        const tarp = rand() < 0.3;
        const yard = pick(['#8a7556', '#7d7a4a']);
        this.add(L_OBJECT, x0, y0, x1, y1, (ctx) => {
          inLocal(ctx, () => {
            ctx.fillStyle = yard;
            ctx.fillRect(-hw, -hd, w, d);
          });
          shadow(ctx, 5, hc);
          inLocal(ctx, () => {
            const horizontal = house.w >= house.h;
            ctx.fillStyle = shade(roof, 0.15);
            if (horizontal) ctx.fillRect(house.x, house.y, house.w, house.h / 2);
            else ctx.fillRect(house.x, house.y, house.w / 2, house.h);
            ctx.fillStyle = shade(roof, -0.18);
            if (horizontal) ctx.fillRect(house.x, house.y + house.h / 2, house.w, house.h / 2);
            else ctx.fillRect(house.x + house.w / 2, house.y, house.w / 2, house.h);
            ctx.strokeStyle = 'rgba(0, 0, 0, 0.35)';
            ctx.lineWidth = 0.6;
            ctx.strokeRect(house.x, house.y, house.w, house.h);
            for (const p of patches) {
              ctx.fillStyle = shade(roof, p.light ? 0.3 : -0.35);
              ctx.fillRect(p.x, p.y, p.w, p.h);
            }
            if (tarp) {
              ctx.fillStyle = '#2f6fc0';
              ctx.fillRect(house.x + house.w * 0.55, house.y + 2, house.w * 0.3, house.h * 0.4);
            }
            ctx.strokeStyle = 'rgba(190, 196, 200, 0.75)';
            ctx.lineWidth = 0.5;
            ctx.setLineDash([1.6, 1]);
            ctx.strokeRect(-hw + 0.5, -hd + 0.5, w - 1, d - 1);
            ctx.setLineDash([]);
          });
        });
        return;
      }
      const junk = Array.from({ length: 8 }, () => ({ x: range(-hw + 4, hw - 4), y: range(-hd + 4, hd - 4), t: randInt(4), r: range(1.5, 3) }));
      const wreck = rand() < 0.5 ? makeLook('beater', pick(['#7a6a55', '#8a4a3a', '#5e7b8a']), '#9a9a9a', false) : null;
      if (wreck) this.solidPoly(boxCorners(cx, cy, angle + 0.3, 8, 5));
      this.add(L_OBJECT, x0, y0, x1, y1, (ctx) =>
        inLocal(ctx, () => {
          ctx.fillStyle = '#86704f';
          ctx.fillRect(-hw, -hd, w, d);
          for (const j of junk) {
            if (j.t === 0) {
              ctx.strokeStyle = '#1a1a1a';
              ctx.lineWidth = 1.2;
              ctx.beginPath();
              ctx.arc(j.x, j.y, j.r, 0, Math.PI * 2);
              ctx.stroke();
            } else {
              ctx.fillStyle = j.t === 1 ? '#25272b' : j.t === 2 ? '#d8d0b8' : 'rgba(70, 110, 40, 0.8)';
              ctx.beginPath();
              ctx.arc(j.x, j.y, j.r, 0, Math.PI * 2);
              ctx.fill();
            }
          }
          if (wreck) drawCar(ctx, wreck, 0, 0, 0.3, true);
          ctx.strokeStyle = 'rgba(190, 196, 200, 0.75)';
          ctx.lineWidth = 0.5;
          ctx.setLineDash([1.6, 1]);
          ctx.strokeRect(-hw + 0.5, -hd + 0.5, w - 1, d - 1);
          ctx.setLineDash([]);
        }),
      );
      return;
    }

    if (district === 'village') {
      const shop = rand() < 0.3;
      const roof = pick(['#c0392b', '#2e86c1', '#d4ac0d', '#7d3c98', '#5d6d7e', '#a0522d']);
      const house = { x: -hw + 4, y: -hd + (shop ? 1 : 6), w: w - 8, h: d - (shop ? 4 : 12) };
      const hc = sub(house);
      this.solidPoly(hc);
      const flowers = Array.from({ length: 8 }, () => ({ x: range(-hw + 2, hw - 2), y: range(hd - 5, hd - 1), c: pick(['#ff6b8b', '#ffd34d', '#ffffff', '#b37cff']) }));
      this.add(L_OBJECT, x0, y0, x1, y1, (ctx) => {
        inLocal(ctx, () => {
          ctx.fillStyle = shop ? '#cfc6b2' : '#7fb35e';
          ctx.fillRect(-hw, -hd, w, d);
          for (const f of flowers) {
            ctx.fillStyle = f.c;
            ctx.beginPath();
            ctx.arc(f.x, f.y, 0.9, 0, Math.PI * 2);
            ctx.fill();
          }
          if (!shop) {
            ctx.strokeStyle = '#f4f1ea';
            ctx.lineWidth = 0.6;
            ctx.setLineDash([1.2, 0.8]);
            ctx.strokeRect(-hw + 0.5, -hd + 0.5, w - 1, d - 1);
            ctx.setLineDash([]);
          }
        });
        shadow(ctx, shop ? 7 : 6, hc);
        inLocal(ctx, () => {
          if (shop) {
            ctx.fillStyle = '#d8d2c6';
            ctx.fillRect(house.x, house.y, house.w, house.h);
            ctx.strokeStyle = 'rgba(0, 0, 0, 0.2)';
            ctx.strokeRect(house.x + 1.5, house.y + 1.5, house.w - 3, house.h - 3);
            for (let i = 0; i < 6; i++) {
              ctx.fillStyle = i % 2 ? '#ffffff' : roof;
              ctx.fillRect(house.x + (i * house.w) / 6, house.y - 2.5, house.w / 6 + 0.1, 2.5);
            }
          } else {
            this.hipRoof(ctx, house, roof);
          }
        });
      });
      return;
    }

    if (district === 'country') {
      // A farmstead: house, red barn and a silo.
      const house = { x: -hw + 6, y: -hd + 6, w: w * 0.3, h: d * 0.32 };
      const barn = { x: hw - w * 0.42 - 4, y: -hd + 8, w: w * 0.42, h: d * 0.48 };
      const silo = { x: hw - 10, y: hd - 12, r: Math.min(9, d * 0.1 + 4) };
      const hc = sub(house);
      const bc = sub(barn);
      const sc = sub({ x: silo.x - silo.r, y: silo.y - silo.r, w: silo.r * 2, h: silo.r * 2 });
      this.solidPoly(hc);
      this.solidPoly(bc);
      this.solidPoly(sc);
      const roof = pick(['#6d5a4e', '#5a5f66', '#8a6f52']);
      this.add(L_OBJECT, x0 - 10, y0 - 10, x1 + 10, y1 + 10, (ctx) => {
        inLocal(ctx, () => {
          ctx.fillStyle = '#7aa85a';
          ctx.fillRect(-hw, -hd, w, d);
          ctx.fillStyle = '#b59a6a';
          ctx.fillRect(house.x + house.w / 2 - 3, -hd - 22, 6, house.y + hd + 22);
          ctx.strokeStyle = '#d8d0b8';
          ctx.lineWidth = 0.8;
          ctx.strokeRect(-hw + 1, -hd + 1, w - 2, d - 2);
        });
        shadow(ctx, 6, hc);
        shadow(ctx, 12, bc);
        shadow(ctx, 18, sc);
        inLocal(ctx, () => {
          this.hipRoof(ctx, house, roof);
          const horizontal = barn.w >= barn.h;
          ctx.fillStyle = '#b8352a';
          ctx.fillRect(barn.x, barn.y, barn.w, barn.h);
          ctx.fillStyle = '#8f2920';
          if (horizontal) ctx.fillRect(barn.x, barn.y + barn.h / 2, barn.w, barn.h / 2);
          else ctx.fillRect(barn.x + barn.w / 2, barn.y, barn.w / 2, barn.h);
          ctx.strokeStyle = '#f1e9da';
          ctx.lineWidth = 0.9;
          ctx.strokeRect(barn.x + 0.5, barn.y + 0.5, barn.w - 1, barn.h - 1);
          ctx.fillStyle = '#9aa7ad';
          ctx.beginPath();
          ctx.arc(silo.x, silo.y, silo.r, 0, Math.PI * 2);
          ctx.fill();
          ctx.fillStyle = '#c6d0d4';
          ctx.beginPath();
          ctx.arc(silo.x - silo.r * 0.25, silo.y - silo.r * 0.25, silo.r * 0.55, 0, Math.PI * 2);
          ctx.fill();
        });
      });
      return;
    }

    if (district === 'military') {
      const roll = rand();
      this.solidPoly(corners);
      if (roll < 0.55) {
        // Barracks: long and low, a ridged olive roof with air-conditioning units along it.
        const units = Array.from({ length: Math.max(2, Math.floor(w / 26)) }, (_, i) => -hw + 12 + i * 26);
        this.add(L_OBJECT, x0, y0, x1, y1, (ctx) => {
          shadow(ctx, 10);
          inLocal(ctx, () => {
            ctx.fillStyle = '#7a8466';
            ctx.fillRect(-hw, -hd, w, d / 2);
            ctx.fillStyle = '#5f6a4e';
            ctx.fillRect(-hw, 0, w, d / 2);
            ctx.fillStyle = '#4b5320';
            ctx.fillRect(-hw, -1, w, 2);
            ctx.fillStyle = '#d5d8dc';
            for (const ux of units) ctx.fillRect(ux, -hd + 4, 8, 6);
          });
        });
      } else if (roll < 0.8) {
        // Headquarters: pale stone, a flagpole on the lawn out front.
        this.add(L_OBJECT, x0, y0, x1, y1, (ctx) => {
          shadow(ctx, 16);
          inLocal(ctx, () => {
            ctx.fillStyle = '#d8d1bf';
            ctx.fillRect(-hw, -hd, w, d);
            ctx.fillStyle = '#bdb5a0';
            ctx.fillRect(-hw + 4, -hd + 4, w - 8, d - 8);
            ctx.fillStyle = '#2c3e50';
            ctx.fillRect(-2, hd - 2, 4, 14);
            ctx.fillStyle = '#c0392b';
            ctx.fillRect(2, hd + 2, 10, 6);
            ctx.fillStyle = '#2471a3';
            ctx.fillRect(2, hd + 2, 4, 3);
          });
        });
      } else {
        // Vehicle shed: open bays with olive trucks inside.
        const bays = Math.max(2, Math.floor(w / 24));
        this.add(L_OBJECT, x0, y0, x1, y1, (ctx) => {
          shadow(ctx, 8);
          inLocal(ctx, () => {
            ctx.fillStyle = '#6b6f62';
            ctx.fillRect(-hw, -hd, w, d);
            for (let i = 0; i < bays; i++) {
              const bx = -hw + 4 + i * ((w - 8) / bays);
              ctx.fillStyle = '#2b2b2e';
              ctx.fillRect(bx, -hd + 4, (w - 8) / bays - 4, d - 8);
              ctx.fillStyle = '#4b5320';
              ctx.fillRect(bx + 3, -hd + 8, (w - 8) / bays - 10, d * 0.45);
            }
          });
        });
      }
      return;
    }

    if (district === 'port') {
      if (rand() < 0.55) {
        const color = pick(['#8d9096', '#a2675a', '#6f8a9a', '#9a9578']);
        this.solidPoly(corners);
        this.add(L_OBJECT, x0, y0, x1, y1, (ctx) => {
          shadow(ctx, 16);
          inLocal(ctx, () => {
            ctx.fillStyle = shade(color, 0.12);
            ctx.fillRect(-hw, -hd, w, d / 2);
            ctx.fillStyle = shade(color, -0.12);
            ctx.fillRect(-hw, 0, w, d / 2);
            ctx.strokeStyle = 'rgba(0, 0, 0, 0.12)';
            ctx.lineWidth = 0.6;
            ctx.beginPath();
            for (let x = -hw + 4; x < hw; x += 4) {
              ctx.moveTo(x, -hd);
              ctx.lineTo(x, hd);
            }
            ctx.stroke();
            ctx.fillStyle = '#2a2b30';
            for (let x = -hw + 8; x < hw - 10; x += 18) ctx.fillRect(x, -hd - 2, 10, 2);
          });
        });
        return;
      }
      const rows = Math.max(1, Math.floor(d / 14));
      const cols = Math.max(1, Math.floor(w / 26));
      const boxes = Array.from({ length: rows * cols }, () => ({ show: rand() < 0.8, color: pick(['#c0392b', '#2471a3', '#d4ac0d', '#239b56', '#7d3c98', '#ca6f1e', '#5d6d7e']) }));
      this.solidPoly(corners);
      this.add(L_OBJECT, x0, y0, x1, y1, (ctx) => {
        shadow(ctx, 7);
        inLocal(ctx, () => {
          ctx.fillStyle = '#76746e';
          ctx.fillRect(-hw, -hd, w, d);
          boxes.forEach((b, k) => {
            if (!b.show) return;
            const bx = -hw + 2 + (k % cols) * 26;
            const by = -hd + 2 + Math.floor(k / cols) * 14;
            ctx.fillStyle = b.color;
            ctx.fillRect(bx, by, 24, 12);
            ctx.strokeStyle = 'rgba(0, 0, 0, 0.25)';
            ctx.lineWidth = 0.5;
            ctx.beginPath();
            for (let x = bx + 3; x < bx + 24; x += 3) {
              ctx.moveTo(x, by);
              ctx.lineTo(x, by + 12);
            }
            ctx.stroke();
          });
        });
      });
    }
  }

  private hipRoof(ctx: CanvasRenderingContext2D, r: { x: number; y: number; w: number; h: number }, color: string): void {
    const inset = Math.min(r.w, r.h) / 2;
    const horizontal = r.w >= r.h;
    const a = horizontal ? { x: r.x + inset, y: r.y + r.h / 2 } : { x: r.x + r.w / 2, y: r.y + inset };
    const b = horizontal ? { x: r.x + r.w - inset, y: r.y + r.h / 2 } : { x: r.x + r.w / 2, y: r.y + r.h - inset };
    const tl = { x: r.x, y: r.y };
    const tr = { x: r.x + r.w, y: r.y };
    const br = { x: r.x + r.w, y: r.y + r.h };
    const bl = { x: r.x, y: r.y + r.h };
    if (horizontal) {
      fillPoly(ctx, [tl, tr, b, a], shade(color, 0.22));
      fillPoly(ctx, [tr, br, b], shade(color, -0.05));
      fillPoly(ctx, [br, bl, a, b], shade(color, -0.25));
      fillPoly(ctx, [bl, tl, a], shade(color, 0.08));
    } else {
      fillPoly(ctx, [tl, tr, a], shade(color, 0.22));
      fillPoly(ctx, [tr, br, b, a], shade(color, -0.05));
      fillPoly(ctx, [br, bl, b], shade(color, -0.25));
      fillPoly(ctx, [bl, tl, a, b], shade(color, 0.08));
    }
  }
}