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