Skip to main content

ras_commander_fast_map/
boundary_session.rs

1//! Deterministic signed-depth boundary pipeline. Raster delivery is untouched.
2use 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}
181// Render-block coverage activates its four sample locations. Ground validity is
182// checked independently. This does not declare the original block a quad clip mask.
183fn 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}
308/// One bounded batch. The complete physical geometry and hydraulics remain global.
309pub 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    // Conservative geometry decode/ownership inflation plus shared tile payloads;
476    // remaining memory is explicitly divided among concurrent contour builders.
477    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    // A large CPU budget must not split contour memory into unusable units.
590    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        // An inactive render block is not a missing ground sample. Surrounding
798        // blocks can activate every corner of the central block. This regression
799        // records the prototype's vertex-activation behavior explicitly; native
800        // mask meaning must be established independently from this fixture.
801        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        // TIFF nodata at a vertex remains None even when the coverage block
819        // activates it: no synthetic ground value or flood depth is substituted.
820        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}