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/// A hand-drawn handle-shaped outline (an irregular blob, splined, with a
328/// smaller splined region inside it — as a free-hand sketch in the web
329/// editor draws, unconstrained) extruded, then cross-drilled with a
330/// circular hole near one end.
331///
332/// Reproduces a real user bug report (2026-09-27) with the exact reported
333/// coordinates, unconstrained, rather than a re-derived "clean" equivalent
334/// that might not carry the same numerical case at all: the hole's boolean
335/// difference failed with a degenerate-face error from
336/// `face_interior_point`. (A second report, of the same outline with a
337/// slightly different hole center, left a non-watertight hole instead; its
338/// coordinates were not recorded.)
339pub fn handle_with_hole() -> Program {
340    let mut program = Program::new();
341
342    let mut outline = Sketch::new();
343    let outer_points = [
344        (0.01007682018456503, 0.8939399106232252),
345        (-0.9693901017551558, 0.8213868052943569),
346        (-1.00566665441959, 0.3175457960661055),
347        (-0.9452057333121998, -0.3555857922628385),
348        (0.09472210973491128, -0.9481028191152622),
349        (0.695300592734987, -0.3878316168534466),
350        (1.384555093359235, -0.4523232660346628),
351        (1.6233271012047248, -0.9224957048864859),
352        (1.9214381194506664, 0.15401630544608172),
353        (1.2092840203075834, 0.8578895429712221),
354    ]
355    .map(|(x, y)| outline.add_point(x, y));
356    let mut outer_loop = outer_points.to_vec();
357    outer_loop.push(outer_points[0]);
358    outline.add_spline(outer_loop);
359
360    let inner_points = [
361        (0.27854547786607586, 0.568500810276078),
362        (-0.3241022513094386, 0.561767316095346),
363        (-0.3645032163938306, 0.00962079327532167),
364        (0.03950643445008967, -0.4246895813818926),
365        (0.507484280010964, -0.11831559615858644),
366        (0.8643594715897602, 0.12072344725739975),
367        (1.1202322504575766, -0.1452495728815144),
368        (1.2919363520662426, 0.18805838906471978),
369        (1.0427970673791584, 0.470865144655464),
370    ]
371    .map(|(x, y)| outline.add_point(x, y));
372    let mut inner_loop = inner_points.to_vec();
373    inner_loop.push(inner_points[0]);
374    outline.add_spline(inner_loop);
375
376    program.push(
377        "outline",
378        AddSketchArgs {
379            plane: EntityRef::Plane {
380                normal: WorldAxis::Z,
381            },
382            sketch: solved(outline),
383        },
384    );
385    program.push(
386        "handle",
387        ExtrudeArgs {
388            sketch: "outline".into(),
389            distance: 1.0,
390            symmetric: false,
391            combine: Combine::NewBody,
392        },
393    );
394
395    let mut hole = Sketch::new();
396    let center = hole.add_point(0.32989396295411244, -0.5632891678453777);
397    hole.add_circle(center, 0.31727744879927977);
398    program.push(
399        "hole_sketch",
400        AddSketchArgs {
401            plane: EntityRef::Plane {
402                normal: WorldAxis::Y,
403            },
404            sketch: solved(hole),
405        },
406    );
407    program.push(
408        "hole",
409        ExtrudeArgs {
410            sketch: "hole_sketch".into(),
411            distance: 2.44,
412            symmetric: true,
413            combine: Combine::Difference {
414                target: "extrude(handle)".into(),
415            },
416        },
417    );
418    program
419}
420
421/// A rounded-top luggage tag: two vertical sides and a horizontal bottom,
422/// closed by a semicircular top tangent to both sides, with a hang-hole
423/// through the top (concentric with it) and a rectangular window in the
424/// body, inset 0.15 from each of the two sides and the bottom.
425pub fn luggage_tag() -> Program {
426    let mut program = Program::new();
427
428    let mut outline = Sketch::new();
429    let p0 = outline.add_point(-0.7051397478635735, 0.7809633733011653);
430    let p1 = outline.add_point(-0.7051397478646341, -0.6729821470985564);
431    let p4 = outline.add_point(0.695311597099524, -0.6729821470973941);
432    let p7 = outline.add_point(0.695311597101934, 0.7809633732995341);
433    let left = outline.add_line(p0, p1);
434    let bottom = outline.add_line(p1, p4);
435    let right = outline.add_line(p4, p7);
436    let top = outline.add_arc_with_sweep(p7, p0, std::f64::consts::PI);
437    outline.constrain(Constraint::Vertical { line: left });
438    outline.constrain(Constraint::Horizontal { line: bottom });
439    outline.constrain(Constraint::Vertical { line: right });
440    outline.constrain(Constraint::Tangent { a: top, b: right });
441    outline.constrain(Constraint::Tangent { a: top, b: left });
442
443    let hole_center = outline.add_point(-0.004914075380876311, 0.7809633733016882);
444    let hole = outline.add_circle(hole_center, 0.30711303824557856);
445    outline.constrain(Constraint::Concentric { a: hole, b: top });
446
447    let w_tl = outline.add_point(-0.5551397478558091, 0.08482972707824256);
448    let w_tr = outline.add_point(0.5453115971039223, 0.08482972707824256);
449    let w_br = outline.add_point(0.5453115971014348, -0.5229821471055477);
450    let w_bl = outline.add_point(-0.5551397478598076, -0.5229821471049918);
451    let w_top = outline.add_line(w_tl, w_tr);
452    let w_right = outline.add_line(w_tr, w_br);
453    let w_bottom = outline.add_line(w_br, w_bl);
454    let w_left = outline.add_line(w_bl, w_tl);
455    outline.constrain(Constraint::Horizontal { line: w_top });
456    outline.constrain(Constraint::Vertical { line: w_right });
457    outline.constrain(Constraint::Horizontal { line: w_bottom });
458    outline.constrain(Constraint::Vertical { line: w_left });
459    outline.constrain(Constraint::PointLineDistance {
460        point: w_br,
461        line: right,
462        value: 0.15,
463    });
464    outline.constrain(Constraint::PointLineDistance {
465        point: w_bl,
466        line: left,
467        value: 0.15,
468    });
469    outline.constrain(Constraint::PointLineDistance {
470        point: w_br,
471        line: bottom,
472        value: 0.15,
473    });
474
475    program.push(
476        "outline",
477        AddSketchArgs {
478            plane: EntityRef::Plane {
479                normal: WorldAxis::Z,
480            },
481            sketch: solved(outline),
482        },
483    );
484    program.push(
485        "tag",
486        ExtrudeArgs {
487            sketch: "outline".into(),
488            distance: 1.0 / 3.0,
489            symmetric: true,
490            combine: Combine::NewBody,
491        },
492    );
493    program
494}
495
496/// Every example, by name.
497pub fn all() -> Vec<(&'static str, Program)> {
498    vec![
499        ("box_with_drill_hole", box_with_drill_hole()),
500        ("cross_drilled_shaft", cross_drilled_shaft()),
501        ("two_plates", two_plates()),
502        ("boss_on_reference_plane", boss_on_reference_plane()),
503        ("handle_with_hole", handle_with_hole()),
504        ("luggage_tag", luggage_tag()),
505    ]
506}
507
508#[cfg(test)]
509mod tests {
510    use std::collections::BTreeMap;
511
512    use geop_core_math::{scalars::ScalInF64 as S, scalars::Scalar, vector::Vector3};
513    use geop_core_part::{Part, PartDescription, RefId};
514    use geop_core_topology::{
515        contains::shell::{PointClassification, shell_contains},
516        validation::{ValidationParameters, validate},
517    };
518
519    use super::*;
520
521    fn outputs_dir() -> std::path::PathBuf {
522        let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../outputs/parts");
523        std::fs::create_dir_all(&dir).unwrap();
524        dir
525    }
526
527    /// Every named entity's exact geometry, by name: vertex points, edge
528    /// curves and face surfaces as their full `Debug` enclosures — equal only
529    /// if the two parts agree to the last bit of every interval.
530    fn geometry_by_name(part: &Part<S>) -> BTreeMap<String, String> {
531        let model = part.topology();
532        part.names()
533            .iter()
534            .filter_map(|(id, name)| {
535                let geometry = match id {
536                    RefId::Vertex(v) => format!("{:?}", model.get_vertex(v).ok()?.point),
537                    RefId::Edge(e) => format!("{:?}", model.get_edge(e).ok()?.curve),
538                    RefId::Face(f) => format!("{:?}", model.get_face(f).ok()?.surface),
539                    RefId::Solid(_) | RefId::Sketch(_) | RefId::Datum(_) => return None,
540                };
541                Some((name.to_string(), geometry))
542            })
543            .collect()
544    }
545
546    /// Builds `program`, writes it and a description of the part it builds
547    /// to `outputs/parts/`, reads the program back from its JSON, and
548    /// requires the read-back program to be the same program and to build
549    /// the very same part: every name, every piece of topology between
550    /// names, and every bit of geometry.
551    fn build_and_round_trip(name: &str, program: &Program) -> Part<S> {
552        let part = program.apply(Part::<S>::new()).unwrap();
553        let validation = ValidationParameters::default();
554        if let Err(errors) = validate(&validation, part.topology()) {
555            panic!(
556                "{name}: {} validation error(s): {}",
557                errors.len(),
558                errors[0]
559            );
560        }
561
562        let json = program.to_json().unwrap();
563        let dir = outputs_dir();
564        std::fs::write(dir.join(format!("{name}.program.json")), &json).unwrap();
565        let description = PartDescription::of(&part).unwrap();
566        std::fs::write(
567            dir.join(format!("{name}.part.json")),
568            serde_json::to_string_pretty(&description).unwrap(),
569        )
570        .unwrap();
571        geop_ops_rasterize::rasterize_model(part.topology(), 16)
572            .unwrap()
573            .save_to_file(dir.join(format!("{name}.html")).to_str().unwrap())
574            .unwrap();
575
576        let read_back = Program::from_json(&json).unwrap();
577        assert_eq!(
578            &read_back, program,
579            "{name}: JSON round trip changed the program"
580        );
581        assert_eq!(
582            read_back.to_json().unwrap(),
583            json,
584            "{name}: JSON is not stable"
585        );
586
587        let rebuilt = read_back.apply(Part::<S>::new()).unwrap();
588        assert_eq!(
589            PartDescription::of(&rebuilt).unwrap(),
590            description,
591            "{name}: the read-back program built a different part"
592        );
593        assert_eq!(
594            geometry_by_name(&rebuilt),
595            geometry_by_name(&part),
596            "{name}: the read-back program built different geometry"
597        );
598        part
599    }
600
601    fn inside(part: &Part<S>, solid: &str, p: [f64; 3]) -> PointClassification {
602        let model = part.topology();
603        let solid = part.solid_id(solid).unwrap();
604        let point = Vector3::from_array(p.map(S::from_f64));
605        let mut result = PointClassification::Outside;
606        for &shell in &model.get_solid(solid).unwrap().shells {
607            match shell_contains(model, shell, point, 2000, S::from_f64(1e-6), 7).unwrap() {
608                PointClassification::Outside => {}
609                other => result = other,
610            }
611        }
612        result
613    }
614
615    #[test]
616    fn box_with_drill_hole_round_trips() {
617        let part = build_and_round_trip("box_with_drill_hole", &box_with_drill_hole());
618        let description = PartDescription::of(&part).unwrap();
619
620        // One solid, named after the step that cut the hole.
621        assert_eq!(
622            description.solids.keys().collect::<Vec<_>>(),
623            ["extrude(hole)"]
624        );
625        // The box's top keeps its name, and now has the hole in it.
626        let top = &description.faces["extrude(box,end)"];
627        assert_eq!(top.holes.len(), 1, "{top:?}");
628        // The hole's bottom is the hole tool's end cap; its wall is the
629        // four quarters swept by the circle.
630        assert!(description.faces.contains_key("extrude(hole,end)"));
631        let circle = box_with_drill_hole().steps[2].clone();
632        let crate::PartOperation::AddSketch(args) = circle.operation else {
633            unreachable!()
634        };
635        let circle_id = *args.sketch.curves.keys().next().unwrap();
636        for piece in ["", "#1", "#2", "#3"] {
637            let wall = format!("extrude(hole,hole_sketch,{circle_id}{piece})");
638            assert!(description.faces.contains_key(&wall), "no face {wall}");
639        }
640
641        assert_eq!(
642            inside(&part, "extrude(hole)", [0.3, 0.3, 0.5]),
643            PointClassification::Inside
644        );
645        assert_eq!(
646            inside(&part, "extrude(hole)", [1.0, 1.0, 0.8]),
647            PointClassification::Outside
648        );
649        assert_eq!(
650            inside(&part, "extrude(hole)", [1.0, 1.0, 0.3]),
651            PointClassification::Inside
652        );
653    }
654
655    #[test]
656    fn cross_drilled_shaft_round_trips() {
657        let part = build_and_round_trip("cross_drilled_shaft", &cross_drilled_shaft());
658        let description = PartDescription::of(&part).unwrap();
659        assert_eq!(
660            description.solids.keys().collect::<Vec<_>>(),
661            ["extrude(bore)"]
662        );
663        assert_eq!(
664            inside(&part, "extrude(bore)", [0.0, 0.8, 0.5]),
665            PointClassification::Inside
666        );
667        assert_eq!(
668            inside(&part, "extrude(bore)", [0.0, 0.0, 2.2]),
669            PointClassification::Outside
670        );
671        assert_eq!(
672            inside(&part, "extrude(bore)", [0.0, 0.0, 2.7]),
673            PointClassification::Inside
674        );
675    }
676
677    #[test]
678    fn two_plates_round_trip() {
679        let part = build_and_round_trip("two_plates", &two_plates());
680        let description = PartDescription::of(&part).unwrap();
681        assert_eq!(
682            description.solids["extrude(plates)"].len(),
683            2,
684            "one shell per plate"
685        );
686        assert_eq!(
687            inside(&part, "extrude(plates)", [0.2, 0.2, 0.0]),
688            PointClassification::Inside
689        );
690        assert_eq!(
691            inside(&part, "extrude(plates)", [0.75, 0.5, 0.0]),
692            PointClassification::Outside
693        );
694        assert_eq!(
695            inside(&part, "extrude(plates)", [2.5, 0.5, 0.1]),
696            PointClassification::Inside
697        );
698    }
699
700    #[test]
701    fn boss_on_reference_plane_round_trips() {
702        let part = build_and_round_trip("boss_on_reference_plane", &boss_on_reference_plane());
703        let description = PartDescription::of(&part).unwrap();
704        assert_eq!(
705            description.solids.keys().collect::<Vec<_>>(),
706            ["extrude(boss)"]
707        );
708        assert_eq!(description.datums, ["lifted"]);
709        // The boss stands on the box: from the box's top up to the plane.
710        assert_eq!(
711            inside(&part, "extrude(boss)", [1.0, 1.0, 1.3]),
712            PointClassification::Inside
713        );
714        assert_eq!(
715            inside(&part, "extrude(boss)", [1.0, 1.0, 1.6]),
716            PointClassification::Outside
717        );
718        assert_eq!(
719            inside(&part, "extrude(boss)", [0.2, 0.2, 1.3]),
720            PointClassification::Outside
721        );
722        assert_eq!(
723            inside(&part, "extrude(boss)", [0.2, 0.2, 0.5]),
724            PointClassification::Inside
725        );
726    }
727
728    /// The names of a program's entities don't depend on its numbers: a
729    /// taller box with a wider hole has the very same names.
730    #[test]
731    fn names_survive_a_change_of_dimensions() {
732        let names = |program: &Program| {
733            let part = program.apply(Part::<S>::new()).unwrap();
734            let description = PartDescription::of(&part).unwrap();
735            (
736                description.faces.keys().cloned().collect::<Vec<_>>(),
737                description.edges.keys().cloned().collect::<Vec<_>>(),
738                description.vertices.keys().cloned().collect::<Vec<_>>(),
739            )
740        };
741        let original = box_with_drill_hole();
742        let mut edited = original.clone();
743        for step in &mut edited.steps {
744            match &mut step.operation {
745                crate::PartOperation::Extrude(args) if step.id == "box" => args.distance = 1.5,
746                crate::PartOperation::AddSketch(args) if step.id == "hole_sketch" => {
747                    for c in args.sketch.constraints.values_mut() {
748                        if let Constraint::Radius { value, .. } = c {
749                            *value = 0.6;
750                        }
751                    }
752                    args.sketch.solve().unwrap();
753                }
754                _ => {}
755            }
756        }
757        assert_ne!(original, edited);
758        assert_eq!(names(&edited), names(&original));
759    }
760
761    /// An unknown name is reported, not guessed at.
762    #[test]
763    fn referring_to_a_missing_entity_fails() {
764        let mut program = box_with_drill_hole();
765        let crate::PartOperation::AddSketch(args) = &mut program.steps[2].operation else {
766            unreachable!()
767        };
768        args.plane = EntityRef::Face {
769            name: "extrude(box,side)".into(),
770        };
771        let Err(err) = program.apply(Part::<S>::new()) else {
772            panic!("a sketch on a face that doesn't exist was placed somewhere");
773        };
774        assert!(format!("{err:?}").contains("extrude(box,side)"), "{err:?}");
775    }
776
777    /// Step ids have to be unique: every name a step creates is built from
778    /// its id.
779    #[test]
780    fn duplicate_step_ids_are_rejected() {
781        let mut program = box_with_drill_hole();
782        program.steps[3].id = "box".into();
783        assert!(program.apply(Part::<S>::new()).is_err());
784    }
785
786    /// Reproduces the real bug report described on `handle_with_hole`.
787    ///
788    /// Used to fail in the "hole" step's `Difference`, with
789    /// `face_interior_point` finding no interior point on a side wall of the
790    /// inner spline. The wall's loop carried a spur whose pcurve ran across
791    /// the whole face: `fit_pcurve` seeded its first Newton projection from
792    /// the patch's parametric middle, and on this strongly curved wall that
793    /// converged to a foot point clamped against the far domain bound. The
794    /// walk is now seeded where the curve actually starts on the surface.
795    #[test]
796    fn handle_with_hole_round_trips() {
797        build_and_round_trip("handle_with_hole", &handle_with_hole());
798    }
799
800    #[test]
801    fn luggage_tag_round_trips() {
802        build_and_round_trip("luggage_tag", &luggage_tag());
803    }
804}