// Terrain — a heightmap ground (docs/terrain-plan.md). The SDK OWNS the data: `heights` (metres per
// sample), `holes` (a byte per sample) and `control` (RGBA layer weights per sample) are plain typed
// arrays the developer edits directly or through the helpers below; `commit()` pushes the dirty
// rectangle to the engine (chunk renderables, the Jolt height field, the control texture). Queries
// (`heightAt`, `normalAt`) run in JS on the same triangle split every host draws and collides, so a
// game reads the ground identically on desktop, web-lite and headless.
//
//   const t = Terrain.create({ size: 513, cellSize: 1, heights: (x, z) => noise(x, z) * 40, layers: [
//     { albedo: asset('./tex/grass.ktx2'), tiling: 4 },
//     { albedo: asset('./tex/rock.ktx2'),  tiling: 6, triplanar: true },
//     { albedo: asset('./tex/dirt.ktx2'),  tiling: 5 },
//     { albedo: asset('./tex/snow.ktx2'),  tiling: 6 },
//   ], normals: [asset('./tex/normals01.ktx2'), asset('./tex/normals23.ktx2')] })
//   t.paintAuto({ base: 0, slope: { layer: 1, from: 30, to: 45 }, height: { layer: 3, from: 60, to: 70 } })
//   t.conform(road, { offset: 0.05, falloff: 6 })          // the ground hugs the road mesh: cut AND fill
//   t.hole({ circle: { x: 120, z: 80, r: 4 } })             // no triangles, no collision, no navmesh there
//   scene.add(t.node.aspect(Shape, { heightfield: true }).aspect(Physics, { motion: 'static' }))
//   t.commit()
//   const y = t.heightAt(x, z)                              // NaN inside a hole / off the grid
//
// Coordinates: sample (0, 0) sits at the node's origin, +X across columns, +Z across rows, heights on
// +Y — LOCAL to `node`; move/rotate the node to place the terrain (keep its scale 1). World-space
// callers use `worldHeightAt` / `toLocal`.
//
// Layers: four PBR layers (albedo + per-layer tiling / roughness / triplanar) blended by `control`;
// normal maps come PACKED two per texture (`normals[0]` = layers 0+1: XY in RG / BA, `normals[1]` =
// layers 2+3) because the material's sampler budget is nine on every host — `lecodes assets` packs
// them; `normalScale` per layer switches the read on.
//
// Hosts: `_creator.terrainCreate` (creator-gl) builds the chunks natively; a host without it (web-lite,
// headless, an older build) gets the same chunks as child Mesh nodes from gl/terrainMesh.ts — the
// project runs everywhere, normal maps just read flat there.

import { Node } from "./Node"
import { Mesh } from "./Mesh"
import { Model } from "./Model"
import { Material } from "./Material"
import { Texture } from "./Texture"
import { Geometry } from "./Geometry"
import { Shape } from "./Shape"
import { Color, type ColorInput } from "../core/color"
import { fetch } from "../runtime/fetch"
import { Vec3, cx, cy, cz, type Vec3Like } from "../math/vec"
import {
  buildTerrainChunk, chunkCounts, faceNormalAt, gridRaycast, heightAt as gridHeightAt, normalAt as gridNormalAt, type TerrainGrid,
} from "./terrainMesh"

export type TerrainLayer = {
  /** Albedo texture (a URL / `asset()` handle, or a loaded Texture). Unset = white. */
  albedo?: string | Texture
  /** Metres per texture repeat. Default 8. */
  tiling?: number
  /** Perceptual roughness of the layer. Default 1. */
  roughness?: number
  /** Normal-map strength (0 = the layer's slot of the pack is not read). Default 0 — set 1 when the
   *  layer has a normal map in `normals`. */
  normalScale?: number
  /** Sample this layer triplanar in world space (cliff rock): no stretching on steep slopes; its
   *  normal map is then skipped. Default false. */
  triplanar?: boolean
}

export type TerrainOptions = {
  /** Samples per side (square). 257 / 513 / 1025 are the usual sizes. Default 129. */
  size?: number
  sizeX?: number
  sizeZ?: number
  /** Metres between samples. Default 1. */
  cellSize?: number
  /** Cells per chunk side (≤ 255). Default 64. */
  chunk?: number
  /** Heights in metres: an array of sizeX × sizeZ (row-major, z rows), or a function of the sample's
   *  LOCAL position in metres. Default flat 0. */
  heights?: Float32Array | ((x: number, z: number) => number)
  /** A byte per sample, 1 = hole (a triangle needs its three samples valid). */
  holes?: Uint8Array
  /** RGBA layer weights per sample (normalised in the shader). Default: all layer 0. */
  control?: Uint8Array
  layers?: TerrainLayer[]
  /** The two normal-map packs: `[layers 0+1, layers 2+3]` — XY of the even layer in RG, the odd in BA. */
  normals?: [string | Texture | undefined, (string | Texture)?]
  /** Multiplier on every layer's albedo. */
  tint?: ColorInput
  name?: string
  /** A material to use instead of `Material.terrain()` (a custom terrain shader with the same uniforms). */
  material?: Material
}

/** A region of the terrain in LOCAL metres (XZ). `node` = the XZ footprint of a node's triangles. */
export type TerrainRegion =
  | { circle: { x: number, z: number, r: number } }
  | { rect: { x: number, z: number, w: number, h: number } }
  | { polygon: readonly (readonly [number, number])[] }
  | { node: Node }

export type ConformOptions = {
  /** How far below the target surface the ground sits (keeps bilinear terrain strictly under a
   *  curved road between samples). Default 0.05. */
  offset?: number
  /** Metres over which the surrounding ground blends back to its old height. Default 4; 0 = hard edge. */
  falloff?: number
  /** `both` (default) cuts and fills; `lower` only cuts; `raise` only fills. */
  mode?: "both" | "lower" | "raise"
}

export type RibbonOptions = {
  /** Width in metres. */
  width: number
  /** Metres of ribbon per texture repeat along its length. Default = width. */
  uvScale?: number
}

/** A brush dab for `sculpt` — LOCAL metres. */
export type SculptOptions = {
  x: number
  z: number
  /** Brush radius in metres. */
  radius: number
  /** `raise` / `lower` by `amount` metres at full weight; `smooth` blends toward the 3×3 average
   *  (`amount` 0…1 = how far); `flatten` pulls toward `target` (default: the height under the
   *  centre) by `amount` 0…1. Default raise. */
  mode?: "raise" | "lower" | "smooth" | "flatten"
  amount?: number
  /** 0…1 — the share of the radius at full weight; the rest feathers out (default 0.5). */
  hardness?: number
  target?: number
}

/** `snapshot()` — the three sample arrays, copied; `restore()` puts them back (undo, a level reset). */
export type TerrainSnapshot = { heights: Float32Array, holes: Uint8Array, control: Uint8Array }

/** A ray hit on the terrain (`raycast` local, `raycastWorld` world). */
export type TerrainHit = { point: Vec3, normal: Vec3, distance: number }

export type AutoPaintRules = {
  /** The layer everything starts as. Default 0. */
  base?: number
  /** Slope rule: the layer takes over between `from` and `to` degrees of slope. */
  slope?: { layer: number, from: number, to: number }
  /** Height rule: the layer takes over between `from` and `to` metres (snow line). */
  height?: { layer: number, from: number, to: number }
  /** Low rule: the layer takes over BELOW `to` metres, fading in from `from` (valley floors). */
  low?: { layer: number, from: number, to: number }
}

/** What Shape { heightfield } reads off a terrain node (`node._terrain`). */
export type TerrainData = {
  sizeX: number, sizeZ: number, cellSize: number,
  heights: Float32Array, holes: Uint8Array | null,
}

type Rect = { x0: number, z0: number, x1: number, z1: number }   // inclusive sample rect

