1use crate::{
8 AddDatumArgs, AddSketchArgs, Combine, Construction, EntityRef, ExtrudeArgs, Program,
9 RevolveArgs, WorldAxis,
10};
11use geop_core_sketch::{Constraint, CurveId, PointId, Sketch};
12
13fn polygon(sketch: &mut Sketch, corners: &[[f64; 2]]) -> (Vec<PointId>, Vec<CurveId>) {
16 let points: Vec<PointId> = corners
17 .iter()
18 .map(|c| sketch.add_point(c[0], c[1]))
19 .collect();
20 let lines = (0..points.len())
21 .map(|i| sketch.add_line(points[i], points[(i + 1) % points.len()]))
22 .collect();
23 (points, lines)
24}
25
26fn solved(mut sketch: Sketch) -> Sketch {
28 let report = sketch.solve().expect("example sketches are valid");
29 assert!(
30 report.converged,
31 "example sketch does not solve: {report:?}"
32 );
33 sketch
34}
35
36fn rectangle(sketch: &mut Sketch, origin: [f64; 2], width: f64, depth: f64) -> Vec<CurveId> {
39 let [x, y] = origin;
40 let (p, l) = polygon(
42 sketch,
43 &[
44 [x + 0.05, y - 0.02],
45 [x + width, y + 0.03],
46 [x + width - 0.04, y + depth],
47 [x, y + depth + 0.01],
48 ],
49 );
50 sketch.constrain(Constraint::Fix { point: p[0], x, y });
51 sketch.constrain(Constraint::Horizontal { line: l[0] });
52 sketch.constrain(Constraint::Horizontal { line: l[2] });
53 sketch.constrain(Constraint::Vertical { line: l[1] });
54 sketch.constrain(Constraint::Vertical { line: l[3] });
55 sketch.constrain(Constraint::Length {
56 curve: l[0],
57 value: width,
58 });
59 sketch.constrain(Constraint::Length {
60 curve: l[1],
61 value: depth,
62 });
63 l
64}
65
66fn circle(sketch: &mut Sketch, center: [f64; 2], radius: f64) -> CurveId {
68 let c = sketch.add_point(center[0], center[1]);
69 let circle = sketch.add_circle(c, radius * 1.1);
70 sketch.constrain(Constraint::Fix {
71 point: c,
72 x: center[0],
73 y: center[1],
74 });
75 sketch.constrain(Constraint::Radius {
76 curve: circle,
77 value: radius,
78 });
79 circle
80}
81
82pub fn box_with_drill_hole() -> Program {
87 let mut program = Program::new();
88
89 let mut outline = Sketch::new();
90 rectangle(&mut outline, [0.0, 0.0], 2.0, 2.0);
91 program.push(
92 "outline",
93 AddSketchArgs {
94 plane: EntityRef::Plane {
95 normal: WorldAxis::Z,
96 },
97 sketch: solved(outline),
98 },
99 );
100 program.push(
101 "box",
102 ExtrudeArgs {
103 sketch: "outline".into(),
104 distance: 1.0,
105 symmetric: false,
106 combine: Combine::NewBody,
107 },
108 );
109
110 let mut hole = Sketch::new();
111 circle(&mut hole, [1.0, 1.0], 0.4);
112 program.push(
113 "hole_sketch",
114 AddSketchArgs {
115 plane: EntityRef::Face {
116 name: "extrude(box,end)".into(),
117 },
118 sketch: solved(hole),
119 },
120 );
121 program.push(
122 "hole",
123 ExtrudeArgs {
124 sketch: "hole_sketch".into(),
125 distance: -0.5,
126 symmetric: false,
127 combine: Combine::Difference {
128 target: "extrude(box)".into(),
129 },
130 },
131 );
132 program
133}
134
135pub fn cross_drilled_shaft() -> Program {
141 let mut program = Program::new();
142
143 let mut section = Sketch::new();
145 let (p, l) = polygon(
146 &mut section,
147 &[
148 [0.0, 0.0],
149 [1.0, 0.0],
150 [1.0, 1.0],
151 [0.6, 1.0],
152 [0.6, 3.0],
153 [0.0, 3.0],
154 ],
155 );
156 let axis = l[5];
157 section.constrain(Constraint::Fix {
158 point: p[0],
159 x: 0.0,
160 y: 0.0,
161 });
162 section.constrain(Constraint::Vertical { line: axis });
163 for (line, horizontal) in [
164 (l[0], true),
165 (l[1], false),
166 (l[2], true),
167 (l[3], false),
168 (l[4], true),
169 ] {
170 section.constrain(if horizontal {
171 Constraint::Horizontal { line }
172 } else {
173 Constraint::Vertical { line }
174 });
175 }
176 section.constrain(Constraint::Length {
177 curve: l[0],
178 value: 1.0,
179 });
180 section.constrain(Constraint::Length {
181 curve: l[1],
182 value: 1.0,
183 });
184 section.constrain(Constraint::Length {
185 curve: l[3],
186 value: 2.0,
187 });
188 section.constrain(Constraint::Length {
189 curve: l[4],
190 value: 0.6,
191 });
192 program.push(
193 "section",
194 AddSketchArgs {
195 plane: EntityRef::Plane {
196 normal: WorldAxis::X,
197 },
198 sketch: solved(section),
199 },
200 );
201 program.push(
202 "shaft",
203 RevolveArgs {
204 sketch: "section".into(),
205 axis,
206 combine: Combine::NewBody,
207 },
208 );
209
210 let mut bore = Sketch::new();
213 circle(&mut bore, [0.0, -2.2], 0.25);
214 program.push(
215 "bore_sketch",
216 AddSketchArgs {
217 plane: EntityRef::Plane {
218 normal: WorldAxis::Y,
219 },
220 sketch: solved(bore),
221 },
222 );
223 program.push(
224 "bore",
225 ExtrudeArgs {
226 sketch: "bore_sketch".into(),
227 distance: 3.0,
228 symmetric: true,
229 combine: Combine::Difference {
230 target: "revolve(shaft)".into(),
231 },
232 },
233 );
234 program
235}
236
237pub fn two_plates() -> Program {
240 let mut program = Program::new();
241 let mut plates = Sketch::new();
242 rectangle(&mut plates, [0.0, 0.0], 1.5, 1.0);
243 rectangle(&mut plates, [2.0, 0.0], 1.0, 1.0);
244 circle(&mut plates, [0.75, 0.5], 0.3);
245 program.push(
246 "plates_sketch",
247 AddSketchArgs {
248 plane: EntityRef::Plane {
249 normal: WorldAxis::Z,
250 },
251 sketch: solved(plates),
252 },
253 );
254 program.push(
255 "plates",
256 ExtrudeArgs {
257 sketch: "plates_sketch".into(),
258 distance: 0.25,
259 symmetric: true,
260 combine: Combine::NewBody,
261 },
262 );
263 program
264}
265
266pub fn boss_on_reference_plane() -> Program {
272 let mut program = Program::new();
273 let mut outline = Sketch::new();
274 rectangle(&mut outline, [0.0, 0.0], 2.0, 2.0);
275 program.push(
276 "outline",
277 AddSketchArgs {
278 plane: EntityRef::Plane {
279 normal: WorldAxis::Z,
280 },
281 sketch: solved(outline),
282 },
283 );
284 program.push(
285 "box",
286 ExtrudeArgs {
287 sketch: "outline".into(),
288 distance: 1.0,
289 symmetric: false,
290 combine: Combine::NewBody,
291 },
292 );
293 program.push(
294 "lifted",
295 AddDatumArgs {
296 selection: vec![EntityRef::Face {
297 name: "extrude(box,end)".into(),
298 }],
299 construction: Construction::Offset { distance: 0.5 },
300 },
301 );
302 let mut boss = Sketch::new();
303 circle(&mut boss, [1.0, 1.0], 0.5);
304 program.push(
305 "boss_sketch",
306 AddSketchArgs {
307 plane: EntityRef::Datum {
308 name: "lifted".into(),
309 },
310 sketch: solved(boss),
311 },
312 );
313 program.push(
314 "boss",
315 ExtrudeArgs {
316 sketch: "boss_sketch".into(),
317 distance: -0.75,
318 symmetric: false,
319 combine: Combine::Union {
320 target: "extrude(box)".into(),
321 },
322 },
323 );
324 program
325}
326
327pub fn handle_with_hole() -> Program {
342 let mut program = Program::new();
343
344 let mut outline = Sketch::new();
345 let outer_points = [
346 (0.01007682018456503, 0.8939399106232252),
347 (-0.9693901017551558, 0.8213868052943569),
348 (-1.00566665441959, 0.3175457960661055),
349 (-0.9452057333121998, -0.3555857922628385),
350 (0.09472210973491128, -0.9481028191152622),
351 (0.695300592734987, -0.3878316168534466),
352 (1.384555093359235, -0.4523232660346628),
353 (1.6233271012047248, -0.9224957048864859),
354 (1.9214381194506664, 0.15401630544608172),
355 (1.2092840203075834, 0.8578895429712221),
356 ]
357 .map(|(x, y)| outline.add_point(x, y));
358 let mut outer_loop = outer_points.to_vec();
359 outer_loop.push(outer_points[0]);
360 outline.add_spline(outer_loop);
361
362 let inner_points = [
363 (0.27854547786607586, 0.568500810276078),
364 (-0.3241022513094386, 0.561767316095346),
365 (-0.3645032163938306, 0.00962079327532167),
366 (0.03950643445008967, -0.4246895813818926),
367 (0.507484280010964, -0.11831559615858644),
368 (0.8643594715897602, 0.12072344725739975),
369 (1.1202322504575766, -0.1452495728815144),
370 (1.2919363520662426, 0.18805838906471978),
371 (1.0427970673791584, 0.470865144655464),
372 ]
373 .map(|(x, y)| outline.add_point(x, y));
374 let mut inner_loop = inner_points.to_vec();
375 inner_loop.push(inner_points[0]);
376 outline.add_spline(inner_loop);
377
378 program.push(
379 "outline",
380 AddSketchArgs {
381 plane: EntityRef::Plane {
382 normal: WorldAxis::Z,
383 },
384 sketch: solved(outline),
385 },
386 );
387 program.push(
388 "handle",
389 ExtrudeArgs {
390 sketch: "outline".into(),
391 distance: 1.0,
392 symmetric: false,
393 combine: Combine::NewBody,
394 },
395 );
396
397 let mut hole = Sketch::new();
398 let center = hole.add_point(0.32989396295411244, -0.5632891678453777);
399 hole.add_circle(center, 0.31727744879927977);
400 program.push(
401 "hole_sketch",
402 AddSketchArgs {
403 plane: EntityRef::Plane {
404 normal: WorldAxis::Y,
405 },
406 sketch: solved(hole),
407 },
408 );
409 program.push(
410 "hole",
411 ExtrudeArgs {
412 sketch: "hole_sketch".into(),
413 distance: 2.44,
414 symmetric: true,
415 combine: Combine::Difference {
416 target: "extrude(handle)".into(),
417 },
418 },
419 );
420 program
421}
422
423pub fn all() -> Vec<(&'static str, Program)> {
425 vec![
426 ("box_with_drill_hole", box_with_drill_hole()),
427 ("cross_drilled_shaft", cross_drilled_shaft()),
428 ("two_plates", two_plates()),
429 ("boss_on_reference_plane", boss_on_reference_plane()),
430 ]
431}
432
433#[cfg(test)]
434mod tests {
435 use std::collections::BTreeMap;
436
437 use geop_core_math::{scalars::ScalInF64 as S, scalars::Scalar, vector::Vector3};
438 use geop_core_part::{Part, PartDescription, RefId};
439 use geop_core_topology::{
440 contains::shell::{PointClassification, shell_contains},
441 validation::{ValidationParameters, validate},
442 };
443
444 use super::*;
445
446 fn outputs_dir() -> std::path::PathBuf {
447 let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../outputs/parts");
448 std::fs::create_dir_all(&dir).unwrap();
449 dir
450 }
451
452 fn geometry_by_name(part: &Part<S>) -> BTreeMap<String, String> {
456 let model = part.topology();
457 part.names()
458 .iter()
459 .filter_map(|(id, name)| {
460 let geometry = match id {
461 RefId::Vertex(v) => format!("{:?}", model.get_vertex(v).ok()?.point),
462 RefId::Edge(e) => format!("{:?}", model.get_edge(e).ok()?.curve),
463 RefId::Face(f) => format!("{:?}", model.get_face(f).ok()?.surface),
464 RefId::Solid(_) | RefId::Sketch(_) | RefId::Datum(_) => return None,
465 };
466 Some((name.to_string(), geometry))
467 })
468 .collect()
469 }
470
471 fn build_and_round_trip(name: &str, program: &Program) -> Part<S> {
477 let part = program.apply(Part::<S>::new()).unwrap();
478 let validation = ValidationParameters::default();
479 if let Err(errors) = validate(&validation, part.topology()) {
480 panic!(
481 "{name}: {} validation error(s): {}",
482 errors.len(),
483 errors[0]
484 );
485 }
486
487 let json = program.to_json().unwrap();
488 let dir = outputs_dir();
489 std::fs::write(dir.join(format!("{name}.program.json")), &json).unwrap();
490 let description = PartDescription::of(&part).unwrap();
491 std::fs::write(
492 dir.join(format!("{name}.part.json")),
493 serde_json::to_string_pretty(&description).unwrap(),
494 )
495 .unwrap();
496 geop_ops_rasterize::rasterize_model(part.topology(), 16)
497 .unwrap()
498 .save_to_file(dir.join(format!("{name}.html")).to_str().unwrap())
499 .unwrap();
500
501 let read_back = Program::from_json(&json).unwrap();
502 assert_eq!(
503 &read_back, program,
504 "{name}: JSON round trip changed the program"
505 );
506 assert_eq!(
507 read_back.to_json().unwrap(),
508 json,
509 "{name}: JSON is not stable"
510 );
511
512 let rebuilt = read_back.apply(Part::<S>::new()).unwrap();
513 assert_eq!(
514 PartDescription::of(&rebuilt).unwrap(),
515 description,
516 "{name}: the read-back program built a different part"
517 );
518 assert_eq!(
519 geometry_by_name(&rebuilt),
520 geometry_by_name(&part),
521 "{name}: the read-back program built different geometry"
522 );
523 part
524 }
525
526 fn inside(part: &Part<S>, solid: &str, p: [f64; 3]) -> PointClassification {
527 let model = part.topology();
528 let solid = part.solid_id(solid).unwrap();
529 let point = Vector3::from_array(p.map(S::from_f64));
530 let mut result = PointClassification::Outside;
531 for &shell in &model.get_solid(solid).unwrap().shells {
532 match shell_contains(model, shell, point, 2000, S::from_f64(1e-6), 7).unwrap() {
533 PointClassification::Outside => {}
534 other => result = other,
535 }
536 }
537 result
538 }
539
540 #[test]
541 fn box_with_drill_hole_round_trips() {
542 let part = build_and_round_trip("box_with_drill_hole", &box_with_drill_hole());
543 let description = PartDescription::of(&part).unwrap();
544
545 assert_eq!(
547 description.solids.keys().collect::<Vec<_>>(),
548 ["extrude(hole)"]
549 );
550 let top = &description.faces["extrude(box,end)"];
552 assert_eq!(top.holes.len(), 1, "{top:?}");
553 assert!(description.faces.contains_key("extrude(hole,end)"));
556 let circle = box_with_drill_hole().steps[2].clone();
557 let crate::PartOperation::AddSketch(args) = circle.operation else {
558 unreachable!()
559 };
560 let circle_id = *args.sketch.curves.keys().next().unwrap();
561 for piece in ["", "#1", "#2", "#3"] {
562 let wall = format!("extrude(hole,hole_sketch,{circle_id}{piece})");
563 assert!(description.faces.contains_key(&wall), "no face {wall}");
564 }
565
566 assert_eq!(
567 inside(&part, "extrude(hole)", [0.3, 0.3, 0.5]),
568 PointClassification::Inside
569 );
570 assert_eq!(
571 inside(&part, "extrude(hole)", [1.0, 1.0, 0.8]),
572 PointClassification::Outside
573 );
574 assert_eq!(
575 inside(&part, "extrude(hole)", [1.0, 1.0, 0.3]),
576 PointClassification::Inside
577 );
578 }
579
580 #[test]
581 fn cross_drilled_shaft_round_trips() {
582 let part = build_and_round_trip("cross_drilled_shaft", &cross_drilled_shaft());
583 let description = PartDescription::of(&part).unwrap();
584 assert_eq!(
585 description.solids.keys().collect::<Vec<_>>(),
586 ["extrude(bore)"]
587 );
588 assert_eq!(
589 inside(&part, "extrude(bore)", [0.0, 0.8, 0.5]),
590 PointClassification::Inside
591 );
592 assert_eq!(
593 inside(&part, "extrude(bore)", [0.0, 0.0, 2.2]),
594 PointClassification::Outside
595 );
596 assert_eq!(
597 inside(&part, "extrude(bore)", [0.0, 0.0, 2.7]),
598 PointClassification::Inside
599 );
600 }
601
602 #[test]
603 fn two_plates_round_trip() {
604 let part = build_and_round_trip("two_plates", &two_plates());
605 let description = PartDescription::of(&part).unwrap();
606 assert_eq!(
607 description.solids["extrude(plates)"].len(),
608 2,
609 "one shell per plate"
610 );
611 assert_eq!(
612 inside(&part, "extrude(plates)", [0.2, 0.2, 0.0]),
613 PointClassification::Inside
614 );
615 assert_eq!(
616 inside(&part, "extrude(plates)", [0.75, 0.5, 0.0]),
617 PointClassification::Outside
618 );
619 assert_eq!(
620 inside(&part, "extrude(plates)", [2.5, 0.5, 0.1]),
621 PointClassification::Inside
622 );
623 }
624
625 #[test]
626 fn boss_on_reference_plane_round_trips() {
627 let part = build_and_round_trip("boss_on_reference_plane", &boss_on_reference_plane());
628 let description = PartDescription::of(&part).unwrap();
629 assert_eq!(
630 description.solids.keys().collect::<Vec<_>>(),
631 ["extrude(boss)"]
632 );
633 assert_eq!(description.datums, ["lifted"]);
634 assert_eq!(
636 inside(&part, "extrude(boss)", [1.0, 1.0, 1.3]),
637 PointClassification::Inside
638 );
639 assert_eq!(
640 inside(&part, "extrude(boss)", [1.0, 1.0, 1.6]),
641 PointClassification::Outside
642 );
643 assert_eq!(
644 inside(&part, "extrude(boss)", [0.2, 0.2, 1.3]),
645 PointClassification::Outside
646 );
647 assert_eq!(
648 inside(&part, "extrude(boss)", [0.2, 0.2, 0.5]),
649 PointClassification::Inside
650 );
651 }
652
653 #[test]
656 fn names_survive_a_change_of_dimensions() {
657 let names = |program: &Program| {
658 let part = program.apply(Part::<S>::new()).unwrap();
659 let description = PartDescription::of(&part).unwrap();
660 (
661 description.faces.keys().cloned().collect::<Vec<_>>(),
662 description.edges.keys().cloned().collect::<Vec<_>>(),
663 description.vertices.keys().cloned().collect::<Vec<_>>(),
664 )
665 };
666 let original = box_with_drill_hole();
667 let mut edited = original.clone();
668 for step in &mut edited.steps {
669 match &mut step.operation {
670 crate::PartOperation::Extrude(args) if step.id == "box" => args.distance = 1.5,
671 crate::PartOperation::AddSketch(args) if step.id == "hole_sketch" => {
672 for c in args.sketch.constraints.values_mut() {
673 if let Constraint::Radius { value, .. } = c {
674 *value = 0.6;
675 }
676 }
677 args.sketch.solve().unwrap();
678 }
679 _ => {}
680 }
681 }
682 assert_ne!(original, edited);
683 assert_eq!(names(&edited), names(&original));
684 }
685
686 #[test]
688 fn referring_to_a_missing_entity_fails() {
689 let mut program = box_with_drill_hole();
690 let crate::PartOperation::AddSketch(args) = &mut program.steps[2].operation else {
691 unreachable!()
692 };
693 args.plane = EntityRef::Face {
694 name: "extrude(box,side)".into(),
695 };
696 let Err(err) = program.apply(Part::<S>::new()) else {
697 panic!("a sketch on a face that doesn't exist was placed somewhere");
698 };
699 assert!(format!("{err:?}").contains("extrude(box,side)"), "{err:?}");
700 }
701
702 #[test]
705 fn duplicate_step_ids_are_rejected() {
706 let mut program = box_with_drill_hole();
707 program.steps[3].id = "box".into();
708 assert!(program.apply(Part::<S>::new()).is_err());
709 }
710
711 #[test]
727 #[ignore = "known failure: face_interior_point degenerate face in the hole's boolean difference, not yet root-caused — see this test's doc comment"]
728 fn handle_with_hole_round_trips() {
729 build_and_round_trip("handle_with_hole", &handle_with_hole());
730 }
731}