1use 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#[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 #[serde(default)]
97 pub velocity_normals_files: Option<Vec<String>>,
98
99 #[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)] fn 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 #[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 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 .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 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 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 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 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}