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