Skip to content

Line-of-Sight and Area-of-Sight terrain analysis#122

Merged
ThomasHalwax merged 10 commits into
mainfrom
feature/line-of-sight
Jul 6, 2026
Merged

Line-of-Sight and Area-of-Sight terrain analysis#122
ThomasHalwax merged 10 commits into
mainfrom
feature/line-of-sight

Conversation

@ThomasHalwax

Copy link
Copy Markdown
Member

Summary

Adds two terrain-analysis tools: Line-of-Sight (observer→target sight line with blocker detection) and Area-of-Sight (real-time viewshed). Both are backed by a reworked elevation service that samples deterministically from Mapbox-Terrain-RGB tiles, and both integrate into the standard feature pipeline like measurements.

The AoS engine runs the R2 viewshed algorithm as a WebGPU compute shader — one thread per ray — computing a 10 km radius at 10 m resolution in single-digit milliseconds, so the viewshed follows the cursor in real time during placement (benchmarked against single/multi-threaded CPU and a naive WebGL2 fragment shader; the CPU implementation remains as reference and fallback).

Changes

  • Elevation service: fixed, data-driven analysis zoom (≤ 15 m/cell) instead of view zoom — results are identical across zoom levels and reloads; promise-cached parallel tile fetch with Float32 decoding; getGrid(extent) stitches tile-aligned elevation grids; handles TileJSON sources that are still loading their metadata
  • Line-of-Sight: click observer, live sight line under the cursor, click target to persist; earth-curvature/refraction-corrected profile analysis; visible/blocked segments, first-blocker and max-range markers rendered by an async style orchestrator
  • Area-of-Sight: viewshed follows the cursor in real time, click to place; circular green/red visibility raster with dashed radius rim; WebGPU compute engine with serialized GPU access and CPU fallback; no-data cells neither block nor show
  • Pipeline integration: LoS/AoS documents are plain GeoJSON features in featureSource — delete, undo, hide/show, lock, vertex modify (dragging endpoints recomputes live) and selection come from the standard machinery; pre-pipeline documents are migrated on startup
  • Placement settings: arrow keys adjust observer/target heights (LoS) and radius/heights (AoS) during placement with OSD feedback and live preview; Escape cancels; last-used values persist per project and seed the next placement; placed objects remain editable via the properties panel
  • Sidebar/search: both types are indexed and listed under the measurements scope with name, distance/radius description, rename and tagging
  • Unit tests for elevation grid stitching, the CPU viewshed reference, the style orchestrators and the TileJSON loading regression

Adds a new "Line of Sight" entry to the Measure menu. After placing an
observer with the first click, the LoS recomputes on every pointermove
against the Mapbox-RGB terrain tile source and renders the visible
segment in green, the blocked segment as a dashed red line and the
first blocker as a red diamond. Distance, eye-height delta and
first-blocker distance are shown in the OSD.

Range is clamped to 10 km (clip marker drawn at the cap); the second
click finalises the LoS and leaves it on the map, so multiple
Line-of-Sights can coexist in a session. Earth curvature and
atmospheric refraction (k=0.13) are applied unconditionally to keep
results realistic at the relevant distances.

The command is gated on terrain availability and WebGL2 support (the
latter is a prerequisite for the planned Area-of-Sight feature and is
checked here for consistency). Observer/target heights default to
1.70 m AGL; an editor for these plus persistence will follow in a
separate change.
Adds a LOS scope (los:{uuid}) to the id catalogue and rewires the
line-of-sight module to be store-driven. Finalised LoS results are
written to the store, restored on app start, and rebuilt incrementally
on subsequent put/del operations.

The in-progress (live-preview) overlay is unchanged. On finalisation
the live features are handed over directly to the persistent map so
there is no flicker, and a fresh losId is inserted into the store. A
small race-safe initial-load path waits for a terrain layer to be
available before rendering the persisted set.

Heights are still fixed to the 1.70 m defaults; the per-feature
height editor will land alongside selection support in a separate
change.
Selecting a finalised LoS on the map (click) now opens a Properties
panel with editable observer/target height fields and a read-only
distance display. Edits flow through store.update, our batch handler
detects the change and rebuilds the visual representation; the
existing clipboard-delete pipeline removes a selected LoS without
any extra wiring.

