Another project
1use crate::KernelError;
2use bone_types::dimensioned_serde;
3use bone_types::{
4 Angle, BodyId, BrepFaceId, BrepVertexId, FeatureId, Length, PositiveLength, SketchId, UnitVec3,
5 degree, radian,
6};
7use serde::{Deserialize, Serialize};
8
9#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
10pub enum ExtrudeSense {
11 Forward,
12 Reverse,
13}
14
15#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
16pub enum PlaneRef {
17 DatumPlane(FeatureId),
18 PlanarFace(BrepFaceId),
19}
20
21#[derive(Copy, Clone, Debug, PartialEq, Serialize, Deserialize)]
22#[serde(deny_unknown_fields)]
23pub enum ExtrudeDirection {
24 Normal { sense: ExtrudeSense },
25 AlongAxis(UnitVec3),
26 BetweenReferences { from: PlaneRef, to: PlaneRef },
27}
28
29#[derive(Copy, Clone, Debug, PartialEq, Serialize, Deserialize)]
30#[serde(deny_unknown_fields)]
31pub enum ExtrudeEndCondition {
32 Blind {
33 depth: PositiveLength,
34 },
35 MidPlane {
36 depth: PositiveLength,
37 },
38 ThroughAll,
39 UpToNext,
40 UpToVertex {
41 vertex: BrepVertexId,
42 },
43 UpToSurface {
44 face: BrepFaceId,
45 },
46 OffsetFromSurface {
47 face: BrepFaceId,
48 #[serde(with = "dimensioned_serde::length_si")]
49 offset: Length,
50 },
51 UpToBody {
52 body: BodyId,
53 },
54}
55
56#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
57pub enum DraftDirection {
58 Inward,
59 Outward,
60}
61
62#[derive(Copy, Clone, Debug, PartialEq, Serialize, Deserialize)]
63#[serde(try_from = "f64", into = "f64")]
64pub struct DraftMagnitude(Angle);
65
66impl DraftMagnitude {
67 pub fn new(angle: Angle) -> Result<Self, KernelError> {
68 let degrees = angle.get::<degree>();
69 if (0.0..90.0).contains(°rees) {
70 Ok(Self(angle))
71 } else {
72 Err(KernelError::DraftAngleOutOfRange(degrees))
73 }
74 }
75
76 #[must_use]
77 pub fn get(self) -> Angle {
78 self.0
79 }
80}
81
82impl From<DraftMagnitude> for f64 {
83 fn from(value: DraftMagnitude) -> Self {
84 value.0.get::<radian>()
85 }
86}
87
88impl TryFrom<f64> for DraftMagnitude {
89 type Error = KernelError;
90
91 fn try_from(value: f64) -> Result<Self, Self::Error> {
92 Self::new(Angle::new::<radian>(value))
93 }
94}
95
96#[derive(Copy, Clone, Debug, PartialEq, Serialize, Deserialize)]
97#[serde(deny_unknown_fields)]
98pub struct DraftAngle {
99 angle: DraftMagnitude,
100 direction: DraftDirection,
101}
102
103impl DraftAngle {
104 #[must_use]
105 pub const fn new(angle: DraftMagnitude, direction: DraftDirection) -> Self {
106 Self { angle, direction }
107 }
108
109 #[must_use]
110 pub const fn angle(self) -> DraftMagnitude {
111 self.angle
112 }
113
114 #[must_use]
115 pub const fn direction(self) -> DraftDirection {
116 self.direction
117 }
118}
119
120#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
121pub enum ThinWallDirection {
122 Inward,
123 Outward,
124 MidPlane,
125}
126
127#[derive(Copy, Clone, Debug, PartialEq, Serialize, Deserialize)]
128#[serde(deny_unknown_fields)]
129pub struct ThinWall {
130 pub thickness: PositiveLength,
131 pub direction: ThinWallDirection,
132}
133
134#[derive(Copy, Clone, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
135pub enum MergeResult {
136 #[default]
137 Merge,
138 Separate,
139}
140
141#[derive(Copy, Clone, Debug, PartialEq, Serialize, Deserialize)]
142#[serde(deny_unknown_fields)]
143pub struct ExtrudeFeature {
144 pub sketch: SketchId,
145 pub direction: ExtrudeDirection,
146 pub end_condition: ExtrudeEndCondition,
147 #[serde(default)]
148 pub draft: Option<DraftAngle>,
149 #[serde(default)]
150 pub thin_wall: Option<ThinWall>,
151 #[serde(default)]
152 pub merge_result: MergeResult,
153}
154
155#[cfg(test)]
156mod tests {
157 use super::{
158 DraftAngle, DraftDirection, DraftMagnitude, ExtrudeDirection, ExtrudeEndCondition,
159 ExtrudeFeature, ExtrudeSense, MergeResult, ThinWall, ThinWallDirection,
160 };
161 use bone_types::{
162 Angle, BrepFaceId, Length, PositiveLength, SketchId, degree, millimeter, radian,
163 };
164 use slotmap::Key;
165 use uom::si::length::meter;
166
167 fn pos_mm(value: f64) -> PositiveLength {
168 let Ok(length) = PositiveLength::new(Length::new::<millimeter>(value)) else {
169 panic!("{value} mm is a positive length");
170 };
171 length
172 }
173
174 fn draft_deg(value: f64) -> DraftMagnitude {
175 let Ok(magnitude) = DraftMagnitude::new(Angle::new::<degree>(value)) else {
176 panic!("{value} deg is a valid draft angle");
177 };
178 magnitude
179 }
180
181 fn sample() -> ExtrudeFeature {
182 ExtrudeFeature {
183 sketch: SketchId::null(),
184 direction: ExtrudeDirection::Normal {
185 sense: ExtrudeSense::Forward,
186 },
187 end_condition: ExtrudeEndCondition::Blind {
188 depth: pos_mm(10.0),
189 },
190 draft: Some(DraftAngle::new(draft_deg(3.0), DraftDirection::Outward)),
191 thin_wall: Some(ThinWall {
192 thickness: pos_mm(2.0),
193 direction: ThinWallDirection::Outward,
194 }),
195 merge_result: MergeResult::default(),
196 }
197 }
198
199 #[test]
200 fn extrude_feature_ron_round_trip() {
201 let feature = sample();
202 let Ok(text) = ron::to_string(&feature) else {
203 panic!("serialize extrude feature");
204 };
205 let Ok(back) = ron::from_str::<ExtrudeFeature>(&text) else {
206 panic!("deserialize extrude feature");
207 };
208 assert_eq!(feature, back);
209 }
210
211 #[test]
212 fn blind_depth_serializes_in_meters() {
213 let blind = ExtrudeEndCondition::Blind {
214 depth: pos_mm(10.0),
215 };
216 let Ok(text) = ron::to_string(&blind) else {
217 panic!("serialize end condition");
218 };
219 let Ok(back) = ron::from_str::<ExtrudeEndCondition>(&text) else {
220 panic!("deserialize end condition");
221 };
222 let ExtrudeEndCondition::Blind { depth } = back else {
223 panic!("round-trips back to a blind depth");
224 };
225 assert!((depth.get().get::<meter>() - 0.01).abs() < f64::EPSILON);
226 assert!(text.contains("0.01"));
227 }
228
229 #[test]
230 fn end_condition_rejects_unknown_field() {
231 let blind = ExtrudeEndCondition::Blind { depth: pos_mm(5.0) };
232 let Ok(text) = ron::to_string(&blind) else {
233 panic!("serialize end condition");
234 };
235 let Some(idx) = text.find("Blind(") else {
236 panic!("expected Blind variant in {text}");
237 };
238 let mut munged = text.clone();
239 munged.insert_str(idx + "Blind(".len(), "bogus:42,");
240 assert!(ron::from_str::<ExtrudeEndCondition>(&munged).is_err());
241 }
242
243 #[test]
244 fn blind_rejects_non_positive_depth() {
245 assert!(ron::from_str::<ExtrudeEndCondition>("Blind(depth:0.0)").is_err());
246 assert!(ron::from_str::<ExtrudeEndCondition>("Blind(depth:-1.0)").is_err());
247 assert!(ron::from_str::<ExtrudeEndCondition>("Blind(depth:inf)").is_err());
248 assert!(ron::from_str::<ExtrudeEndCondition>("Blind(depth:-inf)").is_err());
249 assert!(ron::from_str::<ExtrudeEndCondition>("Blind(depth:NaN)").is_err());
250 }
251
252 #[test]
253 fn feature_fills_defaults() {
254 let Ok(sketch) = ron::to_string(&SketchId::null()) else {
255 panic!("serialize sketch id");
256 };
257 let text = format!(
258 "(sketch:{sketch},direction:Normal(sense:Forward),end_condition:Blind(depth:0.01))"
259 );
260 let Ok(feature) = ron::from_str::<ExtrudeFeature>(&text) else {
261 panic!("deserialize feature with a defaulted tail in {text}");
262 };
263 assert_eq!(feature.draft, None);
264 assert_eq!(feature.thin_wall, None);
265 assert_eq!(feature.merge_result, MergeResult::Merge);
266 }
267
268 #[test]
269 fn draft_angle_serializes_in_radians() {
270 let Ok(magnitude) = DraftMagnitude::new(Angle::new::<radian>(0.5)) else {
271 panic!("0.5 rad is a valid draft angle");
272 };
273 let draft = DraftAngle::new(magnitude, DraftDirection::Inward);
274 let Ok(text) = ron::to_string(&draft) else {
275 panic!("serialize draft angle");
276 };
277 let Ok(back) = ron::from_str::<DraftAngle>(&text) else {
278 panic!("deserialize draft angle");
279 };
280 assert_eq!(draft, back);
281 assert!((back.angle().get().get::<radian>() - 0.5).abs() < f64::EPSILON);
282 assert_eq!(back.direction(), DraftDirection::Inward);
283 assert!(text.contains("0.5"));
284 }
285
286 #[test]
287 fn draft_magnitude_rejects_out_of_range() {
288 assert!(DraftMagnitude::new(Angle::new::<degree>(0.0)).is_ok());
289 assert!(DraftMagnitude::new(Angle::new::<degree>(45.0)).is_ok());
290 assert!(DraftMagnitude::new(Angle::new::<degree>(-5.0)).is_err());
291 assert!(DraftMagnitude::new(Angle::new::<degree>(90.0)).is_err());
292 assert!(DraftMagnitude::new(Angle::new::<degree>(200.0)).is_err());
293 assert!(DraftMagnitude::new(Angle::new::<radian>(f64::INFINITY)).is_err());
294 assert!(DraftMagnitude::new(Angle::new::<radian>(f64::NAN)).is_err());
295 }
296
297 #[test]
298 fn draft_angle_deserialize_rejects_out_of_range() {
299 assert!(ron::from_str::<DraftAngle>("(angle:0.5,direction:Outward)").is_ok());
300 assert!(ron::from_str::<DraftAngle>("(angle:3.5,direction:Outward)").is_err());
301 }
302
303 #[test]
304 fn offset_from_surface_allows_negative_offset() {
305 let condition = ExtrudeEndCondition::OffsetFromSurface {
306 face: BrepFaceId::null(),
307 offset: Length::new::<meter>(-0.005),
308 };
309 let Ok(text) = ron::to_string(&condition) else {
310 panic!("serialize offset end condition");
311 };
312 let Ok(back) = ron::from_str::<ExtrudeEndCondition>(&text) else {
313 panic!("deserialize offset end condition");
314 };
315 assert_eq!(condition, back);
316 assert!(text.contains("-0.005"));
317 }
318}