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