const smoothstep = (a: number, b: number, t: number): number => {
  const x = Math.min(1, Math.max(0, (t - a) / (b - a || 1e-9)))
  return x * x * (3 - 2 * x)
}

const FILE_MAGIC = 0x5245544c   // 'LTER' little-endian
const FILE_VERSION = 1

let warnedPixels = false
let warnedHost = false

export class Terrain {
  /** The node the terrain hangs off: transform it to place the ground; add it to the scene. */
  readonly node: Node
  readonly sizeX: number
  readonly sizeZ: number
  readonly cellSize: number
  readonly chunk: number
  /** sizeX × sizeZ heights in metres, row-major (z rows). Edit, then `commit()`. */
  readonly heights: Float32Array
  /** A byte per sample, 1 = hole. */
  readonly holes: Uint8Array
  /** RGBA layer weights per sample. */
  readonly control: Uint8Array
  /** The splat material (`Material.terrain()` unless one was passed). */
  readonly material: Material
  /** Resolves when every layer texture has loaded (the terrain renders white until then). */
  readonly ready: Promise<void>
  layers: TerrainLayer[]

  private _native = false
  private _chunkMeshes: (Mesh | null)[] = []
  private _controlTex: Texture | null = null
  private _dirty: Rect | null = null
  private _holesTouched = false
  private _controlDirty: Rect | null = null
  private _anyHole = false
  private _cast = true
  private _receive = true

  private constructor(opts: TerrainOptions) {
    this.sizeX = Math.max(2, Math.floor(opts.sizeX ?? opts.size ?? 129))
    this.sizeZ = Math.max(2, Math.floor(opts.sizeZ ?? opts.size ?? 129))
    this.cellSize = opts.cellSize ?? 1
    this.chunk = Math.min(255, Math.max(1, Math.floor(opts.chunk ?? 64)))
    const n = this.sizeX * this.sizeZ
    this.heights = new Float32Array(n)
    if (opts.heights instanceof Float32Array) this.heights.set(opts.heights.subarray(0, n))
    else if (typeof opts.heights === "function") {
      const f = opts.heights
      for (let z = 0; z < this.sizeZ; z++) for (let x = 0; x < this.sizeX; x++) this.heights[z * this.sizeX + x] = f(x * this.cellSize, z * this.cellSize)
    }
    this.holes = new Uint8Array(n)
    if (opts.holes) { this.holes.set(opts.holes.subarray(0, n)); this._anyHole = this.holes.some((v) => v !== 0) }
    this.control = new Uint8Array(n * 4)
    if (opts.control) this.control.set(opts.control.subarray(0, n * 4))
    else for (let i = 0; i < n; i++) this.control[i * 4] = 255
    this.layers = (opts.layers ?? []).slice(0, 4)
    this.material = opts.material ?? Material.terrain()
    this.material.set("gridSize", [ this.sizeX, this.sizeZ ]).set("cellSize", this.cellSize)
    if (opts.tint !== undefined) this.material.set("tint", Color.toHexString(opts.tint))

    this.node = new Node()
    if (opts.name !== undefined) this.node.name = opts.name
    ;(this.node as unknown as { _terrain: Terrain })._terrain = this
    this._build()
    this._uploadControl(null)
    this.ready = this._applyLayers(opts.normals)
  }

  /** Build a terrain from data / a generator (synchronous; `ready` resolves once its textures loaded). */
  static create(opts: TerrainOptions = {}): Terrain { return new Terrain(opts) }

  /** The terrain a node carries (the node `Terrain.create` returned), or null. */
  static of(node: Node | null | undefined): Terrain | null {
    return (node as unknown as { _terrain?: Terrain } | null | undefined)?._terrain ?? null
  }

  /** Load a `.terrain` file (`Terrain.serialize` / the editor / `lecodes terrain import`); layer textures
   *  are not in the file — pass them in `opts` (they are asset handles the file cannot hold). */
  static async load(url: string, opts: Omit<TerrainOptions, "size" | "sizeX" | "sizeZ" | "heights" | "holes" | "control"> = {}): Promise<Terrain> {
    const resp = await fetch(url, { useOnce: true })
    if (resp.status >= 400) throw new Error(`Terrain: could not fetch ${url} (HTTP ${resp.status})`)
    if (!_creatorUtils.fetchToBytes) { resp.dispose(); throw new Error("Terrain.load needs a newer host (no binary fetch reader)") }
    const bytes = _creatorUtils.fetchToBytes((resp as unknown as { _id: number })._id)
    resp.dispose()
    const data = Terrain.parse(bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer)
    const layers = opts.layers ?? data.layers
    return new Terrain({ ...opts, ...data, layers, cellSize: opts.cellSize ?? data.cellSize, chunk: opts.chunk ?? data.chunk })
  }

  // ---- queries (LOCAL metres) --------------------------------------------------------------------

  private get _grid(): TerrainGrid {
    return { sizeX: this.sizeX, sizeZ: this.sizeZ, cellSize: this.cellSize, heights: this.heights, holes: this._anyHole ? this.holes : null }
  }

  /** Height at a local (x, z) on the drawn triangles; NaN in a hole or outside the grid. */
  heightAt(x: number, z: number): number { return gridHeightAt(this._grid, x, z) }

  /** The drawn triangle's normal at a local (x, z) (unit Vec3, +Y up). */
  normalAt(x: number, z: number): Vec3 {
    const n = faceNormalAt(this._grid, x, z)
    return new Vec3(n[0], n[1], n[2])
  }

  /** The smooth (vertex) normal at integer sample (ix, iz). */
  sampleNormal(ix: number, iz: number): Vec3 {
    const n = gridNormalAt(this._grid, ix, iz)
    return new Vec3(n[0], n[1], n[2])
  }

  /** Extent in local metres: `[width, depth]`. */
  get extent(): [number, number] { return [ (this.sizeX - 1) * this.cellSize, (this.sizeZ - 1) * this.cellSize ] }

  /** Min / max height over the grid. */
  get heightRange(): [number, number] {
    let lo = Infinity, hi = -Infinity
    for (let i = 0; i < this.heights.length; i++) { const h = this.heights[i]; if (h < lo) lo = h; if (h > hi) hi = h }
    return [ lo, hi ]
  }

  /** A world-space point into the terrain's local frame. */
  toLocal(p: Vec3Like): Vec3 {
    const inv = invertAffine(this._world())
    return transformPoint(inv, cx(p), cy(p), cz(p))
  }

  /** A local point into world space. */
  toWorld(p: Vec3Like): Vec3 { return transformPoint(this._world(), cx(p), cy(p), cz(p)) }

  /** The ground's WORLD height under a world (x, z): NaN off the grid / in a hole. */
  worldHeightAt(x: number, z: number): number {
    const l = this.toLocal([ x, 0, z ])
    const h = this.heightAt(l.x, l.z)
    if (Number.isNaN(h)) return NaN
    return this.toWorld([ l.x, h, l.z ]).y
  }

  /** Nearest hit of a LOCAL ray on the drawn triangles (holes let it through), null on a miss.
   *  Exact against what is rendered — the editor's brush and a host without physics pick with it. */
  raycast(origin: Vec3Like, dir: Vec3Like, maxDistance = 1e5): TerrainHit | null {
    let dx = cx(dir), dy = cy(dir), dz = cz(dir)
    const l = Math.hypot(dx, dy, dz)
    if (!(l > 0)) return null
    dx /= l; dy /= l; dz /= l
    const h = gridRaycast(this._grid, cx(origin), cy(origin), cz(origin), dx, dy, dz, maxDistance)
    return h ? { point: new Vec3(h.x, h.y, h.z), normal: new Vec3(h.nx, h.ny, h.nz), distance: h.distance } : null
  }

