axiolid-construct 0.3.6

Solid generation: profiles, lofts, sweeps, revolutions and half-space clipping
Documentation
//! Stepped union of two coaxial prisms, as constant-section bands.
//!
//! [`boolean_prisms_exact`](crate::boolean_exact::boolean_prisms_exact)
//! builds a stepped union as ONE exact solid, ledges included (#120, ADR
//! 0072). This module is the older, lighter answer to the same question:
//! cut the union at every height where an operand starts or stops, and
//! return the bands. Within a band the active operand set is constant, so
//! each band is a genuine prism whose section is the planar union of
//! whatever is active there.
//!
//! Use it when bands are what you want (per-storey quantities, say); use
//! the boolean when you want the solid. The two agree on volume, which the
//! tests check.

use axiolid_contracts::{GeomError, GeomResult};
use axiolid_core::{Frame2, Scalar, Tolerance, Vec2};
use axiolid_overlay::{overlay, FillRule, OverlayInput, OverlayOperation, Polygon, Ring};

use crate::boolean_exact::{unsupported, Prism};
use crate::BACKEND_ID;

/// One constant-section slab of a stepped result.
#[derive(Debug, Clone, PartialEq)]
pub struct Band {
    /// Cross-section rings: outer first, then holes.
    pub rings: Vec<Vec<axiolid_core::Point2>>,
    /// Base height of this slab.
    pub bottom: Scalar,
    /// Top height of this slab.
    pub top: Scalar,
}

/// Decompose a coaxial union into constant-section bands, bottom to top.
///
/// Returns one [`Band`] per height interval over which the set of active
/// operands does not change. A union whose operands span the same height
/// yields exactly one band, which is the case
/// [`boolean_prisms_exact`](crate::boolean_exact::boolean_prisms_exact)
/// already handles.
///
/// Operands that do not touch are refused: their union is two separate
/// solids, and a band stack describes one.
pub fn union_prisms_stepped(
    subject: &Prism,
    tool: &Prism,
    tolerance: Tolerance,
) -> GeomResult<Vec<Band>> {
    if tool.bottom > subject.top + tolerance.linear()
        || subject.bottom > tool.top + tolerance.linear()
    {
        return Err(unsupported(
            "stepped union of prisms that do not meet along the axis",
        ));
    }

    // Every height where an operand starts or stops is a potential
    // section change. Heights closer than tolerance are the SAME cut:
    // keeping both would emit a zero-thickness band that no solid can
    // represent.
    let mut cuts = vec![subject.bottom, subject.top, tool.bottom, tool.top];
    cuts.sort_by(|a, b| a.total_cmp(b));
    cuts.dedup_by(|a, b| tolerance.eq(*a, *b));

    let mut bands = Vec::with_capacity(cuts.len().saturating_sub(1));
    for pair in cuts.windows(2) {
        let (bottom, top) = (pair[0], pair[1]);
        // The midpoint decides membership: it is interior to the band, so
        // it cannot land on a boundary and give an ambiguous answer.
        let middle = 0.5 * (bottom + top);
        let in_subject = middle > subject.bottom && middle < subject.top;
        let in_tool = middle > tool.bottom && middle < tool.top;
        let rings = match (in_subject, in_tool) {
            (false, false) => continue,
            (true, false) => subject.rings.clone(),
            (false, true) => tool.rings.clone(),
            // Both active: the band's section is their planar union, which
            // is exactly the overlay the single-prism path performs.
            (true, true) => section_union(subject, tool, tolerance)?,
        };
        bands.push(Band { rings, bottom, top });
    }
    if bands.is_empty() {
        return Err(GeomError::Degenerate(
            "stepped union has no band of positive height".to_owned(),
        ));
    }
    Ok(bands)
}

/// Planar union of the two cross-sections.
fn section_union(
    subject: &Prism,
    tool: &Prism,
    tolerance: Tolerance,
) -> GeomResult<Vec<Vec<axiolid_core::Point2>>> {
    let frame = Frame2 {
        origin: Vec2::ZERO,
        x: Vec2::X,
        y: Vec2::Y,
    };
    let result = overlay(
        &OverlayInput {
            frame,
            polygons: to_polygons(subject),
        },
        &OverlayInput {
            frame,
            polygons: to_polygons(tool),
        },
        OverlayOperation::Union,
        FillRule::NonZero,
        tolerance,
    )
    .map_err(|error| GeomError::BackendContractViolation {
        backend: BACKEND_ID,
        detail: format!("stepped union cross-section overlay failed: {error:?}"),
    })?;
    if result.polygons.len() != 1 {
        return Err(unsupported(
            "stepped union band with a disconnected cross-section",
        ));
    }
    let polygon = &result.polygons[0];
    let mut rings = Vec::with_capacity(1 + polygon.holes.len());
    rings.push(polygon.outer.points.clone());
    for hole in &polygon.holes {
        rings.push(hole.points.clone());
    }
    Ok(rings)
}

fn to_polygons(prism: &Prism) -> Vec<Polygon> {
    let mut rings = prism.rings.iter();
    let outer = Ring {
        points: rings.next().cloned().unwrap_or_default(),
    };
    let holes = rings.map(|r| Ring { points: r.clone() }).collect();
    vec![Polygon { outer, holes }]
}