Skip to main content

ras_commander_fast_map/
sloping.rs

1//! Experimental sloping reconstruction. Native qualification is stage-specific.
2//!
3//! Topology is immutable; hydraulic connectivity and corner arcs are rebuilt for each
4//! profile. A supplied Rayon pool bounds parallel work without changing summation order.
5//! Raster ownership, polygon weights and final depth delivery are separate stages.
6
7use crate::MapError;
8use rayon::prelude::*;
9use std::sync::atomic::{AtomicBool, Ordering};
10
11/// User-visible RAS Mapper sloping controls. Internal experimental modes are excluded.
12#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Deserialize, serde::Serialize)]
13#[serde(deny_unknown_fields)]
14pub struct Options {
15    #[serde(default)]
16    pub render_mode: RenderMode,
17    #[serde(default)]
18    pub reduce_shallow_to_horizontal: bool,
19    #[serde(default)]
20    pub use_depth_weighted_faces: bool,
21}
22#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Deserialize, serde::Serialize)]
23pub enum RenderMode {
24    #[default]
25    #[serde(rename = "sloping")]
26    Corners,
27    #[serde(rename = "slopingPretty")]
28    WithFaces,
29}
30impl Options {
31    pub fn validate(self) -> Result<(), MapError> {
32        if self.render_mode == RenderMode::Corners
33            && (self.reduce_shallow_to_horizontal || self.use_depth_weighted_faces)
34        {
35            return Err(MapError::Invalid(
36                "shallow and depth-weighted controls require slopingPretty".into(),
37            ));
38        }
39        Ok(())
40    }
41}
42
43pub const NODATA: f32 = -9999.0;
44
45/// Native uncached face application point: the first centre-line intersection
46/// is accepted only within the middle 90% of the full face polyline.
47pub fn face_application(points: &[[f64; 2]], centers: [[f64; 2]; 2]) -> Result<[f64; 2], MapError> {
48    if points.len() < 2
49        || points
50            .iter()
51            .flatten()
52            .chain(centers.iter().flatten())
53            .any(|v| !v.is_finite())
54    {
55        return Err(MapError::Invalid(
56            "invalid sloping face polyline or centres".into(),
57        ));
58    }
59    let [c, d] = centers;
60    let cx = d[0] - c[0];
61    let cy = d[1] - c[1];
62    let length = |a: [f64; 2], b: [f64; 2]| {
63        let x = b[0] - a[0];
64        let y = b[1] - a[1];
65        (x * x + y * y).sqrt()
66    };
67    let total = points.windows(2).map(|p| length(p[0], p[1])).sum::<f64>();
68    let mut preceding = 0.0;
69    for segment in points.windows(2) {
70        let [a, b] = [segment[0], segment[1]];
71        let before = preceding;
72        let segment_length = length(a, b);
73        preceding += segment_length;
74        if (0..2).any(|axis| {
75            a[axis].max(b[axis]) + 1e-6 < c[axis].min(d[axis])
76                || c[axis].max(d[axis]) + 1e-6 < a[axis].min(b[axis])
77        }) {
78            continue;
79        }
80        let ax = b[0] - a[0];
81        let ay = b[1] - a[1];
82        let determinant = cy * ax - cx * ay;
83        if determinant == 0.0 {
84            continue;
85        }
86        let dy = a[1] - c[1];
87        let dx = a[0] - c[0];
88        let t = (cx * dy - cy * dx) / determinant;
89        let u = (ax * dy - ay * dx) / determinant;
90        if t > -1e-6 && t < 1.0 + 1e-6 && u > -1e-6 && u < 1.0 + 1e-6 {
91            let fraction = (before + t.clamp(0.0, 1.0) * segment_length) / total;
92            if fraction > 0.05 && fraction < 0.95 {
93                // Native returns XY computed before clamping the segment fractions.
94                return Ok([a[0] + t * ax, a[1] + t * ay]);
95            }
96            break;
97        }
98    }
99    let half = total * 0.5;
100    let mut running = 0.0;
101    for segment in points.windows(2) {
102        let before = running;
103        let segment_length = length(segment[0], segment[1]);
104        running += segment_length;
105        if running >= half && segment_length > 0.0 {
106            let t = (half - before) / segment_length;
107            return Ok([
108                segment[0][0] + t * (segment[1][0] - segment[0][0]),
109                segment[0][1] + t * (segment[1][1] - segment[0][1]),
110            ]);
111        }
112    }
113    Ok(*points.last().unwrap())
114}
115
116#[derive(Clone, Copy, Debug, PartialEq, Eq)]
117pub enum Connection {
118    None,
119    Levee,
120    Backfill,
121    DownhillDeep,
122    DownhillIntermediate,
123    DownhillShallow,
124}
125
126impl Connection {
127    pub fn connected(self) -> bool {
128        !matches!(self, Self::None | Self::Levee)
129    }
130}
131
132#[derive(Clone, Copy, Debug)]
133pub struct Cell {
134    pub minimum: f32,
135    pub virtual_cell: bool,
136}
137
138#[derive(Clone, Debug)]
139pub struct Face {
140    pub cells: [usize; 2],
141    pub vertices: [usize; 2],
142    pub minimum: f32,
143    pub perimeter: bool,
144    pub application: [f64; 2],
145}
146
147#[derive(Clone, Debug)]
148pub struct Corner {
149    pub point: [f64; 2],
150    pub faces: Vec<usize>,
151    pub ccw: Vec<usize>,
152    pub cw: Vec<usize>,
153}
154
155#[derive(Clone, Copy, Debug)]
156pub struct FaceWater {
157    pub values: [f32; 2],
158    pub connection: Connection,
159}
160
161/// Oriented native mesh incidence. Face endpoint order is not a spatial sort key.
162pub struct Mesh {
163    pub cells: Vec<Cell>,
164    pub faces: Vec<Face>,
165    pub corners: Vec<Corner>,
166}
167
168pub struct Reconstruction {
169    pub faces: Vec<FaceWater>,
170    pub corners: Vec<Vec<f32>>,
171}
172
173impl Mesh {
174    pub fn validate(&self) -> Result<(), MapError> {
175        if self.cells.is_empty()
176            || self.faces.is_empty()
177            || self.corners.is_empty()
178            || self.cells.iter().any(|c| !c.minimum.is_finite())
179        {
180            return Err(MapError::Invalid(
181                "empty sloping mesh or nonfinite cell minimum".into(),
182            ));
183        }
184        for face in &self.faces {
185            if face.cells.iter().any(|&i| i >= self.cells.len())
186                || face.vertices.iter().any(|&i| i >= self.corners.len())
187                || !face.minimum.is_finite()
188                || face.application.iter().any(|v| !v.is_finite())
189            {
190                return Err(MapError::Invalid(
191                    "sloping face incidence out of range".into(),
192                ));
193            }
194        }
195        for (vertex, corner) in self.corners.iter().enumerate() {
196            let n = corner.faces.len();
197            if corner.ccw.len() != n
198                || corner.cw.len() != n
199                || corner.point.iter().any(|v| !v.is_finite())
200            {
201                return Err(MapError::Invalid(
202                    "sloping corner ring length mismatch".into(),
203                ));
204            }
205            for j in 0..n {
206                let face = self
207                    .faces
208                    .get(corner.faces[j])
209                    .ok_or_else(|| MapError::Invalid("sloping corner face out of range".into()))?;
210                if !face.vertices.contains(&vertex)
211                    || corner.ccw[j] >= n
212                    || corner.cw[j] >= n
213                    || corner.cw[corner.ccw[j]] != j
214                {
215                    return Err(MapError::Invalid(
216                        "invalid sloping corner orientation".into(),
217                    ));
218                }
219            }
220            if n > 0 {
221                let mut j = 0;
222                for step in 0..n {
223                    j = corner.ccw[j];
224                    if (j == 0) != (step + 1 == n) {
225                        return Err(MapError::Invalid(
226                            "sloping incidence is not one ring".into(),
227                        ));
228                    }
229                }
230            }
231        }
232        Ok(())
233    }
234
235    pub fn reconstruct(
236        &self,
237        wse: &[f32],
238        plot_tolerance: f32,
239        pool: &rayon::ThreadPool,
240    ) -> Result<Reconstruction, MapError> {
241        self.validate()?;
242        if wse.len() != self.cells.len()
243            || wse.iter().any(|v| !v.is_finite())
244            || !plot_tolerance.is_finite()
245            || plot_tolerance < 0.0
246        {
247            return Err(MapError::Invalid(
248                "invalid sloping profile or native plot tolerance".into(),
249            ));
250        }
251        pool.install(|| {
252            let faces: Vec<_> = self
253                .faces
254                .par_iter()
255                .map(|face| reconstruct_face(face, &self.cells, wse, plot_tolerance))
256                .collect();
257            let corners = self
258                .corners
259                .par_iter()
260                .enumerate()
261                .map(|(i, corner)| reconstruct_corner(i, corner, &self.faces, &faces))
262                .collect();
263            Ok(Reconstruction { faces, corners })
264        })
265    }
266}
267
268/// Preserve binary32 intermediate rounding and native face classification.
269fn reconstruct_face(face: &Face, cells: &[Cell], wse: &[f32], tolerance: f32) -> FaceWater {
270    let [a, b] = face.cells;
271    let [wa, wb] = [wse[a], wse[b]];
272    let [za, zb] = [cells[a].minimum, cells[b].minimum];
273    let dry_a = wa <= za + tolerance;
274    let dry_b = wb <= zb + tolerance;
275    if dry_a || dry_b || face.perimeter {
276        return FaceWater {
277            values: [
278                if dry_a || cells[a].virtual_cell {
279                    NODATA
280                } else {
281                    wa
282                },
283                if dry_b || cells[b].virtual_cell {
284                    NODATA
285                } else {
286                    wb
287                },
288            ],
289            connection: Connection::None,
290        };
291    }
292    if wa <= face.minimum && wb <= face.minimum {
293        return FaceWater {
294            values: [wa, wb],
295            connection: Connection::None,
296        };
297    }
298    let (upstream, high, low) = if wa > wb { (a, wa, wb) } else { (b, wb, wa) };
299    let floor = cells[upstream].minimum;
300    let elevation_delta = zb - za;
301    let backfill = (wb - wa) * elevation_delta <= 0.0;
302    let relief = elevation_delta.abs();
303    let depth = high - floor;
304    let downstream = face.minimum.max(low);
305    let submerged = downstream - floor;
306    let head = high - face.minimum;
307    let average = (wa + wb) * 0.5;
308    let critical = head * (2.0_f32 / 3.0_f32) + face.minimum;
309    let critical_cap = average.partial_cmp(&critical) != Some(std::cmp::Ordering::Greater);
310    let levee = critical_cap && depth / head > 2.0;
311    // The current adjusted native method retains the upstream WSE here;
312    // the critical computation classifies connectivity, not an averaged level.
313    if levee {
314        return FaceWater {
315            values: if upstream == a {
316                [high, wb]
317            } else {
318                [wa, high]
319            },
320            connection: Connection::Levee,
321        };
322    }
323    if backfill {
324        return FaceWater {
325            values: [high; 2],
326            connection: Connection::Backfill,
327        };
328    }
329    if depth >= 2.0 * relief {
330        return FaceWater {
331            values: [high; 2],
332            connection: Connection::DownhillDeep,
333        };
334    }
335    let mut value = downstream + (depth * depth - submerged * submerged) / (2.0 * relief);
336    let connection = if depth > relief {
337        value = ((2.0 * relief - depth) * value + (depth - relief) * high) / relief;
338        Connection::DownhillIntermediate
339    } else {
340        Connection::DownhillShallow
341    };
342    FaceWater {
343        values: [value; 2],
344        connection,
345    }
346}
347
348#[derive(Default)]
349struct Regression {
350    xx: f64,
351    x: f64,
352    yy: f64,
353    y: f64,
354    z: f64,
355    xy: f64,
356    yz: f64,
357    xz: f64,
358    n: usize,
359}
360
361impl Regression {
362    fn add(&mut self, origin: [f64; 2], point: [f64; 2], value: f32) {
363        let x = point[0] - origin[0];
364        let y = point[1] - origin[1];
365        let z = f64::from(value);
366        self.xx += x * x;
367        self.x += x;
368        self.yy += y * y;
369        self.y += y;
370        self.z += z;
371        self.xy += x * y;
372        self.yz += y * z;
373        self.xz += x * z;
374        self.n += 1;
375    }
376
377    fn solve(&self) -> f32 {
378        if self.n == 1 {
379            return self.z as f32;
380        }
381        if self.n == 2 {
382            return (self.z * 0.5) as f32;
383        }
384        let n = self.n as f64;
385        let determinant = self.xx * (self.yy * n - self.y * self.y)
386            - self.xy * (self.xy * n - self.y * self.x)
387            + self.x * (self.xy * self.y - self.yy * self.x);
388        if determinant == 0.0 {
389            return (self.z / n) as f32;
390        }
391        ((self.xx * (self.yy * self.z - self.yz * self.y)
392            - self.xy * (self.xy * self.z - self.yz * self.x)
393            + self.xz * (self.xy * self.y - self.yy * self.x))
394            / determinant) as f32
395    }
396}
397
398fn reconstruct_corner(
399    vertex: usize,
400    corner: &Corner,
401    faces: &[Face],
402    values: &[FaceWater],
403) -> Vec<f32> {
404    let mut output = vec![NODATA; corner.faces.len()];
405    if corner
406        .faces
407        .iter()
408        .all(|&f| values[f].values == [NODATA; 2])
409    {
410        return output;
411    }
412    for j in 0..corner.faces.len() {
413        if output[j] != NODATA {
414            continue;
415        }
416        let mut stop = corner.ccw[j];
417        while values[corner.faces[stop]].connection.connected() && stop != j {
418            stop = corner.ccw[stop];
419        }
420        let mut first = j;
421        if first != stop {
422            while values[corner.faces[first]].connection.connected() {
423                first = corner.cw[first];
424            }
425        }
426        let mut regression = Regression::default();
427        let mut current = first;
428        loop {
429            let f = corner.faces[current];
430            let side = usize::from(faces[f].vertices[0] != vertex);
431            let value = values[f].values[side];
432            if value != NODATA {
433                regression.add(corner.point, faces[f].application, value);
434            }
435            output[current] = value;
436            current = corner.ccw[current];
437            if current == stop {
438                break;
439            }
440        }
441        let boundary = corner.faces[stop];
442        if !values[boundary].connection.connected() {
443            let side = usize::from(faces[boundary].vertices[0] == vertex);
444            let value = values[boundary].values[side];
445            if value != NODATA {
446                regression.add(corner.point, faces[boundary].application, value);
447            }
448        }
449        if regression.n > 0 {
450            let value = regression.solve();
451            current = first;
452            loop {
453                output[current] = value;
454                current = corner.ccw[current];
455                if current == stop {
456                    break;
457                }
458            }
459        }
460        if first == stop {
461            break;
462        }
463    }
464    output
465}
466
467/// Immutable coefficients for oriented face endpoints, not a centre-point TIN.
468pub struct PolygonStencil {
469    points: Vec<[f64; 2]>,
470    corner_products: Vec<f64>,
471}
472
473/// Reuse one scratch buffer per worker; no per-pixel allocation is necessary.
474pub struct WeightScratch {
475    clipped: bool,
476    cross: Vec<f32>,
477    products: Vec<f64>,
478    weights: Vec<f32>,
479    donated: Vec<f32>,
480    clockwise: Vec<f32>,
481    counterclockwise: Vec<f32>,
482}
483
484fn cross(a: [f64; 2], b: [f64; 2], c: [f64; 2]) -> f64 {
485    let x1 = b[0] - a[0];
486    let y1 = b[1] - a[1];
487    let x2 = c[0] - a[0];
488    let y2 = c[1] - a[1];
489    x1 * y2 - y1 * x2
490}
491
492impl WeightScratch {
493    /// Clears the retained donation scratch (native: every 256 points).
494    pub(crate) fn clear_donation_history(&mut self) {
495        self.clockwise.fill(0.);
496        self.counterclockwise.fill(0.);
497    }
498
499    pub fn new(vertices: usize) -> Self {
500        Self {
501            clipped: false,
502            cross: vec![0.0; vertices],
503            products: vec![0.0; vertices],
504            weights: vec![0.0; vertices],
505            donated: vec![0.0; 2 * vertices],
506            clockwise: vec![0.0; vertices],
507            counterclockwise: vec![0.0; vertices],
508        }
509    }
510
511    fn resize(&mut self, vertices: usize) {
512        self.cross.resize(vertices, 0.0);
513        self.products.resize(vertices, 0.0);
514        self.weights.resize(vertices, 0.0);
515        self.donated.resize(2 * vertices, 0.0);
516        self.clockwise.resize(vertices, 0.0);
517        self.counterclockwise.resize(vertices, 0.0);
518    }
519}
520
521impl PolygonStencil {
522    // The native square test intentionally rounds translated coordinates to f32.
523    fn square_bounds(&self) -> Option<[f64; 4]> {
524        if self.points.len() != 4 {
525            return None;
526        }
527        let min_x = self
528            .points
529            .iter()
530            .map(|p| p[0])
531            .fold(f64::INFINITY, f64::min);
532        let min_y = self
533            .points
534            .iter()
535            .map(|p| p[1])
536            .fold(f64::INFINITY, f64::min);
537        let x: Vec<f32> = self.points.iter().map(|p| (p[0] - min_x) as f32).collect();
538        let y: Vec<f32> = self.points.iter().map(|p| (p[1] - min_y) as f32).collect();
539        let cx = (x[0] + x[1] + x[2] + x[3]) / 4.0;
540        let cy = (y[0] + y[1] + y[2] + y[3]) / 4.0;
541        let radius = (x[0] - cx).abs();
542        if radius == 0.0
543            || (0..4).any(|i| {
544                ((x[i] - cx).abs() - radius).abs() > 0.001
545                    || ((y[i] - cy).abs() - radius).abs() > 0.001
546            })
547        {
548            return None;
549        }
550        Some([
551            self.points[0][0].min(self.points[2][0]),
552            self.points[0][1].min(self.points[2][1]),
553            self.points[0][0].max(self.points[2][0]),
554            self.points[0][1].max(self.points[2][1]),
555        ])
556    }
557
558    fn square_weights<'a>(
559        &self,
560        bounds: [f64; 4],
561        point: [f64; 2],
562        scratch: &'a mut WeightScratch,
563    ) -> &'a [f32] {
564        let [left, bottom, right, top] = bounds;
565        let mut x = (point[0] - left) / (right - left);
566        let mut y = (point[1] - bottom) / (top - bottom);
567        if x <= 0.0 {
568            x = 0.00001;
569        }
570        if x >= 1.0 {
571            x = 0.99999;
572        }
573        if y <= 0.0 {
574            y = 0.00001;
575        }
576        if y >= 1.0 {
577            y = 0.99999;
578        }
579        for (i, p) in self.points.iter().enumerate() {
580            let wx = if p[0] <= (left + right) * 0.5 {
581                1.0 - x
582            } else {
583                x
584            };
585            let wy = if p[1] <= (bottom + top) * 0.5 {
586                1.0 - y
587            } else {
588                y
589            };
590            scratch.weights[i] = (wx * wy) as f32;
591        }
592        &scratch.weights
593    }
594
595    pub fn new(points: Vec<[f64; 2]>) -> Result<Self, MapError> {
596        let n = points.len();
597        if n < 3 || points.iter().flatten().any(|v| !v.is_finite()) {
598            return Err(MapError::Invalid(
599                "sloping polygon requires at least three finite endpoints".into(),
600            ));
601        }
602        let corner_products = (0..n)
603            .map(|j| cross(points[j], points[(j + 1) % n], points[(j + n - 1) % n]))
604            .collect();
605        Ok(Self {
606            points,
607            corner_products,
608        })
609    }
610
611    pub fn weights<'a>(
612        &self,
613        point: [f64; 2],
614        scratch: &'a mut WeightScratch,
615    ) -> Result<&'a [f32], MapError> {
616        let n = self.points.len();
617        scratch.clipped = false;
618        if scratch.weights.len() != n || point.iter().any(|v| !v.is_finite()) {
619            return Err(MapError::Invalid(
620                "sloping weight scratch dimension or query is invalid".into(),
621            ));
622        }
623        for i in 0..n {
624            let value = cross(point, self.points[i], self.points[(i + 1) % n]) as f32;
625            scratch.cross[i] = if value == 0.0 { 0.00001_f32 } else { value };
626        }
627        let mut total = 0.0_f64;
628        for j in 0..n {
629            let previous = (j + n - 1) % n;
630            let mut product = 1.0_f64;
631            for k in 0..n {
632                if k != j && k != previous {
633                    product *= f64::from(scratch.cross[k]);
634                }
635            }
636            scratch.products[j] = product * self.corner_products[j];
637            total += scratch.products[j];
638        }
639        let mut has_negative_product = false;
640        for j in 0..n {
641            if scratch.products[j] < 0.0 {
642                has_negative_product = true;
643            }
644            scratch.weights[j] = (scratch.products[j] / total) as f32;
645            if scratch.weights[j] == 0.0 && scratch.products[j] != 0.0 {
646                scratch.clipped = true;
647            }
648        }
649        if has_negative_product {
650            let mut total = 0.0_f32;
651            for value in &mut scratch.weights {
652                if *value < 0.0 {
653                    scratch.clipped = true;
654                    *value = 0.0;
655                } else {
656                    total += *value;
657                }
658            }
659            if total != 0.0 {
660                for value in &mut scratch.weights {
661                    *value /= total;
662                }
663            }
664        }
665        Ok(&scratch.weights)
666    }
667}
668
669/// Match native binary32 offset accumulation, including its nodata convention.
670pub fn paint_corner_values(weights: &[f32], values: &[f32]) -> Result<f32, MapError> {
671    if weights.len() != values.len() || values.is_empty() {
672        return Err(MapError::Invalid(
673            "sloping paint dimensions do not match".into(),
674        ));
675    }
676    let anchor = values[0];
677    let mut delta = 0.0_f32;
678    let mut count = 0;
679    for (&weight, &value) in weights.iter().zip(values) {
680        let difference = value - anchor;
681        if difference != NODATA {
682            count += 1;
683            delta += weight * difference;
684        }
685    }
686    Ok(if count > 0 { delta + anchor } else { NODATA })
687}
688
689/// Native plot tolerance is part of the method; there is no extra depth cutoff.
690pub fn deliver_pixel(wse: f32, terrain: f32, native_tolerance: f32) -> Option<[f32; 2]> {
691    if !wse.is_finite()
692        || !terrain.is_finite()
693        || wse == NODATA
694        || terrain == NODATA
695        || wse < terrain + native_tolerance
696    {
697        None
698    } else {
699        Some([wse - terrain, wse])
700    }
701}
702
703pub struct CellStencil {
704    pub physical_ring: Vec<[f64; 2]>,
705    polygon: PolygonStencil,
706    square: Option<[f64; 4]>,
707    corners: Vec<[usize; 2]>, // vertex, incident-face slot
708    faces: Vec<[usize; 2]>,   // face, cell side
709    elevations: Option<Vec<f32>>,
710}
711
712/// Prepare immutable geometry once. Profiles never determine ring ordering.
713pub fn prepare_stencils(
714    mesh: &Mesh,
715    cell_faces: &[Vec<usize>],
716) -> Result<Vec<Option<CellStencil>>, MapError> {
717    mesh.validate()?;
718    if cell_faces.len() != mesh.cells.len() {
719        return Err(MapError::Invalid(
720            "sloping cell-face table length mismatch".into(),
721        ));
722    }
723    cell_faces
724        .iter()
725        .enumerate()
726        .map(|(cell, faces)| {
727            if mesh.cells[cell].virtual_cell {
728                return Ok(None);
729            }
730            let mut points = Vec::with_capacity(faces.len());
731            let mut corners = Vec::with_capacity(faces.len());
732            for &id in faces {
733                let face = mesh
734                    .faces
735                    .get(id)
736                    .ok_or_else(|| MapError::Invalid("cell face out of range".into()))?;
737                let vertex = if face.cells[0] == cell {
738                    face.vertices[0]
739                } else if face.cells[1] == cell {
740                    face.vertices[1]
741                } else {
742                    return Err(MapError::Invalid("cell not incident to face".into()));
743                };
744                let corner = &mesh.corners[vertex];
745                let slot =
746                    corner.faces.iter().position(|&f| f == id).ok_or_else(|| {
747                        MapError::Invalid("cell face not incident to corner".into())
748                    })?;
749                points.push(corner.point);
750                corners.push([vertex, slot]);
751            }
752            let polygon = PolygonStencil::new(points)?;
753            let square = polygon.square_bounds();
754            Ok(Some(CellStencil {
755                physical_ring: Vec::new(),
756                polygon,
757                square,
758                corners,
759                faces: faces
760                    .iter()
761                    .map(|&id| [id, usize::from(mesh.faces[id].cells[0] != cell)])
762                    .collect(),
763                elevations: None,
764            }))
765        })
766        .collect()
767}
768
769impl CellStencil {
770    /// Corner (vertex, incident-face slot) pairs in ring order.
771    pub(crate) fn corner_slots(&self) -> impl Iterator<Item = [usize; 2]> + '_ {
772        self.corners.iter().copied()
773    }
774    /// Face ids in ring order (face i joins corner i and corner i + 1).
775    pub(crate) fn face_ids(&self) -> impl Iterator<Item = usize> + '_ {
776        self.faces.iter().map(|f| f[0])
777    }
778    /// Native face-donation node weights at `point`: `n` corner weights then
779    /// `n` face weights, binary32, as used by cell-face sloping painting.
780    pub(crate) fn donated_weights(
781        &self,
782        point: [f64; 2],
783        scratch: &mut WeightScratch,
784    ) -> Result<Vec<f32>, MapError> {
785        self.donated_weights_with_history(point, scratch, false)
786    }
787
788    /// True when the last weights were clipped such that a corner's neighbours
789    /// both carry zero weight: native donation then depends on scratch history.
790    pub(crate) fn history_sensitive(&self, scratch: &WeightScratch) -> bool {
791        let n = self.corners.len();
792        scratch.clipped
793            && (0..n)
794                .any(|k| scratch.weights[(k + n - 1) % n] + scratch.weights[(k + 1) % n] == 0.0)
795    }
796
797    /// As `donated_weights`; with `retain`, clockwise/counter-clockwise
798    /// donations of corners with zero-weight neighbours keep the previous
799    /// pixel's values (native scratch history).
800    pub(crate) fn donated_weights_with_history(
801        &self,
802        point: [f64; 2],
803        scratch: &mut WeightScratch,
804        retain: bool,
805    ) -> Result<Vec<f32>, MapError> {
806        let n = self.corners.len();
807        scratch.resize(n);
808        if let Some(bounds) = self.square {
809            self.polygon.square_weights(bounds, point, scratch);
810        } else {
811            self.polygon.weights(point, scratch)?;
812        }
813        for i in 0..n {
814            let previous = (i + n - 1) % n;
815            let next = (i + 1) % n;
816            let adjacent = scratch.weights[previous] + scratch.weights[next];
817            if !retain {
818                scratch.clockwise[i] = 0.0;
819                scratch.counterclockwise[i] = 0.0;
820            }
821            if adjacent != 0.0 {
822                let fraction = scratch.weights[i] / adjacent;
823                scratch.clockwise[i] = fraction * scratch.weights[previous];
824                scratch.counterclockwise[i] = fraction * scratch.weights[next];
825            }
826            scratch.donated[i] = scratch.weights[i];
827        }
828        for i in 0..n {
829            let next = (i + 1) % n;
830            let donation = scratch.counterclockwise[i].min(scratch.clockwise[next]);
831            scratch.donated[i] -= donation;
832            scratch.donated[next] -= donation;
833            scratch.donated[n + i] = donation * 2.0;
834        }
835        Ok(scratch.donated[..2 * n].to_vec())
836    }
837}
838
839/// Optional native stored corner elevations, required only for depth weighting.
840pub fn set_vertex_elevations(
841    stencils: &mut [Option<CellStencil>],
842    elevations: &[f32],
843    vertex_count: usize,
844) -> Result<(), MapError> {
845    if elevations.len() != vertex_count || elevations.iter().any(|x| !x.is_finite()) {
846        return Err(MapError::Invalid(
847            "sloping corner elevation dimensions or values invalid".into(),
848        ));
849    }
850    for stencil in stencils.iter_mut().flatten() {
851        stencil.elevations = Some(
852            stencil
853                .corners
854                .iter()
855                .map(|&[vertex, _]| elevations[vertex])
856                .collect(),
857        );
858    }
859    Ok(())
860}
861
862pub struct PreparedProfile {
863    local_values: Vec<Vec<f32>>,
864    face_values: Vec<Vec<f32>>,
865    depth_weights: Vec<Vec<f32>>,
866    horizontal: Vec<Option<f32>>,
867    wet: Vec<bool>,
868    options: Options,
869}
870
871impl PreparedProfile {
872    /// Native wet gate of a cell: WSE above its minimum plus the plot tolerance.
873    pub fn is_wet(&self, cell: usize) -> bool {
874        self.wet[cell]
875    }
876}
877
878pub fn prepare_profile(
879    mesh: &Mesh,
880    stencils: &[Option<CellStencil>],
881    values: &[f32],
882    tolerance: f32,
883    pool: &rayon::ThreadPool,
884) -> Result<PreparedProfile, MapError> {
885    prepare_profile_with_options(mesh, stencils, values, tolerance, pool, Options::default())
886}
887
888pub fn prepare_profile_with_options(
889    mesh: &Mesh,
890    stencils: &[Option<CellStencil>],
891    values: &[f32],
892    tolerance: f32,
893    pool: &rayon::ThreadPool,
894    options: Options,
895) -> Result<PreparedProfile, MapError> {
896    options.validate()?;
897    if stencils.len() != mesh.cells.len() {
898        return Err(MapError::Invalid("sloping stencil count mismatch".into()));
899    }
900    if options.use_depth_weighted_faces && stencils.iter().flatten().any(|s| s.elevations.is_none())
901    {
902        return Err(MapError::Unsupported(
903            "depth weighting requires stored native FacePoints Elevation".into(),
904        ));
905    }
906    let reconstructed = mesh.reconstruct(values, tolerance, pool)?;
907    let local_values: Vec<Vec<f32>> = pool.install(|| {
908        stencils
909            .par_iter()
910            .map(|s| match s {
911                Some(s) => s
912                    .corners
913                    .iter()
914                    .map(|&[vertex, slot]| reconstructed.corners[vertex][slot])
915                    .collect(),
916                None => Vec::new(),
917            })
918            .collect()
919    });
920    let face_values: Vec<Vec<f32>> = if options.render_mode == RenderMode::WithFaces {
921        pool.install(|| {
922            stencils
923                .par_iter()
924                .map(|s| match s {
925                    Some(s) => s
926                        .faces
927                        .iter()
928                        .map(|&[face, side]| reconstructed.faces[face].values[side])
929                        .collect(),
930                    None => Vec::new(),
931                })
932                .collect()
933        })
934    } else {
935        Vec::new()
936    };
937    let depth_weights = if options.use_depth_weighted_faces {
938        pool.install(|| {
939            stencils
940                .par_iter()
941                .enumerate()
942                .map(|(cell, s)| match s {
943                    Some(s) => {
944                        let mut result: Vec<_> = local_values[cell]
945                            .iter()
946                            .zip(s.elevations.as_ref().unwrap())
947                            .map(|(&w, &z)| depth_weight(w, z))
948                            .collect();
949                        result.extend(
950                            s.faces
951                                .iter()
952                                .zip(&face_values[cell])
953                                .map(|(&[face, _], &w)| depth_weight(w, mesh.faces[face].minimum)),
954                        );
955                        result
956                    }
957                    None => Vec::new(),
958                })
959                .collect()
960        })
961    } else {
962        Vec::new()
963    };
964    let horizontal = stencils
965        .iter()
966        .enumerate()
967        .map(|(cell, s)| {
968            s.as_ref().and_then(|s| {
969                (options.reduce_shallow_to_horizontal
970                    && s.faces.iter().all(|&[face, _]| {
971                        let c = reconstructed.faces[face].connection;
972                        !c.connected() || c == Connection::DownhillShallow
973                    }))
974                .then_some(values[cell])
975            })
976        })
977        .collect();
978    let wet = mesh
979        .cells
980        .iter()
981        .zip(values)
982        .map(|(cell, &value)| !cell.virtual_cell && value > cell.minimum + tolerance)
983        .collect();
984    Ok(PreparedProfile {
985        local_values,
986        face_values,
987        depth_weights,
988        horizontal,
989        wet,
990        options,
991    })
992}
993
994fn depth_weight(wse: f32, elevation: f32) -> f32 {
995    if wse == NODATA || elevation == NODATA || wse <= elevation {
996        0.01_f32
997    } else {
998        f64::from(wse - elevation).max(0.01_f64) as f32
999    }
1000}
1001
1002impl WeightScratch {
1003    /// Native node donation: all arithmetic remains binary32 and in ring order.
1004    fn paint_with_faces(&mut self, corners: &[f32], faces: &[f32], depths: Option<&[f32]>) -> f32 {
1005        self.paint_with_face_history(corners, faces, depths, false)
1006    }
1007
1008    fn paint_with_face_history(
1009        &mut self,
1010        corners: &[f32],
1011        faces: &[f32],
1012        depths: Option<&[f32]>,
1013        retain: bool,
1014    ) -> f32 {
1015        let n = corners.len();
1016        for i in 0..n {
1017            let previous = (i + n - 1) % n;
1018            let next = (i + 1) % n;
1019            let adjacent = self.weights[previous] + self.weights[next];
1020            // The independent pass starts fresh; affected cells are replayed
1021            // below with native scratch retention inside each geometry batch.
1022            if !retain {
1023                self.clockwise[i] = 0.0;
1024                self.counterclockwise[i] = 0.0;
1025            }
1026            if adjacent != 0.0 {
1027                let fraction = self.weights[i] / adjacent;
1028                self.clockwise[i] = fraction * self.weights[previous];
1029                self.counterclockwise[i] = fraction * self.weights[next];
1030            }
1031            self.donated[i] = self.weights[i];
1032        }
1033        for i in 0..n {
1034            let next = (i + 1) % n;
1035            let donation = self.counterclockwise[i].min(self.clockwise[next]);
1036            self.donated[i] -= donation;
1037            self.donated[next] -= donation;
1038            self.donated[n + i] = donation * 2.0;
1039        }
1040        let mut total = 0.0_f64;
1041        if let Some(depths) = depths {
1042            for (weight, &depth) in self.donated.iter_mut().zip(depths) {
1043                *weight *= depth;
1044                total += f64::from(*weight);
1045            }
1046        }
1047        let anchor = faces[0];
1048        let mut delta = 0.0_f32;
1049        for (i, &corner) in corners.iter().enumerate() {
1050            delta += (corner - anchor) * self.donated[i];
1051        }
1052        for (i, &face) in faces.iter().enumerate() {
1053            delta += (face - anchor) * self.donated[n + i];
1054        }
1055        if depths.is_some() {
1056            (f64::from(delta) / total) as f32 + anchor
1057        } else {
1058            delta + anchor
1059        }
1060    }
1061}
1062
1063/// Internal signed-depth surface path. Raster plot tolerance is deliberately
1064/// not applied here: zero-contour interpolation needs valid dry-side samples.
1065/// The caller subtracts valid terrain from the returned water-surface value.
1066/// The resulting depth uses Option, so a numeric depth of -9999 is not missing.
1067pub(crate) fn boundary_surface_value(
1068    stencils: &[Vec<Option<CellStencil>>],
1069    profiles: &[PreparedProfile],
1070    mesh: usize,
1071    cell: usize,
1072    point: [f64; 2],
1073    scratch: &mut WeightScratch,
1074) -> Result<Option<f32>, MapError> {
1075    let profile = profiles
1076        .get(mesh)
1077        .ok_or_else(|| MapError::Invalid("boundary surface mesh out of range".into()))?;
1078    if profile.options.render_mode != RenderMode::Corners {
1079        return Err(MapError::Unsupported(
1080            "boundary contours currently require plain corner sloping; face options are not qualified".into(),
1081        ));
1082    }
1083    if point.iter().any(|v| !v.is_finite()) {
1084        return Err(MapError::Invalid("nonfinite boundary surface query".into()));
1085    }
1086    let wet = *profile
1087        .wet
1088        .get(cell)
1089        .ok_or_else(|| MapError::Invalid("boundary surface cell out of range".into()))?;
1090    if !wet {
1091        return Ok(None);
1092    }
1093    let cells = stencils
1094        .get(mesh)
1095        .ok_or_else(|| MapError::Invalid("boundary stencil mesh out of range".into()))?;
1096    let stencil = cells
1097        .get(cell)
1098        .ok_or_else(|| MapError::Invalid("boundary stencil cell out of range".into()))?;
1099    let Some(stencil) = stencil else {
1100        return Ok(None);
1101    };
1102    if let Some(value) = profile.horizontal[cell] {
1103        return Ok((value != NODATA).then_some(value));
1104    }
1105    let values = &profile.local_values[cell];
1106    scratch.resize(values.len());
1107    let weights = if let Some(bounds) = stencil.square {
1108        stencil.polygon.square_weights(bounds, point, scratch)
1109    } else {
1110        stencil.polygon.weights(point, scratch)?
1111    };
1112    let value = paint_corner_values(weights, values)?;
1113    if !value.is_finite() {
1114        return Err(MapError::Invalid(
1115            "nonfinite boundary surface interpolation".into(),
1116        ));
1117    }
1118    Ok((value != NODATA).then_some(value))
1119}
1120
1121/// Geometry and terrain belong to the caller and can be reused across timesteps.
1122/// Labels index `owner_cells` plus one; zero is outside the physical 2D footprint.
1123pub struct RasterWindow<'a> {
1124    pub source_transform: [f64; 6],
1125    pub source_tile: [usize; 2],
1126    pub width: usize,
1127    pub height: usize,
1128    pub transform: [f64; 6],
1129    pub owner: &'a [u32],
1130    pub terrain: &'a [f32],
1131    pub owner_cells: &'a [[usize; 2]], // mesh, local cell
1132}
1133
1134/// Bounded immutable geometry-only weights for a prefix of one output window.
1135/// Face rendering keeps its original scratch/history path.
1136pub(crate) struct PixelWeights {
1137    stride: usize,
1138    values: Vec<f32>,
1139}
1140
1141impl PixelWeights {
1142    pub(crate) fn prepare(
1143        stencils: &[Vec<Option<CellStencil>>],
1144        window: &RasterWindow<'_>,
1145        budget: usize,
1146        pool: &rayon::ThreadPool,
1147    ) -> Result<Self, MapError> {
1148        let stride = stencils
1149            .iter()
1150            .flatten()
1151            .filter_map(|s| s.as_ref())
1152            .map(|s| s.polygon.points.len())
1153            .max()
1154            .unwrap_or(1);
1155        if window.width == 0
1156            || window
1157                .owner
1158                .iter()
1159                .any(|&label| label as usize > window.owner_cells.len())
1160            || window
1161                .owner_cells
1162                .iter()
1163                .any(|&[mesh, cell]| mesh >= stencils.len() || cell >= stencils[mesh].len())
1164        {
1165            return Err(MapError::Invalid("invalid cached pixel addresses".into()));
1166        }
1167        let row_bytes = stride
1168            .checked_mul(4)
1169            .ok_or_else(|| MapError::Budget("weight cache stride overflow".into()))?;
1170        let count = window.owner.len().min(budget / row_bytes);
1171        let mut values = vec![0.0; count * stride];
1172        pool.install(|| {
1173            values.par_chunks_mut(stride).enumerate().try_for_each_init(
1174                || WeightScratch::new(0),
1175                |scratch, (i, row)| {
1176                    let label = window.owner[i] as usize;
1177                    if label != 0 {
1178                        let [mesh, cell] = window.owner_cells[label - 1];
1179                        if let Some(stencil) = &stencils[mesh][cell] {
1180                            scratch.resize(stencil.polygon.points.len());
1181                            let point = [
1182                                window.transform[0]
1183                                    + ((i % window.width) as f64 + 0.5) * window.transform[1],
1184                                window.transform[3]
1185                                    + ((i / window.width) as f64 + 0.5) * window.transform[5],
1186                            ];
1187                            let weights = if let Some(bounds) = stencil.square {
1188                                stencil.polygon.square_weights(bounds, point, scratch)
1189                            } else {
1190                                stencil.polygon.weights(point, scratch)?
1191                            };
1192                            row[..weights.len()].copy_from_slice(weights);
1193                        }
1194                    }
1195                    Ok::<_, MapError>(())
1196                },
1197            )
1198        })?;
1199        Ok(Self { stride, values })
1200    }
1201    pub fn bytes(&self) -> usize {
1202        self.values.len() * 4
1203    }
1204    fn get(&self, index: usize, len: usize) -> Option<&[f32]> {
1205        (index < self.values.len() / self.stride && len <= self.stride)
1206            .then(|| &self.values[index * self.stride..index * self.stride + len])
1207    }
1208}
1209
1210pub fn render_profile(
1211    stencils: &[Vec<Option<CellStencil>>],
1212    profiles: &[PreparedProfile],
1213    window: &RasterWindow<'_>,
1214    tolerance: f32,
1215    pool: &rayon::ThreadPool,
1216) -> Result<(Vec<f32>, Vec<f32>), MapError> {
1217    render_profile_cached(stencils, profiles, window, tolerance, pool, None)
1218}
1219
1220pub(crate) fn render_profile_cached(
1221    stencils: &[Vec<Option<CellStencil>>],
1222    profiles: &[PreparedProfile],
1223    window: &RasterWindow<'_>,
1224    tolerance: f32,
1225    pool: &rayon::ThreadPool,
1226    cache: Option<&PixelWeights>,
1227) -> Result<(Vec<f32>, Vec<f32>), MapError> {
1228    let count = window
1229        .width
1230        .checked_mul(window.height)
1231        .filter(|&n| n > 0)
1232        .ok_or_else(|| MapError::Invalid("empty sloping raster window".into()))?;
1233    if window.owner.len() != count
1234        || window.terrain.len() != count
1235        || stencils.len() != profiles.len()
1236        || window.transform[2] != 0.0
1237        || window.transform[4] != 0.0
1238        || window.transform.iter().any(|v| !v.is_finite())
1239        || !tolerance.is_finite()
1240        || tolerance < 0.0
1241    {
1242        return Err(MapError::Invalid(
1243            "sloping raster inputs do not match".into(),
1244        ));
1245    }
1246    for &[mesh, cell] in window.owner_cells {
1247        if mesh >= stencils.len()
1248            || cell >= stencils[mesh].len()
1249            || cell >= profiles[mesh].local_values.len()
1250        {
1251            return Err(MapError::Invalid(
1252                "sloping raster owner-cell address out of range".into(),
1253            ));
1254        }
1255    }
1256    if window
1257        .owner
1258        .iter()
1259        .any(|&i| i as usize > window.owner_cells.len())
1260    {
1261        return Err(MapError::Invalid("sloping owner label out of range".into()));
1262    }
1263    let mut depth = vec![NODATA; count];
1264    let mut wse = vec![NODATA; count];
1265    let repair: Vec<_> = window
1266        .owner_cells
1267        .iter()
1268        .map(|_| AtomicBool::new(false))
1269        .collect();
1270    pool.install(|| {
1271        depth
1272            .par_chunks_mut(4096)
1273            .zip(wse.par_chunks_mut(4096))
1274            .enumerate()
1275            .try_for_each_init(
1276                || WeightScratch::new(0),
1277                |scratch, (chunk, (d, w))| {
1278                    for j in 0..d.len() {
1279                        let i = chunk * 4096 + j;
1280                        let label = window.owner[i] as usize;
1281                        if label == 0 {
1282                            continue;
1283                        }
1284                        let [mesh, cell] = window.owner_cells[label - 1];
1285                        if !profiles[mesh].wet[cell] {
1286                            continue;
1287                        }
1288                        if let Some(value) = profiles[mesh].horizontal[cell] {
1289                            if let Some([delta, value]) =
1290                                deliver_pixel(value, window.terrain[i], tolerance)
1291                            {
1292                                d[j] = delta;
1293                                w[j] = value;
1294                            }
1295                            continue;
1296                        }
1297                        let Some(stencil) = &stencils[mesh][cell] else {
1298                            continue;
1299                        };
1300                        let values = &profiles[mesh].local_values[cell];
1301                        scratch.resize(values.len());
1302                        let col = i % window.width;
1303                        let row = i / window.width;
1304                        let point = [
1305                            window.transform[0] + (col as f64 + 0.5) * window.transform[1],
1306                            window.transform[3] + (row as f64 + 0.5) * window.transform[5],
1307                        ];
1308                        let weights = if let Some(weights) = cache
1309                            .filter(|_| profiles[mesh].options.render_mode != RenderMode::WithFaces)
1310                            .and_then(|c| c.get(i, values.len()))
1311                        {
1312                            weights
1313                        } else if let Some(bounds) = stencil.square {
1314                            stencil.polygon.square_weights(bounds, point, scratch)
1315                        } else {
1316                            stencil.polygon.weights(point, scratch)?
1317                        };
1318                        let value = if profiles[mesh].options.render_mode == RenderMode::WithFaces {
1319                            // End the borrowed weights view before mutating scratch.
1320                            let _ = weights;
1321                            if scratch.clipped
1322                                && (0..values.len()).any(|k| {
1323                                    scratch.weights[(k + values.len() - 1) % values.len()]
1324                                        + scratch.weights[(k + 1) % values.len()]
1325                                        == 0.0
1326                                })
1327                            {
1328                                repair[label - 1].store(true, Ordering::Relaxed);
1329                            }
1330                            scratch.paint_with_faces(
1331                                values,
1332                                &profiles[mesh].face_values[cell],
1333                                profiles[mesh]
1334                                    .options
1335                                    .use_depth_weighted_faces
1336                                    .then(|| profiles[mesh].depth_weights[cell].as_slice()),
1337                            )
1338                        } else {
1339                            paint_corner_values(weights, values)?
1340                        };
1341                        if let Some([delta, value]) =
1342                            deliver_pixel(value, window.terrain[i], tolerance)
1343                        {
1344                            d[j] = delta;
1345                            w[j] = value;
1346                        }
1347                    }
1348                    Ok::<_, MapError>(())
1349                },
1350            )
1351    })?;
1352    // Native geometry donation retains scratch inside fixed 256-point ranges.
1353    // Replay only cells where clipped weights can expose that state. Replaying
1354    // complete source tiles keeps bbox/polygon outputs equal to full-map crops.
1355    let repaired = pool.install(|| {
1356        window
1357            .owner_cells
1358            .par_iter()
1359            .enumerate()
1360            .filter_map(|(label, &[mesh, cell])| {
1361                repair[label]
1362                    .load(Ordering::Relaxed)
1363                    .then_some((label + 1, mesh, cell))
1364            })
1365            .map(
1366                |(label, mesh, cell)| -> Result<Vec<(usize, [f32; 2])>, MapError> {
1367                    let stencil = stencils[mesh][cell].as_ref().unwrap();
1368                    if stencil.physical_ring.is_empty() {
1369                        return Err(MapError::Unsupported(
1370                            "native face donation replay needs full cell perimeter geometry".into(),
1371                        ));
1372                    }
1373                    let [tw, th] = window.source_tile;
1374                    if tw == 0 || th == 0 || tw.checked_mul(th).is_none_or(|n| n > 16_777_216) {
1375                        return Err(MapError::Unsupported(
1376                            "unsupported native donation tile size".into(),
1377                        ));
1378                    }
1379                    let gt = window.source_transform;
1380                    let xoff = ((window.transform[0] - gt[0]) / gt[1]).round() as i64;
1381                    let yoff = ((window.transform[3] - gt[3]) / gt[5]).round() as i64;
1382                    let minx = stencil
1383                        .physical_ring
1384                        .iter()
1385                        .map(|p| p[0])
1386                        .fold(f64::INFINITY, f64::min);
1387                    let maxx = stencil
1388                        .physical_ring
1389                        .iter()
1390                        .map(|p| p[0])
1391                        .fold(f64::NEG_INFINITY, f64::max);
1392                    let miny = stencil
1393                        .physical_ring
1394                        .iter()
1395                        .map(|p| p[1])
1396                        .fold(f64::INFINITY, f64::min);
1397                    let maxy = stencil
1398                        .physical_ring
1399                        .iter()
1400                        .map(|p| p[1])
1401                        .fold(f64::NEG_INFINITY, f64::max);
1402                    let c0 = (((minx - gt[0]) / gt[1]).floor() as i64).max(xoff).max(0);
1403                    let c1 =
1404                        (((maxx - gt[0]) / gt[1]).ceil() as i64).min(xoff + window.width as i64);
1405                    let r0 = (((maxy - gt[3]) / gt[5]).floor() as i64).max(yoff).max(0);
1406                    let r1 =
1407                        (((miny - gt[3]) / gt[5]).ceil() as i64).min(yoff + window.height as i64);
1408                    let mut output = Vec::new();
1409                    if c0 >= c1 || r0 >= r1 {
1410                        return Ok(output);
1411                    }
1412                    for ty in r0 as usize / th..=(r1 as usize - 1) / th {
1413                        for tx in c0 as usize / tw..=(c1 as usize - 1) / tw {
1414                            let tile_gt = [
1415                                gt[0] + (tx * tw) as f64 * gt[1],
1416                                gt[1],
1417                                0.,
1418                                gt[3] + (ty * th) as f64 * gt[5],
1419                                0.,
1420                                gt[5],
1421                            ];
1422                            let owned = crate::ownership::burn_ring(
1423                                &stencil.physical_ring,
1424                                terrio::ops::rasterize::Grid {
1425                                    geo_transform: tile_gt,
1426                                    cols: tw,
1427                                    rows: th,
1428                                    crs: Default::default(),
1429                                },
1430                            )?;
1431                            let values = &profiles[mesh].local_values[cell];
1432                            let faces = &profiles[mesh].face_values[cell];
1433                            let depths = profiles[mesh]
1434                                .options
1435                                .use_depth_weighted_faces
1436                                .then(|| profiles[mesh].depth_weights[cell].as_slice());
1437                            let mut scratch = WeightScratch::new(values.len());
1438                            let mut native_index = 0;
1439                            for (i, &inside) in owned.iter().enumerate() {
1440                                if inside == 0 {
1441                                    continue;
1442                                }
1443                                if native_index % 256 == 0 {
1444                                    scratch.clockwise.fill(0.);
1445                                    scratch.counterclockwise.fill(0.);
1446                                }
1447                                native_index += 1;
1448                                let point = [
1449                                    tile_gt[0] + (i % tw) as f64 * gt[1] + 0.5 * gt[1],
1450                                    tile_gt[3] + (i / tw) as f64 * gt[5] + 0.5 * gt[5],
1451                                ];
1452                                stencil.polygon.weights(point, &mut scratch)?;
1453                                let value =
1454                                    scratch.paint_with_face_history(values, faces, depths, true);
1455                                let col = (tx * tw + i % tw) as i64 - xoff;
1456                                let row = (ty * th + i / tw) as i64 - yoff;
1457                                if col < 0
1458                                    || row < 0
1459                                    || col >= window.width as i64
1460                                    || row >= window.height as i64
1461                                {
1462                                    continue;
1463                                }
1464                                let index = row as usize * window.width + col as usize;
1465                                if window.owner[index] as usize != label {
1466                                    continue;
1467                                }
1468                                output.push((
1469                                    index,
1470                                    deliver_pixel(value, window.terrain[index], tolerance)
1471                                        .unwrap_or([NODATA, NODATA]),
1472                                ));
1473                            }
1474                        }
1475                    }
1476                    Ok(output)
1477                },
1478            )
1479            .collect::<Result<Vec<_>, MapError>>()
1480    })?;
1481    for cell in repaired {
1482        for (index, [d, w]) in cell {
1483            depth[index] = d;
1484            wse[index] = w;
1485        }
1486    }
1487    Ok((depth, wse))
1488}
1489
1490#[cfg(test)]
1491mod tests {
1492    use super::*;
1493
1494    #[test]
1495    fn zero_adjacent_donation_keeps_native_batch_history() {
1496        let corners = [1., 2., 3., 4., 5., 6.];
1497        let faces = [2., 3., 4., 5., 6., 7.];
1498        let mut carried = WeightScratch::new(6);
1499        carried
1500            .weights
1501            .copy_from_slice(&[0.1, 0.2, 0.1, 0.2, 0.1, 0.3]);
1502        carried.paint_with_face_history(&corners, &faces, None, true);
1503        let previous = carried.clockwise[3];
1504        assert_ne!(previous, 0.);
1505        carried.weights.copy_from_slice(&[1., 0., 0., 0., 0., 0.]);
1506        let value = carried.paint_with_face_history(&corners, &faces, None, true);
1507        assert_eq!(carried.clockwise[3].to_bits(), previous.to_bits());
1508        let mut fresh = WeightScratch::new(6);
1509        fresh.weights.copy_from_slice(&[1., 0., 0., 0., 0., 0.]);
1510        assert_ne!(
1511            value.to_bits(),
1512            fresh.paint_with_faces(&corners, &faces, None).to_bits()
1513        );
1514    }
1515
1516    #[test]
1517    fn native_davis_donated_and_depth_rebalanced_scalar_bits() {
1518        // Independent reflection of native 7.0.1 private scalar painters on
1519        // public Davis corner/face values. Supplied depth coefficients isolate
1520        // normalization; tiny negative donated weights must not be clamped.
1521        let weights = [0x3e648a40, 0x3eda93c1, 0x3e74091d, 0x3de48a40].map(f32::from_bits);
1522        let corners = [0x42452a70, 0x4244fae3, 0x4242a6f2, 0x424466fa].map(f32::from_bits);
1523        let faces = [0x4244fae3, 0x4244fae3, 0x4243c5bf, 0x42453384].map(f32::from_bits);
1524        let depths = [
1525            0x3c23d70a, 0x3fa00000, 0x418b0000, 0x40490fd0, 0x3c23d70a, 0x3fa00000, 0x418b0000,
1526            0x40490fd0,
1527        ]
1528        .map(f32::from_bits);
1529        let mut scratch = WeightScratch::new(4);
1530        scratch.weights.copy_from_slice(&weights);
1531        assert_eq!(
1532            scratch.paint_with_faces(&corners, &faces, None).to_bits(),
1533            1111809851
1534        );
1535        let expected_donated = [
1536            0xb2000000, 0x3d7a0954, 0xb2800000, 0x3c82b774, 0x3eb52e2d, 0x3ec17700, 0x3dca4878,
1537            0x3dbd704e,
1538        ];
1539        assert_eq!(
1540            scratch
1541                .donated
1542                .iter()
1543                .map(|v| v.to_bits())
1544                .collect::<Vec<_>>(),
1545            expected_donated
1546        );
1547        assert_eq!(
1548            scratch
1549                .paint_with_faces(&corners, &faces, Some(&depths))
1550                .to_bits(),
1551            1111765762
1552        );
1553    }
1554
1555    #[test]
1556    fn davis_square_pixel_preserves_native_rounding() {
1557        // Real Davis p02 cell146 and a pixel that differed with general weights.
1558        let polygon = PolygonStencil::new(vec![
1559            [6630441.54307014, 1966773.76305611],
1560            [6630441.54307014, 1966623.76305611],
1561            [6630591.54307014, 1966623.76305611],
1562            [6630591.54307014, 1966773.76305611],
1563        ])
1564        .unwrap();
1565        let mut scratch = WeightScratch::new(4);
1566        let point = [6630507.763170041, 1966711.399494069];
1567        let values = [0x423e156c, 0x423b1da6, 0x423a9ca2, 0x423dcb4e].map(f32::from_bits);
1568        let weights = polygon.square_weights(polygon.square_bounds().unwrap(), point, &mut scratch);
1569        assert_eq!(
1570            paint_corner_values(weights, &values).unwrap().to_bits(),
1571            0x423caebe
1572        );
1573        let general = polygon.weights(point, &mut scratch).unwrap();
1574        assert_ne!(
1575            paint_corner_values(general, &values).unwrap().to_bits(),
1576            0x423caebe
1577        );
1578    }
1579
1580    #[test]
1581    fn bounded_pixel_weights_preserve_square_bits_and_fall_back_outside_prefix() {
1582        let polygon = PolygonStencil::new(vec![[0., 0.], [0., 2.], [2., 2.], [2., 0.]]).unwrap();
1583        let square = polygon.square_bounds();
1584        let stencil = CellStencil {
1585            physical_ring: polygon.points.clone(),
1586            polygon,
1587            square,
1588            corners: vec![],
1589            faces: vec![],
1590            elevations: None,
1591        };
1592        let stencils = vec![vec![Some(stencil)]];
1593        let window = RasterWindow {
1594            source_transform: [0., 1., 0., 2., 0., -1.],
1595            source_tile: [2, 2],
1596            width: 2,
1597            height: 2,
1598            transform: [0., 1., 0., 2., 0., -1.],
1599            owner: &[1, 1, 1, 1],
1600            terrain: &[0.; 4],
1601            owner_cells: &[[0, 0]],
1602        };
1603        let pool = rayon::ThreadPoolBuilder::new()
1604            .num_threads(2)
1605            .build()
1606            .unwrap();
1607        let weights = PixelWeights::prepare(&stencils, &window, 32, &pool).unwrap();
1608        assert_eq!(weights.bytes(), 32);
1609        let stencil = stencils[0][0].as_ref().unwrap();
1610        let mut scratch = WeightScratch::new(4);
1611        for i in 0..2 {
1612            let point = [i as f64 + 0.5, 1.5];
1613            let expected = stencil
1614                .polygon
1615                .square_weights(square.unwrap(), point, &mut scratch);
1616            assert_eq!(
1617                weights
1618                    .get(i, 4)
1619                    .unwrap()
1620                    .iter()
1621                    .map(|v| v.to_bits())
1622                    .collect::<Vec<_>>(),
1623                expected.iter().map(|v| v.to_bits()).collect::<Vec<_>>()
1624            );
1625        }
1626        assert!(weights.get(2, 4).is_none());
1627        let empty = PixelWeights::prepare(&stencils, &window, 0, &pool).unwrap();
1628        assert_eq!(empty.bytes(), 0);
1629        assert!(empty.get(0, 4).is_none());
1630    }
1631
1632    #[test]
1633    fn davis_endpoint_intersection_uses_polyline_midpoint() {
1634        let point = face_application(
1635            &[
1636                [6628997.072593668, 1968668.2335325822],
1637                [6629091.54307014, 1968573.76305611],
1638            ],
1639            [
1640                [6629166.54307014, 1968648.76305611],
1641                [6629016.54307014, 1968498.76305611],
1642            ],
1643        )
1644        .unwrap();
1645        assert_eq!(point, [6629044.307831904, 1968620.998294346]);
1646    }
1647
1648    #[test]
1649    fn native_plot_tolerance_is_not_an_extra_depth_cutoff() {
1650        assert!(deliver_pixel(10.0005, 10.0, 0.001).is_none());
1651        assert!(deliver_pixel(10.01, 10.0, 0.001).is_some());
1652        // Native f32 terrain+tolerance may round to terrain at large elevations.
1653        assert_eq!(
1654            deliver_pixel(100000.0, 100000.0, 0.001),
1655            Some([0.0, 100000.0])
1656        );
1657        assert!(deliver_pixel(NODATA, 10.0, 0.001).is_none());
1658    }
1659
1660    #[test]
1661    fn invalid_face_geometry_is_rejected() {
1662        assert!(face_application(&[[0.0, f64::NAN], [1.0, 1.0]], [[0.0; 2]; 2]).is_err());
1663        assert!(face_application(&[[0.0; 2]], [[0.0; 2]; 2]).is_err());
1664    }
1665}
1666
1667#[cfg(test)]
1668mod boundary_surface_tests {
1669    use super::*;
1670    fn fixture() -> (Vec<Vec<Option<CellStencil>>>, Vec<PreparedProfile>) {
1671        let points = vec![[0., 0.], [1., 0.], [1., 1.], [0., 1.]];
1672        let polygon = PolygonStencil::new(points.clone()).unwrap();
1673        let stencil = CellStencil {
1674            physical_ring: points,
1675            polygon,
1676            square: Some([0., 0., 1., 1.]),
1677            corners: vec![[0, 0]; 4],
1678            faces: vec![],
1679            elevations: None,
1680        };
1681        let profile = PreparedProfile {
1682            local_values: vec![vec![2.; 4]],
1683            face_values: vec![vec![]],
1684            depth_weights: vec![vec![]],
1685            horizontal: vec![None],
1686            wet: vec![true],
1687            options: Options::default(),
1688        };
1689        (vec![vec![Some(stencil)]], vec![profile])
1690    }
1691    #[test]
1692    fn signed_boundary_surface_preserves_dry_side_without_changing_raster_visibility() {
1693        let (stencils, profiles) = fixture();
1694        let mut scratch = WeightScratch::new(0);
1695        let surface = boundary_surface_value(&stencils, &profiles, 0, 0, [0.5, 0.5], &mut scratch)
1696            .unwrap()
1697            .unwrap();
1698        assert_eq!(surface.to_bits(), 2f32.to_bits());
1699        assert_eq!(Some(surface - 10001.), Some(-9999.));
1700        assert!(deliver_pixel(surface, 10001., 0.001).is_none());
1701    }
1702    #[test]
1703    fn boundary_surface_rejects_faces_and_retains_explicit_missing_cells() {
1704        let (stencils, mut profiles) = fixture();
1705        let mut scratch = WeightScratch::new(0);
1706        profiles[0].wet[0] = false;
1707        assert_eq!(
1708            boundary_surface_value(&stencils, &profiles, 0, 0, [0.5, 0.5], &mut scratch).unwrap(),
1709            None
1710        );
1711        profiles[0].options.render_mode = RenderMode::WithFaces;
1712        assert!(matches!(
1713            boundary_surface_value(&stencils, &profiles, 0, 0, [0.5, 0.5], &mut scratch),
1714            Err(MapError::Unsupported(_))
1715        ));
1716    }
1717}