  /** `raycast` in WORLD space — through the node's transform (rotation + uniform scale). */
  raycastWorld(origin: Vec3Like, dir: Vec3Like, maxDistance = 1e5): TerrainHit | null {
    const o = this.toLocal(origin)
    const p = this.toLocal([ cx(origin) + cx(dir), cy(origin) + cy(dir), cz(origin) + cz(dir) ])
    const ldx = p.x - o.x, ldy = p.y - o.y, ldz = p.z - o.z
    const scale = Math.hypot(ldx, ldy, ldz) / (Math.hypot(cx(dir), cy(dir), cz(dir)) || 1)   // local units per world metre
    const hit = this.raycast(o, [ ldx, ldy, ldz ], maxDistance * (scale || 1))
    if (!hit) return null
    const wp = this.toWorld(hit.point)
    const wn = this.toWorld([ hit.point.x + hit.normal.x, hit.point.y + hit.normal.y, hit.point.z + hit.normal.z ])
    const nx = wn.x - wp.x, ny = wn.y - wp.y, nz = wn.z - wp.z
    const nl = Math.hypot(nx, ny, nz) || 1
    return {
      point: wp,
      normal: new Vec3(nx / nl, ny / nl, nz / nl),
      distance: Math.hypot(wp.x - cx(origin), wp.y - cy(origin), wp.z - cz(origin)),
    }
  }

  // ---- edits (mark dirty; nothing reaches the engine until commit) --------------------------------

  /** Write a rectangle of samples (`src` = w × h heights, row-major). */
  setHeights(x0: number, z0: number, w: number, h: number, src: ArrayLike<number>): this {
    for (let z = 0; z < h; z++) {
      const zz = z0 + z
      if (zz < 0 || zz >= this.sizeZ) continue
      for (let x = 0; x < w; x++) {
        const xx = x0 + x
        if (xx < 0 || xx >= this.sizeX) continue
        this.heights[zz * this.sizeX + xx] = src[z * w + x]
      }
    }
    this._touch(x0, z0, x0 + w - 1, z0 + h - 1)
    return this
  }

  /** Apply `fn(oldHeight, weight, x, z)` over a region; `weight` is 1 inside, fading to 0 over
   *  `falloff` metres outside it. The building block of flatten / raise / hole. */
  shape(region: TerrainRegion, fn: (h: number, w: number, x: number, z: number) => number, falloff = 0): this {
    const r = this._regionRect(region, falloff)
    if (!r) return this
    const weight = this._regionWeights(region, r, falloff)
    let k = 0
    for (let iz = r.z0; iz <= r.z1; iz++) {
      for (let ix = r.x0; ix <= r.x1; ix++, k++) {
        const w = weight[k]
        if (w <= 0) continue
        const i = iz * this.sizeX + ix
        this.heights[i] = fn(this.heights[i], w, ix * this.cellSize, iz * this.cellSize)
      }
    }
    this._touch(r.x0, r.z0, r.x1, r.z1)
    return this
  }

  /** Flatten a region to `height` (a plateau / a building pad), blending over `falloff` metres. */
  flatten(region: TerrainRegion, height: number, opts: { falloff?: number } = {}): this {
    return this.shape(region, (h, w) => h + (height - h) * w, opts.falloff ?? 4)
  }

  /** Raise (or lower, negative) a region by `delta` metres, blending over `falloff` metres. */
  raise(region: TerrainRegion, delta: number, opts: { falloff?: number } = {}): this {
    return this.shape(region, (h, w) => h + delta * w, opts.falloff ?? 4)
  }

  /** Copies of heights / holes / control — an undo point or a level reset. */
  snapshot(): TerrainSnapshot {
    return { heights: this.heights.slice(), holes: this.holes.slice(), control: this.control.slice() }
  }

  /** Put a `snapshot()` back (same grid size) — everything marked dirty for the next `commit()`. */
  restore(s: TerrainSnapshot): this {
    const n = this.sizeX * this.sizeZ
    if (s.heights.length !== n || s.holes.length !== n || s.control.length !== n * 4) throw new Error("Terrain.restore: snapshot size mismatch")
    this.heights.set(s.heights)
    this.holes.set(s.holes)
    this.control.set(s.control)
    this._anyHole = this.holes.some((v) => v !== 0)
    this._holesTouched = true
    this._touch(0, 0, this.sizeX - 1, this.sizeZ - 1)
    this._touchControl(0, 0, this.sizeX - 1, this.sizeZ - 1)
    return this
  }

  /** One brush dab: raise / lower / smooth / flatten a disc of `radius` metres around (x, z), feathered
   *  by `hardness` — the editor's sculpt tool and runtime deformation (craters, digging) share it. */
  sculpt(opts: SculptOptions): this {
    const hardness = Math.min(1, Math.max(0, opts.hardness ?? 0.5))
    const inner = opts.radius * hardness, falloff = Math.max(0, opts.radius - inner)
    const region: TerrainRegion = { circle: { x: opts.x, z: opts.z, r: inner } }
    const amount = opts.amount ?? 1
    switch (opts.mode ?? "raise") {
      case "raise": return this.shape(region, (h, w) => h + amount * w, falloff)
      case "lower": return this.shape(region, (h, w) => h - amount * w, falloff)
      case "flatten": {
        let target = opts.target ?? this.heightAt(opts.x, opts.z)
        if (Number.isNaN(target)) {
          // the centre is in a hole / off-grid: level to the disc's mean instead
          let sum = 0, n = 0
          this.shape(region, (h) => { sum += h; n++; return h }, falloff)
          target = n ? sum / n : 0
        }
        const k = Math.min(1, Math.max(0, amount))
        return this.shape(region, (h, w) => h + (target - h) * k * w, falloff)
      }
      case "smooth": {
        const r = this._regionRect(region, falloff)
        if (!r) return this
        // blur reads a snapshot (the rect + one ring), so the pass is order-independent
        const x0 = Math.max(0, r.x0 - 1), z0 = Math.max(0, r.z0 - 1), x1 = Math.min(this.sizeX - 1, r.x1 + 1), z1 = Math.min(this.sizeZ - 1, r.z1 + 1)
        const w = x1 - x0 + 1, hgt = z1 - z0 + 1
        const src = new Float32Array(w * hgt)
        for (let iz = z0; iz <= z1; iz++) for (let ix = x0; ix <= x1; ix++) src[(iz - z0) * w + (ix - x0)] = this.heights[iz * this.sizeX + ix]
        const k = Math.min(1, Math.max(0, amount))
        const c = this.cellSize
        return this.shape(region, (h, wt, px, pz) => {
          const ix = Math.round(px / c), iz = Math.round(pz / c)
          let sum = 0, n = 0
          for (let dz = -1; dz <= 1; dz++) for (let dx = -1; dx <= 1; dx++) {
            const sx = ix + dx, sz = iz + dz
            if (sx < x0 || sx > x1 || sz < z0 || sz > z1) continue
            sum += src[(sz - z0) * w + (sx - x0)]; n++
          }
          return n ? h + (sum / n - h) * k * wt : h
        }, falloff)
      }
    }
    return this
  }

  /** Cut a hole (no triangles, no collision, no navmesh) — or fill one back with `on = false`. */
  hole(region: TerrainRegion, on = true): this {
    const r = this._regionRect(region, 0)
    if (!r) return this
    const weight = this._regionWeights(region, r, 0)
    let k = 0
    for (let iz = r.z0; iz <= r.z1; iz++) {
      for (let ix = r.x0; ix <= r.x1; ix++, k++) {
        if (weight[k] <= 0) continue
        this.holes[iz * this.sizeX + ix] = on ? 1 : 0
      }
    }
    this._holesTouched = true
    this._anyHole = on || this.holes.some((v) => v !== 0)
    this._touch(r.x0, r.z0, r.x1, r.z1)
    return this
  }

