1use crate::{
3 MapError, boundary_contour as contour,
4 model::{self, NodataPolicy, QueryConvention},
5 sloping, sloping_session,
6 terrain::NativeTerrain,
7};
8use rayon::prelude::*;
9use rstar::{AABB, RTree, RTreeObject};
10use serde::Deserialize;
11use serde_json::{Value, json};
12use std::{
13 collections::{HashMap, HashSet},
14 fs,
15 io::Write,
16 path::Path,
17 time::Instant,
18};
19use wbvector::{Geometry, Layer};
20const TILE: usize = 256;
21const SIDE: usize = 257;
22const SAMPLES: usize = SIDE * SIDE;
23pub const SCHEMA: &str = "ras-commander-boundary-request/1";
24#[derive(Deserialize)]
25#[serde(deny_unknown_fields)]
26pub struct Request {
27 pub schema: String,
28 pub interpolation: String,
29 pub model: sloping_session::Request,
30 pub terrain_metadata: TerrainMetadata,
31}
32#[derive(Deserialize)]
33pub struct Array {
34 pub path: String,
35 pub dtype: String,
36 pub shape: Vec<usize>,
37}
38#[derive(Deserialize)]
39pub struct TerrainMetadata {
40 pub shape: [usize; 2],
41 pub transform: [f64; 6],
42 pub tile_shape: [usize; 2],
43 pub tile_grid: [usize; 2],
44 pub selected_tiles: Vec<usize>,
45 pub mask: Array,
46 pub perimeter: Array,
47 pub stitch_points: Option<Array>,
48 pub stitch_triangles: Option<Array>,
49}
50fn invalid(s: &str) -> MapError {
51 MapError::Invalid(s.into())
52}
53fn bytes(
54 a: &Array,
55 dtype: &str,
56 shape: &[usize],
57 width: usize,
58 base: &Path,
59) -> Result<u64, MapError> {
60 if a.dtype != dtype || a.shape != shape {
61 return Err(invalid("boundary numeric descriptor mismatch"));
62 }
63 let n = shape
64 .iter()
65 .try_fold(width, |n, d| n.checked_mul(*d))
66 .ok_or_else(|| invalid("boundary array size overflow"))?;
67 if fs::metadata(model::resolve(base, &a.path))?.len() != n as u64 {
68 return Err(invalid("boundary numeric file length mismatch"));
69 }
70 Ok(n as u64)
71}
72fn read(a: &Array, base: &Path, expected: u64) -> Result<Vec<u8>, MapError> {
73 let data = fs::read(model::resolve(base, &a.path))?;
74 if data.len() as u64 != expected {
75 return Err(invalid("boundary numeric file changed size"));
76 }
77 Ok(data)
78}
79#[derive(Clone)]
80struct Indexed {
81 id: usize,
82 bbox: AABB<[f64; 2]>,
83}
84impl RTreeObject for Indexed {
85 type Envelope = AABB<[f64; 2]>;
86 fn envelope(&self) -> Self::Envelope {
87 self.bbox
88 }
89}
90struct Owners {
91 layer: Layer,
92 index: RTree<Indexed>,
93}
94impl Owners {
95 fn load(path: &Path, count: usize) -> Result<Self, MapError> {
96 let layer = crate::ownership::load(path, count, false)?;
97 let indexed = layer
98 .features
99 .iter()
100 .enumerate()
101 .map(|(id, f)| {
102 let b = f
103 .geometry
104 .as_ref()
105 .and_then(Geometry::bbox)
106 .ok_or_else(|| invalid("empty boundary physical cell"))?;
107 Ok(Indexed {
108 id,
109 bbox: AABB::from_corners([b.min_x, b.min_y], [b.max_x, b.max_y]),
110 })
111 })
112 .collect::<Result<Vec<_>, MapError>>()?;
113 Ok(Self {
114 layer,
115 index: RTree::bulk_load(indexed),
116 })
117 }
118 fn candidates(&self, bounds: AABB<[f64; 2]>) -> Vec<usize> {
119 let mut ids: Vec<_> = self
120 .index
121 .locate_in_envelope_intersecting(bounds)
122 .map(|x| x.id)
123 .collect();
124 ids.sort_unstable();
125 ids
126 }
127 fn grid(&self, transform: [f64; 6]) -> Result<Vec<u32>, MapError> {
128 let a = [transform[0], transform[3] + SIDE as f64 * transform[5]];
129 let b = [transform[0] + SIDE as f64 * transform[1], transform[3]];
130 let mut layer = Layer::new("boundary ownership");
131 layer.schema = self.layer.schema.clone();
132 layer.features = self
133 .candidates(AABB::from_corners(a, b))
134 .into_iter()
135 .map(|i| self.layer.features[i].clone())
136 .collect();
137 if layer.features.is_empty() {
138 return Ok(vec![0; SAMPLES]);
139 }
140 crate::ownership::burn(
141 layer,
142 terrio::ops::rasterize::Grid {
143 geo_transform: transform,
144 cols: SIDE,
145 rows: SIDE,
146 crs: Default::default(),
147 },
148 )
149 }
150 fn point(&self, p: [f64; 2]) -> u32 {
151 self.candidates(AABB::from_point(p))
152 .into_iter()
153 .rev()
154 .find(
155 |&i| match self.layer.features[i].geometry.as_ref().unwrap() {
156 Geometry::Polygon {
157 exterior,
158 interiors,
159 } => inside(&exterior.0, p) && !interiors.iter().any(|r| inside(&r.0, p)),
160 _ => false,
161 },
162 )
163 .map_or(0, |i| i as u32 + 1)
164 }
165}
166fn inside(r: &[wbvector::Coord], p: [f64; 2]) -> bool {
167 let mut hit = false;
168 for (a, b) in r.iter().zip(r.iter().cycle().skip(1)).take(r.len()) {
169 if (a.y > p[1]) != (b.y > p[1]) && p[0] < (b.x - a.x) * (p[1] - a.y) / (b.y - a.y) + a.x {
170 hit = !hit;
171 }
172 }
173 hit
174}
175struct TileData {
176 id: usize,
177 transform: [f64; 6],
178 owner: Vec<u32>,
179 ground: Vec<Option<f32>>,
180}
181fn activated_samples(mask: &[u8], rows: usize, cols: usize) -> Vec<bool> {
184 let stride = cols + 1;
185 let mut active = vec![false; (rows + 1) * stride];
186 for row in 0..rows {
187 for col in 0..cols {
188 if mask[row * cols + col] & 1 != 0 {
189 let index = row * stride + col;
190 for offset in [0, 1, stride, stride + 1] {
191 active[index + offset] = true;
192 }
193 }
194 }
195 }
196 active
197}
198fn prepare_tile(
199 id: usize,
200 position: usize,
201 metadata: &TerrainMetadata,
202 mask: &[u8],
203 perimeter: &[f32],
204 terrain: &NativeTerrain,
205 owners: &Owners,
206) -> Result<TileData, MapError> {
207 let row = (id / metadata.tile_grid[1]) * TILE;
208 let col = (id % metadata.tile_grid[1]) * TILE;
209 let mut transform = metadata.transform;
210 transform[0] += col as f64 * transform[1];
211 transform[3] += row as f64 * transform[5];
212 let owner = owners.grid(transform)?;
213 let start = position * TILE * TILE;
214 let valid = activated_samples(&mask[start..start + TILE * TILE], TILE, TILE);
215 let mut ground = vec![None; SAMPLES];
216 let mut blocks = HashMap::new();
217 for r in 0..SIDE {
218 for c in 0..SIDE {
219 let k = r * SIDE + c;
220 if !valid[k] || owner[k] == 0 {
221 continue;
222 }
223 let v = if r == TILE {
224 perimeter[position * 513 + TILE + c]
225 } else if c == TILE {
226 perimeter[position * 513 + r]
227 } else if row + r < terrain.height && col + c < terrain.width {
228 let key = ((col + c) / terrain.block.0, (row + r) / terrain.block.1);
229 if let std::collections::hash_map::Entry::Vacant(entry) = blocks.entry(key) {
230 entry.insert(terrain.get_block(key.0, key.1)?);
231 }
232 let block = &blocks[&key];
233 let w =
234 terrain.block_window((col + c) / terrain.block.0, (row + r) / terrain.block.1);
235 block[((row + r) % terrain.block.1) * w.width + (col + c) % terrain.block.0]
236 } else {
237 continue;
238 };
239 if let Some(v) = terrain.classify(v) {
240 if !v.is_finite() {
241 return Err(invalid("infinite boundary terrain sample"));
242 }
243 ground[k] = Some(v as f32);
244 }
245 }
246 }
247 Ok(TileData {
248 id,
249 transform,
250 owner,
251 ground,
252 })
253}
254fn aoi(base: &Path, filename: &Option<String>) -> Result<Option<contour::MultiPolygon>, MapError> {
255 let Some(name) = filename else {
256 return Ok(None);
257 };
258 let l = crate::ownership::load(&model::resolve(base, name), 1, true)?;
259 let ring = |r: &wbvector::Ring| r.0.iter().map(|c| [c.x, c.y]).collect::<Vec<_>>();
260 let polygon = |e: &wbvector::Ring, h: &[wbvector::Ring]| {
261 std::iter::once(ring(e))
262 .chain(h.iter().map(ring))
263 .collect::<Vec<_>>()
264 };
265 Ok(Some(match l.features[0].geometry.as_ref().unwrap() {
266 Geometry::Polygon {
267 exterior,
268 interiors,
269 } => vec![polygon(exterior, interiors)],
270 Geometry::MultiPolygon(p) => p.iter().map(|(e, h)| polygon(e, h)).collect(),
271 _ => return Err(invalid("boundary AOI must be polygon")),
272 }))
273}
274fn values(path: &Path, n: usize) -> Result<Vec<f32>, MapError> {
275 if fs::metadata(path)?.len() != n as u64 * 4 {
276 return Err(invalid("boundary profile length mismatch"));
277 }
278 let raw = fs::read(path)?;
279 if raw.len() != n * 4 {
280 return Err(invalid("boundary profile size changed"));
281 }
282 let result: Vec<_> = raw
283 .as_chunks::<4>()
284 .0
285 .iter()
286 .map(|x| f32::from_le_bytes(*x))
287 .collect();
288 if result.iter().any(|x| !x.is_finite()) {
289 return Err(invalid("boundary profile requires finite cell WSE"));
290 }
291 Ok(result)
292}
293fn atomic_json(path: &Path, value: &Value) -> Result<(), MapError> {
294 let partial = path.with_extension("partial");
295 let mut file = fs::File::create(&partial)?;
296 serde_json::to_writer(&mut file, value).map_err(|e| invalid(&e.to_string()))?;
297 file.flush()?;
298 drop(file);
299 fs::rename(partial, path)?;
300 Ok(())
301}
302fn horizontal_surface(water: f32, minimum: f32) -> Option<f32> {
303 (water != sloping::NODATA && water > minimum + 0.001_f32).then_some(water)
304}
305pub fn method_record() -> Value {
306 json!({"method_id":contour::METHOD_ID,"coordinate_precision":0.001,"native_vector_parity":false,"scope":"physical 2D cells; horizontal or plain corner sloping; deterministic zero-depth contours","differences":"stable ordered all-subject integer union; RASMapper polygons can differ in holes and quantized coordinates","depth_cutoff":0,"cell_wet_tolerance":0.001,"cell_wet_rule":"float32 cell WSE > float32(minimum elevation + 0.001); no per-pixel depth filtering"})
307}
308pub fn run(req: &Request, base: &Path, out: &Path) -> Result<Value, MapError> {
310 if out.exists() && fs::read_dir(out)?.next().is_some() {
311 return Err(invalid("boundary output directory must be empty"));
312 }
313 fs::create_dir_all(out)?;
314 let mut report = json!({"schema":"ras-commander-boundary-report/1","status":"in_progress","method":method_record(),"units":[],"profiles":[]});
315 atomic_json(&out.join("report.json"), &report)?;
316 match execute(req, base, out, &mut report) {
317 Ok(()) => {
318 report["status"] = "complete".into();
319 atomic_json(&out.join("report.json"), &report)?;
320 Ok(report)
321 }
322 Err(error) => {
323 for item in fs::read_dir(out)? {
324 let path = item?.path();
325 if path
326 .extension()
327 .is_some_and(|e| e == "geojson" || e == "partial")
328 {
329 fs::remove_file(path)?;
330 }
331 }
332 report["status"] = "aborted".into();
333 report["profiles"] = req
334 .model
335 .profiles
336 .iter()
337 .map(|p| json!({"id":p.id,"status":"aborted","error":error.to_string()}))
338 .collect::<Vec<_>>()
339 .into();
340 for unit in report["units"].as_array_mut().unwrap() {
341 unit["status"] = "aborted".into();
342 unit.as_object_mut().unwrap().remove("path");
343 }
344 report["error"] = error.to_string().into();
345 atomic_json(&out.join("report.json"), &report)?;
346 Err(error)
347 }
348 }
349}
350fn execute(req: &Request, base: &Path, out: &Path, report: &mut Value) -> Result<(), MapError> {
351 let tick = Instant::now();
352 let m = &req.model;
353 let t = &req.terrain_metadata;
354 if req.schema != SCHEMA
355 || m.schema != sloping_session::SCHEMA
356 || !["horizontal", "sloping"].contains(&req.interpolation.as_str())
357 || m.options != sloping::Options::default()
358 {
359 return Err(invalid("unsupported boundary schema/interpolation/options"));
360 }
361 if m.velocity_normals_files.is_some()
362 || m.profiles.iter().any(|p| p.face_velocity_files.is_some())
363 {
364 return Err(MapError::Unsupported(
365 "velocity products are not part of boundary requests".into(),
366 ));
367 }
368 if m.terrain_scope.is_some() {
369 return Err(MapError::Unsupported(
370 "top-priority-source terrains are not qualified for boundaries".into(),
371 ));
372 }
373 if m.epsg == 0
374 || m.threads == 0
375 || m.threads > 64
376 || m.max_inflight_profiles == 0
377 || m.profiles.is_empty()
378 || m.mesh_files.is_empty()
379 || m.owner_cells.is_empty()
380 || m.owner_cells.len() >= u32::MAX as usize
381 {
382 return Err(invalid("invalid boundary threads/profiles/geometry"));
383 }
384 let mut ids = HashSet::new();
385 for profile in &m.profiles {
386 model::validate_id(&profile.id)
387 .map_err(|error| invalid(&format!("invalid boundary profile id: {error}")))?;
388 if !ids.insert(model::id_key(&profile.id)) {
389 return Err(invalid("duplicate boundary profile ids (case-insensitive)"));
390 }
391 if profile.values_files.len() != m.mesh_files.len() {
392 return Err(invalid("boundary profile files must match mesh count"));
393 }
394 }
395 if t.tile_shape != [TILE, TILE]
396 || t.shape.contains(&0)
397 || t.tile_grid != [t.shape[0].div_ceil(TILE), t.shape[1].div_ceil(TILE)]
398 || t.transform != m.terrain_transform
399 || t.transform.iter().any(|x| !x.is_finite())
400 || t.transform[1] <= 0.
401 || t.transform[5] != -t.transform[1]
402 || t.transform[2] != 0.
403 || t.transform[4] != 0.
404 {
405 return Err(invalid(
406 "boundary requires original square north-up 256 terrain tiles",
407 ));
408 }
409 let mut unique = HashSet::new();
410 let total_tiles = t.tile_grid[0]
411 .checked_mul(t.tile_grid[1])
412 .ok_or_else(|| invalid("boundary tile count overflow"))?;
413 if t.selected_tiles.is_empty()
414 || t.selected_tiles
415 .iter()
416 .any(|i| *i >= total_tiles || !unique.insert(*i))
417 {
418 return Err(invalid("boundary selected tiles invalid"));
419 }
420 let n = t.selected_tiles.len();
421 let mask_bytes = bytes(&t.mask, "|u1", &[n, TILE * TILE], 1, base)?;
422 let perimeter_bytes = bytes(&t.perimeter, "<f4", &[n, 513], 4, base)?;
423 let geometry_bytes = fs::metadata(model::resolve(base, &m.geometry_file))?.len();
424 let mut input = mask_bytes
425 .checked_add(perimeter_bytes)
426 .and_then(|v| v.checked_add(geometry_bytes))
427 .ok_or_else(|| invalid("boundary input size overflow"))?;
428 if let Some(name) = &m.aoi_file {
429 input = input
430 .checked_add(fs::metadata(model::resolve(base, name))?.len())
431 .ok_or_else(|| invalid("boundary AOI budget overflow"))?;
432 }
433 for name in &m.mesh_files {
434 input = input
435 .checked_add(crate::sloping_binary::input_bytes(&model::resolve(
436 base, name,
437 ))?)
438 .ok_or_else(|| invalid("boundary input budget overflow"))?;
439 }
440 let (point_bytes, tri_bytes) = match (&t.stitch_points, &t.stitch_triangles) {
441 (Some(p), Some(q))
442 if p.shape.len() == 2 && p.shape[1] == 4 && q.shape.len() == 2 && q.shape[1] == 3 =>
443 {
444 (
445 bytes(p, "<f8", &p.shape, 8, base)?,
446 bytes(q, "<i4", &q.shape, 4, base)?,
447 )
448 }
449 (None, None) => (0, 0),
450 _ => {
451 return Err(invalid(
452 "boundary stitch descriptors must be paired Nx4/Mx3",
453 ));
454 }
455 };
456 input = input
457 .checked_add(point_bytes)
458 .and_then(|x| x.checked_add(tri_bytes))
459 .ok_or_else(|| invalid("boundary input budget overflow"))?;
460 let terrain = NativeTerrain::open(
461 model::resolve(base, &m.terrain)
462 .to_str()
463 .ok_or_else(|| invalid("non-UTF8 terrain path"))?,
464 m.epsg,
465 &m.crs_identity,
466 QueryConvention::PixelCenter,
467 NodataPolicy::Dry,
468 64 << 20,
469 )?;
470 if [terrain.height, terrain.width] != t.shape || terrain.gt != t.transform {
471 return Err(invalid(
472 "boundary original terrain metadata disagrees with TIFF",
473 ));
474 }
475 let shared = input
478 .checked_mul(16)
479 .and_then(|x| {
480 (n as u64)
481 .checked_mul((SAMPLES * 16) as u64)
482 .and_then(|y| x.checked_add(y))
483 })
484 .and_then(|x| x.checked_add(64 << 20))
485 .and_then(|x| {
486 terrain
487 .decoder_transient_bytes_per_load()
488 .checked_mul(m.threads as u64)
489 .and_then(|y| x.checked_add(y))
490 })
491 .ok_or_else(|| invalid("boundary memory plan overflow"))?;
492 if shared >= m.max_working_bytes {
493 return Err(MapError::Budget(
494 "boundary shared preparation exceeds budget".into(),
495 ));
496 }
497 let remaining = m.max_working_bytes - shared;
498 let hydraulic_allowance = input
499 .checked_mul(8)
500 .ok_or_else(|| invalid("boundary hydraulic budget overflow"))?;
501 let slot = hydraulic_allowance
502 .checked_add(16 << 20)
503 .ok_or_else(|| invalid("boundary slot budget overflow"))?;
504 let inflight = m
505 .max_inflight_profiles
506 .min(m.threads)
507 .min(m.profiles.len())
508 .min((remaining / slot) as usize);
509 if inflight == 0 {
510 return Err(MapError::Budget(
511 "boundary contour allowance below 16 MiB".into(),
512 ));
513 }
514 let allowance = ((remaining / inflight as u64) - hydraulic_allowance) as usize;
515 let limits = contour::Limits {
516 max_working_bytes: allowance,
517 };
518 let pool = rayon::ThreadPoolBuilder::new()
519 .num_threads(m.threads)
520 .build()
521 .map_err(|e| invalid(&e.to_string()))?;
522 let mut meshes = vec![];
523 let mut stencils = vec![];
524 for name in &m.mesh_files {
525 let (mesh, s) = crate::sloping_binary::load(&model::resolve(base, name))?.prepare()?;
526 meshes.push(mesh);
527 stencils.push(s);
528 }
529 for &[mi, ci] in &m.owner_cells {
530 if meshes
531 .get(mi)
532 .and_then(|x| x.cells.get(ci))
533 .is_none_or(|x| x.virtual_cell)
534 {
535 return Err(invalid("boundary ownership must address physical 2D cells"));
536 }
537 }
538 let owners = Owners::load(&model::resolve(base, &m.geometry_file), m.owner_cells.len())?;
539 let mask = read(&t.mask, base, mask_bytes)?;
540 let raw = read(&t.perimeter, base, perimeter_bytes)?;
541 let perimeter: Vec<_> = raw
542 .as_chunks::<4>()
543 .0
544 .iter()
545 .map(|x| f32::from_le_bytes(*x))
546 .collect();
547 drop(raw);
548 let tiles = pool.install(|| {
549 t.selected_tiles
550 .par_iter()
551 .enumerate()
552 .map(|(p, &id)| prepare_tile(id, p, t, &mask, &perimeter, &terrain, &owners))
553 .collect::<Result<Vec<_>, MapError>>()
554 })?;
555 let mut points = vec![];
556 let mut triangles = vec![];
557 if let (Some(p), Some(q)) = (&t.stitch_points, &t.stitch_triangles) {
558 for row in read(p, base, point_bytes)?.as_chunks::<32>().0.iter() {
559 let mut v = [0.; 4];
560 for (x, b) in v.iter_mut().zip(row.as_chunks::<8>().0.iter()) {
561 *x = f64::from_le_bytes(*b);
562 }
563 if v.iter().any(|x| !x.is_finite()) {
564 return Err(invalid("nonfinite stitch point"));
565 }
566 points.push(v);
567 }
568 for row in read(q, base, tri_bytes)?.as_chunks::<12>().0.iter() {
569 let mut v = [0u32; 3];
570 for (x, b) in v.iter_mut().zip(row.as_chunks::<4>().0.iter()) {
571 let index = i32::from_le_bytes(*b);
572 if index < 0 || index as usize >= points.len() {
573 return Err(invalid("invalid stitch triangle index"));
574 }
575 *x = index as u32;
576 }
577 triangles.push(v);
578 }
579 }
580 let stitch_owners: Vec<_> = points.iter().map(|p| owners.point([p[0], p[1]])).collect();
581 let xy: Vec<_> = points.iter().map(|p| [p[0], p[1]]).collect();
582 let clip = aoi(base, &m.aoi_file)?;
583 report["interpolation"] = req.interpolation.clone().into();
584 report["threads"] = m.threads.into();
585 report["effective_inflight_profiles"] = inflight.into();
586 report["planned_shared_bytes"] = shared.into();
587 report["per_profile_contour_budget_bytes"] = allowance.into();
588 report["selected_tiles"] = n.into();
589 let tile_workers = (m.threads / inflight)
591 .max(1)
592 .min(n)
593 .min((allowance / 2 / (16 << 20)).max(1));
594 report["tile_workers_per_profile"] = tile_workers.into();
595 report["prepare_seconds"] = tick.elapsed().as_secs_f64().into();
596 let render_tick = Instant::now();
597 for batch in m.profiles.chunks(inflight) {
598 let compute = |profile: &sloping_session::Profile| -> Result<(Value, Value), MapError> {
599 let profile_tick = Instant::now();
600 let mut cell_values = vec![];
601 let mut prepared = vec![];
602 for ((mesh, stencil), name) in meshes.iter().zip(&stencils).zip(&profile.values_files) {
603 let values = values(&model::resolve(base, name), mesh.cells.len())?;
604 if req.interpolation == "sloping" {
605 prepared.push(sloping::prepare_profile(
606 mesh, stencil, &values, 0.001, &pool,
607 )?);
608 }
609 cell_values.push(values);
610 }
611 let hydraulic_seconds = profile_tick.elapsed().as_secs_f64();
612 let contour_tick = Instant::now();
613 let sample = |label: u32,
614 point: [f64; 2],
615 ground: Option<f32>,
616 scratch: &mut sloping::WeightScratch|
617 -> Result<Option<f32>, MapError> {
618 let Some(ground) = ground else {
619 return Ok(None);
620 };
621 if label == 0 {
622 return Ok(None);
623 }
624 let [mesh, cell] = m.owner_cells[label as usize - 1];
625 let water = if req.interpolation == "horizontal" {
626 let water = cell_values[mesh][cell];
627 horizontal_surface(water, meshes[mesh].cells[cell].minimum)
628 } else {
629 sloping::boundary_surface_value(
630 &stencils, &prepared, mesh, cell, point, scratch,
631 )?
632 };
633 Ok(water.map(|water| water - ground))
634 };
635 let mut units = vec![];
636 let unit_limits = contour::Limits {
637 max_working_bytes: allowance / (2 * tile_workers),
638 };
639 let mut retained_estimate = 0usize;
640 let mut surface_worker_seconds = 0.;
641 let mut contour_worker_seconds = 0.;
642 for tile_batch in tiles.chunks(tile_workers) {
643 let compute_tile = |tile: &TileData| -> Result<_, MapError> {
644 let tick = Instant::now();
645 let mut scratch = sloping::WeightScratch::new(32);
646 let depth = (0..SAMPLES)
647 .map(|index| {
648 let row = index / SIDE;
649 let col = index % SIDE;
650 let point = [
651 tile.transform[0] + (col as f64 + 0.5) * tile.transform[1],
652 tile.transform[3] + (row as f64 + 0.5) * tile.transform[5],
653 ];
654 sample(tile.owner[index], point, tile.ground[index], &mut scratch)
655 })
656 .collect::<Result<Vec<_>, _>>()?;
657 let surface_seconds = tick.elapsed().as_secs_f64();
658 let tick = Instant::now();
659 let unit = contour::grid(
660 tile.id as u64,
661 contour::Grid {
662 rows: SIDE,
663 cols: SIDE,
664 transform: tile.transform,
665 },
666 &depth,
667 unit_limits,
668 )?;
669 Ok((unit, surface_seconds, tick.elapsed().as_secs_f64()))
670 };
671 let computed = tile_batch
672 .par_iter()
673 .map(compute_tile)
674 .collect::<Result<Vec<_>, MapError>>()?;
675 for (unit, surface_seconds, contour_seconds) in computed {
676 surface_worker_seconds += surface_seconds;
677 contour_worker_seconds += contour_seconds;
678 retained_estimate = unit
679 .vertex_count()
680 .checked_mul(256)
681 .and_then(|vertices| {
682 unit.ring_count()
683 .checked_mul(128)
684 .and_then(|rings| vertices.checked_add(rings))
685 })
686 .and_then(|current| current.checked_add(retained_estimate))
687 .ok_or_else(|| invalid("boundary retained units size overflow"))?;
688 if retained_estimate > allowance / 2 {
689 return Err(MapError::Budget(
690 "boundary retained contour units exceed profile allowance".into(),
691 ));
692 }
693 units.push(unit);
694 }
695 }
696 if !triangles.is_empty() {
697 let tick = Instant::now();
698 let mut scratch = sloping::WeightScratch::new(32);
699 let depth = points
700 .iter()
701 .zip(&stitch_owners)
702 .map(|(point, &owner)| {
703 sample(
704 owner,
705 [point[0], point[1]],
706 terrain.classify(point[2] as f32).map(|v| v as f32),
707 &mut scratch,
708 )
709 })
710 .collect::<Result<Vec<_>, _>>()?;
711 surface_worker_seconds += tick.elapsed().as_secs_f64();
712 let tick = Instant::now();
713 units.push(contour::tin(
714 total_tiles as u64,
715 &xy,
716 &depth,
717 &triangles,
718 contour::Limits {
719 max_working_bytes: allowance / 2,
720 },
721 )?);
722 contour_worker_seconds += tick.elapsed().as_secs_f64();
723 }
724 let tick = Instant::now();
725 let result = contour::finish(&units, clip.as_ref(), limits)?;
726 contour_worker_seconds += tick.elapsed().as_secs_f64();
727 let polygon_count = result.coordinates.len();
728 let hole_count: usize = result
729 .coordinates
730 .iter()
731 .map(|polygon| polygon.len().saturating_sub(1))
732 .sum();
733 let vertex_count: usize = result.coordinates.iter().flatten().map(Vec::len).sum();
734 let area: f64 = result
735 .coordinates
736 .iter()
737 .map(|polygon| {
738 polygon
739 .iter()
740 .enumerate()
741 .map(|(index, ring)| {
742 let origin = ring.first().copied().unwrap_or([0., 0.]);
743 let area = ring
744 .windows(2)
745 .map(|pair| {
746 (pair[0][0] - origin[0]) * (pair[1][1] - origin[1])
747 - (pair[1][0] - origin[0]) * (pair[0][1] - origin[1])
748 })
749 .sum::<f64>()
750 .abs()
751 * 0.5;
752 if index == 0 { area } else { -area }
753 })
754 .sum::<f64>()
755 })
756 .sum();
757 let stats = json!({"polygon_count":polygon_count,"hole_count":hole_count,"vertex_count":vertex_count,"area":area,"hydraulic_seconds":hydraulic_seconds,"contour_seconds":contour_tick.elapsed().as_secs_f64(),"surface_worker_seconds":surface_worker_seconds,"contour_worker_seconds":contour_worker_seconds});
758 let product = json!({"type":"FeatureCollection","crs":{"type":"name","properties":{"name":format!("EPSG:{}",m.epsg)}},"features":[{"type":"Feature","properties":{"profile_id":profile.id,"interpolation":req.interpolation,"method_id":contour::METHOD_ID,"native_vector_parity":false},"geometry":{"type":"MultiPolygon","coordinates":result.coordinates}}]});
759 Ok((product, stats))
760 };
761 let products = pool.install(|| {
762 batch
763 .par_iter()
764 .map(compute)
765 .collect::<Result<Vec<_>, MapError>>()
766 })?;
767 for (profile, (product, stats)) in batch.iter().zip(products) {
768 let name = format!("{}.geojson", profile.id);
769 atomic_json(&out.join(&name), &product)?;
770 report["units"]
771 .as_array_mut()
772 .unwrap()
773 .push(json!({"profile_id":profile.id,"status":"complete","path":name}));
774 let mut row = stats;
775 row["id"] = profile.id.clone().into();
776 row["status"] = if row["polygon_count"] == 0 {
777 "empty"
778 } else {
779 "complete"
780 }
781 .into();
782 row["file"] = name.into();
783 report["profiles"].as_array_mut().unwrap().push(row);
784 }
785 }
786 report["render_seconds"] = render_tick.elapsed().as_secs_f64().into();
787 report["terrain_cache"] =
788 serde_json::to_value(terrain.stats()).map_err(|e| invalid(&e.to_string()))?;
789 Ok(())
790}
791
792#[cfg(test)]
793mod tests {
794 use super::*;
795 #[test]
796 fn coverage_gap_and_missing_ground_have_distinct_contour_semantics() {
797 let mut mask = vec![1u8; 9];
802 mask[4] = 0;
803 let active = activated_samples(&mask, 3, 3);
804 assert!(active.iter().all(|&value| value));
805 let depth: Vec<_> = active.iter().map(|&value| value.then_some(1f32)).collect();
806 let grid = contour::Grid {
807 rows: 4,
808 cols: 4,
809 transform: [0., 1., 0., 4., 0., -1.],
810 };
811 let limits = contour::Limits {
812 max_working_bytes: 16 << 20,
813 };
814 let unit = contour::grid(0, grid, &depth, limits).unwrap();
815 let result = contour::finish(&[unit], None, limits).unwrap();
816 assert_eq!(result.coordinates.len(), 1);
817 assert_eq!(result.coordinates[0].len(), 1);
818 let mut missing_ground = depth;
821 missing_ground[5] = None;
822 missing_ground[6] = None;
823 missing_ground[9] = None;
824 missing_ground[10] = None;
825 let unit = contour::grid(0, grid, &missing_ground, limits).unwrap();
826 let result = contour::finish(&[unit], None, limits).unwrap();
827 assert_ne!(result.coordinates[0][0].len(), 5);
828 }
829 #[test]
830 fn intrinsic_wet_gate_matches_plain_sloping_float32_tolerance() {
831 let minimum = 100_f32;
832 let threshold = minimum + 0.001_f32;
833 assert_eq!(horizontal_surface(minimum, minimum), None);
834 assert_eq!(horizontal_surface(threshold, minimum), None);
835 let above = f32::from_bits(threshold.to_bits() + 1);
836 assert_eq!(horizontal_surface(above, minimum), Some(above));
837 assert_eq!(horizontal_surface(sloping::NODATA, -10000.), None);
838 assert_eq!(horizontal_surface(1., 0.).map(|w| w - 2.), Some(-1.));
839 }
840}