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}