Skip to main content

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}