Skip to main content

ras_commander_fast_map/
sloping_session.rs

1//! Explicit sloping batch path with independently selected native rendering controls. Geometry, terrain and ownership are shared
2//! across profiles; hydraulic connectivity is reconstructed for every profile.
3use crate::{
4    MapError,
5    model::{self, CrsIdentity, NodataPolicy, QueryConvention},
6    sink,
7    sloping::{self, Cell, Corner, Face, Mesh},
8    terrain::NativeTerrain,
9};
10use rayon::prelude::*;
11use serde::Deserialize;
12use serde_json::{Value, json};
13use std::{collections::HashSet, fs, io::Write, path::Path, sync::Arc, time::Instant};
14
15type CachedGeometry = (Vec<Mesh>, Vec<Vec<Option<sloping::CellStencil>>>);
16type WindowKey = (
17    String,
18    String,
19    [usize; 4],
20    Vec<[usize; 2]>,
21    Vec<u8>,
22    Option<Vec<u8>>,
23);
24struct WindowData {
25    owner: Vec<u32>,
26    ground: Vec<f32>,
27    weights: Option<sloping::PixelWeights>,
28}
29
30/// One explicitly owned, serialized native session. At most one geometry,
31/// terrain and bounded output window are retained. Dropping it releases all.
32#[derive(Default)]
33pub struct PreparedSession {
34    terrain: Option<(String, Arc<NativeTerrain>)>,
35    geometry: Option<(String, Arc<CachedGeometry>)>,
36    window: Option<(WindowKey, Arc<WindowData>)>,
37    pool: Option<(usize, Arc<rayon::ThreadPool>)>,
38}
39
40fn identity(path: &Path) -> Result<String, MapError> {
41    let path = path.canonicalize()?;
42    let metadata = path.metadata()?;
43    Ok(format!(
44        "{:?}:{}:{:?}",
45        path,
46        metadata.len(),
47        metadata.modified()?
48    ))
49}
50fn geometry_key(req: &Request, base: &Path) -> Result<String, MapError> {
51    let mut key = String::new();
52    for filename in &req.mesh_files {
53        let path = model::resolve(base, filename);
54        key.push_str(&identity(&path)?);
55        let document: Value = serde_json::from_reader(fs::File::open(&path)?)
56            .map_err(|e| MapError::Invalid(e.to_string()))?;
57        if let Some(arrays) = document["arrays"].as_object() {
58            for descriptor in arrays.values() {
59                if let Some(name) = descriptor["path"].as_str() {
60                    key.push_str(&identity(&model::resolve(path.parent().unwrap(), name))?);
61                }
62            }
63        }
64    }
65    Ok(key)
66}
67
68pub const SCHEMA: &str = "ras-commander-sloping-request/1";
69
70#[derive(Deserialize)]
71#[serde(deny_unknown_fields)]
72pub struct Request {
73    pub schema: String,
74    #[serde(default)]
75    pub options: sloping::Options,
76    pub terrain: String,
77    pub epsg: u32,
78    pub crs_identity: CrsIdentity,
79    pub terrain_transform: [f64; 6],
80    pub window: [usize; 4],
81    pub geometry_file: String,
82    #[serde(default)]
83    pub aoi_file: Option<String>,
84    pub mesh_files: Vec<String>,
85    pub owner_cells: Vec<[usize; 2]>,
86    pub profiles: Vec<Profile>,
87    pub threads: usize,
88    #[serde(default = "one_profile")]
89    pub max_inflight_profiles: usize,
90    pub max_working_bytes: u64,
91    #[serde(default)]
92    pub stream_outputs: bool,
93    /// Per mesh: HDF-ordered float32 (x, y, polyline length) face normals.
94    /// With it, every profile supplies `face_velocity_files` and a velocity
95    /// magnitude product is rendered (method `rasmapper-sloping-velocity/0`).
96    #[serde(default)]
97    pub velocity_normals_files: Option<Vec<String>>,
98
99    /// Declares `terrain` as the top-priority source of a registered terrain.
100    #[serde(default)]
101    pub terrain_scope: Option<crate::terrain_scope::TerrainScope>,
102}
103
104fn one_profile() -> usize {
105    1
106}
107
108fn admit_profiles(
109    requested: usize,
110    threads: usize,
111    profiles: usize,
112    budget: u64,
113    serial: u64,
114    additional: u64,
115) -> Result<(usize, u64), MapError> {
116    if serial > budget || additional == 0 || requested == 0 || threads == 0 || profiles == 0 {
117        return Err(MapError::Budget(
118            "sloping decoder plan exceeds budget".into(),
119        ));
120    }
121    let limit = requested.min(threads).min(profiles);
122    let extra = ((budget - serial) / additional).min((limit - 1) as u64);
123    Ok((extra as usize + 1, serial + extra * additional))
124}
125
126fn map_batch<T: Sync, R: Send>(
127    batch: &[T],
128    pool: &rayon::ThreadPool,
129    compute: impl Fn(&T) -> Result<R, MapError> + Sync + Send,
130) -> Result<Vec<R>, MapError> {
131    pool.install(|| batch.par_iter().map(compute).collect())
132}
133
134struct RenderedProfile {
135    depth: Vec<f32>,
136    wse: Vec<f32>,
137    velocity: Option<Vec<f32>>,
138    hydraulic_seconds: f64,
139    render_seconds: f64,
140}
141
142#[allow(clippy::too_many_arguments)] // Explicit immutable geometry/window/cache borrows.
143fn render(
144    profile: &Profile,
145    base: &Path,
146    meshes: &[Mesh],
147    stencils: &[Vec<Option<sloping::CellStencil>>],
148    window: &sloping::RasterWindow<'_>,
149    pool: &rayon::ThreadPool,
150    options: sloping::Options,
151    weights: Option<&sloping::PixelWeights>,
152    normals: Option<&[Vec<crate::velocity::FaceNormal>]>,
153    foreign: &[usize],
154) -> Result<RenderedProfile, MapError> {
155    let tick = Instant::now();
156    let mut prepared = Vec::new();
157    let mut cell_values = Vec::new();
158    for ((mesh, stencil), filename) in meshes.iter().zip(stencils).zip(&profile.values_files) {
159        let wse = values(&model::resolve(base, filename), mesh.cells.len())?;
160        prepared.push(sloping::prepare_profile_with_options(
161            mesh, stencil, &wse, 0.001, pool, options,
162        )?);
163        if normals.is_some() {
164            cell_values.push(wse);
165        }
166    }
167    crate::terrain_scope::deny_wet_foreign(foreign, window.owner, window.owner_cells, |m, c| {
168        prepared[m].is_wet(c)
169    })?;
170    let hydraulic_seconds = tick.elapsed().as_secs_f64();
171    let tick = Instant::now();
172    let (mut depth, mut wse) =
173        sloping::render_profile_cached(stencils, &prepared, window, 0.001, pool, weights)?;
174    let render_seconds = tick.elapsed().as_secs_f64();
175    pool.install(|| {
176        depth
177            .par_iter_mut()
178            .zip(wse.par_iter_mut())
179            .for_each(|(d, w)| {
180                if *d == sloping::NODATA {
181                    *d = f32::NAN;
182                    *w = f32::NAN;
183                }
184            })
185    });
186    let velocity = match (normals, &profile.face_velocity_files) {
187        (Some(normals), Some(files)) => {
188            let mut face_velocity = Vec::new();
189            for (mesh, file) in meshes.iter().zip(files) {
190                face_velocity.push(crate::velocity::read_face_velocity(
191                    &model::resolve(base, file),
192                    mesh.faces.len(),
193                )?);
194            }
195            let inputs: Vec<_> = meshes
196                .iter()
197                .zip(stencils)
198                .zip(&cell_values)
199                .zip(normals)
200                .zip(&face_velocity)
201                .map(|((((mesh, stencil), wse), normal), velocity)| {
202                    (
203                        mesh,
204                        stencil.as_slice(),
205                        wse.as_slice(),
206                        normal.as_slice(),
207                        velocity.as_slice(),
208                    )
209                })
210                .collect();
211            Some(crate::velocity::render(&inputs, window, &depth, pool)?)
212        }
213        _ => None,
214    };
215    Ok(RenderedProfile {
216        depth,
217        wse,
218        velocity,
219        hydraulic_seconds,
220        render_seconds,
221    })
222}
223
224#[derive(Deserialize)]
225#[serde(deny_unknown_fields)]
226pub struct Profile {
227    pub id: String,
228    pub values_files: Vec<String>,
229    /// Per mesh: HDF-ordered float32 face-normal velocities of this profile.
230    #[serde(default)]
231    pub face_velocity_files: Option<Vec<String>>,
232}
233
234#[derive(Deserialize)]
235pub struct Geometry {
236    #[serde(default)]
237    pub vertex_elevations: Option<Vec<f32>>,
238    pub cells: Vec<InputCell>,
239    pub faces: Vec<InputFace>,
240    pub vertices: Vec<InputVertex>,
241}
242#[derive(Deserialize)]
243pub struct InputCell {
244    pub point: [f64; 2],
245    pub minimum_elevation: f32,
246    #[serde(rename = "virtual")]
247    pub virtual_cell: bool,
248    pub faces: Vec<usize>,
249}
250#[derive(Deserialize)]
251pub struct InputFace {
252    pub cells: [usize; 2],
253    pub vertices: [usize; 2],
254    pub minimum_elevation: f32,
255    pub perimeter: bool,
256    pub polyline: Vec<[f64; 2]>,
257}
258#[derive(Deserialize)]
259pub struct InputVertex {
260    pub point: [f64; 2],
261    pub faces: Vec<usize>,
262    pub ccw: Vec<usize>,
263    pub cw: Vec<usize>,
264}
265
266impl Geometry {
267    pub fn prepare(&self) -> Result<(Mesh, Vec<Option<sloping::CellStencil>>), MapError> {
268        if self
269            .cells
270            .iter()
271            .any(|c| c.virtual_cell != (c.faces.len() == 1))
272        {
273            return Err(MapError::Invalid(
274                "sloping virtual-cell flag contradicts topology".into(),
275            ));
276        }
277        let faces =
278            self.faces
279                .iter()
280                .map(|f| {
281                    let a = self.cells.get(f.cells[0]).ok_or_else(|| {
282                        MapError::Invalid("sloping cell address out of range".into())
283                    })?;
284                    let b = self.cells.get(f.cells[1]).ok_or_else(|| {
285                        MapError::Invalid("sloping cell address out of range".into())
286                    })?;
287                    Ok(Face {
288                        cells: f.cells,
289                        vertices: f.vertices,
290                        minimum: f.minimum_elevation,
291                        perimeter: f.perimeter,
292                        application: sloping::face_application(&f.polyline, [a.point, b.point])?,
293                    })
294                })
295                .collect::<Result<Vec<_>, MapError>>()?;
296        let mesh = Mesh {
297            cells: self
298                .cells
299                .iter()
300                .map(|c| Cell {
301                    minimum: c.minimum_elevation,
302                    virtual_cell: c.virtual_cell,
303                })
304                .collect(),
305            faces,
306            corners: self
307                .vertices
308                .iter()
309                .map(|v| Corner {
310                    point: v.point,
311                    faces: v.faces.clone(),
312                    ccw: v.ccw.clone(),
313                    cw: v.cw.clone(),
314                })
315                .collect(),
316        };
317        let cell_faces = self
318            .cells
319            .iter()
320            .map(|c| c.faces.clone())
321            .collect::<Vec<_>>();
322        let mut stencils = sloping::prepare_stencils(&mesh, &cell_faces)?;
323        for (cell, stencil) in stencils.iter_mut().enumerate() {
324            if let Some(stencil) = stencil {
325                for &face in &self.cells[cell].faces {
326                    let f = &self.faces[face];
327                    if f.cells[0] == cell {
328                        stencil.physical_ring.extend(f.polyline.iter().copied());
329                    } else {
330                        stencil
331                            .physical_ring
332                            .extend(f.polyline.iter().rev().copied());
333                    }
334                }
335                stencil.physical_ring.dedup();
336            }
337        }
338        if let Some(elevations) = &self.vertex_elevations {
339            sloping::set_vertex_elevations(&mut stencils, elevations, mesh.corners.len())?;
340        }
341        Ok((mesh, stencils))
342    }
343}
344
345pub fn method_record() -> Value {
346    json!({"method_id":"rasmapper-plain-sloping/1", "backend":"rust", "experimental":true,
347        "native_version_qualified":"7.0.1", "stream_outputs":true, "native_ownership":true,"aoi_mask":crate::aoi::METHOD,
348        "enhanced_options":true,"enhanced_method_id":"rasmapper-sloping-with-faces/1",
349        "depth_weighted_faces":true,
350        "native_donation_history":true,
351        "bounded_profile_concurrency":true,
352        "persistent_session":true,"bounded_pixel_weights":true,
353        "velocity":crate::velocity::METHOD_ID,
354
355        "terrain_scope":crate::terrain_scope::METHOD_ID,
356        "options":{"CellRenderMode":"Sloping","CellStencilMode":"JustFacepoints",
357            "FacepointAdjustmentMode":"None","ShallowBehaviorMode":"None",
358            "UseDepthWeightedFaces":false,"FaceWSMode":"Adjusted"},
359        "native_plot_tolerance":0.001_f32,"extra_depth_cutoff":null,
360        "scope":"2D cells; plain cell-corner sloping; no 1D, slopingPretty or additional rendering options",
361        "reuse":"geometry, terrio ownership and terrain prepared once; bounded Rayon pixel and reconstruction workers"})
362}
363
364fn selected_method(options: sloping::Options) -> Value {
365    let mut record = method_record();
366    if options.render_mode == sloping::RenderMode::WithFaces {
367        record["method_id"] = "rasmapper-sloping-with-faces/1".into();
368        record["options"]["CellStencilMode"] = "WithFaces".into();
369        record["options"]["ShallowBehaviorMode"] = if options.reduce_shallow_to_horizontal {
370            "ReduceToHorizontal"
371        } else {
372            "None"
373        }
374        .into();
375        record["options"]["UseDepthWeightedFaces"] = options.use_depth_weighted_faces.into();
376        record["scope"] = "physical 2D cells; cell-corner and face sloping; no 1D or standalone storage-area surfaces".into();
377    }
378    record
379}
380
381fn values(path: &Path, count: usize) -> Result<Vec<f32>, MapError> {
382    let expected = count
383        .checked_mul(4)
384        .ok_or_else(|| MapError::Budget("sloping values size overflow".into()))?;
385    if fs::metadata(path)?.len() != expected as u64 {
386        return Err(MapError::Invalid("sloping profile length mismatch".into()));
387    }
388    let raw = fs::read(path)?;
389    if raw.len() != expected {
390        return Err(MapError::Invalid("sloping profile changed size".into()));
391    }
392    let values: Vec<_> = raw
393        .as_chunks::<4>()
394        .0
395        .iter()
396        .map(|v| f32::from_le_bytes(*v))
397        .collect();
398    if values.iter().any(|v| !v.is_finite()) {
399        return Err(MapError::Invalid(
400            "sloping profiles require finite cell WSE including virtual cells".into(),
401        ));
402    }
403    Ok(values)
404}
405
406pub fn run(req: &Request, base: &Path, out: &Path) -> Result<Value, MapError> {
407    run_with_session(req, base, out, None)
408}
409
410pub fn run_with_session(
411    req: &Request,
412    base: &Path,
413    out: &Path,
414    mut session: Option<&mut PreparedSession>,
415) -> Result<Value, MapError> {
416    req.options.validate()?;
417    if let Some(scope) = &req.terrain_scope {
418        scope.validate()?;
419        if req.options.render_mode == sloping::RenderMode::WithFaces {
420            return Err(MapError::Unsupported(
421                "terrain_scope is qualified for plain sloping only".into(),
422            ));
423        }
424    }
425    let started = Instant::now();
426    let [col, row, width, height] = req.window;
427    let count = width
428        .checked_mul(height)
429        .filter(|&n| n > 0 && n <= model::MAX_PROFILE_PIXELS as usize)
430        .ok_or_else(|| MapError::Budget("sloping window empty or too large".into()))?;
431    if req.schema != SCHEMA
432        || !(1..=64).contains(&req.threads)
433        || !(1..=64).contains(&req.max_inflight_profiles)
434        || req.epsg == 0
435        || req.mesh_files.is_empty()
436        || req.owner_cells.is_empty()
437        || req.owner_cells.len() >= u32::MAX as usize
438        || req.profiles.is_empty()
439    {
440        return Err(MapError::Invalid("invalid sloping request".into()));
441    }
442    if let Some(files) = &req.velocity_normals_files
443        && files.len() != req.mesh_files.len()
444    {
445        return Err(MapError::Invalid(
446            "velocity_normals_files needs one file per mesh".into(),
447        ));
448    }
449    if req.profiles.iter().any(|p| {
450        p.face_velocity_files.is_some() != req.velocity_normals_files.is_some()
451            || p.face_velocity_files
452                .as_ref()
453                .is_some_and(|f| f.len() != req.mesh_files.len())
454    }) {
455        return Err(MapError::Invalid(
456            "velocity_normals_files and every profile's face_velocity_files (one per mesh) go together".into(),
457        ));
458    }
459    let mut ids = HashSet::new();
460    for profile in &req.profiles {
461        model::validate_id(&profile.id)?;
462        if !ids.insert(model::id_key(&profile.id))
463            || profile.values_files.len() != req.mesh_files.len()
464        {
465            return Err(MapError::Invalid(
466                "duplicate sloping profile id or mesh value count mismatch".into(),
467            ));
468        }
469    }
470    let geometry_bytes = req
471        .mesh_files
472        .iter()
473        .chain(std::iter::once(&req.geometry_file))
474        .chain(req.aoi_file.iter())
475        .try_fold(0_u64, |sum, p| {
476            sum.checked_add(fs::metadata(model::resolve(base, p))?.len())
477                .ok_or_else(|| MapError::Budget("sloping geometry size overflow".into()))
478        })?;
479    let binary_bytes = req.mesh_files.iter().try_fold(0_u64, |sum, name| {
480        sum.checked_add(crate::sloping_binary::input_bytes(&model::resolve(
481            base, name,
482        ))?)
483        .ok_or_else(|| MapError::Budget("sloping binary geometry size overflow".into()))
484    })?;
485    let geometry_bytes = geometry_bytes
486        .checked_add(binary_bytes)
487        .ok_or_else(|| MapError::Budget("sloping geometry size overflow".into()))?;
488    let cache = 64_u64 << 20;
489    // JSON parsing, topology, stencils, one hydraulic profile and IO copies are
490    // conservatively admitted, rather than claiming an OS-enforced RSS limit.
491    let persistent_bytes = if session.is_some() { 64_u64 << 20 } else { 0 };
492    let aoi_scratch = crate::aoi::scratch_bytes(
493        req.aoi_file
494            .as_ref()
495            .map(|p| model::resolve(base, p))
496            .as_deref(),
497        req.threads,
498    )?;
499    let planned = (count as u64)
500        .checked_mul(
501            if req.options.render_mode == sloping::RenderMode::WithFaces {
502                56
503            } else {
504                40
505            },
506        )
507        .and_then(|v| {
508            geometry_bytes
509                .checked_mul(
510                    if req.options.render_mode == sloping::RenderMode::WithFaces {
511                        20
512                    } else {
513                        16
514                    },
515                )
516                .and_then(|g| v.checked_add(g))
517        })
518        .and_then(|v| v.checked_add(cache))
519        .and_then(|v| v.checked_add(persistent_bytes))
520        .and_then(|v| v.checked_add(aoi_scratch))
521        // Terrain scope: one validity byte per expanded-window pixel.
522        .and_then(|v| {
523            v.checked_add(if req.terrain_scope.is_some() {
524                (width as u64 + 2) * (height as u64 + 2)
525            } else {
526                0
527            })
528        })
529        .ok_or_else(|| MapError::Budget("sloping array plan overflow".into()))?;
530    if planned > req.max_working_bytes {
531        return Err(MapError::Budget("sloping array plan exceeds budget".into()));
532    }
533    let key = if session.is_some() {
534        geometry_key(req, base)?
535    } else {
536        String::new()
537    };
538    // Release an incompatible previous model before opening/loading this one.
539    if let Some(s) = session.as_mut()
540        && s.geometry.as_ref().is_some_and(|(old, _)| old != &key)
541    {
542        s.geometry = None;
543        s.window = None;
544    }
545    let terrain_path = model::resolve(base, &req.terrain);
546    let terrain_key = if session.is_some() {
547        format!(
548            "{}:{}:{}",
549            identity(&terrain_path)?,
550            req.epsg,
551            serde_json::to_string(&req.crs_identity)
552                .map_err(|e| MapError::Invalid(e.to_string()))?
553        )
554    } else {
555        String::new()
556    };
557    let terrain_reused = session.as_ref().is_some_and(|s| {
558        s.terrain
559            .as_ref()
560            .is_some_and(|(key, _)| key == &terrain_key)
561    });
562    if !terrain_reused && let Some(s) = session.as_mut() {
563        s.terrain = None;
564        s.window = None;
565    }
566    let terrain = if terrain_reused {
567        session
568            .as_ref()
569            .unwrap()
570            .terrain
571            .as_ref()
572            .unwrap()
573            .1
574            .clone()
575    } else {
576        Arc::new(NativeTerrain::open(
577            model::resolve(base, &req.terrain)
578                .to_str()
579                .ok_or_else(|| MapError::Invalid("terrain path is not UTF8".into()))?,
580            req.epsg,
581            &req.crs_identity,
582            QueryConvention::PixelCenter,
583            NodataPolicy::Dry,
584            cache,
585        )?)
586    };
587    if let Some(s) = session.as_mut() {
588        s.terrain = Some((terrain_key.clone(), terrain.clone()));
589    }
590    if terrain.nodata.is_none()
591        || terrain.gt != req.terrain_transform
592        || (terrain.gt[1] + terrain.gt[5]).abs() > terrain.gt[1].abs() * 1e-12
593        || col.checked_add(width).is_none_or(|v| v > terrain.width)
594        || row.checked_add(height).is_none_or(|v| v > terrain.height)
595        || terrain.max_block_bytes() > cache
596    {
597        return Err(MapError::Unsupported(
598            "sloping terrain identity, square grid, block or window mismatch".into(),
599        ));
600    }
601    let admitted = planned
602        .checked_add(
603            if req.options.render_mode == sloping::RenderMode::WithFaces {
604                (terrain.block.0 as u64)
605                    .checked_mul(terrain.block.1 as u64)
606                    .and_then(|v| v.checked_mul(req.threads as u64))
607                    .and_then(|v| v.checked_mul(64))
608                    .ok_or_else(|| {
609                        MapError::Budget("native donation replay tile plan overflow".into())
610                    })?
611            } else {
612                0
613            },
614        )
615        .ok_or_else(|| MapError::Budget("native donation replay plan overflow".into()))?
616        .checked_add(
617            terrain
618                .decoder_transient_bytes_per_load()
619                .checked_mul(req.threads as u64)
620                .ok_or_else(|| MapError::Budget("sloping parallel decoder plan overflow".into()))?,
621        )
622        .ok_or_else(|| MapError::Budget("sloping decoder plan overflow".into()))?;
623    // Retain the serial plan. Each additional slot conservatively reserves
624    // output/work arrays and the existing full geometry/hydraulic allowance.
625    // This is planned admission, not an OS-enforced RSS limit.
626    let additional = (count as u64)
627        .checked_mul(
628            if req.options.render_mode == sloping::RenderMode::WithFaces {
629                32
630            } else {
631                16
632            },
633        )
634        .and_then(|v| {
635            geometry_bytes
636                .checked_mul(
637                    if req.options.render_mode == sloping::RenderMode::WithFaces {
638                        20
639                    } else {
640                        16
641                    },
642                )
643                .and_then(|g| v.checked_add(g))
644        })
645        .ok_or_else(|| MapError::Budget("sloping concurrent profile plan overflow".into()))?;
646    let (effective, admitted) = admit_profiles(
647        req.max_inflight_profiles,
648        req.threads,
649        req.profiles.len(),
650        req.max_working_bytes,
651        admitted,
652        additional,
653    )?;
654    fs::create_dir(out)?;
655    let mut report = json!({"schema":"ras-commander-sloping-report/1","status":"in_progress", "method":selected_method(req.options),
656        "threads":req.threads,"window":req.window,"planned_bytes":admitted,
657        "requested_inflight_profiles":req.max_inflight_profiles,"effective_inflight_profiles":effective,
658        "additional_profile_planned_bytes":additional,"profiles":[]});
659    sink::write_json(&out.join("report.json"), &report)?;
660    let execution = std::panic::catch_unwind(std::panic::AssertUnwindSafe(
661        || -> Result<(), MapError> {
662            if let Some(s) = session.as_mut()
663                && s.pool
664                    .as_ref()
665                    .is_some_and(|(threads, _)| *threads != req.threads)
666            {
667                s.pool = None;
668            }
669            let pool = if let Some((threads, pool)) = session
670                .as_ref()
671                .and_then(|s| s.pool.as_ref())
672                .filter(|(threads, _)| *threads == req.threads)
673            {
674                let _ = threads;
675                pool.clone()
676            } else {
677                Arc::new(
678                    rayon::ThreadPoolBuilder::new()
679                        .num_threads(req.threads)
680                        .build()
681                        .map_err(|e| MapError::Internal(e.to_string()))?,
682                )
683            };
684            if let Some(s) = session.as_mut() {
685                s.pool = Some((req.threads, pool.clone()));
686            }
687            let tick = Instant::now();
688            let geometry_reused = session
689                .as_ref()
690                .is_some_and(|s| s.geometry.as_ref().is_some_and(|(old, _)| old == &key));
691            if !geometry_reused && let Some(s) = session.as_mut() {
692                s.geometry = None;
693                s.window = None;
694            }
695            let geometry = if geometry_reused {
696                session
697                    .as_ref()
698                    .unwrap()
699                    .geometry
700                    .as_ref()
701                    .unwrap()
702                    .1
703                    .clone()
704            } else {
705                let mut meshes = Vec::new();
706                let mut stencils = Vec::new();
707                for filename in &req.mesh_files {
708                    let source = crate::sloping_binary::load(&model::resolve(base, filename))?;
709                    let (mesh, stencil) = source.prepare()?;
710                    meshes.push(mesh);
711                    stencils.push(stencil);
712                }
713                Arc::new((meshes, stencils))
714            };
715            if let Some(s) = session.as_mut() {
716                s.geometry = Some((key.clone(), geometry.clone()));
717            }
718            let (meshes, stencils) = &*geometry;
719            let velocity_normals = req
720                .velocity_normals_files
721                .as_ref()
722                .map(|files| -> Result<Vec<_>, MapError> {
723                    files
724                        .iter()
725                        .zip(meshes)
726                        .map(|(file, mesh)| {
727                            crate::velocity::read_normals(
728                                &model::resolve(base, file),
729                                mesh.faces.len(),
730                            )
731                        })
732                        .collect()
733                })
734                .transpose()?;
735            if velocity_normals.is_some() {
736                report["velocity"] = json!({"method": crate::velocity::method_record()});
737            }
738            report["session"] = json!({"terrain_reused":terrain_reused,"geometry_reused":geometry_reused,"retained_window_budget_bytes":persistent_bytes});
739            for &[mesh, cell] in &req.owner_cells {
740                if mesh >= meshes.len()
741                    || cell >= meshes[mesh].cells.len()
742                    || meshes[mesh].cells[cell].virtual_cell
743                {
744                    return Err(MapError::Invalid(
745                        "sloping ownership requires physical mesh cells".into(),
746                    ));
747                }
748            }
749            report["geometry_prepare_seconds"] = tick.elapsed().as_secs_f64().into();
750            report["ownership_prepare_seconds"] = 0.0.into();
751            report["terrain_prepare_seconds"] = 0.0.into();
752            let transform = [
753                terrain.gt[0] + col as f64 * terrain.gt[1],
754                terrain.gt[1],
755                0.,
756                terrain.gt[3] + row as f64 * terrain.gt[5],
757                0.,
758                terrain.gt[5],
759            ];
760            let grid = terrio::ops::rasterize::Grid {
761                geo_transform: transform,
762                cols: width,
763                rows: height,
764                crs: Default::default(),
765            };
766            let tick = Instant::now();
767            let window_key = if session.is_some() {
768                (
769                    terrain_key.clone(),
770                    key.clone(),
771                    req.window,
772                    req.owner_cells.clone(),
773                    fs::read(model::resolve(base, &req.geometry_file))?,
774                    req.aoi_file
775                        .as_ref()
776                        .map(|p| fs::read(model::resolve(base, p)))
777                        .transpose()?,
778                )
779            } else {
780                (
781                    String::new(),
782                    String::new(),
783                    req.window,
784                    Vec::new(),
785                    Vec::new(),
786                    None,
787                )
788            };
789            let window_reused = session
790                .as_ref()
791                .is_some_and(|s| s.window.as_ref().is_some_and(|(old, _)| old == &window_key));
792            if !window_reused && let Some(s) = session.as_mut() {
793                s.window = None;
794            }
795            let mut data = if window_reused {
796                session.as_ref().unwrap().window.as_ref().unwrap().1.clone()
797            } else {
798                let cells = crate::ownership::load(
799                    &model::resolve(base, &req.geometry_file),
800                    req.owner_cells.len(),
801                    false,
802                )?;
803                let mut owner = pool.install(|| crate::ownership::burn(cells, grid.clone()))?;
804                if let Some(filename) = &req.aoi_file {
805                    let mask = pool.install(|| {
806                        crate::aoi::mask(&model::resolve(base, filename), terrain.gt, req.window)
807                    })?;
808                    pool.install(|| {
809                        owner
810                            .par_iter_mut()
811                            .zip(mask.par_iter())
812                            .for_each(|(label, &m)| {
813                                if m == 0 {
814                                    *label = 0;
815                                }
816                            })
817                    });
818                }
819                report["ownership_prepare_seconds"] = tick.elapsed().as_secs_f64().into();
820                let tick = Instant::now();
821                let mut ground = vec![sloping::NODATA; count];
822                let (bw, bh) = terrain.block;
823                // Row stripes own disjoint output slices. Decodes run inside the same
824                // bounded pool, and the shared single-flight cache handles boundary
825                // blocks seen by adjacent stripes. Skip blocks outside ownership/AOI.
826                let stripe_pixels = width
827                    .checked_mul(bh)
828                    .ok_or_else(|| MapError::Budget("sloping terrain stripe overflow".into()))?;
829                pool.install(|| {
830                    ground
831                        .par_chunks_mut(stripe_pixels)
832                        .enumerate()
833                        .try_for_each(|(stripe, destination)| -> Result<(), MapError> {
834                            let first = row + stripe * bh;
835                            let last = first + destination.len() / width;
836                            for by in first / bh..=(last - 1) / bh {
837                                for bx in col / bw..=(col + width - 1) / bw {
838                                    let win = terrain.block_window(bx, by);
839                                    let r0 = first.max(win.y);
840                                    let r1 = last.min(win.y + win.height);
841                                    let c0 = col.max(win.x);
842                                    let c1 = (col + width).min(win.x + win.width);
843                                    if !(r0..r1).any(|r| {
844                                        owner[(r - row) * width + c0 - col
845                                            ..(r - row) * width + c1 - col]
846                                            .iter()
847                                            .any(|&label| label != 0)
848                                    }) {
849                                        continue;
850                                    }
851                                    let block = terrain.get_block(bx, by)?;
852                                    for r in r0..r1 {
853                                        for c in c0..c1 {
854                                            if owner[(r - row) * width + c - col] == 0 {
855                                                continue;
856                                            }
857                                            if let Some(value) = terrain.classify(
858                                                block[(r - win.y) * win.width + c - win.x],
859                                            ) {
860                                                if !value.is_finite() {
861                                                    return Err(MapError::Terrain(
862                                                        "nonfinite sloping terrain".into(),
863                                                    ));
864                                                }
865                                                destination[(r - first) * width + c - col] =
866                                                    value as f32;
867                                            }
868                                        }
869                                    }
870                                }
871                            }
872                            Ok(())
873                        })
874                })?;
875                report["terrain_prepare_seconds"] = tick.elapsed().as_secs_f64().into();
876                Arc::new(WindowData {
877                    owner,
878                    ground,
879                    weights: None,
880                })
881            };
882            let (bw, bh) = terrain.block;
883            if session.is_some()
884                && !window_reused
885                && count as u64 * 8 < persistent_bytes / 2
886                && req.options.render_mode != sloping::RenderMode::WithFaces
887            {
888                let window = sloping::RasterWindow {
889                    source_transform: terrain.gt,
890                    source_tile: [bw, bh],
891                    width,
892                    height,
893                    transform,
894                    owner: &data.owner,
895                    terrain: &data.ground,
896                    owner_cells: &req.owner_cells,
897                };
898                let weights = sloping::PixelWeights::prepare(
899                    stencils,
900                    &window,
901                    (persistent_bytes / 2) as usize,
902                    &pool,
903                )?;
904                Arc::get_mut(&mut data).unwrap().weights = Some(weights);
905            }
906            let weight_bytes = data.weights.as_ref().map_or(0, |w| w.bytes());
907            report["session"]["window_reused"] = window_reused.into();
908            report["session"]["pixel_weight_cache_bytes"] = weight_bytes.into();
909            let window_bytes = count as u64 * 8
910                + weight_bytes as u64
911                + window_key.0.len() as u64
912                + window_key.1.len() as u64
913                + (window_key.3.len() * std::mem::size_of::<[usize; 2]>()) as u64
914                + window_key.4.len() as u64
915                + window_key.5.as_ref().map_or(0, |v| v.len()) as u64;
916            if window_bytes <= persistent_bytes
917                && let Some(s) = session.as_mut()
918            {
919                s.window = Some((window_key, data.clone()));
920            }
921            report["session"]["retained_window_bytes"] =
922                if session.is_some() && window_bytes <= persistent_bytes {
923                    window_bytes
924                } else {
925                    0
926                }
927                .into();
928            let window = sloping::RasterWindow {
929                source_transform: terrain.gt,
930                source_tile: [bw, bh],
931                width,
932                height,
933                transform,
934                owner: &data.owner,
935                terrain: &data.ground,
936                owner_cells: &req.owner_cells,
937            };
938            let foreign = match &req.terrain_scope {
939                Some(scope) => {
940                    let scoped = crate::terrain_scope::check(
941                        scope,
942                        base,
943                        &terrain,
944                        req.window,
945                        &data.owner,
946                    )?;
947                    report["terrain_scope"] = scoped.record;
948                    scoped.foreign
949                }
950                None => Vec::new(),
951            };
952            for batch in req.profiles.chunks(effective) {
953                let results = map_batch(batch, &pool, |profile| {
954                    render(
955                        profile,
956                        base,
957                        meshes,
958                        stencils,
959                        &window,
960                        &pool,
961                        req.options,
962                        data.weights.as_ref(),
963                        velocity_normals.as_deref(),
964                        &foreign,
965                    )
966                })?;
967                // Indexed collection and ordered emission preserve the pipe/report
968                // contract even when later profiles finish first.
969                for (profile, rendered) in batch.iter().zip(results) {
970                    let RenderedProfile {
971                        depth,
972                        wse,
973                        velocity,
974                        hydraulic_seconds,
975                        render_seconds,
976                    } = rendered;
977                    let dir = out.join(&profile.id);
978                    fs::create_dir(&dir)?;
979                    if req.stream_outputs {
980                        let mut stream =
981                            std::io::BufWriter::with_capacity(65536, std::io::stdout().lock());
982                        let mut header =
983                            json!({"id":profile.id,"status":"complete","pixels":count});
984                        if velocity.is_some() {
985                            header["velocity"] = true.into();
986                        }
987                        writeln!(stream, "{header}")?;
988                        crate::horizontal::write_f32(&mut stream, &depth)?;
989                        crate::horizontal::write_f32(&mut stream, &wse)?;
990                        if let Some(velocity) = &velocity {
991                            crate::horizontal::write_f32(&mut stream, velocity)?;
992                        }
993                        stream.flush()?;
994                    } else {
995                        crate::horizontal::write_values(&dir.join("depth.bin"), &depth)?;
996                        crate::horizontal::write_values(&dir.join("wse.bin"), &wse)?;
997                        if let Some(velocity) = &velocity {
998                            crate::horizontal::write_values(&dir.join("velocity.bin"), velocity)?;
999                        }
1000                    }
1001                    let unit = json!({"id":profile.id,"status":"complete","hydraulic_seconds":hydraulic_seconds,"render_seconds":render_seconds});
1002                    sink::write_json(&dir.join("unit.json"), &unit)?;
1003                    report["profiles"].as_array_mut().unwrap().push(unit);
1004                    sink::write_json(&out.join("report.json"), &report)?;
1005                }
1006            }
1007            Ok(())
1008        },
1009    ));
1010    match execution {
1011        Ok(Ok(())) => report["status"] = "complete".into(),
1012        other => {
1013            report["status"] = "aborted".into();
1014            report["error"] = match other {
1015                Ok(Err(e)) => e.to_string(),
1016                _ => "sloping internal panic".into(),
1017            }
1018            .into();
1019            for profile in &req.profiles {
1020                let dir = out.join(&profile.id);
1021                for filename in [
1022                    "depth.bin",
1023                    "wse.bin",
1024                    "depth.bin.partial",
1025                    "wse.bin.partial",
1026                ] {
1027                    let _ = fs::remove_file(dir.join(filename));
1028                }
1029                if dir.is_dir() {
1030                    sink::write_json(
1031                        &dir.join("unit.json"),
1032                        &json!({"id":profile.id,"status":"failed","error":"session aborted"}),
1033                    )?;
1034                }
1035            }
1036            report["profiles"] = req
1037                .profiles
1038                .iter()
1039                .map(|p| json!({"id":p.id,"status":"failed","error":"session aborted"}))
1040                .collect::<Vec<_>>()
1041                .into();
1042        }
1043    }
1044    report["total_seconds"] = started.elapsed().as_secs_f64().into();
1045    report["terrain_cache"] = json!(terrain.stats());
1046    sink::write_json(&out.join("report.json"), &report)?;
1047    Ok(report)
1048}
1049
1050#[cfg(test)]
1051mod tests {
1052    use super::*;
1053    use std::sync::{
1054        Barrier,
1055        atomic::{AtomicUsize, Ordering},
1056    };
1057
1058    #[test]
1059    fn admission_caps_threads_profiles_memory_and_overflow() {
1060        assert_eq!(admit_profiles(8, 4, 6, 1000, 100, 50).unwrap(), (4, 250));
1061        assert_eq!(admit_profiles(8, 4, 2, 1000, 100, 50).unwrap(), (2, 150));
1062        assert_eq!(admit_profiles(8, 4, 6, 149, 100, 50).unwrap(), (1, 100));
1063        assert!(admit_profiles(8, 4, 6, 99, 100, 50).is_err());
1064        assert_eq!(
1065            admit_profiles(64, 64, 64, u64::MAX, u64::MAX - 1, u64::MAX).unwrap(),
1066            (1, u64::MAX - 1)
1067        );
1068        assert!(admit_profiles(0, 1, 1, 100, 50, 10).is_err());
1069    }
1070
1071    #[test]
1072    fn batches_really_overlap_without_exceeding_bound_and_keep_order() {
1073        let pool = rayon::ThreadPoolBuilder::new()
1074            .num_threads(2)
1075            .build()
1076            .unwrap();
1077        let active = AtomicUsize::new(0);
1078        let peak = AtomicUsize::new(0);
1079        let barrier = Barrier::new(2);
1080        let mut output = Vec::new();
1081        for batch in [0, 1, 2, 3].chunks(2) {
1082            output.extend(
1083                map_batch(batch, &pool, |&i| {
1084                    let n = active.fetch_add(1, Ordering::SeqCst) + 1;
1085                    peak.fetch_max(n, Ordering::SeqCst);
1086                    barrier.wait();
1087                    active.fetch_sub(1, Ordering::SeqCst);
1088                    Ok(i)
1089                })
1090                .unwrap(),
1091            );
1092        }
1093        assert_eq!(peak.load(Ordering::SeqCst), 2);
1094        assert_eq!(output, vec![0, 1, 2, 3]);
1095        assert!(
1096            map_batch(&[0, 1], &pool, |_| -> Result<(), MapError> {
1097                Err(MapError::Invalid("failed profile".into()))
1098            })
1099            .is_err()
1100        );
1101    }
1102}