Skip to main content

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//!
7//! Two entry points share the exact vertex classification and segment
8//! building:
9//!
10//! - `section` is the legacy path. A face lying in the plane is refused as
11//!   degenerate, and its messages are part of its contract.
12//! - `section_request` (ADR 0091) reports faces lying in the plane as
13//!   regions with a side, keeps cut curves to transversal crossings, and
14//!   counts touching edges. `request` orchestrates it, `regions` traces the
15//!   region boundaries and `chains` assembles open and closed chains.
16
17mod chains;
18mod cut_regions;
19mod regions;
20mod request;
21mod ring_nesting;
22
23use std::collections::{BTreeMap, BTreeSet};
24
25use axiolid_contracts::{
26    Backend, BackendDescriptor, BackendId, CancellationGranularity, ExecutionOptions,
27    ExecutionTarget, GeomError, GeomResult, ScratchRequirement, Sign,
28};
29use axiolid_core::{Frame3, Point2, Point3};
30use axiolid_mesh::TriMesh;
31use axiolid_mesh_section_contract::{
32    MeshPlaneSection, PlaneSection, SectionEvidence, SectionLimits, SectionOutcome, SectionRequest,
33};
34
35use crate::orient3::orient3d;
36use crate::orientation::orient2d;
37use chains::assemble_contours;
38
39/// Portable deterministic mesh plane-section oracle.
40#[derive(Debug, Default, Clone, Copy)]
41pub struct ScalarSection;
42
43impl ScalarSection {
44    /// Stable identity for this reference provider.
45    pub const ID: BackendId = BackendId::new("scalar-section");
46
47    /// Construct the provider.
48    #[must_use]
49    pub const fn new() -> Self {
50        Self
51    }
52}
53
54impl Backend for ScalarSection {
55    fn descriptor(&self) -> BackendDescriptor {
56        BackendDescriptor::new(Self::ID, ExecutionTarget::PortableCpu)
57    }
58}
59
60impl MeshPlaneSection for ScalarSection {
61    fn scratch_requirement(&self) -> ScratchRequirement {
62        // Signs/distances, source-edge keys, segment adjacency, and cycle state.
63        // A closed triangular manifold has O(triangles) vertices and edges.
64        ScratchRequirement::PerElement {
65            bytes_per_element: 512,
66        }
67    }
68
69    fn request_scratch_requirement(&self) -> ScratchRequirement {
70        // Per element, where an element is a position or a triangle: the
71        // legacy bound (512); for a sheet, the edge-use map (3 entries of
72        // about 40 bytes with tree overhead, 120) and the boundary edge and
73        // vertex sets (at most 3 and 3 entries of about 24 bytes, 144); for
74        // in-plane triangles, the directed-edge map (3 entries of about 40
75        // bytes, 120), the triangles, boxes and flat copies (about 112), the
76        // sweep order and component roots (16), the boundary set (72) and
77        // the cycle and ring state (about 100). About 1.2 KiB in all,
78        // rounded up to 1536 bytes. Cut regions exist for a solid only, so
79        // their state never coexists with the sheet state (264): the cut
80        // graph (two maps, 2 entries per segment of about 48 bytes, 96), the
81        // traced paths and rings (about 64) and the nesting sweep's edges
82        // and ring bounds (about 96) add about 0.25 KiB to the 0.9 KiB that
83        // remain without the sheet state, which the bound still covers.
84        ScratchRequirement::PerElement {
85            bytes_per_element: 1536,
86        }
87    }
88
89    fn cancellation_granularity(&self) -> CancellationGranularity {
90        CancellationGranularity::Incremental
91    }
92
93    fn section_request(
94        &self,
95        mesh: &TriMesh,
96        request: &SectionRequest,
97        options: &ExecutionOptions,
98    ) -> GeomResult<PlaneSection> {
99        request::plane_section(mesh, request, options)
100    }
101
102    fn section(
103        &self,
104        mesh: &TriMesh,
105        frame: Frame3,
106        limits: SectionLimits,
107        options: &ExecutionOptions,
108    ) -> GeomResult<SectionOutcome> {
109        options.check_cancelled()?;
110        check_source_limits(mesh, limits)?;
111
112        let plane = ExactSectionPlane::new(frame)?;
113        let classifications = classify_vertices(mesh, plane)?;
114
115        let mut segments = BTreeSet::<Segment>::new();
116        let mut on_plane_edges = BTreeMap::<EdgeKey, Vec<Sign>>::new();
117        for (triangle_index, triangle) in mesh.triangles().enumerate() {
118            options.check_cancelled()?;
119            let vertices = [
120                mesh_index(triangle[0])?,
121                mesh_index(triangle[1])?,
122                mesh_index(triangle[2])?,
123            ];
124            let signs = vertices.map(|index| classifications[index].sign);
125            let zero_count = signs.iter().filter(|&&sign| sign == Sign::Zero).count();
126            match zero_count {
127                3 => {
128                    return Err(GeomError::Degenerate(format!(
129                        "section plane contains source triangle {triangle_index}; a two-dimensional overlap is not a curve"
130                    )))
131                }
132                2 => {
133                    let mut zeros = triangle
134                        .into_iter()
135                        .zip(signs)
136                        .filter_map(|(index, sign)| (sign == Sign::Zero).then_some(index));
137                    let left = zeros.next().ok_or_else(internal_topology_error)?;
138                    let right = zeros.next().ok_or_else(internal_topology_error)?;
139                    let third = signs
140                        .into_iter()
141                        .find(|&sign| sign != Sign::Zero)
142                        .ok_or_else(internal_topology_error)?;
143                    on_plane_edges
144                        .entry(EdgeKey::new(left, right))
145                        .or_default()
146                        .push(third);
147                }
148                1 => {
149                    let zero_corner = (0..3)
150                        .find(|&corner| signs[corner] == Sign::Zero)
151                        .ok_or_else(internal_topology_error)?;
152                    let (first, second) = match zero_corner {
153                        0 => (1, 2),
154                        1 => (0, 2),
155                        2 => (0, 1),
156                        _ => return Err(internal_topology_error()),
157                    };
158                    if opposite(signs[first], signs[second]) {
159                        let segment = Segment::new(
160                            NodeKey::Vertex(triangle[zero_corner]),
161                            NodeKey::Edge(EdgeKey::new(triangle[first], triangle[second])),
162                        )?;
163                        insert_segment(&mut segments, segment, limits)?;
164                    }
165                }
166                0 => {
167                    let mut crossing = [None, None];
168                    let mut crossing_count = 0usize;
169                    for (left, right) in [(0, 1), (1, 2), (2, 0)] {
170                        if opposite(signs[left], signs[right]) {
171                            if crossing_count >= crossing.len() {
172                                return Err(internal_topology_error());
173                            }
174                            crossing[crossing_count] = Some(NodeKey::Edge(EdgeKey::new(
175                                triangle[left],
176                                triangle[right],
177                            )));
178                            crossing_count += 1;
179                        }
180                    }
181                    match (crossing[0], crossing[1]) {
182                        (Some(first), Some(second)) => insert_segment(
183                            &mut segments,
184                            Segment::new(first, second)?,
185                            limits,
186                        )?,
187                        (None, None) => {}
188                        _ => return Err(internal_topology_error()),
189                    }
190                }
191                _ => return Err(internal_topology_error()),
192            }
193        }
194
195        for (edge, incident_signs) in on_plane_edges {
196            options.check_cancelled()?;
197            if incident_signs.len() != 2 {
198                return Err(GeomError::NotManifold(format!(
199                    "on-plane mesh edge {:?} has {} incident triangles",
200                    edge,
201                    incident_signs.len()
202                )));
203            }
204            if opposite(incident_signs[0], incident_signs[1]) {
205                insert_segment(
206                    &mut segments,
207                    Segment::new(NodeKey::Vertex(edge.0), NodeKey::Vertex(edge.1))?,
208                    limits,
209                )?;
210            }
211        }
212
213        let contours =
214            assemble_contours(mesh, frame, &classifications, &segments, limits, options)?;
215        let output_vertices = contours.iter().map(|contour| contour.points.len()).sum();
216        let evidence =
217            SectionEvidence::input_mesh(mesh.triangle_count(), output_vertices, contours.len());
218        Ok(SectionOutcome::new(frame, contours, evidence))
219    }
220}
221
222#[derive(Debug, Clone, Copy)]
223struct Classification {
224    sign: Sign,
225    distance: f64,
226}
227
228#[derive(Debug, Clone, Copy)]
229struct ExactSectionPlane {
230    origin: Point3,
231    x_point: Point3,
232    y_point: Point3,
233    normal: axiolid_core::Vec3,
234}
235
236impl ExactSectionPlane {
237    fn new(frame: Frame3) -> GeomResult<Self> {
238        let x_point = frame.origin + frame.x;
239        let y_point = frame.origin + frame.y;
240        if !x_point.is_finite()
241            || !y_point.is_finite()
242            || x_point == frame.origin
243            || y_point == frame.origin
244            || x_point == y_point
245        {
246            return Err(GeomError::Degenerate(
247                "section frame cannot resolve a finite affine plane at this coordinate magnitude"
248                    .into(),
249            ));
250        }
251        let normal = (x_point - frame.origin).cross(y_point - frame.origin);
252        let normal_length = normal.length();
253        if !normal_length.is_finite() || normal_length == 0.0 {
254            return Err(GeomError::Degenerate(
255                "section affine plane has no finite normal".into(),
256            ));
257        }
258        Ok(Self {
259            origin: frame.origin,
260            x_point,
261            y_point,
262            normal: normal / normal_length,
263        })
264    }
265
266    fn classify(self, point: Point3) -> GeomResult<Classification> {
267        let sign = match orient3d(self.origin, self.x_point, self.y_point, point) {
268            axiolid_contracts::Certified::Certain { sign, .. } => sign,
269            _ => {
270                return Err(GeomError::Degenerate(
271                    "certified plane-side predicate returned an uncertain sign".into(),
272                ))
273            }
274        };
275        let distance = self.normal.dot(point - self.origin);
276        if !distance.is_finite() {
277            return Err(GeomError::Degenerate(
278                "section signed distance is not finite".into(),
279            ));
280        }
281        if sign != Sign::Zero && distance == 0.0 {
282            return Err(GeomError::Degenerate(
283                "section interpolation lost a certified nonzero plane offset".into(),
284            ));
285        }
286        Ok(Classification { sign, distance })
287    }
288}
289
290/// Exact plane side and signed distance of every source position.
291fn classify_vertices(mesh: &TriMesh, plane: ExactSectionPlane) -> GeomResult<Vec<Classification>> {
292    let mut classifications = Vec::new();
293    classifications
294        .try_reserve_exact(mesh.positions.len())
295        .map_err(|_| GeomError::BudgetExceeded { resource: "memory" })?;
296    for &point in &mesh.positions {
297        let classification = plane.classify(point)?;
298        classifications.push(classification);
299    }
300    Ok(classifications)
301}
302
303fn opposite(left: Sign, right: Sign) -> bool {
304    matches!(
305        (left, right),
306        (Sign::Negative, Sign::Positive) | (Sign::Positive, Sign::Negative)
307    )
308}
309
310#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
311struct EdgeKey(u32, u32);
312
313impl EdgeKey {
314    fn new(left: u32, right: u32) -> Self {
315        Self(left.min(right), left.max(right))
316    }
317}
318
319#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
320enum NodeKey {
321    Vertex(u32),
322    Edge(EdgeKey),
323}
324
325#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
326struct Segment(NodeKey, NodeKey);
327
328impl Segment {
329    fn new(left: NodeKey, right: NodeKey) -> GeomResult<Self> {
330        if left == right {
331            return Err(GeomError::Degenerate(
332                "plane section collapsed a segment to one source-topology node".into(),
333            ));
334        }
335        Ok(Self(left.min(right), left.max(right)))
336    }
337}
338
339fn insert_segment(
340    segments: &mut BTreeSet<Segment>,
341    segment: Segment,
342    limits: SectionLimits,
343) -> GeomResult<()> {
344    if segments.contains(&segment) {
345        return Err(GeomError::NotManifold(
346            "two source faces produced the same non-coplanar section segment".into(),
347        ));
348    }
349    if segments.len() >= limits.max_output_vertices {
350        return Err(GeomError::BudgetExceeded {
351            resource: "section output vertices",
352        });
353    }
354    segments.insert(segment);
355    Ok(())
356}
357
358fn mesh_index(index: u32) -> GeomResult<usize> {
359    usize::try_from(index)
360        .map_err(|_| GeomError::InvalidInput("mesh index does not fit usize".into()))
361}
362
363fn node_point(
364    mesh: &TriMesh,
365    classifications: &[Classification],
366    node: NodeKey,
367) -> GeomResult<Point3> {
368    match node {
369        NodeKey::Vertex(index) => Ok(mesh.positions[mesh_index(index)?]),
370        NodeKey::Edge(EdgeKey(left, right)) => {
371            let a = mesh.positions[mesh_index(left)?];
372            let b = mesh.positions[mesh_index(right)?];
373            let da = classifications[mesh_index(left)?].distance.abs();
374            let db = classifications[mesh_index(right)?].distance.abs();
375            if !(da > 0.0 && db > 0.0 && da.is_finite() && db.is_finite()) {
376                return Err(GeomError::Degenerate(
377                    "crossing edge has no representable endpoint distance".into(),
378                ));
379            }
380            let t = if da >= db {
381                1.0 / (1.0 + db / da)
382            } else {
383                let ratio = da / db;
384                ratio / (1.0 + ratio)
385            };
386            let point = a + (b - a) * t;
387            if !point.is_finite() {
388                return Err(GeomError::Degenerate(
389                    "edge-plane intersection exceeded finite arithmetic".into(),
390                ));
391            }
392            Ok(point)
393        }
394    }
395}
396
397fn simplify_collinear(points: &mut Vec<Point2>, tolerance: f64) {
398    loop {
399        if points.len() <= 3 {
400            return;
401        }
402        let mut removed = false;
403        for index in 0..points.len() {
404            let previous = points[(index + points.len() - 1) % points.len()];
405            let current = points[index];
406            let next = points[(index + 1) % points.len()];
407            let chord = next - previous;
408            let scale = chord.length();
409            let cross = (current - previous).perp_dot(chord).abs();
410            let within_tolerance = scale > 0.0 && cross <= tolerance * scale;
411            if orient2d(previous, current, next).sign() == Some(Sign::Zero) || within_tolerance {
412                points.remove(index);
413                removed = true;
414                break;
415            }
416        }
417        if !removed {
418            return;
419        }
420    }
421}
422
423fn signed_area(points: &[Point2]) -> f64 {
424    let mut twice = 0.0;
425    for index in 0..points.len() {
426        let current = points[index];
427        let next = points[(index + 1) % points.len()];
428        twice += current.x * next.y - current.y * next.x;
429    }
430    twice * 0.5
431}
432
433/// The exact sign of the signed area of the polygon through `points`: the sum
434/// of `x_i y_{i+1} - x_{i+1} y_i` in expansion arithmetic, with no rounding.
435/// Positive is counter-clockwise.
436///
437/// Products are exact only inside the binary64 range, so a nonzero coordinate
438/// outside `2^-450 ..= 2^450` in magnitude (subnormal included) is refused:
439/// its products could underflow or overflow, and a wrong sign is worse than a
440/// refusal.
441fn exact_area_sign(points: &[Point2]) -> GeomResult<Sign> {
442    use axiolid_predicates::arithmetic::{expansion_sign, expansion_sum};
443    use axiolid_predicates::two_product;
444
445    let in_range = |value: f64| {
446        let encoded = ((value.to_bits() >> 52) & 0x7ff) as i32;
447        value == 0.0 || (encoded != 0 && (-450..=450).contains(&(encoded - 1023)))
448    };
449    if !points.iter().all(|p| in_range(p.x) && in_range(p.y)) {
450        return Err(GeomError::Degenerate(
451            "section chain coordinates are outside the exact area range".into(),
452        ));
453    }
454    let mut twice = vec![0.0];
455    for index in 0..points.len() {
456        let (current, next) = (points[index], points[(index + 1) % points.len()]);
457        if !(current.is_finite() && next.is_finite()) {
458            return Err(GeomError::Degenerate(
459                "section chain has a non-finite point".into(),
460            ));
461        }
462        let (forward, forward_error) = two_product(current.x, next.y);
463        let (backward, backward_error) = two_product(next.x, current.y);
464        twice = expansion_sum(&twice, &[forward_error, forward]);
465        twice = expansion_sum(&twice, &[-backward_error, -backward]);
466    }
467    Ok(expansion_sign(&twice))
468}
469
470fn point_order(left: &Point2, right: &Point2) -> std::cmp::Ordering {
471    left.x
472        .total_cmp(&right.x)
473        .then_with(|| left.y.total_cmp(&right.y))
474}
475
476/// Lexicographic order of point sequences; a prefix sorts first.
477fn sequence_order(left: &[Point2], right: &[Point2]) -> std::cmp::Ordering {
478    left.iter()
479        .zip(right)
480        .map(|(a, b)| point_order(a, b))
481        .find(|order| order.is_ne())
482        .unwrap_or_else(|| left.len().cmp(&right.len()))
483}
484
485fn check_source_limits(mesh: &TriMesh, limits: SectionLimits) -> GeomResult<()> {
486    if mesh.positions.len() > limits.max_source_vertices {
487        return Err(GeomError::BudgetExceeded {
488            resource: "section source vertices",
489        });
490    }
491    if mesh.triangle_count() > limits.max_source_triangles {
492        return Err(GeomError::BudgetExceeded {
493            resource: "section source triangles",
494        });
495    }
496    Ok(())
497}
498
499fn internal_topology_error() -> GeomError {
500    GeomError::BackendContractViolation {
501        backend: ScalarSection::ID,
502        detail: "internal section topology state is inconsistent".into(),
503    }
504}
505
506/// Plane-local coordinates of a model-space point. Chain points and region
507/// vertices both go through this one function, so a chain end that lies on a
508/// region boundary is bit-identical to the region vertex.
509fn project_local(frame: Frame3, world: Point3) -> GeomResult<Point2> {
510    let local = world - frame.origin;
511    let point = Point2::new(local.dot(frame.x), local.dot(frame.y));
512    if point.is_finite() {
513        Ok(point)
514    } else {
515        Err(GeomError::Degenerate(
516            "section projection exceeded finite arithmetic".into(),
517        ))
518    }
519}
520
521/// The exact sign of `orient2d`, refusing an uncertain predicate.
522fn orient_sign(a: Point2, b: Point2, c: Point2) -> GeomResult<Sign> {
523    orient2d(a, b, c).sign().ok_or_else(|| {
524        GeomError::Degenerate("certified orientation predicate returned an uncertain sign".into())
525    })
526}
527
528/// Product of two signs.
529fn sign_times(left: Sign, right: Sign) -> Sign {
530    if left == Sign::Zero || right == Sign::Zero {
531        Sign::Zero
532    } else if left == right {
533        Sign::Positive
534    } else {
535        Sign::Negative
536    }
537}
538
539#[cfg(test)]
540mod tests {
541    use super::*;
542
543    #[test]
544    fn the_area_sign_is_exact_where_the_float_area_is_wrong() {
545        // A thin triangle 1e8 from the origin: counter-clockwise, twice its
546        // area about 0.41, while the float sum of cross products is -2.
547        let points = [
548            Point2::new(100000002.228125, 100000001.5671875),
549            Point2::new(100000001.9375, 100000001.275),
550            Point2::new(100000002.615625, 100000000.5578125),
551        ];
552        assert!(signed_area(&points) < 0.0);
553        assert_eq!(exact_area_sign(&points).expect("sign"), Sign::Positive);
554        let reversed = [points[2], points[1], points[0]];
555        assert_eq!(exact_area_sign(&reversed).expect("sign"), Sign::Negative);
556        let flat = [points[0], points[0], points[1]];
557        assert_eq!(exact_area_sign(&flat).expect("sign"), Sign::Zero);
558    }
559
560    #[test]
561    fn the_area_sign_refuses_coordinates_whose_products_leave_the_range() {
562        // Products near 2^-1060 underflow: refused, not a wrong sign.
563        let tiny = [
564            Point2::new(-8.036185191855754e-160, -8.029313620019926e-160),
565            Point2::new(-8.036193993089111e-160, -8.028301829579562e-160),
566            Point2::new(-8.019240586512659e-160, -8.033664284419165e-160),
567        ];
568        assert!(matches!(
569            exact_area_sign(&tiny),
570            Err(GeomError::Degenerate(_))
571        ));
572        // Products near 1e400 overflow.
573        let huge = [
574            Point2::new(1e200, 1e200),
575            Point2::new(2e200, 1e200),
576            Point2::new(1e200, 3e200),
577        ];
578        assert!(matches!(
579            exact_area_sign(&huge),
580            Err(GeomError::Degenerate(_))
581        ));
582        // Large but inside the range stays exact.
583        let large = [
584            Point2::new(1e100, 1e100),
585            Point2::new(2e100, 1e100),
586            Point2::new(1e100, 3e100),
587        ];
588        assert_eq!(exact_area_sign(&large).expect("sign"), Sign::Positive);
589    }
590
591    #[test]
592    fn exact_plane_sign_keeps_a_tiny_nonzero_binary64_offset() {
593        let frame = Frame3 {
594            origin: Point3::ZERO,
595            x: Point3::X,
596            y: Point3::Y,
597            z: Point3::Z,
598        };
599        let plane = ExactSectionPlane::new(frame).expect("resolvable plane");
600        let point = Point3::new(1.0, -1.0, f64::from_bits(1));
601        let certified = orient3d(plane.origin, plane.x_point, plane.y_point, point);
602        assert!(matches!(
603            certified,
604            axiolid_contracts::Certified::Certain {
605                precision: axiolid_contracts::Precision::Exact,
606                ..
607            }
608        ));
609        let positive = plane.classify(point).expect("finite subnormal");
610        assert_ne!(positive.sign, Sign::Zero);
611    }
612}