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