/** * Measure controller (spec: issue #20 "Measure widget — geodesic distance and * area"). Capture is entirely the existing draw stack: it drives the shared * DrawController on the runtime-internal target `__onlymapjs-measure`, so the * one active-tool slot gives measure↔draw mutual exclusion for free, and the * internal prefix already exempts the geometry from ctx.layers, license gates, * telemetry, undo history, and save. This module adds only the geodesic math * hookup (via a DrawController change observer), the live badge labels (an * internal PopupLayer on the per-frame channel), and the `om-measure` readout * event the widget renders. Owned per (the draw/tooltip precedent), * driven by `measure-*` actions. */ import type { PickingInfo } from "@deck.gl/core"; import { type UnitSystem } from "./units"; import { type BaseSurfaceKind } from "./volumetrics"; import type { RuntimeCore, GizmoDragEvent } from "./runtime-core"; export type MeasureMode = "distance" | "area" | "volume"; /** * One elevation-profile sample on the `om-measure` readout. * * `vertexIndex` is present ONLY on samples that ARE one of the drawn * footprint's own vertices (0-based, in draw order; the closing sample back at * the start carries the last index). Interpolated samples omit it entirely, so * a Vega-Lite chart marks the real corners with `isValid(datum.vertexIndex)` — * see `dev/examples/features/widgets/measure-cut-fill-volume.html`. Without * this a reader cannot tell WHICH point on the map a place in the chart * corresponds to; vertex 0 is the leftmost sample, and the measure tool marks * that same point on the map with a "Start" badge. */ export interface ProfilePoint { /** Distance along the footprint's perimeter from the first vertex, in meters. */ x: number; /** Terrain elevation at this sample, in meters. */ y: number; /** 0-based index into the drawn footprint's vertices — present only on real vertices. */ vertexIndex?: number; } /** Live readout dispatched as the `om-measure` event `detail` and consumed by the widget. */ export interface MeasureReadout { mode: MeasureMode | null; units: UnitSystem; /** distance: total path length (m) + fixed-segment count. */ totalMeters: number; segments: number; /** area: |area| (m²) — null when < 3 vertices or the ring encloses a pole. */ areaMeters2: number | null; perimeterMeters: number; /** area readout is unavailable because the ring winds around a pole (documented limitation). */ poleWarning: boolean; /** * volume mode only (spec: "Cut/fill volume measurement"). `null` before the * footprint is closed — cut/fill/net are meaningless without a base * elevation. RAW geometric volumes (ground truth: "does this reach target * elevation") — deliberately UNAFFECTED by `swell`/`shrink` (see * `cutAdjustedMeters3`/`fillAdjustedMeters3` for the material-adjusted * figures) so this pair never silently changes meaning depending on * whether a density/factor attribute happens to be set. v1 samples ONE * ground elevation at the footprint's centroid and treats it as flat: cut * and fill are mutually exclusive per polygon (dragging the gizmo up is * pure fill, down is pure cut) — real per-cell terrain-relative * integration on sloped ground is a documented follow-up. */ cutMeters3?: number | null; fillMeters3?: number | null; /** * volume mode only. SIGNED, from the RAW (not material-adjusted) cut/fill: * `fillMeters3 − cutMeters3`. Positive = net material added. */ netMeters3?: number | null; /** * volume mode only (spec item D — "Reporting, units, factors"). Unsigned * RAW `cutMeters3 + fillMeters3` — distinct from the SIGNED `netMeters3`: * cut and fill are mutually exclusive per polygon in this v1 (dragging is * one direction at a time), so this only ever equals whichever of cut/fill * is currently nonzero, but it's a separate field so a "Total moved" * readout doesn't have to infer that from the sign of net — same "both * conventions labelled unambiguously" ask that motivates `netMeters3`'s * own sign. */ totalMeters3?: number | null; /** * volume mode only (spec item D). Material-adjusted volumes, standard * Bank/Loose/Compacted earthworks convention: `cutAdjustedMeters3` = raw * cut × `swell` (bank → loose/haul volume — excavating adds air voids, so * this is BIGGER than `cutMeters3`); `fillAdjustedMeters3` = raw fill ÷ * `shrink` (raw fill is already a compacted target-void volume; dividing * by a <1 shrink factor gives the BIGGER loose/borrow volume actually * needed on-site to achieve it after compaction settles it down). Each is * `null` unless its own factor was actually configured away from the * neutral default (1×) — the widget renders the "Material" section only * when at least one of these (or `cutMassKg`/`fillMassKg`) is non-null, no * separate show/hide toggle. */ cutAdjustedMeters3?: number | null; fillAdjustedMeters3?: number | null; /** volume mode only. The `swell`/`shrink` multipliers currently in effect (default 1×) — for labeling the Material section's rows with the actual factor applied, not just the adjusted number. */ swell?: number; shrink?: number; /** * volume mode only (spec item D). Mass in kg — canonical regardless of * unit system, format with `formatMass` at render time. Computed from the * RAW (not material-adjusted) cut/fill volume: swell/shrink change VOLUME * via added/removed air voids, not the actual mass of material, so basing * tonnage on the raw figure is the only mass-conserving choice — a second * "adjusted tonnage" would misrepresent the same dirt as weighing a * different amount depending on how it's packed. `null` both before a * footprint closes AND whenever the widget's `density` attribute was never * set (same "not applicable" convention as `cutMeters3`; the widget only * renders a tonnage row when this is non-null). */ cutMassKg?: number | null; fillMassKg?: number | null; /** * volume mode only (spec: issue #35 — the real per-cell grid integration). * Populated ONLY once the grid integrator has run — i.e. terrain is * active, the footprint has closed, and the covering DEM tiles resolved; * `null` before that (and forever, on a no-terrain page, where the flat * fallback math is all there is). When these are non-null, `cutMeters3`/ * `fillMeters3` above ARE the integrator's per-cell results — mixed cut * AND fill within one footprint on undulating ground, not the flat * one-or-the-other approximation — and these fields carry the published * error model the numbers were computed under: * - `cellSizeM`: the grid cell size actually used (≥ `gsdM` when the cell * budget forced coarsening — never silently finer than the data). * - `gsdM`: the DEM's native ground-sample distance at the ring's * latitude — what the ±error is quoted against. * - `cutErrorM3`/`fillErrorM3`: ± bounds, per-cell `cellArea × 1.5 × gsd` * summed on whichever side each cell landed (the spec's exact formula). * - `nodataFraction`: the share of inside-the-ring cells skipped for * missing DEM data — a consumer should distrust the totals when this is * material. */ cellSizeM?: number | null; gsdM?: number | null; cutErrorM3?: number | null; fillErrorM3?: number | null; nodataFraction?: number | null; /** * volume mode only. Which base-surface strategy produced the cut/fill * numbers (widget `base-surface` attribute; default `custom` — the * gizmo's draggable target plane). Always present in volume mode, even * while the integrator hasn't resolved (the flat fallback is a `custom` * plane by construction). */ baseSurface?: BaseSurfaceKind; /** * volume mode + `base-surface="custom"` only (the gizmo-draggable target * plane — the other strategies have no gizmo to toggle). Switches which * target SURFACE the footprint is graded to, math and rendering together: * `false` (default) — the terrain itself offset by the dragged distance * (a slope-parallel prism; uniform depth everywhere, so volume = * |offset| × area, pure cut OR pure fill, exactly zero at rest); `true` — * one genuinely level plane at centroid elevation + offset (mixed cut AND * fill on sloped ground, per-cell integrated). The drawn footprint ring * itself always stays on real terrain in both — the toggle only moves the * TARGET face. Reported here so the widget's toggle button can reflect * the controller's own truth rather than tracking a shadow copy of it. */ flatTargetPlane?: boolean; /** * volume mode only (spec item D). True while the currently-dispatched * cut/fill/net/total numbers reflect a PRIOR footprint/elevation sample, * not the one now in effect — set the instant a footprint commits (before * its one-shot terrain fetch resolves) and cleared once that fetch * completes and a fresh readout is dispatched. Distinct from * `footprintClosed`'s "sampling elevation…" case (no numbers exist yet at * all): `stale` covers the narrower "numbers exist, but they're for a * footprint/base-elevation that no longer applies" window, e.g. right * after committing a SECOND footprint while the first's numbers are still * the last thing dispatched. */ stale?: boolean; /** * volume mode only. Disambiguates the two states that otherwise look * identical from the numbers alone (segments >= 3, areaMeters2 set, * cutMeters3 still null): still sketching a 3+-vertex footprint that * hasn't been double-clicked closed yet, vs. just closed and waiting on * the one-shot terrain elevation sample to resolve. The widget uses this * to avoid claiming "sampling elevation…" while the user is still * dragging the rubber-band preview around. */ footprintClosed?: boolean; /** * volume mode only, and only when the measure widget's `profile` attribute * is set (spec item E — "Elevation profile"; not a mode of its own — see * `setProfileEnabled`'s doc comment). `PROFILE_SAMPLE_COUNT` evenly-spaced * `{x: distance-from-start meters, y: elevation meters}` points around the * CLOSED footprint's own perimeter, sampled once alongside the existing * per-vertex elevation fetch when the footprint closes — not resampled on * drag, since dragging only changes `heightOffsetM`, not the ground ring. * `null` (the same "not applicable yet" convention as `cutMeters3`/ * `fillMeters3`) before a footprint has closed, or whenever `profile` * isn't set; absent entirely (not even `null`) in `distance`/`area` mode, * whose own dispatches never set this field at all. Either way, a * `dynamic-chart` widget reading it sees "not a real array" and leaves * whatever it last rendered alone — that's the whole "freeze" mechanism. */ profileSeries?: ProfilePoint[] | null; } export declare class MeasureController { private readonly mapEl; private readonly core; private mode; private units; private baseElevationM; private heightOffsetM; private lastCommittedCount; private committedRing; /** * One terrain elevation sample per `committedRing` vertex (same order), * used for the VISUAL geometry (the extruded prism's base ring + the cut * guide lines) so the footprint's corners hug the real ground even where * it differs from `baseElevationM`'s single centroid sample. The cut/fill * VOLUME MATH still deliberately uses that one flat `baseElevationM` * delta (documented v1 simplification, unaffected) — this only fixes the * PICTURE: reported as "certain sides of the base floating above the * terrain rather than draping onto it," which is the expected result of * rendering a perfectly flat plane under a footprint whose corners sit at * genuinely different real elevations away from the one sampled point. */ private vertexElevationsM; /** * The committed ring's perimeter DENSIFIED to `GROUND_RING_SAMPLES` points, * each with its own terrain sample — the ground-facing edge of everything * rendered (extruded base ring, flat-mode wall bottoms). Corner-only rings * rendered straight wall bottoms across valleys (visible daylight between * the prism and the ground). Sampled once per commit in the SAME * heightfield batch as the vertex/centroid/profile points. The footprint * INTERIOR ground face is the terrain-draped VOLUME_DRAPE_ID layer, which * conforms exactly by construction. Null before terrain resolves — the * drawn corners stand in. */ private densifiedGroundRing; /** Drag-invariant visual geometry derived from the current ground ring (`buildVolumeVisual` runs per drag frame; stable `data` identity lets deck skip re-parsing/re-draping the unchanged footprint). Keyed by the `ground` array's identity. */ private volumeVisualCache; /** Whether VOLUME_FLAT_ID currently holds faces — an empty-clear of an already-empty layer still costs a full patch/rebuild, and `buildVolumeVisual` runs per drag frame on the (default) non-flat path. */ private flatFacesLive; private centroidLngLat; private gizmoDrag; /** * "Flat target plane" toggle (a widget button, not an authored attribute * — a live per-interaction preference, same category as the clip-box * gizmo's own `editing` flag, not something a page author configures * upfront). See `MeasureReadout.flatTargetPlane`'s own doc comment: it * switches the target-surface semantics — parallel-offset vs level plane * — so both the integration and the rendered prism change with it. */ private flatTargetPlane; /** * measure-config action, set from the widget's own `profile` attribute * (mirrors `draw-config`'s autosave — a widget-level SETTING, not tied to * mode-switching). Deliberately NOT a fourth measure mode: there's no * separate line to draw — the profile is always the boundary of whatever * footprint volume mode's own polygon already is, resampled live as * vertices are added while still sketching and once more, authoritatively, * on commit (see `recomputeVolume`/`resampleLivePreviewProfile` and * `onFootprintCommitted`). Persists across mode switches (like autosave) * rather than resetting — it's a standing "when you draw/close a volume * footprint, also do this" preference. */ private profileEnabled; private profileSeries; /** The vertex count `profileSeries` was last live-resampled at while sketching — gates resampling on a REAL vertex being added (a click), not on hover, since it touches the network. Unrelated to `lastCommittedCount`, which gates the separate final-commit sample. */ private lastProfileVertexCount; /** Generation token — a live-preview resample that resolves AFTER a newer vertex has already superseded it must not overwrite `profileSeries` with stale (shorter-path) data. Two clicks close enough together for their tile fetches to overlap and resolve out of order is the exact scenario this guards. */ private profileFetchGeneration; /** Canonical kg/m³ (converted from the author's t/m³ or lb/yd³ at set-time — see `densityToKgM3`); `null` = tonnage not reported. */ private densityKgM3; /** Bulking multiplier applied to CUT (excavated material occupies more volume loose than in situ). */ private swell; /** Compaction multiplier applied to FILL (placed/compacted material occupies less volume than loose). */ private shrink; /** Cut/fill below this volume (m³, post swell/shrink) reports as zero — filters drag noise/float jitter near h=0 from reading as a nonzero result. */ private deadbandM3; /** True while the dispatched cut/fill/net/total reflect a stale (pre-fetch or prior-footprint) elevation sample — see `MeasureReadout.stale`'s doc comment. */ private volumeStale; /** Base-surface strategy (widget `base-surface` attribute; `custom` = the gizmo's draggable target plane, the v1-compatible default). */ private baseSurface; /** The latest grid-integration result — `null` until the integrator has run for the CURRENT footprint (no terrain, still in flight, or it failed → the flat fallback math stands in). */ private volResult; /** The cached per-cell grid (custom base only) — gizmo drags re-sum it synchronously via `reintegrateCustomBase` instead of re-running the whole pipeline. */ private volGrid; /** Bounds heightfield + boundary samples cached per footprint, so a `base-surface` switch re-integrates without re-fetching tiles. */ private volHf; private volBoundary; /** Generation token — an integration resolving AFTER the footprint it was started for is gone (cleared, replaced, mode-switched) must not clobber the new state. */ private volGeneration; constructor(mapEl: Element, core: RuntimeCore); /** measure-config action. */ setProfileEnabled(enabled: boolean): void; /** * measure-config action (spec item D). Each field left `undefined` keeps * its current value — the widget always emits all four together, but a * future caller updating just one factor shouldn't have to resend the * others. `density` is interpreted in whatever unit system is ACTIVE right * now (`this.units`) and canonicalized to kg/m³ immediately, so a later * units toggle doesn't reinterpret it (see `densityToKgM3`'s doc comment). * Non-finite or non-positive `density`/`swell`/`shrink` are ignored (fall * back to "not set"/1×) rather than propagating NaN/0 into every volume * number; a non-finite or negative `deadband` falls back to 0 (no deadband). */ setVolumeFactors(opts: { density?: number; swell?: number; shrink?: number; deadband?: number; }): void; /** * measure-config action (spec: issue #35 — "base-surface picker"). A * widget-level setting like the factors above. Switching strategies on an * ALREADY-COMMITTED footprint re-integrates against the cached heightfield * and boundary samples — no tile re-fetch — and flags the readout stale * for the (short) in-flight window. `custom` is the gizmo's draggable * target plane; every other strategy derives the base from the footprint's * own boundary, so the gizmo disappears (there's no plane to drag). */ setBaseSurface(kind: BaseSurfaceKind): void; isActive(): boolean; /** measure-mode action. `distance`→line capture, `area`/`volume`→polygon capture, `null`→off. */ setMode(mode: MeasureMode | null): void; /** * Reset volume-tool state (spec: "Cut/fill volume measurement") — on mode * change, clear, or teardown. `lastCommittedCount` baselines against the * session's ACTUAL current feature count, not 0: distance/area/volume all * share one DrawSession (same MEASURE_TARGET), and a mode switch never * clears its committed features (only `pending`/`cursor` reset — see * draw.ts's own `setMode`). Baselining at 0 made entering volume mode with * ANYTHING already committed (a shape drawn in area mode first, or a prior * volume attempt) misread as "just committed" on the very next recompute — * building the extrusion/gizmo from stale leftover geometry before the * user had drawn anything new. `featureCount` reads the session directly * (not `getDrawData`'s shared cross-map mirror, which can itself carry a * DIFFERENT map's leftover data under the same target id). */ private resetVolumeState; /** * measure-flat-target-plane action (the widget's own toggle button — see * `flatTargetPlane`'s field doc comment). The toggle changes the target * SURFACE the prism is graded to — parallel-to-terrain offset vs one level * plane — so it re-integrates the cached grid AND re-renders: two * different shapes report two different volumes. */ setFlatTargetPlane(flat: boolean): void; /** * Re-sums the cached per-cell grid under the CURRENT target semantics — * `flatTargetPlane` picks which surface the terrain is differenced * against: one level plane at centroid + offset (`reintegrateCustomBase`), * or the terrain itself shifted by the offset (`reintegrateParallelOffset`, * the default — matching the default-rendered slope-parallel prism, and * yielding exactly zero at h=0 instead of "grade everything to the * centroid's elevation"). Synchronous arithmetic over the cached samples — * no re-sampling, no worker round-trip — so it's safe on every drag frame * and on the toggle itself. */ private resumCachedGrid; /** measure-units action. Re-render labels + readout in the new system (from the committed shape). */ setUnits(units: UnitSystem): void; /** * measure-clear action. Drop the geometry and reset the readout; tool * stays selected. `resetVolumeState()` must run AFTER `draw.clear()` (not * before) — it reads the draw controller's own feature count as the new * `lastCommittedCount` baseline, which is only correct once the clear has * actually happened. But `draw.clear()`'s own observer fires FIRST, with * `profileSeries` (and the rest of the volume-mode fields) still at their * PRE-clear values — previously the only reset was on a MODE SWITCH, so a * Clear left the elevation-profile chart showing the just-deleted * footprint's stale data forever. The explicit `recompute()` below is a * second, corrected dispatch that supersedes that stale one. */ clear(): void; /** * Forwarded from om-map's own `onViewportChange` (any camera move, not * just gizmo drags/commits) — the gizmo's pixel-locked size needs to * re-derive its world-space meters on every zoom change, not only when * something ELSE triggers a `buildVolumeVisual` call, or it'd only snap to * the correct size the next time the user happens to drag or redraw. */ onViewportChanged(): void; private recompute; /** The path/ring currently being measured: the in-progress preview (+ live cursor), else the last committed shape. */ private activeCoords; /** Shared by `distance` and `profile` — both are just a line measured live as vertices are added; profile additionally resamples elevation along it. */ private lineLabelsAndTotal; private recomputeDistance; private recomputeArea; /** * Detects the footprint's close (a feature-count diff against THIS map's * own DrawSession — see DrawSession.count's doc comment for why that beats * the shared `getDrawData` mirror — rather than a new DrawController event * kind, matching how `activeCoords` already duplicates a bit of logic * rather than asking DrawController for something new) and kicks off the * one-shot elevation sample. While still sketching, area/perimeter update * live exactly like area mode; cut/fill/net stay null until closed. */ private recomputeVolume; /** * The async half of the live-preview path above — mirrors * `onFootprintCommitted`'s own sampling but against the CURRENT (still * open) preview PATH, left open (not closed back to the first vertex) — * closing only makes sense once there's an actual footprint, which is * `onFootprintCommitted`'s job. With 1 vertex and no cursor yet this is a * single point at distance 0 (`resamplePath`'s own documented behavior for * a 1-point input); with 2+ it's a real, growing line. Re-dispatches off * the SAME `coords` snapshot passed in (not a fresh `recompute()`, which * needs a live `DrawSession` this method doesn't have) — if the user has * moved on by the time this resolves, the vertex-count gate above already * queued a fresh call for that, or the shape has since closed and * `onFootprintCommitted` owns the readout instead (guarded by the mode * check below either way). */ private resampleLivePreviewProfile; private onFootprintCommitted; /** * Fetch the covering DEM tile set for the committed footprint's bbox, * sample the boundary, and run the grid integrator (worker-offloaded via * `runVolumetrics`; the pure fallback computes identically). Results land * in `volResult`/`volGrid` unless the footprint changed underneath the * async work (generation token) — the flat v1 math remains the standing * fallback whenever this leaves `volResult` null (no terrain, fetch * failure, degenerate ring). */ private runVolumeIntegration; /** * Cut/fill/net/total + material-adjusted volumes + tonnage (spec item D — * "Reporting, units, factors"). The RAW cut/fill come from the per-cell * grid integrator (`volResult` — spec: issue #35) whenever it has resolved * for the current footprint: real terrain-relative integration, mixed cut * AND fill within one polygon on undulating ground, with the published * error model riding along. Before it resolves — or forever, with no * terrain — the v1 flat single-elevation math stands in (one centroid * sample × area, one side at a time), with the `stale` flag covering the * in-flight window. Either way: `cutMeters3`/`fillMeters3`/`netMeters3`/ * `totalMeters3` are RAW geometric volumes — `deadband` zeroes out either * side of a figure too small to be meaningful (drag jitter near h=0), but * `swell`/`shrink` never touch them, so these four always answer "does the * ground reach target elevation" regardless of whether a density/factor * attribute happens to be configured. `cutAdjustedMeters3`/ * `fillAdjustedMeters3` are the SEPARATE material-adjusted figures (Bank/ * Loose/Compacted convention — see their own doc comments), derived from * the raw (deadband-applied) cut/fill, not from area×height directly, so a * zeroed-by-deadband side stays zero in its adjusted form too. Tonnage is * likewise computed from the raw volume (mass-conserving — see * `cutMassKg`'s doc comment). */ private recomputeVolumeReadout; /** * Rebuilds the footprint + gizmo from current baseElevationM/heightOffsetM. * Called on commit and on every gizmo-drag frame. At h=0 and while filling * (h>0), the DRAPED layer and the extruded prism are mutually exclusive — * one flat, one solid, never both populated. While CUTTING (h<0) the * drape layer stays populated ALONGSIDE the extruded prism: the prism * alone represents the excavation, but its own top face sits at the real * terrain surface where it's unreliable to see (near-coplanar with the * opaque terrain mesh) — the drape layer is what reliably keeps the * original green footprint visible for a cut, matching how it already * reads for fill/at-rest. */ /** Clear the flat-target faces layer only when it actually holds any — see `flatFacesLive`. */ private clearFlatFaces; private buildVolumeVisual; /** * Gizmo drag lifecycle — from om-map's forwarded RuntimeCore onGizmoDrag* * callbacks (raw deck.gl PickingInfo, not a resolved Selection). Converts * vertical screen-pixel delta to a world elevation delta by linearizing * the viewport's projection once at grab-time (project the anchor at two * known elevations 100m apart) — a local approximation valid for the * gesture's duration since dragPan is suspended for the same duration, so * the camera can't move underneath it. */ handleGizmoDragStart(info: PickingInfo, event: GizmoDragEvent): void; handleGizmoDrag(info: PickingInfo): void; handleGizmoDragEnd(): void; private emptyReadout; private dispatch; private ensureLayers; /** * Volume mode's internal layers (spec: "Cut/fill volume measurement"). * TWO layers carry the footprint, only one populated at a time, mirroring * a real-world reference implementation's own documented fix for the same * problem (mutating `terrain`/extensions on a LIVE layer corrupts its * shader pipeline — the fix is a distinct layer id per terrain mode, so * deck tears down and rebuilds cleanly instead of patching in place): * - VOLUME_DRAPE_ID: flat 2D ring, `terrain="drape"` — used exactly while * `heightOffsetM === 0`, so the "ready" footprint sits seamlessly on * the real terrain surface (same as the in-progress sketch preview), * instead of floating above/sinking below it as a lone flat plane * would on anything but dead-flat ground. * - VOLUME_ID: extruded prism, `terrain="off"`, explicit `[lng,lat,z]` at * each vertex's own sampled elevation (`vertexElevationsM` — not one * repeated flat value, so the base ring hugs real ground undulation * instead of visibly floating on sides away from the centroid sample) * — used once the gizmo is dragged away from zero, when the shape * needs a real, undistorted prism (a truly draped ring's per-vertex * heights would twist the side walls). * - VOLUME_CUT_GUIDES_ID: a LineLayer, one vertical strut per footprint * vertex, populated only while cutting (heightOffsetM < 0). A cut's * entire prism sits below the original ground, so it's fully occluded * by the opaque terrain mesh from a normal view — `depthTest: false` * (patched once below, not attribute-expressible) keeps these struts * visible through it, the only way to SEE how deep a cut goes short of * reading the numeric Cut readout. * Plus the draggable height gizmo: two SimpleMeshLayers (GIZMO_SHAFT_ID a * cylinder, GIZMO_CONE_ID a cone reused for both the top cap and the * inverted bottom one) sharing the static unit meshes built once at module * load. All start with empty data; `buildVolumeVisual` fills in whichever * pair is live on commit/drag. */ private ensureVolumeLayers; /** * Numbered vertex badges for the profile, in draw order. Only meaningful * with `profile` on (nothing else references vertex order), and only once a * ring exists — so it clears itself in every other state rather than * lingering on a deleted footprint. */ private buildProfileVertexMarkers; private removeLayers; } export declare function getMeasureController(mapEl: Element, core: RuntimeCore): MeasureController; export declare function peekMeasureController(mapEl: Element): MeasureController | undefined;