/** * Volumetric integration core (spec: issue #35 "Volumetric measurement — * cut/fill against a base surface, with a published error model"; grid * design per §C of the acceptance spec: C1 tangent-plane grid, C2 bulk * heightfield sampling, C3 scanline point-in-polygon). * * PURE by design — no DOM, no fetch, no globals. This module runs both on * the main thread and inside `volumetrics-worker.ts` (the issue's "all of it * in a worker"), so the heightfield arrives as already-decoded pixel buffers * (`HeightfieldTiles`, produced by terrain-heightfield.ts's DOM half) and * everything here is arithmetic over them. * * The three load-bearing choices, per the spec: * * C1 — the grid lives in a LOCAL TANGENT-PLANE metric frame at the ring * centroid (x = R·cos(φ₀)·Δλ, y = R·Δφ, on the same measure sphere as * geodesy.ts), NOT in lng/lat ("cell size 0.25m" is meaningless in degrees) * and NOT in uncorrected Web Mercator (linear scale sec(φ) → area/volume * error sec²(φ), +100% at 45° latitude). Equirectangular error over a * stockpile-sized extent (≤ a few km) is ~10⁻⁵ — far below the DEM's own * z noise. * * C2 — the heightfield is bulk-decoded ONCE upstream and bilinear-sampled * in memory here; never an async per-cell fetch (a fine grid is 10⁵–10⁶ * cells — one network call per cell is dead on arrival). * * C3 — point-in-polygon by SCANLINE, not per-cell ray-cast: per grid row, * intersect ring edges with the row's y, sort crossings, fill spans — * O(rows × edges + inside cells) instead of O(cells × edges), and it emits * exactly the inside-cell runs the integrator iterates. Fractional * boundary-cell coverage is deliberately skipped (documented; the market * leader doesn't bother either). */ import type { LngLat } from "./geodesy"; export interface ElevationDecoderSpec { rScaler: number; gScaler: number; bScaler: number; offset: number; } /** One decoded DEM tile — plain buffers so the whole set can postMessage to the worker (Uint8ClampedArray is structured-cloneable, and its ArrayBuffer transferable). */ export interface HeightfieldTile { x: number; y: number; width: number; height: number; data: Uint8ClampedArray; } /** The serializable heightfield: every decoded tile at one zoom + the decoder. Produced by terrain-heightfield.ts, consumed here (main thread or worker). */ export interface HeightfieldTiles { zoom: number; tiles: HeightfieldTile[]; decoder: ElevationDecoderSpec; } /** * Bilinear heightfield sample at `lngLat`, correct across TILE SEAMS. * * Works in global fractional pixel space at the heightfield's zoom, in each * tile's NATIVE resolution scaled to a common 256-per-tile grid: the four * pixels around the sample point may live in up to four different tiles, and * each is looked up in whichever tile owns it. Corners whose tile is missing * or failed drop out and the remaining weights renormalize — a point right * at the edge of available data degrades gracefully toward nearest-available * rather than snapping to `null`; only a point with NO available corner * returns `null` ("no data", the caller's skip-and-count contract). * * Native-resolution note: tiles can ship at 512px. Sampling positions are * computed in 256-grid units (matching `tilePixel`'s convention) and each * corner reads the native pixel COVERING that 256-grid cell — bilinear over * the 256 grid is slightly coarser than the 512 tile could support, but is * consistent across mixed-resolution tile sets and matches the GSD the error * model reports. */ export declare function sampleBilinear(hf: HeightfieldTiles, lngLat: LngLat, tileIndex?: Map): number | null; /** Key the tile list for O(1) lookup — build once per batch of samples, pass into `sampleBilinear` (it rebuilds per call otherwise). */ export declare function buildTileIndex(hf: HeightfieldTiles): Map; /** * Base-surface strategies (spec: "Multiple Base Surfaces"): * - `custom`: a constant elevation — the interactive gizmo's target plane * (drag sets `customZ`), and the direct successor of the v1 flat math. * - `plane`: least-squares plane fit to the ring's own boundary elevations — * the right base for a stockpile on visibly sloped ground. * - `lowest` / `highest` / `average`: constant at the boundary's min/max/mean * elevation. * - `triangulated`: boundary TIN (Delaunay over densely-resampled boundary * points, interpolated per cell) — the industry default for stockpiles, * since it follows the toe of the pile all the way around. */ export type BaseSurfaceKind = "custom" | "plane" | "lowest" | "highest" | "average" | "triangulated"; export declare const BASE_SURFACE_KINDS: readonly BaseSurfaceKind[]; export declare function parseBaseSurface(raw: string | null | undefined): BaseSurfaceKind | null; export interface BaseSurfaceSpec { kind: BaseSurfaceKind; /** Required for `kind: "custom"` — the target plane's elevation in meters. */ customZ?: number; } export interface VolumetricsRequest { /** Footprint ring, lng/lat, open or closed (normalized internally). */ ring: LngLat[]; heightfield: HeightfieldTiles; base: BaseSurfaceSpec; /** The measure sphere radius (pass `getMeasureRadiusMeters()` — a parameter so this module stays free of mutable global state, worker included). */ radiusM: number; /** * Boundary elevation samples for the non-custom base surfaces — lng/lat * plus the elevation the caller sampled there (they ride the same * heightfield; the caller already has them for the profile). Converted to * the internal tangent frame here, so the caller never has to reproduce * this module's frame math. Required for `plane`/`lowest`/`highest`/ * `average`/`triangulated`; ignored for `custom`. */ boundary?: Array<{ position: LngLat; z: number; }>; /** Cell-count budget — cell size grows from the DEM's GSD until the grid fits. Default 1.5M. */ maxCells?: number; /** Return the per-cell grids (`zTerrain`, `inside`) for cheap re-integration on gizmo drag + the heat map. */ includeGrid?: boolean; } export interface VolumetricsGrid { /** Tangent-frame origin (the ring centroid), lng/lat — for projecting the grid back to the map (heat map bounds). */ originLngLat: LngLat; /** Grid extent: cell (i, j) center is at x = x0 + (i + 0.5)·cellSize, y = y0 + (j + 0.5)·cellSize in tangent meters. */ x0: number; y0: number; nx: number; ny: number; cellSizeM: number; /** Terrain elevation per cell, row-major (j·nx + i). NaN = no data. */ zTerrain: Float32Array; /** Base elevation per cell (same layout). NaN outside the ring. */ zBase: Float32Array; /** 1 = inside the ring, 0 = outside. */ inside: Uint8Array; } export interface VolumetricsResult { cutM3: number; fillM3: number; netM3: number; totalM3: number; /** The actual cell size used (≥ the DEM's GSD when the budget forced coarsening). */ cellSizeM: number; /** The elevation source's native ground-sample distance at the ring's latitude — what the error model is quoted against. */ gsdM: number; insideCells: number; /** Inside cells skipped for lack of DEM data (missing/failed tiles). */ nodataCells: number; /** ± one-sided error bounds (spec: per-cell `cellArea × 1.5 × GSD_data`, summed separately per side). */ cutErrorM3: number; fillErrorM3: number; /** How the base surface resolved (constant z, plane params) — provenance for the readout. */ base: { kind: BaseSurfaceKind; z?: number; plane?: { a: number; b: number; c: number; }; }; grid?: VolumetricsGrid; } /** * The grid integrator. Everything in the tangent frame at the ring centroid; * per inside cell: z_terrain from the bilinear heightfield, z_base from the * strategy, signed delta accumulated into cut (terrain ABOVE base — material * to remove) or fill (terrain BELOW base — material to add). Mixed cut+fill * within one polygon is the entire point of the per-cell design — the * v1 flat math this replaces could only ever be one or the other. */ export declare function computeVolumetrics(req: VolumetricsRequest): VolumetricsResult; /** * Re-integration for the DEFAULT (non-flat-target) `custom` semantics: the * target surface is the terrain itself offset by one constant depth/height — * "grade this footprint down/up by N meters from wherever the ground is." * Every valid cell moves by exactly `offsetM`, so the result is pure cut OR * pure fill (a parallel offset can never produce both), each cell * contributing |offset| × cell area — closed-form over the SAME per-cell * counts and GSD error model `reintegrateCustomBase` accumulates, so the two * stay directly comparable when the flat-target toggle switches between * them. `base.z` is deliberately absent: no single plane elevation exists. */ export declare function reintegrateParallelOffset(grid: VolumetricsGrid, offsetM: number, gsdM: number): Omit & { base: { kind: "custom"; }; }; /** * Cheap re-integration for the interactive flat-target case: the gizmo drag * changes ONLY the `custom` base plane's z, so a cached grid re-sums in one * arithmetic pass over the terrain samples — no re-sampling, no scanline, no * worker round-trip needed per drag frame. Everything except the base offset * (cell size, GSD, error basis) is inherited from the original result. */ export declare function reintegrateCustomBase(grid: VolumetricsGrid, customZ: number, gsdM: number): Omit & { base: { kind: "custom"; z: number; }; };