  /**
   * Conform the ground to a mesh — the terrain takes the target's TOP surface minus `offset` under
   * its footprint (a road sits exactly on it, nothing pokes through), and blends back to the old
   * ground over `falloff` metres around it. `target` = a node (a Mesh, a Model, or a node holding
   * them — every triangle in the subtree counts), a Geometry in local coordinates, or a raw soup
   * (9 floats per triangle, local). Call again after the target moves.
   */
  conform(target: Node | Geometry | Float32Array, opts: ConformOptions = {}): this {
    const tris = this._triangles(target)
    if (tris.length < 9) return this
    const offset = opts.offset ?? 0.05
    const falloff = Math.max(0, opts.falloff ?? 4)
    const mode = opts.mode ?? "both"
    const cell = this.cellSize
    const sx = this.sizeX, sz = this.sizeZ
    const inside = new Uint8Array(sx * sz)
    const target2 = new Float32Array(sx * sz).fill(Infinity)
    let fx0 = sx, fz0 = sz, fx1 = -1, fz1 = -1
    // 1. every sample under a triangle takes the LOWEST covering surface: nothing of the target may
    //    end up below ground (overlapping road quads at a bend, a building's floor rather than its roof)
    for (let t = 0; t + 8 < tris.length; t += 9) {
      const ax = tris[t], ay = tris[t + 1], az = tris[t + 2]
      const bx = tris[t + 3], by = tris[t + 4], bz = tris[t + 5]
      const qx = tris[t + 6], qy = tris[t + 7], qz = tris[t + 8]
      const minX = Math.max(0, Math.ceil(Math.min(ax, bx, qx) / cell)), maxX = Math.min(sx - 1, Math.floor(Math.max(ax, bx, qx) / cell))
      const minZ = Math.max(0, Math.ceil(Math.min(az, bz, qz) / cell)), maxZ = Math.min(sz - 1, Math.floor(Math.max(az, bz, qz) / cell))
      if (minX > maxX || minZ > maxZ) continue
      const det = (bx - ax) * (qz - az) - (qx - ax) * (bz - az)
      if (Math.abs(det) < 1e-12) continue   // vertical / degenerate in XZ
      for (let iz = minZ; iz <= maxZ; iz++) {
        const pz = iz * cell
        for (let ix = minX; ix <= maxX; ix++) {
          const px = ix * cell
          const l1 = ((bx - ax) * (pz - az) - (px - ax) * (bz - az)) / det   // weight of q
          const l2 = ((px - ax) * (qz - az) - (qx - ax) * (pz - az)) / det   // weight of b
          const l0 = 1 - l1 - l2
          if (l0 < -1e-6 || l1 < -1e-6 || l2 < -1e-6) continue
          const h = l0 * ay + l2 * by + l1 * qy
          const i = iz * sx + ix
          if (h < target2[i]) target2[i] = h
          if (!inside[i]) { inside[i] = 1; if (ix < fx0) fx0 = ix; if (ix > fx1) fx1 = ix; if (iz < fz0) fz0 = iz; if (iz > fz1) fz1 = iz }
        }
      }
    }
    if (fx1 < 0) return this
    const apply = (i: number, target: number): void => {
      const h = this.heights[i]
      this.heights[i] = mode === "lower" ? Math.min(h, target) : mode === "raise" ? Math.max(h, target) : target
    }
    // 2. the footprint itself
    for (let iz = fz0; iz <= fz1; iz++) for (let ix = fx0; ix <= fx1; ix++) {
      const i = iz * sx + ix
      if (inside[i]) { target2[i] -= offset; apply(i, target2[i]) }
    }
    // 3. the ring: every sample outside the footprint but within `falloff` of the target's BOUNDARY
    //    blends from the boundary's own height (− offset) back to the old ground. The distance is
    //    measured to the triangle edges in XZ, not to the nearest inside sample — the first outside
    //    sample is usually a fraction of a cell from the road edge, and anchoring it there is what
    //    keeps the ground under the edge itself at road level (docs/terrain-plan.md §4.1). The first
    //    CELL outside is a flat shoulder (weight 0): the terrain triangle straddling the edge then
    //    interpolates between two samples at road level, so the edge is exact even where the natural
    //    ground is tens of metres away — a blend starting AT the edge would leak a few percent of that
    //    difference back onto the edge. Edges are binned on a coarse grid so a long road stays cheap.
    const shoulder = cell
    const ring = Math.max(falloff, shoulder)
    const R = Math.ceil(ring / cell)
    {
      const coarse = Math.max(ring, cell * 2)
      const bins = new Map<number, number[]>()   // coarse cell → edge indices (start float offset)
      const edges: number[] = []                  // ax, az, ay, bx, bz, by per edge
      const key = (cx0: number, cz0: number): number => cz0 * 65536 + cx0
      const addEdge = (ax: number, ay: number, az: number, bx: number, by: number, bz: number): void => {
        const e = edges.length
        edges.push(ax, az, ay, bx, bz, by)
        const cx0 = Math.floor(Math.min(ax, bx) / coarse), cx1 = Math.floor(Math.max(ax, bx) / coarse)
        const cz0 = Math.floor(Math.min(az, bz) / coarse), cz1 = Math.floor(Math.max(az, bz) / coarse)
        for (let cz = cz0; cz <= cz1; cz++) for (let cxx = cx0; cxx <= cx1; cxx++) {
          const k = key(cxx, cz)
          const list = bins.get(k)
          if (list) list.push(e); else bins.set(k, [ e ])
        }
      }
      for (let t = 0; t + 8 < tris.length; t += 9) {
        addEdge(tris[t], tris[t + 1], tris[t + 2], tris[t + 3], tris[t + 4], tris[t + 5])
        addEdge(tris[t + 3], tris[t + 4], tris[t + 5], tris[t + 6], tris[t + 7], tris[t + 8])
        addEdge(tris[t + 6], tris[t + 7], tris[t + 8], tris[t], tris[t + 1], tris[t + 2])
      }
      const rx0 = Math.max(0, fx0 - R), rz0 = Math.max(0, fz0 - R), rx1 = Math.min(sx - 1, fx1 + R), rz1 = Math.min(sz - 1, fz1 + R)
      for (let iz = rz0; iz <= rz1; iz++) {
        const pz = iz * cell
        for (let ix = rx0; ix <= rx1; ix++) {
          const i = iz * sx + ix
          if (inside[i]) continue
          const px = ix * cell
          let best = Infinity, bestH = 0
          const ccx = Math.floor(px / coarse), ccz = Math.floor(pz / coarse)
          for (let dz = -1; dz <= 1; dz++) for (let dx = -1; dx <= 1; dx++) {
            const list = bins.get(key(ccx + dx, ccz + dz))
            if (!list) continue
            for (const e of list) {
              const ax = edges[e], az = edges[e + 1], ay = edges[e + 2], bx = edges[e + 3], bz = edges[e + 4], by = edges[e + 5]
              const ex = bx - ax, ez = bz - az
              const l2 = ex * ex + ez * ez
              const u = l2 > 0 ? Math.min(1, Math.max(0, ((px - ax) * ex + (pz - az) * ez) / l2)) : 0
              const qx = ax + ex * u, qz = az + ez * u
              const d2 = (px - qx) * (px - qx) + (pz - qz) * (pz - qz)
              if (d2 < best) { best = d2; bestH = ay + (by - ay) * u }
            }
          }
          if (best === Infinity) continue
          const d = Math.sqrt(best)
          if (d >= ring) continue
          const target = bestH - offset
          // 0 across the shoulder → 1 at falloff
          const s = d <= shoulder ? 0 : falloff > shoulder ? smoothstep(0, 1, (d - shoulder) / (falloff - shoulder)) : 1
          apply(i, target + (this.heights[i] - target) * s)
        }
      }
      this._touch(rx0, rz0, rx1, rz1)
    }
    return this
  }

