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}