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        // The legacy bound plus the directed-edge map of the in-plane
69        // triangles (key, value and tree overhead), the overlap sweep order,
70        // component and boundary state, and region rings: about 1 KiB per
71        // element, where an element is a position or a triangle.
72        ScratchRequirement::PerElement {
73            bytes_per_element: 1024,
74        }
75    }
76
77    fn cancellation_granularity(&self) -> CancellationGranularity {
78        CancellationGranularity::Incremental
79    }
80
81    fn section_request(
82        &self,
83        mesh: &TriMesh,
84        request: &SectionRequest,
85        options: &ExecutionOptions,
86    ) -> GeomResult<PlaneSection> {
87        request::plane_section(mesh, request, options)
88    }
89
90    fn section(
91        &self,
92        mesh: &TriMesh,
93        frame: Frame3,
94        limits: SectionLimits,
95        options: &ExecutionOptions,
96    ) -> GeomResult<SectionOutcome> {
97        options.check_cancelled()?;
98        check_source_limits(mesh, limits)?;
99
100        let plane = ExactSectionPlane::new(frame)?;
101        let classifications = classify_vertices(mesh, plane)?;
102
103        let mut segments = BTreeSet::<Segment>::new();
104        let mut on_plane_edges = BTreeMap::<EdgeKey, Vec<Sign>>::new();
105        for (triangle_index, triangle) in mesh.triangles().enumerate() {
106            options.check_cancelled()?;
107            let vertices = [
108                mesh_index(triangle[0])?,
109                mesh_index(triangle[1])?,
110                mesh_index(triangle[2])?,
111            ];
112            let signs = vertices.map(|index| classifications[index].sign);
113            let zero_count = signs.iter().filter(|&&sign| sign == Sign::Zero).count();
114            match zero_count {
115                3 => {
116                    return Err(GeomError::Degenerate(format!(
117                        "section plane contains source triangle {triangle_index}; a two-dimensional overlap is not a curve"
118                    )))
119                }
120                2 => {
121                    let mut zeros = triangle
122                        .into_iter()
123                        .zip(signs)
124                        .filter_map(|(index, sign)| (sign == Sign::Zero).then_some(index));
125                    let left = zeros.next().ok_or_else(internal_topology_error)?;
126                    let right = zeros.next().ok_or_else(internal_topology_error)?;
127                    let third = signs
128                        .into_iter()
129                        .find(|&sign| sign != Sign::Zero)
130                        .ok_or_else(internal_topology_error)?;
131                    on_plane_edges
132                        .entry(EdgeKey::new(left, right))
133                        .or_default()
134                        .push(third);
135                }
136                1 => {
137                    let zero_corner = (0..3)
138                        .find(|&corner| signs[corner] == Sign::Zero)
139                        .ok_or_else(internal_topology_error)?;
140                    let (first, second) = match zero_corner {
141                        0 => (1, 2),
142                        1 => (0, 2),
143                        2 => (0, 1),
144                        _ => return Err(internal_topology_error()),
145                    };
146                    if opposite(signs[first], signs[second]) {
147                        let segment = Segment::new(
148                            NodeKey::Vertex(triangle[zero_corner]),
149                            NodeKey::Edge(EdgeKey::new(triangle[first], triangle[second])),
150                        )?;
151                        insert_segment(&mut segments, segment, limits)?;
152                    }
153                }
154                0 => {
155                    let mut crossing = [None, None];
156                    let mut crossing_count = 0usize;
157                    for (left, right) in [(0, 1), (1, 2), (2, 0)] {
158                        if opposite(signs[left], signs[right]) {
159                            if crossing_count >= crossing.len() {
160                                return Err(internal_topology_error());
161                            }
162                            crossing[crossing_count] = Some(NodeKey::Edge(EdgeKey::new(
163                                triangle[left],
164                                triangle[right],
165                            )));
166                            crossing_count += 1;
167                        }
168                    }
169                    match (crossing[0], crossing[1]) {
170                        (Some(first), Some(second)) => insert_segment(
171                            &mut segments,
172                            Segment::new(first, second)?,
173                            limits,
174                        )?,
175                        (None, None) => {}
176                        _ => return Err(internal_topology_error()),
177                    }
178                }
179                _ => return Err(internal_topology_error()),
180            }
181        }
182
183        for (edge, incident_signs) in on_plane_edges {
184            options.check_cancelled()?;
185            if incident_signs.len() != 2 {
186                return Err(GeomError::NotManifold(format!(
187                    "on-plane mesh edge {:?} has {} incident triangles",
188                    edge,
189                    incident_signs.len()
190                )));
191            }
192            if opposite(incident_signs[0], incident_signs[1]) {
193                insert_segment(
194                    &mut segments,
195                    Segment::new(NodeKey::Vertex(edge.0), NodeKey::Vertex(edge.1))?,
196                    limits,
197                )?;
198            }
199        }
200
201        let contours =
202            assemble_contours(mesh, frame, &classifications, &segments, limits, options)?;
203        let output_vertices = contours.iter().map(|contour| contour.points.len()).sum();
204        let evidence =
205            SectionEvidence::input_mesh(mesh.triangle_count(), output_vertices, contours.len());
206        Ok(SectionOutcome::new(frame, contours, evidence))
207    }
208}
209
210#[derive(Debug, Clone, Copy)]
211struct Classification {
212    sign: Sign,
213    distance: f64,
214}
215
216#[derive(Debug, Clone, Copy)]
217struct ExactSectionPlane {
218    origin: Point3,
219    x_point: Point3,
220    y_point: Point3,
221    normal: axiolid_core::Vec3,
222}
223
224impl ExactSectionPlane {
225    fn new(frame: Frame3) -> GeomResult<Self> {
226        let x_point = frame.origin + frame.x;
227        let y_point = frame.origin + frame.y;
228        if !x_point.is_finite()
229            || !y_point.is_finite()
230            || x_point == frame.origin
231            || y_point == frame.origin
232            || x_point == y_point
233        {
234            return Err(GeomError::Degenerate(
235                "section frame cannot resolve a finite affine plane at this coordinate magnitude"
236                    .into(),
237            ));
238        }
239        let normal = (x_point - frame.origin).cross(y_point - frame.origin);
240        let normal_length = normal.length();
241        if !normal_length.is_finite() || normal_length == 0.0 {
242            return Err(GeomError::Degenerate(
243                "section affine plane has no finite normal".into(),
244            ));
245        }
246        Ok(Self {
247            origin: frame.origin,
248            x_point,
249            y_point,
250            normal: normal / normal_length,
251        })
252    }
253
254    fn classify(self, point: Point3) -> GeomResult<Classification> {
255        let sign = match orient3d(self.origin, self.x_point, self.y_point, point) {
256            axiolid_contracts::Certified::Certain { sign, .. } => sign,
257            _ => {
258                return Err(GeomError::Degenerate(
259                    "certified plane-side predicate returned an uncertain sign".into(),
260                ))
261            }
262        };
263        let distance = self.normal.dot(point - self.origin);
264        if !distance.is_finite() {
265            return Err(GeomError::Degenerate(
266                "section signed distance is not finite".into(),
267            ));
268        }
269        if sign != Sign::Zero && distance == 0.0 {
270            return Err(GeomError::Degenerate(
271                "section interpolation lost a certified nonzero plane offset".into(),
272            ));
273        }
274        Ok(Classification { sign, distance })
275    }
276}
277
278/// Exact plane side and signed distance of every source position.
279fn classify_vertices(mesh: &TriMesh, plane: ExactSectionPlane) -> GeomResult<Vec<Classification>> {
280    let mut classifications = Vec::new();
281    classifications
282        .try_reserve_exact(mesh.positions.len())
283        .map_err(|_| GeomError::BudgetExceeded { resource: "memory" })?;
284    for &point in &mesh.positions {
285        let classification = plane.classify(point)?;
286        classifications.push(classification);
287    }
288    Ok(classifications)
289}
290
291fn opposite(left: Sign, right: Sign) -> bool {
292    matches!(
293        (left, right),
294        (Sign::Negative, Sign::Positive) | (Sign::Positive, Sign::Negative)
295    )
296}
297
298#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
299struct EdgeKey(u32, u32);
300
301impl EdgeKey {
302    fn new(left: u32, right: u32) -> Self {
303        Self(left.min(right), left.max(right))
304    }
305}
306
307#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
308enum NodeKey {
309    Vertex(u32),
310    Edge(EdgeKey),
311}
312
313#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
314struct Segment(NodeKey, NodeKey);
315
316impl Segment {
317    fn new(left: NodeKey, right: NodeKey) -> GeomResult<Self> {
318        if left == right {
319            return Err(GeomError::Degenerate(
320                "plane section collapsed a segment to one source-topology node".into(),
321            ));
322        }
323        Ok(Self(left.min(right), left.max(right)))
324    }
325}
326
327fn insert_segment(
328    segments: &mut BTreeSet<Segment>,
329    segment: Segment,
330    limits: SectionLimits,
331) -> GeomResult<()> {
332    if segments.contains(&segment) {
333        return Err(GeomError::NotManifold(
334            "two source faces produced the same non-coplanar section segment".into(),
335        ));
336    }
337    if segments.len() >= limits.max_output_vertices {
338        return Err(GeomError::BudgetExceeded {
339            resource: "section output vertices",
340        });
341    }
342    segments.insert(segment);
343    Ok(())
344}
345
346fn mesh_index(index: u32) -> GeomResult<usize> {
347    usize::try_from(index)
348        .map_err(|_| GeomError::InvalidInput("mesh index does not fit usize".into()))
349}
350
351fn node_point(
352    mesh: &TriMesh,
353    classifications: &[Classification],
354    node: NodeKey,
355) -> GeomResult<Point3> {
356    match node {
357        NodeKey::Vertex(index) => Ok(mesh.positions[mesh_index(index)?]),
358        NodeKey::Edge(EdgeKey(left, right)) => {
359            let a = mesh.positions[mesh_index(left)?];
360            let b = mesh.positions[mesh_index(right)?];
361            let da = classifications[mesh_index(left)?].distance.abs();
362            let db = classifications[mesh_index(right)?].distance.abs();
363            if !(da > 0.0 && db > 0.0 && da.is_finite() && db.is_finite()) {
364                return Err(GeomError::Degenerate(
365                    "crossing edge has no representable endpoint distance".into(),
366                ));
367            }
368            let t = if da >= db {
369                1.0 / (1.0 + db / da)
370            } else {
371                let ratio = da / db;
372                ratio / (1.0 + ratio)
373            };
374            let point = a + (b - a) * t;
375            if !point.is_finite() {
376                return Err(GeomError::Degenerate(
377                    "edge-plane intersection exceeded finite arithmetic".into(),
378                ));
379            }
380            Ok(point)
381        }
382    }
383}
384
385fn simplify_collinear(points: &mut Vec<Point2>, tolerance: f64) {
386    loop {
387        if points.len() <= 3 {
388            return;
389        }
390        let mut removed = false;
391        for index in 0..points.len() {
392            let previous = points[(index + points.len() - 1) % points.len()];
393            let current = points[index];
394            let next = points[(index + 1) % points.len()];
395            let chord = next - previous;
396            let scale = chord.length();
397            let cross = (current - previous).perp_dot(chord).abs();
398            let within_tolerance = scale > 0.0 && cross <= tolerance * scale;
399            if orient2d(previous, current, next).sign() == Some(Sign::Zero) || within_tolerance {
400                points.remove(index);
401                removed = true;
402                break;
403            }
404        }
405        if !removed {
406            return;
407        }
408    }
409}
410
411fn signed_area(points: &[Point2]) -> f64 {
412    let mut twice = 0.0;
413    for index in 0..points.len() {
414        let current = points[index];
415        let next = points[(index + 1) % points.len()];
416        twice += current.x * next.y - current.y * next.x;
417    }
418    twice * 0.5
419}
420
421fn point_order(left: &Point2, right: &Point2) -> std::cmp::Ordering {
422    left.x
423        .total_cmp(&right.x)
424        .then_with(|| left.y.total_cmp(&right.y))
425}
426
427/// Lexicographic order of point sequences; a prefix sorts first.
428fn sequence_order(left: &[Point2], right: &[Point2]) -> std::cmp::Ordering {
429    left.iter()
430        .zip(right)
431        .map(|(a, b)| point_order(a, b))
432        .find(|order| order.is_ne())
433        .unwrap_or_else(|| left.len().cmp(&right.len()))
434}
435
436fn check_source_limits(mesh: &TriMesh, limits: SectionLimits) -> GeomResult<()> {
437    if mesh.positions.len() > limits.max_source_vertices {
438        return Err(GeomError::BudgetExceeded {
439            resource: "section source vertices",
440        });
441    }
442    if mesh.triangle_count() > limits.max_source_triangles {
443        return Err(GeomError::BudgetExceeded {
444            resource: "section source triangles",
445        });
446    }
447    Ok(())
448}
449
450fn internal_topology_error() -> GeomError {
451    GeomError::BackendContractViolation {
452        backend: ScalarSection::ID,
453        detail: "internal section topology state is inconsistent".into(),
454    }
455}
456
457/// Plane-local coordinates of a model-space point. Chain points and region
458/// vertices both go through this one function, so a chain end that lies on a
459/// region boundary is bit-identical to the region vertex.
460fn project_local(frame: Frame3, world: Point3) -> GeomResult<Point2> {
461    let local = world - frame.origin;
462    let point = Point2::new(local.dot(frame.x), local.dot(frame.y));
463    if point.is_finite() {
464        Ok(point)
465    } else {
466        Err(GeomError::Degenerate(
467            "section projection exceeded finite arithmetic".into(),
468        ))
469    }
470}
471
472/// The exact sign of `orient2d`, refusing an uncertain predicate.
473fn orient_sign(a: Point2, b: Point2, c: Point2) -> GeomResult<Sign> {
474    orient2d(a, b, c).sign().ok_or_else(|| {
475        GeomError::Degenerate("certified orientation predicate returned an uncertain sign".into())
476    })
477}
478
479/// Product of two signs.
480fn sign_times(left: Sign, right: Sign) -> Sign {
481    if left == Sign::Zero || right == Sign::Zero {
482        Sign::Zero
483    } else if left == right {
484        Sign::Positive
485    } else {
486        Sign::Negative
487    }
488}
489
490#[cfg(test)]
491mod tests {
492    use super::*;
493
494    #[test]
495    fn exact_plane_sign_keeps_a_tiny_nonzero_binary64_offset() {
496        let frame = Frame3 {
497            origin: Point3::ZERO,
498            x: Point3::X,
499            y: Point3::Y,
500            z: Point3::Z,
501        };
502        let plane = ExactSectionPlane::new(frame).expect("resolvable plane");
503        let point = Point3::new(1.0, -1.0, f64::from_bits(1));
504        let certified = orient3d(plane.origin, plane.x_point, plane.y_point, point);
505        assert!(matches!(
506            certified,
507            axiolid_contracts::Certified::Certain {
508                precision: axiolid_contracts::Precision::Exact,
509                ..
510            }
511        ));
512        let positive = plane.classify(point).expect("finite subnormal");
513        assert_ne!(positive.sign, Sign::Zero);
514    }
515}