  /** `conform` to a ribbon along `points` (world/local metres, see `ribbon`) — the road stamp. */
  conformPath(points: readonly Vec3Like[], opts: ConformOptions & RibbonOptions): this {
    const g = Terrain.ribbon(points, opts)
    return this.conform(g, opts)
  }

  /**
   * A flat ribbon Geometry along a polyline (the road mesh AND the conform target): `width` metres,
   * vertices take the points' own Y, UV0 = (across 0…1, along / uvScale). Build it from heights you
   * sampled with `heightAt` and smoothed, then `Mesh.from(geometry, { material })` + `conform`.
   * It also carries a lightmap UV (uv1: along 0…1 × across 0…width/length — the strip's true aspect,
   * so `lecodes lightmap bake` gives it uniform texels), which makes the road a bake receiver.
   */
  static ribbon(points: readonly Vec3Like[], opts: RibbonOptions): Geometry {
    const n = points.length
    if (n < 2) throw new Error("Terrain.ribbon needs at least two points")
    const half = opts.width / 2
    const uvScale = opts.uvScale ?? opts.width
    const verts = new Float32Array(n * 6), normals = new Float32Array(n * 6), uv = new Float32Array(n * 4)
    const uv1 = new Float32Array(n * 4)
    let along = 0
    for (let i = 0; i < n; i++) {
      const p = points[i]
      const prev = points[Math.max(0, i - 1)], next = points[Math.min(n - 1, i + 1)]
      let tx = cx(next) - cx(prev), tz = cz(next) - cz(prev)
      const tl = Math.hypot(tx, tz) || 1
      tx /= tl; tz /= tl
      const sxv = -tz * half, szv = tx * half
      if (i > 0) along += Math.hypot(cx(p) - cx(prev), cy(p) - cy(prev), cz(p) - cz(prev))
      const o = i * 6
      verts[o] = cx(p) - sxv; verts[o + 1] = cy(p); verts[o + 2] = cz(p) - szv
      verts[o + 3] = cx(p) + sxv; verts[o + 4] = cy(p); verts[o + 5] = cz(p) + szv
      normals[o + 1] = 1; normals[o + 4] = 1
      uv[i * 4] = 0; uv[i * 4 + 1] = along / uvScale; uv[i * 4 + 2] = 1; uv[i * 4 + 3] = along / uvScale
      uv1[i * 4] = along; uv1[i * 4 + 2] = along   // scaled to 0…1 below, once the length is known
    }
    const length = along || 1, across = opts.width / length
    for (let i = 0; i < n; i++) { uv1[i * 4] /= length; uv1[i * 4 + 1] = 0; uv1[i * 4 + 2] /= length; uv1[i * 4 + 3] = across }
    const idx = new Uint16Array((n - 1) * 6)
    for (let i = 0; i + 1 < n; i++) {
      const a = i * 2, b = a + 1, c = a + 2, d = a + 3
      // left = a/c, right = b/d; CCW seen from +Y: (a, d, c), (a, b, d)
      idx.set([ a, d, c, a, b, d ], i * 6)
    }
    // segment normals from the geometry (a banked or climbing ribbon): (d − a) × (c − a) faces up
    for (let i = 0; i + 1 < n; i++) {
      const a = i * 2, c = a + 2, d = a + 3
      const ux = verts[d * 3] - verts[a * 3], uy = verts[d * 3 + 1] - verts[a * 3 + 1], uz = verts[d * 3 + 2] - verts[a * 3 + 2]
      const vx = verts[c * 3] - verts[a * 3], vy = verts[c * 3 + 1] - verts[a * 3 + 1], vz = verts[c * 3 + 2] - verts[a * 3 + 2]
      let nx = uy * vz - uz * vy, ny = uz * vx - ux * vz, nz = ux * vy - uy * vx
      const l = Math.hypot(nx, ny, nz) || 1
      nx /= l; ny /= l; nz /= l
      for (const v of [ a, a + 1, c, d ]) { normals[v * 3] = nx; normals[v * 3 + 1] = ny; normals[v * 3 + 2] = nz }
    }
    const g = new Geometry(verts, normals, idx, uv)
    g.uv1 = uv1
    return g
  }

  /** Write RGBA layer weights for a rectangle of samples (`rgba` = w × h × 4 bytes). */
  paint(x0: number, z0: number, w: number, h: number, rgba: ArrayLike<number>): this {
    for (let z = 0; z < h; z++) {
      const zz = z0 + z
      if (zz < 0 || zz >= this.sizeZ) continue
      for (let x = 0; x < w; x++) {
        const xx = x0 + x
        if (xx < 0 || xx >= this.sizeX) continue
        const s = (zz * this.sizeX + xx) * 4, d = (z * w + x) * 4
        this.control[s] = rgba[d]; this.control[s + 1] = rgba[d + 1]; this.control[s + 2] = rgba[d + 2]; this.control[s + 3] = rgba[d + 3]
      }
    }
    this._touchControl(x0, z0, x0 + w - 1, z0 + h - 1)
    return this
  }

  /** Paint `layer` over a region with `strength` (0…1), feathered over `falloff` metres. */
  paintLayer(region: TerrainRegion, layer: number, strength = 1, falloff = 2): this {
    const r = this._regionRect(region, falloff)
    if (!r) return this
    const weight = this._regionWeights(region, r, falloff)
    let k = 0
    for (let iz = r.z0; iz <= r.z1; iz++) {
      for (let ix = r.x0; ix <= r.x1; ix++, k++) {
        const w = weight[k] * strength
        if (w <= 0) continue
        this._blendLayer(iz * this.sizeX + ix, layer, w)
      }
    }
    this._touchControl(r.x0, r.z0, r.x1, r.z1)
    return this
  }

  /** Paint the whole terrain from slope / height rules (grass on the flats, rock on cliffs, snow on
   *  the peaks, dirt in the valleys) — the CPU pass a procedural level starts from. */
  paintAuto(rules: AutoPaintRules = {}): this {
    const base = rules.base ?? 0
    const n = [ 0, 0, 0 ]
    for (let iz = 0; iz < this.sizeZ; iz++) {
      for (let ix = 0; ix < this.sizeX; ix++) {
        const i = iz * this.sizeX + ix
        const c = i * 4
        this.control[c] = 0; this.control[c + 1] = 0; this.control[c + 2] = 0; this.control[c + 3] = 0
        this.control[c + Math.min(3, Math.max(0, base))] = 255
        gridNormalAt(this._grid, ix, iz, n)
        const h = this.heights[i]
        if (rules.low) this._blendLayer(i, rules.low.layer, 1 - smoothstep(rules.low.from, rules.low.to, h))
        if (rules.slope) {
          const deg = Math.acos(Math.min(1, Math.max(-1, n[1]))) * 180 / Math.PI
          this._blendLayer(i, rules.slope.layer, smoothstep(rules.slope.from, rules.slope.to, deg))
        }
        if (rules.height) this._blendLayer(i, rules.height.layer, smoothstep(rules.height.from, rules.height.to, h))
      }
    }
    this._touchControl(0, 0, this.sizeX - 1, this.sizeZ - 1)
    return this
  }

  private _blendLayer(i: number, layer: number, w: number): void {
    if (w <= 0) return
    const c = i * 4
    const L = Math.min(3, Math.max(0, layer | 0))
    for (let k = 0; k < 4; k++) {
      const target = k === L ? 255 : 0
      this.control[c + k] = Math.round(this.control[c + k] + (target - this.control[c + k]) * Math.min(1, w))
    }
  }

  // ---- commit ------------------------------------------------------------------------------------

