axiolid_construct/
half_space.rs

1//! Bounding an unbounded half-space against a finite boundary.
2//!
3//! A half-space is one side of a plane, infinite in every direction, so it
4//! has no mesh of its own. To take part in a finite operation it must first
5//! be bounded, and the boundary profile is what bounds it.
6//!
7//! The slab is built by extruding the boundary profile along the plane
8//! normal, far enough in each direction to cover the boundary's own extent,
9//! then keeping the selected side. Sizing from the boundary rather than
10//! from a fixed constant is what keeps the result independent of model
11//! units: a boundary in millimetres and the same boundary in metres
12//! produce the same shape.
13
14use axiolid_contracts::{GeomError, GeomResult};
15use axiolid_core::{Plane3, PlaneFrame, Point2, Scalar, Tolerance, Vec3};
16use axiolid_mesh::TriMesh;
17use axiolid_primitive::{ClipMargin, HalfSpace};
18
19use crate::loft::{self, Frame, Station};
20use crate::profile::Rings;
21
22/// Build the finite solid that represents a half-space bounded by a profile.
23///
24/// `agreement` selects which side of the plane survives: `true` keeps the
25/// normal side, `false` the opposite one. The boundary profile is assumed
26/// to lie in the plane's own frame, which is how the graph stores it.
27///
28/// The margin scales the extrusion depth relative to the boundary's own
29/// size. It is relative rather than absolute so the result does not depend
30/// on the model's units.
31pub fn bounded_half_space(
32    boundary: &Rings,
33    plane: Plane3,
34    agreement: bool,
35    margin: ClipMargin,
36    tolerance: Tolerance,
37) -> GeomResult<TriMesh> {
38    bounded_half_space_framed(boundary, plane, None, agreement, margin, tolerance)
39}
40
41/// Build a bounded half-space with an explicitly authored in-plane frame.
42///
43/// [`bounded_half_space`] derives the boundary's in-plane axes from the clip
44/// plane normal alone, which fixes only 2 of the 3 orientation degrees of
45/// freedom: the rotation ABOUT the normal is picked by an internal heuristic.
46/// That is fine when the profile carries no authored orientation of its own.
47///
48/// It is not fine when the source format authors one. IFC's
49/// `IfcPolygonalBoundedHalfSpace.Position` is an placement explicitly
50/// independent of `BaseSurface`, and its `PolygonalBoundary` is expressed in
51/// THAT frame. Deriving the axes instead of honouring the authored ones
52/// silently rotates the clipping profile about the plane normal, so the wrong
53/// region of the subject is cut.
54///
55/// The frame's own axes are used as written. Its normal is not required to
56/// match `plane.normal`: only the in-plane axes are taken from it, and the
57/// sweep still runs along the clip plane's normal, so an authored frame that
58/// is tilted relative to the clip plane still contributes only its rotation
59/// about that normal.
60///
61/// The frame's origin anchors the boundary, projected onto the clip plane: an
62/// in-plane offset from `plane.origin` moves the footprint by exactly that
63/// offset, and the component along the normal is dropped so the sweep still
64/// starts on the clip plane.
65pub fn bounded_half_space_in_frame(
66    boundary: &Rings,
67    plane: Plane3,
68    frame: PlaneFrame,
69    agreement: bool,
70    margin: ClipMargin,
71    tolerance: Tolerance,
72) -> GeomResult<TriMesh> {
73    bounded_half_space_framed(boundary, plane, Some(frame), agreement, margin, tolerance)
74}
75
76fn bounded_half_space_framed(
77    boundary: &Rings,
78    plane: Plane3,
79    authored: Option<PlaneFrame>,
80    agreement: bool,
81    margin: ClipMargin,
82    tolerance: Tolerance,
83) -> GeomResult<TriMesh> {
84    let normal = plane.normal.normalize_or_zero();
85    if normal == Vec3::ZERO {
86        return Err(GeomError::InvalidInput(
87            "half-space boundary plane needs a non-zero normal".to_owned(),
88        ));
89    }
90    if boundary.outer.len() < 3 {
91        return Err(GeomError::InvalidInput(format!(
92            "half-space boundary needs at least 3 vertices, got {}",
93            boundary.outer.len()
94        )));
95    }
96
97    // Size the slab from the boundary's own extent. A profile spanning 10
98    // units gets a slab proportional to 10, so the construction carries no
99    // hidden absolute length.
100    let mut extent: Scalar = 0.0;
101    for p in boundary.outer.iter().chain(boundary.holes.iter().flatten()) {
102        extent = extent.max(p.x.abs()).max(p.y.abs());
103    }
104    // No zero-extent guard here: a boundary with no area cannot be
105    // triangulated, so `loft` rejects it downstream with a message naming
106    // the real problem. A guard here was unreachable, and unreachable
107    // validation reads as a promise the code cannot keep.
108    let depth = extent * margin.factor();
109
110    // Frame the profile in the plane. The plane normal is the sweep
111    // direction, so the profile's own x and y span the plane.
112    //
113    // An authored frame is used as written: it is the caller's statement of
114    // where the profile's x and y point, and re-deriving them would discard
115    // the one piece of information the caller supplied. Without one, the
116    // rotation about the normal is unconstrained, so a reference axis is
117    // picked -- the least parallel of X and Y, so the cross product stays
118    // well conditioned.
119    let base = match authored {
120        Some(frame) => {
121            // Project the authored axes into the clip plane and re-orthonormalise
122            // against the sweep normal. The authored frame fixes the rotation
123            // about the normal; the sweep direction is still the clip plane's.
124            let projected = frame.x_axis() - normal * frame.x_axis().dot(normal);
125            let reference = projected.normalize_or_zero();
126            if reference == Vec3::ZERO {
127                return Err(GeomError::InvalidInput(
128                    "authored boundary frame x axis is parallel to the clip plane normal, \
129                     so it fixes no in-plane direction"
130                        .to_owned(),
131                ));
132            }
133            // The authored origin places the footprint in the plane (#164).
134            // Anchoring at `plane.origin` instead silently moves the prism
135            // whenever the boundary frame is offset from the clip plane's own
136            // origin, which the frame's contract allows. Only the offset ALONG
137            // the normal is dropped, exactly as for the axes: the sweep still
138            // starts on the clip plane, so polarity and depth are unchanged.
139            let offset = frame.origin() - plane.origin;
140            let anchor = frame.origin() - normal * offset.dot(normal);
141            Frame::from_reference(anchor, normal, reference)?
142        }
143        None => {
144            let reference = if normal.x.abs() < 0.9 {
145                Vec3::X
146            } else {
147                Vec3::Y
148            };
149            Frame::from_reference(plane.origin, normal, reference)?
150        }
151    };
152
153    // Keeping the normal side sweeps from the plane along +normal; the
154    // opposite side sweeps the other way.
155    let step = if agreement { normal } else { -normal };
156    let near = Frame {
157        origin: base.origin,
158        x: base.x,
159        y: base.y,
160    };
161    let far = Frame {
162        origin: base.origin + step * depth,
163        x: base.x,
164        y: base.y,
165    };
166    // Order the stations so traversal always runs along +normal. `loft`
167    // derives its winding from station order, and the frame's own handedness
168    // is fixed (x cross y == +normal), so the two must agree or the solid
169    // comes out inside-out. Swapping the order rather than mirroring an
170    // in-plane axis keeps the boundary's footprint untouched: `agreement`
171    // selects a side, it never reshapes the profile.
172    let ordered = if agreement { [near, far] } else { [far, near] };
173    let stations: Vec<Station> = ordered
174        .iter()
175        .map(|f| loft::place(boundary, |p| loft::at(f, p)))
176        .collect();
177    let _ = tolerance;
178    loft::loft(boundary, &stations, false)
179}
180
181pub fn for_subject(
182    subject: &TriMesh,
183    half_space: HalfSpace,
184    tolerance: Tolerance,
185) -> GeomResult<TriMesh> {
186    let normal = half_space.boundary.normal.normalize_or_zero();
187    if normal == Vec3::ZERO || subject.positions.is_empty() {
188        return Err(GeomError::InvalidInput(
189            "half-space boolean needs finite subject bounds".into(),
190        ));
191    }
192    let reference = if normal.x.abs() < 0.9 {
193        Vec3::X
194    } else {
195        Vec3::Y
196    };
197    let frame = Frame::from_reference(half_space.boundary.origin, normal, reference)?;
198    let mut min = Point2::splat(Scalar::INFINITY);
199    let mut max = Point2::splat(Scalar::NEG_INFINITY);
200    let side = if half_space.agreement { 1.0 } else { -1.0 };
201    let mut selected_depth: Scalar = 0.0;
202    for &point in &subject.positions {
203        if !point.is_finite() {
204            return Err(GeomError::InvalidInput(
205                "half-space subject contains non-finite points".into(),
206            ));
207        }
208        let delta = point - half_space.boundary.origin;
209        let uv = Point2::new(delta.dot(frame.x), delta.dot(frame.y));
210        min = min.min(uv);
211        max = max.max(uv);
212        selected_depth = selected_depth.max(delta.dot(normal) * side);
213    }
214    let span = (max - min).max_element().max(tolerance.linear());
215    let pad = span * 0.1 + tolerance.linear();
216    min -= Point2::splat(pad);
217    max += Point2::splat(pad);
218    let boundary = Rings {
219        outer: vec![
220            min,
221            Point2::new(max.x, min.y),
222            max,
223            Point2::new(min.x, max.y),
224        ],
225        holes: Vec::new(),
226    };
227    let extent = min.abs().max(max.abs()).max_element();
228    let depth = selected_depth + pad;
229    let factor = depth / extent;
230    let margin = ClipMargin::new(factor).ok_or_else(|| {
231        GeomError::InvalidInput("half-space subject has no finite clipping extent".into())
232    })?;
233    bounded_half_space(
234        &boundary,
235        half_space.boundary,
236        half_space.agreement,
237        margin,
238        tolerance,
239    )
240}