axiolid_construct/
boolean_stepped.rs

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