  /** Push every pending edit to the engine: chunk renderables + the height-field collider for the
   *  dirty rectangle, the control texture. Call once after a batch of edits. */
  commit(): this {
    const d = this._dirty
    if (d) {
      const w = d.x1 - d.x0 + 1, h = d.z1 - d.z0 + 1
      const holes = this._anyHole ? this.holes : null
      if (this._native) _creator.terrainUpdate!(this.node.id, this.heights, holes, d.x0, d.z0, w, h)
      else this._rebuildFallbackChunks(d)
      const shape = this.node.get(Shape)
      if (shape && shape.heightfield && shape.shapeId) {
        const range = shape._heightfieldRange
        const [ lo, hi ] = this.heightRange
        if (range && (lo < range[0] || hi > range[1])) shape._rebuildShape()
        else if (_creator.physicsUpdateHeightField) _creator.physicsUpdateHeightField(shape.shapeId, this.heights, this.sizeX, this.sizeZ, d.x0, d.z0, w, h, holes)
      }
      this._dirty = null
      this._holesTouched = false
    }
    if (this._controlDirty) { this._uploadControl(this._controlDirty); this._controlDirty = null }
    return this
  }

  set castShadows(v: boolean) { this._cast = v; this._applyShadows() }
  get castShadows(): boolean { return this._cast }
  set receiveShadows(v: boolean) { this._receive = v; this._applyShadows() }
  get receiveShadows(): boolean { return this._receive }

  /** The whole terrain as a `.terrain` file (LTER: heights quantised to 16 bits, holes, control). */
  serialize(): ArrayBuffer {
    const [ lo, hi ] = this.heightRange
    const range = hi - lo > 1e-6 ? hi - lo : 1
    const n = this.sizeX * this.sizeZ
    const meta = {
      sizeX: this.sizeX, sizeZ: this.sizeZ, cellSize: this.cellSize, chunk: this.chunk, heightMin: lo, heightMax: hi,
      layers: this.layers.map((l) => ({ tiling: l.tiling, roughness: l.roughness, normalScale: l.normalScale, triplanar: l.triplanar })),
      blobs: { heights: { fmt: "u16", off: 0, len: n * 2 }, holes: { off: n * 2, len: n }, control: { off: n * 3, len: n * 4 } },
    }
    const json = JSON.stringify(meta)
    const jsonBytes = utf8(json)
    const headerLen = 12 + jsonBytes.length
    const blobStart = (headerLen + 3) & ~3
    const out = new ArrayBuffer(blobStart + n * 7)
    const dv = new DataView(out)
    dv.setUint32(0, FILE_MAGIC, true)
    dv.setUint32(4, FILE_VERSION, true)
    dv.setUint32(8, jsonBytes.length, true)
    new Uint8Array(out, 12, jsonBytes.length).set(jsonBytes)
    const u16 = new Uint16Array(out, blobStart, n)
    for (let i = 0; i < n; i++) u16[i] = Math.round(((this.heights[i] - lo) / range) * 65535)
    new Uint8Array(out, blobStart + n * 2, n).set(this.holes)
    new Uint8Array(out, blobStart + n * 3, n * 4).set(this.control)
    return out
  }

  /** Decode a `.terrain` file into create() options (heights back in metres). */
  static parse(buf: ArrayBuffer): { sizeX: number, sizeZ: number, cellSize: number, chunk: number, heights: Float32Array, holes: Uint8Array, control: Uint8Array, layers: TerrainLayer[] } {
    const dv = new DataView(buf)
    if (buf.byteLength < 12 || dv.getUint32(0, true) !== FILE_MAGIC) throw new Error("Terrain: not a .terrain file")
    const version = dv.getUint32(4, true)
    if (version !== FILE_VERSION) throw new Error(`Terrain: unsupported file version ${version}`)
    const jsonLen = dv.getUint32(8, true)
    const meta = JSON.parse(fromUtf8(new Uint8Array(buf, 12, jsonLen))) as ReturnType<typeof JSON.parse>
    const blobStart = (12 + jsonLen + 3) & ~3
    const n = meta.sizeX * meta.sizeZ
    const heights = new Float32Array(n)
    const range = meta.heightMax - meta.heightMin > 1e-6 ? meta.heightMax - meta.heightMin : 1
    const u16 = new Uint16Array(buf.slice(blobStart + meta.blobs.heights.off, blobStart + meta.blobs.heights.off + n * 2))
    for (let i = 0; i < n; i++) heights[i] = meta.heightMin + (u16[i] / 65535) * range
    const holes = new Uint8Array(buf.slice(blobStart + meta.blobs.holes.off, blobStart + meta.blobs.holes.off + n))
    const control = new Uint8Array(buf.slice(blobStart + meta.blobs.control.off, blobStart + meta.blobs.control.off + n * 4))
    return { sizeX: meta.sizeX, sizeZ: meta.sizeZ, cellSize: meta.cellSize, chunk: meta.chunk, heights, holes, control, layers: meta.layers ?? [] }
  }

  // ---- internals ---------------------------------------------------------------------------------

  /** @internal what Shape { heightfield } reads. */
  get _data(): TerrainData {
    return { sizeX: this.sizeX, sizeZ: this.sizeZ, cellSize: this.cellSize, heights: this.heights, holes: this._anyHole ? this.holes : null }
  }

  private _build(): void {
    const holes = this._anyHole ? this.holes : null
    if (_creator.terrainCreate) {
      const n = _creator.terrainCreate(this.node.id, Material.idOf(this.material), this.heights, this.sizeX, this.sizeZ, this.cellSize, this.chunk, holes)
      if (n > 0) { this._native = true; return }
    }
    if (!warnedHost) { warnedHost = true; console.warn("Terrain: this host has no native terrain — drawing the chunks as meshes (normal maps read flat)") }
    const [ nx, nz ] = chunkCounts(this._grid, this.chunk)
    this._chunkMeshes = new Array(nx * nz).fill(null)
    for (let cz = 0; cz < nz; cz++) for (let cx2 = 0; cx2 < nx; cx2++) this._buildFallbackChunk(cx2, cz, nx)
  }

  private _buildFallbackChunk(cx2: number, cz: number, nx: number): void {
    const k = cz * nx + cx2
    const old = this._chunkMeshes[k]
    if (old) { old.destroy(); this._chunkMeshes[k] = null }   // destroy = engine subtree removal + registry cleanup
    const geom = buildTerrainChunk(this._grid, cx2, cz, this.chunk)
    if (!geom) return
    const mesh = new Mesh(geom, this.material)
    mesh.name = `chunk_${cx2}_${cz}`
    mesh.castShadows = this._cast
    mesh.receiveShadows = this._receive
    ;(mesh as unknown as { _terrainChunk: boolean })._terrainChunk = true
    this.node.add(mesh)
    this._chunkMeshes[k] = mesh
  }

  private _rebuildFallbackChunks(d: Rect): void {
    const [ nx, nz ] = chunkCounts(this._grid, this.chunk)
    const cx0 = Math.floor(Math.max(0, d.x0 - 1) / this.chunk), cz0 = Math.floor(Math.max(0, d.z0 - 1) / this.chunk)
    const cx1 = Math.min(nx - 1, Math.floor(Math.min(this.sizeX - 1, d.x1 + 1) / this.chunk))
    const cz1 = Math.min(nz - 1, Math.floor(Math.min(this.sizeZ - 1, d.z1 + 1) / this.chunk))
    for (let cz = cz0; cz <= cz1; cz++) for (let cx2 = cx0; cx2 <= cx1; cx2++) this._buildFallbackChunk(cx2, cz, nx)
  }

  private _applyShadows(): void {
    if (this._native) _creator.terrainSetShadows?.(this.node.id, this._cast, this._receive)
    else for (const m of this._chunkMeshes) if (m) { m.castShadows = this._cast; m.receiveShadows = this._receive }
  }

