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 all() -> Vec<(&'static str, Program)> {
329 vec![
330 ("box_with_drill_hole", box_with_drill_hole()),
331 ("cross_drilled_shaft", cross_drilled_shaft()),
332 ("two_plates", two_plates()),
333 ("boss_on_reference_plane", boss_on_reference_plane()),
334 ]
335}
336
337#[cfg(test)]
338mod tests {
339 use std::collections::BTreeMap;
340
341 use geop_core_math::{scalars::ScalInF64 as S, scalars::Scalar, vector::Vector3};
342 use geop_core_part::{Part, PartDescription, RefId};
343 use geop_core_topology::{
344 contains::shell::{PointClassification, shell_contains},
345 validation::{ValidationParameters, validate},
346 };
347
348 use super::*;
349
350 fn outputs_dir() -> std::path::PathBuf {
351 let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../outputs/parts");
352 std::fs::create_dir_all(&dir).unwrap();
353 dir
354 }
355
356 fn geometry_by_name(part: &Part<S>) -> BTreeMap<String, String> {
360 let model = part.topology();
361 part.names()
362 .iter()
363 .filter_map(|(id, name)| {
364 let geometry = match id {
365 RefId::Vertex(v) => format!("{:?}", model.get_vertex(v).ok()?.point),
366 RefId::Edge(e) => format!("{:?}", model.get_edge(e).ok()?.curve),
367 RefId::Face(f) => format!("{:?}", model.get_face(f).ok()?.surface),
368 RefId::Solid(_) | RefId::Sketch(_) | RefId::Datum(_) => return None,
369 };
370 Some((name.to_string(), geometry))
371 })
372 .collect()
373 }
374
375 fn build_and_round_trip(name: &str, program: &Program) -> Part<S> {
381 let part = program.apply(Part::<S>::new()).unwrap();
382 let validation = ValidationParameters::default();
383 if let Err(errors) = validate(&validation, part.topology()) {
384 panic!(
385 "{name}: {} validation error(s): {}",
386 errors.len(),
387 errors[0]
388 );
389 }
390
391 let json = program.to_json().unwrap();
392 let dir = outputs_dir();
393 std::fs::write(dir.join(format!("{name}.program.json")), &json).unwrap();
394 let description = PartDescription::of(&part).unwrap();
395 std::fs::write(
396 dir.join(format!("{name}.part.json")),
397 serde_json::to_string_pretty(&description).unwrap(),
398 )
399 .unwrap();
400 geop_ops_rasterize::rasterize_model(part.topology(), 16)
401 .unwrap()
402 .save_to_file(dir.join(format!("{name}.html")).to_str().unwrap())
403 .unwrap();
404
405 let read_back = Program::from_json(&json).unwrap();
406 assert_eq!(
407 &read_back, program,
408 "{name}: JSON round trip changed the program"
409 );
410 assert_eq!(
411 read_back.to_json().unwrap(),
412 json,
413 "{name}: JSON is not stable"
414 );
415
416 let rebuilt = read_back.apply(Part::<S>::new()).unwrap();
417 assert_eq!(
418 PartDescription::of(&rebuilt).unwrap(),
419 description,
420 "{name}: the read-back program built a different part"
421 );
422 assert_eq!(
423 geometry_by_name(&rebuilt),
424 geometry_by_name(&part),
425 "{name}: the read-back program built different geometry"
426 );
427 part
428 }
429
430 fn inside(part: &Part<S>, solid: &str, p: [f64; 3]) -> PointClassification {
431 let model = part.topology();
432 let solid = part.solid_id(solid).unwrap();
433 let point = Vector3::from_array(p.map(S::from_f64));
434 let mut result = PointClassification::Outside;
435 for &shell in &model.get_solid(solid).unwrap().shells {
436 match shell_contains(model, shell, point, 2000, S::from_f64(1e-6), 7).unwrap() {
437 PointClassification::Outside => {}
438 other => result = other,
439 }
440 }
441 result
442 }
443
444 #[test]
445 fn box_with_drill_hole_round_trips() {
446 let part = build_and_round_trip("box_with_drill_hole", &box_with_drill_hole());
447 let description = PartDescription::of(&part).unwrap();
448
449 assert_eq!(
451 description.solids.keys().collect::<Vec<_>>(),
452 ["extrude(hole)"]
453 );
454 let top = &description.faces["extrude(box,end)"];
456 assert_eq!(top.holes.len(), 1, "{top:?}");
457 assert!(description.faces.contains_key("extrude(hole,end)"));
460 let circle = box_with_drill_hole().steps[2].clone();
461 let crate::PartOperation::AddSketch(args) = circle.operation else {
462 unreachable!()
463 };
464 let circle_id = *args.sketch.curves.keys().next().unwrap();
465 for piece in ["", "#1", "#2", "#3"] {
466 let wall = format!("extrude(hole,hole_sketch,{circle_id}{piece})");
467 assert!(description.faces.contains_key(&wall), "no face {wall}");
468 }
469
470 assert_eq!(
471 inside(&part, "extrude(hole)", [0.3, 0.3, 0.5]),
472 PointClassification::Inside
473 );
474 assert_eq!(
475 inside(&part, "extrude(hole)", [1.0, 1.0, 0.8]),
476 PointClassification::Outside
477 );
478 assert_eq!(
479 inside(&part, "extrude(hole)", [1.0, 1.0, 0.3]),
480 PointClassification::Inside
481 );
482 }
483
484 #[test]
485 fn cross_drilled_shaft_round_trips() {
486 let part = build_and_round_trip("cross_drilled_shaft", &cross_drilled_shaft());
487 let description = PartDescription::of(&part).unwrap();
488 assert_eq!(
489 description.solids.keys().collect::<Vec<_>>(),
490 ["extrude(bore)"]
491 );
492 assert_eq!(
493 inside(&part, "extrude(bore)", [0.0, 0.8, 0.5]),
494 PointClassification::Inside
495 );
496 assert_eq!(
497 inside(&part, "extrude(bore)", [0.0, 0.0, 2.2]),
498 PointClassification::Outside
499 );
500 assert_eq!(
501 inside(&part, "extrude(bore)", [0.0, 0.0, 2.7]),
502 PointClassification::Inside
503 );
504 }
505
506 #[test]
507 fn two_plates_round_trip() {
508 let part = build_and_round_trip("two_plates", &two_plates());
509 let description = PartDescription::of(&part).unwrap();
510 assert_eq!(
511 description.solids["extrude(plates)"].len(),
512 2,
513 "one shell per plate"
514 );
515 assert_eq!(
516 inside(&part, "extrude(plates)", [0.2, 0.2, 0.0]),
517 PointClassification::Inside
518 );
519 assert_eq!(
520 inside(&part, "extrude(plates)", [0.75, 0.5, 0.0]),
521 PointClassification::Outside
522 );
523 assert_eq!(
524 inside(&part, "extrude(plates)", [2.5, 0.5, 0.1]),
525 PointClassification::Inside
526 );
527 }
528
529 #[test]
530 fn boss_on_reference_plane_round_trips() {
531 let part = build_and_round_trip("boss_on_reference_plane", &boss_on_reference_plane());
532 let description = PartDescription::of(&part).unwrap();
533 assert_eq!(
534 description.solids.keys().collect::<Vec<_>>(),
535 ["extrude(boss)"]
536 );
537 assert_eq!(description.datums, ["lifted"]);
538 assert_eq!(
540 inside(&part, "extrude(boss)", [1.0, 1.0, 1.3]),
541 PointClassification::Inside
542 );
543 assert_eq!(
544 inside(&part, "extrude(boss)", [1.0, 1.0, 1.6]),
545 PointClassification::Outside
546 );
547 assert_eq!(
548 inside(&part, "extrude(boss)", [0.2, 0.2, 1.3]),
549 PointClassification::Outside
550 );
551 assert_eq!(
552 inside(&part, "extrude(boss)", [0.2, 0.2, 0.5]),
553 PointClassification::Inside
554 );
555 }
556
557 #[test]
560 fn names_survive_a_change_of_dimensions() {
561 let names = |program: &Program| {
562 let part = program.apply(Part::<S>::new()).unwrap();
563 let description = PartDescription::of(&part).unwrap();
564 (
565 description.faces.keys().cloned().collect::<Vec<_>>(),
566 description.edges.keys().cloned().collect::<Vec<_>>(),
567 description.vertices.keys().cloned().collect::<Vec<_>>(),
568 )
569 };
570 let original = box_with_drill_hole();
571 let mut edited = original.clone();
572 for step in &mut edited.steps {
573 match &mut step.operation {
574 crate::PartOperation::Extrude(args) if step.id == "box" => args.distance = 1.5,
575 crate::PartOperation::AddSketch(args) if step.id == "hole_sketch" => {
576 for c in args.sketch.constraints.values_mut() {
577 if let Constraint::Radius { value, .. } = c {
578 *value = 0.6;
579 }
580 }
581 args.sketch.solve().unwrap();
582 }
583 _ => {}
584 }
585 }
586 assert_ne!(original, edited);
587 assert_eq!(names(&edited), names(&original));
588 }
589
590 #[test]
592 fn referring_to_a_missing_entity_fails() {
593 let mut program = box_with_drill_hole();
594 let crate::PartOperation::AddSketch(args) = &mut program.steps[2].operation else {
595 unreachable!()
596 };
597 args.plane = EntityRef::Face {
598 name: "extrude(box,side)".into(),
599 };
600 let Err(err) = program.apply(Part::<S>::new()) else {
601 panic!("a sketch on a face that doesn't exist was placed somewhere");
602 };
603 assert!(format!("{err:?}").contains("extrude(box,side)"), "{err:?}");
604 }
605
606 #[test]
609 fn duplicate_step_ids_are_rejected() {
610 let mut program = box_with_drill_hole();
611 program.steps[3].id = "box".into();
612 assert!(program.apply(Part::<S>::new()).is_err());
613 }
614}