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/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 new file mode 100644 index 00000000..752b65be --- /dev/null +++ b/src/renderer/ol/interaction/observer-siting/index.js @@ -0,0 +1,311 @@ +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 { polygonOf } from './geometry' +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 = 12 +const MIN_GAIN = 0.01 // stop when the best candidate adds < 1 % of the area +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 + * 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 + let radiusM = DEFAULT_RADIUS_M + let pendingPolygon = null // selected polygon, waiting for radius confirmation + + const showOSD = message => services.emitter.emit('osd', { message, cell: 'A3' }) + + const cancelDraw = () => { + if (!drawInteraction) return + drawInteraction.abortDrawing() + map.removeInteraction(drawInteraction) + drawInteraction = null + } + + /** + * @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() + 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]) + if (!feature) continue + const polygon = polygonOf(feature.getGeometry()) + return polygon ? { polygon } : { unsuitable: true } + } + 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 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 …') + + 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. + // 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 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)}, tag #${groupTag})` + const advice = coverage < TARGET_COVERAGE + ? ' — increase the sensor radius for better coverage' + : '' + showOSD(summary + advice) + setTimeout(() => { if (!cancelled()) showOSD('') }, 8000) + } + + 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++ + + if (!elevationService.setSource(map)) { + showOSD('No terrain layer available') + setTimeout(() => showOSD(''), 3000) + return + } + + const defaults = await services.sessionStore.get('tools.aos', {}) + radiusM = Math.min(defaults.radius ?? DEFAULT_RADIUS_M, MAX_RADIUS_M) + + // With a preselected area, wait for radius confirmation instead + // of computing right away — this is the moment to set the radius. + const selected = findSelectedArea() + if (selected?.polygon) { + pendingPolygon = selected.polygon + armedHint() + return + } + 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) + drawInteraction = null + run(feature.getGeometry().clone()) + }) + drawInteraction.once('drawabort', () => { + map.removeInteraction(drawInteraction) + drawInteraction = null + showOSD('') + }) + map.addInteraction(drawInteraction) + } + + const cancel = () => { + if (!drawInteraction && !running && !pendingPolygon) return false + cancelDraw() + pendingPolygon = null + generation++ + running = false + showOSD('') + return true + } + + const onKeyDown = (event) => { + if (event.key === 'Escape') { + if (cancel()) { + event.preventDefault() + event.stopPropagation() + } + 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)) + if (pendingPolygon) armedHint() + else drawHint() + } + 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..92798970 --- /dev/null +++ b/src/renderer/ol/interaction/observer-siting/solve.js @@ -0,0 +1,154 @@ +/** + * 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 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] (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 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) + } + } + return candidates + } + + 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 candidates.sort((a, b) => b.elevation - a.elevation) +} + +/** + * 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..a4acfc82 --- /dev/null +++ b/test/renderer/observer-siting-test.js @@ -0,0 +1,211 @@ +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]]] + 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('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('covers flat terrain with candidates', function () { + const g = grid(60, 60, () => 100) + 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}`) + }) + }) + + 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') + }) + }) +})