Another project
0

Configure Feed

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

test(kernel): snapshot tests for 3d surfaces

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

author
Lewis
date (May 27, 2026, 8:19 PM +0300) commit a59d4960 parent 57404634 change-id lnvvkqso
+528
+32
crates/bone-kernel/tests/snapshots/surfaces3__cylinder_surface_full_circle.snap
··· 1 + --- 2 + source: crates/bone-kernel/tests/surfaces3.rs 3 + expression: "fmt_surface3(\"cyl_full\", &cyl, ChordHeightTolerance::from_mm(0.5),\nAngleTolerance::from_radians(1.0),)" 4 + --- 5 + cyl_full_display = cylinder_surface{ c=(0 mm, 0 mm, 0 mm), r=5 mm, start=0 rad, sweep=6.283185307179586 rad, h=10 mm, axis=[0, 0, 1] } 6 + cyl_full_debug = CylinderSurface { plane: Plane3 { origin: Point3(0 mm, 0 mm, 0 mm), x: UnitVec3([[1.0, 0.0, 0.0]]), y: UnitVec3([[0.0, 1.0, 0.0]]) }, radius: 0.005 m^1, start_angle: 0.0, sweep_angle: 6.283185307179586, height: 0.01 m^1 } 7 + cyl_full_samples: 8 + (u=0.00, v=0.00) -> pt=(5.000000, 0.000000, 0.000000) n=[1, 0, 0] du=<0 mm, 31.41592653589793 mm, 0 mm> dv=<0 mm, 0 mm, 10 mm> 9 + (u=0.50, v=0.00) -> pt=(-5.000000, 0.000000, 0.000000) n=[-1, 0.00000000000000012246467991473532, 0] du=<-0.00000000000000384734138744358 mm, -31.41592653589793 mm, -0 mm> dv=<0 mm, 0 mm, 10 mm> 10 + (u=1.00, v=0.00) -> pt=(5.000000, -0.000000, 0.000000) n=[1, -0.00000000000000024492935982947064, 0] du=<0.00000000000000769468277488716 mm, 31.41592653589793 mm, 0 mm> dv=<0 mm, 0 mm, 10 mm> 11 + (u=0.00, v=1.00) -> pt=(5.000000, 0.000000, 10.000000) n=[1, 0, 0] du=<0 mm, 31.41592653589793 mm, 0 mm> dv=<0 mm, 0 mm, 10 mm> 12 + (u=0.50, v=0.50) -> pt=(-5.000000, 0.000000, 5.000000) n=[-1, 0.00000000000000012246467991473532, 0] du=<-0.00000000000000384734138744358 mm, -31.41592653589793 mm, -0 mm> dv=<0 mm, 0 mm, 10 mm> 13 + (u=1.00, v=1.00) -> pt=(5.000000, -0.000000, 10.000000) n=[1, -0.00000000000000024492935982947064, 0] du=<0.00000000000000769468277488716 mm, 31.41592653589793 mm, 0 mm> dv=<0 mm, 0 mm, 10 mm> 14 + cyl_full_bbox = aabb[(-5 mm, -5 mm, 0 mm)..(5 mm, 5 mm, 10 mm)] 15 + cyl_full_mesh (verts=16, tris=14): 16 + (5.000000, 0.000000, 0.000000) n=[1, 0, 0] 17 + (3.117449, 3.909157, 0.000000) n=[0.6234898018587336, 0.7818314824680298, 0] 18 + (-1.112605, 4.874640, 0.000000) n=[-0.22252093395631434, 0.9749279121818236, 0] 19 + (-4.504844, 2.169419, 0.000000) n=[-0.900968867902419, 0.43388373911755823, 0] 20 + (-4.504844, -2.169419, 0.000000) n=[-0.9009688679024191, -0.433883739117558, 0] 21 + (-1.112605, -4.874640, 0.000000) n=[-0.22252093395631545, -0.9749279121818234, 0] 22 + (3.117449, -3.909157, 0.000000) n=[0.6234898018587334, -0.7818314824680299, 0] 23 + (5.000000, -0.000000, 0.000000) n=[1, -0.00000000000000024492935982947064, 0] 24 + (5.000000, 0.000000, 10.000000) n=[1, 0, 0] 25 + (3.117449, 3.909157, 10.000000) n=[0.6234898018587336, 0.7818314824680298, 0] 26 + (-1.112605, 4.874640, 10.000000) n=[-0.22252093395631434, 0.9749279121818236, 0] 27 + (-4.504844, 2.169419, 10.000000) n=[-0.900968867902419, 0.43388373911755823, 0] 28 + (-4.504844, -2.169419, 10.000000) n=[-0.9009688679024191, -0.433883739117558, 0] 29 + (-1.112605, -4.874640, 10.000000) n=[-0.22252093395631545, -0.9749279121818234, 0] 30 + (3.117449, -3.909157, 10.000000) n=[0.6234898018587334, -0.7818314824680299, 0] 31 + (5.000000, -0.000000, 10.000000) n=[1, -0.00000000000000024492935982947064, 0] 32 + cyl_full_triangles: [0, 1, 9] [0, 9, 8] [1, 2, 10] [1, 10, 9] [2, 3, 11] [2, 11, 10] [3, 4, 12] [3, 12, 11] [4, 5, 13] [4, 13, 12] [5, 6, 14] [5, 14, 13] [6, 7, 15] [6, 15, 14]
+26
crates/bone-kernel/tests/snapshots/surfaces3__cylinder_surface_half_wall.snap
··· 1 + --- 2 + source: crates/bone-kernel/tests/surfaces3.rs 3 + expression: "fmt_surface3(\"cyl_half\", &cyl, ChordHeightTolerance::from_mm(0.5),\nAngleTolerance::from_radians(1.0),)" 4 + --- 5 + cyl_half_display = cylinder_surface{ c=(1 mm, 0 mm, 0 mm), r=2 mm, start=0 rad, sweep=3.141592653589793 rad, h=4 mm, axis=[0, -1, 0] } 6 + cyl_half_debug = CylinderSurface { plane: Plane3 { origin: Point3(1 mm, 0 mm, 0 mm), x: UnitVec3([[1.0, 0.0, 0.0]]), y: UnitVec3([[0.0, 0.0, 1.0]]) }, radius: 0.002 m^1, start_angle: 0.0, sweep_angle: 3.141592653589793, height: 0.004 m^1 } 7 + cyl_half_samples: 8 + (u=0.00, v=0.00) -> pt=(3.000000, 0.000000, 0.000000) n=[1, 0, 0] du=<0 mm, 0 mm, 6.283185307179586 mm> dv=<0 mm, -4 mm, 0 mm> 9 + (u=0.50, v=0.00) -> pt=(1.000000, 0.000000, 2.000000) n=[0.00000000000000006123233995736766, 0, 1] du=<-6.283185307179586 mm, 0 mm, 0.00000000000000038473413874435795 mm> dv=<0 mm, -4 mm, 0 mm> 10 + (u=1.00, v=0.00) -> pt=(-1.000000, 0.000000, 0.000000) n=[-1, 0, 0.00000000000000012246467991473532] du=<-0.0000000000000007694682774887159 mm, -0 mm, -6.283185307179586 mm> dv=<0 mm, -4 mm, 0 mm> 11 + (u=0.00, v=1.00) -> pt=(3.000000, -4.000000, 0.000000) n=[1, 0, 0] du=<0 mm, 0 mm, 6.283185307179586 mm> dv=<0 mm, -4 mm, 0 mm> 12 + (u=0.50, v=0.50) -> pt=(1.000000, -2.000000, 2.000000) n=[0.00000000000000006123233995736766, 0, 1] du=<-6.283185307179586 mm, 0 mm, 0.00000000000000038473413874435795 mm> dv=<0 mm, -4 mm, 0 mm> 13 + (u=1.00, v=1.00) -> pt=(-1.000000, -4.000000, 0.000000) n=[-1, 0, 0.00000000000000012246467991473532] du=<-0.0000000000000007694682774887159 mm, -0 mm, -6.283185307179586 mm> dv=<0 mm, -4 mm, 0 mm> 14 + cyl_half_bbox = aabb[(-1 mm, -4 mm, 0 mm)..(3 mm, 0 mm, 2 mm)] 15 + cyl_half_mesh (verts=10, tris=8): 16 + (3.000000, 0.000000, 0.000000) n=[1, 0, 0] 17 + (2.414214, 0.000000, 1.414214) n=[0.7071067811865476, 0, 0.7071067811865475] 18 + (1.000000, 0.000000, 2.000000) n=[0.00000000000000006123233995736766, 0, 1] 19 + (-0.414214, 0.000000, 1.414214) n=[-0.7071067811865475, 0, 0.7071067811865476] 20 + (-1.000000, 0.000000, 0.000000) n=[-1, 0, 0.00000000000000012246467991473532] 21 + (3.000000, -4.000000, 0.000000) n=[1, 0, 0] 22 + (2.414214, -4.000000, 1.414214) n=[0.7071067811865476, 0, 0.7071067811865475] 23 + (1.000000, -4.000000, 2.000000) n=[0.00000000000000006123233995736766, 0, 1] 24 + (-0.414214, -4.000000, 1.414214) n=[-0.7071067811865475, 0, 0.7071067811865476] 25 + (-1.000000, -4.000000, 0.000000) n=[-1, 0, 0.00000000000000012246467991473532] 26 + cyl_half_triangles: [0, 1, 6] [0, 6, 5] [1, 2, 7] [1, 7, 6] [2, 3, 8] [2, 8, 7] [3, 4, 9] [3, 9, 8]
+20
crates/bone-kernel/tests/snapshots/surfaces3__plane_surface_axis_aligned.snap
··· 1 + --- 2 + source: crates/bone-kernel/tests/surfaces3.rs 3 + expression: "fmt_surface3(\"plane\", &plane, ChordHeightTolerance::from_mm(0.5),\nAngleTolerance::from_radians(1.0),)" 4 + --- 5 + plane_display = plane_surface{ o=(1 mm, 2 mm, 3 mm), normal=[0, 0, 1], u=4 mm, v=3 mm } 6 + plane_debug = PlaneSurface { plane: Plane3 { origin: Point3(1 mm, 2 mm, 3 mm), x: UnitVec3([[1.0, 0.0, 0.0]]), y: UnitVec3([[0.0, 1.0, 0.0]]) }, u_extent: 0.004 m^1, v_extent: 0.003 m^1 } 7 + plane_samples: 8 + (u=0.00, v=0.00) -> pt=(1.000000, 2.000000, 3.000000) n=[0, 0, 1] du=<4 mm, 0 mm, 0 mm> dv=<0 mm, 3 mm, 0 mm> 9 + (u=0.50, v=0.00) -> pt=(3.000000, 2.000000, 3.000000) n=[0, 0, 1] du=<4 mm, 0 mm, 0 mm> dv=<0 mm, 3 mm, 0 mm> 10 + (u=1.00, v=0.00) -> pt=(5.000000, 2.000000, 3.000000) n=[0, 0, 1] du=<4 mm, 0 mm, 0 mm> dv=<0 mm, 3 mm, 0 mm> 11 + (u=0.00, v=1.00) -> pt=(1.000000, 5.000000, 3.000000) n=[0, 0, 1] du=<4 mm, 0 mm, 0 mm> dv=<0 mm, 3 mm, 0 mm> 12 + (u=0.50, v=0.50) -> pt=(3.000000, 3.500000, 3.000000) n=[0, 0, 1] du=<4 mm, 0 mm, 0 mm> dv=<0 mm, 3 mm, 0 mm> 13 + (u=1.00, v=1.00) -> pt=(5.000000, 5.000000, 3.000000) n=[0, 0, 1] du=<4 mm, 0 mm, 0 mm> dv=<0 mm, 3 mm, 0 mm> 14 + plane_bbox = aabb[(1 mm, 2 mm, 3 mm)..(5 mm, 5 mm, 3 mm)] 15 + plane_mesh (verts=4, tris=2): 16 + (1.000000, 2.000000, 3.000000) n=[0, 0, 1] 17 + (5.000000, 2.000000, 3.000000) n=[0, 0, 1] 18 + (1.000000, 5.000000, 3.000000) n=[0, 0, 1] 19 + (5.000000, 5.000000, 3.000000) n=[0, 0, 1] 20 + plane_triangles: [0, 1, 3] [0, 3, 2]
+20
crates/bone-kernel/tests/snapshots/surfaces3__plane_surface_tilted.snap
··· 1 + --- 2 + source: crates/bone-kernel/tests/surfaces3.rs 3 + expression: "fmt_surface3(\"plane_tilted\", &plane, ChordHeightTolerance::from_mm(0.5),\nAngleTolerance::from_radians(1.0),)" 4 + --- 5 + plane_tilted_display = plane_surface{ o=(0 mm, 1 mm, 0 mm), normal=[0, -1, 0], u=2 mm, v=5 mm } 6 + plane_tilted_debug = PlaneSurface { plane: Plane3 { origin: Point3(0 mm, 1 mm, 0 mm), x: UnitVec3([[1.0, 0.0, 0.0]]), y: UnitVec3([[0.0, 0.0, 1.0]]) }, u_extent: 0.002 m^1, v_extent: 0.005 m^1 } 7 + plane_tilted_samples: 8 + (u=0.00, v=0.00) -> pt=(0.000000, 1.000000, 0.000000) n=[0, -1, 0] du=<2 mm, 0 mm, 0 mm> dv=<0 mm, 0 mm, 5 mm> 9 + (u=0.50, v=0.00) -> pt=(1.000000, 1.000000, 0.000000) n=[0, -1, 0] du=<2 mm, 0 mm, 0 mm> dv=<0 mm, 0 mm, 5 mm> 10 + (u=1.00, v=0.00) -> pt=(2.000000, 1.000000, 0.000000) n=[0, -1, 0] du=<2 mm, 0 mm, 0 mm> dv=<0 mm, 0 mm, 5 mm> 11 + (u=0.00, v=1.00) -> pt=(0.000000, 1.000000, 5.000000) n=[0, -1, 0] du=<2 mm, 0 mm, 0 mm> dv=<0 mm, 0 mm, 5 mm> 12 + (u=0.50, v=0.50) -> pt=(1.000000, 1.000000, 2.500000) n=[0, -1, 0] du=<2 mm, 0 mm, 0 mm> dv=<0 mm, 0 mm, 5 mm> 13 + (u=1.00, v=1.00) -> pt=(2.000000, 1.000000, 5.000000) n=[0, -1, 0] du=<2 mm, 0 mm, 0 mm> dv=<0 mm, 0 mm, 5 mm> 14 + plane_tilted_bbox = aabb[(0 mm, 1 mm, 0 mm)..(2 mm, 1 mm, 5 mm)] 15 + plane_tilted_mesh (verts=4, tris=2): 16 + (0.000000, 1.000000, 0.000000) n=[0, -1, 0] 17 + (2.000000, 1.000000, 0.000000) n=[0, -1, 0] 18 + (0.000000, 1.000000, 5.000000) n=[0, -1, 0] 19 + (2.000000, 1.000000, 5.000000) n=[0, -1, 0] 20 + plane_tilted_triangles: [0, 1, 3] [0, 3, 2]
+430
crates/bone-kernel/tests/surfaces3.rs
··· 1 + use bone_kernel::{CylinderSurface, PlaneSurface, Surface3, TriMesh}; 2 + use bone_types::{ 3 + Angle, AngleTolerance, ChordHeightTolerance, Length, Parameter, Plane3, Point3, Tolerance, 4 + UnitVec3, Vec3, 5 + }; 6 + use core::f64::consts::{FRAC_PI_2, PI, TAU}; 7 + use core::fmt::{Debug, Display}; 8 + use uom::si::angle::radian; 9 + use uom::si::length::millimeter; 10 + 11 + const TOL: Tolerance = Tolerance::new(1e-9); 12 + 13 + fn mm(value: f64) -> Length { 14 + Length::new::<millimeter>(value) 15 + } 16 + 17 + fn rad(value: f64) -> Angle { 18 + Angle::new::<radian>(value) 19 + } 20 + 21 + fn p(u: f64) -> Parameter { 22 + Parameter::new(u) 23 + } 24 + 25 + fn fmt_point(point: Point3) -> String { 26 + let (x, y, z) = point.coords_mm(); 27 + format!("({x:.6}, {y:.6}, {z:.6})") 28 + } 29 + 30 + fn xy_plane(origin: Point3) -> Plane3 { 31 + Plane3::new_unchecked(origin, UnitVec3::x_axis(), UnitVec3::y_axis()) 32 + } 33 + 34 + fn xz_plane(origin: Point3) -> Plane3 { 35 + Plane3::new_unchecked(origin, UnitVec3::x_axis(), UnitVec3::z_axis()) 36 + } 37 + 38 + fn dot_un(vector: Vec3, unit: UnitVec3) -> f64 { 39 + vector.dot_mm2(unit.into_vec(mm(1.0))) 40 + } 41 + 42 + fn fmt_surface3<S: Surface3 + Display + Debug>( 43 + name: &str, 44 + s: &S, 45 + chord: ChordHeightTolerance, 46 + angle: AngleTolerance, 47 + ) -> String { 48 + let samples = [ 49 + (0.0, 0.0), 50 + (0.5, 0.0), 51 + (1.0, 0.0), 52 + (0.0, 1.0), 53 + (0.5, 0.5), 54 + (1.0, 1.0), 55 + ]; 56 + let rows = samples 57 + .iter() 58 + .map(|&(u, v)| { 59 + let (du, dv) = s.partials(p(u), p(v)); 60 + format!( 61 + "(u={u:.2}, v={v:.2}) -> pt={} n={} du={} dv={}", 62 + fmt_point(s.evaluate(p(u), p(v))), 63 + s.normal(p(u), p(v)), 64 + du, 65 + dv, 66 + ) 67 + }) 68 + .collect::<Vec<_>>() 69 + .join("\n "); 70 + let mesh = s.tessellate(chord, angle); 71 + let verts = mesh 72 + .vertices() 73 + .iter() 74 + .map(|mv| format!("{} n={}", fmt_point(mv.position()), mv.normal())) 75 + .collect::<Vec<_>>() 76 + .join("\n "); 77 + let tris = mesh 78 + .triangles() 79 + .iter() 80 + .map(|t| format!("{t:?}")) 81 + .collect::<Vec<_>>() 82 + .join(" "); 83 + format!( 84 + "{name}_display = {s}\n\ 85 + {name}_debug = {s:?}\n\ 86 + {name}_samples:\n {rows}\n\ 87 + {name}_bbox = {}\n\ 88 + {name}_mesh (verts={}, tris={}):\n {verts}\n\ 89 + {name}_triangles: {tris}", 90 + s.bounding_box(), 91 + mesh.vertices().len(), 92 + mesh.triangles().len(), 93 + ) 94 + } 95 + 96 + fn full_cylinder(plane: Plane3, radius: Length, height: Length) -> CylinderSurface { 97 + let Ok(cyl) = CylinderSurface::new(plane, radius, rad(0.0), rad(TAU), height, TOL) else { 98 + panic!("nondegenerate cylinder"); 99 + }; 100 + cyl 101 + } 102 + 103 + fn neg_sweep_cylinder() -> CylinderSurface { 104 + let Ok(cyl) = CylinderSurface::new( 105 + xz_plane(Point3::from_mm(1.0, -2.0, 0.0)), 106 + mm(3.0), 107 + rad(PI), 108 + rad(-PI), 109 + mm(5.0), 110 + TOL, 111 + ) else { 112 + panic!("nondegenerate cylinder"); 113 + }; 114 + cyl 115 + } 116 + 117 + #[test] 118 + fn plane_surface_axis_aligned() { 119 + let Ok(plane) = PlaneSurface::new( 120 + xy_plane(Point3::from_mm(1.0, 2.0, 3.0)), 121 + mm(4.0), 122 + mm(3.0), 123 + TOL, 124 + ) else { 125 + panic!("positive extents"); 126 + }; 127 + insta::assert_snapshot!(fmt_surface3( 128 + "plane", 129 + &plane, 130 + ChordHeightTolerance::from_mm(0.5), 131 + AngleTolerance::from_radians(1.0), 132 + )); 133 + } 134 + 135 + #[test] 136 + fn plane_surface_tilted() { 137 + let Ok(plane) = PlaneSurface::new( 138 + xz_plane(Point3::from_mm(0.0, 1.0, 0.0)), 139 + mm(2.0), 140 + mm(5.0), 141 + TOL, 142 + ) else { 143 + panic!("positive extents"); 144 + }; 145 + insta::assert_snapshot!(fmt_surface3( 146 + "plane_tilted", 147 + &plane, 148 + ChordHeightTolerance::from_mm(0.5), 149 + AngleTolerance::from_radians(1.0), 150 + )); 151 + } 152 + 153 + #[test] 154 + fn cylinder_surface_full_circle() { 155 + let cyl = full_cylinder(xy_plane(Point3::origin()), mm(5.0), mm(10.0)); 156 + insta::assert_snapshot!(fmt_surface3( 157 + "cyl_full", 158 + &cyl, 159 + ChordHeightTolerance::from_mm(0.5), 160 + AngleTolerance::from_radians(1.0), 161 + )); 162 + } 163 + 164 + #[test] 165 + fn cylinder_surface_half_wall() { 166 + let Ok(cyl) = CylinderSurface::new( 167 + xz_plane(Point3::from_mm(1.0, 0.0, 0.0)), 168 + mm(2.0), 169 + rad(0.0), 170 + rad(PI), 171 + mm(4.0), 172 + TOL, 173 + ) else { 174 + panic!("nondegenerate cylinder"); 175 + }; 176 + insta::assert_snapshot!(fmt_surface3( 177 + "cyl_half", 178 + &cyl, 179 + ChordHeightTolerance::from_mm(0.5), 180 + AngleTolerance::from_radians(1.0), 181 + )); 182 + } 183 + 184 + #[test] 185 + fn plane_surface_rejects_zero_extent() { 186 + assert!(PlaneSurface::new(xy_plane(Point3::origin()), mm(0.0), mm(3.0), TOL).is_err()); 187 + assert!(PlaneSurface::new(xy_plane(Point3::origin()), mm(3.0), mm(0.0), TOL).is_err()); 188 + } 189 + 190 + #[test] 191 + fn cylinder_surface_rejects_degenerate() { 192 + let plane = xy_plane(Point3::origin()); 193 + assert!(CylinderSurface::new(plane, mm(0.0), rad(0.0), rad(TAU), mm(1.0), TOL).is_err()); 194 + assert!(CylinderSurface::new(plane, mm(1.0), rad(0.0), rad(TAU), mm(0.0), TOL).is_err()); 195 + assert!(CylinderSurface::new(plane, mm(1.0), rad(0.0), rad(0.0), mm(1.0), TOL).is_err()); 196 + assert!(CylinderSurface::new(plane, mm(1.0), rad(0.0), rad(3.0 * PI), mm(1.0), TOL).is_err()); 197 + } 198 + 199 + #[test] 200 + fn plane_surface_rejects_non_finite() { 201 + let plane = xy_plane(Point3::origin()); 202 + assert!(PlaneSurface::new(plane, mm(f64::NAN), mm(3.0), TOL).is_err()); 203 + assert!(PlaneSurface::new(plane, mm(4.0), mm(f64::NAN), TOL).is_err()); 204 + assert!(PlaneSurface::new(plane, mm(f64::INFINITY), mm(3.0), TOL).is_err()); 205 + } 206 + 207 + #[test] 208 + fn cylinder_surface_rejects_non_finite() { 209 + let plane = xy_plane(Point3::origin()); 210 + assert!(CylinderSurface::new(plane, mm(f64::NAN), rad(0.0), rad(PI), mm(1.0), TOL).is_err()); 211 + assert!(CylinderSurface::new(plane, mm(1.0), rad(0.0), rad(PI), mm(f64::NAN), TOL).is_err()); 212 + assert!(CylinderSurface::new(plane, mm(1.0), rad(0.0), rad(f64::NAN), mm(1.0), TOL).is_err()); 213 + assert!(CylinderSurface::new(plane, mm(1.0), rad(0.0), rad(f64::INFINITY), mm(1.0), TOL).is_err()); 214 + } 215 + 216 + #[test] 217 + fn plane_surface_bbox_spans_corners() { 218 + let Ok(plane) = PlaneSurface::new( 219 + xy_plane(Point3::from_mm(1.0, 2.0, 5.0)), 220 + mm(4.0), 221 + mm(3.0), 222 + TOL, 223 + ) else { 224 + panic!("positive extents"); 225 + }; 226 + let bbox = plane.bounding_box(); 227 + let (lx, ly, lz) = bbox.min().coords_mm(); 228 + let (hx, hy, hz) = bbox.max().coords_mm(); 229 + assert!((lx - 1.0).abs() < 1e-9 && (hx - 5.0).abs() < 1e-9); 230 + assert!((ly - 2.0).abs() < 1e-9 && (hy - 5.0).abs() < 1e-9); 231 + assert!((lz - 5.0).abs() < 1e-9 && (hz - 5.0).abs() < 1e-9); 232 + } 233 + 234 + #[test] 235 + fn cylinder_surface_bbox_spans_tube() { 236 + let cyl = full_cylinder(xy_plane(Point3::origin()), mm(5.0), mm(10.0)); 237 + let bbox = cyl.bounding_box(); 238 + let (lx, ly, lz) = bbox.min().coords_mm(); 239 + let (hx, hy, hz) = bbox.max().coords_mm(); 240 + assert!((lx + 5.0).abs() < 1e-9 && (hx - 5.0).abs() < 1e-9); 241 + assert!((ly + 5.0).abs() < 1e-9 && (hy - 5.0).abs() < 1e-9); 242 + assert!(lz.abs() < 1e-9 && (hz - 10.0).abs() < 1e-9); 243 + } 244 + 245 + #[test] 246 + fn cylinder_neg_sweep_bbox_spans_arc() { 247 + let bbox = neg_sweep_cylinder().bounding_box(); 248 + let (lx, ly, lz) = bbox.min().coords_mm(); 249 + let (hx, hy, hz) = bbox.max().coords_mm(); 250 + assert!((lx + 2.0).abs() < 1e-9 && (hx - 4.0).abs() < 1e-9); 251 + assert!((ly + 7.0).abs() < 1e-9 && (hy + 2.0).abs() < 1e-9); 252 + assert!(lz.abs() < 1e-9 && (hz - 3.0).abs() < 1e-9); 253 + } 254 + 255 + fn assert_partials_orthogonal_to_normal<S: Surface3>(name: &str, s: &S) { 256 + let grid = [(0.0, 0.0), (0.3, 0.7), (0.5, 0.5), (1.0, 1.0), (0.8, 0.2)]; 257 + grid.iter().for_each(|&(u, v)| { 258 + let (du, dv) = s.partials(p(u), p(v)); 259 + let n = s.normal(p(u), p(v)); 260 + assert!( 261 + dot_un(du, n).abs() < 1e-9, 262 + "{name}: du not orthogonal to normal at ({u},{v})" 263 + ); 264 + assert!( 265 + dot_un(dv, n).abs() < 1e-9, 266 + "{name}: dv not orthogonal to normal at ({u},{v})" 267 + ); 268 + }); 269 + } 270 + 271 + #[test] 272 + fn normal_is_orthogonal_to_partials() { 273 + let Ok(plane) = PlaneSurface::new( 274 + xz_plane(Point3::from_mm(1.0, 2.0, 3.0)), 275 + mm(4.0), 276 + mm(3.0), 277 + TOL, 278 + ) else { 279 + panic!("positive extents"); 280 + }; 281 + assert_partials_orthogonal_to_normal("plane", &plane); 282 + let cyl = full_cylinder(xz_plane(Point3::from_mm(2.0, 0.0, -1.0)), mm(3.0), mm(7.0)); 283 + assert_partials_orthogonal_to_normal("cylinder", &cyl); 284 + assert_partials_orthogonal_to_normal("neg_cylinder", &neg_sweep_cylinder()); 285 + } 286 + 287 + fn assert_normal_matches_cross<S: Surface3>(name: &str, s: &S) { 288 + let grid = [(0.0, 0.5), (0.4, 0.0), (1.0, 1.0)]; 289 + grid.iter().for_each(|&(u, v)| { 290 + let (du, dv) = s.partials(p(u), p(v)); 291 + let Ok(cross) = du.cross(dv).try_normalize(TOL) else { 292 + panic!("{name}: degenerate partials at ({u},{v})"); 293 + }; 294 + let n = s.normal(p(u), p(v)); 295 + let (cx, cy, cz) = cross.components(); 296 + let (nx, ny, nz) = n.components(); 297 + assert!( 298 + (cx - nx).abs() < 1e-9 && (cy - ny).abs() < 1e-9 && (cz - nz).abs() < 1e-9, 299 + "{name}: normal disagrees with normalize(du x dv) at ({u},{v})" 300 + ); 301 + }); 302 + } 303 + 304 + #[test] 305 + fn normal_agrees_with_partial_cross_product() { 306 + let Ok(plane) = PlaneSurface::new(xy_plane(Point3::origin()), mm(4.0), mm(3.0), TOL) else { 307 + panic!("positive extents"); 308 + }; 309 + assert_normal_matches_cross("plane", &plane); 310 + let cyl = full_cylinder(xy_plane(Point3::origin()), mm(5.0), mm(10.0)); 311 + assert_normal_matches_cross("cylinder", &cyl); 312 + assert_normal_matches_cross("neg_cylinder", &neg_sweep_cylinder()); 313 + } 314 + 315 + fn assert_winding_matches_normals<S: Surface3>(name: &str, s: &S) { 316 + let mesh: TriMesh = s.tessellate( 317 + ChordHeightTolerance::from_mm(0.25), 318 + AngleTolerance::from_radians(0.4), 319 + ); 320 + let verts = mesh.vertices(); 321 + mesh.triangles().iter().for_each(|&[a, b, c]| { 322 + let va = verts[a as usize]; 323 + let vb = verts[b as usize]; 324 + let vc = verts[c as usize]; 325 + let face = (vb.position() - va.position()).cross(vc.position() - va.position()); 326 + [va, vb, vc].iter().for_each(|vert| { 327 + assert!( 328 + dot_un(face, vert.normal()) > 0.0, 329 + "{name}: triangle [{a},{b},{c}] winds against vertex normal" 330 + ); 331 + }); 332 + }); 333 + } 334 + 335 + #[test] 336 + fn tessellation_winding_follows_normals() { 337 + let Ok(plane) = PlaneSurface::new(xy_plane(Point3::origin()), mm(4.0), mm(3.0), TOL) else { 338 + panic!("positive extents"); 339 + }; 340 + assert_winding_matches_normals("plane", &plane); 341 + let cyl = full_cylinder(xy_plane(Point3::origin()), mm(5.0), mm(10.0)); 342 + assert_winding_matches_normals("cylinder", &cyl); 343 + let Ok(arc_wall) = CylinderSurface::new( 344 + xz_plane(Point3::origin()), 345 + mm(2.0), 346 + rad(FRAC_PI_2), 347 + rad(PI), 348 + mm(3.0), 349 + TOL, 350 + ) else { 351 + panic!("nondegenerate cylinder"); 352 + }; 353 + assert_winding_matches_normals("arc_wall", &arc_wall); 354 + assert_winding_matches_normals("neg_wall", &neg_sweep_cylinder()); 355 + } 356 + 357 + #[test] 358 + fn tessellation_is_deterministic() { 359 + let cyl = full_cylinder(xy_plane(Point3::from_mm(1.0, -2.0, 0.5)), mm(4.0), mm(6.0)); 360 + let chord = ChordHeightTolerance::from_mm(0.3); 361 + let angle = AngleTolerance::from_radians(0.5); 362 + assert_eq!(cyl.tessellate(chord, angle), cyl.tessellate(chord, angle)); 363 + } 364 + 365 + #[test] 366 + fn cylinder_tessellation_count_responds_to_both_tolerances() { 367 + let cyl = full_cylinder(xy_plane(Point3::origin()), mm(5.0), mm(10.0)); 368 + let coarse = cyl.tessellate( 369 + ChordHeightTolerance::from_mm(1.0), 370 + AngleTolerance::from_radians(1.5), 371 + ); 372 + let chord_driven = cyl.tessellate( 373 + ChordHeightTolerance::from_mm(0.05), 374 + AngleTolerance::from_radians(1.5), 375 + ); 376 + let angle_driven = cyl.tessellate( 377 + ChordHeightTolerance::from_mm(1.0), 378 + AngleTolerance::from_radians(0.1), 379 + ); 380 + assert!(chord_driven.vertices().len() > coarse.vertices().len()); 381 + assert!(angle_driven.vertices().len() > coarse.vertices().len()); 382 + } 383 + 384 + #[test] 385 + fn plane_surface_membership() { 386 + let Ok(plane) = PlaneSurface::new( 387 + xy_plane(Point3::from_mm(1.0, 1.0, 2.0)), 388 + mm(4.0), 389 + mm(3.0), 390 + TOL, 391 + ) else { 392 + panic!("positive extents"); 393 + }; 394 + assert!(plane.contains_point(plane.evaluate(p(0.5), p(0.5)), Tolerance::new(1e-6))); 395 + assert!(!plane.contains_point(Point3::from_mm(2.0, 2.0, 3.0), Tolerance::new(1e-6))); 396 + assert!(!plane.contains_point(Point3::from_mm(20.0, 2.0, 2.0), Tolerance::new(1e-6))); 397 + } 398 + 399 + #[test] 400 + fn cylinder_surface_membership() { 401 + let Ok(cyl) = CylinderSurface::new( 402 + xy_plane(Point3::origin()), 403 + mm(5.0), 404 + rad(0.0), 405 + rad(PI), 406 + mm(10.0), 407 + TOL, 408 + ) else { 409 + panic!("nondegenerate cylinder"); 410 + }; 411 + let on = Tolerance::new(1e-6); 412 + assert!(cyl.contains_point(cyl.evaluate(p(0.5), p(0.5)), on)); 413 + assert!(!cyl.contains_point(Point3::from_mm(6.0, 0.0, 5.0), on)); 414 + assert!(!cyl.contains_point(Point3::from_mm(5.0, 0.0, 12.0), on)); 415 + assert!(!cyl.contains_point(Point3::from_mm(0.0, -5.0, 5.0), on)); 416 + } 417 + 418 + #[test] 419 + fn cylinder_full_circle_membership_wraps() { 420 + let cyl = full_cylinder(xy_plane(Point3::origin()), mm(5.0), mm(10.0)); 421 + let on = Tolerance::new(1e-6); 422 + [0.0, 0.25, 0.5, 0.75].into_iter().for_each(|u| { 423 + assert!( 424 + cyl.contains_point(cyl.evaluate(p(u), p(0.5)), on), 425 + "full sweep should contain its surface point at u={u}" 426 + ); 427 + }); 428 + assert!(!cyl.contains_point(Point3::from_mm(0.0, 0.0, 5.0), on)); 429 + assert!(!cyl.contains_point(Point3::from_mm(5.0, 0.0, 12.0), on)); 430 + }