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}