Skip to main content

axiolid_construct/
boolean_stepped.rs

1//! Stepped union of two coaxial prisms (ADR 0050 follow-up).
2//!
3//! # Why a union with differing spans is not one prism
4//!
5//! [`boolean_prisms_exact`](crate::boolean_exact::boolean_prisms_exact)
6//! refuses a union whose operands span different heights, because the
7//! result is stepped: the cross-section CHANGES partway up, and a single
8//! prism carries exactly one section.
9//!
10//! The refusal is honest but the shape is perfectly well defined. Cutting
11//! the union at every height where an operand starts or stops leaves bands,
12//! and WITHIN a band the active operand set is constant -- so each band is
13//! a genuine prism whose section is the planar union of whatever is active
14//! there. The stepped solid is that stack, and the decomposition is exact:
15//! the planar work is the same overlay the single-prism path already uses.
16//!
17//! # What this module does and does not give you
18//!
19//! It returns the BANDS. Assembling them into one `ExactBRep` additionally
20//! needs the ledge faces where the section changes, which is its own piece
21//! of work; returning the exact decomposition is the honest half that a
22//! caller can already use, and it is verifiable on its own terms.
23
24use axiolid_contracts::{GeomError, GeomResult};
25use axiolid_core::{Frame2, Scalar, Tolerance, Vec2};
26use axiolid_overlay::{overlay, FillRule, OverlayInput, OverlayOperation, Polygon, Ring};
27
28use crate::boolean_exact::{unsupported, Prism};
29use crate::BACKEND_ID;
30
31/// One constant-section slab of a stepped result.
32#[derive(Debug, Clone, PartialEq)]
33pub struct Band {
34    /// Cross-section rings: outer first, then holes.
35    pub rings: Vec<Vec<axiolid_core::Point2>>,
36    /// Base height of this slab.
37    pub bottom: Scalar,
38    /// Top height of this slab.
39    pub top: Scalar,
40}
41
42/// Decompose a coaxial union into constant-section bands, bottom to top.
43///
44/// Returns one [`Band`] per height interval over which the set of active
45/// operands does not change. A union whose operands span the same height
46/// yields exactly one band, which is the case
47/// [`boolean_prisms_exact`](crate::boolean_exact::boolean_prisms_exact)
48/// already handles.
49///
50/// Operands that do not touch are refused: their union is two separate
51/// solids, and a band stack describes one.
52pub fn union_prisms_stepped(
53    subject: &Prism,
54    tool: &Prism,
55    tolerance: Tolerance,
56) -> GeomResult<Vec<Band>> {
57    if tool.bottom > subject.top + tolerance.linear()
58        || subject.bottom > tool.top + tolerance.linear()
59    {
60        return Err(unsupported(
61            "stepped union of prisms that do not meet along the axis",
62        ));
63    }
64
65    // Every height where an operand starts or stops is a potential
66    // section change. Heights closer than tolerance are the SAME cut:
67    // keeping both would emit a zero-thickness band that no solid can
68    // represent.
69    let mut cuts = vec![subject.bottom, subject.top, tool.bottom, tool.top];
70    cuts.sort_by(|a, b| a.total_cmp(b));
71    cuts.dedup_by(|a, b| tolerance.eq(*a, *b));
72
73    let mut bands = Vec::with_capacity(cuts.len().saturating_sub(1));
74    for pair in cuts.windows(2) {
75        let (bottom, top) = (pair[0], pair[1]);
76        // The midpoint decides membership: it is interior to the band, so
77        // it cannot land on a boundary and give an ambiguous answer.
78        let middle = 0.5 * (bottom + top);
79        let in_subject = middle > subject.bottom && middle < subject.top;
80        let in_tool = middle > tool.bottom && middle < tool.top;
81        let rings = match (in_subject, in_tool) {
82            (false, false) => continue,
83            (true, false) => subject.rings.clone(),
84            (false, true) => tool.rings.clone(),
85            // Both active: the band's section is their planar union, which
86            // is exactly the overlay the single-prism path performs.
87            (true, true) => section_union(subject, tool, tolerance)?,
88        };
89        bands.push(Band { rings, bottom, top });
90    }
91    if bands.is_empty() {
92        return Err(GeomError::Degenerate(
93            "stepped union has no band of positive height".to_owned(),
94        ));
95    }
96    Ok(bands)
97}
98
99/// Planar union of the two cross-sections.
100fn section_union(
101    subject: &Prism,
102    tool: &Prism,
103    tolerance: Tolerance,
104) -> GeomResult<Vec<Vec<axiolid_core::Point2>>> {
105    let frame = Frame2 {
106        origin: Vec2::ZERO,
107        x: Vec2::X,
108        y: Vec2::Y,
109    };
110    let result = overlay(
111        &OverlayInput {
112            frame,
113            polygons: to_polygons(subject),
114        },
115        &OverlayInput {
116            frame,
117            polygons: to_polygons(tool),
118        },
119        OverlayOperation::Union,
120        FillRule::NonZero,
121        tolerance,
122    )
123    .map_err(|error| GeomError::BackendContractViolation {
124        backend: BACKEND_ID,
125        detail: format!("stepped union cross-section overlay failed: {error:?}"),
126    })?;
127    if result.polygons.len() != 1 {
128        return Err(unsupported(
129            "stepped union band with a disconnected cross-section",
130        ));
131    }
132    let polygon = &result.polygons[0];
133    let mut rings = Vec::with_capacity(1 + polygon.holes.len());
134    rings.push(polygon.outer.points.clone());
135    for hole in &polygon.holes {
136        rings.push(hole.points.clone());
137    }
138    Ok(rings)
139}
140
141fn to_polygons(prism: &Prism) -> Vec<Polygon> {
142    let mut rings = prism.rings.iter();
143    let outer = Ring {
144        points: rings.next().cloned().unwrap_or_default(),
145    };
146    let holes = rings.map(|r| Ring { points: r.clone() }).collect();
147    vec![Polygon { outer, holes }]
148}