axiolid_reference/
section.rs

1//! Portable scalar plane section of a closed oriented triangle mesh.
2//!
3//! Topology is classified from the exact binary64 plane equation. Geometry is
4//! emitted in the caller's plane frame, with source-edge connectivity preserved;
5//! no tolerance-based point welding is used.
6
7use std::collections::{BTreeMap, BTreeSet};
8
9use axiolid_contracts::{
10    Backend, BackendDescriptor, BackendId, CancellationGranularity, ExecutionOptions,
11    ExecutionTarget, GeomError, GeomResult, ScratchRequirement, Sign,
12};
13use axiolid_core::{Frame3, Point2, Point3};
14use axiolid_mesh::TriMesh;
15use axiolid_mesh_section_contract::{
16    MeshPlaneSection, SectionContour, SectionEvidence, SectionLimits, SectionOutcome,
17};
18
19use crate::orient3::orient3d;
20use crate::orientation::orient2d;
21
22/// Portable deterministic mesh plane-section oracle.
23#[derive(Debug, Default, Clone, Copy)]
24pub struct ScalarSection;
25
26impl ScalarSection {
27    /// Stable identity for this reference provider.
28    pub const ID: BackendId = BackendId::new("scalar-section");
29
30    /// Construct the provider.
31    #[must_use]
32    pub const fn new() -> Self {
33        Self
34    }
35}
36
37impl Backend for ScalarSection {
38    fn descriptor(&self) -> BackendDescriptor {
39        BackendDescriptor::new(Self::ID, ExecutionTarget::PortableCpu)
40    }
41}
42
43impl MeshPlaneSection for ScalarSection {
44    fn scratch_requirement(&self) -> ScratchRequirement {
45        // Signs/distances, source-edge keys, segment adjacency, and cycle state.
46        // A closed triangular manifold has O(triangles) vertices and edges.
47        ScratchRequirement::PerElement {
48            bytes_per_element: 512,
49        }
50    }
51
52    fn cancellation_granularity(&self) -> CancellationGranularity {
53        CancellationGranularity::Incremental
54    }
55
56    fn section(
57        &self,
58        mesh: &TriMesh,
59        frame: Frame3,
60        limits: SectionLimits,
61        options: &ExecutionOptions,
62    ) -> GeomResult<SectionOutcome> {
63        options.check_cancelled()?;
64        check_source_limits(mesh, limits)?;
65
66        let plane = ExactSectionPlane::new(frame)?;
67        let mut classifications = Vec::new();
68        classifications
69            .try_reserve_exact(mesh.positions.len())
70            .map_err(|_| GeomError::BudgetExceeded { resource: "memory" })?;
71        for &point in &mesh.positions {
72            let classification = plane.classify(point)?;
73            classifications.push(classification);
74        }
75
76        let mut segments = BTreeSet::<Segment>::new();
77        let mut on_plane_edges = BTreeMap::<EdgeKey, Vec<Sign>>::new();
78        for (triangle_index, triangle) in mesh.triangles().enumerate() {
79            options.check_cancelled()?;
80            let vertices = [
81                mesh_index(triangle[0])?,
82                mesh_index(triangle[1])?,
83                mesh_index(triangle[2])?,
84            ];
85            let signs = vertices.map(|index| classifications[index].sign);
86            let zero_count = signs.iter().filter(|&&sign| sign == Sign::Zero).count();
87            match zero_count {
88                3 => {
89                    return Err(GeomError::Degenerate(format!(
90                        "section plane contains source triangle {triangle_index}; a two-dimensional overlap is not a curve"
91                    )))
92                }
93                2 => {
94                    let mut zeros = triangle
95                        .into_iter()
96                        .zip(signs)
97                        .filter_map(|(index, sign)| (sign == Sign::Zero).then_some(index));
98                    let left = zeros.next().ok_or_else(internal_topology_error)?;
99                    let right = zeros.next().ok_or_else(internal_topology_error)?;
100                    let third = signs
101                        .into_iter()
102                        .find(|&sign| sign != Sign::Zero)
103                        .ok_or_else(internal_topology_error)?;
104                    on_plane_edges
105                        .entry(EdgeKey::new(left, right))
106                        .or_default()
107                        .push(third);
108                }
109                1 => {
110                    let zero_corner = (0..3)
111                        .find(|&corner| signs[corner] == Sign::Zero)
112                        .ok_or_else(internal_topology_error)?;
113                    let (first, second) = match zero_corner {
114                        0 => (1, 2),
115                        1 => (0, 2),
116                        2 => (0, 1),
117                        _ => return Err(internal_topology_error()),
118                    };
119                    if opposite(signs[first], signs[second]) {
120                        let segment = Segment::new(
121                            NodeKey::Vertex(triangle[zero_corner]),
122                            NodeKey::Edge(EdgeKey::new(triangle[first], triangle[second])),
123                        )?;
124                        insert_segment(&mut segments, segment, limits)?;
125                    }
126                }
127                0 => {
128                    let mut crossing = [None, None];
129                    let mut crossing_count = 0usize;
130                    for (left, right) in [(0, 1), (1, 2), (2, 0)] {
131                        if opposite(signs[left], signs[right]) {
132                            if crossing_count >= crossing.len() {
133                                return Err(internal_topology_error());
134                            }
135                            crossing[crossing_count] = Some(NodeKey::Edge(EdgeKey::new(
136                                triangle[left],
137                                triangle[right],
138                            )));
139                            crossing_count += 1;
140                        }
141                    }
142                    match (crossing[0], crossing[1]) {
143                        (Some(first), Some(second)) => insert_segment(
144                            &mut segments,
145                            Segment::new(first, second)?,
146                            limits,
147                        )?,
148                        (None, None) => {}
149                        _ => return Err(internal_topology_error()),
150                    }
151                }
152                _ => return Err(internal_topology_error()),
153            }
154        }
155
156        for (edge, incident_signs) in on_plane_edges {
157            options.check_cancelled()?;
158            if incident_signs.len() != 2 {
159                return Err(GeomError::NotManifold(format!(
160                    "on-plane mesh edge {:?} has {} incident triangles",
161                    edge,
162                    incident_signs.len()
163                )));
164            }
165            if opposite(incident_signs[0], incident_signs[1]) {
166                insert_segment(
167                    &mut segments,
168                    Segment::new(NodeKey::Vertex(edge.0), NodeKey::Vertex(edge.1))?,
169                    limits,
170                )?;
171            }
172        }
173
174        let contours =
175            assemble_contours(mesh, frame, &classifications, &segments, limits, options)?;
176        let output_vertices = contours.iter().map(|contour| contour.points.len()).sum();
177        let evidence =
178            SectionEvidence::input_mesh(mesh.triangle_count(), output_vertices, contours.len());
179        Ok(SectionOutcome::new(frame, contours, evidence))
180    }
181}
182
183#[derive(Debug, Clone, Copy)]
184struct Classification {
185    sign: Sign,
186    distance: f64,
187}
188
189#[derive(Debug, Clone, Copy)]
190struct ExactSectionPlane {
191    origin: Point3,
192    x_point: Point3,
193    y_point: Point3,
194    normal: axiolid_core::Vec3,
195}
196
197impl ExactSectionPlane {
198    fn new(frame: Frame3) -> GeomResult<Self> {
199        let x_point = frame.origin + frame.x;
200        let y_point = frame.origin + frame.y;
201        if !x_point.is_finite()
202            || !y_point.is_finite()
203            || x_point == frame.origin
204            || y_point == frame.origin
205            || x_point == y_point
206        {
207            return Err(GeomError::Degenerate(
208                "section frame cannot resolve a finite affine plane at this coordinate magnitude"
209                    .into(),
210            ));
211        }
212        let normal = (x_point - frame.origin).cross(y_point - frame.origin);
213        let normal_length = normal.length();
214        if !normal_length.is_finite() || normal_length == 0.0 {
215            return Err(GeomError::Degenerate(
216                "section affine plane has no finite normal".into(),
217            ));
218        }
219        Ok(Self {
220            origin: frame.origin,
221            x_point,
222            y_point,
223            normal: normal / normal_length,
224        })
225    }
226
227    fn classify(self, point: Point3) -> GeomResult<Classification> {
228        let sign = match orient3d(self.origin, self.x_point, self.y_point, point) {
229            axiolid_contracts::Certified::Certain { sign, .. } => sign,
230            _ => {
231                return Err(GeomError::Degenerate(
232                    "certified plane-side predicate returned an uncertain sign".into(),
233                ))
234            }
235        };
236        let distance = self.normal.dot(point - self.origin);
237        if !distance.is_finite() {
238            return Err(GeomError::Degenerate(
239                "section signed distance is not finite".into(),
240            ));
241        }
242        if sign != Sign::Zero && distance == 0.0 {
243            return Err(GeomError::Degenerate(
244                "section interpolation lost a certified nonzero plane offset".into(),
245            ));
246        }
247        Ok(Classification { sign, distance })
248    }
249}
250
251fn opposite(left: Sign, right: Sign) -> bool {
252    matches!(
253        (left, right),
254        (Sign::Negative, Sign::Positive) | (Sign::Positive, Sign::Negative)
255    )
256}
257
258#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
259struct EdgeKey(u32, u32);
260
261impl EdgeKey {
262    fn new(left: u32, right: u32) -> Self {
263        Self(left.min(right), left.max(right))
264    }
265}
266
267#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
268enum NodeKey {
269    Vertex(u32),
270    Edge(EdgeKey),
271}
272
273#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
274struct Segment(NodeKey, NodeKey);
275
276impl Segment {
277    fn new(left: NodeKey, right: NodeKey) -> GeomResult<Self> {
278        if left == right {
279            return Err(GeomError::Degenerate(
280                "plane section collapsed a segment to one source-topology node".into(),
281            ));
282        }
283        Ok(Self(left.min(right), left.max(right)))
284    }
285}
286
287fn insert_segment(
288    segments: &mut BTreeSet<Segment>,
289    segment: Segment,
290    limits: SectionLimits,
291) -> GeomResult<()> {
292    if segments.contains(&segment) {
293        return Err(GeomError::NotManifold(
294            "two source faces produced the same non-coplanar section segment".into(),
295        ));
296    }
297    if segments.len() >= limits.max_output_vertices {
298        return Err(GeomError::BudgetExceeded {
299            resource: "section output vertices",
300        });
301    }
302    segments.insert(segment);
303    Ok(())
304}
305
306fn assemble_contours(
307    mesh: &TriMesh,
308    frame: Frame3,
309    classifications: &[Classification],
310    segments: &BTreeSet<Segment>,
311    limits: SectionLimits,
312    options: &ExecutionOptions,
313) -> GeomResult<Vec<SectionContour>> {
314    let mut adjacency = BTreeMap::<NodeKey, Vec<NodeKey>>::new();
315    for &Segment(left, right) in segments {
316        adjacency.entry(left).or_default().push(right);
317        adjacency.entry(right).or_default().push(left);
318    }
319    for neighbours in adjacency.values_mut() {
320        neighbours.sort_unstable();
321        if neighbours.len() != 2 {
322            return Err(GeomError::NotManifold(format!(
323                "section graph has degree {}, expected 2",
324                neighbours.len()
325            )));
326        }
327    }
328
329    let mut visited = BTreeSet::<Segment>::new();
330    let mut contours = Vec::new();
331    for &start in adjacency.keys() {
332        let start_is_complete = adjacency[&start].iter().try_fold(true, |complete, &next| {
333            let segment = Segment::new(start, next)?;
334            Ok::<bool, GeomError>(complete && visited.contains(&segment))
335        })?;
336        if start_is_complete {
337            continue;
338        }
339        options.check_cancelled()?;
340        if contours.len() >= limits.max_contours {
341            return Err(GeomError::BudgetExceeded {
342                resource: "section contours",
343            });
344        }
345        let mut nodes = Vec::new();
346        let mut previous = None;
347        let mut current = start;
348        loop {
349            if nodes.len() >= limits.max_output_vertices {
350                return Err(GeomError::BudgetExceeded {
351                    resource: "section output vertices",
352                });
353            }
354            nodes.push(current);
355            let neighbours = adjacency
356                .get(&current)
357                .ok_or_else(internal_topology_error)?;
358            let next = match previous {
359                None => neighbours[0],
360                Some(previous) if neighbours[0] == previous => neighbours[1],
361                Some(_) => neighbours[0],
362            };
363            let edge = Segment::new(current, next)?;
364            if !visited.insert(edge) && next != start {
365                return Err(GeomError::NotManifold(
366                    "section graph revisited an edge before closing a contour".into(),
367                ));
368            }
369            previous = Some(current);
370            current = next;
371            if current == start {
372                break;
373            }
374            if nodes.len() > segments.len() {
375                return Err(internal_topology_error());
376            }
377        }
378        if nodes.len() < 3 {
379            return Err(GeomError::Degenerate(
380                "section contour has fewer than three source-topology nodes".into(),
381            ));
382        }
383        let mut points = Vec::new();
384        points
385            .try_reserve_exact(nodes.len())
386            .map_err(|_| GeomError::BudgetExceeded { resource: "memory" })?;
387        for node in nodes {
388            let world = node_point(mesh, classifications, node)?;
389            let local = world - frame.origin;
390            let point = Point2::new(local.dot(frame.x), local.dot(frame.y));
391            if !point.is_finite() {
392                return Err(GeomError::Degenerate(
393                    "section projection exceeded finite arithmetic".into(),
394                ));
395            }
396            points.push(point);
397        }
398        simplify_collinear(&mut points, options.tolerance().linear());
399        if points.len() < 3 {
400            return Err(GeomError::Degenerate(
401                "section contour collapsed below three vertices".into(),
402            ));
403        }
404        let area = signed_area(&points);
405        if !area.is_finite() || area == 0.0 {
406            return Err(GeomError::Degenerate(
407                "section contour has no finite signed area".into(),
408            ));
409        }
410        if area < 0.0 {
411            points[1..].reverse();
412        }
413        contours.push(SectionContour::new(points));
414    }
415    contours.sort_by(|left, right| point_order(&left.points[0], &right.points[0]));
416    Ok(contours)
417}
418
419fn mesh_index(index: u32) -> GeomResult<usize> {
420    usize::try_from(index)
421        .map_err(|_| GeomError::InvalidInput("mesh index does not fit usize".into()))
422}
423
424fn node_point(
425    mesh: &TriMesh,
426    classifications: &[Classification],
427    node: NodeKey,
428) -> GeomResult<Point3> {
429    match node {
430        NodeKey::Vertex(index) => Ok(mesh.positions[mesh_index(index)?]),
431        NodeKey::Edge(EdgeKey(left, right)) => {
432            let a = mesh.positions[mesh_index(left)?];
433            let b = mesh.positions[mesh_index(right)?];
434            let da = classifications[mesh_index(left)?].distance.abs();
435            let db = classifications[mesh_index(right)?].distance.abs();
436            if !(da > 0.0 && db > 0.0 && da.is_finite() && db.is_finite()) {
437                return Err(GeomError::Degenerate(
438                    "crossing edge has no representable endpoint distance".into(),
439                ));
440            }
441            let t = if da >= db {
442                1.0 / (1.0 + db / da)
443            } else {
444                let ratio = da / db;
445                ratio / (1.0 + ratio)
446            };
447            let point = a + (b - a) * t;
448            if !point.is_finite() {
449                return Err(GeomError::Degenerate(
450                    "edge-plane intersection exceeded finite arithmetic".into(),
451                ));
452            }
453            Ok(point)
454        }
455    }
456}
457
458fn simplify_collinear(points: &mut Vec<Point2>, tolerance: f64) {
459    loop {
460        if points.len() <= 3 {
461            return;
462        }
463        let mut removed = false;
464        for index in 0..points.len() {
465            let previous = points[(index + points.len() - 1) % points.len()];
466            let current = points[index];
467            let next = points[(index + 1) % points.len()];
468            let chord = next - previous;
469            let scale = chord.length();
470            let cross = (current - previous).perp_dot(chord).abs();
471            let within_tolerance = scale > 0.0 && cross <= tolerance * scale;
472            if orient2d(previous, current, next).sign() == Some(Sign::Zero) || within_tolerance {
473                points.remove(index);
474                removed = true;
475                break;
476            }
477        }
478        if !removed {
479            return;
480        }
481    }
482}
483
484fn signed_area(points: &[Point2]) -> f64 {
485    let mut twice = 0.0;
486    for index in 0..points.len() {
487        let current = points[index];
488        let next = points[(index + 1) % points.len()];
489        twice += current.x * next.y - current.y * next.x;
490    }
491    twice * 0.5
492}
493
494fn point_order(left: &Point2, right: &Point2) -> std::cmp::Ordering {
495    left.x
496        .total_cmp(&right.x)
497        .then_with(|| left.y.total_cmp(&right.y))
498}
499
500fn check_source_limits(mesh: &TriMesh, limits: SectionLimits) -> GeomResult<()> {
501    if mesh.positions.len() > limits.max_source_vertices {
502        return Err(GeomError::BudgetExceeded {
503            resource: "section source vertices",
504        });
505    }
506    if mesh.triangle_count() > limits.max_source_triangles {
507        return Err(GeomError::BudgetExceeded {
508            resource: "section source triangles",
509        });
510    }
511    Ok(())
512}
513
514fn internal_topology_error() -> GeomError {
515    GeomError::BackendContractViolation {
516        backend: ScalarSection::ID,
517        detail: "internal section topology state is inconsistent".into(),
518    }
519}
520
521#[cfg(test)]
522mod tests {
523    use super::*;
524
525    #[test]
526    fn exact_plane_sign_keeps_a_tiny_nonzero_binary64_offset() {
527        let frame = Frame3 {
528            origin: Point3::ZERO,
529            x: Point3::X,
530            y: Point3::Y,
531            z: Point3::Z,
532        };
533        let plane = ExactSectionPlane::new(frame).expect("resolvable plane");
534        let point = Point3::new(1.0, -1.0, f64::from_bits(1));
535        let certified = orient3d(plane.origin, plane.x_point, plane.y_point, point);
536        assert!(matches!(
537            certified,
538            axiolid_contracts::Certified::Certain {
539                precision: axiolid_contracts::Precision::Exact,
540                ..
541            }
542        ));
543        let positive = plane.classify(point).expect("finite subnormal");
544        assert_ne!(positive.sign, Sign::Zero);
545    }
546}