  private _uploadControl(region: Rect | null): void {
    if (!_creator.createTexturePixels || !_creator.updateTexturePixels) {
      if (!warnedPixels) { warnedPixels = true; console.warn("Terrain: this host cannot upload a control map — every layer shows equally") }
      return
    }
    if (!this._controlTex) {
      const id = _creator.createTexturePixels(this.sizeX, this.sizeZ, 4, this.control, false)
      if (id === 0xFFFFFFFF) return
      this._controlTex = new Texture(this.sizeX, this.sizeZ, id)
      this._controlTex.wrapS = 0
      this._controlTex.wrapT = 0
      this.material.set("control", this._controlTex)
      return
    }
    const r = region ?? { x0: 0, z0: 0, x1: this.sizeX - 1, z1: this.sizeZ - 1 }
    const w = r.x1 - r.x0 + 1, h = r.z1 - r.z0 + 1
    const sub = new Uint8Array(w * h * 4)
    for (let z = 0; z < h; z++) sub.set(this.control.subarray(((r.z0 + z) * this.sizeX + r.x0) * 4, ((r.z0 + z) * this.sizeX + r.x0 + w) * 4), z * w * 4)
    _creator.updateTexturePixels(this._controlTex._id, r.x0, r.z0, w, h, sub)
  }

  private async _applyLayers(normals?: TerrainOptions["normals"]): Promise<void> {
    const m = this.material
    const tiling = [ 8, 8, 8, 8 ], rough = [ 1, 1, 1, 1 ], nscale = [ 0, 0, 0, 0 ], tri = [ 0, 0, 0, 0 ]
    const waits: Promise<void>[] = []
    const bind = (key: string, src: string | Texture | undefined): void => {
      if (src === undefined) return
      if (src instanceof Texture) { m.set(key, src); return }
      waits.push(Texture.load(src).then((t) => { m.set(key, t) }, (e) => { console.warn(`Terrain: ${key}: ${e instanceof Error ? e.message : String(e)}`) }))
    }
    this.layers.forEach((l, i) => {
      tiling[i] = l.tiling ?? 8
      rough[i] = l.roughness ?? 1
      nscale[i] = l.normalScale ?? 0
      tri[i] = l.triplanar ? 1 : 0
      bind(`albedo${i}`, l.albedo)
    })
    m.set("tiling", tiling).set("roughness", rough).set("normalScale", nscale).set("triplanar", tri)
    bind("normals01", normals?.[0])
    bind("normals23", normals?.[1])
    await Promise.all(waits)
  }

  /** Re-push layer scalars after editing `layers` (textures given as Texture apply too). */
  applyLayers(): Promise<void> { return this._applyLayers() }

  private _touch(x0: number, z0: number, x1: number, z1: number): void {
    x0 = Math.max(0, x0); z0 = Math.max(0, z0); x1 = Math.min(this.sizeX - 1, x1); z1 = Math.min(this.sizeZ - 1, z1)
    if (x0 > x1 || z0 > z1) return
    const d = this._dirty
    this._dirty = d ? { x0: Math.min(d.x0, x0), z0: Math.min(d.z0, z0), x1: Math.max(d.x1, x1), z1: Math.max(d.z1, z1) } : { x0, z0, x1, z1 }
  }

  private _touchControl(x0: number, z0: number, x1: number, z1: number): void {
    x0 = Math.max(0, x0); z0 = Math.max(0, z0); x1 = Math.min(this.sizeX - 1, x1); z1 = Math.min(this.sizeZ - 1, z1)
    if (x0 > x1 || z0 > z1) return
    const d = this._controlDirty
    this._controlDirty = d ? { x0: Math.min(d.x0, x0), z0: Math.min(d.z0, z0), x1: Math.max(d.x1, x1), z1: Math.max(d.z1, z1) } : { x0, z0, x1, z1 }
  }

  /** The sample rectangle a region (+ falloff) touches, or null when it misses the grid. */
  private _regionRect(region: TerrainRegion, falloff: number): Rect | null {
    const c = this.cellSize
    let minX: number, minZ: number, maxX: number, maxZ: number
    if ("circle" in region) {
      const { x, z, r } = region.circle
      minX = x - r - falloff; maxX = x + r + falloff; minZ = z - r - falloff; maxZ = z + r + falloff
    } else if ("rect" in region) {
      const { x, z, w, h } = region.rect
      minX = x - falloff; maxX = x + w + falloff; minZ = z - falloff; maxZ = z + h + falloff
    } else if ("polygon" in region) {
      minX = Infinity; maxX = -Infinity; minZ = Infinity; maxZ = -Infinity
      for (const [ px, pz ] of region.polygon) { if (px < minX) minX = px; if (px > maxX) maxX = px; if (pz < minZ) minZ = pz; if (pz > maxZ) maxZ = pz }
      minX -= falloff; maxX += falloff; minZ -= falloff; maxZ += falloff
    } else {
      const tris = this._triangles(region.node)
      if (tris.length < 9) return null
      minX = Infinity; maxX = -Infinity; minZ = Infinity; maxZ = -Infinity
      for (let i = 0; i < tris.length; i += 3) { const px = tris[i], pz = tris[i + 2]; if (px < minX) minX = px; if (px > maxX) maxX = px; if (pz < minZ) minZ = pz; if (pz > maxZ) maxZ = pz }
      minX -= falloff; maxX += falloff; minZ -= falloff; maxZ += falloff
    }
    const r = { x0: Math.max(0, Math.floor(minX / c)), z0: Math.max(0, Math.floor(minZ / c)), x1: Math.min(this.sizeX - 1, Math.ceil(maxX / c)), z1: Math.min(this.sizeZ - 1, Math.ceil(maxZ / c)) }
    return r.x0 <= r.x1 && r.z0 <= r.z1 ? r : null
  }

  /** Per-sample weights over `r`: 1 inside the region, smooth 1→0 across `falloff` metres outside. */
  private _regionWeights(region: TerrainRegion, r: Rect, falloff: number): Float32Array {
    const w = r.x1 - r.x0 + 1, h = r.z1 - r.z0 + 1
    const out = new Float32Array(w * h)
    const c = this.cellSize
    const fade = (d: number): number => (d <= 0 ? 1 : falloff > 0 && d < falloff ? 1 - smoothstep(0, 1, d / falloff) : 0)
    if ("node" in region) {
      // coverage by the node's triangles, no falloff
      const tris = this._triangles(region.node)
      for (let t = 0; t + 8 < tris.length; t += 9) {
        const ax = tris[t], az = tris[t + 2], bx = tris[t + 3], bz = tris[t + 5], qx = tris[t + 6], qz = tris[t + 8]
        const det = (bx - ax) * (qz - az) - (qx - ax) * (bz - az)
        if (Math.abs(det) < 1e-12) continue
        const minX = Math.max(r.x0, Math.ceil(Math.min(ax, bx, qx) / c)), maxX = Math.min(r.x1, Math.floor(Math.max(ax, bx, qx) / c))
        const minZ = Math.max(r.z0, Math.ceil(Math.min(az, bz, qz) / c)), maxZ = Math.min(r.z1, Math.floor(Math.max(az, bz, qz) / c))
        for (let iz = minZ; iz <= maxZ; iz++) for (let ix = minX; ix <= maxX; ix++) {
          const px = ix * c, pz = iz * c
          const l1 = ((bx - ax) * (pz - az) - (px - ax) * (bz - az)) / det
          const l2 = ((px - ax) * (qz - az) - (qx - ax) * (pz - az)) / det
          if (l1 < -1e-6 || l2 < -1e-6 || 1 - l1 - l2 < -1e-6) continue
          out[(iz - r.z0) * w + (ix - r.x0)] = 1
        }
      }
      return out
    }
    let k = 0
    for (let iz = r.z0; iz <= r.z1; iz++) {
      const pz = iz * c
      for (let ix = r.x0; ix <= r.x1; ix++, k++) {
        const px = ix * c
        if ("circle" in region) {
          const d = Math.hypot(px - region.circle.x, pz - region.circle.z) - region.circle.r
          out[k] = fade(d)
        } else if ("rect" in region) {
          const { x, z, w: rw, h: rh } = region.rect
          const dx = Math.max(x - px, 0, px - (x + rw)), dz = Math.max(z - pz, 0, pz - (z + rh))
          out[k] = fade(Math.hypot(dx, dz))
        } else {
          const poly = region.polygon
          out[k] = fade(pointInPolygon(poly, px, pz) ? 0 : distanceToPolygon(poly, px, pz))
        }
      }
    }
    return out
  }

