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 region is the kept side of the plane within the prism of the
8//! boundary: the lines through the boundary's region parallel to the prism
9//! axis, infinite both ways ([`BoundaryPrism`]). The axis is the plane
10//! normal ([`bounded_half_space`], [`bounded_half_space_in_frame`]) or any
11//! direction that crosses the plane ([`bounded_half_space_prism`], #321),
12//! so the prism may stand oblique to the plane. The slab is that region cut
13//! off by a plane parallel to the clip plane at a depth on the kept side
14//! ([`SlabDepth`]): relative to the boundary's own extent, which keeps the
15//! result independent of model units (a boundary in millimetres and the
16//! same boundary in metres produce the same shape), or the reach of a
17//! subject the slab must cover.
18//!
19//! Its near cap is the boundary carried along the axis onto the clip plane,
20//! its far cap the near one moved along the axis, and its walls the lines
21//! between them: the boundary's own prism, exactly, for a polygon.
22
23use axiolid_contracts::{GeomError, GeomResult};
24use axiolid_core::{Plane3, PlaneFrame, Point2, Point3, Scalar, Tolerance, Transform3, Vec3};
25use axiolid_mesh::TriMesh;
26use axiolid_primitive::{ClipMargin, HalfSpace};
27
28use crate::loft::{self, Frame, Station};
29use crate::profile::Rings;
30
31/// Where a bounded half-space's boundary stands and which way its prism
32/// runs (#321).
33///
34/// The boundary point `(u, v)` lies at `origin + u x + v y`, and the prism
35/// is the set of lines through the boundary's region parallel to `axis`,
36/// infinite both ways. `x` and `y` are used as given: their lengths scale
37/// the boundary, the angle between them shears it, and their handedness
38/// mirrors it. Only the direction of `axis` counts, not its length or sign.
39#[derive(Debug, Clone, Copy, PartialEq)]
40pub struct BoundaryPrism {
41 /// Where the boundary's `(0, 0)` lies.
42 pub origin: Point3,
43 /// The image of the boundary's `+u`.
44 pub x: Vec3,
45 /// The image of the boundary's `+v`.
46 pub y: Vec3,
47 /// The direction of the prism's lines.
48 pub axis: Vec3,
49}
50
51impl BoundaryPrism {
52 /// The prism along `plane`'s normal of a boundary authored in `frame`
53 /// ([`bounded_half_space_in_frame`]): `frame`'s `x` projected into the
54 /// plane fixes the boundary's rotation about the normal, its `y` is the
55 /// normal cross that `x` -- or its opposite for a frame mirrored about
56 /// the normal, whose `x cross y` points against it (#322) -- and its
57 /// origin is projected onto the plane.
58 ///
59 /// # Errors
60 ///
61 /// `InvalidInput`, by name, for a plane without a normal, a frame whose
62 /// `x` is parallel to it, and a frame standing perpendicular to the
63 /// plane (within the angular tolerance), which has no handedness there.
64 pub fn along_normal(
65 frame: PlaneFrame,
66 plane: Plane3,
67 tolerance: Tolerance,
68 ) -> GeomResult<Self> {
69 let normal = plane_normal(plane)?;
70 let turn = frame.x_axis().cross(frame.y_axis()).dot(normal);
71 if turn.abs() <= unit_floor(tolerance) {
72 return Err(GeomError::InvalidInput(
73 "authored boundary frame stands perpendicular to the clip plane, so it \
74 has no handedness there"
75 .to_owned(),
76 ));
77 }
78 Self::laid_on(
79 (frame.origin(), frame.x_axis(), frame.y_axis()),
80 turn < 0.0,
81 normal,
82 plane,
83 )
84 }
85
86 /// The prism along `plane`'s normal of a boundary authored in
87 /// `placement`'s `xy` plane, the whole frame honoured (#322): the
88 /// boundary keeps the shape the placement gives it -- the length of its
89 /// `x`, the length and lean of its `y` against that `x` -- and the
90 /// placement's handedness, laid into the plane with its `x` along the
91 /// placement's `x` projected there and its `y` to the side the normal
92 /// cross that `x` points, or the other side for a left-handed
93 /// placement, whose `x`, `y` and `z` are mirrored. The origin is
94 /// projected onto the plane. For a right-handed placement with unit,
95 /// perpendicular `x` and `y` this is [`BoundaryPrism::along_normal`].
96 ///
97 /// # Errors
98 ///
99 /// `InvalidInput`, by name, for a plane without a normal, a non-finite
100 /// placement, a degenerate one, whose `x`, `y` and `z` columns do not
101 /// span space within the angular tolerance (parallel or zero axes), and
102 /// one whose `x` is parallel to the normal.
103 pub fn along_plane_normal(
104 placement: Transform3,
105 plane: Plane3,
106 tolerance: Tolerance,
107 ) -> GeomResult<Self> {
108 let normal = plane_normal(plane)?;
109 let (x, y, handedness) = placement_axes(placement, tolerance)?;
110 Self::laid_on(
111 (placement.translation, x, y),
112 handedness < 0.0,
113 normal,
114 plane,
115 )
116 }
117
118 /// `x` and `y` at `origin` laid into the plane along its `normal`:
119 /// `x`'s projection fixes the direction, the frame's own shape is kept,
120 /// and `mirrored` turns `y` to the side opposite `normal cross x`.
121 fn laid_on(
122 (origin, x, y): (Point3, Vec3, Vec3),
123 mirrored: bool,
124 normal: Vec3,
125 plane: Plane3,
126 ) -> GeomResult<Self> {
127 // Project the authored x into the clip plane. The authored frame
128 // fixes the rotation about the normal; the sweep direction is
129 // still the clip plane's.
130 let reference = (x - normal * x.dot(normal)).normalize_or_zero();
131 if reference == Vec3::ZERO {
132 return Err(GeomError::InvalidInput(
133 "authored boundary frame x axis is parallel to the clip plane normal, \
134 so it fixes no in-plane direction"
135 .to_owned(),
136 ));
137 }
138 // The authored origin places the footprint in the plane (#164).
139 // Anchoring at `plane.origin` instead silently moves the prism
140 // whenever the boundary frame is offset from the clip plane's own
141 // origin, which the frame's contract allows. Only the offset ALONG
142 // the normal is dropped, exactly as for the axes: the sweep still
143 // starts on the clip plane, so polarity and depth are unchanged.
144 let anchor = origin - normal * (origin - plane.origin).dot(normal);
145 let base = Frame::from_reference(anchor, normal, reference)?;
146 // The frame's own shape: `y` leans `shear` along `x` and rises
147 // `rise` across it.
148 let length = x.length();
149 let shear = y.dot(x) / length;
150 let rise = (y - x * (shear / length)).length();
151 let across = if mirrored { -base.y } else { base.y };
152 Ok(Self {
153 origin: base.origin,
154 x: base.x * length,
155 y: base.x * shear + across * rise,
156 axis: normal,
157 })
158 }
159
160 /// The prism of a boundary authored in `placement`'s `xy` plane and
161 /// running along its `z` (#321): the point `(u, v)` at
162 /// `placement * (u, v, 0)`, the axis `placement`'s `z` column. This is
163 /// how IFC bounds an `IfcPolygonalBoundedHalfSpace`, by its
164 /// `Position`.
165 ///
166 /// # Errors
167 ///
168 /// `InvalidInput`, by name, for a non-finite placement and for a
169 /// degenerate one, whose `x`, `y` and `z` columns do not span space
170 /// within the angular tolerance (parallel or zero axes): its boundary
171 /// has no area, or its prism no direction off the boundary's plane.
172 pub fn along_placement_z(placement: Transform3, tolerance: Tolerance) -> GeomResult<Self> {
173 let (x, y, _) = placement_axes(placement, tolerance)?;
174 Ok(Self {
175 origin: placement.translation,
176 x,
177 y,
178 axis: placement.matrix3.z_axis,
179 })
180 }
181
182 /// The cosine of the prism's axis against `plane`'s unit normal, signed:
183 /// how steeply, and from which side, the prism crosses the plane.
184 ///
185 /// # Errors
186 ///
187 /// `InvalidInput`, by name, for a plane without a normal, an axis that
188 /// is zero or not finite, and an axis within the angular tolerance of
189 /// the plane: its prism never crosses it.
190 pub fn crossing(&self, plane: Plane3, tolerance: Tolerance) -> GeomResult<Scalar> {
191 let normal = plane_normal(plane)?;
192 let axis = self.axis.normalize_or_zero();
193 if !axis.is_finite() || axis == Vec3::ZERO {
194 return Err(GeomError::InvalidInput(
195 "bounded half-space prism axis must be a non-zero direction".to_owned(),
196 ));
197 }
198 let crossing = axis.dot(normal);
199 if crossing.abs() <= unit_floor(tolerance) {
200 return Err(GeomError::InvalidInput(
201 "bounded half-space prism axis is parallel to the clip plane, so its prism \
202 never crosses it"
203 .to_owned(),
204 ));
205 }
206 Ok(crossing)
207 }
208}
209
210/// How far a bounded half-space's slab reaches past its plane on the kept
211/// side, measured perpendicular to the plane.
212#[derive(Debug, Clone, Copy, PartialEq)]
213pub enum SlabDepth {
214 /// The boundary's own extent on the plane, its largest coordinate
215 /// scaled by its longest axis there, times the margin: independent of
216 /// the model's units, and of everything the slab meets.
217 Relative(ClipMargin),
218 /// This distance: the reach of the subject the slab must cover, so that
219 /// a boolean with the slab is the boolean with the half-space.
220 Reach(Scalar),
221}
222
223/// Build the finite solid that represents a half-space bounded by a profile.
224///
225/// `agreement` selects which side of the plane survives: `true` keeps the
226/// normal side, `false` the opposite one. The boundary profile is assumed
227/// to lie in the plane's own frame, which is how the graph stores it.
228///
229/// The margin scales the extrusion depth relative to the boundary's own
230/// size. It is relative rather than absolute so the result does not depend
231/// on the model's units.
232pub fn bounded_half_space(
233 boundary: &Rings,
234 plane: Plane3,
235 agreement: bool,
236 margin: ClipMargin,
237 tolerance: Tolerance,
238) -> GeomResult<TriMesh> {
239 bounded_half_space_framed(boundary, plane, None, agreement, margin, tolerance)
240}
241
242/// Build a bounded half-space with an explicitly authored in-plane frame.
243///
244/// [`bounded_half_space`] derives the boundary's in-plane axes from the clip
245/// plane normal alone, which fixes only 2 of the 3 orientation degrees of
246/// freedom: the rotation ABOUT the normal is picked by an internal heuristic.
247/// That is fine when the profile carries no authored orientation of its own.
248///
249/// It is not fine when the source format authors one. IFC's
250/// `IfcPolygonalBoundedHalfSpace.Position` is an placement explicitly
251/// independent of `BaseSurface`, and its `PolygonalBoundary` is expressed in
252/// THAT frame. Deriving the axes instead of honouring the authored ones
253/// silently rotates the clipping profile about the plane normal, so the wrong
254/// region of the subject is cut.
255///
256/// The frame's own axes are used as written. Its normal is not required to
257/// match `plane.normal`: only the in-plane axes are taken from it, and the
258/// sweep still runs along the clip plane's normal, so an authored frame that
259/// is tilted relative to the clip plane still contributes only its rotation
260/// about that normal -- and its handedness: a frame whose `y` is
261/// `-(normal x x)`, mirrored about the normal, builds its boundary
262/// mirrored, as written, still outward-facing (#322). A frame standing
263/// perpendicular to the plane has no handedness there and is refused by
264/// name. [`BoundaryPrism::along_plane_normal`] reads a whole placement,
265/// scale, shear and handedness included.
266///
267/// The frame's origin anchors the boundary, projected onto the clip plane: an
268/// in-plane offset from `plane.origin` moves the footprint by exactly that
269/// offset, and the component along the normal is dropped so the sweep still
270/// starts on the clip plane.
271pub fn bounded_half_space_in_frame(
272 boundary: &Rings,
273 plane: Plane3,
274 frame: PlaneFrame,
275 agreement: bool,
276 margin: ClipMargin,
277 tolerance: Tolerance,
278) -> GeomResult<TriMesh> {
279 bounded_half_space_framed(boundary, plane, Some(frame), agreement, margin, tolerance)
280}
281
282fn bounded_half_space_framed(
283 boundary: &Rings,
284 plane: Plane3,
285 authored: Option<PlaneFrame>,
286 agreement: bool,
287 margin: ClipMargin,
288 tolerance: Tolerance,
289) -> GeomResult<TriMesh> {
290 // Frame the profile in the plane. The plane normal is the sweep
291 // direction, so the profile's own x and y span the plane.
292 //
293 // An authored frame is used as written: it is the caller's statement of
294 // where the profile's x and y point, and re-deriving them would discard
295 // the one piece of information the caller supplied. Without one, the
296 // rotation about the normal is unconstrained, so a reference axis is
297 // picked -- the least parallel of X and Y, so the cross product stays
298 // well conditioned.
299 let prism = match authored {
300 Some(frame) => BoundaryPrism::along_normal(frame, plane, tolerance)?,
301 None => {
302 let normal = plane_normal(plane)?;
303 let reference = if normal.x.abs() < 0.9 {
304 Vec3::X
305 } else {
306 Vec3::Y
307 };
308 let base = Frame::from_reference(plane.origin, normal, reference)?;
309 BoundaryPrism {
310 origin: base.origin,
311 x: base.x,
312 y: base.y,
313 axis: normal,
314 }
315 }
316 };
317 bounded_half_space_prism(
318 boundary,
319 plane,
320 prism,
321 agreement,
322 SlabDepth::Relative(margin),
323 tolerance,
324 )
325}
326
327/// Build the slab of a half-space bounded by the prism of `boundary` along
328/// any axis that crosses the plane (#321).
329///
330/// The region is the side of `plane` that `agreement` selects (`true` the
331/// normal side) within `prism`, cut off at `depth` from the plane. The
332/// prism may stand at any angle to the plane: the near cap is the boundary
333/// carried along the axis onto the plane, the far cap the same moved along
334/// the axis to the depth. The boundary's winding is its own affair: a ring
335/// wound either way, under a frame of either handedness, gives the same
336/// outward-facing solid, the loft's stations ordered to suit.
337///
338/// # Errors
339///
340/// `InvalidInput`, by name, for a plane without a normal, a boundary of
341/// fewer than 3 vertices, a prism axis that is zero, not finite or within
342/// the angular tolerance of the plane (its prism never crosses it), a frame
343/// whose `x` and `y` carried onto the plane are parallel or zero, and a
344/// depth that is not positive and finite. A boundary that bounds no region
345/// is refused by the cap triangulation, naming the ring.
346pub fn bounded_half_space_prism(
347 boundary: &Rings,
348 plane: Plane3,
349 prism: BoundaryPrism,
350 agreement: bool,
351 depth: SlabDepth,
352 tolerance: Tolerance,
353) -> GeomResult<TriMesh> {
354 let normal = plane_normal(plane)?;
355 if boundary.outer.len() < 3 {
356 return Err(GeomError::InvalidInput(format!(
357 "half-space boundary needs at least 3 vertices, got {}",
358 boundary.outer.len()
359 )));
360 }
361 // The cosine of the axis against the kept side: how steeply the prism
362 // crosses the plane. Its sign turns the axis to the kept side, which
363 // is all a prism infinite both ways asks of it.
364 let crossing = prism.crossing(plane, tolerance)?;
365 let lean = if agreement { crossing } else { -crossing };
366 let axis = prism.axis.normalize();
367 let toward = axis * lean.signum();
368 // Carry the frame along the axis onto the plane: a point `p` lands at
369 // `p - axis ((p - origin) . n) / (axis . n)`, an affine map, so the
370 // near cap is the boundary in the carried frame.
371 let carry = |v: Vec3| v - axis * (v.dot(normal) / crossing);
372 let origin = prism.origin - axis * ((prism.origin - plane.origin).dot(normal) / crossing);
373 let (x, y) = (carry(prism.x), carry(prism.y));
374 let face = x.cross(y);
375 if !(origin.is_finite() && face.is_finite())
376 || face.length() <= unit_floor(tolerance) * x.length() * y.length()
377 {
378 return Err(GeomError::InvalidInput(
379 "bounded half-space boundary frame is degenerate on the clip plane: its axes \
380 are parallel or zero"
381 .to_owned(),
382 ));
383 }
384 // Size the slab. Relative to the boundary, a profile spanning 10 units
385 // gets a slab proportional to 10, so the construction carries no hidden
386 // absolute length.
387 let reach = match depth {
388 SlabDepth::Relative(margin) => {
389 let mut extent: Scalar = 0.0;
390 for p in boundary.outer.iter().chain(boundary.holes.iter().flatten()) {
391 extent = extent.max(p.x.abs()).max(p.y.abs());
392 }
393 // No zero-extent guard here: a boundary with no area cannot be
394 // triangulated, so `loft` rejects it downstream with a message
395 // naming the real problem.
396 extent * x.length().max(y.length()) * margin.factor()
397 }
398 SlabDepth::Reach(reach) => reach,
399 };
400 if !(reach.is_finite() && reach > 0.0) {
401 return Err(GeomError::InvalidInput(format!(
402 "bounded half-space slab depth must be positive and finite, got {reach}"
403 )));
404 }
405 // Along the axis, the far cap stands `reach` from the plane.
406 let step = toward * (reach / lean.abs());
407
408 // `loft` winds its walls and caps outward when the stations run along
409 // the face normal of a counter-clockwise ring, so a clockwise ring is
410 // read reversed (the same region), and the stations run near to far
411 // when that face normal points to the kept side, far to near when the
412 // frame (or the side) turns it over. Swapping stations rather than
413 // mirroring an axis keeps the boundary's footprint untouched.
414 let rings = counter_clockwise(boundary);
415 let near = Frame { origin, x, y };
416 let far = Frame {
417 origin: origin + step,
418 x,
419 y,
420 };
421 let ordered = if face.dot(toward) > 0.0 {
422 [near, far]
423 } else {
424 [far, near]
425 };
426 let stations: Vec<Station> = ordered
427 .iter()
428 .map(|f| loft::place(&rings, |p| loft::at(f, p)))
429 .collect();
430 loft::loft(&rings, &stations, false)
431}
432
433/// A placement's `x` and `y` columns and the signed volume its three
434/// columns span, positive for a right-handed frame; refused by name when it
435/// is not finite or its columns do not span space within the angular
436/// tolerance.
437fn placement_axes(placement: Transform3, tolerance: Tolerance) -> GeomResult<(Vec3, Vec3, Scalar)> {
438 let [x, y, z] = [
439 placement.matrix3.x_axis,
440 placement.matrix3.y_axis,
441 placement.matrix3.z_axis,
442 ];
443 if !(placement.translation.is_finite() && x.is_finite() && y.is_finite() && z.is_finite()) {
444 return Err(GeomError::InvalidInput(
445 "bounded half-space placement is not finite".to_owned(),
446 ));
447 }
448 let volume = x.cross(y).dot(z);
449 if volume.abs() <= unit_floor(tolerance) * x.length() * y.length() * z.length() {
450 return Err(GeomError::InvalidInput(
451 "bounded half-space placement is degenerate: its axes are parallel or zero".to_owned(),
452 ));
453 }
454 Ok((x, y, volume))
455}
456
457/// The plane's unit normal, refused when it has none.
458fn plane_normal(plane: Plane3) -> GeomResult<Vec3> {
459 let normal = plane.normal.normalize_or_zero();
460 if normal == Vec3::ZERO || !normal.is_finite() {
461 return Err(GeomError::InvalidInput(
462 "half-space boundary plane needs a non-zero normal".to_owned(),
463 ));
464 }
465 Ok(normal)
466}
467
468/// The smallest sine (or cosine) a direction test accepts: the angular
469/// tolerance, floored at a few ulps so `Tolerance::ZERO` still refuses an
470/// exactly degenerate frame.
471fn unit_floor(tolerance: Tolerance) -> Scalar {
472 tolerance.angular().max(Scalar::EPSILON * 8.0)
473}
474
475/// `rings` with its outer ring counter-clockwise, every ring reversed when
476/// it was not: the same region, wound as `loft` expects.
477fn counter_clockwise(rings: &Rings) -> std::borrow::Cow<'_, Rings> {
478 let twice_area: Scalar = (0..rings.outer.len())
479 .map(|i| {
480 let (a, b) = (rings.outer[i], rings.outer[(i + 1) % rings.outer.len()]);
481 a.perp_dot(b)
482 })
483 .sum();
484 if twice_area >= 0.0 {
485 return std::borrow::Cow::Borrowed(rings);
486 }
487 let reversed = |ring: &Vec<Point2>| ring.iter().rev().copied().collect();
488 std::borrow::Cow::Owned(Rings {
489 outer: reversed(&rings.outer),
490 holes: rings.holes.iter().map(reversed).collect(),
491 })
492}
493
494pub fn for_subject(
495 subject: &TriMesh,
496 half_space: HalfSpace,
497 tolerance: Tolerance,
498) -> GeomResult<TriMesh> {
499 let normal = half_space.boundary.normal.normalize_or_zero();
500 if normal == Vec3::ZERO || subject.positions.is_empty() {
501 return Err(GeomError::InvalidInput(
502 "half-space boolean needs finite subject bounds".into(),
503 ));
504 }
505 let reference = if normal.x.abs() < 0.9 {
506 Vec3::X
507 } else {
508 Vec3::Y
509 };
510 let frame = Frame::from_reference(half_space.boundary.origin, normal, reference)?;
511 let mut min = Point2::splat(Scalar::INFINITY);
512 let mut max = Point2::splat(Scalar::NEG_INFINITY);
513 let side = if half_space.agreement { 1.0 } else { -1.0 };
514 let mut selected_depth: Scalar = 0.0;
515 for &point in &subject.positions {
516 if !point.is_finite() {
517 return Err(GeomError::InvalidInput(
518 "half-space subject contains non-finite points".into(),
519 ));
520 }
521 let delta = point - half_space.boundary.origin;
522 let uv = Point2::new(delta.dot(frame.x), delta.dot(frame.y));
523 min = min.min(uv);
524 max = max.max(uv);
525 selected_depth = selected_depth.max(delta.dot(normal) * side);
526 }
527 let span = (max - min).max_element().max(tolerance.linear());
528 let pad = span * 0.1 + tolerance.linear();
529 min -= Point2::splat(pad);
530 max += Point2::splat(pad);
531 let boundary = Rings {
532 outer: vec![
533 min,
534 Point2::new(max.x, min.y),
535 max,
536 Point2::new(min.x, max.y),
537 ],
538 holes: Vec::new(),
539 };
540 let extent = min.abs().max(max.abs()).max_element();
541 let depth = selected_depth + pad;
542 let factor = depth / extent;
543 let margin = ClipMargin::new(factor).ok_or_else(|| {
544 GeomError::InvalidInput("half-space subject has no finite clipping extent".into())
545 })?;
546 bounded_half_space(
547 &boundary,
548 half_space.boundary,
549 half_space.agreement,
550 margin,
551 tolerance,
552 )
553}