Skip to main content

axiolid_construct/
boolean_exact.rs

1//! Exact boolean over axis-aligned prisms (#66).
2//!
3//! # Why this family, and why it is genuinely exact
4//!
5//! Exact boolean support previously reached only half-space-bounded
6//! difference and intersection. Widening it to arbitrary solids needs a
7//! general polyhedral boolean, which is a large algorithm in its own right.
8//!
9//! But there is a family where the general problem collapses to one the
10//! kernel already solves EXACTLY: two prisms sharing an extrusion axis. Their
11//! boolean is the 2D boolean of their cross-sections crossed with the boolean
12//! of their height intervals. `axiolid-overlay` computes the planar part
13//! exactly, so the result is exact -- not a tessellated approximation.
14//!
15//! That family is not a toy. A wall with a rectangular opening is exactly
16//! this shape, and it is the dominant pattern in building models.
17//!
18//! # When the answer is NOT a prism
19//!
20//! The reduction only holds when the result is itself a prism:
21//!
22//! - Intersection: always. The heights intersect to one interval.
23//! - Union: only when both operands span the same height. Otherwise the
24//!   result is stepped -- two different cross-sections at two heights -- and
25//!   a single prism cannot represent it.
26//! - Difference: only when the tool spans at least the subject's full height.
27//!   A tool ending mid-way leaves a stepped solid for the same reason.
28//!
29//! Those cases are refused rather than approximated by the nearest prism,
30//! which would silently change the geometry.
31
32use axiolid_brep::{ExactBRep, FaceName, Operand, SweptFace};
33use axiolid_contracts::{GeomError, GeomResult, Operation};
34use axiolid_core::{BooleanOperator, Frame2, Point2, Scalar, Tolerance, Vec2, Vec3};
35use axiolid_overlay::{
36    arc_overlay, overlay, validate_arc_ring, ArcRing, FillRule, OverlayInput, OverlayOperation,
37    Polygon, Ring,
38};
39
40use crate::boolean_provenance::{name_side_fragment, OperandRings};
41use axiolid_brep_audit::geometric_audit;
42
43use crate::extrude_arc::extrude_arc_ring;
44use crate::extrude_exact::extrude_polygon_rings_named;
45use crate::BACKEND_ID;
46
47pub fn unsupported(input: &'static str) -> GeomError {
48    GeomError::UnsupportedInput {
49        backend: BACKEND_ID,
50        operation: Operation::MeshBoolean,
51        input,
52    }
53}
54
55/// A prism: a closed planar cross-section swept along +z.
56///
57/// Deliberately explicit rather than recovered from an `ExactBRep`. Reading a
58/// prism back out of a general B-rep is its own inference problem, and
59/// getting it wrong would silently mis-identify the operand.
60#[derive(Debug, Clone, PartialEq)]
61pub struct Prism {
62    /// Outer boundary, counter-clockwise; further rings are holes.
63    pub rings: Vec<Vec<Point2>>,
64    /// Base height along z.
65    pub bottom: Scalar,
66    /// Top height along z.
67    pub top: Scalar,
68}
69
70/// A prism whose cross-section may contain arc edges (ADR 0050).
71///
72/// Separate from [`Prism`] rather than replacing it: the polygon path is
73/// exact through integer predicates, the arc path agrees with closed forms
74/// to machine precision. Those are different guarantees, so a caller that
75/// wants the stronger one must not be silently moved to the weaker one.
76#[derive(Debug, Clone, PartialEq)]
77pub struct ArcPrism {
78    /// Cross-section boundary; arcs carry a non-zero bulge.
79    pub section: ArcRing,
80    /// Base height along z.
81    pub bottom: Scalar,
82    /// Top height along z.
83    pub top: Scalar,
84}
85
86/// Exact boolean of two coaxial prisms.
87///
88/// Returns an exact B-rep, or a typed refusal naming why the result is not
89/// itself a prism. Never falls back to a mesh.
90pub fn boolean_prisms_exact(
91    subject: &Prism,
92    tool: &Prism,
93    operator: BooleanOperator,
94    tolerance: Tolerance,
95) -> GeomResult<ExactBRep> {
96    validate(subject, "subject")?;
97    validate(tool, "tool")?;
98
99    // Height logic decides whether a prism can represent the answer at all,
100    // so it is settled before any planar work.
101    let (bottom, top) = resolve_span(
102        (subject.bottom, subject.top),
103        (tool.bottom, tool.top),
104        operator,
105        tolerance,
106    )?;
107    let operation = match operator {
108        BooleanOperator::Intersection => OverlayOperation::Intersection,
109        BooleanOperator::Union => OverlayOperation::Union,
110        BooleanOperator::Difference => OverlayOperation::Difference,
111        _ => return Err(unsupported("unknown exact prism boolean operator")),
112    };
113
114    let frame = Frame2 {
115        origin: Vec2::ZERO,
116        x: Vec2::X,
117        y: Vec2::Y,
118    };
119    let result = overlay(
120        &OverlayInput {
121            frame,
122            polygons: to_polygons(subject),
123        },
124        &OverlayInput {
125            frame,
126            polygons: to_polygons(tool),
127        },
128        operation,
129        FillRule::NonZero,
130        tolerance,
131    )
132    .map_err(|error| GeomError::BackendContractViolation {
133        backend: BACKEND_ID,
134        detail: format!("exact prism cross-section overlay failed: {error:?}"),
135    })?;
136
137    if result.polygons.is_empty() {
138        return Err(GeomError::Degenerate(
139            "prism boolean produced an empty cross-section".to_owned(),
140        ));
141    }
142    // A disconnected result is several solids, and one ExactBRep is one
143    // solid. Returning just the first would silently discard material.
144    if result.polygons.len() > 1 {
145        return Err(unsupported(
146            "exact prism boolean producing disconnected components",
147        ));
148    }
149
150    let polygon = &result.polygons[0];
151    let mut rings = Vec::with_capacity(1 + polygon.holes.len());
152    rings.push(polygon.outer.points.clone());
153    for hole in &polygon.holes {
154        rings.push(hole.points.clone());
155    }
156
157    // `extrude_polygon_rings` builds from z = 0, so a band that does not start
158    // there is not representable by it. Refusing is correct rather than
159    // silently dropping the offset and returning a solid at the wrong height.
160    if bottom.abs() > tolerance.linear() {
161        return Err(unsupported(
162            "exact prism boolean whose result does not start at z = 0",
163        ));
164    }
165    // Every edge of an exact overlay lies on an edge of one input, so each
166    // wall of the result is a fragment of an input wall and can say which.
167    // Recovering it here -- from geometry, after the fact -- avoids
168    // threading provenance through the overlay backend.
169    let subject_rings = OperandRings {
170        operand: Operand::Subject,
171        rings: &subject.rings,
172    };
173    let tool_rings = OperandRings {
174        operand: Operand::Tool,
175        rings: &tool.rings,
176    };
177    let operands = [subject_rings, tool_rings];
178    let mut solid =
179        extrude_polygon_rings_named(&rings, Vec3::Z * (top - bottom), &mut |(start, end)| {
180            name_side_fragment(start, end, &operands)
181        })?;
182
183    // The caps lie in the planes the height logic selected above, so they
184    // are fragments of whichever operand supplied each bound.
185    name_caps(&mut solid, subject, tool, bottom, top, tolerance);
186    gate_geometry(solid, tolerance)
187}
188
189fn validate(prism: &Prism, role: &'static str) -> GeomResult<()> {
190    if prism.rings.is_empty() {
191        return Err(GeomError::InvalidInput(format!(
192            "{role} prism has no cross-section rings"
193        )));
194    }
195    for ring in &prism.rings {
196        if ring.len() < 3 {
197            return Err(GeomError::InvalidInput(format!(
198                "{role} prism ring needs at least three points"
199            )));
200        }
201        if !ring.iter().all(|p| p.x.is_finite() && p.y.is_finite()) {
202            return Err(GeomError::InvalidInput(format!(
203                "{role} prism ring has a non-finite point"
204            )));
205        }
206    }
207    if !prism.bottom.is_finite() || !prism.top.is_finite() {
208        return Err(GeomError::InvalidInput(format!(
209            "{role} prism heights must be finite"
210        )));
211    }
212    if prism.top <= prism.bottom {
213        return Err(GeomError::InvalidInput(format!(
214            "{role} prism top must lie above its bottom"
215        )));
216    }
217    Ok(())
218}
219
220fn to_polygons(prism: &Prism) -> Vec<Polygon> {
221    let mut rings = prism.rings.iter();
222    let outer = Ring {
223        points: rings.next().cloned().unwrap_or_default(),
224    };
225    let holes = rings.map(|r| Ring { points: r.clone() }).collect();
226    vec![Polygon { outer, holes }]
227}
228
229/// Name the result caps after the operand whose cap plane they lie in.
230///
231/// A coaxial boolean never tilts a cap, so each result cap is coplanar with
232/// a cap of at least one operand. When both operands share the plane the
233/// subject is named: the result is a fragment of both, and naming it after
234/// the subject keeps the choice deterministic rather than order-dependent.
235/// A cap matching neither operand is left unnamed rather than guessed.
236fn name_caps(
237    solid: &mut ExactBRep,
238    subject: &Prism,
239    tool: &Prism,
240    bottom: Scalar,
241    top: Scalar,
242    tolerance: Tolerance,
243) {
244    let start = cap_operand(subject.bottom, tool.bottom, bottom, tolerance);
245    let end = cap_operand(subject.top, tool.top, top, tolerance);
246    solid.name_caps(
247        start.map(|operand| FaceName::swept(SweptFace::StartCap).fragment(operand)),
248        end.map(|operand| FaceName::swept(SweptFace::EndCap).fragment(operand)),
249    );
250}
251
252/// Which operand a result cap height came from.
253fn cap_operand(
254    subject: Scalar,
255    tool: Scalar,
256    result: Scalar,
257    tolerance: Tolerance,
258) -> Option<Operand> {
259    if tolerance.eq(subject, result) {
260        Some(Operand::Subject)
261    } else if tolerance.eq(tool, result) {
262        Some(Operand::Tool)
263    } else {
264        None
265    }
266}
267
268/// Exact boolean of two coaxial prisms whose sections may contain arcs.
269///
270/// # What is exact here
271///
272/// The height reduction is identical to [`boolean_prisms_exact`]: a
273/// coaxial boolean is the planar boolean of the sections crossed with the
274/// boolean of the height intervals. Arc edges survive as arcs, so a
275/// cylindrical wall stays a `Cylinder` face rather than becoming a fan of
276/// planar strips.
277///
278/// The planar part agrees with closed-form areas to machine precision
279/// rather than being exact through integer predicates. That is a weaker
280/// claim than the polygon path makes and is stated here so a caller can
281/// choose deliberately.
282pub fn boolean_arc_prisms_exact(
283    subject: &ArcPrism,
284    tool: &ArcPrism,
285    operator: BooleanOperator,
286    tolerance: Tolerance,
287) -> GeomResult<ExactBRep> {
288    for (section, role) in [(&subject.section, "subject"), (&tool.section, "tool")] {
289        validate_arc_ring(section, tolerance).map_err(|error| {
290            GeomError::InvalidInput(format!("{role} arc prism section: {error:?}"))
291        })?;
292    }
293    if !subject.bottom.is_finite()
294        || !subject.top.is_finite()
295        || !tool.bottom.is_finite()
296        || !tool.top.is_finite()
297    {
298        return Err(GeomError::InvalidInput(
299            "arc prism heights must be finite".to_owned(),
300        ));
301    }
302    if subject.top <= subject.bottom || tool.top <= tool.bottom {
303        return Err(GeomError::InvalidInput(
304            "arc prism top must lie above its bottom".to_owned(),
305        ));
306    }
307
308    let (bottom, top) = resolve_span(
309        (subject.bottom, subject.top),
310        (tool.bottom, tool.top),
311        operator,
312        tolerance,
313    )?;
314
315    let operation = match operator {
316        BooleanOperator::Intersection => OverlayOperation::Intersection,
317        BooleanOperator::Union => OverlayOperation::Union,
318        BooleanOperator::Difference => OverlayOperation::Difference,
319        _ => return Err(unsupported("unknown exact prism boolean operator")),
320    };
321
322    let result =
323        arc_overlay(&subject.section, &tool.section, operation, tolerance).map_err(|error| {
324            GeomError::BackendContractViolation {
325                backend: BACKEND_ID,
326                detail: format!("arc prism cross-section overlay failed: {error:?}"),
327            }
328        })?;
329
330    if result.regions.is_empty() {
331        return Err(GeomError::Degenerate(
332            "arc prism boolean produced an empty cross-section".to_owned(),
333        ));
334    }
335    // One ExactBRep is one solid, so several regions cannot be returned
336    // without silently discarding material.
337    if result.regions.len() > 1 {
338        return Err(unsupported(
339            "exact arc prism boolean producing disconnected components",
340        ));
341    }
342
343    let region = &result.regions[0];
344    // A hole needs a cap face carrying two bounds with arc loops, which
345    // the arc extruder does not build. Refusing names the gap instead of
346    // returning a solid with its opening filled in.
347    if !region.holes.is_empty() {
348        return Err(unsupported(
349            "exact arc prism boolean whose result has an interior hole",
350        ));
351    }
352    // The arc extruder builds from z = 0, so a band starting elsewhere
353    // would come back at the wrong height.
354    if bottom.abs() > tolerance.linear() {
355        return Err(unsupported(
356            "exact arc prism boolean whose result does not start at z = 0",
357        ));
358    }
359    let solid = extrude_arc_ring(&region.outer, Vec3::Z * (top - bottom))?;
360    gate_geometry(solid, tolerance)
361}
362
363/// The height span a coaxial boolean result occupies.
364///
365/// Shared by the polygon and arc paths: the height reduction does not
366/// depend on what the cross-section looks like, so duplicating it would
367/// invite the two paths to disagree about which spans are representable.
368fn resolve_span(
369    subject: (Scalar, Scalar),
370    tool: (Scalar, Scalar),
371    operator: BooleanOperator,
372    tolerance: Tolerance,
373) -> GeomResult<(Scalar, Scalar)> {
374    match operator {
375        BooleanOperator::Intersection => {
376            let bottom = subject.0.max(tool.0);
377            let top = subject.1.min(tool.1);
378            if top - bottom <= tolerance.linear() {
379                return Err(GeomError::Degenerate(
380                    "prism intersection is empty along the extrusion axis".to_owned(),
381                ));
382            }
383            Ok((bottom, top))
384        }
385        BooleanOperator::Union => {
386            // Differing spans give a stepped solid, which is not a prism.
387            if !tolerance.eq(subject.0, tool.0) || !tolerance.eq(subject.1, tool.1) {
388                return Err(unsupported(
389                    "exact prism union with differing extrusion spans",
390                ));
391            }
392            Ok(subject)
393        }
394        BooleanOperator::Difference => {
395            // A tool that stops inside the subject leaves a step.
396            if tool.0 > subject.0 + tolerance.linear() || tool.1 < subject.1 - tolerance.linear() {
397                return Err(unsupported(
398                    "exact prism difference with a tool shorter than the subject",
399                ));
400            }
401            Ok(subject)
402        }
403        _ => Err(unsupported("unknown exact prism boolean operator")),
404    }
405}
406
407/// Reject a boolean result whose geometry does not hold together.
408///
409/// # Why booleans specifically
410///
411/// A boolean is where pcurves get rebuilt against surfaces they did not
412/// originally trim, so it is the operation most able to produce a solid that
413/// is topologically perfect and geometrically wrong. Both properties held on
414/// the cap loops of an earlier arc boolean, and nothing caught it.
415///
416/// The audit is cheap here because an exact analytic boolean returns a handful
417/// of faces, not a mesh: a few evaluations per edge use.
418fn gate_geometry(solid: ExactBRep, tolerance: Tolerance) -> GeomResult<ExactBRep> {
419    let health = geometric_audit(&solid, tolerance);
420    if health.is_consistent() {
421        return Ok(solid);
422    }
423    // Report the measured deviation, not just the fact of failure: a caller
424    // deciding whether their tolerance is wrong needs the number.
425    let detail = match health.worst_error() {
426        Some(error) => format!(
427            "boolean result failed its geometric audit: {} defect(s), worst deviation {error:e}",
428            health.defects().len()
429        ),
430        None => format!(
431            "boolean result failed its geometric audit: {} defect(s)",
432            health.defects().len()
433        ),
434    };
435    Err(GeomError::BackendContractViolation {
436        backend: BACKEND_ID,
437        detail,
438    })
439}