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 to one prism 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.
25//! - Difference: only when the tool spans at least the subject's full height.
26//!   A tool ending mid-way leaves a stepped solid for the same reason.
27//!
28//! Stepped results are built as column solids instead (`column`, #120):
29//! the plan is cut into cells by an exact arrangement of every operand
30//! ring, and each cell carries the heights its operands give it. The
31//! result is still exact, with ledge faces where the section changes; it
32//! is never approximated by the nearest prism. A result enclosing a cavity
33//! carries it as a void shell; one with several pieces as well as a cavity
34//! is refused by name.
35
36use axiolid_brep::{ExactBRep, FaceName, Operand, SweptFace};
37use axiolid_contracts::{GeomError, GeomResult, Operation};
38use axiolid_core::{BooleanOperator, Frame2, Point2, Scalar, Tolerance, Vec2, Vec3};
39use axiolid_overlay::{
40    arc_overlay, overlay, validate_arc_ring, ArcPolygon, ArcRing, ArcVertex, FillRule,
41    OverlayInput, OverlayOperation, Polygon, Ring,
42};
43use axiolid_primitive::HalfSpace;
44
45use crate::boolean_column::{clip_columns, coaxial_columns, is_stepped, ColumnOperand};
46use crate::boolean_provenance::{name_side_fragment, OperandRings};
47use axiolid_brep_audit::geometric_audit;
48
49use crate::contour_lower::orient_arc_ring;
50use crate::extrude_arc::{arc_geometry, extrude_arc_rings_between, extrude_arc_rings_from, Level};
51use crate::extrude_exact::extrude_polygon_rings_named;
52use crate::BACKEND_ID;
53
54pub fn unsupported(input: &'static str) -> GeomError {
55    GeomError::UnsupportedInput {
56        backend: BACKEND_ID,
57        operation: Operation::MeshBoolean,
58        input,
59    }
60}
61
62/// A prism: a closed planar cross-section swept along +z.
63///
64/// Deliberately explicit rather than recovered from an `ExactBRep`. Reading a
65/// prism back out of a general B-rep is its own inference problem, and
66/// getting it wrong would silently mis-identify the operand.
67#[derive(Debug, Clone, PartialEq)]
68pub struct Prism {
69    /// Outer boundary, counter-clockwise; further rings are holes.
70    pub rings: Vec<Vec<Point2>>,
71    /// Base height along z.
72    pub bottom: Scalar,
73    /// Top height along z.
74    pub top: Scalar,
75}
76
77/// A prism whose cross-section may contain arc edges (ADR 0050).
78///
79/// Separate from [`Prism`] rather than replacing it: the polygon path is
80/// exact through integer predicates, the arc path agrees with closed forms
81/// to machine precision. Those are different guarantees, so a caller that
82/// wants the stronger one must not be silently moved to the weaker one.
83#[derive(Debug, Clone, PartialEq)]
84pub struct ArcPrism {
85    /// Cross-section boundary; arcs carry a non-zero bulge.
86    pub section: ArcRing,
87    /// Base height along z.
88    pub bottom: Scalar,
89    /// Top height along z.
90    pub top: Scalar,
91}
92
93/// Exact boolean of two coaxial prisms.
94///
95/// Returns an exact B-rep, or a typed refusal. Never falls back to a mesh.
96/// Operands with differing spans give a stepped solid, built exactly with
97/// ledge faces where the section changes (#120).
98///
99/// A result that falls apart into several separate solids is refused here,
100/// because one `ExactBRep` is one solid and returning just one piece would
101/// silently discard material. [`boolean_prisms_exact_solids`] returns every
102/// piece.
103pub fn boolean_prisms_exact(
104    subject: &Prism,
105    tool: &Prism,
106    operator: BooleanOperator,
107    tolerance: Tolerance,
108) -> GeomResult<ExactBRep> {
109    if let Some(solids) = prism_columns(subject, tool, operator, tolerance)? {
110        return one_solid(
111            solids,
112            "prism boolean produced an empty result",
113            "exact prism boolean producing disconnected components \
114             (boolean_prisms_exact_solids returns every piece)",
115        );
116    }
117    let (bottom, top) = prism_span(subject, tool, operator, tolerance)?;
118    let polygons = prism_sections(subject, tool, operator, tolerance)?;
119    single_solid(
120        polygons,
121        "prism boolean produced an empty cross-section",
122        "exact prism boolean producing disconnected components \
123         (boolean_prisms_exact_solids returns every piece)",
124        |polygon| prism_solid(polygon, subject, tool, (bottom, top), tolerance),
125    )
126}
127
128/// Exact boolean of two coaxial prisms, one exact B-rep per separate piece.
129///
130/// Same reduction and refusals as [`boolean_prisms_exact`], except that a
131/// result which falls apart into several solids (a difference cutting a
132/// wall in two, an intersection of two separate islands) returns every
133/// piece instead of refusing, and an empty result is an empty list rather
134/// than an error.
135///
136/// Solids are ordered by the lowest vertex of their outer boundary, `x`
137/// first and then `y`, so the order is stable across runs and does not
138/// depend on the overlay's internal traversal.
139pub fn boolean_prisms_exact_solids(
140    subject: &Prism,
141    tool: &Prism,
142    operator: BooleanOperator,
143    tolerance: Tolerance,
144) -> GeomResult<Vec<ExactBRep>> {
145    if let Some(solids) = prism_columns(subject, tool, operator, tolerance)? {
146        return Ok(solids);
147    }
148    let Some((bottom, top)) = empty_span_is_none(prism_span(subject, tool, operator, tolerance))?
149    else {
150        return Ok(Vec::new());
151    };
152    // `overlay` already orders polygons by their lowest outer vertex (its
153    // documented contract), which is the order the arc path sorts into.
154    let polygons = prism_sections(subject, tool, operator, tolerance)?;
155    polygons
156        .iter()
157        .map(|polygon| prism_solid(polygon, subject, tool, (bottom, top), tolerance))
158        .collect()
159}
160
161/// Validate both prisms and settle the height span of the result.
162///
163/// Height logic decides whether a prism can represent the answer at all,
164/// so it is settled before any planar work.
165fn prism_span(
166    subject: &Prism,
167    tool: &Prism,
168    operator: BooleanOperator,
169    tolerance: Tolerance,
170) -> GeomResult<(Scalar, Scalar)> {
171    validate(subject, "subject")?;
172    validate(tool, "tool")?;
173    resolve_span(
174        (subject.bottom, subject.top),
175        (tool.bottom, tool.top),
176        operator,
177        tolerance,
178    )
179}
180
181/// The planar boolean of the two cross-sections, one polygon per piece.
182fn prism_sections(
183    subject: &Prism,
184    tool: &Prism,
185    operator: BooleanOperator,
186    tolerance: Tolerance,
187) -> GeomResult<Vec<Polygon>> {
188    let operation = overlay_operation(operator)?;
189    let frame = Frame2 {
190        origin: Vec2::ZERO,
191        x: Vec2::X,
192        y: Vec2::Y,
193    };
194    let result = overlay(
195        &OverlayInput {
196            frame,
197            polygons: to_polygons(subject),
198        },
199        &OverlayInput {
200            frame,
201            polygons: to_polygons(tool),
202        },
203        operation,
204        FillRule::NonZero,
205        tolerance,
206    )
207    .map_err(|error| GeomError::BackendContractViolation {
208        backend: BACKEND_ID,
209        detail: format!("exact prism cross-section overlay failed: {error:?}"),
210    })?;
211    Ok(result.polygons)
212}
213
214/// Extrude one result piece and name its faces after the operands.
215fn prism_solid(
216    polygon: &Polygon,
217    subject: &Prism,
218    tool: &Prism,
219    (bottom, top): (Scalar, Scalar),
220    tolerance: Tolerance,
221) -> GeomResult<ExactBRep> {
222    let mut rings = Vec::with_capacity(1 + polygon.holes.len());
223    rings.push(polygon.outer.points.clone());
224    for hole in &polygon.holes {
225        rings.push(hole.points.clone());
226    }
227
228    // `extrude_polygon_rings` builds from z = 0, so a band that does not start
229    // there is not representable by it. Refusing is correct rather than
230    // silently dropping the offset and returning a solid at the wrong height.
231    if bottom.abs() > tolerance.linear() {
232        return Err(unsupported(
233            "exact prism boolean whose result does not start at z = 0",
234        ));
235    }
236    // Every edge of an exact overlay lies on an edge of one input, so each
237    // wall of the result is a fragment of an input wall and can say which.
238    // Recovering it here -- from geometry, after the fact -- avoids
239    // threading provenance through the overlay backend.
240    let subject_rings = OperandRings {
241        operand: Operand::Subject,
242        rings: &subject.rings,
243    };
244    let tool_rings = OperandRings {
245        operand: Operand::Tool,
246        rings: &tool.rings,
247    };
248    let operands = [subject_rings, tool_rings];
249    let mut solid =
250        extrude_polygon_rings_named(&rings, Vec3::Z * (top - bottom), &mut |(start, end)| {
251            name_side_fragment(start, end, &operands)
252        })?;
253
254    // The caps lie in the planes the height logic selected above, so they
255    // are fragments of whichever operand supplied each bound.
256    name_caps(&mut solid, subject, tool, bottom, top, tolerance);
257    gate_geometry(solid, tolerance)
258}
259
260/// The one solid a single-result boolean may return.
261///
262/// Empty and multi-piece results are refused with the messages callers
263/// already match on: one `ExactBRep` is one solid, and returning just one
264/// piece would silently discard material.
265fn single_solid<T>(
266    pieces: Vec<T>,
267    empty: &'static str,
268    disconnected: &'static str,
269    build: impl FnOnce(&T) -> GeomResult<ExactBRep>,
270) -> GeomResult<ExactBRep> {
271    match pieces.as_slice() {
272        [] => Err(GeomError::Degenerate(empty.to_owned())),
273        [only] => build(only),
274        _ => Err(unsupported(disconnected)),
275    }
276}
277
278/// Map an empty height span to `None`; every other refusal stays an error.
279///
280/// For the multi-solid entry points an empty result is a valid answer (an
281/// empty list), not a failure.
282fn empty_span_is_none(span: GeomResult<(Scalar, Scalar)>) -> GeomResult<Option<(Scalar, Scalar)>> {
283    match span {
284        Ok(span) => Ok(Some(span)),
285        Err(GeomError::Degenerate(_)) => Ok(None),
286        Err(error) => Err(error),
287    }
288}
289
290/// Order boundaries by their lowest vertex, `x` then `y`.
291fn lowest_first(a: &[Point2], b: &[Point2]) -> std::cmp::Ordering {
292    let key = |ring: &[Point2]| {
293        ring.iter()
294            .copied()
295            .min_by(|p, q| p.x.total_cmp(&q.x).then(p.y.total_cmp(&q.y)))
296    };
297    match (key(a), key(b)) {
298        (Some(p), Some(q)) => p.x.total_cmp(&q.x).then(p.y.total_cmp(&q.y)),
299        (a, b) => a.is_some().cmp(&b.is_some()),
300    }
301}
302
303fn overlay_operation(operator: BooleanOperator) -> GeomResult<OverlayOperation> {
304    match operator {
305        BooleanOperator::Intersection => Ok(OverlayOperation::Intersection),
306        BooleanOperator::Union => Ok(OverlayOperation::Union),
307        BooleanOperator::Difference => Ok(OverlayOperation::Difference),
308        _ => Err(unsupported("unknown exact prism boolean operator")),
309    }
310}
311
312fn validate(prism: &Prism, role: &'static str) -> GeomResult<()> {
313    if prism.rings.is_empty() {
314        return Err(GeomError::InvalidInput(format!(
315            "{role} prism has no cross-section rings"
316        )));
317    }
318    for ring in &prism.rings {
319        if ring.len() < 3 {
320            return Err(GeomError::InvalidInput(format!(
321                "{role} prism ring needs at least three points"
322            )));
323        }
324        if !ring.iter().all(|p| p.x.is_finite() && p.y.is_finite()) {
325            return Err(GeomError::InvalidInput(format!(
326                "{role} prism ring has a non-finite point"
327            )));
328        }
329    }
330    if !prism.bottom.is_finite() || !prism.top.is_finite() {
331        return Err(GeomError::InvalidInput(format!(
332            "{role} prism heights must be finite"
333        )));
334    }
335    if prism.top <= prism.bottom {
336        return Err(GeomError::InvalidInput(format!(
337            "{role} prism top must lie above its bottom"
338        )));
339    }
340    Ok(())
341}
342
343fn to_polygons(prism: &Prism) -> Vec<Polygon> {
344    let mut rings = prism.rings.iter();
345    let outer = Ring {
346        points: rings.next().cloned().unwrap_or_default(),
347    };
348    let holes = rings.map(|r| Ring { points: r.clone() }).collect();
349    vec![Polygon { outer, holes }]
350}
351
352/// Name the result caps after the operand whose cap plane they lie in.
353///
354/// A coaxial boolean never tilts a cap, so each result cap is coplanar with
355/// a cap of at least one operand. When both operands share the plane the
356/// subject is named: the result is a fragment of both, and naming it after
357/// the subject keeps the choice deterministic rather than order-dependent.
358/// A cap matching neither operand is left unnamed rather than guessed.
359fn name_caps(
360    solid: &mut ExactBRep,
361    subject: &Prism,
362    tool: &Prism,
363    bottom: Scalar,
364    top: Scalar,
365    tolerance: Tolerance,
366) {
367    let start = cap_operand(subject.bottom, tool.bottom, bottom, tolerance);
368    let end = cap_operand(subject.top, tool.top, top, tolerance);
369    solid.name_caps(
370        start.map(|operand| FaceName::swept(SweptFace::StartCap).fragment(operand)),
371        end.map(|operand| FaceName::swept(SweptFace::EndCap).fragment(operand)),
372    );
373}
374
375/// Which operand a result cap height came from.
376fn cap_operand(
377    subject: Scalar,
378    tool: Scalar,
379    result: Scalar,
380    tolerance: Tolerance,
381) -> Option<Operand> {
382    if tolerance.eq(subject, result) {
383        Some(Operand::Subject)
384    } else if tolerance.eq(tool, result) {
385        Some(Operand::Tool)
386    } else {
387        None
388    }
389}
390
391/// Exact boolean of two coaxial prisms whose sections may contain arcs.
392///
393/// # What is exact here
394///
395/// The height reduction is identical to [`boolean_prisms_exact`]: a
396/// coaxial boolean is the planar boolean of the sections crossed with the
397/// boolean of the height intervals. Arc edges survive as arcs, so a
398/// cylindrical wall stays a `Cylinder` face rather than becoming a fan of
399/// planar strips.
400///
401/// The planar part is the exact arc overlay (ADR 0070): every
402/// topological decision is exact for the given input, and crossing points
403/// of two curves are rounded to `f64` once, in the output. Results may
404/// carry holes (through-openings) and may start above `z = 0`.
405///
406/// # Refused
407///
408/// A result with several disconnected regions (one `ExactBRep` is one
409/// solid; [`boolean_arc_prisms_exact_solids`] returns every piece). A
410/// cavity comes back as a void shell of the solid. Other stepped spans are
411/// built as in [`boolean_prisms_exact`], with cylindrical walls split at
412/// the step heights.
413pub fn boolean_arc_prisms_exact(
414    subject: &ArcPrism,
415    tool: &ArcPrism,
416    operator: BooleanOperator,
417    tolerance: Tolerance,
418) -> GeomResult<ExactBRep> {
419    if let Some(solids) = arc_prism_columns(subject, tool, operator, tolerance)? {
420        return one_solid(
421            solids,
422            "arc prism boolean produced an empty result",
423            "exact arc prism boolean producing disconnected components \
424             (boolean_arc_prisms_exact_solids returns every piece)",
425        );
426    }
427    let span = arc_prism_span(subject, tool, operator, tolerance)?;
428    let regions = arc_prism_sections(subject, tool, operator, tolerance)?;
429    single_solid(
430        regions,
431        "arc prism boolean produced an empty cross-section",
432        "exact arc prism boolean producing disconnected components \
433         (boolean_arc_prisms_exact_solids returns every piece)",
434        |region| arc_prism_solid(region, span, tolerance),
435    )
436}
437
438/// Exact boolean of two coaxial arc prisms, one exact B-rep per piece.
439///
440/// Same reduction and refusals as [`boolean_arc_prisms_exact`], except that
441/// a result which falls apart into several solids returns every piece, and
442/// an empty result is an empty list rather than an error. Solids are
443/// ordered as in [`boolean_prisms_exact_solids`]: by the lowest vertex of
444/// their outer boundary, `x` first and then `y`.
445pub fn boolean_arc_prisms_exact_solids(
446    subject: &ArcPrism,
447    tool: &ArcPrism,
448    operator: BooleanOperator,
449    tolerance: Tolerance,
450) -> GeomResult<Vec<ExactBRep>> {
451    if let Some(solids) = arc_prism_columns(subject, tool, operator, tolerance)? {
452        return Ok(solids);
453    }
454    let Some(span) = empty_span_is_none(arc_prism_span(subject, tool, operator, tolerance))? else {
455        return Ok(Vec::new());
456    };
457    let mut regions = arc_prism_sections(subject, tool, operator, tolerance)?;
458    regions.sort_by(|a, b| lowest_first(&arc_points(&a.outer), &arc_points(&b.outer)));
459    regions
460        .iter()
461        .map(|region| arc_prism_solid(region, span, tolerance))
462        .collect()
463}
464
465/// Cut an arc prism with a half-space: the prism's material on the kept
466/// side of a plane (#120).
467///
468/// This is the "round column under a sloped roof" case. When the plane
469/// passes cleanly through the prism -- above its bottom and below its top
470/// everywhere over the section -- the result is the same prism with one
471/// cap replaced by the cut:
472///
473/// - each cylindrical wall now ends on an ELLIPSE (the exact
474///   cylinder/plane intersection, #119), trimmed on the wall by the
475///   [`Sinusoid2`](axiolid_curve::Sinusoid2) pcurve (ADR 0071), so the wall
476///   stays a true `Cylinder` face;
477/// - each planar wall ends on a sloped straight edge;
478/// - the new cap is a planar face in the cutting plane.
479///
480/// `half_space.agreement` picks the kept side as elsewhere: `true` keeps
481/// the side the boundary normal points into. A plane tilted towards the
482/// kept side replaces the bottom cap, otherwise the top cap. The untouched
483/// cap keeps its `StartCap`/`EndCap` name; the cut cap is unnamed, because
484/// it is a fragment of the half-space, not of the prism.
485///
486/// A plane that crosses a cap inside the section leaves part of that cap
487/// in place and cuts the rest away: the result keeps the named fragment of
488/// the original cap beside the unnamed cut face, and the walls there end
489/// partly on the cap and partly on the cut. That shape is built by the
490/// column builder over the section split along the cap's crossing line.
491///
492/// # Refused, by name
493///
494/// - a plane parallel to the extrusion axis (a plan cut, not a cap cut);
495/// - a plane that crosses a cap along a line that splits the kept material
496///   into separate pieces, which one `ExactBRep` cannot hold (a concave
497///   section can do this);
498/// - a plane that misses the prism on the kept side entirely (the result
499///   is empty; this is a [`GeomError::Degenerate`], matching the other
500///   exact booleans' empty results).
501///
502/// A plane that keeps the whole prism returns the prism unchanged.
503pub fn clip_arc_prism_exact(
504    prism: &ArcPrism,
505    half_space: &HalfSpace,
506    tolerance: Tolerance,
507) -> GeomResult<ExactBRep> {
508    validate_arc_ring(&prism.section, tolerance)
509        .map_err(|error| GeomError::InvalidInput(format!("arc prism section: {error:?}")))?;
510    if !(prism.bottom.is_finite() && prism.top.is_finite()) {
511        return Err(GeomError::InvalidInput(
512            "arc prism heights must be finite".to_owned(),
513        ));
514    }
515    if prism.top <= prism.bottom {
516        return Err(GeomError::InvalidInput(
517            "arc prism top must lie above its bottom".to_owned(),
518        ));
519    }
520    let origin = half_space.boundary.origin;
521    let normal = half_space.boundary.normal;
522    if !(origin.is_finite() && normal.is_finite()) || normal.length_squared() == 0.0 {
523        return Err(GeomError::InvalidInput(
524            "half-space boundary must have a finite point and a non-zero normal".to_owned(),
525        ));
526    }
527    if normal.z == 0.0 {
528        return Err(unsupported(
529            "exact arc prism clip by a plane parallel to the extrusion axis",
530        ));
531    }
532    // z = height + gradient . (x, y) on the plane.
533    let level = Level {
534        height: normal.dot(origin) / normal.z,
535        gradient: Vec2::new(-normal.x / normal.z, -normal.y / normal.z),
536    };
537    if !(level.height.is_finite() && level.gradient.is_finite()) {
538        return Err(GeomError::Degenerate(
539            "half-space boundary is too steep to express as a height".to_owned(),
540        ));
541    }
542    // Kept side above the plane when the normal side is up and selected,
543    // or down and rejected.
544    let keeps_above = (normal.z > 0.0) == half_space.agreement;
545
546    let section = orient_arc_ring(&prism.section, true)?;
547    let (low, high) = level_range(&section, level)?;
548    let linear = tolerance.linear();
549    let rings = [section];
550    let flat = |bottom, top| {
551        extrude_arc_rings_between(&rings, Level::flat(bottom), Level::flat(top), (true, true))
552    };
553    let solid = if keeps_above {
554        if high <= prism.bottom + linear {
555            flat(prism.bottom, prism.top)?
556        } else if low >= prism.top - linear {
557            return Err(GeomError::Degenerate(
558                "arc prism clip is empty: the plane lies above the prism".to_owned(),
559            ));
560        } else if low > prism.bottom + linear && high < prism.top - linear {
561            extrude_arc_rings_between(&rings, level, Level::flat(prism.top), (false, true))?
562        } else {
563            return clip_crossing(&rings[0], prism, level, keeps_above, tolerance);
564        }
565    } else if low >= prism.top - linear {
566        flat(prism.bottom, prism.top)?
567    } else if high <= prism.bottom + linear {
568        return Err(GeomError::Degenerate(
569            "arc prism clip is empty: the plane lies below the prism".to_owned(),
570        ));
571    } else if low > prism.bottom + linear && high < prism.top - linear {
572        extrude_arc_rings_between(&rings, Level::flat(prism.bottom), level, (true, false))?
573    } else {
574        return clip_crossing(&rings[0], prism, level, keeps_above, tolerance);
575    };
576    gate_geometry(solid, tolerance)
577}
578
579/// Lowest and highest value of an affine level over a closed arc ring.
580///
581/// An affine function over a disc sector takes its extremes at the edge
582/// endpoints or where an arc is tangent to the level's contour lines: at
583/// the circle points in the directions `+gradient` and `-gradient`, when
584/// those lie inside the arc's sweep. Checking exactly those candidates
585/// gives the true range, not a sampled estimate.
586fn level_range(ring: &ArcRing, level: Level) -> GeomResult<(Scalar, Scalar)> {
587    let mut low = Scalar::INFINITY;
588    let mut high = Scalar::NEG_INFINITY;
589    let mut take = |p: Point2| {
590        let z = level.at(p);
591        low = low.min(z);
592        high = high.max(z);
593    };
594    let count = ring.vertices.len();
595    for index in 0..count {
596        let from = ring.vertices[index];
597        let to = ring.vertices[(index + 1) % count];
598        take(from.point);
599        if from.bulge == 0.0 || level.gradient == Vec2::ZERO {
600            continue;
601        }
602        let arc = arc_geometry(from.point, to.point, from.bulge)?;
603        let start = (from.point - arc.centre).to_angle();
604        let direction = level.gradient.to_angle();
605        for extreme in [direction, direction + core::f64::consts::PI] {
606            // Angle from the arc start to the candidate, measured the way
607            // the arc turns, in [0, 2 pi).
608            let turned = if arc.sweep > 0.0 {
609                (extreme - start).rem_euclid(core::f64::consts::TAU)
610            } else {
611                (start - extreme).rem_euclid(core::f64::consts::TAU)
612            };
613            if turned <= arc.sweep.abs() {
614                take(arc.centre + Vec2::from_angle(extreme) * arc.radius);
615            }
616        }
617    }
618    Ok((low, high))
619}
620
621fn arc_points(ring: &ArcRing) -> Vec<Point2> {
622    ring.vertices.iter().map(|vertex| vertex.point).collect()
623}
624
625/// The one solid a single-result entry point may return from a column
626/// build: empty and multi-piece results are refused as elsewhere.
627fn one_solid(
628    mut solids: Vec<ExactBRep>,
629    empty: &'static str,
630    disconnected: &'static str,
631) -> GeomResult<ExactBRep> {
632    match solids.len() {
633        0 => Err(GeomError::Degenerate(empty.to_owned())),
634        1 => Ok(solids.remove(0)),
635        _ => Err(unsupported(disconnected)),
636    }
637}
638
639/// A polygon prism boolean that is not one prism (stepped spans); `None`
640/// means the single-prism path handles it.
641fn prism_columns(
642    subject: &Prism,
643    tool: &Prism,
644    operator: BooleanOperator,
645    tolerance: Tolerance,
646) -> GeomResult<Option<Vec<ExactBRep>>> {
647    validate(subject, "subject")?;
648    validate(tool, "tool")?;
649    if !is_stepped(
650        (subject.bottom, subject.top),
651        (tool.bottom, tool.top),
652        operator,
653        tolerance,
654    ) {
655        return Ok(None);
656    }
657    let operand = |prism: &Prism| ColumnOperand {
658        rings: prism
659            .rings
660            .iter()
661            .map(|ring| ArcRing::new(ring.iter().copied().map(ArcVertex::straight).collect()))
662            .collect(),
663        bottom: prism.bottom,
664        top: prism.top,
665    };
666    coaxial_columns(&operand(subject), &operand(tool), operator, tolerance).map(Some)
667}
668
669/// An arc prism boolean with stepped spans; `None` when it is one prism.
670fn arc_prism_columns(
671    subject: &ArcPrism,
672    tool: &ArcPrism,
673    operator: BooleanOperator,
674    tolerance: Tolerance,
675) -> GeomResult<Option<Vec<ExactBRep>>> {
676    validate_arc_prisms(subject, tool, tolerance)?;
677    if !is_stepped(
678        (subject.bottom, subject.top),
679        (tool.bottom, tool.top),
680        operator,
681        tolerance,
682    ) {
683        return Ok(None);
684    }
685    let operand = |prism: &ArcPrism| ColumnOperand {
686        rings: vec![prism.section.clone()],
687        bottom: prism.bottom,
688        top: prism.top,
689    };
690    coaxial_columns(&operand(subject), &operand(tool), operator, tolerance).map(Some)
691}
692
693/// A clip whose plane crosses a cap inside the section, built as columns.
694fn clip_crossing(
695    section: &ArcRing,
696    prism: &ArcPrism,
697    level: Level,
698    keeps_above: bool,
699    tolerance: Tolerance,
700) -> GeomResult<ExactBRep> {
701    let solids = clip_columns(
702        section,
703        (prism.bottom, prism.top),
704        level,
705        keeps_above,
706        tolerance,
707    )?;
708    one_solid(
709        solids,
710        "arc prism clip is empty",
711        "exact arc prism clip leaving disconnected pieces",
712    )
713}
714
715fn validate_arc_prisms(
716    subject: &ArcPrism,
717    tool: &ArcPrism,
718    tolerance: Tolerance,
719) -> GeomResult<()> {
720    for (section, role) in [(&subject.section, "subject"), (&tool.section, "tool")] {
721        validate_arc_ring(section, tolerance).map_err(|error| {
722            GeomError::InvalidInput(format!("{role} arc prism section: {error:?}"))
723        })?;
724    }
725    if !subject.bottom.is_finite()
726        || !subject.top.is_finite()
727        || !tool.bottom.is_finite()
728        || !tool.top.is_finite()
729    {
730        return Err(GeomError::InvalidInput(
731            "arc prism heights must be finite".to_owned(),
732        ));
733    }
734    if subject.top <= subject.bottom || tool.top <= tool.bottom {
735        return Err(GeomError::InvalidInput(
736            "arc prism top must lie above its bottom".to_owned(),
737        ));
738    }
739    Ok(())
740}
741
742/// Validate both arc prisms and settle the height span of the result.
743fn arc_prism_span(
744    subject: &ArcPrism,
745    tool: &ArcPrism,
746    operator: BooleanOperator,
747    tolerance: Tolerance,
748) -> GeomResult<(Scalar, Scalar)> {
749    validate_arc_prisms(subject, tool, tolerance)?;
750    resolve_span(
751        (subject.bottom, subject.top),
752        (tool.bottom, tool.top),
753        operator,
754        tolerance,
755    )
756}
757
758/// The exact planar boolean of the two arc sections, one region per piece.
759fn arc_prism_sections(
760    subject: &ArcPrism,
761    tool: &ArcPrism,
762    operator: BooleanOperator,
763    tolerance: Tolerance,
764) -> GeomResult<Vec<ArcPolygon>> {
765    let operation = overlay_operation(operator)?;
766    let result =
767        arc_overlay(&subject.section, &tool.section, operation, tolerance).map_err(|error| {
768            GeomError::BackendContractViolation {
769                backend: BACKEND_ID,
770                detail: format!("arc prism cross-section overlay failed: {error:?}"),
771            }
772        })?;
773    Ok(result.regions)
774}
775
776/// Extrude one arc region between the result's heights.
777fn arc_prism_solid(
778    region: &ArcPolygon,
779    (bottom, top): (Scalar, Scalar),
780    tolerance: Tolerance,
781) -> GeomResult<ExactBRep> {
782    // Holes are through-openings: each becomes its own wall ring and a
783    // second bound on both caps. The result may start above z = 0 (an
784    // intersection with a raised tool), so the section is extruded from
785    // its own base height.
786    let mut rings = Vec::with_capacity(1 + region.holes.len());
787    rings.push(region.outer.clone());
788    rings.extend(region.holes.iter().cloned());
789    let solid = extrude_arc_rings_from(&rings, bottom, Vec3::Z * (top - bottom))?;
790    gate_geometry(solid, tolerance)
791}
792
793/// The height span a coaxial boolean result occupies.
794///
795/// Shared by the polygon and arc paths: the height reduction does not
796/// depend on what the cross-section looks like, so duplicating it would
797/// invite the two paths to disagree about which spans are representable.
798fn resolve_span(
799    subject: (Scalar, Scalar),
800    tool: (Scalar, Scalar),
801    operator: BooleanOperator,
802    tolerance: Tolerance,
803) -> GeomResult<(Scalar, Scalar)> {
804    match operator {
805        BooleanOperator::Intersection => {
806            let bottom = subject.0.max(tool.0);
807            let top = subject.1.min(tool.1);
808            if top - bottom <= tolerance.linear() {
809                return Err(GeomError::Degenerate(
810                    "prism intersection is empty along the extrusion axis".to_owned(),
811                ));
812            }
813            Ok((bottom, top))
814        }
815        BooleanOperator::Union => {
816            // Differing spans give a stepped solid, which is not a prism.
817            // Callers route those to the column builder first; this guard
818            // keeps the single-prism path from ever flattening one.
819            if !tolerance.eq(subject.0, tool.0) || !tolerance.eq(subject.1, tool.1) {
820                return Err(unsupported(
821                    "exact prism union with differing extrusion spans",
822                ));
823            }
824            Ok(subject)
825        }
826        BooleanOperator::Difference => {
827            // A tool that stops inside the subject leaves a step.
828            if tool.0 > subject.0 + tolerance.linear() || tool.1 < subject.1 - tolerance.linear() {
829                return Err(unsupported(
830                    "exact prism difference with a tool shorter than the subject",
831                ));
832            }
833            Ok(subject)
834        }
835        _ => Err(unsupported("unknown exact prism boolean operator")),
836    }
837}
838
839/// Reject a boolean result whose geometry does not hold together.
840///
841/// # Why booleans specifically
842///
843/// A boolean is where pcurves get rebuilt against surfaces they did not
844/// originally trim, so it is the operation most able to produce a solid that
845/// is topologically perfect and geometrically wrong. Both properties held on
846/// the cap loops of an earlier arc boolean, and nothing caught it.
847///
848/// The audit is cheap here because an exact analytic boolean returns a handful
849/// of faces, not a mesh: a few evaluations per edge use.
850pub(crate) fn gate_geometry(solid: ExactBRep, tolerance: Tolerance) -> GeomResult<ExactBRep> {
851    let health = geometric_audit(&solid, tolerance);
852    if health.is_consistent() {
853        return Ok(solid);
854    }
855    // Report the measured deviation, not just the fact of failure: a caller
856    // deciding whether their tolerance is wrong needs the number.
857    let detail = match health.worst_error() {
858        Some(error) => format!(
859            "boolean result failed its geometric audit: {} defect(s), worst deviation {error:e}",
860            health.defects().len()
861        ),
862        None => format!(
863            "boolean result failed its geometric audit: {} defect(s)",
864            health.defects().len()
865        ),
866    };
867    Err(GeomError::BackendContractViolation {
868        backend: BACKEND_ID,
869        detail,
870    })
871}