Skip to main content

geop_ops_extrude_revolve/
revolve.rs

1//! [`Revolve`]: sweep a sketch's area around a line in its plane.
2
3use geop_core_editor::{
4    edit_form::{EditForm, FormState},
5    editable::{Context, OperationGroup, OperationInfo, OperationTier},
6    editing::{Number, Unit},
7    one_of, part_operation,
8    part_operation::PartOperation,
9};
10use geop_core_program::{
11    element::ElementReference, numeric::NumericInput, part::StepContext, reader::Reader,
12};
13use std::collections::BTreeSet;
14
15use geop_core_geometry::{
16    search::{MAX_NODES, min_subdivision_size},
17    shape::Axis,
18};
19use geop_core_math::{
20    geop_error::{GeopError, GeopResult, WithContext},
21    primitives::CoordinateSystem,
22    scalars::Scalar,
23    vector::{Vector2, Vector3},
24    with_context,
25};
26use geop_core_sketch::{
27    CurveKind, Enclosure, PointId, ProfileJoint, ProfileLoop, Shape, profile::curve_nurbs,
28};
29use geop_core_topology::build::BuiltBody;
30use geop_ops_model::datums::Datums;
31use geop_ops_model::sketch::{PlacedSketch, SketchRef, chain_of_pieces};
32use geop_ops_model::{Extent, Line, ModelOperation, NamedModel, Namer, build_model, read_model};
33use serde::{Deserialize, Serialize};
34
35use geop_lib_sweep::{SweepLoop, revolution, revolve};
36
37use crate::sweeping::{Extents, Output, Sweeping, build_extents};
38
39one_of! {
40    /// A line a revolve turns around: a line of a sketch — its own, or
41    /// another's — a datum axis or a frame's, a straight edge.
42    pub enum RevolveAxis -> Axis {
43        Sketch(ElementReference<PlacedSketch, Line>),
44        Datum(ElementReference<Datums, Line>),
45        Edge(ElementReference<NamedModel, Line>),
46    }
47}
48
49/// Revolves the areas of a sketch around a line in its plane, into a
50/// solid named `revolve(R)` for the operation `R` — or combines that with
51/// another solid (see [`Combine`](geop_ops_model::Combine)), the result named `revolve(R)` all the
52/// same. Or, as faces ([`Output::Faces`]), sweeps the sketch's curves into
53/// faces standing on their own — the area's outline, or a chain of curves
54/// enclosing nothing.
55///
56/// How far it turns is an [`Extents`] in degrees: an angle, up to a full
57/// turn, or up to the next face of the solid it is joined to or cut from —
58/// on one side of the sketch's plane, both alike, or each its own way.
59///
60/// The axis is any line (see [`RevolveAxis`]) in the sketch's plane: a
61/// line of the sketch, of another one, a datum axis, a frame's axis, a
62/// straight edge. The area, holes and all, lies on one side of it, and touches it
63/// only where the constraints put it on the axis — which must then be a line
64/// of the sketch itself (see `Sketch::on_line`), typically the axis line
65/// itself. A point there is a pole, swept into nothing but itself, and an
66/// edge there sweeps nothing: a full turn closes the solid over it, a
67/// partial one leaves it the edge between its two caps.
68///
69/// The profile names what it sweeps (see [`revolve`]): with `X` a piece of
70/// a sketch curve and `P` a joint of the sketch `K`, as for
71/// [`crate::Extrude`],
72///
73/// - `revolve(R,K,X,q0)`, `q1`, ...: the face `X` sweeps through each span
74///   of at most a quarter turn — four for a full turn — starting from the
75///   sketch plane;
76/// - `revolve(R,K,X,a0)`, `a1`, ...: `X` itself at each angle between the
77///   spans (`a0` is the profile where it was drawn, for a turn starting at
78///   the sketch plane);
79/// - `revolve(R,K,P,q0)` .. / `revolve(R,K,P,a0)` ..: the circular edges and
80///   vertices `P` sweeps, and `revolve(R,K,P)` for a `P` on the axis;
81/// - `revolve(R,start)` / `revolve(R,end)`: the caps of a partial turn.
82///
83/// A side going up to the next face is built on its own, as for
84/// [`crate::Extrude`].
85#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
86pub struct Revolve;
87
88#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
89pub struct RevolveArgs {
90    /// The sketch to revolve.
91    pub sketch: SketchRef,
92    /// The line to revolve around, in the sketch's plane; none yet, for a
93    /// step that has not picked one.
94    pub axis: Option<RevolveAxis>,
95    /// How far, in degrees: turning from the area's side of the axis
96    /// counter-clockwise, as seen looking down the axis — negative to turn
97    /// the other way.
98    pub extent: Extents,
99    /// A solid, kept as a new body or combined with another, or faces.
100    #[serde(default)]
101    pub output: Output,
102}
103
104/// The axis in the sketch's own coordinates.
105struct SketchAxis {
106    /// A point on it, and its unit direction, enclosed.
107    point: Vector2,
108    direction: Vector2,
109    /// A point on it and its direction (of any length), as drawn: which side
110    /// of it a region lies on is a question about the sketch as drawn.
111    drawn: (Vector2, Vector2),
112    /// The points of the sketch constrained onto it — none, unless it is a
113    /// line of the sketch.
114    on_axis: BTreeSet<PointId>,
115}
116
117impl SketchAxis {
118    /// `axis` in the coordinates of `placed`, the sketch `sketch`, whose
119    /// solution is `geometry`, other elements read through `reader`. A line
120    /// of the sketch itself is its own line; any other line must lie in the
121    /// sketch's plane, and is used as it lies there.
122    fn of(
123        axis: &RevolveAxis,
124        reader: &Reader,
125        sketch: &SketchRef,
126        placed: &PlacedSketch,
127        geometry: &Enclosure,
128    ) -> GeopResult<Self> {
129        let ctx = with_context!("revolving around {axis:?}");
130        let name = &placed.name;
131        let unit = |d: Vector2| d.normalize().with_context(ctx);
132        if let RevolveAxis::Sketch(line) = axis
133            && line.target() == sketch.target()
134        {
135            let curve = line.name().parse().with_context(ctx)?;
136            let sketch = &placed.sketch;
137            let CurveKind::Line { start, end } = sketch.curve(curve).with_context(ctx)?.kind else {
138                return Err(GeopError::new(format!("{curve} is not a line"))).with_context(ctx);
139            };
140            let drawn = |p: PointId| sketch.points[&p].xy();
141            let point = geometry.points[&start];
142            return Ok(SketchAxis {
143                point,
144                direction: unit(geometry.points[&end].sub(&point))?,
145                drawn: (drawn(start), drawn(end).sub(&drawn(start))),
146                on_axis: sketch.on_line(curve).with_context(ctx)?,
147            });
148        }
149        let line = axis.resolve(reader).with_context(ctx)?;
150        let plane = &placed.plane;
151        let point = plane.to_uvw(&line.point);
152        let along = |axis: &Vector3| line.direction.prod_dot(axis);
153        let direction = [along(plane.u()), along(plane.v()), along(plane.w())];
154        if !point[2].could_be_equal(Scalar::ZERO) || !direction[2].could_be_equal(Scalar::ZERO) {
155            return Err(GeopError::new(format!(
156                "it does not lie in the plane of sketch {name:?}"
157            )))
158            .with_context(ctx);
159        }
160        let point = Vector2::from_array([point[0], point[1]]);
161        let direction = unit(Vector2::from_array([direction[0], direction[1]]))?;
162        Ok(SketchAxis {
163            drawn: (point, direction),
164            point,
165            direction,
166            on_axis: BTreeSet::new(),
167        })
168    }
169}
170
171part_operation!(Revolve(RevolveArgs));
172
173impl PartOperation for Revolve {
174    const KIND: &'static str = "revolve";
175    const INFO: OperationInfo = OperationInfo::new(
176        "Revolve",
177        "Sweep a sketch's area around one of its lines into a solid, or its curves into faces.",
178        OperationGroup::Solid,
179        OperationTier::Big,
180    )
181    .shortcut("R")
182    .icon(crate::icons::REVOLVE);
183    type Args = RevolveArgs;
184    type Session = ();
185
186    /// The newest sketch a full turn, joined to the newest solid if there is
187    /// one. Which line it turns around is the user's to pick: a guess at one
188    /// shows a part they did not ask for, and leaves the question unasked.
189    fn new_args(context: &Context) -> RevolveArgs {
190        let before = &context.before;
191        let newest = before.targets_of::<PlacedSketch>().pop();
192        let sketch = SketchRef::whole(newest.unwrap_or_else(|| geop_ops_model::sketch::sketch("")));
193        RevolveArgs {
194            axis: None,
195            sketch,
196            extent: Extents::blind(360.0),
197            output: Output::new_for(&read_model(before).unwrap_or_default()),
198        }
199    }
200
201    fn run(ctx: &mut StepContext, args: &RevolveArgs) -> GeopResult<()> {
202        let _step = ctx.step();
203        build_model::<Self>(ctx, args)
204    }
205
206    /// The sketch and the axis, picked; how far; whether a face; and how to
207    /// combine. Another sketch picked leaves no axis that was a line of the
208    /// old one: the new one's is to be picked.
209    fn form<'a>(
210        context: &'a Context<'a>,
211        args: &RevolveArgs,
212        _: &FormState<()>,
213    ) -> EditForm<'a, RevolveArgs> {
214        let before = &context.before;
215        let mut f = EditForm::<RevolveArgs>::default();
216        f.pick_one(
217            "sketch",
218            "sketch",
219            before,
220            Some(&args.sketch),
221            move |edit, picked| {
222                let args = edit.args;
223                let old = std::mem::replace(&mut args.sketch, picked);
224                if let Some(RevolveAxis::Sketch(line)) = &args.axis
225                    && line.target() == old.target()
226                {
227                    args.axis = None;
228                }
229            },
230        );
231        f.pick_one(
232            "axis",
233            "axis",
234            before,
235            args.axis.as_ref(),
236            |edit, picked| edit.args.axis = Some(picked),
237        );
238        let amount = |angle: &NumericInput, second| {
239            let label = if second { "angle 2" } else { "angle" };
240            Number::numeric(angle, label, before, Unit::Angle).range(-360.0, 360.0)
241        };
242        args.extent.fields(
243            &mut f,
244            ("angle", 360.0),
245            amount,
246            |args| &mut args.extent,
247            |edit, angle| edit.args.extent.side1 = Extent::Blind(angle),
248        );
249        let reaches = args.extent.reaches_target();
250        let output = |args: &mut RevolveArgs, output| args.output = output;
251        (args.output).fields(&mut f, before, reaches, output);
252        f
253    }
254}
255
256impl ModelOperation for Revolve {
257    fn build(
258        model: &mut NamedModel,
259        reader: &Reader,
260        step: &str,
261        args: &RevolveArgs,
262    ) -> GeopResult<()> {
263        let namer = Namer::new("revolve", step)?;
264        let placed = reader.retrieve_element(&args.sketch).concerning("sketch")?;
265        let sketch = &placed.sketch;
266        let Some(axis) = &args.axis else {
267            return Err(GeopError::new("pick a line to revolve around"));
268        };
269        let geometry = sketch.enclose()?;
270        let axis = SketchAxis::of(axis, reader, &args.sketch, placed, &geometry)?;
271        let (a, dir) = axis.drawn;
272        let axis_left = axis.direction.perp();
273        // A point is on the axis if the constraints say so, in so many
274        // words or not — fixed on it, held level with one that is — or a
275        // datum lies along the line it was constrained onto. What the
276        // solved sketch cannot tell from the axis is on it: a corner there
277        // is a pole however it got there, and could be nothing else.
278        let on_axis: BTreeSet<PointId> = (geometry.points.iter())
279            .filter(|(p, at)| {
280                let off = at.sub(&axis.point).prod_dot(&axis_left);
281                axis.on_axis.contains(p) || off.could_be_equal(Scalar::ZERO)
282            })
283            .map(|(&p, _)| p)
284            .collect();
285
286        // What to sweep, each loop as the sketch runs it: an area's outer
287        // loop counter-clockwise, its holes clockwise.
288        let shape = args.output.shape(sketch)?;
289        args.extent.sweeps(&shape)?;
290        let (loops, closed): (Vec<ProfileLoop>, bool) = match shape {
291            Shape::Regions(regions) => (
292                (regions.into_iter())
293                    .flat_map(|r| std::iter::once(r.outer).chain(r.holes))
294                    .collect(),
295                true,
296            ),
297            Shape::Chain(chain) => (vec![chain], false),
298        };
299
300        // Which side of the axis the profile lies on; it must not cross. The
301        // points on the axis, and the edges between two of them, say nothing;
302        // every other point of a curve must lie definitely on one side, and
303        // all on the same. A curve is furthest to either side at its ends or
304        // where it runs parallel to the axis
305        // ([`NurbCurve::stationary_parameters`] across it): there are its
306        // extremes, so they say on which sides it lies.
307        let mut left = false;
308        let mut right = false;
309        for edge in loops.iter().flat_map(|l| &l.edges) {
310            let ends = sketch.curves[&edge.curve].endpoints();
311            let on = |p: Option<PointId>| p.is_some_and(|p| on_axis.contains(&p));
312            let (start_on, end_on) = (on(ends.map(|e| e.0)), on(ends.map(|e| e.1)));
313            if start_on
314                && end_on
315                && matches!(sketch.curves[&edge.curve].kind, CurveKind::Line { .. })
316            {
317                continue;
318            }
319            let pieces = curve_nurbs(sketch, &geometry, edge.curve)?;
320            let last = pieces.len().saturating_sub(1);
321            for (k, piece) in pieces.iter().enumerate() {
322                let (t0, t1) = piece.domain();
323                let side_at = |t: Scalar| -> GeopResult<Scalar> {
324                    Ok(dir.prod_cross(&piece.evaluate(t)?.sub(&a)))
325                };
326                let mut ends = Vec::new();
327                if !(k == 0 && start_on) {
328                    ends.push(t0);
329                }
330                if !(k == last && end_on) {
331                    ends.push(t1);
332                }
333                // A stationary point inside the curve that could lie on the
334                // axis is a touch no joint stands for: revolved, the wall
335                // would come to a pole there that no vertex of the profile
336                // is. One at a constrained end is that end's own pole.
337                let at_constrained_end = |t: &Scalar| {
338                    (k == 0 && start_on && t.could_be_equal(t0))
339                        || (k == last && end_on && t.could_be_equal(t1))
340                };
341                let interior = piece
342                    .stationary_parameters(&dir.perp(), MAX_NODES, min_subdivision_size())?
343                    .into_iter()
344                    .filter(|t| !at_constrained_end(t));
345                for t in interior {
346                    let side = side_at(t)?;
347                    if side.could_be_equal(Scalar::ZERO) {
348                        return Err(GeopError::new(format!(
349                            "the curve {},{} touches the revolve axis inside it, at parameter {t:?}: split it there at a point on the axis, or keep it clear of the axis",
350                            placed.name, edge.curve
351                        )));
352                    }
353                    left |= side.definitely_greater(Scalar::ZERO);
354                    right |= side.definitely_less(Scalar::ZERO);
355                }
356                for t in ends {
357                    let side = side_at(t)?;
358                    left |= side.definitely_greater(Scalar::ZERO);
359                    right |= side.definitely_less(Scalar::ZERO);
360                }
361            }
362        }
363        if left && right {
364            return Err(GeopError::new("the profile crosses the revolve axis"));
365        }
366        if !left && !right {
367            return Err(GeopError::new("the profile lies on the revolve axis"));
368        }
369
370        // `(r, z)` coordinates: `r` towards the profile, `z` along the axis,
371        // oriented like the sketch (`z` is `r` turned counter-clockwise), so
372        // loops keep their winding — a rigid motion, which leaves sweeps and
373        // radii as they are. Points on the axis get `r = 0` exactly.
374        let r_dir = axis_left.prod_scalar(if left { Scalar::ONE } else { Scalar::ONE.neg() });
375        let z_dir = r_dir.perp();
376        let rz = Enclosure {
377            points: geometry
378                .points
379                .iter()
380                .map(|(&p, xy)| {
381                    let d = xy.sub(&axis.point);
382                    let r = if on_axis.contains(&p) {
383                        Scalar::ZERO
384                    } else {
385                        d.prod_dot(&r_dir)
386                    };
387                    (p, Vector2::from_array([r, d.prod_dot(&z_dir)]))
388                })
389                .collect(),
390            params: geometry.params.clone(),
391        };
392        let pole =
393            |joint: &ProfileJoint| matches!(joint, ProfileJoint::Point(p) if on_axis.contains(p));
394        let sweep_loops = loops
395            .iter()
396            .map(|lp| {
397                let pieces = lp.to_nurbs(sketch, &rz)?;
398                let mut poles: Vec<bool> = pieces.iter().map(|p| pole(&p.start)).collect();
399                if !closed && let Some(last) = pieces.last() {
400                    poles.push(pole(&last.end));
401                }
402                let along_axis: Vec<bool> = pieces
403                    .iter()
404                    .map(|p| match sketch.curves[&p.source].kind {
405                        CurveKind::Line { start, end } => {
406                            on_axis.contains(&start) && on_axis.contains(&end)
407                        }
408                        _ => false,
409                    })
410                    .collect();
411                let profile = chain_of_pieces(&placed.name, pieces, closed);
412                for (i, &is_pole) in poles.iter().enumerate() {
413                    let at = profile.joint(i)?;
414                    if !is_pole && at[0].could_be_equal(Scalar::ZERO) {
415                        return Err(GeopError::new(format!(
416                            "the profile touches the revolve axis at {}, where no point of the sketch is: put one there, or keep the profile clear of the axis",
417                            profile.joint_names[i]
418                        )));
419                    }
420                }
421                Ok(SweepLoop {
422                    profile,
423                    poles,
424                    on_axis: along_axis,
425                    apex: false,
426                })
427            })
428            .collect::<GeopResult<Vec<_>>>()
429            ?;
430
431        let plane = &placed.plane;
432        let (u, w) = (plane.uv_direction(&r_dir), plane.uv_direction(&z_dir));
433        let origin = plane.uv_to_xyz(&axis.point);
434        let axes = CoordinateSystem::try_from_xz(origin, u, w)?;
435
436        let revolving = Revolving {
437            axes: &axes,
438            loops: &sweep_loops,
439        };
440        let how = (&args.extent, &args.output);
441        build_extents(model, reader, &namer, step, how, &revolving)
442    }
443}
444
445/// A revolve's loops, swept around the axis `axes.w()` from `axes.u()`
446/// (see [`Sweeping`]): its measure is the angle turned, in degrees.
447struct Revolving<'a> {
448    axes: &'a CoordinateSystem,
449    loops: &'a [SweepLoop],
450}
451
452impl Sweeping for Revolving<'_> {
453    fn loops(&self) -> &[SweepLoop] {
454        self.loops
455    }
456
457    fn sweep(
458        &self,
459        model: &mut NamedModel,
460        namer: &Namer,
461        name: Option<&str>,
462        from: f64,
463        to: f64,
464    ) -> GeopResult<BuiltBody> {
465        revolve(model, namer, name, self.axes, from, to, self.loops)
466    }
467
468    fn past(&self, hull: &[Vector3], sign: f64) -> GeopResult<f64> {
469        turn_past(hull, self.axes, sign)
470    }
471
472    fn ends(&self, to: f64) -> GeopResult<(String, String)> {
473        let stations = revolution(self.axes, 0.0, to)?.station_names;
474        Ok((stations[0].clone(), stations[stations.len() - 1].clone()))
475    }
476
477    fn all_the_way(&self, _: f64) -> f64 {
478        360.0
479    }
480
481    fn full_turn(&self) -> Option<f64> {
482        Some(360.0)
483    }
484
485    fn measure(&self, sign: f64, p: &Vector3) -> Option<f64> {
486        turned_angle(self.axes, p, sign)
487    }
488}
489
490/// How far `p` lies turned around `axes.w()` from where the turn starts,
491/// at `axes.u()` — forwards for `sign = 1`, backwards for `-1` — in
492/// degrees in `(0, 360)`; none for a point that could lie on the half plane
493/// the turn starts from, or on the axis.
494fn turned_angle(axes: &CoordinateSystem, p: &Vector3, sign: f64) -> Option<f64> {
495    let q = axes.to_uvw(p);
496    let on_axis = q[0].could_be_equal(Scalar::ZERO) && q[1].could_be_equal(Scalar::ZERO);
497    let at_start = q[1].could_be_equal(Scalar::ZERO) && q[0].definitely_greater(Scalar::ZERO);
498    if on_axis || at_start {
499        return None;
500    }
501    let angle = (sign * q[1].to_f64().atan2(q[0].to_f64()).to_degrees()).rem_euclid(360.0);
502    (angle > 0.0 && angle < 360.0).then_some(angle)
503}
504
505/// How far to turn around `axes.w()` — forwards for `sign = 1`, backwards
506/// for `-1`, in degrees from `axes.u()` — for a side going up to its next
507/// face to meet whichever face that is, short of a full turn, which would
508/// run into itself.
509///
510/// Seen down the axis, the solid whose convex hull `hull` spans fills the
511/// arc between the hull's points that leaves out the widest gap: from `b`
512/// to `e`, turning the way the side does. A turn starting inside that arc
513/// passes its end and stops in the middle of the gap after it. One starting
514/// before it — in the gap — passes all of it and stops between its end and a
515/// full turn; but if the arc reaches round to where the turn starts, there is
516/// nothing beyond it short of a full turn, and the turn stops in the middle
517/// of the arc instead: whatever the side meets on the way in is there
518/// already. If the axis runs through the hull, there is no gap at all; one
519/// merely touching it still leaves the half turn on its other side.
520///
521/// Touching is the usual case of a revolve around an edge of the target —
522/// a cup's seam, the edge of a plate — and the hull's points then lie on
523/// the axis and on a plane through it, exactly where rounding decides
524/// nothing. So they are measured as enclosed: a point that could lie on the
525/// axis says nothing of which way the target lies, and the target only
526/// surrounds the axis if the two points bounding the widest gap definitely
527/// leave less than a half turn between them.
528fn turn_past(hull: &[Vector3], axes: &CoordinateSystem, sign: f64) -> GeopResult<f64> {
529    let surrounds = |detail: String| {
530        surrounds(format!(
531            "{detail}; the axis through {:?} along {:?}",
532            axes.origin(),
533            axes.w()
534        ))
535    };
536    // In `[0, 360)`: `rem_euclid` of the smallest negative angle rounds to
537    // 360 itself, which is where the turn starts — 0.
538    let turned = |degrees: f64| match degrees.rem_euclid(360.0) {
539        a if a >= 360.0 => 0.0,
540        a => a,
541    };
542    // Each point seen down the axis, `y` measured the way the turn goes, and
543    // its angle there.
544    let mut seen = Vec::with_capacity(hull.len());
545    for p in hull {
546        let q = axes.to_uvw(p);
547        let (x, y) = (q[0], q[1].mul(Scalar::from_f64(sign)));
548        // A point that could lie on the axis could lie on the edge of every
549        // half turn: where a hull touching the axis touches it — the axis an
550        // edge of the target, or through a corner of it.
551        if x.could_be_equal(Scalar::ZERO) && y.could_be_equal(Scalar::ZERO) {
552            continue;
553        }
554        let angle = turned(y.to_f64().atan2(x.to_f64()).to_degrees());
555        seen.push((angle, x, y, p));
556    }
557    if seen.is_empty() {
558        return Err(surrounds(format!(
559            "all of the hull's {} points could lie on the axis",
560            hull.len()
561        )));
562    }
563    seen.sort_by(|a, b| a.0.total_cmp(&b.0));
564    let n = seen.len();
565    let (gap, i) = (0..n)
566        .map(|i| {
567            let next = if i + 1 < n {
568                seen[i + 1].0
569            } else {
570                seen[0].0 + 360.0
571            };
572            (next - seen[i].0, i)
573        })
574        .max_by(|a, b| a.0.total_cmp(&b.0))
575        .expect("a hull has points");
576    // A convex hull clear of the axis fills less than a half turn of
577    // directions, and one merely touching it — a solid with a face in a
578    // plane through the axis — exactly a half turn: the other half is free
579    // either way. Only a hull the axis runs through leaves less: the gap
580    // from `a` round to `b` is less than a half turn where `a × b` is
581    // definitely positive.
582    let (a, b) = (&seen[i], &seen[(i + 1) % n]);
583    let across = a.1.mul(b.2).sub(a.2.mul(b.1));
584    if gap < 180.0 && across.definitely_greater(Scalar::ZERO) {
585        return Err(surrounds(format!(
586            "the widest gap between the hull's {n} points seen down the axis, from {:?} at {:?} degrees to {:?} at {:?}, is only {gap:?} degrees",
587            a.3, a.0, b.3, b.0,
588        )));
589    }
590    // The arc the solid fills, from `b` to `e`; an end at the start is a
591    // full turn round.
592    let after = a.0;
593    let b = turned(after + gap);
594    let e = if after == 0.0 { 360.0 } else { after };
595    Ok(if b > e {
596        // The arc runs over the start: past its end, into the gap.
597        e + gap / 2.0
598    } else if e < 360.0 {
599        (e + 360.0) / 2.0
600    } else {
601        (b + 360.0) / 2.0
602    })
603}
604
605/// Why [`turn_past`] finds no angle to turn to.
606fn surrounds(detail: String) -> GeopError {
607    GeopError::new(format!(
608        "up to next: the target lies all around the revolve axis, so no turn short of a full one comes past it; give the angle instead ({detail})",
609    ))
610}
611
612#[cfg(test)]
613mod tests {
614    use super::turn_past;
615    use geop_core_math::{primitives::CoordinateSystem, scalars::Scalar, vector::Vector3};
616
617    /// A solid with a face in the plane the turn starts from — a revolve
618    /// sketched on that face — lies against the start from behind: one of
619    /// its points, at an angle a hair below zero, rounds to a whole turn.
620    /// The turn must still stop short of one, wherever it goes.
621    #[test]
622    fn a_target_against_the_start_plane_stops_the_turn_short_of_a_full_one() {
623        let axes = CoordinateSystem::world_at(Vector3::zero());
624        // The half space below `y = 0`, touching the start plane at `x > 0`
625        // — one point a hair below it.
626        let hull = [
627            [2.0, -1e-17, 0.0],
628            [3.0, 0.0, 1.0],
629            [-3.0, 0.0, 1.0],
630            [0.0, -3.0, 0.0],
631            [2.0, -2.0, 1.0],
632        ]
633        .map(|p| Vector3::from_array(p.map(Scalar::from_f64)));
634        for sign in [1.0, -1.0] {
635            let far = turn_past(&hull, &axes, sign).unwrap();
636            assert!(far > 0.0 && far < 360.0, "{sign}: {far}");
637        }
638        // Turning forwards, through the free half first, it must come past
639        // the half turn where the solid begins.
640        assert!(turn_past(&hull, &axes, 1.0).unwrap() > 180.0);
641    }
642}