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)]
9pub struct Section {
10    /// Profile node.
11    pub profile: NodeId,
12    /// Local placement of the profile.
13    pub placement: Transform3,
14}
15
16/// Relationship that constructs a solid from lower-level geometry.
17#[non_exhaustive]
18#[derive(Debug, Clone, PartialEq)]
19pub enum SolidOperation {
20    /// Linear extrusion of a profile.
21    Extrusion {
22        profile: NodeId,
23        direction: Vec3,
24        depth: Scalar,
25    },
26    /// Tapered linear extrusion between two profiles.
27    TaperedExtrusion {
28        start_profile: NodeId,
29        end_profile: NodeId,
30        direction: Vec3,
31        depth: Scalar,
32    },
33    /// Revolution of a profile.
34    Revolution {
35        profile: NodeId,
36        axis_origin: Point3,
37        axis_direction: Vec3,
38        angle: Scalar,
39    },
40    /// Tapered revolution between two profiles.
41    TaperedRevolution {
42        start_profile: NodeId,
43        end_profile: NodeId,
44        axis_origin: Point3,
45        axis_direction: Vec3,
46        angle: Scalar,
47    },
48    /// Disk swept along a directrix curve.
49    ///
50    /// `fillet_radius` rounds the corners where consecutive directrix segments
51    /// meet, and is meaningful only on a piecewise-linear directrix: a smooth
52    /// curve has no corners to round. `None` means sharp corners, which is
53    /// also the correct reading for any directrix that is already smooth.
54    ///
55    /// It is a property of the SWEEP, not of the disk: the disk stays circular
56    /// and it is the swept path whose corners are filleted. A consumer that
57    /// cannot round corners must refuse a `Some` rather than drop it, because
58    /// silently sharpening a pipe run produces geometry that builds, renders,
59    /// and is wrong.
60    SweptDisk {
61        directrix: NodeId,
62        radius: Scalar,
63        inner_radius: Option<Scalar>,
64        parameter_range: Option<(Scalar, Scalar)>,
65        /// Corner rounding radius; `None` means sharp corners.
66        fillet_radius: Option<Scalar>,
67    },
68    /// Profile swept along a directrix using a fixed reference direction.
69    FixedReferenceSweep {
70        profile: NodeId,
71        directrix: NodeId,
72        reference_direction: Vec3,
73        parameter_range: Option<(Scalar, Scalar)>,
74    },
75    /// Profile swept along a directrix constrained by a reference surface.
76    SurfaceCurveSweep {
77        profile: NodeId,
78        directrix: NodeId,
79        reference_surface: NodeId,
80        parameter_range: Option<(Scalar, Scalar)>,
81    },
82    /// Sections interpolated along a spine.
83    SectionedSpine {
84        spine: NodeId,
85        sections: Vec<Section>,
86    },
87    /// General CSG binary operation.
88    Boolean {
89        left: NodeId,
90        right: NodeId,
91        operator: BooleanOperator,
92    },
93    /// Unbounded half-space clipped by a finite boundary geometry.
94    BoundedHalfSpace {
95        half_space: NodeId,
96        /// A closed 2D curve in `placement`'s XY plane. A 3D curve is
97        /// refused when the graph is built.
98        boundary: NodeId,
99        /// The boundary's own frame, independent of the clip plane.
100        ///
101        /// The boundary profile is authored in this frame, so its rotation
102        /// orients the profile itself rather than the finished solid. A
103        /// source format may place the boundary independently of the base
104        /// surface, and only the rotation about the clip normal is
105        /// meaningful: the compiler projects this frame's axes into the
106        /// plane, so a component along the normal is dropped rather than
107        /// tilting the profile out of its own plane.
108        placement: Transform3,
109    },
110    /// Closed profiles standing at stations along a directrix (#241).
111    ///
112    /// Each profile is placed in the directrix's `frame` at its station
113    /// (profile `x` to the lateral axis, `y` to up, normal along the
114    /// tangent; see [`crate::station`]). Between two stations the profiles
115    /// are matched vertex for vertex, by ring and index, and interpolated
116    /// linearly in distance, as are the offsets, so every section must
117    /// flatten to the same ring structure. At least two sections, strictly
118    /// increasing in distance, are required.
119    StationedSpine {
120        directrix: NodeId,
121        sections: Vec<StationedSection>,
122        frame: StationFrame,
123    },
124    /// Closed profiles standing at stations along a directrix, each
125    /// optionally tagged and explicitly oriented (#246): the general form
126    /// of [`SolidOperation::StationedSpine`].
127    ///
128    /// Each profile is placed as in a `StationedSpine`, its plane turned by
129    /// the section's orientation, and interpolated linearly in distance
130    /// with the next. Untagged sections are matched by ring and index;
131    /// tagged ones by tag, every section carrying the same tags (see
132    /// [`crate::station`]). At least two sections, strictly increasing in
133    /// distance, all tagged or none, are required.
134    SectionsAtStations {
135        directrix: NodeId,
136        sections: Vec<SectionAtStation>,
137        frame: StationFrame,
138    },
139}
140
141impl SolidOperation {
142    pub(crate) fn references(&self, out: &mut Vec<NodeId>) {
143        match self {
144            Self::Extrusion { profile, .. } | Self::Revolution { profile, .. } => {
145                out.push(*profile)
146            }
147            Self::TaperedExtrusion {
148                start_profile,
149                end_profile,
150                ..
151            }
152            | Self::TaperedRevolution {
153                start_profile,
154                end_profile,
155                ..
156            } => out.extend([*start_profile, *end_profile]),
157            Self::SweptDisk { directrix, .. } => out.push(*directrix),
158            Self::FixedReferenceSweep {
159                profile, directrix, ..
160            } => out.extend([*profile, *directrix]),
161            Self::SurfaceCurveSweep {
162                profile,
163                directrix,
164                reference_surface,
165                ..
166            } => out.extend([*profile, *directrix, *reference_surface]),
167            Self::SectionedSpine { spine, sections } => {
168                out.push(*spine);
169                out.extend(sections.iter().map(|section| section.profile));
170            }
171            Self::StationedSpine {
172                directrix,
173                sections,
174                ..
175            } => {
176                out.push(*directrix);
177                out.extend(sections.iter().map(|section| section.profile));
178            }
179            Self::SectionsAtStations {
180                directrix,
181                sections,
182                ..
183            } => {
184                out.push(*directrix);
185                out.extend(sections.iter().map(|section| section.profile));
186            }
187            Self::Boolean { left, right, .. } => out.extend([*left, *right]),
188            Self::BoundedHalfSpace {
189                half_space,
190                boundary,
191                ..
192            } => out.extend([*half_space, *boundary]),
193        }
194    }
195}
196
197#[cfg(test)]
198mod tests {
199    use super::*;
200    use crate::graph::GeometryGraphBuilder;
201    use crate::node::GeometryNode;
202    use axiolid_core::Vec3;
203
204    fn directrix(builder: &mut GeometryGraphBuilder) -> NodeId {
205        builder.push(GeometryNode::Point3(Vec3::ZERO)).unwrap()
206    }
207
208    /// A fillet radius does not add a node reference.
209    ///
210    /// `references` drives graph traversal and validation, so a scalar that
211    /// leaked into it would be read as a `NodeId` and either dangle or alias
212    /// an unrelated node. The fillet is geometry data, not a reference.
213    #[test]
214    fn a_fillet_radius_is_not_a_node_reference() {
215        let mut builder = GeometryGraphBuilder::default();
216        let curve = directrix(&mut builder);
217
218        let sharp = SolidOperation::SweptDisk {
219            directrix: curve,
220            radius: 0.05,
221            inner_radius: None,
222            parameter_range: None,
223            fillet_radius: None,
224        };
225        let rounded = SolidOperation::SweptDisk {
226            directrix: curve,
227            radius: 0.05,
228            inner_radius: None,
229            parameter_range: None,
230            fillet_radius: Some(0.09),
231        };
232
233        let mut sharp_refs = Vec::new();
234        sharp.references(&mut sharp_refs);
235        let mut rounded_refs = Vec::new();
236        rounded.references(&mut rounded_refs);
237
238        assert_eq!(sharp_refs, vec![curve]);
239        assert_eq!(
240            sharp_refs, rounded_refs,
241            "a fillet changes geometry, not the reference graph"
242        );
243    }
244
245    /// Sharp and rounded sweeps are distinguishable.
246    ///
247    /// This is the whole reason the field exists. If they compared equal, a
248    /// consumer could not tell a filleted pipe run from a mitred one, and
249    /// dropping the fillet would be undetectable downstream.
250    #[test]
251    fn a_fillet_radius_distinguishes_two_otherwise_identical_sweeps() {
252        let mut builder = GeometryGraphBuilder::default();
253        let curve = directrix(&mut builder);
254
255        let common = |fillet| SolidOperation::SweptDisk {
256            directrix: curve,
257            radius: 0.05,
258            inner_radius: Some(0.04),
259            parameter_range: Some((0.0, 2.0)),
260            fillet_radius: fillet,
261        };
262        assert_ne!(common(None), common(Some(0.09)));
263        assert_eq!(common(Some(0.09)), common(Some(0.09)));
264    }
265}