Skip to main content

axiolid_model/
solid_operation.rs

1//! Solid construction relationships and CSG instructions.
2
3use axiolid_core::{BooleanOperator, Point3, Scalar, Transform3, Vec3};
4
5use crate::{NodeId, SectionAtStation, StationFrame, StationedSection};
6
7/// Position of one section along a sectioned sweep.
8#[derive(Debug, Clone, Copy, PartialEq)]
9#[cfg_attr(
10    feature = "serde",
11    derive(serde::Serialize, serde::Deserialize),
12    serde(deny_unknown_fields)
13)]
14pub struct Section {
15    /// Profile node.
16    pub profile: NodeId,
17    /// Local placement of the profile.
18    pub placement: Transform3,
19}
20
21/// Relationship that constructs a solid from lower-level geometry.
22#[non_exhaustive]
23#[derive(Debug, Clone, PartialEq)]
24#[cfg_attr(
25    feature = "serde",
26    derive(serde::Serialize, serde::Deserialize),
27    serde(deny_unknown_fields)
28)]
29pub enum SolidOperation {
30    /// Linear extrusion of a profile.
31    Extrusion {
32        profile: NodeId,
33        direction: Vec3,
34        depth: Scalar,
35    },
36    /// Tapered linear extrusion between two profiles.
37    TaperedExtrusion {
38        start_profile: NodeId,
39        end_profile: NodeId,
40        direction: Vec3,
41        depth: Scalar,
42    },
43    /// Revolution of a profile.
44    Revolution {
45        profile: NodeId,
46        axis_origin: Point3,
47        axis_direction: Vec3,
48        angle: Scalar,
49    },
50    /// Tapered revolution between two profiles.
51    TaperedRevolution {
52        start_profile: NodeId,
53        end_profile: NodeId,
54        axis_origin: Point3,
55        axis_direction: Vec3,
56        angle: Scalar,
57    },
58    /// Disk swept along a directrix curve.
59    ///
60    /// `fillet_radius` rounds the corners where consecutive directrix segments
61    /// meet, and is meaningful only on a piecewise-linear directrix: a smooth
62    /// curve has no corners to round. `None` means sharp corners, which is
63    /// also the correct reading for any directrix that is already smooth.
64    ///
65    /// It is a property of the SWEEP, not of the disk: the disk stays circular
66    /// and it is the swept path whose corners are filleted. A consumer that
67    /// cannot round corners must refuse a `Some` rather than drop it, because
68    /// silently sharpening a pipe run produces geometry that builds, renders,
69    /// and is wrong.
70    SweptDisk {
71        directrix: NodeId,
72        radius: Scalar,
73        inner_radius: Option<Scalar>,
74        parameter_range: Option<(Scalar, Scalar)>,
75        /// Corner rounding radius; `None` means sharp corners.
76        fillet_radius: Option<Scalar>,
77    },
78    /// Profile swept along a directrix using a fixed reference direction.
79    ///
80    /// At each point of the directrix the profile's x axis is the reference
81    /// direction projected square to the tangent, its z axis is the tangent
82    /// and its y axis is z cross x. The directrix must be tangent
83    /// continuous (a corner has no tangent), and the reference must never
84    /// be parallel to the tangent, or the profile has no orientation there.
85    /// `parameter_range` selects the part of the directrix swept: a curve's
86    /// own parameter, and on a composite of curves the arc length.
87    ///
88    /// The reference compiler proves its chord budget along a chain of
89    /// segments and circular arcs, open or closed, when the reference is
90    /// normal to the plane of every arc or lies in it, or leans off by a
91    /// twist of the frame the walls' half of the budget pays for (up to an
92    /// eighth of the budget at the profile's furthest vertex); it sweeps an
93    /// arc oblique to the reference beyond that as sampled and reports it
94    /// unbounded. It
95    /// refuses a corner, a reference parallel to the tangent, a gap between
96    /// pieces and a profile that reaches an arc's axis by name. A closed
97    /// chain it proves is swept without caps.
98    FixedReferenceSweep {
99        profile: NodeId,
100        directrix: NodeId,
101        reference_direction: Vec3,
102        parameter_range: Option<(Scalar, Scalar)>,
103    },
104    /// Profile swept along a directrix constrained by a reference surface.
105    SurfaceCurveSweep {
106        profile: NodeId,
107        directrix: NodeId,
108        reference_surface: NodeId,
109        parameter_range: Option<(Scalar, Scalar)>,
110    },
111    /// Sections interpolated along a spine.
112    SectionedSpine {
113        spine: NodeId,
114        sections: Vec<Section>,
115    },
116    /// General CSG binary operation.
117    Boolean {
118        left: NodeId,
119        right: NodeId,
120        operator: BooleanOperator,
121    },
122    /// Unbounded half-space clipped by a finite boundary geometry.
123    BoundedHalfSpace {
124        half_space: NodeId,
125        /// The boundary in `placement`'s XY plane: a closed 2D curve, or a
126        /// `Profile` node whose region it is (#277). A 3D curve, or any
127        /// other node, is refused when the graph is built.
128        ///
129        /// A profile is how a boundary of line and circular-arc segments
130        /// is stated: a [`Profile::Contour`](axiolid_profile::Profile)
131        /// keeps each arc an exact `Circle2` span, which a polyline
132        /// cannot. The half-space is then its side of the plane within the
133        /// infinite prism of that region, swept along the plane normal, so
134        /// an arc segment bounds it by a right circular cylinder. Which
135        /// profile families a compiler accepts is the compiler's to say;
136        /// it refuses the others by name.
137        boundary: NodeId,
138        /// The boundary's own frame, independent of the clip plane.
139        ///
140        /// The boundary profile is authored in this frame, so its rotation
141        /// orients the profile itself rather than the finished solid. A
142        /// source format may place the boundary independently of the base
143        /// surface. The whole frame is read (#322): its `x` projected into
144        /// the plane fixes the boundary's rotation about the clip normal, so
145        /// a component along the normal is dropped rather than tilting the
146        /// profile out of its own plane, while the frame's own shape -- the
147        /// lengths of its `x` and `y`, the lean of `y` against `x`, and its
148        /// handedness -- is kept: `y` is laid to the side of `normal x x`,
149        /// the other side for a left-handed frame. Only a degenerate frame,
150        /// whose axes are parallel or zero, is refused.
151        placement: Transform3,
152    },
153    /// Closed profiles standing at stations along a directrix (#241).
154    ///
155    /// Each profile is placed in the directrix's `frame` at its station
156    /// (profile `x` to the lateral axis, `y` to up, normal along the
157    /// tangent; see [`crate::station`]). Between two stations the profiles
158    /// are matched vertex for vertex, by ring and index, and interpolated
159    /// linearly in distance, as are the offsets, so every section must
160    /// flatten to the same ring structure. At least two sections, strictly
161    /// increasing in distance, are required.
162    StationedSpine {
163        directrix: NodeId,
164        sections: Vec<StationedSection>,
165        frame: StationFrame,
166    },
167    /// Closed profiles standing at stations along a directrix, each
168    /// optionally tagged and explicitly oriented (#246): the general form
169    /// of [`SolidOperation::StationedSpine`].
170    ///
171    /// Each profile is placed as in a `StationedSpine`, its plane turned by
172    /// the section's orientation, and interpolated linearly in distance
173    /// with the next. Untagged sections are matched by ring and index;
174    /// tagged ones by tag, every section carrying the same tags (see
175    /// [`crate::station`]). At least two sections, strictly increasing in
176    /// distance, all tagged or none, are required.
177    SectionsAtStations {
178        directrix: NodeId,
179        sections: Vec<SectionAtStation>,
180        frame: StationFrame,
181    },
182    // Added after every earlier variant, so their implicit discriminants
183    // stay as they were (#321).
184    /// A half-space bounded by the prism of a boundary along its
185    /// placement's `z` (#321): the side of the plane that `half_space`
186    /// keeps, within the lines through the boundary's region parallel to
187    /// `placement`'s `z`, infinite both ways and at any angle to the plane.
188    ///
189    /// This is how IFC's `IfcPolygonalBoundedHalfSpace` is bounded, by its
190    /// `Position`: a vertical prism under a sloped roof plane, the common
191    /// gable clip, which [`SolidOperation::BoundedHalfSpace`] (its prism
192    /// always along the plane normal) cannot state. The boundary's point
193    /// `(u, v)` lies at `placement * (u, v, 0)`, the frame read as written:
194    /// its scale, shear and handedness all shape the prism. Only the
195    /// direction of `z` counts, not its length or sign. A `z` within the
196    /// angular tolerance of the plane never crosses it, and a placement
197    /// whose axes are parallel or zero has no prism: both are refused by
198    /// name when compiled.
199    BoundedHalfSpaceAlongZ {
200        half_space: NodeId,
201        /// The boundary in `placement`'s XY plane, as for
202        /// [`SolidOperation::BoundedHalfSpace`]: a closed 2D curve, or a
203        /// `Profile` node whose region it is. An arc segment bounds the
204        /// half-space by the cylinder of lines through it along `z`.
205        boundary: NodeId,
206        /// The boundary's frame, its `z` the prism's axis.
207        placement: Transform3,
208    },
209}
210
211impl SolidOperation {
212    pub(crate) fn references(&self, out: &mut Vec<NodeId>) {
213        match self {
214            Self::Extrusion { profile, .. } | Self::Revolution { profile, .. } => {
215                out.push(*profile)
216            }
217            Self::TaperedExtrusion {
218                start_profile,
219                end_profile,
220                ..
221            }
222            | Self::TaperedRevolution {
223                start_profile,
224                end_profile,
225                ..
226            } => out.extend([*start_profile, *end_profile]),
227            Self::SweptDisk { directrix, .. } => out.push(*directrix),
228            Self::FixedReferenceSweep {
229                profile, directrix, ..
230            } => out.extend([*profile, *directrix]),
231            Self::SurfaceCurveSweep {
232                profile,
233                directrix,
234                reference_surface,
235                ..
236            } => out.extend([*profile, *directrix, *reference_surface]),
237            Self::SectionedSpine { spine, sections } => {
238                out.push(*spine);
239                out.extend(sections.iter().map(|section| section.profile));
240            }
241            Self::StationedSpine {
242                directrix,
243                sections,
244                ..
245            } => {
246                out.push(*directrix);
247                out.extend(sections.iter().map(|section| section.profile));
248            }
249            Self::SectionsAtStations {
250                directrix,
251                sections,
252                ..
253            } => {
254                out.push(*directrix);
255                out.extend(sections.iter().map(|section| section.profile));
256            }
257            Self::Boolean { left, right, .. } => out.extend([*left, *right]),
258            Self::BoundedHalfSpace {
259                half_space,
260                boundary,
261                ..
262            }
263            | Self::BoundedHalfSpaceAlongZ {
264                half_space,
265                boundary,
266                ..
267            } => out.extend([*half_space, *boundary]),
268        }
269    }
270}
271
272#[cfg(test)]
273mod tests {
274    use super::*;
275    use crate::graph::GeometryGraphBuilder;
276    use crate::node::GeometryNode;
277    use axiolid_core::Vec3;
278
279    fn directrix(builder: &mut GeometryGraphBuilder) -> NodeId {
280        builder.push(GeometryNode::Point3(Vec3::ZERO)).unwrap()
281    }
282
283    /// A fillet radius does not add a node reference.
284    ///
285    /// `references` drives graph traversal and validation, so a scalar that
286    /// leaked into it would be read as a `NodeId` and either dangle or alias
287    /// an unrelated node. The fillet is geometry data, not a reference.
288    #[test]
289    fn a_fillet_radius_is_not_a_node_reference() {
290        let mut builder = GeometryGraphBuilder::default();
291        let curve = directrix(&mut builder);
292
293        let sharp = SolidOperation::SweptDisk {
294            directrix: curve,
295            radius: 0.05,
296            inner_radius: None,
297            parameter_range: None,
298            fillet_radius: None,
299        };
300        let rounded = SolidOperation::SweptDisk {
301            directrix: curve,
302            radius: 0.05,
303            inner_radius: None,
304            parameter_range: None,
305            fillet_radius: Some(0.09),
306        };
307
308        let mut sharp_refs = Vec::new();
309        sharp.references(&mut sharp_refs);
310        let mut rounded_refs = Vec::new();
311        rounded.references(&mut rounded_refs);
312
313        assert_eq!(sharp_refs, vec![curve]);
314        assert_eq!(
315            sharp_refs, rounded_refs,
316            "a fillet changes geometry, not the reference graph"
317        );
318    }
319
320    /// Sharp and rounded sweeps are distinguishable.
321    ///
322    /// This is the whole reason the field exists. If they compared equal, a
323    /// consumer could not tell a filleted pipe run from a mitred one, and
324    /// dropping the fillet would be undetectable downstream.
325    #[test]
326    fn a_fillet_radius_distinguishes_two_otherwise_identical_sweeps() {
327        let mut builder = GeometryGraphBuilder::default();
328        let curve = directrix(&mut builder);
329
330        let common = |fillet| SolidOperation::SweptDisk {
331            directrix: curve,
332            radius: 0.05,
333            inner_radius: Some(0.04),
334            parameter_range: Some((0.0, 2.0)),
335            fillet_radius: fillet,
336        };
337        assert_ne!(common(None), common(Some(0.09)));
338        assert_eq!(common(Some(0.09)), common(Some(0.09)));
339    }
340}