From eddd9bb7b66768b464a4e6f4fb17fd132befbcba Mon Sep 17 00:00:00 2001 From: Thomas Halwax Date: Mon, 6 Jul 2026 20:13:14 +0200 Subject: [PATCH 1/5] feat(siting): observer placement optimization for an area MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Given an area (drawn ad hoc or a selected polygon feature), find a small set of observer positions inside it whose combined viewsheds maximize coverage of the area — greedy max-coverage over candidate viewsheds ((1 - 1/e) approximation of the NP-hard set-cover optimum). - candidates: local terrain maxima inside the polygon, thinned by non-maximum suppression (250 m spacing), lattice fallback for flat terrain; per-candidate viewsheds via the shared WebGPU engine - polygon rasterization by even-odd scanline (holes supported) - stops at 95 % coverage, 8 observers, or < 1 % marginal gain - radius and heights come from the last-used AoS settings - results are inserted as regular AoS documents (one undo step): viewsheds render through the standard pipeline, each observer stays individually editable; Escape cancels draw or a running computation --- src/renderer/components/Toolbar.js | 3 +- src/renderer/components/map/Map.js | 2 + src/renderer/model/CommandRegistry.js | 2 + .../model/commands/ObserverSitingCommands.js | 50 ++++ .../ol/interaction/observer-siting/index.js | 244 ++++++++++++++++++ .../ol/interaction/observer-siting/solve.js | 167 ++++++++++++ test/renderer/observer-siting-test.js | 173 +++++++++++++ 7 files changed, 640 insertions(+), 1 deletion(-) create mode 100644 src/renderer/model/commands/ObserverSitingCommands.js create mode 100644 src/renderer/ol/interaction/observer-siting/index.js create mode 100644 src/renderer/ol/interaction/observer-siting/solve.js create mode 100644 test/renderer/observer-siting-test.js diff --git a/src/renderer/components/Toolbar.js b/src/renderer/components/Toolbar.js index 8a119bb9..b44788e4 100644 --- a/src/renderer/components/Toolbar.js +++ b/src/renderer/components/Toolbar.js @@ -54,7 +54,8 @@ export const Toolbar = () => { commandRegistry.command('MEASURE_CIRCLE'), commandRegistry.command('ELEVATION_PROFILE'), commandRegistry.command('LINE_OF_SIGHT'), - commandRegistry.command('AREA_OF_SIGHT') + commandRegistry.command('AREA_OF_SIGHT'), + commandRegistry.command('OBSERVER_SITING') ] const replicationCommands = [ diff --git a/src/renderer/components/map/Map.js b/src/renderer/components/map/Map.js index beac4323..eb3cf2ea 100644 --- a/src/renderer/components/map/Map.js +++ b/src/renderer/components/map/Map.js @@ -18,6 +18,7 @@ import shapeInteraction from '../../ol/interaction/shape-interaction' import elevationProfile from '../../ol/interaction/elevation-profile' import lineOfSight from '../../ol/interaction/line-of-sight' import areaOfSight from '../../ol/interaction/area-of-sight' +import observerSiting from '../../ol/interaction/observer-siting' import print from '../print' import './Map.css' import './ScaleLine.css' @@ -87,6 +88,7 @@ export const Map = () => { elevationProfile({ services, map }) lineOfSight({ services, map }) areaOfSight({ services, map }) + observerSiting({ services, map }) // Expose a function to query the current map resolution. services.getMapResolution = () => map.getView().getResolution() diff --git a/src/renderer/model/CommandRegistry.js b/src/renderer/model/CommandRegistry.js index b2bc5080..20d4534a 100644 --- a/src/renderer/model/CommandRegistry.js +++ b/src/renderer/model/CommandRegistry.js @@ -9,6 +9,7 @@ import shapeCommands from './commands/ShapeCommands' import elevationProfileCommands from './commands/ElevationProfileCommands' import lineOfSightCommands from './commands/LineOfSightCommands' import areaOfSightCommands from './commands/AreaOfSightCommands' +import observerSitingCommands from './commands/ObserverSitingCommands' import printCommands from './commands/PrintCommands' import replicationCommands from './commands/ReplicationCommands' @@ -27,6 +28,7 @@ export function CommandRegistry (services) { Object.assign(this, elevationProfileCommands(services)) Object.assign(this, lineOfSightCommands(services)) Object.assign(this, areaOfSightCommands(services)) + Object.assign(this, observerSitingCommands(services)) Object.assign(this, printCommands(services)) Object.assign(this, replicationCommands(services)) diff --git a/src/renderer/model/commands/ObserverSitingCommands.js b/src/renderer/model/commands/ObserverSitingCommands.js new file mode 100644 index 00000000..c736b3ad --- /dev/null +++ b/src/renderer/model/commands/ObserverSitingCommands.js @@ -0,0 +1,50 @@ +import EventEmitter from '../../../shared/emitter' +import * as ID from '../../ids' + +const hasTerrainService = async (store) => { + const tuples = await store.tuples(ID.TILE_SERVICE_SCOPE) + return tuples.some(([, service]) => + service?.capabilities?.contentType === 'terrain/mapbox-rgb' || + service?.terrain?.length > 0 + ) +} + +const ObserverSiting = function (services) { + this.emitter = services.emitter + this.store = services.store + this.label = 'Observer Siting' + this.path = 'mdiBinoculars' + this.isEnabled = false + + hasTerrainService(this.store).then(available => { + this.isEnabled = available + this.emit('changed') + }) + + this.store.on('batch', ({ operations }) => { + const relevant = operations.some(({ key }) => + ID.isTileServiceId(key) || ID.isTilePresetId(key) + ) + if (!relevant) return + hasTerrainService(this.store).then(available => { + if (this.isEnabled !== available) { + this.isEnabled = available + this.emit('changed') + } + }) + }) +} + +Object.assign(ObserverSiting.prototype, EventEmitter.prototype) + +ObserverSiting.prototype.execute = function () { + this.emitter.emit('OBSERVER_SITING') +} + +ObserverSiting.prototype.enabled = function () { + return this.isEnabled +} + +export default services => ({ + OBSERVER_SITING: new ObserverSiting(services) +}) diff --git a/src/renderer/ol/interaction/observer-siting/index.js b/src/renderer/ol/interaction/observer-siting/index.js new file mode 100644 index 00000000..4c1b8f8d --- /dev/null +++ b/src/renderer/ol/interaction/observer-siting/index.js @@ -0,0 +1,244 @@ +import { Draw } from 'ol/interaction' +import { Vector as VectorSource } from 'ol/source' +import { toLonLat } from 'ol/proj' +import uuid from '../../../../shared/uuid' +import { militaryFormat } from '../../../../shared/datetime' +import * as ID from '../../../ids' +import { ElevationService } from '../../../model/ElevationService' +import { ViewshedEngine, VISIBLE, NO_DATA } from '../area-of-sight/engine' +import { DEFAULT_RADIUS_M, MAX_RADIUS_M } from '../area-of-sight' +import { rasterizePolygon, findCandidates, greedySiting } from './solve' +import GeometryType from '../GeometryType' + +const ORIGINATOR_ID = uuid() + +const TARGET_COVERAGE = 0.95 // stop when this fraction of the area is visible +const MAX_OBSERVERS = 8 +const MIN_GAIN = 0.01 // stop when the best candidate adds < 1 % of the area +const MIN_SPACING_M = 250 // candidate thinning (non-maximum suppression) +const MAX_CANDIDATES = 120 +const DEFAULT_OBSERVER_HEIGHT_M = 2 +const DEFAULT_TARGET_HEIGHT_M = 2 + +/** + * Observer siting: given an area (drawn or selected polygon), find a + * small set of observer positions inside it whose combined viewsheds + * cover as much of the area as possible (greedy max-coverage over + * candidate viewsheds, computed by the shared WebGPU engine). + * + * The chosen positions are inserted as regular AoS documents — their + * viewsheds render through the standard pipeline and each observer + * stays individually editable and deletable. + */ +export default ({ map, services }) => { + const elevationService = new ElevationService() + const engine = new ViewshedEngine() + + let drawInteraction = null + let generation = 0 + let running = false + + const showOSD = message => services.emitter.emit('osd', { message, cell: 'A3' }) + + const cancelDraw = () => { + if (!drawInteraction) return + drawInteraction.abortDrawing() + map.removeInteraction(drawInteraction) + drawInteraction = null + } + + const findSelectedPolygon = () => { + const selectedIds = services.selection.selected() + if (selectedIds.length !== 1) return null + const layers = map.getLayerGroup().getLayersArray() + for (const layer of layers) { + if (typeof layer.getSource !== 'function') continue + const source = layer.getSource() + if (typeof source?.getFeatureById !== 'function') continue + const feature = source.getFeatureById(selectedIds[0]) + const geometry = feature?.getGeometry() + if (geometry && geometry.getType() === GeometryType.POLYGON) return geometry + } + return null + } + + const run = async (polygon) => { + const gen = ++generation + const cancelled = () => gen !== generation + running = true + try { + await solveArea(polygon, gen, cancelled) + } finally { + if (!cancelled()) running = false + } + } + + const solveArea = async (polygon, gen, cancelled) => { + + if (!elevationService.setSource(map)) { + showOSD('No terrain layer available') + setTimeout(() => showOSD(''), 3000) + return + } + + const defaults = await services.sessionStore.get('tools.aos', {}) + const radiusM = Math.min(defaults.radius ?? DEFAULT_RADIUS_M, MAX_RADIUS_M) + const observerHeight = defaults.observerHeight ?? DEFAULT_OBSERVER_HEIGHT_M + const targetHeight = defaults.targetHeight ?? DEFAULT_TARGET_HEIGHT_M + + showOSD('Observer siting: loading terrain …') + + const extent = polygon.getExtent() + const margin = 100 // sight lines between points inside stay within the hull + const grid = await elevationService.getGrid([ + extent[0] - margin, extent[1] - margin, extent[2] + margin, extent[3] + margin + ]) + if (cancelled()) return + if (!grid) { + showOSD('Observer siting: area too large or no terrain') + setTimeout(() => showOSD(''), 3000) + return + } + for (let i = 0; i < grid.data.length; i++) { + if (Number.isNaN(grid.data[i])) grid.data[i] = NO_DATA + } + + const res = grid.resolution + const toCell = ([x, y]) => [(x - grid.origin[0]) / res, (grid.origin[1] - y) / res] + const toCoordinate = ({ x, y }) => [ + grid.origin[0] + (x + 0.5) * res, + grid.origin[1] - (y + 0.5) * res + ] + + const rings = polygon.getCoordinates().map(ring => ring.map(toCell)) + const { mask: inArea, cells } = rasterizePolygon(rings, grid.width, grid.height) + if (!cells) { + showOSD('Observer siting: empty area') + setTimeout(() => showOSD(''), 3000) + return + } + + const center = toLonLat([(extent[0] + extent[2]) / 2, (extent[1] + extent[3]) / 2]) + const metersPerCell = res * Math.max(0.087, Math.cos(center[1] * Math.PI / 180)) + const radius = Math.max(2, Math.round(radiusM / metersPerCell)) + const minSpacing = Math.max(2, MIN_SPACING_M / metersPerCell) + + const candidates = findCandidates(grid, inArea, { minSpacing, maxCandidates: MAX_CANDIDATES }) + if (!candidates.length) { + showOSD('Observer siting: no candidate positions found') + setTimeout(() => showOSD(''), 3000) + return + } + + const viewsheds = [] + for (let i = 0; i < candidates.length; i++) { + if (cancelled()) return + if (i % 10 === 0) showOSD(`Observer siting: analysing candidate ${i + 1}/${candidates.length} …`) + const candidate = candidates[i] + const result = await engine.compute(grid, { + ox: candidate.x, + oy: candidate.y, + radius, + metersPerCell, + observerHeight, + targetHeight + }) + if (!result) continue + const covers = new Uint8Array(grid.width * grid.height) + for (let y = 0; y < result.h; y++) { + for (let x = 0; x < result.w; x++) { + const idx = (result.y0 + y) * grid.width + (result.x0 + x) + if (inArea[idx] && result.mask[y * result.w + x] === VISIBLE) covers[idx] = 1 + } + } + viewsheds.push({ candidate, covers }) + } + if (cancelled()) return + + const { picks, coverage } = greedySiting({ + areaCells: cells, + viewsheds, + targetCoverage: TARGET_COVERAGE, + maxObservers: MAX_OBSERVERS, + minGain: MIN_GAIN + }) + + if (!picks.length) { + showOSD('Observer siting: no viable observer position') + setTimeout(() => showOSD(''), 3000) + return + } + + // one batch insert → a single undo step removes all observers + const stamp = militaryFormat.now() + const tuples = picks.map(({ candidate }, index) => [ID.aosId(), { + type: 'Feature', + name: `OP ${index + 1}/${picks.length} - ${stamp}`, + geometry: { type: 'Point', coordinates: toCoordinate(candidate) }, + properties: { radius: radiusM, observerHeight, targetHeight } + }]) + services.store.insert(tuples) + + showOSD(`Observer siting: ${picks.length} observer${picks.length > 1 ? 's' : ''} ` + + `cover ${(coverage * 100).toFixed(0)} % of the area`) + setTimeout(() => { if (!cancelled()) showOSD('') }, 6000) + } + + const start = () => { + cancelDraw() + generation++ + + if (!elevationService.setSource(map)) { + showOSD('No terrain layer available') + setTimeout(() => showOSD(''), 3000) + return + } + + const selected = findSelectedPolygon() + if (selected) { + run(selected.clone()) + return + } + + showOSD('Observer siting: draw the area to cover (double-click to finish)') + drawInteraction = new Draw({ type: GeometryType.POLYGON, source: new VectorSource() }) + drawInteraction.once('drawend', ({ feature }) => { + map.removeInteraction(drawInteraction) + drawInteraction = null + run(feature.getGeometry().clone()) + }) + drawInteraction.once('drawabort', () => { + map.removeInteraction(drawInteraction) + drawInteraction = null + showOSD('') + }) + map.addInteraction(drawInteraction) + } + + const cancel = () => { + if (!drawInteraction && !running) return false + cancelDraw() + generation++ + running = false + showOSD('') + return true + } + + const onKeyDown = (event) => { + if (event.key !== 'Escape') return + if (cancel()) { + event.preventDefault() + event.stopPropagation() + } + } + document.addEventListener('keydown', onKeyDown, true) + + services.emitter.on('OBSERVER_SITING', () => { + services.emitter.emit('command/draw/cancel', { originatorId: ORIGINATOR_ID }) + start() + }) + + services.emitter.on('command/draw/cancel', ({ originatorId }) => { + if (originatorId !== ORIGINATOR_ID) cancel() + }) +} diff --git a/src/renderer/ol/interaction/observer-siting/solve.js b/src/renderer/ol/interaction/observer-siting/solve.js new file mode 100644 index 00000000..92b05662 --- /dev/null +++ b/src/renderer/ol/interaction/observer-siting/solve.js @@ -0,0 +1,167 @@ +/** + * Multiple observer siting: find a small set of observer positions + * inside an area whose combined viewsheds maximize coverage of that + * area (set cover over viewsheds — NP-hard; solved with the standard + * greedy max-coverage heuristic, (1 - 1/e) approximation). + * + * All functions are pure and operate in grid-cell space so they are + * unit-testable without OpenLayers or a GPU. + */ + +/** + * Rasterize polygon rings onto a grid using even-odd scanline filling. + * Rings are arrays of [x, y] in grid-cell coordinates (fractional + * allowed; cell centers at integer + 0.5 are tested). Holes work via + * the even-odd rule. + * + * @param {Array>} rings - outer ring (+ holes) + * @param {number} width - grid width [cells] + * @param {number} height - grid height [cells] + * @returns {{ mask: Uint8Array, cells: number }} 1 = inside + */ +export const rasterizePolygon = (rings, width, height) => { + const mask = new Uint8Array(width * height) + let cells = 0 + + for (let row = 0; row < height; row++) { + const y = row + 0.5 + const intersections = [] + for (const ring of rings) { + for (let i = 0; i < ring.length; i++) { + const [x1, y1] = ring[i] + const [x2, y2] = ring[(i + 1) % ring.length] + if ((y1 <= y && y2 > y) || (y2 <= y && y1 > y)) { + intersections.push(x1 + ((y - y1) / (y2 - y1)) * (x2 - x1)) + } + } + } + intersections.sort((a, b) => a - b) + for (let i = 0; i + 1 < intersections.length; i += 2) { + const from = Math.max(0, Math.ceil(intersections[i] - 0.5)) + const to = Math.min(width - 1, Math.floor(intersections[i + 1] - 0.5)) + for (let col = from; col <= to; col++) { + mask[row * width + col] = 1 + cells++ + } + } + } + + return { mask, cells } +} + +/** + * Candidate observer cells: local terrain maxima inside the area, + * thinned by non-maximum suppression so candidates keep a minimum + * spacing. Falls back to a regular lattice when the terrain yields + * too few maxima (flat areas). + * + * @param {{data: Float32Array, width: number, height: number}} grid + * @param {Uint8Array} inArea - rasterized polygon mask (grid-sized) + * @param {object} options - minSpacing [cells], maxCandidates + * @returns {Array<{x: number, y: number, elevation: number}>} + */ +export const findCandidates = (grid, inArea, { minSpacing, maxCandidates }) => { + const { data, width, height } = grid + + const peaks = [] + for (let y = 1; y < height - 1; y++) { + for (let x = 1; x < width - 1; x++) { + const idx = y * width + x + if (!inArea[idx]) continue + const v = data[idx] + if (!Number.isFinite(v)) continue + if ( + v >= data[idx - 1] && v >= data[idx + 1] && + v >= data[idx - width] && v >= data[idx + width] && + v >= data[idx - width - 1] && v >= data[idx - width + 1] && + v >= data[idx + width - 1] && v >= data[idx + width + 1] + ) peaks.push({ x, y, elevation: v }) + } + } + peaks.sort((a, b) => b.elevation - a.elevation) + + // non-maximum suppression: keep highest, drop peaks closer than minSpacing + const spacing2 = minSpacing * minSpacing + const selected = [] + for (const peak of peaks) { + if (selected.length >= maxCandidates) break + const tooClose = selected.some(s => { + const dx = s.x - peak.x + const dy = s.y - peak.y + return dx * dx + dy * dy < spacing2 + }) + if (!tooClose) selected.push(peak) + } + + // lattice fallback for flat terrain: sample area cells on a grid + if (selected.length < Math.min(8, maxCandidates)) { + const step = Math.max(1, Math.round(minSpacing)) + for (let y = Math.floor(step / 2); y < height && selected.length < maxCandidates; y += step) { + for (let x = Math.floor(step / 2); x < width && selected.length < maxCandidates; x += step) { + const idx = y * width + x + if (!inArea[idx] || !Number.isFinite(data[idx])) continue + const tooClose = selected.some(s => { + const dx = s.x - x + const dy = s.y - y + return dx * dx + dy * dy < spacing2 + }) + if (!tooClose) selected.push({ x, y, elevation: data[idx] }) + } + } + } + + return selected +} + +/** + * Greedy max-coverage over candidate viewsheds. + * + * @param {object} options + * @param {number} options.areaCells - number of cells inside the area + * @param {Array<{candidate: object, covers: Uint8Array}>} options.viewsheds - + * per candidate: grid-sized 0/1 array of area cells visible from it + * @param {number} options.targetCoverage - stop at this fraction [0..1] + * @param {number} options.maxObservers + * @param {number} options.minGain - stop when the best remaining + * candidate adds less than this fraction of the area + * @returns {{ picks: Array<{candidate, gain: number}>, covered: number, coverage: number }} + */ +export const greedySiting = ({ areaCells, viewsheds, targetCoverage, maxObservers, minGain }) => { + if (!viewsheds.length || !areaCells) return { picks: [], covered: 0, coverage: 0 } + + const size = viewsheds[0].covers.length + const uncovered = new Uint8Array(size) + viewsheds.forEach(({ covers }) => { + for (let i = 0; i < size; i++) if (covers[i]) uncovered[i] = 1 + }) + // uncovered now marks every cell at least one candidate could see; + // cells nobody can see are excluded from the gain computation but + // coverage is still reported against the full area. + + const remaining = [...viewsheds] + const picks = [] + let covered = 0 + + while (picks.length < maxObservers && remaining.length) { + let best = -1 + let bestGain = 0 + for (let c = 0; c < remaining.length; c++) { + const { covers } = remaining[c] + let gain = 0 + for (let i = 0; i < size; i++) if (covers[i] && uncovered[i]) gain++ + if (gain > bestGain) { bestGain = gain; best = c } + } + + if (best < 0 || bestGain < minGain * areaCells) break + + const [pick] = remaining.splice(best, 1) + for (let i = 0; i < size; i++) { + if (pick.covers[i] && uncovered[i]) { uncovered[i] = 0; covered++ } + } + picks.push({ candidate: pick.candidate, gain: bestGain }) + + if (covered / areaCells >= targetCoverage) break + } + + return { picks, covered, coverage: covered / areaCells } +} diff --git a/test/renderer/observer-siting-test.js b/test/renderer/observer-siting-test.js new file mode 100644 index 00000000..efe37e4e --- /dev/null +++ b/test/renderer/observer-siting-test.js @@ -0,0 +1,173 @@ +import assert from 'assert' +import { + rasterizePolygon, + findCandidates, + greedySiting +} from '../../src/renderer/ol/interaction/observer-siting/solve' +import { viewshedCPU, VISIBLE } from '../../src/renderer/ol/interaction/area-of-sight/engine' + +describe('observer siting', function () { + describe('rasterizePolygon', function () { + it('fills a rectangle', function () { + const rings = [[[10, 10], [30, 10], [30, 20], [10, 20]]] + const { mask, cells } = rasterizePolygon(rings, 40, 30) + assert.strictEqual(cells, 20 * 10) + assert.strictEqual(mask[15 * 40 + 20], 1, 'inside') + assert.strictEqual(mask[5 * 40 + 20], 0, 'above') + assert.strictEqual(mask[15 * 40 + 35], 0, 'right of it') + }) + + it('supports holes via even-odd rule', function () { + const rings = [ + [[0, 0], [40, 0], [40, 40], [0, 40]], + [[10, 10], [30, 10], [30, 30], [10, 30]] + ] + const { mask } = rasterizePolygon(rings, 40, 40) + assert.strictEqual(mask[20 * 40 + 20], 0, 'inside the hole') + assert.strictEqual(mask[5 * 40 + 5], 1, 'between outer ring and hole') + }) + }) + + describe('findCandidates', function () { + const grid = (width, height, fn) => { + const data = new Float32Array(width * height) + for (let y = 0; y < height; y++) for (let x = 0; x < width; x++) data[y * width + x] = fn(x, y) + return { data, width, height } + } + const everywhere = (width, height) => new Uint8Array(width * height).fill(1) + + it('finds the two hill tops, highest first', function () { + const hill = (x, y, cx, cy, h) => h * Math.exp(-((x - cx) ** 2 + (y - cy) ** 2) / 50) + const g = grid(100, 40, (x, y) => hill(x, y, 25, 20, 100) + hill(x, y, 75, 20, 80)) + const candidates = findCandidates(g, everywhere(100, 40), { minSpacing: 10, maxCandidates: 10 }) + + assert.ok(candidates.length >= 2) + assert.ok(Math.abs(candidates[0].x - 25) <= 1 && Math.abs(candidates[0].y - 20) <= 1, 'highest hill first') + assert.ok(candidates.some(c => Math.abs(c.x - 75) <= 1 && Math.abs(c.y - 20) <= 1), 'second hill found') + }) + + it('keeps minimum spacing between candidates', function () { + const g = grid(60, 60, (x, y) => 100 - (x + y) * 0.001) // near-flat ridge + const candidates = findCandidates(g, everywhere(60, 60), { minSpacing: 15, maxCandidates: 20 }) + for (let i = 0; i < candidates.length; i++) { + for (let j = i + 1; j < candidates.length; j++) { + const dx = candidates[i].x - candidates[j].x + const dy = candidates[i].y - candidates[j].y + assert.ok(dx * dx + dy * dy >= 15 * 15, 'spacing respected') + } + } + }) + + it('falls back to a lattice on flat terrain', function () { + const g = grid(60, 60, () => 100) + const candidates = findCandidates(g, everywhere(60, 60), { minSpacing: 10, maxCandidates: 30 }) + assert.ok(candidates.length >= 8, `expected lattice fallback, got ${candidates.length}`) + }) + }) + + describe('greedySiting', function () { + const covers = indices => { + const mask = new Uint8Array(100) + indices.forEach(i => { mask[i] = 1 }) + return mask + } + const range = (from, to) => Array.from({ length: to - from }, (_, i) => from + i) + + it('picks the minimal covering set, largest gain first', function () { + const viewsheds = [ + { candidate: 'A', covers: covers(range(0, 60)) }, + { candidate: 'B', covers: covers(range(60, 100)) }, + { candidate: 'C', covers: covers(range(20, 70)) } // redundant given A+B + ] + const result = greedySiting({ + areaCells: 100, viewsheds, targetCoverage: 1, maxObservers: 5, minGain: 0.01 + }) + assert.deepStrictEqual(result.picks.map(p => p.candidate), ['A', 'B']) + assert.strictEqual(result.coverage, 1) + }) + + it('stops at the coverage target', function () { + const viewsheds = [ + { candidate: 'A', covers: covers(range(0, 80)) }, + { candidate: 'B', covers: covers(range(80, 90)) }, + { candidate: 'C', covers: covers(range(90, 100)) } + ] + const result = greedySiting({ + areaCells: 100, viewsheds, targetCoverage: 0.75, maxObservers: 5, minGain: 0.01 + }) + assert.strictEqual(result.picks.length, 1) + }) + + it('stops when the remaining gain is negligible', function () { + const viewsheds = [ + { candidate: 'A', covers: covers(range(0, 90)) }, + { candidate: 'B', covers: covers(range(89, 91)) } // adds 1 cell = 1 % + ] + const result = greedySiting({ + areaCells: 100, viewsheds, targetCoverage: 1, maxObservers: 5, minGain: 0.05 + }) + assert.deepStrictEqual(result.picks.map(p => p.candidate), ['A']) + }) + + it('respects the observer limit', function () { + const viewsheds = range(0, 10).map(i => ({ candidate: i, covers: covers(range(i * 10, i * 10 + 10)) })) + const result = greedySiting({ + areaCells: 100, viewsheds, targetCoverage: 1, maxObservers: 3, minGain: 0.01 + }) + assert.strictEqual(result.picks.length, 3) + }) + }) + + describe('end to end on synthetic terrain', function () { + it('two hills separated by a deep valley need two observers', function () { + // 200×80 grid: two 150 m hills at x=50 and x=150, valley floor at 0 + const width = 200 + const height = 80 + const data = new Float32Array(width * height) + const hill = (x, y, cx, cy) => 150 * Math.exp(-((x - cx) ** 2 + (y - cy) ** 2) / 400) + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + data[y * width + x] = hill(x, y, 50, 40) + hill(x, y, 150, 40) + } + } + const grid = { data, width, height } + + const rings = [[[10, 10], [190, 10], [190, 70], [10, 70]]] + const { mask: inArea, cells } = rasterizePolygon(rings, width, height) + + const candidates = findCandidates(grid, inArea, { minSpacing: 20, maxCandidates: 20 }) + + const viewsheds = candidates.map(candidate => { + const result = viewshedCPU(grid, { + ox: candidate.x, + oy: candidate.y, + radius: 250, + metersPerCell: 10, + observerHeight: 2, + targetHeight: 2 + }) + const covers = new Uint8Array(width * height) + if (result) { + for (let y = 0; y < result.h; y++) { + for (let x = 0; x < result.w; x++) { + const idx = (result.y0 + y) * width + (result.x0 + x) + if (inArea[idx] && result.mask[y * result.w + x] === VISIBLE) covers[idx] = 1 + } + } + } + return { candidate, covers } + }) + + const result = greedySiting({ + areaCells: cells, viewsheds, targetCoverage: 0.95, maxObservers: 8, minGain: 0.01 + }) + + assert.ok(result.picks.length >= 2, 'one observer cannot see behind the other hill') + assert.ok(result.coverage > 0.8, `expected high coverage, got ${result.coverage.toFixed(2)}`) + // the two hill tops should be among the picks + const near = (pick, cx) => Math.abs(pick.candidate.x - cx) <= 5 + assert.ok(result.picks.some(p => near(p, 50)), 'west hill picked') + assert.ok(result.picks.some(p => near(p, 150)), 'east hill picked') + }) + }) +}) From 62c6beefa096e71fbfbca0105bd57f411fb1f4c7 Mon Sep 17 00:00:00 2001 From: Thomas Halwax Date: Mon, 6 Jul 2026 20:22:38 +0200 Subject: [PATCH 2/5] fix(siting): uniform candidate distribution and visible sensor radius MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Candidates were local maxima sorted by elevation and capped — next to high terrain, a lower flat part of the area received no candidates at all and stayed uncovered (and dead-flat terrain flooded the peak detector, every cell being >= its neighbours). - candidates now come from block max-pooling: each block contributes its highest in-area cell, so candidates spread uniformly over the whole area while hilly blocks still yield their local summit; block size grows until the count fits the budget - sensor radius is shown in the OSD, adjustable with arrow keys while drawing the area, and persisted as the shared AoS default - result message reports coverage and radius, and suggests a larger radius when the coverage target was missed; observer cap raised to 12 - regression test: low flat plain next to mountains keeps its share of candidates --- .../ol/interaction/observer-siting/index.js | 50 ++++++++--- .../ol/interaction/observer-siting/solve.js | 85 ++++++++----------- test/renderer/observer-siting-test.js | 31 ++++--- 3 files changed, 92 insertions(+), 74 deletions(-) diff --git a/src/renderer/ol/interaction/observer-siting/index.js b/src/renderer/ol/interaction/observer-siting/index.js index 4c1b8f8d..a4efca00 100644 --- a/src/renderer/ol/interaction/observer-siting/index.js +++ b/src/renderer/ol/interaction/observer-siting/index.js @@ -13,13 +13,18 @@ import GeometryType from '../GeometryType' const ORIGINATOR_ID = uuid() const TARGET_COVERAGE = 0.95 // stop when this fraction of the area is visible -const MAX_OBSERVERS = 8 +const MAX_OBSERVERS = 12 const MIN_GAIN = 0.01 // stop when the best candidate adds < 1 % of the area -const MIN_SPACING_M = 250 // candidate thinning (non-maximum suppression) +const MIN_SPACING_M = 250 // initial candidate block size const MAX_CANDIDATES = 120 +const RADIUS_STEP_M = 250 +const MIN_RADIUS_M = 250 const DEFAULT_OBSERVER_HEIGHT_M = 2 const DEFAULT_TARGET_HEIGHT_M = 2 +const formatRadius = radiusM => + radiusM >= 1000 ? `${(radiusM / 1000).toFixed(2)} km` : `${radiusM} m` + /** * Observer siting: given an area (drawn or selected polygon), find a * small set of observer positions inside it whose combined viewsheds @@ -37,6 +42,7 @@ export default ({ map, services }) => { let drawInteraction = null let generation = 0 let running = false + let radiusM = DEFAULT_RADIUS_M const showOSD = message => services.emitter.emit('osd', { message, cell: 'A3' }) @@ -82,9 +88,9 @@ export default ({ map, services }) => { } const defaults = await services.sessionStore.get('tools.aos', {}) - const radiusM = Math.min(defaults.radius ?? DEFAULT_RADIUS_M, MAX_RADIUS_M) const observerHeight = defaults.observerHeight ?? DEFAULT_OBSERVER_HEIGHT_M const targetHeight = defaults.targetHeight ?? DEFAULT_TARGET_HEIGHT_M + services.sessionStore.put('tools.aos', { ...defaults, radius: radiusM }) showOSD('Observer siting: loading terrain …') @@ -179,12 +185,19 @@ export default ({ map, services }) => { }]) services.store.insert(tuples) - showOSD(`Observer siting: ${picks.length} observer${picks.length > 1 ? 's' : ''} ` + - `cover ${(coverage * 100).toFixed(0)} % of the area`) - setTimeout(() => { if (!cancelled()) showOSD('') }, 6000) + const summary = `Observer siting: ${picks.length} observer${picks.length > 1 ? 's' : ''} ` + + `cover ${(coverage * 100).toFixed(0)} % of the area (sensor radius ${formatRadius(radiusM)})` + const advice = coverage < TARGET_COVERAGE + ? ' — increase the sensor radius for better coverage' + : '' + showOSD(summary + advice) + setTimeout(() => { if (!cancelled()) showOSD('') }, 8000) } - const start = () => { + const drawHint = () => + showOSD(`Observer siting: draw the area (double-click to finish) | sensor radius ${formatRadius(radiusM)} ↑↓`) + + const start = async () => { cancelDraw() generation++ @@ -194,13 +207,16 @@ export default ({ map, services }) => { return } + const defaults = await services.sessionStore.get('tools.aos', {}) + radiusM = Math.min(defaults.radius ?? DEFAULT_RADIUS_M, MAX_RADIUS_M) + const selected = findSelectedPolygon() if (selected) { run(selected.clone()) return } - showOSD('Observer siting: draw the area to cover (double-click to finish)') + drawHint() drawInteraction = new Draw({ type: GeometryType.POLYGON, source: new VectorSource() }) drawInteraction.once('drawend', ({ feature }) => { map.removeInteraction(drawInteraction) @@ -225,11 +241,21 @@ export default ({ map, services }) => { } const onKeyDown = (event) => { - if (event.key !== 'Escape') return - if (cancel()) { - event.preventDefault() - event.stopPropagation() + if (event.key === 'Escape') { + if (cancel()) { + event.preventDefault() + event.stopPropagation() + } + return } + // adjust sensor radius while drawing the area + if (!drawInteraction) return + if (event.key !== 'ArrowUp' && event.key !== 'ArrowDown') return + event.preventDefault() + event.stopPropagation() + const delta = event.key === 'ArrowUp' ? RADIUS_STEP_M : -RADIUS_STEP_M + radiusM = Math.min(MAX_RADIUS_M, Math.max(MIN_RADIUS_M, radiusM + delta)) + drawHint() } document.addEventListener('keydown', onKeyDown, true) diff --git a/src/renderer/ol/interaction/observer-siting/solve.js b/src/renderer/ol/interaction/observer-siting/solve.js index 92b05662..92798970 100644 --- a/src/renderer/ol/interaction/observer-siting/solve.js +++ b/src/renderer/ol/interaction/observer-siting/solve.js @@ -50,67 +50,54 @@ export const rasterizePolygon = (rings, width, height) => { } /** - * Candidate observer cells: local terrain maxima inside the area, - * thinned by non-maximum suppression so candidates keep a minimum - * spacing. Falls back to a regular lattice when the terrain yields - * too few maxima (flat areas). + * Candidate observer cells via block max-pooling: the grid is divided + * into square blocks and each block contributes its highest in-area + * cell. This spreads candidates uniformly over the whole area — flat + * parts get their share of candidates instead of being crowded out by + * higher terrain elsewhere — while hilly blocks still contribute their + * local summit. The block size grows until the candidate count fits + * the budget. * * @param {{data: Float32Array, width: number, height: number}} grid * @param {Uint8Array} inArea - rasterized polygon mask (grid-sized) - * @param {object} options - minSpacing [cells], maxCandidates - * @returns {Array<{x: number, y: number, elevation: number}>} + * @param {object} options - minSpacing [cells] (initial block size), + * maxCandidates + * @returns {Array<{x: number, y: number, elevation: number}>} sorted + * by elevation, highest first */ export const findCandidates = (grid, inArea, { minSpacing, maxCandidates }) => { const { data, width, height } = grid - const peaks = [] - for (let y = 1; y < height - 1; y++) { - for (let x = 1; x < width - 1; x++) { - const idx = y * width + x - if (!inArea[idx]) continue - const v = data[idx] - if (!Number.isFinite(v)) continue - if ( - v >= data[idx - 1] && v >= data[idx + 1] && - v >= data[idx - width] && v >= data[idx + width] && - v >= data[idx - width - 1] && v >= data[idx - width + 1] && - v >= data[idx + width - 1] && v >= data[idx + width + 1] - ) peaks.push({ x, y, elevation: v }) + const collect = size => { + const candidates = [] + for (let by = 0; by < height; by += size) { + for (let bx = 0; bx < width; bx += size) { + let best = null + const yMax = Math.min(height, by + size) + const xMax = Math.min(width, bx + size) + for (let y = by; y < yMax; y++) { + for (let x = bx; x < xMax; x++) { + const idx = y * width + x + if (!inArea[idx]) continue + const v = data[idx] + if (!Number.isFinite(v)) continue + if (!best || v > best.elevation) best = { x, y, elevation: v } + } + } + if (best) candidates.push(best) + } } - } - peaks.sort((a, b) => b.elevation - a.elevation) - - // non-maximum suppression: keep highest, drop peaks closer than minSpacing - const spacing2 = minSpacing * minSpacing - const selected = [] - for (const peak of peaks) { - if (selected.length >= maxCandidates) break - const tooClose = selected.some(s => { - const dx = s.x - peak.x - const dy = s.y - peak.y - return dx * dx + dy * dy < spacing2 - }) - if (!tooClose) selected.push(peak) + return candidates } - // lattice fallback for flat terrain: sample area cells on a grid - if (selected.length < Math.min(8, maxCandidates)) { - const step = Math.max(1, Math.round(minSpacing)) - for (let y = Math.floor(step / 2); y < height && selected.length < maxCandidates; y += step) { - for (let x = Math.floor(step / 2); x < width && selected.length < maxCandidates; x += step) { - const idx = y * width + x - if (!inArea[idx] || !Number.isFinite(data[idx])) continue - const tooClose = selected.some(s => { - const dx = s.x - x - const dy = s.y - y - return dx * dx + dy * dy < spacing2 - }) - if (!tooClose) selected.push({ x, y, elevation: data[idx] }) - } - } + let size = Math.max(2, Math.round(minSpacing)) + let candidates = collect(size) + while (candidates.length > maxCandidates) { + size = Math.ceil(size * Math.sqrt(candidates.length / maxCandidates)) + candidates = collect(size) } - return selected + return candidates.sort((a, b) => b.elevation - a.elevation) } /** diff --git a/test/renderer/observer-siting-test.js b/test/renderer/observer-siting-test.js index efe37e4e..fc4592c3 100644 --- a/test/renderer/observer-siting-test.js +++ b/test/renderer/observer-siting-test.js @@ -46,22 +46,27 @@ describe('observer siting', function () { assert.ok(candidates.some(c => Math.abs(c.x - 75) <= 1 && Math.abs(c.y - 20) <= 1), 'second hill found') }) - it('keeps minimum spacing between candidates', function () { - const g = grid(60, 60, (x, y) => 100 - (x + y) * 0.001) // near-flat ridge - const candidates = findCandidates(g, everywhere(60, 60), { minSpacing: 15, maxCandidates: 20 }) - for (let i = 0; i < candidates.length; i++) { - for (let j = i + 1; j < candidates.length; j++) { - const dx = candidates[i].x - candidates[j].x - const dy = candidates[i].y - candidates[j].y - assert.ok(dx * dx + dy * dy >= 15 * 15, 'spacing respected') - } - } + it('respects the candidate budget by growing the block size', function () { + const g = grid(120, 120, (x, y) => Math.sin(x) * Math.cos(y)) // busy terrain + const candidates = findCandidates(g, everywhere(120, 120), { minSpacing: 5, maxCandidates: 30 }) + assert.ok(candidates.length <= 30) + assert.ok(candidates.length >= 15, 'still spatially dense') }) - it('falls back to a lattice on flat terrain', function () { + it('covers flat terrain with candidates', function () { const g = grid(60, 60, () => 100) - const candidates = findCandidates(g, everywhere(60, 60), { minSpacing: 10, maxCandidates: 30 }) - assert.ok(candidates.length >= 8, `expected lattice fallback, got ${candidates.length}`) + const candidates = findCandidates(g, everywhere(60, 60), { minSpacing: 10, maxCandidates: 60 }) + assert.ok(candidates.length >= 30, `expected block candidates on flat terrain, got ${candidates.length}`) + }) + + it('does not starve a low flat region next to high terrain', function () { + // left half: 500 m mountains; right half: dead-flat 100 m plain + const g = grid(120, 60, (x, y) => + x < 60 ? 500 + 50 * Math.sin(x / 3) * Math.cos(y / 3) : 100) + const candidates = findCandidates(g, everywhere(120, 60), { minSpacing: 10, maxCandidates: 40 }) + const inPlain = candidates.filter(c => c.x >= 70) + assert.ok(inPlain.length >= 5, + `flat plain must keep its share of candidates, got ${inPlain.length}`) }) }) From 1eb5120a40a2e53975f3458e99f9f652a1913a03 Mon Sep 17 00:00:00 2001 From: Thomas Halwax Date: Mon, 6 Jul 2026 20:36:52 +0200 Subject: [PATCH 3/5] feat(siting): confirm sensor radius before computing on a selection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Starting the tool with a preselected polygon no longer computes right away: it arms and shows the sensor radius in the OSD — arrow keys adjust, Enter starts the computation, Escape cancels. Same radius adjustment as in the draw phase. --- .../ol/interaction/observer-siting/index.js | 29 +++++++++++++++---- 1 file changed, 24 insertions(+), 5 deletions(-) diff --git a/src/renderer/ol/interaction/observer-siting/index.js b/src/renderer/ol/interaction/observer-siting/index.js index a4efca00..87357ec5 100644 --- a/src/renderer/ol/interaction/observer-siting/index.js +++ b/src/renderer/ol/interaction/observer-siting/index.js @@ -43,6 +43,7 @@ export default ({ map, services }) => { let generation = 0 let running = false let radiusM = DEFAULT_RADIUS_M + let pendingPolygon = null // selected polygon, waiting for radius confirmation const showOSD = message => services.emitter.emit('osd', { message, cell: 'A3' }) @@ -197,6 +198,9 @@ export default ({ map, services }) => { const drawHint = () => showOSD(`Observer siting: draw the area (double-click to finish) | sensor radius ${formatRadius(radiusM)} ↑↓`) + const armedHint = () => + showOSD(`Observer siting: sensor radius ${formatRadius(radiusM)} ↑↓ | Enter to compute, Escape to cancel`) + const start = async () => { cancelDraw() generation++ @@ -210,9 +214,12 @@ export default ({ map, services }) => { const defaults = await services.sessionStore.get('tools.aos', {}) radiusM = Math.min(defaults.radius ?? DEFAULT_RADIUS_M, MAX_RADIUS_M) + // With a preselected polygon, wait for radius confirmation instead + // of computing right away — this is the moment to set the radius. const selected = findSelectedPolygon() if (selected) { - run(selected.clone()) + pendingPolygon = selected.clone() + armedHint() return } @@ -232,8 +239,9 @@ export default ({ map, services }) => { } const cancel = () => { - if (!drawInteraction && !running) return false + if (!drawInteraction && !running && !pendingPolygon) return false cancelDraw() + pendingPolygon = null generation++ running = false showOSD('') @@ -248,14 +256,25 @@ export default ({ map, services }) => { } return } - // adjust sensor radius while drawing the area - if (!drawInteraction) return + + if (event.key === 'Enter' && pendingPolygon) { + event.preventDefault() + event.stopPropagation() + const polygon = pendingPolygon + pendingPolygon = null + run(polygon) + return + } + + // adjust sensor radius while drawing or while waiting for Enter + if (!drawInteraction && !pendingPolygon) return if (event.key !== 'ArrowUp' && event.key !== 'ArrowDown') return event.preventDefault() event.stopPropagation() const delta = event.key === 'ArrowUp' ? RADIUS_STEP_M : -RADIUS_STEP_M radiusM = Math.min(MAX_RADIUS_M, Math.max(MIN_RADIUS_M, radiusM + delta)) - drawHint() + if (pendingPolygon) armedHint() + else drawHint() } document.addEventListener('keydown', onKeyDown, true) From 67dd36cf0b8ddd4355f94777310aab1c1ba7f3d7 Mon Sep 17 00:00:00 2001 From: Thomas Halwax Date: Mon, 6 Jul 2026 20:41:39 +0200 Subject: [PATCH 4/5] feat(siting): accept closed boundary lines as area MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tactical boundaries are LineString graphics; when a selected line forms a (nearly) closed ring — endpoints within 100 m — it is converted to a polygon and used as the siting area. Unusable selections get an OSD notice and fall back to drawing. --- .../interaction/observer-siting/geometry.js | 35 +++++++++++++++++++ .../ol/interaction/observer-siting/index.js | 30 +++++++++++----- test/renderer/observer-siting-test.js | 33 +++++++++++++++++ 3 files changed, 89 insertions(+), 9 deletions(-) create mode 100644 src/renderer/ol/interaction/observer-siting/geometry.js diff --git a/src/renderer/ol/interaction/observer-siting/geometry.js b/src/renderer/ol/interaction/observer-siting/geometry.js new file mode 100644 index 00000000..a348bba4 --- /dev/null +++ b/src/renderer/ol/interaction/observer-siting/geometry.js @@ -0,0 +1,35 @@ +import Polygon from 'ol/geom/Polygon' +import GeometryType from '../GeometryType' + +// A LineString counts as a closed ring when its endpoints are within +// this distance [projection units] — boundaries drawn around an area +// rarely snap exactly onto their starting point. +const CLOSE_TOLERANCE = 100 + +/** + * Interpret a feature geometry as an area for observer siting. + * Polygons are used as-is; a (nearly) closed LineString — e.g. a + * boundary drawn around an area — is converted to a polygon. + * + * @param {import('ol/geom/Geometry').default} geometry + * @returns {Polygon|null} + */ +export const polygonOf = geometry => { + if (!geometry) return null + const type = geometry.getType() + + if (type === GeometryType.POLYGON) return geometry.clone() + + if (type === GeometryType.LINE_STRING) { + const coords = geometry.getCoordinates() + if (coords.length < 4) return null + const [x1, y1] = coords[0] + const [x2, y2] = coords[coords.length - 1] + if (Math.hypot(x2 - x1, y2 - y1) > CLOSE_TOLERANCE) return null + const ring = [...coords] + if (x1 !== x2 || y1 !== y2) ring.push([x1, y1]) + return new Polygon([ring]) + } + + return null +} diff --git a/src/renderer/ol/interaction/observer-siting/index.js b/src/renderer/ol/interaction/observer-siting/index.js index 87357ec5..c5bc3a72 100644 --- a/src/renderer/ol/interaction/observer-siting/index.js +++ b/src/renderer/ol/interaction/observer-siting/index.js @@ -8,6 +8,7 @@ import { ElevationService } from '../../../model/ElevationService' import { ViewshedEngine, VISIBLE, NO_DATA } from '../area-of-sight/engine' import { DEFAULT_RADIUS_M, MAX_RADIUS_M } from '../area-of-sight' import { rasterizePolygon, findCandidates, greedySiting } from './solve' +import { polygonOf } from './geometry' import GeometryType from '../GeometryType' const ORIGINATOR_ID = uuid() @@ -54,7 +55,13 @@ export default ({ map, services }) => { drawInteraction = null } - const findSelectedPolygon = () => { + /** + * @returns {{ polygon: Polygon } | { unsuitable: true } | null} + * polygon: selected feature usable as area (polygon or closed line) + * unsuitable: something is selected but cannot serve as an area + * null: nothing selected + */ + const findSelectedArea = () => { const selectedIds = services.selection.selected() if (selectedIds.length !== 1) return null const layers = map.getLayerGroup().getLayersArray() @@ -63,8 +70,9 @@ export default ({ map, services }) => { const source = layer.getSource() if (typeof source?.getFeatureById !== 'function') continue const feature = source.getFeatureById(selectedIds[0]) - const geometry = feature?.getGeometry() - if (geometry && geometry.getType() === GeometryType.POLYGON) return geometry + if (!feature) continue + const polygon = polygonOf(feature.getGeometry()) + return polygon ? { polygon } : { unsuitable: true } } return null } @@ -214,16 +222,20 @@ export default ({ map, services }) => { const defaults = await services.sessionStore.get('tools.aos', {}) radiusM = Math.min(defaults.radius ?? DEFAULT_RADIUS_M, MAX_RADIUS_M) - // With a preselected polygon, wait for radius confirmation instead + // With a preselected area, wait for radius confirmation instead // of computing right away — this is the moment to set the radius. - const selected = findSelectedPolygon() - if (selected) { - pendingPolygon = selected.clone() + const selected = findSelectedArea() + if (selected?.polygon) { + pendingPolygon = selected.polygon armedHint() return } - - drawHint() + if (selected?.unsuitable) { + showOSD('Observer siting: selection is not a closed area — draw one') + setTimeout(() => { if (drawInteraction) drawHint() }, 2500) + } else { + drawHint() + } drawInteraction = new Draw({ type: GeometryType.POLYGON, source: new VectorSource() }) drawInteraction.once('drawend', ({ feature }) => { map.removeInteraction(drawInteraction) diff --git a/test/renderer/observer-siting-test.js b/test/renderer/observer-siting-test.js index fc4592c3..a4acfc82 100644 --- a/test/renderer/observer-siting-test.js +++ b/test/renderer/observer-siting-test.js @@ -1,12 +1,45 @@ import assert from 'assert' +import LineString from 'ol/geom/LineString' +import Polygon from 'ol/geom/Polygon' import { rasterizePolygon, findCandidates, greedySiting } from '../../src/renderer/ol/interaction/observer-siting/solve' +import { polygonOf } from '../../src/renderer/ol/interaction/observer-siting/geometry' import { viewshedCPU, VISIBLE } from '../../src/renderer/ol/interaction/area-of-sight/engine' describe('observer siting', function () { + describe('polygonOf', function () { + it('passes polygons through', function () { + const polygon = new Polygon([[[0, 0], [100, 0], [100, 100], [0, 100], [0, 0]]]) + const result = polygonOf(polygon) + assert.strictEqual(result.getType(), 'Polygon') + assert.notStrictEqual(result, polygon, 'clone, not the original') + }) + + it('converts a closed boundary line to a polygon', function () { + const boundary = new LineString([[0, 0], [1000, 0], [1000, 1000], [0, 1000], [0, 0]]) + const result = polygonOf(boundary) + assert.strictEqual(result.getType(), 'Polygon') + assert.strictEqual(result.getCoordinates()[0].length, 5) + }) + + it('accepts a nearly closed line and closes the ring', function () { + const boundary = new LineString([[0, 0], [1000, 0], [1000, 1000], [0, 1000], [30, 40]]) + const result = polygonOf(boundary) + assert.strictEqual(result.getType(), 'Polygon') + const ring = result.getCoordinates()[0] + assert.deepStrictEqual(ring[ring.length - 1], ring[0], 'ring explicitly closed') + }) + + it('rejects open lines and points', function () { + const open = new LineString([[0, 0], [1000, 0], [1000, 1000], [0, 1000], [500, 500]]) + assert.strictEqual(polygonOf(open), null) + assert.strictEqual(polygonOf(null), null) + }) + }) + describe('rasterizePolygon', function () { it('fills a rectangle', function () { const rings = [[[10, 10], [30, 10], [30, 20], [10, 20]]] From 1e92de309363e8379d16f7ebfb0ccf14be428521 Mon Sep 17 00:00:00 2001 From: Thomas Halwax Date: Mon, 6 Jul 2026 21:00:36 +0200 Subject: [PATCH 5/5] feat(siting): shared group tag for all observers of a run MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Observers placed by one siting run get a common random tag (e.g. OP-3f2a) so they stay recognizable — and filterable in the sidebar via #tag — as the set covering one defined area. The tag is part of the same insert batch, so one undo still removes everything, and it is reported in the OSD summary. --- .../ol/interaction/observer-siting/index.js | 26 +++++++++++++------ 1 file changed, 18 insertions(+), 8 deletions(-) diff --git a/src/renderer/ol/interaction/observer-siting/index.js b/src/renderer/ol/interaction/observer-siting/index.js index c5bc3a72..752b65be 100644 --- a/src/renderer/ol/interaction/observer-siting/index.js +++ b/src/renderer/ol/interaction/observer-siting/index.js @@ -184,18 +184,28 @@ export default ({ map, services }) => { return } - // one batch insert → a single undo step removes all observers + // One batch insert → a single undo step removes all observers. + // All observers of a run share a random group tag so they remain + // recognizable (and filterable via #tag) as covering one area. const stamp = militaryFormat.now() - const tuples = picks.map(({ candidate }, index) => [ID.aosId(), { - type: 'Feature', - name: `OP ${index + 1}/${picks.length} - ${stamp}`, - geometry: { type: 'Point', coordinates: toCoordinate(candidate) }, - properties: { radius: radiusM, observerHeight, targetHeight } - }]) + const groupTag = `OP-${uuid().slice(0, 4)}` + const tuples = picks.flatMap(({ candidate }, index) => { + const aosId = ID.aosId() + return [ + [aosId, { + type: 'Feature', + name: `OP ${index + 1}/${picks.length} - ${stamp}`, + geometry: { type: 'Point', coordinates: toCoordinate(candidate) }, + properties: { radius: radiusM, observerHeight, targetHeight } + }], + [ID.tagsId(aosId), [groupTag]] + ] + }) services.store.insert(tuples) const summary = `Observer siting: ${picks.length} observer${picks.length > 1 ? 's' : ''} ` + - `cover ${(coverage * 100).toFixed(0)} % of the area (sensor radius ${formatRadius(radiusM)})` + `cover ${(coverage * 100).toFixed(0)} % of the area ` + + `(sensor radius ${formatRadius(radiusM)}, tag #${groupTag})` const advice = coverage < TARGET_COVERAGE ? ' — increase the sensor radius for better coverage' : ''