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;
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}
111
112impl SolidOperation {
113    pub(crate) fn references(&self, out: &mut Vec<NodeId>) {
114        match self {
115            Self::Extrusion { profile, .. } | Self::Revolution { profile, .. } => {
116                out.push(*profile)
117            }
118            Self::TaperedExtrusion {
119                start_profile,
120                end_profile,
121                ..
122            }
123            | Self::TaperedRevolution {
124                start_profile,
125                end_profile,
126                ..
127            } => out.extend([*start_profile, *end_profile]),
128            Self::SweptDisk { directrix, .. } => out.push(*directrix),
129            Self::FixedReferenceSweep {
130                profile, directrix, ..
131            } => out.extend([*profile, *directrix]),
132            Self::SurfaceCurveSweep {
133                profile,
134                directrix,
135                reference_surface,
136                ..
137            } => out.extend([*profile, *directrix, *reference_surface]),
138            Self::SectionedSpine { spine, sections } => {
139                out.push(*spine);
140                out.extend(sections.iter().map(|section| section.profile));
141            }
142            Self::Boolean { left, right, .. } => out.extend([*left, *right]),
143            Self::BoundedHalfSpace {
144                half_space,
145                boundary,
146                ..
147            } => out.extend([*half_space, *boundary]),
148        }
149    }
150}
151
152#[cfg(test)]
153mod tests {
154    use super::*;
155    use crate::graph::GeometryGraphBuilder;
156    use crate::node::GeometryNode;
157    use axiolid_core::Vec3;
158
159    fn directrix(builder: &mut GeometryGraphBuilder) -> NodeId {
160        builder.push(GeometryNode::Point3(Vec3::ZERO)).unwrap()
161    }
162
163    /// A fillet radius does not add a node reference.
164    ///
165    /// `references` drives graph traversal and validation, so a scalar that
166    /// leaked into it would be read as a `NodeId` and either dangle or alias
167    /// an unrelated node. The fillet is geometry data, not a reference.
168    #[test]
169    fn a_fillet_radius_is_not_a_node_reference() {
170        let mut builder = GeometryGraphBuilder::default();
171        let curve = directrix(&mut builder);
172
173        let sharp = SolidOperation::SweptDisk {
174            directrix: curve,
175            radius: 0.05,
176            inner_radius: None,
177            parameter_range: None,
178            fillet_radius: None,
179        };
180        let rounded = SolidOperation::SweptDisk {
181            directrix: curve,
182            radius: 0.05,
183            inner_radius: None,
184            parameter_range: None,
185            fillet_radius: Some(0.09),
186        };
187
188        let mut sharp_refs = Vec::new();
189        sharp.references(&mut sharp_refs);
190        let mut rounded_refs = Vec::new();
191        rounded.references(&mut rounded_refs);
192
193        assert_eq!(sharp_refs, vec![curve]);
194        assert_eq!(
195            sharp_refs, rounded_refs,
196            "a fillet changes geometry, not the reference graph"
197        );
198    }
199
200    /// Sharp and rounded sweeps are distinguishable.
201    ///
202    /// This is the whole reason the field exists. If they compared equal, a
203    /// consumer could not tell a filleted pipe run from a mitred one, and
204    /// dropping the fillet would be undetectable downstream.
205    #[test]
206    fn a_fillet_radius_distinguishes_two_otherwise_identical_sweeps() {
207        let mut builder = GeometryGraphBuilder::default();
208        let curve = directrix(&mut builder);
209
210        let common = |fillet| SolidOperation::SweptDisk {
211            directrix: curve,
212            radius: 0.05,
213            inner_radius: Some(0.04),
214            parameter_range: Some((0.0, 2.0)),
215            fillet_radius: fillet,
216        };
217        assert_ne!(common(None), common(Some(0.09)));
218        assert_eq!(common(Some(0.09)), common(Some(0.09)));
219    }
220}