All sub-features of a LoS (observer point, visible/blocked segments,
blocker diamond, clip marker) carry the same losId as OpenLayers
feature id, so a click on any of them selects the whole document.
The LoS layer is tagged selectable; the map-wide Select interaction
is suspended while the placement tool is live so the first/second
click never doubles as a feature select.

Multi-select shows M/V in the height fields and accepts a value to
apply to all selected LoS at once, mirroring how the other property
panels behave.
- ElevationService samples at a data-driven analysis zoom (finest zoom
  resolving <= 15 m/cell) instead of the current view zoom: LoS and
  elevation profile results no longer change with zoom or after reload
- promise-based tile cache deduplicates concurrent downloads; tiles are
  decoded once into Float32 elevations
- profileAlongLine fetches required tiles in parallel, then samples
  synchronously
- getGrid(extent) stitches a tile-aligned Float32 elevation grid
  (foundation for Area-of-Sight), coarsening zoom to fit a cell budget
- drop the WebGL2 gate from the LoS command (LoS is CPU sampling only)
- R2 viewshed engine: WebGPU compute shader (one thread per ray),
  10 km @ 10 m in single-digit ms; viewshedCPU as reference and
  fallback when no GPU adapter is available
- tool follows the cursor with a live preview (latest-wins), click
  places the observer and persists the document in the aos: scope
- visibility mask rendered as circular raster overlay (ImageCanvas),
  green/red, no-data cells stay transparent and never block
- selectable observer point with properties panel for radius (default
  2500 m, max 10 km) and observer/target heights; changes recompute
- map clicks no longer deselect LoS/AoS results
LoS and AoS documents are now plain GeoJSON features flowing through
featureSource — one feature per document, like measure. This replaces
the parallel rendering paths (private layers, own store sync, multiple
OL features sharing one id) and brings the standard machinery for free:
delete, undo, hide/show, lock, vertex modify and selection.

- LoS: LineString observer→target; the async sight-line analysis lives
  in the style orchestrator (ol/style/los.js) — renders as pending line
  until the profile arrives, recomputes on geometry/height changes and
  once terrain becomes available (bridge: ol/style/losCompute.js)
- AoS: Point observer with radius/height properties; observer point and
  radius rim are pipeline styles (ol/style/aos.js), the visibility
  raster stays in the interaction and now mirrors hidden state
  (including temporary reveal while highlighted in search)
- sidebar/search: options/documents handlers for both scopes with name,
  distance/radius description, rename and tagging
- properties panels operate on GeoJSON properties
- pre-pipeline documents are migrated to GeoJSON on startup
- LoS: arrow up/down adjusts observer height, shift+arrows target
  height while placing; live preview recomputes, values shown in OSD
- AoS: arrow up/down adjusts radius (250 m steps), shift/alt+arrows
  observer/target height; preview recomputes on change
- Escape cancels an active placement
- last-used values persist per project (session store) and become the
  defaults for the next placement; placed objects remain editable via
  the properties panel
getTileGrid() returns null until a TileJSON source has fetched its
metadata. setSource treated "terrain layer present" as "terrain ready"
and the AoS initial load crashed on the null tile grid at startup.

- setSource returns false while the tile grid is not available yet
- new onTerrainReady(map, attempt) retries when a layer is added AND
  when a pending source finishes loading; LoS/AoS use it for computer
  registration and initial document rendering
- getGrid/profileAlongLine/elevationAt guard against a missing grid
The emitter dispatches handlers via setImmediate, so the OSD clear from
idle tools' command/draw/cancel resets landed after the freshly shown
placement hint and erased it — the hint only reappeared after the first
arrow-key press.

- reset() clears the OSD only when the tool was actually active
- the hint is re-asserted on pointer move (LoS placing phase, AoS
  preview) so it survives any remaining dispatch-order race
The measurements scope switch now covers '@measure @Los @aos' (scope
query tokens combine with OR). The active-state check handles
multi-token switches: active when all of its tokens are part of the
current search scope.
@ThomasHalwax
ThomasHalwax merged commit 084fe65 into main Jul 6, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant