Skip to main content

geop_ops_extrude_revolve/
sweeping.rs

1//! What extrude, revolve, sweep and loft share: what each makes of the
2//! profile, an [`Output`]; how far extrude and revolve go, by [`Extents`],
3//! and how both are built from them by `build_extents`, each sweeping its
4//! own way (see `Sweeping`); and the curves a sweep runs along, [`PathRef`].
5
6use geop_core_program::{numeric::NumericInput, reader::Reader};
7use std::collections::HashSet;
8
9use geop_core_editor::{
10    edit_form::{Edit, EditForm},
11    editing::Number,
12};
13use geop_core_math::{
14    geop_error::{GeopError, GeopResult},
15    vector::Vector3,
16};
17use geop_core_sketch::{Shape, Sketch};
18use geop_core_topology::{SolidId, build::BuiltBody};
19use geop_lib_booleans::{Reach, RemeshParams, boolean, combine_tools, trim_up_to_next};
20use geop_ops_model::{BooleanOp, Combine, End, Extent, SolidRef, Tool, hull};
21use serde::{Deserialize, Serialize};
22
23use geop_ops_model::{NamedModel, Namer, ToolReach, read_model};
24
25use geop_lib_sweep::SweepLoop;
26
27/// Curves a sweep runs along: the model's (see [`geop_ops_model::PathRef`]).
28pub use geop_ops_model::PathRef;
29
30/// What a step sweeping a profile makes of it: a solid — kept as a new
31/// body, or combined with another solid as [`Combine`] says — or faces
32/// standing on their own, swept from its curves: the area's outline, or a
33/// chain of curves enclosing nothing. Faces going as far as a solid (see
34/// [`Extent`]) go as far as the solid named `up_to` — or, if none, every
35/// solid of the model — and leave it as it is.
36#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
37#[serde(rename_all = "snake_case")]
38pub enum Output {
39    Solid(Combine),
40    Faces {
41        #[serde(default, skip_serializing_if = "Option::is_none")]
42        up_to: Option<SolidRef>,
43    },
44}
45
46impl Default for Output {
47    fn default() -> Self {
48        Output::Solid(Combine::NewBody)
49    }
50}
51
52impl From<Combine> for Output {
53    fn from(combine: Combine) -> Self {
54        Output::Solid(combine)
55    }
56}
57
58impl Output {
59    /// The default for a step after `before`: a solid, joined to the newest
60    /// solid if there is one (see [`Combine::new_for`]).
61    pub fn new_for(before: &NamedModel) -> Self {
62        Output::Solid(Combine::new_for(before))
63    }
64
65    /// What of `sketch` it sweeps: as faces, what the sketch draws — its
66    /// areas, or the one chain of curves enclosing nothing (see
67    /// [`Sketch::shape`]); as a solid, its areas, each a lump of it.
68    pub fn shape(&self, sketch: &Sketch) -> GeopResult<Shape> {
69        match self {
70            Output::Faces { .. } => sketch.shape(),
71            Output::Solid(_) => sketch.regions().map(Shape::Regions),
72        }
73    }
74
75    /// The solids a step going as far as a solid goes as far as: the one
76    /// it is combined with, or the faces' `up_to` — or, for a new body or
77    /// faces up to none, every solid `model` has.
78    fn stops(&self, model: &NamedModel) -> Vec<String> {
79        let target = match self {
80            Output::Solid(combine) => combine.target(),
81            Output::Faces { up_to } => up_to.as_ref().map(|solid| solid.name()),
82        };
83        match target {
84            Some(target) => vec![target.to_string()],
85            None => model.solid_names(),
86        }
87    }
88
89    /// Its fields in `f`: whether faces; for a solid, how to combine it;
90    /// for faces going as far as a solid, `reaches`, which solid. `set`
91    /// puts the output set where the arguments keep it. Faces turned into a
92    /// solid join the solid they went up to — or, if none, are combined as
93    /// [`Output::new_for`] the part `reader` reads; a solid turned into
94    /// faces goes up to what it was combined with.
95    pub(crate) fn fields<'a, A, T>(
96        &self,
97        f: &mut EditForm<'a, A, T>,
98        reader: &Reader<'a>,
99        reaches: bool,
100        set: impl Fn(&mut A, Output) + Clone + 'a,
101    ) {
102        let faces = matches!(self, Output::Faces { .. });
103        // What the other way round is: faces of a solid, a solid of faces.
104        let turned = match self {
105            Output::Solid(Combine::With { target, .. }) => Output::Faces {
106                up_to: Some(target.clone()),
107            },
108            Output::Solid(Combine::NewBody) => Output::Faces { up_to: None },
109            Output::Faces {
110                up_to: Some(target),
111            } => Output::Solid(Combine::join(target.name())),
112            Output::Faces { up_to: None } => {
113                Output::new_for(&read_model(reader).unwrap_or_default())
114            }
115        };
116        let set_faces = set.clone();
117        f.checkbox("face", "face", faces, move |edit, checked| {
118            if checked != faces {
119                set_faces(edit.args, turned.clone());
120            }
121        });
122        match self {
123            Output::Solid(combine) => {
124                combine.fields(f, reader, move |args, combine| {
125                    set(args, Output::Solid(combine))
126                });
127            }
128            Output::Faces { up_to } if reaches => {
129                Combine::target_field(
130                    f,
131                    reader,
132                    "up to solid",
133                    up_to.as_ref(),
134                    move |edit, up_to| set(edit.args, Output::Faces { up_to: Some(up_to) }),
135                );
136            }
137            Output::Faces { .. } => {}
138        }
139    }
140
141    /// Builds what a step sweeps whole, named by `namer`, as the step `step`:
142    /// `sweep(model, name)` sweeps a solid named `name`, or faces if none —
143    /// the solid then combined as its [`Combine`] says.
144    pub(crate) fn build_whole(
145        &self,
146        model: &mut NamedModel,
147        namer: &Namer,
148        step: &str,
149        sweep: impl FnOnce(&mut NamedModel, Option<&str>) -> GeopResult<BuiltBody>,
150    ) -> GeopResult<()> {
151        match self {
152            Output::Faces { .. } => sweep(model, None).map(drop),
153            Output::Solid(combine) => {
154                let built = sweep(model, Some(&combine.built_name(namer)))?;
155                let tool = Tool::whole(built.solid.expect("swept as a solid"));
156                combine_tools(model, namer, step, &[tool], combine)?.stopped()
157            }
158        }
159    }
160}
161
162/// How far an extrude or revolve goes, from its sketch's plane: `side1`
163/// along its direction — the plane's normal, the turn's, or against it if
164/// `reversed` — and, if `symmetric`, as far again the other way, half of a
165/// length each way; or else `side2`, if any, the other way on its own
166/// terms.
167#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
168pub struct Extents {
169    pub side1: Extent,
170    #[serde(default)]
171    pub symmetric: bool,
172    #[serde(default, skip_serializing_if = "Option::is_none")]
173    pub side2: Option<Extent>,
174    #[serde(default)]
175    pub reversed: bool,
176}
177
178impl Extents {
179    /// One side, `amount` far: a number, or a formula's text.
180    pub fn blind(amount: impl Into<NumericInput>) -> Self {
181        Self {
182            side1: Extent::blind(amount),
183            symmetric: false,
184            side2: None,
185            reversed: false,
186        }
187    }
188
189    /// Refuses to sweep `shape` of several areas up to the next face: each
190    /// area would go as far as a face of its own, and is swept up to it in a
191    /// sketch of its own. Blind and through all, the areas go together.
192    pub(crate) fn sweeps(&self, shape: &Shape) -> GeopResult<()> {
193        let next = |e: &Extent| matches!(e, Extent::UpToNext);
194        let up_to_next =
195            next(&self.side1) || (!self.symmetric && self.side2.as_ref().is_some_and(next));
196        match shape {
197            Shape::Regions(regions) if regions.len() > 1 && up_to_next => {
198                Err(GeopError::new(format!(
199                    "the sketch has {} areas apart, and up to next goes as far as one area meets a face: sweep them blind or through all, or each up to next in a sketch of its own",
200                    regions.len()
201                )))
202            }
203            _ => Ok(()),
204        }
205    }
206
207    /// Whether a side goes as far as the solid it is combined with, which
208    /// it then needs.
209    pub fn reaches_target(&self) -> bool {
210        let far = |e: &Extent| matches!(e, Extent::UpToNext | Extent::ThroughAll);
211        far(&self.side1) || (!self.symmetric && self.side2.as_ref().is_some_and(far))
212    }
213
214    /// What to build for it, see [`Plan`], its lengths' formulas reading
215    /// the parameters through `reader`: `far(sign)` is how far, the way
216    /// `sign` says, reaches past the solid it is combined with, and
217    /// `full_turn` the measure of a full turn, for a sweep that comes round
218    /// to where it started.
219    ///
220    /// Two sides of a turn each reaching past the solid both pass all of it,
221    /// so between them they turn more than a full turn round, and each
222    /// overlaps the other where the other stops. The second side's tool
223    /// then runs on through the piece of the first one kept, along the same
224    /// surfaces, which no boolean resolves. So the sides share the turn: a
225    /// side going as far as the solid gives way to a blind one, and two
226    /// meet in the middle of where they overlap — in the solid, where both
227    /// have stopped before.
228    fn plan(
229        &self,
230        reader: &Reader,
231        far: impl Fn(f64) -> GeopResult<f64>,
232        full_turn: Option<f64>,
233    ) -> GeopResult<Plan> {
234        use End::*;
235        let ahead = if self.reversed { -1.0 } else { 1.0 };
236        let side1 = self.side1.end(reader)?;
237        let side2 = match &self.side2 {
238            Some(e) if !self.symmetric => Some(e.end(reader)?),
239            _ => None,
240        };
241        // Each side, and whether it goes as far as the solid.
242        let side = |sign: f64, end: End| -> GeopResult<(Side, bool)> {
243            Ok(match end {
244                Blind(d) => (
245                    Side {
246                        to: sign * d,
247                        up_to_next: false,
248                    },
249                    false,
250                ),
251                ThroughAll | UpToNext => (
252                    Side {
253                        to: sign * far(sign)?,
254                        up_to_next: end == UpToNext,
255                    },
256                    true,
257                ),
258            })
259        };
260        let mut sides = match (side1, self.symmetric, side2) {
261            (Blind(d), true, _) => return Ok(Plan::Whole(-d / 2.0, d / 2.0)),
262            (e, true, _) => vec![side(ahead, e)?, side(-ahead, e)?],
263            (e, false, None) => vec![side(ahead, e)?],
264            (e, false, Some(e2)) => vec![side(ahead, e)?, side(-ahead, e2)?],
265        };
266        if let (Some(turn), [(a, far_a), (b, far_b)]) = (full_turn, sides.as_mut_slice())
267            && (a.up_to_next || b.up_to_next)
268        {
269            let overlap = a.to.abs() + b.to.abs() - turn;
270            let (give_a, give_b) = match (*far_a, *far_b) {
271                (true, true) => (overlap / 2.0, overlap / 2.0),
272                (true, false) => (overlap, 0.0),
273                (false, true) => (0.0, overlap),
274                (false, false) => (0.0, 0.0),
275            };
276            if overlap > 0.0 {
277                a.to -= a.to.signum() * give_a;
278                b.to -= b.to.signum() * give_b;
279            }
280        }
281        let sides: Vec<Side> = sides.into_iter().map(|(side, _)| side).collect();
282        Ok(if sides.iter().any(|s| s.up_to_next) {
283            Plan::Sides(sides)
284        } else {
285            Plan::Whole(sides.get(1).map_or(0.0, |s| s.to), sides[0].to)
286        })
287    }
288
289    /// The fields to say how far: how the first side ends and, blind, how
290    /// far — typed, as a number or a formula of the part's parameters, or
291    /// dragged on its handle — whether it is turned the other way, whether
292    /// symmetric, and unless so, the other side the same way, a side turned
293    /// blind starting `default` far. `amount(length, second)` makes a
294    /// side's number field (see [`NumericInput::number`]), keyed `key` for
295    /// the first side and `key2` for the second; `get` finds the extents in
296    /// the arguments. The first side's length is `length`'s to set, which an
297    /// operation may want to react to.
298    pub(crate) fn fields<'a, A, T>(
299        &self,
300        f: &mut EditForm<'a, A, T>,
301        (key, default): (&str, f64),
302        mut amount: impl FnMut(&NumericInput, bool) -> Number,
303        get: impl Fn(&mut A) -> &mut Extents + Copy + 'a,
304        length: impl Fn(Edit<'_, A, T>, NumericInput) + 'a,
305    ) {
306        let side1 = Extent::mode(Some(&self.side1));
307        let choices = Extent::choices(false);
308        f.select("end", "end", side1, choices, false, move |edit, mode| {
309            let extents = get(edit.args);
310            if let Some(extent) = Extent::pick(Some(&extents.side1), mode, default) {
311                extents.side1 = extent;
312            }
313        });
314        if let Extent::Blind(d) = &self.side1 {
315            f.numeric(key, amount(d, false), length);
316        }
317        let reversed = self.reversed;
318        f.checkbox("reversed", "reverse direction", reversed, move |edit, b| {
319            get(edit.args).reversed = b
320        })
321        .checkbox("symmetric", "symmetric", self.symmetric, move |edit, b| {
322            get(edit.args).symmetric = b
323        });
324        if self.symmetric {
325            return;
326        }
327        let second = Extent::mode(self.side2.as_ref());
328        let choices = Extent::choices(true);
329        f.select(
330            "second",
331            "second side",
332            second,
333            choices,
334            false,
335            move |edit, mode| {
336                let extents = get(edit.args);
337                extents.side2 = Extent::pick(extents.side2.as_ref(), mode, default);
338            },
339        );
340        if let Some(Extent::Blind(d)) = &self.side2 {
341            f.numeric(&format!("{key}2"), amount(d, true), move |edit, length| {
342                get(edit.args).side2 = Some(Extent::Blind(length))
343            });
344        }
345    }
346}
347
348/// What an [`Extents`] builds: one sweep between two ends, or — when a side
349/// goes up to the next face — each side on its own, from the sketch's plane.
350#[derive(Clone, Debug, PartialEq)]
351enum Plan {
352    /// From the first to the second, in the operation's measure: its start
353    /// on the sketch's plane, unless it goes both ways.
354    Whole(f64, f64),
355    /// Each side, the first one first.
356    Sides(Vec<Side>),
357}
358
359/// A side built on its own: from the sketch's plane to `to`, in the
360/// operation's measure, and only up to the next face it meets if
361/// `up_to_next` — then `to` is past everything it could meet.
362#[derive(Clone, Copy, Debug, PartialEq)]
363struct Side {
364    to: f64,
365    up_to_next: bool,
366}
367
368/// The names of the side `k` of a step built side by side, by `namer`: the
369/// first side is named like a step built whole, the second within the scope
370/// `side2`.
371fn side_namer(namer: &Namer, k: usize) -> Namer {
372    match k {
373        0 => namer.clone(),
374        k => namer.scoped(&format!("side{}", k + 1)),
375    }
376}
377
378/// What an extrude and a revolve each do their own way, for
379/// [`build_extents`]: sweep their loops between two stations of their
380/// measure — a distance along the sketch plane's normal, an angle around
381/// the axis — and say how far along it things lie.
382pub(crate) trait Sweeping {
383    /// The loops it sweeps.
384    fn loops(&self) -> &[SweepLoop];
385
386    /// Sweeps its loops from `from` to `to`, in its measure, named by
387    /// `namer`: into a solid named `name`, or faces standing on their own if
388    /// none.
389    fn sweep(
390        &self,
391        model: &mut NamedModel,
392        namer: &Namer,
393        name: Option<&str>,
394        from: f64,
395        to: f64,
396    ) -> GeopResult<BuiltBody>;
397
398    /// How far — forwards for `sign = 1`, backwards for `-1` — reaches past
399    /// everything of the solids whose convex hull `hull` spans (see
400    /// [`hull`]).
401    fn past(&self, hull: &[Vector3], sign: f64) -> GeopResult<f64>;
402
403    /// The names of the stations a sweep from the sketch's plane to `to`
404    /// starts and ends at.
405    fn ends(&self, to: f64) -> GeopResult<(String, String)>;
406
407    /// How far a side reaching `far` past the solids it stops at goes all
408    /// the way: through all, or a full turn.
409    fn all_the_way(&self, far: f64) -> f64;
410
411    /// The measure of a full turn, for a sweep that comes round to where it
412    /// started; none for one that does not.
413    fn full_turn(&self) -> Option<f64>;
414
415    /// How far along a side `p` is — forwards for `sign = 1`, backwards for
416    /// `-1` — or none for a point that could lie on the sketch's plane
417    /// itself, where every side starts.
418    fn measure(&self, sign: f64, p: &Vector3) -> Option<f64>;
419}
420
421/// Builds an extrude or a revolve, the step `step` named by `namer`: what
422/// `sweeping` sweeps, as far as `extents` say — its formulas read through
423/// `reader` — made into what `output` says. A side going as far as a solid
424/// goes as far as the solids [`Output::stops`] names, and one going up to
425/// the next face is built on its own (see [`Plan`]) and then trimmed — faces
426/// — or combined (see [`combine_sides`]).
427pub(crate) fn build_extents(
428    model: &mut NamedModel,
429    reader: &Reader,
430    namer: &Namer,
431    step: &str,
432    (extents, output): (&Extents, &Output),
433    sweeping: &impl Sweeping,
434) -> GeopResult<()> {
435    let stops = output.stops(model);
436    let hull = if extents.reaches_target() {
437        hull(model, &stops)?
438    } else {
439        None
440    };
441    let far = |sign| match &hull {
442        Some(hull) => sweeping.past(hull, sign),
443        None => Err(GeopError::new(
444            "up to next and through all go as far as a solid, and the model has none yet",
445        )),
446    };
447    let plan = extents.plan(reader, far, sweeping.full_turn())?;
448    match (output, plan) {
449        (_, Plan::Whole(from, to)) => output.build_whole(model, namer, step, |model, name| {
450            sweeping.sweep(model, namer, name, from, to)
451        }),
452        (Output::Faces { .. }, Plan::Sides(sides)) => {
453            for (k, side) in sides.into_iter().enumerate() {
454                let named = side_namer(namer, k);
455                let built = sweeping.sweep(model, &named, None, 0.0, side.to)?;
456                if side.up_to_next {
457                    let (first, last) = sweeping.ends(side.to)?;
458                    trim_side(
459                        model,
460                        step,
461                        k,
462                        &built,
463                        &stops,
464                        (&named, sweeping.loops()),
465                        (&first, &last),
466                    )?;
467                }
468            }
469            Ok(())
470        }
471        (Output::Solid(combine), Plan::Sides(sides)) => {
472            let mut tools = Vec::new();
473            for (k, &side) in sides.iter().enumerate() {
474                let named = side_namer(namer, k);
475                let name = combine.built_name(&named);
476                let built = sweeping.sweep(model, &named, Some(&name), 0.0, side.to)?;
477                // Built from the sketch's plane, so its cap there is named
478                // `start`, and its far one `end`.
479                tools.push(Tool {
480                    solid: built.solid.expect("swept as a solid"),
481                    reach: ToolReach {
482                        up_to_next: side
483                            .up_to_next
484                            .then(|| (named.name(&["start"]), named.name(&["end"]))),
485                        scope: (k > 0).then(|| format!("side{}", k + 1)),
486                    },
487                });
488            }
489            combine_sides(model, namer, step, combine, tools, &sides, &stops, sweeping)
490        }
491    }
492}
493
494/// Trims the sheets the side `k` of the step `step` built — by `namer`,
495/// from `loops`, from the station `first` on the sketch's plane to `last`
496/// — up to the next face of the solids `targets` (see [`trim_up_to_next`]).
497fn trim_side(
498    model: &mut NamedModel,
499    step: &str,
500    k: usize,
501    built: &BuiltBody,
502    targets: &[String],
503    (namer, loops): (&Namer, &[SweepLoop]),
504    (first, last): (&str, &str),
505) -> GeopResult<()> {
506    let targets = targets
507        .iter()
508        .map(|t| model.solid_id(t))
509        .collect::<GeopResult<Vec<_>>>()?;
510    let at = |station: &str| -> HashSet<String> {
511        loops
512            .iter()
513            .flat_map(|l| &l.profile.curve_names)
514            .map(|curve| namer.name(&[curve, station]))
515            .collect()
516    };
517    let (start, end) = (at(first), at(last));
518    let combine = side_namer(&Namer::new("combine", step)?, k);
519    for &sheet in &built.shells {
520        trim_up_to_next(
521            model,
522            &combine,
523            sheet,
524            &targets,
525            (&start, &end),
526            RemeshParams::default(),
527        )?;
528    }
529    Ok(())
530}
531
532/// Combines the `tools` the `sides` of a step built, as `combine` says (see
533/// [`Combine::apply`]). A side going up to the next face that the solids
534/// it stops at, `stops`, do not stop all round — the profile meets them
535/// with part of itself, and the rest goes on past — goes another way, but
536/// never flat up to where it first meets them: that is a corner or an edge
537/// they already have, and a tool ending there touches them along it alone
538/// — a join left two solids sharing an edge.
539///
540/// A cut or an intersection meeting nothing of them before it first meets
541/// them ([`first_contact`]) — its profile came from outside — goes on from
542/// there up to the next face, as a tool starting there would. Otherwise —
543/// a join, which keeps what lies before, a cut from inside, or one not
544/// stopped all round from there either — part of the profile, once in,
545/// only comes out going all the way, a full turn or through all (see
546/// [`Sweeping::all_the_way`]), and so it does: with nothing of them before
547/// it met them, going all the way cuts from them exactly what that leaves.
548/// A new body has no way to go all the way — it would overlap the solids it
549/// is not combined with — and is refused, as is a profile meeting nothing
550/// of them at all.
551#[allow(clippy::too_many_arguments)]
552fn combine_sides(
553    model: &mut NamedModel,
554    namer: &Namer,
555    step: &str,
556    combine: &Combine,
557    mut tools: Vec<Tool>,
558    sides: &[Side],
559    stops: &[String],
560    sweeping: &impl Sweeping,
561) -> GeopResult<()> {
562    let mut trial = model.clone();
563    let runs_on = match combine_tools(&mut trial, namer, step, &tools, combine)? {
564        Reach::Stopped(()) => {
565            *model = trial;
566            return Ok(());
567        }
568        Reach::RunsOn(error) => error,
569    };
570    let Combine::With { op, .. } = combine else {
571        return Err(runs_on);
572    };
573    let stops = stops
574        .iter()
575        .map(|s| model.solid_id(s))
576        .collect::<GeopResult<Vec<_>>>()?;
577    // The side `k`, swept from `from` to `to` along it.
578    let rebuild = |model: &mut NamedModel, k: usize, from: f64, to: f64| {
579        let named = side_namer(namer, k);
580        let name = combine.built_name(&named);
581        let sign = sides[k].to.signum();
582        let built = sweeping.sweep(model, &named, Some(&name), from * sign, to * sign)?;
583        Ok::<_, GeopError>(built.solid.expect("swept as a solid"))
584    };
585    for k in 0..tools.len() {
586        let Some(caps) = tools[k].reach.up_to_next.take() else {
587            continue;
588        };
589        let solid = tools[k].solid;
590        let sign = sides[k].to.signum();
591        let measure = |p: &Vector3| sweeping.measure(sign, p);
592        let Some(contact) = first_contact(model, step, solid, &stops, measure)? else {
593            return Err(runs_on);
594        };
595        model.delete_bodies(&[solid.into()])?;
596        let far = sides[k].to.abs();
597        if *op != BooleanOp::Union {
598            let mut scratch = model.clone();
599            let before = rebuild(&mut scratch, k, 0.0, contact)?;
600            let mut from_outside = true;
601            for &stop in &stops {
602                from_outside &= overlap_with(&scratch, step, before, stop)?.is_none();
603            }
604            if from_outside {
605                // From where it first meets them, up to the next face, as a
606                // tool starting there would: ending on the faces it meets.
607                let rest = Tool {
608                    solid: rebuild(model, k, contact, far)?,
609                    reach: ToolReach {
610                        up_to_next: Some(caps),
611                        scope: tools[k].reach.scope.clone(),
612                    },
613                };
614                let mut trial = model.clone();
615                let alone = std::slice::from_ref(&rest);
616                if let Reach::Stopped(()) = combine_tools(&mut trial, namer, step, alone, combine)?
617                {
618                    tools[k] = rest;
619                    continue;
620                }
621                model.delete_bodies(&[rest.solid.into()])?;
622            }
623        }
624        tools[k].solid = rebuild(model, k, 0.0, sweeping.all_the_way(far))?;
625    }
626    combine_tools(model, namer, step, &tools, combine)?.stopped()
627}
628
629/// Where `tool` and the solid `stop` overlap, worked out on a copy of
630/// `model`: the copy and the overlap in it, or none if they do not.
631fn overlap_with(
632    model: &NamedModel,
633    step: &str,
634    tool: SolidId,
635    stop: SolidId,
636) -> GeopResult<Option<(NamedModel, SolidId)>> {
637    let mut scratch = model.clone();
638    let namer = Namer::new("contact", step)?;
639    let overlap = boolean(
640        &mut scratch,
641        &namer,
642        stop,
643        tool,
644        BooleanOp::Intersection,
645        RemeshParams::default(),
646    )?;
647    Ok(overlap.map(|overlap| (scratch, overlap)))
648}
649
650/// How far along a side the profile first meets `stops`: the least of
651/// `measure` over the corners of where the side's tool `tool` and each of
652/// them overlap, their intersection worked out on a copy of the model. None
653/// if it meets none of them.
654fn first_contact(
655    model: &NamedModel,
656    step: &str,
657    tool: SolidId,
658    stops: &[SolidId],
659    measure: impl Fn(&Vector3) -> Option<f64>,
660) -> GeopResult<Option<f64>> {
661    let mut least: Option<f64> = None;
662    for &stop in stops {
663        let Some((scratch, overlap)) = overlap_with(model, step, tool, stop)? else {
664            continue;
665        };
666        let topology = scratch.topology();
667        for vertex in topology.iter_body_vertices(overlap)? {
668            if let Some(at) = measure(&topology.get_vertex(vertex)?.point) {
669                least = Some(least.map_or(at, |l: f64| l.min(at)));
670            }
671        }
672    }
673    Ok(least)
674}