Another project
1use bone_kernel::{
2 BrepSolid, Circle2, Curve2Kind, ExtrudeDirection, ExtrudeEndCondition, ExtrudeFeature,
3 ExtrudeProfile, ExtrudeSense, Line2, MergeResult, ProfileEdge, ProfileLoop, SolidMesh,
4 evaluate_extrude,
5};
6use bone_render::{
7 EdgeScene, OffscreenContext, SnapshotFrame, SolidRenderer, SolidScene, Style, frame_isometric,
8};
9use bone_types::{
10 Aabb3, Angle, AngleTolerance, Camera3, ChordHeightTolerance, DisplayMode, FeatureId, Length,
11 Plane3, Point2, Point3, PositiveLength, Projection, SketchEntityId, SketchId, Tolerance,
12 UnitVec3, millimeter, radian,
13};
14use slotmap::{Key, SlotMap};
15
16mod common;
17
18use common::{check_golden, extent_square as extent, make_context};
19
20const TOLERANCE: Tolerance = Tolerance::new(1.0e-9);
21const UPDATE_ENV: &str = "BONE_UPDATE_EDGES_GOLDENS";
22const DISPLAY_ENV: &str = "BONE_UPDATE_DISPLAY_GOLDENS";
23const CHORD_MM: f64 = 0.05;
24const ANGLE_RAD: f64 = 0.2;
25
26fn xy_plane() -> Plane3 {
27 let Ok(plane) = Plane3::new(
28 Point3::origin(),
29 UnitVec3::x_axis(),
30 UnitVec3::y_axis(),
31 TOLERANCE,
32 ) else {
33 panic!("x and y axes are orthonormal");
34 };
35 plane
36}
37
38fn blind(depth_mm: f64) -> ExtrudeFeature {
39 let Ok(depth) = PositiveLength::new(Length::new::<millimeter>(depth_mm)) else {
40 panic!("{depth_mm} mm is a positive length");
41 };
42 ExtrudeFeature {
43 sketch: SketchId::null(),
44 direction: ExtrudeDirection::Normal {
45 sense: ExtrudeSense::Forward,
46 },
47 end_condition: ExtrudeEndCondition::Blind { depth },
48 draft: None,
49 thin_wall: None,
50 merge_result: MergeResult::Merge,
51 }
52}
53
54fn unit_cube() -> BrepSolid {
55 let mut entities: SlotMap<SketchEntityId, ()> = SlotMap::with_key();
56 let mut features: SlotMap<FeatureId, ()> = SlotMap::with_key();
57 let corners = [
58 Point2::from_mm(0.0, 0.0),
59 Point2::from_mm(1.0, 0.0),
60 Point2::from_mm(1.0, 1.0),
61 Point2::from_mm(0.0, 1.0),
62 ];
63 let edges = (0..4)
64 .map(|index| {
65 let start = corners[index];
66 let end = corners[(index + 1) % 4];
67 let Ok(segment) = Line2::new(start, end, TOLERANCE) else {
68 panic!("rectangle endpoints are distinct");
69 };
70 ProfileEdge::new(
71 Curve2Kind::Line(segment),
72 entities.insert(()),
73 entities.insert(()),
74 )
75 })
76 .collect();
77 let profile = ExtrudeProfile::new(xy_plane(), vec![ProfileLoop::Open(edges)]);
78 let Ok(solid) = evaluate_extrude(features.insert(()), &profile, &blind(1.0)) else {
79 panic!("the unit square extrudes into a cube");
80 };
81 solid
82}
83
84fn cylinder() -> BrepSolid {
85 let mut entities: SlotMap<SketchEntityId, ()> = SlotMap::with_key();
86 let mut features: SlotMap<FeatureId, ()> = SlotMap::with_key();
87 let Ok(disk) = Circle2::new(
88 Point2::from_mm(0.0, 0.0),
89 Length::new::<millimeter>(5.0),
90 TOLERANCE,
91 ) else {
92 panic!("circle radius is positive");
93 };
94 let profile = ExtrudeProfile::new(
95 xy_plane(),
96 vec![ProfileLoop::Closed {
97 curve: Curve2Kind::Circle(disk),
98 curve_entity: entities.insert(()),
99 }],
100 );
101 let Ok(solid) = evaluate_extrude(features.insert(()), &profile, &blind(10.0)) else {
102 panic!("the disk extrudes into a cylinder");
103 };
104 solid
105}
106
107fn mesh_of(solid: &BrepSolid) -> SolidMesh {
108 let Ok(mesh) = solid.tessellate(
109 ChordHeightTolerance::from_mm(CHORD_MM),
110 AngleTolerance::from_radians(ANGLE_RAD),
111 ) else {
112 panic!("the solid tessellates");
113 };
114 mesh
115}
116
117fn scenes(solid: &BrepSolid) -> (SolidScene, EdgeScene) {
118 let mesh = mesh_of(solid);
119 let Ok(faces) = SolidScene::from_mesh(&mesh) else {
120 panic!("the mesh packs face pick ids");
121 };
122 let Ok(edges) = EdgeScene::from_solid(solid, &mesh, ChordHeightTolerance::from_mm(CHORD_MM))
123 else {
124 panic!("the solid packs edge pick ids");
125 };
126 (faces, edges)
127}
128
129fn aabb_of(solid: &BrepSolid) -> Aabb3 {
130 let Some(aabb) = solid.bounding_box() else {
131 panic!("the solid has a bounding box");
132 };
133 aabb
134}
135
136fn direction(x: f64, y: f64, z: f64) -> UnitVec3 {
137 let Ok(unit) = UnitVec3::try_from_components(x, y, z, TOLERANCE) else {
138 panic!("({x}, {y}, {z}) is a nonzero direction");
139 };
140 unit
141}
142
143fn framed(aabb: Aabb3, from: UnitVec3, up: UnitVec3) -> Camera3 {
144 let center = aabb.center();
145 let span = 0.5 * aabb.extent().norm_mm();
146 let eye = center + from.into_vec(Length::new::<millimeter>(span * 3.0));
147 let Ok(projection) = Projection::orthographic(Length::new::<millimeter>(span * 1.2)) else {
148 panic!("half height is positive");
149 };
150 let Ok(camera) = Camera3::new(eye, center, up, projection) else {
151 panic!("camera is non-degenerate");
152 };
153 camera
154}
155
156fn cube_camera(solid: &BrepSolid, size: bone_render::ViewportExtent) -> Camera3 {
157 let Ok(camera) = frame_isometric(aabb_of(solid), size) else {
158 panic!("the cube frames isometrically");
159 };
160 camera
161}
162
163fn framed_perspective(aabb: Aabb3, from: UnitVec3, up: UnitVec3) -> Camera3 {
164 let center = aabb.center();
165 let span = 0.5 * aabb.extent().norm_mm();
166 let eye = center + from.into_vec(Length::new::<millimeter>(span * 4.0));
167 let Ok(projection) = Projection::perspective(
168 Angle::new::<radian>(core::f64::consts::FRAC_PI_4),
169 Length::new::<millimeter>(span),
170 Length::new::<millimeter>(span * 12.0),
171 ) else {
172 panic!("the field of view is within (0, pi)");
173 };
174 let Ok(camera) = Camera3::new(eye, center, up, projection) else {
175 panic!("camera is non-degenerate");
176 };
177 camera
178}
179
180fn render(
181 ctx: &OffscreenContext,
182 faces: &SolidScene,
183 edges: &EdgeScene,
184 camera: Camera3,
185 mode: DisplayMode,
186) -> SnapshotFrame {
187 let mut renderer = SolidRenderer::new(ctx.gpu(), ctx.color_format());
188 let Ok(frame) = renderer.render_display(ctx, faces, edges, camera, &Style::default(), mode)
189 else {
190 panic!("SolidRenderer::render_display failed");
191 };
192 frame
193}
194
195fn cube_frame(mode: DisplayMode) -> SnapshotFrame {
196 let size = extent(256);
197 let ctx = make_context(size);
198 let solid = unit_cube();
199 let (faces, edges) = scenes(&solid);
200 render(&ctx, &faces, &edges, cube_camera(&solid, size), mode)
201}
202
203fn cylinder_frame(mode: DisplayMode) -> SnapshotFrame {
204 let size = extent(256);
205 let ctx = make_context(size);
206 let solid = cylinder();
207 let (faces, edges) = scenes(&solid);
208 let camera = framed(
209 aabb_of(&solid),
210 direction(1.0, 1.0, 0.6),
211 UnitVec3::z_axis(),
212 );
213 render(&ctx, &faces, &edges, camera, mode)
214}
215
216#[test]
217fn cube_shaded_with_edges_matches_golden() {
218 check_golden(
219 &cube_frame(DisplayMode::ShadedWithEdges),
220 "tests/goldens/shaded_with_edges_256.png",
221 UPDATE_ENV,
222 );
223}
224
225#[test]
226fn cube_hidden_line_removed_matches_golden() {
227 check_golden(
228 &cube_frame(DisplayMode::HiddenLineRemoved),
229 "tests/goldens/hidden_line_removed_256.png",
230 UPDATE_ENV,
231 );
232}
233
234#[test]
235fn cube_perspective_with_edges_matches_golden() {
236 let size = extent(256);
237 let ctx = make_context(size);
238 let solid = unit_cube();
239 let (faces, edges) = scenes(&solid);
240 let camera = framed_perspective(
241 aabb_of(&solid),
242 direction(1.0, 1.0, 0.6),
243 UnitVec3::z_axis(),
244 );
245 let frame = render(&ctx, &faces, &edges, camera, DisplayMode::ShadedWithEdges);
246 check_golden(
247 &frame,
248 "tests/goldens/perspective_with_edges_256.png",
249 UPDATE_ENV,
250 );
251}
252
253#[test]
254fn cylinder_shaded_with_edges_matches_golden() {
255 check_golden(
256 &cylinder_frame(DisplayMode::ShadedWithEdges),
257 "tests/goldens/shaded_with_edges_cylinder_256.png",
258 UPDATE_ENV,
259 );
260}
261
262#[test]
263fn cylinder_hidden_line_removed_matches_golden() {
264 check_golden(
265 &cylinder_frame(DisplayMode::HiddenLineRemoved),
266 "tests/goldens/hidden_line_removed_cylinder_256.png",
267 UPDATE_ENV,
268 );
269}
270
271#[test]
272fn cube_wireframe_matches_golden() {
273 check_golden(
274 &cube_frame(DisplayMode::Wireframe),
275 "tests/goldens/wireframe_256.png",
276 DISPLAY_ENV,
277 );
278}
279
280#[test]
281fn cylinder_wireframe_matches_golden() {
282 check_golden(
283 &cylinder_frame(DisplayMode::Wireframe),
284 "tests/goldens/wireframe_cylinder_256.png",
285 DISPLAY_ENV,
286 );
287}
288
289#[test]
290fn cube_hidden_line_gray_matches_golden() {
291 check_golden(
292 &cube_frame(DisplayMode::HiddenLineGray),
293 "tests/goldens/hidden_line_gray_256.png",
294 DISPLAY_ENV,
295 );
296}
297
298#[test]
299fn cylinder_hidden_line_gray_matches_golden() {
300 check_golden(
301 &cylinder_frame(DisplayMode::HiddenLineGray),
302 "tests/goldens/hidden_line_gray_cylinder_256.png",
303 DISPLAY_ENV,
304 );
305}
306
307#[test]
308fn cube_shaded_no_edges_matches_golden() {
309 check_golden(
310 &cube_frame(DisplayMode::ShadedNoEdges),
311 "tests/goldens/shaded_no_edges_256.png",
312 DISPLAY_ENV,
313 );
314}
315
316#[test]
317fn cylinder_shaded_no_edges_matches_golden() {
318 check_golden(
319 &cylinder_frame(DisplayMode::ShadedNoEdges),
320 "tests/goldens/shaded_no_edges_cylinder_256.png",
321 DISPLAY_ENV,
322 );
323}
324
325#[test]
326fn cube_genuine_edges_classify_as_creases() {
327 let solid = unit_cube();
328 let mesh = mesh_of(&solid);
329 let Ok(edges) = EdgeScene::from_solid(&solid, &mesh, ChordHeightTolerance::from_mm(CHORD_MM))
330 else {
331 panic!("the cube packs edge pick ids");
332 };
333 assert!(!edges.genuine().is_empty(), "the cube emits genuine edges");
334 let right_angle = core::f64::consts::FRAC_PI_2;
335 assert!(
336 edges
337 .genuine()
338 .iter()
339 .all(|edge| (edge.crease().radians() - right_angle).abs() < 1.0e-6),
340 "every cube edge is a 90-degree crease",
341 );
342}
343
344#[test]
345fn cube_flat_faces_emit_no_silhouette_candidates() {
346 let solid = unit_cube();
347 let mesh = mesh_of(&solid);
348 let Ok(edges) = EdgeScene::from_solid(&solid, &mesh, ChordHeightTolerance::from_mm(CHORD_MM))
349 else {
350 panic!("the cube packs edge pick ids");
351 };
352 assert!(
353 edges.silhouettes().is_empty(),
354 "coplanar interior edges never straddle, so a flat-faced cube yields no silhouette candidates",
355 );
356}
357
358#[test]
359fn cylinder_side_yields_silhouette_candidates() {
360 let solid = cylinder();
361 let mesh = mesh_of(&solid);
362 let Ok(edges) = EdgeScene::from_solid(&solid, &mesh, ChordHeightTolerance::from_mm(CHORD_MM))
363 else {
364 panic!("the cylinder packs edge pick ids");
365 };
366 assert!(
367 !edges.silhouettes().is_empty(),
368 "the tessellated cylinder side yields silhouette candidates",
369 );
370 assert!(
371 edges
372 .silhouettes()
373 .iter()
374 .any(|candidate| candidate.normal_a() != candidate.normal_b()),
375 "neighboring side facets disagree in orientation so a silhouette can form",
376 );
377}