Skip to main content

geop_ops_parts/
examples.rs

1//! Example programs, written in Rust against the operation types.
2//!
3//! Each refers to what earlier steps built only by name, and so reads as
4//! the recipe it is: `extrude(box,end)` is the end cap of the step `box`,
5//! whatever internal id it happens to get.
6
7use crate::{
8    AddDatumArgs, AddSketchArgs, Combine, Construction, EntityRef, ExtrudeArgs, Program,
9    RevolveArgs, WorldAxis,
10};
11use geop_core_sketch::{Constraint, CurveId, PointId, Sketch};
12
13/// A closed polygon through `corners`, one line per side: its points and
14/// lines.
15fn 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
26/// Solves `sketch`, which the examples all constrain fully.
27fn 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
36/// A `width` x `depth` rectangle with its first corner at `origin`, drawn
37/// roughly and fully constrained.
38fn rectangle(sketch: &mut Sketch, origin: [f64; 2], width: f64, depth: f64) -> Vec<CurveId> {
39    let [x, y] = origin;
40    // Deliberately a little off: the constraints decide the shape.
41    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
66/// A circle of `radius` around `center`, fully constrained.
67fn 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
82/// A 2 x 2 x 1 box with a blind hole drilled into its top: a rectangle
83/// sketched on the Z plane and extruded up (`box`), and a circle sketched on
84/// the box's end cap `extrude(box,end)` and extruded back into it, cutting
85/// it out of the box (`hole`) — the drilled box is `extrude(hole)`.
86pub 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
135/// A stepped shaft revolved around the world z-axis, cross-drilled through
136/// its thinner end: a half section sketched on the X plane, closed by the
137/// axis itself (`shaft`), and a circle on the Y plane extruded through both
138/// sides of it and cut out of it (`bore`) — the drilled shaft is
139/// `extrude(bore)`.
140pub fn cross_drilled_shaft() -> Program {
141    let mut program = Program::new();
142
143    // Sketch x runs along world y, sketch y along world z.
144    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    // Sketch x runs along world x, sketch y along world -z: a bore across
211    // the thin end, at z = 2.2.
212    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
237/// Two separate plates from one sketch of two regions, one with a round
238/// hole, extruded symmetrically into a single solid of two shells.
239pub 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
266/// A box with a round boss standing on it, sketched on a reference plane:
267/// the plane half a unit above the box's top (`lifted`, offset from
268/// `extrude(box,end)`), a circle sketched on it, and extruded back down
269/// through the gap and into the box, joined to it (`boss`) — the part is
270/// `extrude(boss)`.
271pub 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
327/// Every example, by name.
328pub 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    /// Every named entity's exact geometry, by name: vertex points, edge
357    /// curves and face surfaces as their full `Debug` enclosures — equal only
358    /// if the two parts agree to the last bit of every interval.
359    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    /// Builds `program`, writes it and a description of the part it builds
376    /// to `outputs/parts/`, reads the program back from its JSON, and
377    /// requires the read-back program to be the same program and to build
378    /// the very same part: every name, every piece of topology between
379    /// names, and every bit of geometry.
380    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        // One solid, named after the step that cut the hole.
450        assert_eq!(
451            description.solids.keys().collect::<Vec<_>>(),
452            ["extrude(hole)"]
453        );
454        // The box's top keeps its name, and now has the hole in it.
455        let top = &description.faces["extrude(box,end)"];
456        assert_eq!(top.holes.len(), 1, "{top:?}");
457        // The hole's bottom is the hole tool's end cap; its wall is the
458        // four quarters swept by the circle.
459        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        // The boss stands on the box: from the box's top up to the plane.
539        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    /// The names of a program's entities don't depend on its numbers: a
558    /// taller box with a wider hole has the very same names.
559    #[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    /// An unknown name is reported, not guessed at.
591    #[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    /// Step ids have to be unique: every name a step creates is built from
607    /// its id.
608    #[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}