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