  /** World matrix of the node (column-major). */
  private _world(): Float32Array {
    const m = new Float32Array(16)
    _creator.getWorldMatrix(this.node.id, m)
    return m
  }

  /** A target's triangles in the terrain's LOCAL frame, 9 floats per triangle. */
  private _triangles(target: Node | Geometry | Float32Array): Float32Array {
    if (target instanceof Float32Array) return target
    if (target instanceof Geometry) return soupFromGeometry(target, null)
    const inv = invertAffine(this._world())
    const parts: Float32Array[] = []
    const scratch = new Float32Array(16)
    const walk = (n: Node): void => {
      if ((n as unknown as { _terrain?: unknown })._terrain) return
      _creator.getWorldMatrix(n.id, scratch)
      const toLocal = multiplyAffine(inv, scratch)
      const geom = n.geometry
      if (geom && geom._meshKind === 0) parts.push(soupFromGeometry(geom, toLocal))
      else if (n instanceof Model && _creator.glbTriangles) {
        const soup = _creator.glbTriangles(n.id)
        if (soup.length >= 9) parts.push(transformSoup(soup, toLocal))
      }
      for (const c of n.children) walk(c)
    }
    walk(target)
    const total = parts.reduce((s, p) => s + p.length, 0)
    const out = new Float32Array(total)
    let o = 0
    for (const p of parts) { out.set(p, o); o += p.length }
    return out
  }
}

// ---- helpers -------------------------------------------------------------------------------------

const soupFromGeometry = (g: Geometry, m: Float32Array | null): Float32Array => {
  const idx = g.indices, v = g.vertices
  const out = new Float32Array(idx.length * 3)
  for (let i = 0; i < idx.length; i++) {
    const vi = idx[i] * 3
    out[i * 3] = v[vi]; out[i * 3 + 1] = v[vi + 1]; out[i * 3 + 2] = v[vi + 2]
  }
  return m ? transformSoup(out, m) : out
}

const transformSoup = (s: Float32Array, m: Float32Array): Float32Array => {
  const out = new Float32Array(s.length)
  for (let i = 0; i + 2 < s.length; i += 3) {
    const x = s[i], y = s[i + 1], z = s[i + 2]
    out[i] = m[0] * x + m[4] * y + m[8] * z + m[12]
    out[i + 1] = m[1] * x + m[5] * y + m[9] * z + m[13]
    out[i + 2] = m[2] * x + m[6] * y + m[10] * z + m[14]
  }
  return out
}

const transformPoint = (m: Float32Array, x: number, y: number, z: number): Vec3 =>
  new Vec3(m[0] * x + m[4] * y + m[8] * z + m[12], m[1] * x + m[5] * y + m[9] * z + m[13], m[2] * x + m[6] * y + m[10] * z + m[14])

/** a × b for column-major affine 4×4s. */
const multiplyAffine = (a: Float32Array, b: Float32Array): Float32Array => {
  const o = new Float32Array(16)
  for (let c = 0; c < 4; c++) for (let r = 0; r < 4; r++) {
    o[c * 4 + r] = a[r] * b[c * 4] + a[4 + r] * b[c * 4 + 1] + a[8 + r] * b[c * 4 + 2] + a[12 + r] * b[c * 4 + 3]
  }
  return o
}

/** Inverse of a column-major affine (TRS) 4×4. */
const invertAffine = (m: Float32Array): Float32Array => {
  const a00 = m[0], a01 = m[1], a02 = m[2], a10 = m[4], a11 = m[5], a12 = m[6], a20 = m[8], a21 = m[9], a22 = m[10]
  const b01 = a22 * a11 - a12 * a21, b11 = -a22 * a10 + a12 * a20, b21 = a21 * a10 - a11 * a20
  let det = a00 * b01 + a01 * b11 + a02 * b21
  if (Math.abs(det) < 1e-20) det = 1e-20
  det = 1 / det
  const o = new Float32Array(16)
  o[0] = b01 * det; o[1] = (-a22 * a01 + a02 * a21) * det; o[2] = (a12 * a01 - a02 * a11) * det
  o[4] = b11 * det; o[5] = (a22 * a00 - a02 * a20) * det; o[6] = (-a12 * a00 + a02 * a10) * det
  o[8] = b21 * det; o[9] = (-a21 * a00 + a01 * a20) * det; o[10] = (a11 * a00 - a01 * a10) * det
  const tx = m[12], ty = m[13], tz = m[14]
  o[12] = -(o[0] * tx + o[4] * ty + o[8] * tz)
  o[13] = -(o[1] * tx + o[5] * ty + o[9] * tz)
  o[14] = -(o[2] * tx + o[6] * ty + o[10] * tz)
  o[15] = 1
  return o
}

const pointInPolygon = (poly: readonly (readonly [number, number])[], x: number, z: number): boolean => {
  let inside = false
  for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) {
    const [ xi, zi ] = poly[i], [ xj, zj ] = poly[j]
    if ((zi > z) !== (zj > z) && x < ((xj - xi) * (z - zi)) / (zj - zi || 1e-12) + xi) inside = !inside
  }
  return inside
}

const distanceToPolygon = (poly: readonly (readonly [number, number])[], x: number, z: number): number => {
  let best = Infinity
  for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) {
    const [ ax, az ] = poly[j], [ bx, bz ] = poly[i]
    const dx = bx - ax, dz = bz - az
    const l2 = dx * dx + dz * dz
    const t = l2 > 0 ? Math.min(1, Math.max(0, ((x - ax) * dx + (z - az) * dz) / l2)) : 0
    const d = Math.hypot(x - (ax + dx * t), z - (az + dz * t))
    if (d < best) best = d
  }
  return best
}

// QuickJS has no TextEncoder: the header is ASCII JSON, encoded by hand.
const utf8 = (s: string): Uint8Array => {
  const out: number[] = []
  for (let i = 0; i < s.length; i++) {
    let c = s.charCodeAt(i)
    if (c < 0x80) out.push(c)
    else if (c < 0x800) out.push(0xc0 | (c >> 6), 0x80 | (c & 0x3f))
    else if (c >= 0xd800 && c < 0xdc00 && i + 1 < s.length) {
      c = 0x10000 + ((c - 0xd800) << 10) + (s.charCodeAt(++i) - 0xdc00)
      out.push(0xf0 | (c >> 18), 0x80 | ((c >> 12) & 0x3f), 0x80 | ((c >> 6) & 0x3f), 0x80 | (c & 0x3f))
    } else out.push(0xe0 | (c >> 12), 0x80 | ((c >> 6) & 0x3f), 0x80 | (c & 0x3f))
  }
  return Uint8Array.from(out)
}

const fromUtf8 = (b: Uint8Array): string => {
  let s = ""
  for (let i = 0; i < b.length;) {
    const c = b[i++]
    if (c < 0x80) s += String.fromCharCode(c)
    else if (c < 0xe0) s += String.fromCharCode(((c & 0x1f) << 6) | (b[i++] & 0x3f))
    else if (c < 0xf0) { s += String.fromCharCode(((c & 0x0f) << 12) | ((b[i] & 0x3f) << 6) | (b[i + 1] & 0x3f)); i += 2 }
    else { const cp = ((c & 0x07) << 18) | ((b[i] & 0x3f) << 12) | ((b[i + 1] & 0x3f) << 6) | (b[i + 2] & 0x3f); i += 3; s += String.fromCodePoint(cp) }
  }
  return s
}
