Another project
0

Configure Feed

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

fix(types): seal projection & better cam validation

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

author
Lewis
date (May 24, 2026, 9:21 PM +0300) commit e2242010 parent 4046d99e change-id utzunnnx
+1206 -13
+457
crates/bone-types/src/camera.rs
··· 1 + use nalgebra::{Quaternion, Unit, UnitQuaternion, Vector3 as NVec3}; 2 + use serde::{Deserialize, Serialize}; 3 + use uom::si::angle::{degree, radian}; 4 + use uom::si::f64::{Angle, Length}; 5 + use uom::si::length::millimeter; 6 + 7 + use crate::dimensioned_serde; 8 + use crate::{AngleTolerance, AxisAngle, Point3, Result, Tolerance, TypesError, UnitVec3}; 9 + 10 + const CAMERA_MIN_SEPARATION: Tolerance = Tolerance::new(1e-9); 11 + const CAMERA_MIN_UP_VIEW_ANGLE: AngleTolerance = AngleTolerance::from_radians(1e-9); 12 + 13 + fn point_is_finite(p: Point3) -> bool { 14 + let (x, y, z) = p.coords_mm(); 15 + x.is_finite() && y.is_finite() && z.is_finite() 16 + } 17 + 18 + #[derive(Copy, Clone, Debug, PartialEq)] 19 + pub enum ProjectionKind { 20 + Orthographic { 21 + half_height: Length, 22 + }, 23 + Perspective { 24 + fov: Angle, 25 + near: Length, 26 + far: Length, 27 + }, 28 + } 29 + 30 + #[derive(Copy, Clone, PartialEq, Serialize, Deserialize)] 31 + #[serde(try_from = "ProjectionWire", into = "ProjectionWire")] 32 + pub struct Projection(ProjectionKind); 33 + 34 + #[derive(Serialize, Deserialize)] 35 + #[serde(rename = "Projection", deny_unknown_fields)] 36 + enum ProjectionWire { 37 + Orthographic { 38 + #[serde(with = "dimensioned_serde::length_si")] 39 + half_height: Length, 40 + }, 41 + Perspective { 42 + #[serde(with = "dimensioned_serde::angle_si")] 43 + fov: Angle, 44 + #[serde(with = "dimensioned_serde::length_si")] 45 + near: Length, 46 + #[serde(with = "dimensioned_serde::length_si")] 47 + far: Length, 48 + }, 49 + } 50 + 51 + impl From<Projection> for ProjectionWire { 52 + fn from(p: Projection) -> Self { 53 + match p.0 { 54 + ProjectionKind::Orthographic { half_height } => Self::Orthographic { half_height }, 55 + ProjectionKind::Perspective { fov, near, far } => Self::Perspective { fov, near, far }, 56 + } 57 + } 58 + } 59 + 60 + impl TryFrom<ProjectionWire> for Projection { 61 + type Error = TypesError; 62 + fn try_from(w: ProjectionWire) -> Result<Self> { 63 + match w { 64 + ProjectionWire::Orthographic { half_height } => Self::orthographic(half_height), 65 + ProjectionWire::Perspective { fov, near, far } => Self::perspective(fov, near, far), 66 + } 67 + } 68 + } 69 + 70 + impl Projection { 71 + pub fn orthographic(half_height: Length) -> Result<Self> { 72 + let kind = ProjectionKind::Orthographic { half_height }; 73 + kind.validate()?; 74 + Ok(Self(kind)) 75 + } 76 + 77 + pub fn perspective(fov: Angle, near: Length, far: Length) -> Result<Self> { 78 + let kind = ProjectionKind::Perspective { fov, near, far }; 79 + kind.validate()?; 80 + Ok(Self(kind)) 81 + } 82 + 83 + #[must_use] 84 + pub fn kind(self) -> ProjectionKind { 85 + self.0 86 + } 87 + } 88 + 89 + impl ProjectionKind { 90 + fn validate(self) -> Result<()> { 91 + match self { 92 + Self::Orthographic { half_height } => { 93 + let h = half_height.get::<millimeter>(); 94 + if !h.is_finite() || h <= 0.0 { 95 + return Err(TypesError::NonPositiveOrthoHeight(h)); 96 + } 97 + } 98 + Self::Perspective { fov, near, far } => { 99 + let f = fov.get::<radian>(); 100 + if !f.is_finite() || f <= 0.0 || f >= core::f64::consts::PI { 101 + return Err(TypesError::FovOutOfRange(f)); 102 + } 103 + let n = near.get::<millimeter>(); 104 + if !n.is_finite() || n <= 0.0 { 105 + return Err(TypesError::NonPositiveNear(n)); 106 + } 107 + let fa = far.get::<millimeter>(); 108 + if !fa.is_finite() || fa <= n { 109 + return Err(TypesError::FarNotBeyondNear { near: n, far: fa }); 110 + } 111 + } 112 + } 113 + Ok(()) 114 + } 115 + } 116 + 117 + impl core::fmt::Debug for Projection { 118 + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { 119 + match self.0 { 120 + ProjectionKind::Orthographic { half_height } => write!( 121 + f, 122 + "Orthographic {{ half_height: {} mm }}", 123 + half_height.get::<millimeter>() 124 + ), 125 + ProjectionKind::Perspective { fov, near, far } => write!( 126 + f, 127 + "Perspective {{ fov: {} deg, near: {} mm, far: {} mm }}", 128 + fov.get::<degree>(), 129 + near.get::<millimeter>(), 130 + far.get::<millimeter>() 131 + ), 132 + } 133 + } 134 + } 135 + 136 + impl core::fmt::Display for Projection { 137 + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { 138 + match self.0 { 139 + ProjectionKind::Orthographic { half_height } => { 140 + write!( 141 + f, 142 + "ortho{{ half_height={} mm }}", 143 + half_height.get::<millimeter>() 144 + ) 145 + } 146 + ProjectionKind::Perspective { fov, near, far } => write!( 147 + f, 148 + "persp{{ fov={} deg, near={} mm, far={} mm }}", 149 + fov.get::<degree>(), 150 + near.get::<millimeter>(), 151 + far.get::<millimeter>() 152 + ), 153 + } 154 + } 155 + } 156 + 157 + #[derive(Copy, Clone, Debug, PartialEq, Serialize, Deserialize)] 158 + #[serde(try_from = "Camera3Wire", into = "Camera3Wire")] 159 + pub struct Camera3 { 160 + eye: Point3, 161 + target: Point3, 162 + up: UnitVec3, 163 + projection: Projection, 164 + } 165 + 166 + #[derive(Copy, Clone, Debug, PartialEq, Serialize, Deserialize)] 167 + #[serde(rename = "Camera3", deny_unknown_fields)] 168 + struct Camera3Wire { 169 + eye: Point3, 170 + target: Point3, 171 + up: UnitVec3, 172 + projection: Projection, 173 + } 174 + 175 + impl From<Camera3> for Camera3Wire { 176 + fn from(c: Camera3) -> Self { 177 + Self { 178 + eye: c.eye, 179 + target: c.target, 180 + up: c.up, 181 + projection: c.projection, 182 + } 183 + } 184 + } 185 + 186 + impl TryFrom<Camera3Wire> for Camera3 { 187 + type Error = TypesError; 188 + fn try_from(w: Camera3Wire) -> Result<Self> { 189 + Self::new(w.eye, w.target, w.up, w.projection) 190 + } 191 + } 192 + 193 + impl Camera3 { 194 + pub fn new(eye: Point3, target: Point3, up: UnitVec3, projection: Projection) -> Result<Self> { 195 + let offset = target - eye; 196 + let separation = offset.norm_mm(); 197 + if !separation.is_finite() { 198 + return Err(TypesError::CameraNotFinite); 199 + } 200 + if separation <= CAMERA_MIN_SEPARATION.value() { 201 + return Err(TypesError::DegenerateCamera(separation)); 202 + } 203 + let (ox, oy, oz) = offset.coords_mm(); 204 + let view = UnitVec3::new_unchecked(ox / separation, oy / separation, oz / separation); 205 + let (ux, uy, uz) = up.components(); 206 + let (vx, vy, vz) = view.components(); 207 + let up_cross = NVec3::new(uy * vz - uz * vy, uz * vx - ux * vz, ux * vy - uy * vx).norm(); 208 + if up_cross <= CAMERA_MIN_UP_VIEW_ANGLE.radians() { 209 + return Err(TypesError::CameraUpParallelToView); 210 + } 211 + Ok(Self { 212 + eye, 213 + target, 214 + up, 215 + projection, 216 + }) 217 + } 218 + 219 + #[must_use] 220 + pub fn eye(self) -> Point3 { 221 + self.eye 222 + } 223 + 224 + #[must_use] 225 + pub fn target(self) -> Point3 { 226 + self.target 227 + } 228 + 229 + #[must_use] 230 + pub fn up(self) -> UnitVec3 { 231 + self.up 232 + } 233 + 234 + #[must_use] 235 + pub fn projection(self) -> Projection { 236 + self.projection 237 + } 238 + } 239 + 240 + impl core::fmt::Display for Camera3 { 241 + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { 242 + write!( 243 + f, 244 + "camera{{ eye={}, target={}, up={}, {} }}", 245 + self.eye, self.target, self.up, self.projection 246 + ) 247 + } 248 + } 249 + 250 + #[derive(Copy, Clone, Debug, PartialEq, PartialOrd, Serialize, Deserialize)] 251 + #[serde(try_from = "f64", into = "f64")] 252 + pub struct ZoomFactor(f64); 253 + 254 + impl ZoomFactor { 255 + pub const IDENTITY: Self = Self(1.0); 256 + 257 + pub fn new(value: f64) -> Result<Self> { 258 + if !value.is_finite() || value <= 0.0 { 259 + return Err(TypesError::NonPositiveZoom(value)); 260 + } 261 + Ok(Self(value)) 262 + } 263 + 264 + #[must_use] 265 + pub const fn value(self) -> f64 { 266 + self.0 267 + } 268 + } 269 + 270 + impl TryFrom<f64> for ZoomFactor { 271 + type Error = TypesError; 272 + fn try_from(value: f64) -> Result<Self> { 273 + Self::new(value) 274 + } 275 + } 276 + 277 + impl From<ZoomFactor> for f64 { 278 + fn from(zoom: ZoomFactor) -> Self { 279 + zoom.0 280 + } 281 + } 282 + 283 + impl core::fmt::Display for ZoomFactor { 284 + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { 285 + write!(f, "zoom={}", self.0) 286 + } 287 + } 288 + 289 + fn axis_angle_to_quat(aa: AxisAngle) -> UnitQuaternion<f64> { 290 + let (x, y, z) = aa.axis().components(); 291 + let axis = Unit::new_unchecked(NVec3::new(x, y, z)); 292 + UnitQuaternion::from_axis_angle(&axis, aa.angle().get::<radian>()) 293 + } 294 + 295 + fn quat_to_axis_angle(q: UnitQuaternion<f64>) -> AxisAngle { 296 + match q.axis_angle() { 297 + Some((axis, angle)) => AxisAngle::new( 298 + UnitVec3::new_unchecked(axis.x, axis.y, axis.z), 299 + Angle::new::<radian>(angle), 300 + ), 301 + None => AxisAngle::new(UnitVec3::z_axis(), Angle::new::<radian>(0.0)), 302 + } 303 + } 304 + 305 + #[derive(Copy, Clone, PartialEq, Serialize, Deserialize)] 306 + #[serde(try_from = "OrbitStateWire", into = "OrbitStateWire")] 307 + pub struct OrbitState { 308 + rotation: UnitQuaternion<f64>, 309 + zoom: ZoomFactor, 310 + pan_target: Point3, 311 + } 312 + 313 + #[derive(Copy, Clone, Debug, PartialEq, Serialize, Deserialize)] 314 + #[serde(rename = "OrbitState", deny_unknown_fields)] 315 + struct OrbitStateWire { 316 + qw: f64, 317 + qx: f64, 318 + qy: f64, 319 + qz: f64, 320 + zoom: ZoomFactor, 321 + pan_target: Point3, 322 + } 323 + 324 + impl From<OrbitState> for OrbitStateWire { 325 + fn from(o: OrbitState) -> Self { 326 + let q = o.rotation.quaternion(); 327 + Self { 328 + qw: q.w, 329 + qx: q.i, 330 + qy: q.j, 331 + qz: q.k, 332 + zoom: o.zoom, 333 + pan_target: o.pan_target, 334 + } 335 + } 336 + } 337 + 338 + impl TryFrom<OrbitStateWire> for OrbitState { 339 + type Error = TypesError; 340 + fn try_from(w: OrbitStateWire) -> Result<Self> { 341 + let rotation = Unit::try_new(Quaternion::new(w.qw, w.qx, w.qy, w.qz), f64::EPSILON) 342 + .ok_or(TypesError::ZeroLengthAxis)?; 343 + if !point_is_finite(w.pan_target) { 344 + return Err(TypesError::OrbitPanTargetNotFinite); 345 + } 346 + Ok(Self { 347 + rotation, 348 + zoom: w.zoom, 349 + pan_target: w.pan_target, 350 + }) 351 + } 352 + } 353 + 354 + impl OrbitState { 355 + #[must_use] 356 + pub fn identity() -> Self { 357 + Self { 358 + rotation: UnitQuaternion::identity(), 359 + zoom: ZoomFactor::IDENTITY, 360 + pan_target: Point3::origin(), 361 + } 362 + } 363 + 364 + pub fn new(rotation: AxisAngle, zoom: ZoomFactor, pan_target: Point3) -> Result<Self> { 365 + if !point_is_finite(pan_target) { 366 + return Err(TypesError::OrbitPanTargetNotFinite); 367 + } 368 + Ok(Self { 369 + rotation: axis_angle_to_quat(rotation), 370 + zoom, 371 + pan_target, 372 + }) 373 + } 374 + 375 + #[must_use] 376 + pub fn rotation(self) -> AxisAngle { 377 + quat_to_axis_angle(self.rotation) 378 + } 379 + 380 + #[must_use] 381 + pub fn zoom(self) -> ZoomFactor { 382 + self.zoom 383 + } 384 + 385 + #[must_use] 386 + pub fn pan_target(self) -> Point3 { 387 + self.pan_target 388 + } 389 + 390 + #[must_use] 391 + pub fn rotated(self, delta: AxisAngle) -> Self { 392 + let combined = axis_angle_to_quat(delta) * self.rotation; 393 + Self { 394 + rotation: UnitQuaternion::new_normalize(combined.into_inner()), 395 + zoom: self.zoom, 396 + pan_target: self.pan_target, 397 + } 398 + } 399 + } 400 + 401 + impl core::fmt::Debug for OrbitState { 402 + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { 403 + write!( 404 + f, 405 + "OrbitState {{ rotation: {:?}, zoom: {}, pan_target: {:?} }}", 406 + self.rotation(), 407 + self.zoom, 408 + self.pan_target 409 + ) 410 + } 411 + } 412 + 413 + impl core::fmt::Display for OrbitState { 414 + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { 415 + write!( 416 + f, 417 + "orbit{{ rot={}, {}, target={} }}", 418 + self.rotation(), 419 + self.zoom, 420 + self.pan_target 421 + ) 422 + } 423 + } 424 + 425 + #[derive(Copy, Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] 426 + pub enum StandardView { 427 + Front, 428 + Back, 429 + Left, 430 + Right, 431 + Top, 432 + Bottom, 433 + Isometric, 434 + NormalTo, 435 + } 436 + 437 + impl StandardView { 438 + #[must_use] 439 + pub const fn label(self) -> &'static str { 440 + match self { 441 + Self::Front => "front", 442 + Self::Back => "back", 443 + Self::Left => "left", 444 + Self::Right => "right", 445 + Self::Top => "top", 446 + Self::Bottom => "bottom", 447 + Self::Isometric => "isometric", 448 + Self::NormalTo => "normal_to", 449 + } 450 + } 451 + } 452 + 453 + impl core::fmt::Display for StandardView { 454 + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { 455 + f.write_str(self.label()) 456 + } 457 + }
+55
crates/bone-types/src/display.rs
··· 1 + use serde::{Deserialize, Serialize}; 2 + 3 + #[derive(Copy, Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] 4 + pub enum DisplayMode { 5 + Wireframe, 6 + HiddenLineRemoved, 7 + HiddenLineGray, 8 + ShadedWithEdges, 9 + ShadedNoEdges, 10 + } 11 + 12 + impl DisplayMode { 13 + pub const DEFAULT: Self = Self::ShadedWithEdges; 14 + 15 + #[must_use] 16 + pub const fn label(self) -> &'static str { 17 + match self { 18 + Self::Wireframe => "wireframe", 19 + Self::HiddenLineRemoved => "hidden_line_removed", 20 + Self::HiddenLineGray => "hidden_line_gray", 21 + Self::ShadedWithEdges => "shaded_with_edges", 22 + Self::ShadedNoEdges => "shaded_no_edges", 23 + } 24 + } 25 + } 26 + 27 + impl core::fmt::Display for DisplayMode { 28 + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { 29 + f.write_str(self.label()) 30 + } 31 + } 32 + 33 + #[derive(Copy, Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] 34 + pub enum ShadingModel { 35 + Flat, 36 + Gouraud, 37 + Phong, 38 + } 39 + 40 + impl ShadingModel { 41 + #[must_use] 42 + pub const fn label(self) -> &'static str { 43 + match self { 44 + Self::Flat => "flat", 45 + Self::Gouraud => "gouraud", 46 + Self::Phong => "phong", 47 + } 48 + } 49 + } 50 + 51 + impl core::fmt::Display for ShadingModel { 52 + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { 53 + f.write_str(self.label()) 54 + } 55 + }
+409 -6
crates/bone-types/src/lib.rs
··· 2 2 pub use uom::si::f64::{Angle, Length}; 3 3 pub use uom::si::length::millimeter; 4 4 5 + pub mod camera; 5 6 pub mod dimensioned_serde; 7 + pub mod display; 6 8 pub mod label; 7 9 pub mod schema; 8 10 pub mod solver; 9 11 pub mod space; 12 + pub mod step; 10 13 14 + pub use camera::{Camera3, OrbitState, Projection, ProjectionKind, StandardView, ZoomFactor}; 15 + pub use display::{DisplayMode, ShadingModel}; 11 16 pub use label::{ 12 17 EdgeLabel, EdgeRole, FaceLabel, FaceRole, ImportOrdinal, LoopIndex, SideKind, VertexLabel, 13 18 VertexRole, ··· 21 26 Aabb3, AxisAngle, OrientedBox3, Plane3, Point2, Point3, SketchPlaneBasis, UnitVec2, UnitVec3, 22 27 Vec2, Vec3, 23 28 }; 29 + pub use step::{ 30 + StepEntityId, StepFileHeader, StepFileName, StepOrganization, StepOriginatingSystem, StepSchema, 31 + }; 24 32 25 33 #[cfg(feature = "testing")] 26 34 pub mod testing; ··· 33 41 ZeroLengthAxis, 34 42 #[error("oriented box half-extent must be finite and non-negative: {0}")] 35 43 InvalidHalfExtent(f64), 44 + #[error("camera eye and target coincide: separation {0} mm")] 45 + DegenerateCamera(f64), 46 + #[error("camera eye or target is not finite")] 47 + CameraNotFinite, 48 + #[error("camera up direction is parallel to the view direction")] 49 + CameraUpParallelToView, 50 + #[error("orthographic half-height must be finite and positive: {0} mm")] 51 + NonPositiveOrthoHeight(f64), 52 + #[error("perspective field of view must be within (0, pi) radians: {0}")] 53 + FovOutOfRange(f64), 54 + #[error("perspective near distance must be finite and positive: {0} mm")] 55 + NonPositiveNear(f64), 56 + #[error("perspective far distance must exceed the near distance: near {near} mm, far {far} mm")] 57 + FarNotBeyondNear { near: f64, far: f64 }, 58 + #[error("zoom factor must be finite and positive: {0}")] 59 + NonPositiveZoom(f64), 60 + #[error("orbit pan target is not finite")] 61 + OrbitPanTargetNotFinite, 62 + #[error("step entity instance name must be positive")] 63 + ZeroStepEntityId, 36 64 } 37 65 38 66 pub type Result<T, E = TypesError> = core::result::Result<T, E>; ··· 162 190 mod tests { 163 191 use super::{ 164 192 Aabb3, Angle, AngleTolerance, AxisAngle, BodyId, BrepEdgeId, BrepFaceId, BrepLoopId, 165 - BrepShellId, BrepVertexId, ChordHeightTolerance, DegreesOfFreedom, DocumentId, EdgeId, 166 - EdgeLabel, EdgeRole, ExtrudeId, FaceId, FaceLabel, FaceRole, FeatureId, ImportOrdinal, 167 - Length, LoopId, LoopIndex, NodeId, OrientedBox3, Parameter, Plane3, Point2, Point3, 168 - ShellId, SideKind, SketchDimensionId, SketchEntityId, SketchId, SketchParameterId, 169 - SketchPlaneBasis, SketchRelationId, SolidId, SolverResidual, Tolerance, UnitVec2, UnitVec3, 170 - Vec2, Vec3, VertexId, VertexLabel, VertexRole, WireId, degree, millimeter, radian, 193 + BrepShellId, BrepVertexId, Camera3, ChordHeightTolerance, DegreesOfFreedom, DisplayMode, 194 + DocumentId, EdgeId, EdgeLabel, EdgeRole, ExtrudeId, FaceId, FaceLabel, FaceRole, FeatureId, 195 + ImportOrdinal, Length, LoopId, LoopIndex, NodeId, OrbitState, OrientedBox3, Parameter, 196 + Plane3, Point2, Point3, Projection, ProjectionKind, ShadingModel, ShellId, SideKind, 197 + SketchDimensionId, SketchEntityId, SketchId, SketchParameterId, SketchPlaneBasis, 198 + SketchRelationId, SolidId, SolverResidual, StandardView, StepEntityId, StepFileHeader, 199 + StepFileName, StepOrganization, StepOriginatingSystem, StepSchema, Tolerance, UnitVec2, 200 + UnitVec3, Vec2, Vec3, VertexId, VertexLabel, VertexRole, WireId, ZoomFactor, degree, 201 + millimeter, radian, 171 202 }; 172 203 use slotmap::Key; 204 + 205 + fn ortho_camera() -> Camera3 { 206 + let Ok(projection) = Projection::orthographic(Length::new::<millimeter>(25.0)) else { 207 + panic!("25 mm half-height is positive"); 208 + }; 209 + let Ok(camera) = Camera3::new( 210 + Point3::from_mm(0.0, 0.0, 10.0), 211 + Point3::origin(), 212 + UnitVec3::y_axis(), 213 + projection, 214 + ) else { 215 + panic!("eye and target are 10 mm apart"); 216 + }; 217 + camera 218 + } 173 219 174 220 #[test] 175 221 fn id_null_is_null() { ··· 660 706 let mut munged = text.clone(); 661 707 munged.insert_str(idx + "Side(".len(), "bogus:42,"); 662 708 assert!(ron::from_str::<FaceLabel>(&munged).is_err()); 709 + } 710 + 711 + #[test] 712 + fn camera_new_rejects_coincident_eye_and_target() { 713 + let Ok(projection) = Projection::orthographic(Length::new::<millimeter>(10.0)) else { 714 + panic!("10 mm half-height is positive"); 715 + }; 716 + let coincident = Camera3::new( 717 + Point3::from_mm(1.0, 2.0, 3.0), 718 + Point3::from_mm(1.0, 2.0, 3.0), 719 + UnitVec3::y_axis(), 720 + projection, 721 + ); 722 + assert!(coincident.is_err()); 723 + } 724 + 725 + #[test] 726 + fn camera_accessors_round_trip_fields() { 727 + let camera = ortho_camera(); 728 + assert!((camera.eye().z().get::<millimeter>() - 10.0).abs() < 1e-12); 729 + assert!(camera.target().coords_mm().0.abs() < 1e-12); 730 + assert!((camera.up().components().1 - 1.0).abs() < 1e-12); 731 + let ProjectionKind::Orthographic { half_height } = camera.projection().kind() else { 732 + panic!("constructed an orthographic camera"); 733 + }; 734 + assert!((half_height.get::<millimeter>() - 25.0).abs() < 1e-12); 735 + } 736 + 737 + #[test] 738 + fn camera_ron_round_trip() { 739 + let camera = ortho_camera(); 740 + let Ok(text) = ron::to_string(&camera) else { 741 + panic!("serialize camera"); 742 + }; 743 + let Ok(back) = ron::from_str::<Camera3>(&text) else { 744 + panic!("deserialize camera"); 745 + }; 746 + assert_eq!(camera, back); 747 + } 748 + 749 + #[test] 750 + fn camera_deserialize_rejects_degenerate() { 751 + let Ok(text) = ron::to_string(&ortho_camera()) else { 752 + panic!("serialize camera"); 753 + }; 754 + assert!(ron::from_str::<Camera3>(&text).is_ok()); 755 + let collapsed = text.replace("z:10.0", "z:0.0"); 756 + assert!(ron::from_str::<Camera3>(&collapsed).is_err()); 757 + } 758 + 759 + #[test] 760 + fn orbit_state_identity_is_unrotated() { 761 + let orbit = OrbitState::identity(); 762 + assert!((orbit.rotation().angle().get::<radian>()).abs() < 1e-12); 763 + assert!((orbit.zoom().value() - 1.0).abs() < 1e-12); 764 + assert!(orbit.pan_target().coords_mm().0.abs() < 1e-12); 765 + } 766 + 767 + #[test] 768 + fn orbit_state_rotated_accumulates_angle() { 769 + let quarter = AxisAngle::new( 770 + UnitVec3::z_axis(), 771 + Angle::new::<radian>(core::f64::consts::FRAC_PI_2), 772 + ); 773 + let once = OrbitState::identity().rotated(quarter); 774 + let twice = once.rotated(quarter); 775 + assert!( 776 + (once.rotation().angle().get::<radian>() - core::f64::consts::FRAC_PI_2).abs() < 1e-12 777 + ); 778 + assert!((twice.rotation().angle().get::<radian>() - core::f64::consts::PI).abs() < 1e-12); 779 + assert!((twice.rotation().axis().components().2 - 1.0).abs() < 1e-12); 780 + } 781 + 782 + #[test] 783 + fn orbit_state_ron_round_trip() { 784 + let Ok(zoom) = ZoomFactor::new(2.5) else { 785 + panic!("positive zoom"); 786 + }; 787 + let Ok(orbit) = OrbitState::new( 788 + AxisAngle::new( 789 + UnitVec3::z_axis(), 790 + Angle::new::<radian>(core::f64::consts::FRAC_PI_2), 791 + ), 792 + zoom, 793 + Point3::from_mm(1.0, 2.0, 3.0), 794 + ) else { 795 + panic!("finite pan target"); 796 + }; 797 + let Ok(text) = ron::to_string(&orbit) else { 798 + panic!("serialize orbit"); 799 + }; 800 + let Ok(back) = ron::from_str::<OrbitState>(&text) else { 801 + panic!("deserialize orbit"); 802 + }; 803 + assert_eq!(orbit, back); 804 + } 805 + 806 + #[test] 807 + fn standard_view_labels_are_distinct() { 808 + let views = [ 809 + StandardView::Front, 810 + StandardView::Back, 811 + StandardView::Left, 812 + StandardView::Right, 813 + StandardView::Top, 814 + StandardView::Bottom, 815 + StandardView::Isometric, 816 + StandardView::NormalTo, 817 + ]; 818 + let labels: std::collections::BTreeSet<&str> = views.iter().map(|v| v.label()).collect(); 819 + assert_eq!(labels.len(), views.len()); 820 + } 821 + 822 + #[test] 823 + fn step_entity_id_display() { 824 + let Ok(id) = StepEntityId::new(123) else { 825 + panic!("123 is a positive instance name"); 826 + }; 827 + assert_eq!(format!("{id}"), "#123"); 828 + let Ok(seven) = StepEntityId::new(7) else { 829 + panic!("7 is a positive instance name"); 830 + }; 831 + assert_eq!(seven.value(), 7); 832 + } 833 + 834 + #[test] 835 + fn step_entity_id_rejects_zero() { 836 + assert!(StepEntityId::new(0).is_err()); 837 + assert!(StepEntityId::new(1).is_ok()); 838 + assert!(ron::from_str::<StepEntityId>("0").is_err()); 839 + assert!(ron::from_str::<StepEntityId>("123").is_ok()); 840 + } 841 + 842 + #[test] 843 + fn step_schema_labels() { 844 + assert_eq!(StepSchema::Ap214.label(), "AP214"); 845 + assert_eq!(StepSchema::Ap242E2.label(), "AP242E2"); 846 + } 847 + 848 + #[test] 849 + fn step_file_header_ron_round_trip() { 850 + let header = StepFileHeader { 851 + schema: StepSchema::Ap214, 852 + originating_system: StepOriginatingSystem::new("bone 0.0.0"), 853 + organization: StepOrganization::new("witchcraft.systems"), 854 + file_name: StepFileName::new("limpet.step"), 855 + }; 856 + let Ok(text) = ron::to_string(&header) else { 857 + panic!("serialize step header"); 858 + }; 859 + let Ok(back) = ron::from_str::<StepFileHeader>(&text) else { 860 + panic!("deserialize step header"); 861 + }; 862 + assert_eq!(header, back); 863 + assert_eq!(header.file_name.as_str(), "limpet.step"); 864 + } 865 + 866 + #[test] 867 + fn display_mode_default_and_labels() { 868 + assert_eq!(DisplayMode::DEFAULT, DisplayMode::ShadedWithEdges); 869 + assert_eq!(DisplayMode::HiddenLineGray.label(), "hidden_line_gray"); 870 + assert_eq!(ShadingModel::Gouraud.label(), "gouraud"); 871 + } 872 + 873 + #[test] 874 + fn camera_rejects_non_finite_eye() { 875 + let Ok(projection) = Projection::orthographic(Length::new::<millimeter>(10.0)) else { 876 + panic!("10 mm half-height is positive"); 877 + }; 878 + let nan = Camera3::new( 879 + Point3::from_mm(f64::NAN, 0.0, 0.0), 880 + Point3::origin(), 881 + UnitVec3::y_axis(), 882 + projection, 883 + ); 884 + assert!(nan.is_err()); 885 + let infinite = Camera3::new( 886 + Point3::from_mm(f64::INFINITY, 0.0, 0.0), 887 + Point3::origin(), 888 + UnitVec3::y_axis(), 889 + projection, 890 + ); 891 + assert!(infinite.is_err()); 892 + } 893 + 894 + #[test] 895 + fn camera_rejects_up_parallel_to_view() { 896 + let Ok(projection) = Projection::orthographic(Length::new::<millimeter>(10.0)) else { 897 + panic!("10 mm half-height is positive"); 898 + }; 899 + let parallel = Camera3::new( 900 + Point3::from_mm(0.0, 0.0, 10.0), 901 + Point3::origin(), 902 + UnitVec3::z_axis(), 903 + projection, 904 + ); 905 + assert!(parallel.is_err()); 906 + } 907 + 908 + #[test] 909 + fn projection_constructors_validate() { 910 + let mm = |v| Length::new::<millimeter>(v); 911 + assert!(Projection::orthographic(mm(25.0)).is_ok()); 912 + assert!(Projection::orthographic(mm(0.0)).is_err()); 913 + assert!(Projection::orthographic(mm(-1.0)).is_err()); 914 + assert!(Projection::orthographic(Length::new::<millimeter>(f64::NAN)).is_err()); 915 + assert!(Projection::perspective(Angle::new::<degree>(60.0), mm(1.0), mm(1000.0)).is_ok()); 916 + assert!(Projection::perspective(Angle::new::<degree>(400.0), mm(1.0), mm(1000.0)).is_err()); 917 + assert!(Projection::perspective(Angle::new::<degree>(60.0), mm(10.0), mm(1.0)).is_err()); 918 + assert!(Projection::perspective(Angle::new::<degree>(60.0), mm(0.0), mm(1000.0)).is_err()); 919 + } 920 + 921 + #[test] 922 + fn projection_deserialize_rejects_degenerate() { 923 + let Ok(valid) = Projection::orthographic(Length::new::<millimeter>(25.0)) else { 924 + panic!("25 mm half-height is positive"); 925 + }; 926 + let Ok(text) = ron::to_string(&valid) else { 927 + panic!("serialize projection"); 928 + }; 929 + assert!(ron::from_str::<Projection>(&text).is_ok()); 930 + let degenerate = text.replace("0.025", "-0.025"); 931 + assert!(ron::from_str::<Projection>(&degenerate).is_err()); 932 + } 933 + 934 + #[test] 935 + fn zoom_factor_rejects_non_positive() { 936 + assert!(ZoomFactor::new(2.5).is_ok()); 937 + assert!(ZoomFactor::new(0.0).is_err()); 938 + assert!(ZoomFactor::new(-1.0).is_err()); 939 + assert!(ZoomFactor::new(f64::NAN).is_err()); 940 + assert!(ZoomFactor::new(f64::INFINITY).is_err()); 941 + } 942 + 943 + #[test] 944 + fn zoom_factor_deserialize_rejects_non_positive() { 945 + assert!(ron::from_str::<ZoomFactor>("2.5").is_ok()); 946 + assert!(ron::from_str::<ZoomFactor>("0.0").is_err()); 947 + assert!(ron::from_str::<ZoomFactor>("-1.0").is_err()); 948 + } 949 + 950 + #[test] 951 + fn orbit_state_deserialize_normalizes_non_unit_quaternion() { 952 + let nonunit = "(qw:2.0,qx:0.0,qy:0.0,qz:0.0,zoom:1.0,pan_target:(x:0.0,y:0.0,z:0.0))"; 953 + let Ok(orbit) = ron::from_str::<OrbitState>(nonunit) else { 954 + panic!("non-unit quaternion should normalize, not fail"); 955 + }; 956 + let Ok(text) = ron::to_string(&orbit) else { 957 + panic!("serialize orbit"); 958 + }; 959 + assert!( 960 + text.contains("qw:1"), 961 + "quaternion should re-normalize to unit, got {text}" 962 + ); 963 + } 964 + 965 + #[test] 966 + fn orbit_state_deserialize_rejects_zero_quaternion() { 967 + let zero = "(qw:0.0,qx:0.0,qy:0.0,qz:0.0,zoom:1.0,pan_target:(x:0.0,y:0.0,z:0.0))"; 968 + assert!(ron::from_str::<OrbitState>(zero).is_err()); 969 + } 970 + 971 + #[test] 972 + fn orbit_state_rejects_non_finite_pan_target() { 973 + let Ok(zoom) = ZoomFactor::new(1.0) else { 974 + panic!("positive zoom"); 975 + }; 976 + let rotation = AxisAngle::new(UnitVec3::z_axis(), Angle::new::<radian>(0.0)); 977 + assert!(OrbitState::new(rotation, zoom, Point3::from_mm(f64::NAN, 0.0, 0.0)).is_err()); 978 + assert!(OrbitState::new(rotation, zoom, Point3::from_mm(f64::INFINITY, 0.0, 0.0)).is_err()); 979 + assert!(OrbitState::new(rotation, zoom, Point3::origin()).is_ok()); 980 + } 981 + 982 + #[test] 983 + fn orbit_state_deserialize_rejects_non_finite_pan_target() { 984 + let finite = "(qw:1.0,qx:0.0,qy:0.0,qz:0.0,zoom:1.0,pan_target:(x:0.0,y:0.0,z:0.0))"; 985 + assert!(ron::from_str::<OrbitState>(finite).is_ok()); 986 + let non_finite = "(qw:1.0,qx:0.0,qy:0.0,qz:0.0,zoom:1.0,pan_target:(x:0.0,y:0.0,z:inf))"; 987 + assert!(ron::from_str::<OrbitState>(non_finite).is_err()); 988 + } 989 + 990 + #[test] 991 + fn camera_perspective_ron_round_trip() { 992 + let Ok(persp) = Projection::perspective( 993 + Angle::new::<degree>(60.0), 994 + Length::new::<millimeter>(1.0), 995 + Length::new::<millimeter>(1000.0), 996 + ) else { 997 + panic!("valid perspective"); 998 + }; 999 + let Ok(camera) = Camera3::new( 1000 + Point3::from_mm(0.0, 0.0, 10.0), 1001 + Point3::origin(), 1002 + UnitVec3::y_axis(), 1003 + persp, 1004 + ) else { 1005 + panic!("eye and target are 10 mm apart"); 1006 + }; 1007 + let Ok(text) = ron::to_string(&camera) else { 1008 + panic!("serialize camera"); 1009 + }; 1010 + let Ok(back) = ron::from_str::<Camera3>(&text) else { 1011 + panic!("deserialize camera"); 1012 + }; 1013 + assert_eq!(camera, back); 1014 + } 1015 + 1016 + fn assert_ron_round_trip<T>(value: &T) 1017 + where 1018 + T: serde::Serialize + serde::de::DeserializeOwned + PartialEq + core::fmt::Debug, 1019 + { 1020 + let Ok(text) = ron::to_string(value) else { 1021 + panic!("serialize {value:?}"); 1022 + }; 1023 + let Ok(back) = ron::from_str::<T>(&text) else { 1024 + panic!("deserialize {text}"); 1025 + }; 1026 + assert_eq!(value, &back); 1027 + } 1028 + 1029 + #[test] 1030 + fn discrete_types_ron_round_trip() { 1031 + [ 1032 + DisplayMode::Wireframe, 1033 + DisplayMode::HiddenLineRemoved, 1034 + DisplayMode::HiddenLineGray, 1035 + DisplayMode::ShadedWithEdges, 1036 + DisplayMode::ShadedNoEdges, 1037 + ] 1038 + .iter() 1039 + .for_each(assert_ron_round_trip); 1040 + [ 1041 + ShadingModel::Flat, 1042 + ShadingModel::Gouraud, 1043 + ShadingModel::Phong, 1044 + ] 1045 + .iter() 1046 + .for_each(assert_ron_round_trip); 1047 + [ 1048 + StandardView::Front, 1049 + StandardView::Back, 1050 + StandardView::Left, 1051 + StandardView::Right, 1052 + StandardView::Top, 1053 + StandardView::Bottom, 1054 + StandardView::Isometric, 1055 + StandardView::NormalTo, 1056 + ] 1057 + .iter() 1058 + .for_each(assert_ron_round_trip); 1059 + [StepSchema::Ap214, StepSchema::Ap242E2] 1060 + .iter() 1061 + .for_each(assert_ron_round_trip); 1062 + let Ok(entity) = StepEntityId::new(123) else { 1063 + panic!("123 is a positive instance name"); 1064 + }; 1065 + assert_ron_round_trip(&entity); 663 1066 } 664 1067 }
+111
crates/bone-types/src/step.rs
··· 1 + use serde::{Deserialize, Serialize}; 2 + 3 + use crate::{Result, TypesError}; 4 + 5 + #[derive(Copy, Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] 6 + #[serde(try_from = "u64", into = "u64")] 7 + pub struct StepEntityId(u64); 8 + 9 + impl StepEntityId { 10 + pub fn new(value: u64) -> Result<Self> { 11 + if value == 0 { 12 + return Err(TypesError::ZeroStepEntityId); 13 + } 14 + Ok(Self(value)) 15 + } 16 + 17 + #[must_use] 18 + pub const fn value(self) -> u64 { 19 + self.0 20 + } 21 + } 22 + 23 + impl TryFrom<u64> for StepEntityId { 24 + type Error = TypesError; 25 + fn try_from(value: u64) -> Result<Self> { 26 + Self::new(value) 27 + } 28 + } 29 + 30 + impl From<StepEntityId> for u64 { 31 + fn from(id: StepEntityId) -> Self { 32 + id.0 33 + } 34 + } 35 + 36 + impl core::fmt::Display for StepEntityId { 37 + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { 38 + write!(f, "#{}", self.0) 39 + } 40 + } 41 + 42 + #[derive(Copy, Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] 43 + pub enum StepSchema { 44 + Ap214, 45 + Ap242E2, 46 + } 47 + 48 + impl StepSchema { 49 + #[must_use] 50 + pub const fn label(self) -> &'static str { 51 + match self { 52 + Self::Ap214 => "AP214", 53 + Self::Ap242E2 => "AP242E2", 54 + } 55 + } 56 + } 57 + 58 + impl core::fmt::Display for StepSchema { 59 + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { 60 + f.write_str(self.label()) 61 + } 62 + } 63 + 64 + macro_rules! string_newtype { 65 + ($($name:ident),+ $(,)?) => { 66 + $( 67 + #[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] 68 + #[serde(transparent)] 69 + pub struct $name(String); 70 + 71 + impl $name { 72 + #[must_use] 73 + pub fn new(value: impl Into<String>) -> Self { 74 + Self(value.into()) 75 + } 76 + 77 + #[must_use] 78 + pub fn as_str(&self) -> &str { 79 + &self.0 80 + } 81 + } 82 + 83 + impl core::fmt::Display for $name { 84 + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { 85 + f.write_str(&self.0) 86 + } 87 + } 88 + )+ 89 + }; 90 + } 91 + 92 + string_newtype!(StepOriginatingSystem, StepOrganization, StepFileName); 93 + 94 + #[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] 95 + #[serde(deny_unknown_fields)] 96 + pub struct StepFileHeader { 97 + pub schema: StepSchema, 98 + pub originating_system: StepOriginatingSystem, 99 + pub organization: StepOrganization, 100 + pub file_name: StepFileName, 101 + } 102 + 103 + impl core::fmt::Display for StepFileHeader { 104 + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { 105 + write!( 106 + f, 107 + "step_header{{ schema={}, originating_system={}, organization={}, file_name={} }}", 108 + self.schema, self.originating_system, self.organization, self.file_name 109 + ) 110 + } 111 + }
+18
crates/bone-types/tests/snapshots/surface__camera_surface.snap
··· 1 + --- 2 + source: crates/bone-types/tests/surface.rs 3 + expression: surface 4 + --- 5 + ortho_debug = Orthographic { half_height: 25 mm } 6 + ortho_display = ortho{ half_height=25 mm } 7 + persp_debug = Perspective { fov: 59.99999999999999 deg, near: 1 mm, far: 1000 mm } 8 + persp_display = persp{ fov=59.99999999999999 deg, near=1 mm, far=1000 mm } 9 + camera_debug = Camera3 { eye: Point3(0 mm, 0 mm, 10 mm), target: Point3(0 mm, 0 mm, 0 mm), up: UnitVec3([[0.0, 1.0, 0.0]]), projection: Perspective { fov: 59.99999999999999 deg, near: 1 mm, far: 1000 mm } } 10 + camera_display = camera{ eye=(0 mm, 0 mm, 10 mm), target=(0 mm, 0 mm, 0 mm), up=[0, 1, 0], persp{ fov=59.99999999999999 deg, near=1 mm, far=1000 mm } } 11 + zoom_display = zoom=1 12 + orbit_id_debug = OrbitState { rotation: AxisAngle(axis=UnitVec3([[0.0, 0.0, 1.0]]), angle=0 rad), zoom: zoom=1, pan_target: Point3(0 mm, 0 mm, 0 mm) } 13 + orbit_id_display = orbit{ rot=axisangle{ axis=[0, 0, 1], angle=0 rad }, zoom=1, target=(0 mm, 0 mm, 0 mm) } 14 + orbit_rot_debug = OrbitState { rotation: AxisAngle(axis=UnitVec3([[0.0, 0.0, 1.0]]), angle=1.5707963267948966 rad), zoom: zoom=2.5, pan_target: Point3(1 mm, 2 mm, 3 mm) } 15 + orbit_rot_disp = orbit{ rot=axisangle{ axis=[0, 0, 1], angle=1.5707963267948966 rad }, zoom=2.5, target=(1 mm, 2 mm, 3 mm) } 16 + view_front = front 17 + view_isometric = isometric 18 + view_normal_to = normal_to
+22
crates/bone-types/tests/snapshots/surface__display_surface.snap
··· 1 + --- 2 + source: crates/bone-types/tests/surface.rs 3 + expression: surface 4 + --- 5 + display_default_disp = shaded_with_edges 6 + display_default_dbg = ShadedWithEdges 7 + wireframe_disp = wireframe 8 + wireframe_dbg = Wireframe 9 + hidden_removed_disp = hidden_line_removed 10 + hidden_removed_dbg = HiddenLineRemoved 11 + hidden_gray_disp = hidden_line_gray 12 + hidden_gray_dbg = HiddenLineGray 13 + shaded_edges_disp = shaded_with_edges 14 + shaded_edges_dbg = ShadedWithEdges 15 + shaded_no_edges_disp = shaded_no_edges 16 + shaded_no_edges_dbg = ShadedNoEdges 17 + shading_flat_disp = flat 18 + shading_flat_dbg = Flat 19 + shading_gouraud_disp = gouraud 20 + shading_gouraud_dbg = Gouraud 21 + shading_phong_disp = phong 22 + shading_phong_dbg = Phong
+10
crates/bone-types/tests/snapshots/surface__step_surface.snap
··· 1 + --- 2 + source: crates/bone-types/tests/surface.rs 3 + expression: surface 4 + --- 5 + entity_debug = StepEntityId(123) 6 + entity_display = #123 7 + schema_214_disp = AP214 8 + schema_242_disp = AP242E2 9 + header_debug = StepFileHeader { schema: Ap214, originating_system: StepOriginatingSystem("bone 0.0.0"), organization: StepOrganization("witchcraft.systems"), file_name: StepFileName("limpet.step") } 10 + header_display = step_header{ schema=AP214, originating_system=bone 0.0.0, organization=witchcraft.systems, file_name=limpet.step }
+124 -7
crates/bone-types/tests/surface.rs
··· 1 1 use bone_types::{ 2 2 Aabb3, Angle, AngleTolerance, AxisAngle, BodyId, BrepEdgeId, BrepFaceId, BrepLoopId, 3 - BrepShellId, BrepVertexId, BudgetCeiling, ChordHeightTolerance, DegreesOfFreedom, DocumentId, 4 - EdgeId, EdgeLabel, EdgeRole, ExtrudeId, FaceId, FaceLabel, FaceRole, FeatureId, ImportOrdinal, 5 - Length, LoopId, LoopIndex, NewtonDamping, NewtonStepTolerance, NodeId, OrientedBox3, Parameter, 6 - ParentIndex, Plane3, Point2, Point3, ShellId, SideKind, SketchDimensionId, SketchEntityId, 7 - SketchId, SketchItemId, SketchParameterId, SketchPlaneBasis, SketchRelationId, SolidId, 8 - SolverResidual, Tolerance, UnitVec2, UnitVec3, Vec2, Vec3, VertexId, VertexLabel, VertexRole, 9 - WireId, degree, millimeter, radian, 3 + BrepShellId, BrepVertexId, BudgetCeiling, Camera3, ChordHeightTolerance, DegreesOfFreedom, 4 + DisplayMode, DocumentId, EdgeId, EdgeLabel, EdgeRole, ExtrudeId, FaceId, FaceLabel, FaceRole, 5 + FeatureId, ImportOrdinal, Length, LoopId, LoopIndex, NewtonDamping, NewtonStepTolerance, 6 + NodeId, OrbitState, OrientedBox3, Parameter, ParentIndex, Plane3, Point2, Point3, Projection, 7 + ShadingModel, ShellId, SideKind, SketchDimensionId, SketchEntityId, SketchId, SketchItemId, 8 + SketchParameterId, SketchPlaneBasis, SketchRelationId, SolidId, SolverResidual, StandardView, 9 + StepEntityId, StepFileHeader, StepFileName, StepOrganization, StepOriginatingSystem, 10 + StepSchema, Tolerance, UnitVec2, UnitVec3, Vec2, Vec3, VertexId, VertexLabel, VertexRole, 11 + WireId, ZoomFactor, degree, millimeter, radian, 10 12 }; 11 13 use slotmap::Key; 12 14 ··· 336 338 ); 337 339 insta::assert_snapshot!(surface); 338 340 } 341 + 342 + #[test] 343 + fn camera_surface() { 344 + let Ok(ortho) = Projection::orthographic(Length::new::<millimeter>(25.0)) else { 345 + panic!("25 mm half-height is positive"); 346 + }; 347 + let Ok(persp) = Projection::perspective( 348 + Angle::new::<degree>(60.0), 349 + Length::new::<millimeter>(1.0), 350 + Length::new::<millimeter>(1000.0), 351 + ) else { 352 + panic!("valid perspective frustum"); 353 + }; 354 + let Ok(camera) = Camera3::new( 355 + Point3::from_mm(0.0, 0.0, 10.0), 356 + Point3::origin(), 357 + UnitVec3::y_axis(), 358 + persp, 359 + ) else { 360 + panic!("eye and target are 10 mm apart"); 361 + }; 362 + let Ok(zoom) = ZoomFactor::new(2.5) else { 363 + panic!("positive zoom"); 364 + }; 365 + let orbit_id = OrbitState::identity(); 366 + let Ok(orbit_rot) = OrbitState::new( 367 + AxisAngle::new( 368 + UnitVec3::z_axis(), 369 + Angle::new::<radian>(core::f64::consts::FRAC_PI_2), 370 + ), 371 + zoom, 372 + Point3::from_mm(1.0, 2.0, 3.0), 373 + ) else { 374 + panic!("finite pan target"); 375 + }; 376 + let surface = format!( 377 + "ortho_debug = {ortho:?}\n\ 378 + ortho_display = {ortho}\n\ 379 + persp_debug = {persp:?}\n\ 380 + persp_display = {persp}\n\ 381 + camera_debug = {camera:?}\n\ 382 + camera_display = {camera}\n\ 383 + zoom_display = {zoom}\n\ 384 + orbit_id_debug = {orbit_id:?}\n\ 385 + orbit_id_display = {orbit_id}\n\ 386 + orbit_rot_debug = {orbit_rot:?}\n\ 387 + orbit_rot_disp = {orbit_rot}\n\ 388 + view_front = {vf}\n\ 389 + view_isometric = {vi}\n\ 390 + view_normal_to = {vn}", 391 + zoom = ZoomFactor::IDENTITY, 392 + vf = StandardView::Front, 393 + vi = StandardView::Isometric, 394 + vn = StandardView::NormalTo, 395 + ); 396 + insta::assert_snapshot!(surface); 397 + } 398 + 399 + #[test] 400 + fn step_surface() { 401 + let header = StepFileHeader { 402 + schema: StepSchema::Ap214, 403 + originating_system: StepOriginatingSystem::new("bone 0.0.0"), 404 + organization: StepOrganization::new("witchcraft.systems"), 405 + file_name: StepFileName::new("limpet.step"), 406 + }; 407 + let Ok(entity) = StepEntityId::new(123) else { 408 + panic!("123 is a positive instance name"); 409 + }; 410 + let surface = format!( 411 + "entity_debug = {entity:?}\n\ 412 + entity_display = {entity}\n\ 413 + schema_214_disp = {s214}\n\ 414 + schema_242_disp = {s242}\n\ 415 + header_debug = {header:?}\n\ 416 + header_display = {header}", 417 + s214 = StepSchema::Ap214, 418 + s242 = StepSchema::Ap242E2, 419 + ); 420 + insta::assert_snapshot!(surface); 421 + } 422 + 423 + #[test] 424 + fn display_surface() { 425 + let surface = format!( 426 + "display_default_disp = {default}\n\ 427 + display_default_dbg = {default:?}\n\ 428 + wireframe_disp = {wf}\n\ 429 + wireframe_dbg = {wf:?}\n\ 430 + hidden_removed_disp = {hlr}\n\ 431 + hidden_removed_dbg = {hlr:?}\n\ 432 + hidden_gray_disp = {hlg}\n\ 433 + hidden_gray_dbg = {hlg:?}\n\ 434 + shaded_edges_disp = {swe}\n\ 435 + shaded_edges_dbg = {swe:?}\n\ 436 + shaded_no_edges_disp = {sne}\n\ 437 + shaded_no_edges_dbg = {sne:?}\n\ 438 + shading_flat_disp = {flat}\n\ 439 + shading_flat_dbg = {flat:?}\n\ 440 + shading_gouraud_disp = {gouraud}\n\ 441 + shading_gouraud_dbg = {gouraud:?}\n\ 442 + shading_phong_disp = {phong}\n\ 443 + shading_phong_dbg = {phong:?}", 444 + default = DisplayMode::DEFAULT, 445 + wf = DisplayMode::Wireframe, 446 + hlr = DisplayMode::HiddenLineRemoved, 447 + hlg = DisplayMode::HiddenLineGray, 448 + swe = DisplayMode::ShadedWithEdges, 449 + sne = DisplayMode::ShadedNoEdges, 450 + flat = ShadingModel::Flat, 451 + gouraud = ShadingModel::Gouraud, 452 + phong = ShadingModel::Phong, 453 + ); 454 + insta::assert_snapshot!(surface); 455 + }