Another project
1use bone_kernel::{
2 Arc2, BrepSolid, Circle2, Curve2Kind, Curve3, EdgeCurve3, EdgePolyline, ExtrudeDirection,
3 ExtrudeEndCondition, ExtrudeFeature, ExtrudeProfile, ExtrudeSense, Line2, MergeResult,
4 ProfileEdge, ProfileLoop, evaluate_extrude,
5};
6use bone_types::{
7 Angle, ChordHeightTolerance, EdgeRole, FeatureId, Length, Plane3, Point2, PositiveLength,
8 SideKind, SketchEntityId, SketchId, Tolerance, UnitVec3, degree, millimeter,
9};
10use core::f64::consts::{FRAC_PI_2, PI};
11use proptest::prelude::*;
12use slotmap::{Key, SlotMap};
13
14const TOLERANCE: Tolerance = Tolerance::new(1.0e-9);
15const RIGHT_ANGLE_EPS: f64 = 1.0e-6;
16
17fn chord() -> ChordHeightTolerance {
18 ChordHeightTolerance::from_mm(0.05)
19}
20
21struct Ids {
22 features: SlotMap<FeatureId, ()>,
23 entities: SlotMap<SketchEntityId, ()>,
24}
25
26impl Ids {
27 fn new() -> Self {
28 Self {
29 features: SlotMap::with_key(),
30 entities: SlotMap::with_key(),
31 }
32 }
33
34 fn feature(&mut self) -> FeatureId {
35 self.features.insert(())
36 }
37
38 fn entity(&mut self) -> SketchEntityId {
39 self.entities.insert(())
40 }
41}
42
43fn length_mm(value: f64) -> Length {
44 Length::new::<millimeter>(value)
45}
46
47fn point(x: f64, y: f64) -> Point2 {
48 Point2::from_mm(x, y)
49}
50
51fn line(a: Point2, b: Point2) -> Curve2Kind {
52 let Ok(segment) = Line2::new(a, b, TOLERANCE) else {
53 panic!("line endpoints are distinct");
54 };
55 Curve2Kind::Line(segment)
56}
57
58fn circle(center: Point2, radius: f64) -> Curve2Kind {
59 let Ok(disk) = Circle2::new(center, length_mm(radius), TOLERANCE) else {
60 panic!("circle radius is positive");
61 };
62 Curve2Kind::Circle(disk)
63}
64
65fn arc(center: Point2, radius: f64, start_deg: f64, sweep_deg: f64) -> Curve2Kind {
66 let Ok(segment) = Arc2::new(
67 center,
68 length_mm(radius),
69 Angle::new::<degree>(start_deg),
70 Angle::new::<degree>(sweep_deg),
71 TOLERANCE,
72 ) else {
73 panic!("arc sweep is within range");
74 };
75 Curve2Kind::Arc(segment)
76}
77
78fn xy_plane() -> Plane3 {
79 let Ok(plane) = Plane3::new(
80 bone_types::Point3::origin(),
81 UnitVec3::x_axis(),
82 UnitVec3::y_axis(),
83 TOLERANCE,
84 ) else {
85 panic!("x and y axes are orthonormal");
86 };
87 plane
88}
89
90fn rectangle(ids: &mut Ids, x0: f64, y0: f64, width: f64, height: f64) -> ProfileLoop {
91 let corners = [
92 point(x0, y0),
93 point(x0 + width, y0),
94 point(x0 + width, y0 + height),
95 point(x0, y0 + height),
96 ];
97 let edges = (0..4)
98 .map(|index| {
99 let start = corners[index];
100 let end = corners[(index + 1) % 4];
101 let corner = ids.entity();
102 let segment = ids.entity();
103 ProfileEdge::new(line(start, end), segment, corner)
104 })
105 .collect();
106 ProfileLoop::Open(edges)
107}
108
109fn circle_loop(ids: &mut Ids, center: Point2, radius: f64) -> ProfileLoop {
110 let entity = ids.entity();
111 ProfileLoop::Closed {
112 curve: circle(center, radius),
113 curve_entity: entity,
114 }
115}
116
117fn triangle(ids: &mut Ids, corners: [Point2; 3]) -> ProfileLoop {
118 let edges = (0..3)
119 .map(|index| {
120 let start = corners[index];
121 let end = corners[(index + 1) % 3];
122 ProfileEdge::new(line(start, end), ids.entity(), ids.entity())
123 })
124 .collect();
125 ProfileLoop::Open(edges)
126}
127
128struct HalfDisk {
129 arc_corner: SketchEntityId,
130 arc_entity: SketchEntityId,
131 line_corner: SketchEntityId,
132 line_entity: SketchEntityId,
133 radius_mm: f64,
134}
135
136impl HalfDisk {
137 fn build(ids: &mut Ids, radius_mm: f64) -> (Self, ProfileLoop) {
138 let parts = Self {
139 arc_corner: ids.entity(),
140 arc_entity: ids.entity(),
141 line_corner: ids.entity(),
142 line_entity: ids.entity(),
143 radius_mm,
144 };
145 let curved = ProfileEdge::new(
146 arc(point(0.0, 0.0), radius_mm, 0.0, 180.0),
147 parts.arc_entity,
148 parts.arc_corner,
149 );
150 let chord_edge = ProfileEdge::new(
151 line(point(-radius_mm, 0.0), point(radius_mm, 0.0)),
152 parts.line_entity,
153 parts.line_corner,
154 );
155 (parts, ProfileLoop::Open(vec![curved, chord_edge]))
156 }
157}
158
159fn blind(depth: f64) -> ExtrudeFeature {
160 ExtrudeFeature {
161 sketch: SketchId::null(),
162 direction: ExtrudeDirection::Normal {
163 sense: ExtrudeSense::Forward,
164 },
165 end_condition: ExtrudeEndCondition::Blind {
166 depth: positive(depth),
167 },
168 draft: None,
169 thin_wall: None,
170 merge_result: MergeResult::Merge,
171 }
172}
173
174fn positive(depth: f64) -> PositiveLength {
175 let Ok(value) = PositiveLength::new(length_mm(depth)) else {
176 panic!("{depth} mm is a positive length");
177 };
178 value
179}
180
181fn evaluate(ids: &mut Ids, profile: &ExtrudeProfile, feature: &ExtrudeFeature) -> BrepSolid {
182 let extrude = ids.feature();
183 let Ok(solid) = evaluate_extrude(extrude, profile, feature) else {
184 panic!("the profile and feature describe a buildable extrude");
185 };
186 solid
187}
188
189fn approx(a: f64, b: f64, eps: f64) -> bool {
190 (a - b).abs() < eps
191}
192
193#[test]
194fn unit_cube_emits_twelve_line_edges() {
195 let mut ids = Ids::new();
196 let profile = ExtrudeProfile::new(xy_plane(), vec![rectangle(&mut ids, 0.0, 0.0, 1.0, 1.0)]);
197 let solid = evaluate(&mut ids, &profile, &blind(1.0));
198 let polylines = solid.edges_for_render(chord());
199 assert_eq!(polylines.len(), 12);
200 polylines.iter().for_each(|edge| {
201 assert!(
202 matches!(edge.curve(), EdgeCurve3::Line(_)),
203 "cube edge is a line"
204 );
205 assert!(edge.points().len() >= 2);
206 let crease = edge.crease();
207 assert!(
208 approx(crease.radians(), FRAC_PI_2, RIGHT_ANGLE_EPS),
209 "cube edge crease ≈ 90°, got {} rad",
210 crease.radians()
211 );
212 });
213}
214
215#[test]
216fn cylinder_caps_emit_circle_curves() {
217 let mut ids = Ids::new();
218 let profile = ExtrudeProfile::new(
219 xy_plane(),
220 vec![circle_loop(&mut ids, point(0.0, 0.0), 5.0)],
221 );
222 let solid = evaluate(&mut ids, &profile, &blind(10.0));
223 let polylines = solid.edges_for_render(chord());
224 let kinds: Vec<&EdgeCurve3> = polylines.iter().map(EdgePolyline::curve).collect();
225 assert_eq!(
226 kinds.len(),
227 2,
228 "cylinder emits two cap edges, seam side edge is filtered"
229 );
230 kinds.iter().for_each(|kind| {
231 assert!(
232 matches!(kind, EdgeCurve3::Circle(_)),
233 "cap edge is a circle"
234 );
235 });
236 polylines.iter().for_each(|edge| {
237 let role = edge.label().role;
238 assert!(
239 matches!(
240 role,
241 EdgeRole::StartCapEdge { .. } | EdgeRole::EndCapEdge { .. }
242 ),
243 "cap edge role"
244 );
245 let crease = edge.crease();
246 assert!(
247 approx(crease.radians(), FRAC_PI_2, RIGHT_ANGLE_EPS),
248 "cap crease ≈ 90°"
249 );
250 assert!(edge.points().len() > 8);
251 });
252}
253
254#[test]
255fn cylinder_seam_side_edge_is_suppressed() {
256 let mut ids = Ids::new();
257 let profile = ExtrudeProfile::new(
258 xy_plane(),
259 vec![circle_loop(&mut ids, point(0.0, 0.0), 5.0)],
260 );
261 let solid = evaluate(&mut ids, &profile, &blind(10.0));
262 let polylines = solid.edges_for_render(chord());
263 let seam_emitted = polylines.iter().any(|edge| {
264 matches!(
265 edge.label().role,
266 EdgeRole::SideEdge {
267 side: SideKind::Seam,
268 ..
269 }
270 )
271 });
272 assert!(
273 !seam_emitted,
274 "seam side edges are filtered from render output"
275 );
276}
277
278#[test]
279fn donut_emits_four_circles() {
280 let mut ids = Ids::new();
281 let outer = circle_loop(&mut ids, point(0.0, 0.0), 10.0);
282 let inner = circle_loop(&mut ids, point(0.0, 0.0), 4.0);
283 let profile = ExtrudeProfile::new(xy_plane(), vec![outer, inner]);
284 let solid = evaluate(&mut ids, &profile, &blind(6.0));
285 let polylines = solid.edges_for_render(chord());
286 assert_eq!(polylines.len(), 4);
287 polylines.iter().for_each(|edge| {
288 assert!(matches!(edge.curve(), EdgeCurve3::Circle(_)));
289 let crease = edge.crease();
290 assert!(approx(crease.radians(), FRAC_PI_2, RIGHT_ANGLE_EPS));
291 });
292}
293
294#[test]
295fn half_disk_cap_emits_line_plus_arc() {
296 let mut ids = Ids::new();
297 let (parts, half_disk) = HalfDisk::build(&mut ids, 5.0);
298 let profile = ExtrudeProfile::new(xy_plane(), vec![half_disk]);
299 let solid = evaluate(&mut ids, &profile, &blind(2.0));
300 let polylines = solid.edges_for_render(chord());
301
302 let cap_kinds: Vec<&EdgeCurve3> = polylines
303 .iter()
304 .filter(|edge| {
305 matches!(
306 edge.label().role,
307 EdgeRole::StartCapEdge { from } if from == parts.arc_entity || from == parts.line_entity
308 ) || matches!(
309 edge.label().role,
310 EdgeRole::EndCapEdge { from } if from == parts.arc_entity || from == parts.line_entity
311 )
312 })
313 .map(EdgePolyline::curve)
314 .collect();
315 let arc_count = cap_kinds
316 .iter()
317 .filter(|kind| matches!(kind, EdgeCurve3::Arc(_)))
318 .count();
319 let line_count = cap_kinds
320 .iter()
321 .filter(|kind| matches!(kind, EdgeCurve3::Line(_)))
322 .count();
323 assert_eq!(arc_count, 2, "two arc cap edges (start + end)");
324 assert_eq!(line_count, 2, "two line cap edges (start + end)");
325}
326
327#[test]
328fn half_disk_arc_centers_lie_on_cap_planes() {
329 let mut ids = Ids::new();
330 let depth_mm = 2.0;
331 let (parts, half_disk) = HalfDisk::build(&mut ids, 5.0);
332 let profile = ExtrudeProfile::new(xy_plane(), vec![half_disk]);
333 let solid = evaluate(&mut ids, &profile, &blind(depth_mm));
334 let polylines = solid.edges_for_render(chord());
335
336 let cap_arcs: Vec<(f64, &EdgeCurve3)> = polylines
337 .iter()
338 .filter_map(|edge| match edge.label().role {
339 EdgeRole::StartCapEdge { from } if from == parts.arc_entity => {
340 Some((0.0, edge.curve()))
341 }
342 EdgeRole::EndCapEdge { from } if from == parts.arc_entity => {
343 Some((depth_mm, edge.curve()))
344 }
345 _ => None,
346 })
347 .collect();
348 assert_eq!(cap_arcs.len(), 2, "one arc per cap");
349 cap_arcs.iter().for_each(|(z_expected, curve)| {
350 let EdgeCurve3::Arc(arc3) = curve else {
351 panic!("cap arc lifts to Arc3");
352 };
353 let (cx, cy, cz) = arc3.center().coords_mm();
354 assert!(approx(cx, 0.0, 1.0e-9), "arc center x");
355 assert!(approx(cy, 0.0, 1.0e-9), "arc center y");
356 assert!(approx(cz, *z_expected, 1.0e-9), "arc center z");
357 let (nx, ny, nz) = arc3.normal().components();
358 assert!(approx(nx, 0.0, 1.0e-9), "arc normal x");
359 assert!(approx(ny, 0.0, 1.0e-9), "arc normal y");
360 assert!(approx(nz, 1.0, 1.0e-9), "arc normal z");
361 assert!(
362 approx(arc3.radius().get::<millimeter>(), parts.radius_mm, 1.0e-9),
363 "arc radius preserved"
364 );
365 assert!(approx(arc3.sweep_rad(), PI, 1.0e-9), "arc sweeps π rad");
366 });
367}
368
369#[test]
370fn half_disk_emits_corner_side_pillars() {
371 let mut ids = Ids::new();
372 let depth_mm = 2.0;
373 let (_parts, half_disk) = HalfDisk::build(&mut ids, 5.0);
374 let profile = ExtrudeProfile::new(xy_plane(), vec![half_disk]);
375 let solid = evaluate(&mut ids, &profile, &blind(depth_mm));
376 let polylines = solid.edges_for_render(chord());
377
378 let pillars: Vec<&EdgePolyline> = polylines
379 .iter()
380 .filter(|edge| {
381 matches!(
382 edge.label().role,
383 EdgeRole::SideEdge {
384 side: SideKind::Corner,
385 ..
386 }
387 )
388 })
389 .collect();
390 assert_eq!(pillars.len(), 2, "two corner pillars (line/arc junctions)");
391 pillars.iter().for_each(|edge| {
392 let EdgeCurve3::Line(segment) = edge.curve() else {
393 panic!("pillar is a line");
394 };
395 let (sx, sy, sz) = segment.start().coords_mm();
396 let (ex, ey, ez) = segment.end().coords_mm();
397 assert!(approx(sx.abs(), 5.0, 1.0e-9), "pillar foot x");
398 assert!(approx(sy, 0.0, 1.0e-9), "pillar foot y");
399 assert!(approx(ex.abs(), 5.0, 1.0e-9), "pillar head x");
400 assert!(approx(ey, 0.0, 1.0e-9), "pillar head y");
401 assert!(
402 approx((ez - sz).abs(), depth_mm, 1.0e-9),
403 "pillar spans depth"
404 );
405 });
406}
407
408#[test]
409fn equilateral_prism_side_creases_are_120_degrees() {
410 let mut ids = Ids::new();
411 let half = 0.5_f64 * 3.0_f64.sqrt();
412 let profile = ExtrudeProfile::new(
413 xy_plane(),
414 vec![triangle(
415 &mut ids,
416 [point(0.0, 0.0), point(1.0, 0.0), point(0.5, half)],
417 )],
418 );
419 let solid = evaluate(&mut ids, &profile, &blind(1.0));
420 let polylines = solid.edges_for_render(chord());
421
422 let side_creases: Vec<f64> = polylines
423 .iter()
424 .filter(|edge| {
425 matches!(
426 edge.label().role,
427 EdgeRole::SideEdge {
428 side: SideKind::Corner,
429 ..
430 }
431 )
432 })
433 .map(|edge| edge.crease().radians())
434 .collect();
435 assert_eq!(side_creases.len(), 3, "three vertical corner pillars");
436 let expected_side = 2.0 * PI / 3.0;
437 side_creases.iter().for_each(|value| {
438 assert!(
439 approx(*value, expected_side, 1.0e-9),
440 "side crease ≈ 120°, got {value} rad"
441 );
442 });
443
444 let cap_creases: Vec<f64> = polylines
445 .iter()
446 .filter(|edge| {
447 matches!(
448 edge.label().role,
449 EdgeRole::StartCapEdge { .. } | EdgeRole::EndCapEdge { .. }
450 )
451 })
452 .map(|edge| edge.crease().radians())
453 .collect();
454 assert_eq!(cap_creases.len(), 6, "three start + three end cap edges");
455 cap_creases.iter().for_each(|value| {
456 assert!(
457 approx(*value, FRAC_PI_2, 1.0e-9),
458 "cap crease ≈ 90°, got {value} rad"
459 );
460 });
461}
462
463#[test]
464fn edges_for_render_is_deterministic() {
465 let mut ids = Ids::new();
466 let cube = ExtrudeProfile::new(xy_plane(), vec![rectangle(&mut ids, 0.0, 0.0, 1.0, 1.0)]);
467 let cube_solid = evaluate(&mut ids, &cube, &blind(1.0));
468 let first = cube_solid.edges_for_render(chord());
469 let second = cube_solid.edges_for_render(chord());
470 assert_eq!(first, second);
471
472 let mut cyl_ids = Ids::new();
473 let cyl = ExtrudeProfile::new(
474 xy_plane(),
475 vec![circle_loop(&mut cyl_ids, point(0.0, 0.0), 5.0)],
476 );
477 let cyl_solid = evaluate(&mut cyl_ids, &cyl, &blind(10.0));
478 let first = cyl_solid.edges_for_render(chord());
479 let second = cyl_solid.edges_for_render(chord());
480 assert_eq!(first, second);
481}
482
483#[test]
484fn edges_for_render_is_deterministic_across_evaluations() {
485 let mut first_ids = Ids::new();
486 let first_profile = ExtrudeProfile::new(
487 xy_plane(),
488 vec![circle_loop(&mut first_ids, point(0.0, 0.0), 5.0)],
489 );
490 let first_extrude = first_ids.feature();
491 let Ok(first_solid) = evaluate_extrude(first_extrude, &first_profile, &blind(10.0)) else {
492 panic!("first evaluation succeeds");
493 };
494
495 let mut second_ids = Ids::new();
496 let second_profile = ExtrudeProfile::new(
497 xy_plane(),
498 vec![circle_loop(&mut second_ids, point(0.0, 0.0), 5.0)],
499 );
500 let second_extrude = second_ids.feature();
501 let Ok(second_solid) = evaluate_extrude(second_extrude, &second_profile, &blind(10.0)) else {
502 panic!("second evaluation succeeds");
503 };
504
505 assert_eq!(
506 first_solid.edges_for_render(chord()),
507 second_solid.edges_for_render(chord()),
508 "two evaluations of the same profile + feature produce identical render polylines",
509 );
510}
511
512#[test]
513fn polylines_carry_label_and_crease_for_each_edge() {
514 let mut ids = Ids::new();
515 let profile = ExtrudeProfile::new(xy_plane(), vec![rectangle(&mut ids, 0.0, 0.0, 2.0, 3.0)]);
516 let solid = evaluate(&mut ids, &profile, &blind(4.0));
517 let polylines = solid.edges_for_render(chord());
518 polylines.iter().for_each(|edge| {
519 let id = edge.edge();
520 let label = edge.label();
521 let crease = edge.crease();
522 assert_ne!(id, bone_types::BrepEdgeId::default());
523 assert!(label.feature != FeatureId::null());
524 assert!(crease.radians().is_finite());
525 });
526}
527
528#[test]
529fn polyline_endpoints_align_with_vertices_order() {
530 let mut ids = Ids::new();
531 let (_parts, half_disk) = HalfDisk::build(&mut ids, 5.0);
532 let profile = ExtrudeProfile::new(xy_plane(), vec![half_disk]);
533 let solid = evaluate(&mut ids, &profile, &blind(2.0));
534 let positions: std::collections::HashMap<bone_types::BrepVertexId, bone_types::Point3> = solid
535 .iter_vertices()
536 .map(|v| (v.id(), v.position()))
537 .collect();
538 solid.iter_edges().for_each(|edge| {
539 let [start_id, end_id] = edge.vertices();
540 if start_id == end_id {
541 return;
542 }
543 let Some(start_pos) = positions.get(&start_id) else {
544 return;
545 };
546 let Some(end_pos) = positions.get(&end_id) else {
547 return;
548 };
549 let curve = edge.curve();
550 let curve_start = curve.evaluate(bone_types::Parameter::new(0.0));
551 let curve_end = curve.evaluate(bone_types::Parameter::new(1.0));
552 let (sx, sy, sz) = start_pos.coords_mm();
553 let (cx, cy, cz) = curve_start.coords_mm();
554 assert!(
555 approx(sx, cx, 1.0e-9) && approx(sy, cy, 1.0e-9) && approx(sz, cz, 1.0e-9),
556 "vertices[0] should sit at curve.evaluate(0)"
557 );
558 let (ex, ey, ez) = end_pos.coords_mm();
559 let (kx, ky, kz) = curve_end.coords_mm();
560 assert!(
561 approx(ex, kx, 1.0e-9) && approx(ey, ky, 1.0e-9) && approx(ez, kz, 1.0e-9),
562 "vertices[1] should sit at curve.evaluate(1)"
563 );
564 });
565 let render = solid.edges_for_render(chord());
566 let edges_by_id: std::collections::HashMap<_, _> =
567 solid.iter_edges().map(|edge| (edge.id(), edge)).collect();
568 render.iter().for_each(|polyline| {
569 let edge = edges_by_id[&polyline.edge()];
570 let [start_id, _] = edge.vertices();
571 if let Some(start_pos) = positions.get(&start_id) {
572 let first = polyline.points()[0];
573 let (sx, sy, sz) = start_pos.coords_mm();
574 let (fx, fy, fz) = first.coords_mm();
575 assert!(
576 approx(sx, fx, 1.0e-9) && approx(sy, fy, 1.0e-9) && approx(sz, fz, 1.0e-9),
577 "polyline.points()[0] sits at vertices[0]"
578 );
579 }
580 });
581}
582
583proptest! {
584 #[test]
585 fn edges_for_render_is_deterministic_across_rectangles(
586 width_mm in 0.5f64..=20.0f64,
587 height_mm in 0.5f64..=20.0f64,
588 depth_mm in 0.5f64..=20.0f64,
589 ) {
590 let mut ids = Ids::new();
591 let profile = ExtrudeProfile::new(
592 xy_plane(),
593 vec![rectangle(&mut ids, 0.0, 0.0, width_mm, height_mm)],
594 );
595 let solid = evaluate(&mut ids, &profile, &blind(depth_mm));
596 let first = solid.edges_for_render(chord());
597 let second = solid.edges_for_render(chord());
598 prop_assert_eq!(first, second);
599 }
600
601 #[test]
602 fn edges_for_render_is_deterministic_across_cylinders(
603 radius_mm in 0.5f64..=20.0f64,
604 depth_mm in 0.5f64..=20.0f64,
605 ) {
606 let mut ids = Ids::new();
607 let profile = ExtrudeProfile::new(
608 xy_plane(),
609 vec![circle_loop(&mut ids, point(0.0, 0.0), radius_mm)],
610 );
611 let solid = evaluate(&mut ids, &profile, &blind(depth_mm));
612 let first = solid.edges_for_render(chord());
613 let second = solid.edges_for_render(chord());
614 prop_assert_eq!(first, second);
615 }
616
617 #[test]
618 fn edges_for_render_is_deterministic_across_half_disks(
619 radius_mm in 1.0f64..=10.0f64,
620 depth_mm in 0.5f64..=10.0f64,
621 ) {
622 let mut ids = Ids::new();
623 let (_parts, half_disk) = HalfDisk::build(&mut ids, radius_mm);
624 let profile = ExtrudeProfile::new(xy_plane(), vec![half_disk]);
625 let solid = evaluate(&mut ids, &profile, &blind(depth_mm));
626 let first = solid.edges_for_render(chord());
627 let second = solid.edges_for_render(chord());
628 prop_assert_eq!(first, second);
629 }
630}