Another project
0

Configure Feed

Select the types of activity you want to include in your feed.

feat(types): extrude params

Lewis: May this revision serve well! <lu5a@proton.me>

author
Lewis
date (May 28, 2026, 10:59 PM +0300) commit 7cb98fda parent 378abee7 change-id mksvlwrl
+398 -2
+2
Cargo.lock
··· 433 433 dependencies = [ 434 434 "bone-types", 435 435 "insta", 436 + "ron", 437 + "serde", 436 438 "slotmap", 437 439 "thiserror 2.0.18", 438 440 "truck-modeling",
+2
crates/bone-kernel/Cargo.toml
··· 7 7 8 8 [dependencies] 9 9 bone-types = { workspace = true } 10 + serde = { workspace = true } 10 11 slotmap = { workspace = true } 11 12 thiserror = { workspace = true } 12 13 truck-modeling = "=0.6.0" ··· 14 15 15 16 [dev-dependencies] 16 17 insta = { workspace = true } 18 + ron = { workspace = true } 17 19 18 20 [lints] 19 21 workspace = true
+318
crates/bone-kernel/src/extrude.rs
··· 1 + use crate::KernelError; 2 + use bone_types::dimensioned_serde; 3 + use bone_types::{ 4 + Angle, BodyId, BrepFaceId, BrepVertexId, FeatureId, Length, PositiveLength, SketchId, UnitVec3, 5 + degree, radian, 6 + }; 7 + use serde::{Deserialize, Serialize}; 8 + 9 + #[derive(Copy, Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] 10 + pub enum ExtrudeSense { 11 + Forward, 12 + Reverse, 13 + } 14 + 15 + #[derive(Copy, Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] 16 + pub enum PlaneRef { 17 + DatumPlane(FeatureId), 18 + PlanarFace(BrepFaceId), 19 + } 20 + 21 + #[derive(Copy, Clone, Debug, PartialEq, Serialize, Deserialize)] 22 + #[serde(deny_unknown_fields)] 23 + pub 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)] 31 + pub 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)] 57 + pub enum DraftDirection { 58 + Inward, 59 + Outward, 60 + } 61 + 62 + #[derive(Copy, Clone, Debug, PartialEq, Serialize, Deserialize)] 63 + #[serde(try_from = "f64", into = "f64")] 64 + pub struct DraftMagnitude(Angle); 65 + 66 + impl DraftMagnitude { 67 + pub fn new(angle: Angle) -> Result<Self, KernelError> { 68 + let degrees = angle.get::<degree>(); 69 + if (0.0..90.0).contains(&degrees) { 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 + 82 + impl From<DraftMagnitude> for f64 { 83 + fn from(value: DraftMagnitude) -> Self { 84 + value.0.get::<radian>() 85 + } 86 + } 87 + 88 + impl 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)] 98 + pub struct DraftAngle { 99 + angle: DraftMagnitude, 100 + direction: DraftDirection, 101 + } 102 + 103 + impl 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)] 121 + pub enum ThinWallDirection { 122 + Inward, 123 + Outward, 124 + MidPlane, 125 + } 126 + 127 + #[derive(Copy, Clone, Debug, PartialEq, Serialize, Deserialize)] 128 + #[serde(deny_unknown_fields)] 129 + pub struct ThinWall { 130 + pub thickness: PositiveLength, 131 + pub direction: ThinWallDirection, 132 + } 133 + 134 + #[derive(Copy, Clone, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] 135 + pub enum MergeResult { 136 + #[default] 137 + Merge, 138 + Separate, 139 + } 140 + 141 + #[derive(Copy, Clone, Debug, PartialEq, Serialize, Deserialize)] 142 + #[serde(deny_unknown_fields)] 143 + pub 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)] 156 + mod 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 + }
+7
crates/bone-kernel/src/lib.rs
··· 11 11 pub mod curve2; 12 12 pub mod curve3; 13 13 pub mod cylinder_surface; 14 + pub mod extrude; 14 15 pub mod intersect; 15 16 pub mod intersect3; 16 17 pub mod line2; ··· 34 35 pub use curve2::{Curve2, Curve2Kind}; 35 36 pub use curve3::{Curve3, Curve3Kind}; 36 37 pub use cylinder_surface::CylinderSurface; 38 + pub use extrude::{ 39 + DraftAngle, DraftDirection, DraftMagnitude, ExtrudeDirection, ExtrudeEndCondition, 40 + ExtrudeFeature, ExtrudeSense, MergeResult, PlaneRef, ThinWall, ThinWallDirection, 41 + }; 37 42 pub use intersect::{IntersectionSet, IntersectionSet2, intersect_curves}; 38 43 pub use intersect3::{IntersectionSet3, intersect_curves_3}; 39 44 pub use line2::Line2; ··· 57 62 DegeneratePlane, 58 63 #[error("cylinder surface radius, height, or sweep is degenerate")] 59 64 DegenerateCylinder, 65 + #[error("draft angle must be within [0, 90) degrees: {0} deg")] 66 + DraftAngleOutOfRange(f64), 60 67 } 61 68 62 69 pub type Result<T, E = KernelError> = core::result::Result<T, E>;
+69 -2
crates/bone-types/src/lib.rs
··· 1 1 pub use uom::si::angle::{degree, radian}; 2 2 pub use uom::si::f64::{Angle, Length}; 3 + use uom::si::length::meter; 3 4 pub use uom::si::length::millimeter; 4 5 5 6 pub mod camera; ··· 61 62 OrbitPanTargetNotFinite, 62 63 #[error("step entity instance name must be positive")] 63 64 ZeroStepEntityId, 65 + #[error("length must be finite and positive: {0} m")] 66 + NonPositiveLength(f64), 64 67 } 65 68 66 69 pub type Result<T, E = TypesError> = core::result::Result<T, E>; ··· 186 189 } 187 190 } 188 191 192 + #[derive(Copy, Clone, Debug, PartialEq, PartialOrd, serde::Serialize, serde::Deserialize)] 193 + #[serde(try_from = "f64", into = "f64")] 194 + pub struct PositiveLength(Length); 195 + 196 + impl PositiveLength { 197 + pub fn new(length: Length) -> Result<Self> { 198 + let meters = length.get::<meter>(); 199 + if meters.is_finite() && meters > 0.0 { 200 + Ok(Self(length)) 201 + } else { 202 + Err(TypesError::NonPositiveLength(meters)) 203 + } 204 + } 205 + 206 + #[must_use] 207 + pub fn get(self) -> Length { 208 + self.0 209 + } 210 + } 211 + 212 + impl From<PositiveLength> for f64 { 213 + fn from(value: PositiveLength) -> Self { 214 + value.0.get::<meter>() 215 + } 216 + } 217 + 218 + impl TryFrom<f64> for PositiveLength { 219 + type Error = TypesError; 220 + 221 + fn try_from(value: f64) -> Result<Self> { 222 + Self::new(Length::new::<meter>(value)) 223 + } 224 + } 225 + 189 226 #[cfg(test)] 190 227 mod tests { 191 228 use super::{ ··· 193 230 BrepShellId, BrepVertexId, Camera3, ChordHeightTolerance, DegreesOfFreedom, DisplayMode, 194 231 DocumentId, EdgeId, EdgeLabel, EdgeRole, ExtrudeId, FaceId, FaceLabel, FaceRole, FeatureId, 195 232 ImportOrdinal, Length, LoopId, LoopIndex, NodeId, OrbitState, OrientedBox3, Parameter, 196 - Plane3, Point2, Point3, Projection, ProjectionKind, ShadingModel, ShellId, SideKind, 197 - SketchDimensionId, SketchEntityId, SketchId, SketchParameterId, SketchPlaneBasis, 233 + Plane3, Point2, Point3, PositiveLength, Projection, ProjectionKind, ShadingModel, ShellId, 234 + SideKind, SketchDimensionId, SketchEntityId, SketchId, SketchParameterId, SketchPlaneBasis, 198 235 SketchRelationId, SolidId, SolverResidual, StandardView, StepEntityId, StepFileHeader, 199 236 StepFileName, StepOrganization, StepOriginatingSystem, StepSchema, Tolerance, UnitVec2, 200 237 UnitVec3, Vec2, Vec3, VertexId, VertexLabel, VertexRole, WireId, ZoomFactor, degree, 201 238 millimeter, radian, 202 239 }; 203 240 use slotmap::Key; 241 + use uom::si::length::meter; 204 242 205 243 fn ortho_camera() -> Camera3 { 206 244 let Ok(projection) = Projection::orthographic(Length::new::<millimeter>(25.0)) else { ··· 945 983 assert!(ron::from_str::<ZoomFactor>("2.5").is_ok()); 946 984 assert!(ron::from_str::<ZoomFactor>("0.0").is_err()); 947 985 assert!(ron::from_str::<ZoomFactor>("-1.0").is_err()); 986 + } 987 + 988 + #[test] 989 + fn positive_length_rejects_non_positive_and_non_finite() { 990 + assert!(PositiveLength::new(Length::new::<meter>(0.5)).is_ok()); 991 + assert!(PositiveLength::new(Length::new::<meter>(f64::MIN_POSITIVE)).is_ok()); 992 + assert!(PositiveLength::new(Length::new::<meter>(0.0)).is_err()); 993 + assert!(PositiveLength::new(Length::new::<meter>(-1.0)).is_err()); 994 + assert!(PositiveLength::new(Length::new::<meter>(f64::INFINITY)).is_err()); 995 + assert!(PositiveLength::new(Length::new::<meter>(f64::NEG_INFINITY)).is_err()); 996 + assert!(PositiveLength::new(Length::new::<meter>(f64::NAN)).is_err()); 997 + } 998 + 999 + #[test] 1000 + fn positive_length_round_trips_through_meters() { 1001 + let Ok(length) = PositiveLength::new(Length::new::<meter>(0.0123)) else { 1002 + panic!("0.0123 m is positive"); 1003 + }; 1004 + assert!((f64::from(length) - 0.0123).abs() < f64::EPSILON); 1005 + assert!((length.get().get::<meter>() - 0.0123).abs() < f64::EPSILON); 1006 + } 1007 + 1008 + #[test] 1009 + fn positive_length_deserialize_rejects_non_positive() { 1010 + assert!(ron::from_str::<PositiveLength>("0.5").is_ok()); 1011 + assert!(ron::from_str::<PositiveLength>("0.0").is_err()); 1012 + assert!(ron::from_str::<PositiveLength>("-1.0").is_err()); 1013 + assert!(ron::from_str::<PositiveLength>("inf").is_err()); 1014 + assert!(ron::from_str::<PositiveLength>("NaN").is_err()); 948 1015 } 949 1016 950 1017 #[test]