Skip to main content

geop_lib_sweep/
revolve.rs

1//! Revolving: sweeping a planar profile around an axis (see
2//! [`crate::sweep`]) — a full turn, or any angle up to one.
3//!
4//! The profile is given in `(r, z)`: `r` away from the axis, `z` along it.
5//! Every station is a half-plane through the axis, at its own angle; every
6//! span between two of them, at most a quarter turn, sweeps each profile
7//! point along an exact circular arc — a rational quadratic.
8//!
9//! A part of the profile on the axis stays put (see [`SweepLoop`]): a joint
10//! there is a pole, a curve along it sweeps nothing. So a region touching
11//! the axis along an edge revolves a full turn into a solid with no caps at
12//! all, and through a partial turn into one whose two caps share that edge.
13
14use crate::sweep::{Frame, Path, Span, SweepLoop, sweep, sweep_drawn};
15use geop_core_geometry::nurb_curve::{
16    Handedness, Helix, HelixRow, NurbCurve2D, arc_span, cos_sin, helix_rows,
17};
18use geop_core_math::{
19    geop_error::{GeopError, GeopResult},
20    primitives::CoordinateSystem,
21    scalars::Scalar,
22    vector::{Vector2, Vector3},
23};
24use geop_core_topology::{SolidId, build::BuiltBody};
25use geop_ops_model::{NamedModel, Namer, Profile};
26
27/// The path around the axis `axes.w()` through `axes.origin()`, from the
28/// angle `from` to `to` — either way round — in degrees, angles turning
29/// from `axes.u()` towards `axes.v()`: a profile point `(r, z)` at angle `a`
30/// lies at `origin + r (cos a u + sin a v) + z w`.
31///
32/// Its stations are `a0` (at `from`), `a1`, ... and the spans between them
33/// `q0`, `q1`, ..., each at most a quarter turn, all equal. A full turn —
34/// `to` a whole turn from `from`, either way — closes on itself, and always
35/// runs backwards: `q3` from `a0` back to `a3`, then `q2`, `q1`, `q0`, so
36/// that `a1` lies a quarter turn on from `a0`.
37pub fn revolution(axes: &CoordinateSystem, from: f64, to: f64) -> GeopResult<Path> {
38    let sweep = to - from;
39    if !(sweep != 0.0 && sweep.abs() <= 360.0) {
40        return Err(GeopError::new(format!(
41            "revolution: cannot turn from {from} to {to} degrees: it must be more than none and at most a full turn"
42        )));
43    }
44    let full = sweep.abs() == 360.0;
45    let (u, v, w) = (axes.u(), axes.v(), axes.w());
46    let direction = |degrees: f64| {
47        let (c, s) = cos_sin(degrees);
48        axes.uv_direction(&Vector2::from_array([
49            Scalar::from_f64(c),
50            Scalar::from_f64(s),
51        ]))
52    };
53    let frame = |e1: Vector3| Frame {
54        origin: *axes.origin(),
55        e1,
56        e2: *w,
57    };
58    // A full turn runs backwards, in quarter turns; any other forwards, in
59    // as few equal spans as keep each at most a quarter turn.
60    let (spans, step) = if full {
61        (4, -90.0)
62    } else {
63        let spans = (sweep.abs() / 90.0).ceil() as usize;
64        (spans, sweep / spans as f64)
65    };
66    let angles: Vec<f64> = (0..spans + usize::from(!full))
67        .map(|k| from + k as f64 * step)
68        .collect();
69    let stations: Vec<Frame> = angles.iter().map(|&a| frame(direction(a))).collect();
70    let (weight, outwards) = arc_span(step);
71    let spans_of = (0..spans)
72        .map(|j| {
73            let (a, b) = (&stations[j], &stations[(j + 1) % stations.len()]);
74            Span::arc(frame(a.e1.add(&b.e1).prod_scalar(outwards)), weight)
75        })
76        .collect();
77    // Which way round the path runs, against the stations' `e1 x e2`: the
78    // tangent at the first station, turning the way the angles do.
79    let (c, s) = cos_sin(from);
80    let tangent = v
81        .prod_scalar(Scalar::from_f64(c))
82        .sub(&u.prod_scalar(Scalar::from_f64(s)));
83    let along = stations[0]
84        .e1
85        .prod_cross(w)
86        .prod_dot(&tangent)
87        .mul(Scalar::from_f64(step));
88    let along_normal = along
89        .definite_sign()
90        .ok_or_else(|| GeopError::new(format!("revolution: the axes {axes} are degenerate")))?;
91    let (station_names, span_names) = if full {
92        (
93            ["a0", "a3", "a2", "a1"].map(String::from).to_vec(),
94            ["q3", "q2", "q1", "q0"]
95                .map(|q| Some(q.to_string()))
96                .to_vec(),
97        )
98    } else {
99        (
100            (0..angles.len()).map(|k| format!("a{k}")).collect(),
101            (0..spans).map(|k| Some(format!("q{k}"))).collect(),
102        )
103    };
104    Ok(Path {
105        stations,
106        spans: spans_of,
107        closed: full,
108        along_normal,
109        station_names,
110        span_names,
111    })
112}
113
114/// Revolves `loops` — the first the outer loop, counter-clockwise in `(r,
115/// z)`, the rest holes in it, clockwise — from `from` to `to` degrees
116/// around `axes` (see [`revolution`]): into a solid named `solid`, its start
117/// cap at `from`, or, without one, into sheets. Either way round, the solid
118/// comes out with its faces pointing outwards.
119///
120/// Named after the profiles' curves `X` and joints `P` (see [`Profile`]
121/// and [`sweep`]): `N(X,q)` the face `X` sweeps through span `q`, `N(X,a)`
122/// the edge `X` is at station `a`, `N(P,q)` the arc `P` sweeps through `q`,
123/// `N(P,a)` the vertex of `P` at `a` — `N(P)` for a pole — and for a partial
124/// turn, the caps `N(start)` and `N(end)`.
125pub fn revolve(
126    model: &mut NamedModel,
127    namer: &Namer,
128    solid: Option<&str>,
129    axes: &CoordinateSystem,
130    from: f64,
131    to: f64,
132    loops: &[SweepLoop],
133) -> GeopResult<BuiltBody> {
134    let path = revolution(axes, from, to)?;
135    sweep_drawn(model, namer, &path, loops, solid)
136}
137
138/// The path of a screw motion around the axis `axes.w()` through
139/// `axes.origin()`: turning from `axes.u()` as `helix` says, right-handed —
140/// from `u` towards `v` — or left-handed. A profile point `(r, z)` travels
141/// along the helix of radius `r` raised by `z`: the stations and middles of
142/// the path are the control rows of [`NurbCurve3D::helix`] (see
143/// [`helix_rows`]), exactly on the cylinder, the height within 0.53 % of
144/// the pitch of the true helix. Tapered, the profile moves out as it rises,
145/// each of its points on a cone, as a pipe thread's does.
146///
147/// Its stations are `a0`, `a1`, ... and its spans `q0`, `q1`, ..., each at
148/// most a quarter turn. It never closes: a sweep along it has caps `start`
149/// and `end`.
150///
151/// [`NurbCurve3D::helix`]: geop_core_geometry::nurb_curve::NurbCurve3D::helix
152pub fn screw_path(axes: &CoordinateSystem, helix: &Helix) -> GeopResult<Path> {
153    let rows = helix_rows(axes, helix)?;
154    let (origin, w) = (axes.origin(), axes.w());
155    let frame = |row: &HelixRow| {
156        let raised = origin.add(&w.prod_scalar(row.rise));
157        Frame {
158            origin: row
159                .out
160                .map_or(raised, |out| raised.add(&row.radial.prod_scalar(out))),
161            e1: row.radial,
162            e2: *w,
163        }
164    };
165    let stations: Vec<Frame> = rows.iter().step_by(2).map(frame).collect();
166    let spans: Vec<Span> = rows
167        .iter()
168        .skip(1)
169        .step_by(2)
170        .map(|row| Span::arc(frame(row), row.weight.expect("a middle row is weighted")))
171        .collect();
172    // Turning from `u` towards `v` — right-handed — runs against `e1 x e2
173    // = u x w = -v` where it starts, as a revolve turning forwards does.
174    let along_normal = helix.handedness == Handedness::Left;
175    Ok(Path {
176        station_names: (0..stations.len()).map(|k| format!("a{k}")).collect(),
177        span_names: (0..spans.len()).map(|k| Some(format!("q{k}"))).collect(),
178        stations,
179        spans,
180        closed: false,
181        along_normal,
182    })
183}
184
185/// Sweeps `loops` — in `(r, z)` as for [`revolve`], the first the outer
186/// loop counter-clockwise, the rest holes in it, clockwise — along the
187/// screw motion of [`screw_path`] into a solid named `solid`, its faces
188/// pointing outwards: a thread, for a profile at most a pitch wide. The
189/// profile must stay clear of the axis, and narrower along it than the
190/// pitch, or the turns run into each other.
191///
192/// Named after the profiles' curves `X` and joints `P`: `N(X,q)` the face
193/// `X` sweeps through span `q`, `N(X,a)` the edge `X` is at station `a`,
194/// `N(P,q)` the helical edge `P` sweeps through `q`, and the caps
195/// `N(start)` and `N(end)`.
196pub fn screw(
197    model: &mut NamedModel,
198    namer: &Namer,
199    solid: &str,
200    axes: &CoordinateSystem,
201    helix: &Helix,
202    loops: &[SweepLoop],
203) -> GeopResult<SolidId> {
204    let path = screw_path(axes, helix)?;
205    sweep_drawn(model, namer, &path, loops, Some(solid))?
206        .solid
207        .ok_or_else(|| GeopError::new("screw: built no solid"))
208}
209
210/// Revolves `profile` a full turn around `axes.w()` into a solid named
211/// `solid` — the way the basic shapes are built: `profile` runs "top-down",
212/// walked from its first point to its last the region it bounds together
213/// with the axis lies on its right (e.g. `(0, h) -> (r, h) -> (r, 0) -> (0,
214/// 0)` for a cylinder). An open chain starts and ends on the axis, and every
215/// point of it there is a pole; a closed loop stays clear of the axis and
216/// sweeps a ring.
217pub fn revolve_at_oriented(
218    model: &mut NamedModel,
219    namer: &Namer,
220    solid: &str,
221    profile: &Profile,
222    axes: &CoordinateSystem,
223) -> GeopResult<SolidId> {
224    profile.check_names()?;
225    let lp = if profile.is_closed() {
226        for (i, curve) in profile.curves.iter().enumerate() {
227            if curve.start()?[0].could_be_equal(Scalar::ZERO) {
228                return Err(GeopError::new(format!(
229                    "revolve: a closed profile must stay off the axis, but its point {i} could be on it"
230                )));
231            }
232        }
233        SweepLoop::plain(profile.clone())
234    } else {
235        // Closed along the axis, which sweeps nothing.
236        let (first, last) = match (profile.curves.first(), profile.curves.last()) {
237            (Some(first), Some(last)) => (first, last),
238            _ => {
239                return Err(GeopError::new(
240                    "revolve: profile must have at least 1 curve",
241                ));
242            }
243        };
244        let mut closed = profile.clone();
245        closed
246            .curves
247            .push(NurbCurve2D::line(last.end()?, first.start()?)?);
248        closed.curve_names.push("axis".into());
249        let poles = closed
250            .curves
251            .iter()
252            .map(|c| Ok(c.start()?[0].could_be_equal(Scalar::ZERO)))
253            .collect::<GeopResult<Vec<bool>>>()?;
254        if !(poles[0] && poles[profile.curves.len()]) {
255            return Err(GeopError::new(
256                "revolve: an open profile must start and end at r = 0 (a pole)",
257            ));
258        }
259        let mut on_axis = vec![false; closed.curves.len()];
260        on_axis[profile.curves.len()] = true;
261        SweepLoop {
262            profile: closed,
263            poles,
264            on_axis,
265            apex: false,
266        }
267    };
268    // A full turn runs along its stations' `e1 x e2`, so it takes the
269    // profile clockwise: top-down, as given.
270    let built = sweep(
271        model,
272        namer,
273        &revolution(axes, 0.0, 360.0)?,
274        &[lp],
275        Some(solid),
276    )?;
277    built
278        .solid
279        .ok_or_else(|| GeopError::new("revolve: built no solid"))
280}
281
282#[cfg(test)]
283mod tests {
284    use super::*;
285    use crate::common::polyline;
286    use crate::shapes::v2;
287    use geop_core_geometry::nurb_curve::sqrt2_over_2;
288    use geop_core_math::vector::Vector2;
289    use geop_ops_model::testing::{assert_model_valid, assert_model_valid_sheets};
290
291    /// Revolves `profile` a full turn around the z-axis, `a0` along `x`.
292    fn revolved_about_z(
293        model: &mut NamedModel,
294        namer: &Namer,
295        profile: &Profile,
296    ) -> GeopResult<SolidId> {
297        let axes = CoordinateSystem::world_at(Vector3::zero());
298        revolve_at_oriented(model, namer, &namer.root(), profile, &axes)
299    }
300
301    /// Revolve `curves` around the z-axis into a fresh part, as operation `r`.
302    fn revolved(curves: Vec<NurbCurve2D>) -> NamedModel {
303        let mut model = NamedModel::new();
304        let namer = Namer::new("revolve", "r").unwrap();
305        revolved_about_z(&mut model, &namer, &Profile::open(curves)).unwrap();
306        model.check_names().unwrap();
307        model
308    }
309
310    /// An exact sphere from two quarter arcs: curved profile edges become
311    /// doubly curved quadrant patches.
312    #[test]
313    fn sphere_from_arcs_is_valid() {
314        let w = sqrt2_over_2();
315        let profile = vec![
316            NurbCurve2D::arc(v2(0.0, 1.0), v2(1.0, 1.0), v2(1.0, 0.0), w).unwrap(),
317            NurbCurve2D::arc(v2(1.0, 0.0), v2(1.0, -1.0), v2(0.0, -1.0), w).unwrap(),
318        ];
319        let model = revolved(profile);
320        let topology = model.topology();
321        assert_model_valid(topology);
322        assert_eq!(topology.faces.len(), 8);
323        // Two poles, and the equator's ring of four vertices named after the
324        // joint between the arcs.
325        for name in [
326            "revolve(r,p0)",
327            "revolve(r,p2)",
328            "revolve(r,p1,a0)",
329            "revolve(r,p1,a3)",
330        ] {
331            model.vertex_id(name).unwrap();
332        }
333        for name in ["revolve(r,c0,a0)", "revolve(r,c1,a2)", "revolve(r,p1,q3)"] {
334            model.edge_id(name).unwrap();
335        }
336        model.face_id("revolve(r,c1,q3)").unwrap();
337    }
338
339    /// A vase: a line up the side and a cubic spline bulging out, capped by
340    /// lines back to the axis.
341    #[test]
342    fn vase_with_spline_is_valid() {
343        let hom = |x: f64, y: f64| {
344            geop_core_math::vector::Vector3::from_array([
345                Scalar::from_f64(x),
346                Scalar::from_f64(y),
347                Scalar::ONE,
348            ])
349        };
350        let spline = geop_core_geometry::nurb_curve::NurbCurve::try_new(
351            3,
352            vec![hom(0.5, 2.0), hom(1.5, 1.5), hom(0.2, 0.7), hom(1.0, 0.0)],
353            vec![
354                Scalar::ZERO,
355                Scalar::ZERO,
356                Scalar::ZERO,
357                Scalar::ZERO,
358                Scalar::ONE,
359                Scalar::ONE,
360                Scalar::ONE,
361                Scalar::ONE,
362            ],
363        )
364        .unwrap();
365        let mut profile = polyline(&[v2(0.0, 2.0), v2(0.5, 2.0)]).unwrap();
366        profile.push(spline);
367        profile.extend(polyline(&[v2(1.0, 0.0), v2(0.0, 0.0)]).unwrap());
368        let model = revolved(profile);
369        let topology = model.topology();
370        assert_model_valid(topology);
371        assert_eq!(topology.faces.len(), 12);
372    }
373
374    #[test]
375    fn cone_is_valid() {
376        let profile = polyline(&[v2(0.0, 1.0), v2(1.0, 0.0), v2(0.0, 0.0)]).unwrap();
377        assert_model_valid(revolved(profile).topology());
378    }
379
380    #[test]
381    fn sphere_is_valid() {
382        let n = 6;
383        let points: Vec<Vector2> = (0..=n)
384            .map(|k| {
385                if k == 0 || k == n {
386                    return v2(0.0, if k == 0 { 1.0 } else { -1.0 });
387                }
388                let t = std::f64::consts::PI * (k as f64) / (n as f64);
389                v2(t.sin(), t.cos())
390            })
391            .collect();
392        assert_model_valid(revolved(polyline(&points).unwrap()).topology());
393    }
394
395    /// Revolve the closed loop `curves` around the z-axis into a fresh part.
396    fn revolved_ring(curves: Vec<NurbCurve2D>) -> NamedModel {
397        let mut model = NamedModel::new();
398        let namer = Namer::new("revolve", "r").unwrap();
399        revolved_about_z(&mut model, &namer, &Profile::closed(curves)).unwrap();
400        model.check_names().unwrap();
401        model
402    }
403
404    /// A square off the axis revolves into a ring with a square cross
405    /// section: genus one, four quadrant faces per side, no poles.
406    #[test]
407    fn square_ring_is_valid() {
408        // Clockwise, so the square lies on the right of it walked along.
409        let profile = polyline(&[
410            v2(1.0, 1.0),
411            v2(2.0, 1.0),
412            v2(2.0, 0.0),
413            v2(1.0, 0.0),
414            v2(1.0, 1.0),
415        ])
416        .unwrap();
417        let model = revolved_ring(profile);
418        let topology = model.topology();
419        assert_model_valid(topology);
420        assert_eq!(topology.faces.len(), 16);
421        assert_eq!(topology.vertices.len(), 16);
422        assert_eq!(topology.edges.len(), 32);
423        for name in ["revolve(r,c0,q0)", "revolve(r,c3,q3)", "revolve(r,p0,a3)"] {
424            assert!(
425                model.face_id(name).is_ok() || model.vertex_id(name).is_ok(),
426                "{name}"
427            );
428        }
429        model.edge_id("revolve(r,p0,q3)").unwrap();
430        model.edge_id("revolve(r,c3,a0)").unwrap();
431    }
432
433    /// A circle off the axis revolves into an exact torus: two half arcs,
434    /// clockwise, swept into doubly curved quadrant patches.
435    #[test]
436    fn torus_is_valid() {
437        let w = sqrt2_over_2();
438        let profile = vec![
439            NurbCurve2D::arc(v2(2.0, 1.0), v2(3.0, 1.0), v2(3.0, 0.0), w).unwrap(),
440            NurbCurve2D::arc(v2(3.0, 0.0), v2(3.0, -1.0), v2(2.0, -1.0), w).unwrap(),
441            NurbCurve2D::arc(v2(2.0, -1.0), v2(1.0, -1.0), v2(1.0, 0.0), w).unwrap(),
442            NurbCurve2D::arc(v2(1.0, 0.0), v2(1.0, 1.0), v2(2.0, 1.0), w).unwrap(),
443        ];
444        let model = revolved_ring(profile);
445        let topology = model.topology();
446        assert_model_valid(topology);
447        assert_eq!(topology.faces.len(), 16);
448    }
449
450    /// A loop touching the axis cannot sweep a ring.
451    #[test]
452    fn rings_stay_off_the_axis() {
453        let profile = polyline(&[v2(0.0, 1.0), v2(1.0, 1.0), v2(1.0, 0.0), v2(0.0, 1.0)]).unwrap();
454        let mut model = NamedModel::new();
455        let namer = Namer::new("revolve", "r").unwrap();
456        assert!(revolved_about_z(&mut model, &namer, &Profile::closed(profile)).is_err());
457    }
458
459    /// The `(r, z)` square `[r0, r0 + 1] x [0, 1]`, counter-clockwise; with
460    /// `r0 = 0` its side on the axis is a curve along it, between two poles.
461    fn square_loop(r0: f64) -> SweepLoop {
462        let profile = Profile::closed(
463            polyline(&[
464                v2(r0, 0.0),
465                v2(r0 + 1.0, 0.0),
466                v2(r0 + 1.0, 1.0),
467                v2(r0, 1.0),
468                v2(r0, 0.0),
469            ])
470            .unwrap(),
471        );
472        let on = r0 == 0.0;
473        SweepLoop {
474            profile,
475            poles: vec![on, false, false, on],
476            on_axis: vec![false, false, false, on],
477            apex: false,
478        }
479    }
480
481    fn z_axes() -> CoordinateSystem {
482        CoordinateSystem::world_at(Vector3::zero())
483    }
484
485    /// Revolves `loops` from `from` to `to` degrees around the z-axis into a
486    /// solid of a fresh part, as operation `r`.
487    fn revolved_by(loops: &[SweepLoop], from: f64, to: f64) -> NamedModel {
488        let mut model = NamedModel::new();
489        let namer = Namer::new("revolve", "r").unwrap();
490        revolve(
491            &mut model,
492            &namer,
493            Some("revolve(r)"),
494            &z_axes(),
495            from,
496            to,
497            loops,
498        )
499        .unwrap();
500        model.check_names().unwrap();
501        model
502    }
503
504    /// A partial turn of a region on the axis: walls for the three sides
505    /// off it, one per span, and two caps sharing the side on the axis.
506    #[test]
507    fn partial_turns_are_capped() {
508        for (from, to, spans) in [
509            (0.0, 90.0, 1),
510            (0.0, 270.0, 3),
511            (0.0, -120.0, 2),
512            (-30.0, 45.0, 1),
513        ] {
514            let model = revolved_by(&[square_loop(0.0)], from, to);
515            let topology = model.topology();
516            assert_model_valid(topology);
517            assert_eq!(topology.faces.len(), 3 * spans + 2, "{from} to {to}");
518            let axis = model.edge_id("revolve(r,c3)").unwrap();
519            let caps: Vec<_> = topology
520                .coedges_of_edge(axis)
521                .into_iter()
522                .map(|c| {
523                    model
524                        .name_of(topology.get_coedge(c).unwrap().face)
525                        .unwrap()
526                        .to_string()
527                })
528                .collect();
529            assert_eq!(caps.len(), 2);
530            assert!(caps.contains(&"revolve(r,start)".to_string()));
531            assert!(caps.contains(&"revolve(r,end)".to_string()));
532            // The end cap lies at `to`.
533            let corner = topology
534                .get_vertex(model.vertex_id(&format!("revolve(r,p2,a{spans})")).unwrap())
535                .unwrap()
536                .point;
537            let (c, s) = cos_sin(to);
538            let expected =
539                Vector3::from_array([Scalar::from_f64(c), Scalar::from_f64(s), Scalar::ONE]);
540            assert!(corner.could_be_equal(&expected), "{corner:?}");
541        }
542    }
543
544    /// A turn of a region clear of the axis, with a hole: a full one sweeps
545    /// the hole into a void, a shell of its own; a partial one cuts a
546    /// tunnel through the caps.
547    #[test]
548    #[ignore = "slow: 4 s — run with `cargo test -- --ignored`"]
549    fn holes_sweep_voids_and_tunnels() {
550        let outer = SweepLoop::plain(Profile::closed(
551            polyline(&[
552                v2(1.0, 0.0),
553                v2(4.0, 0.0),
554                v2(4.0, 3.0),
555                v2(1.0, 3.0),
556                v2(1.0, 0.0),
557            ])
558            .unwrap(),
559        ));
560        let hole = SweepLoop::plain(
561            Profile::closed(
562                polyline(&[
563                    v2(2.0, 1.0),
564                    v2(2.0, 2.0),
565                    v2(3.0, 2.0),
566                    v2(3.0, 1.0),
567                    v2(2.0, 1.0),
568                ])
569                .unwrap(),
570            )
571            .with_prefix("h"),
572        );
573        let loops = [outer, hole];
574        let full = revolved_by(&loops, 0.0, 360.0);
575        assert_model_valid(full.topology());
576        let solid = full.solid_id("revolve(r)").unwrap();
577        assert_eq!(full.topology().get_solid(solid).unwrap().shells.len(), 2);
578        assert_eq!(full.topology().faces.len(), 32);
579
580        let quarter = revolved_by(&loops, 0.0, 90.0);
581        assert_model_valid(quarter.topology());
582        assert_eq!(quarter.topology().faces.len(), 4 + 4 + 2);
583        let cap = quarter.face_id("revolve(r,start)").unwrap();
584        assert_eq!(quarter.topology().get_face(cap).unwrap().holes.len(), 1);
585    }
586
587    /// Without a solid, a sweep builds a sheet of walls: an open chain off
588    /// the axis turned fully is a tube with two free rims, one ending on
589    /// the axis a disc closing over its pole.
590    #[test]
591    fn sheets_are_walls_alone() {
592        let namer = Namer::new("revolve", "r").unwrap();
593        for (chain, faces, free) in [
594            (polyline(&[v2(1.0, 0.0), v2(1.0, 1.0)]).unwrap(), 4, 8),
595            (polyline(&[v2(0.0, 0.0), v2(1.0, 0.0)]).unwrap(), 4, 4),
596        ] {
597            let mut model = NamedModel::new();
598            let profile = Profile::open(chain);
599            let poles = profile
600                .joint_names
601                .iter()
602                .enumerate()
603                .map(|(i, _)| {
604                    i == 0 && profile.curves[0].start().unwrap()[0].could_be_equal(Scalar::ZERO)
605                })
606                .collect();
607            let lp = SweepLoop {
608                on_axis: vec![false],
609                poles,
610                profile,
611                apex: false,
612            };
613            let built = revolve(&mut model, &namer, None, &z_axes(), 0.0, 360.0, &[lp]).unwrap();
614            model.check_names().unwrap();
615            assert!(built.solid.is_none());
616            let topology = model.topology();
617            assert_model_valid_sheets(topology);
618            assert_eq!(topology.faces.len(), faces);
619            assert_eq!(model.sheet_face_names().len(), faces);
620            let one_sided = topology
621                .edges
622                .keys()
623                .filter(|&&e| topology.coedges_of_edge(e).len() == 1)
624                .count();
625            assert_eq!(one_sided, free);
626        }
627    }
628
629    /// A rectangular thread, a turn and a quarter of it either way round:
630    /// a valid solid, its helical edges exactly the helices
631    /// `NurbCurve3D::helix` builds — on the cylinder of their radius, rising
632    /// by the pitch per turn.
633    #[test]
634    fn a_screw_sweeps_a_thread() {
635        a_screw_sweeps_a_thread_tapered(Scalar::ZERO);
636    }
637
638    /// The same thread tapered as a pipe thread is, one in sixteen on the
639    /// diameter: valid, its helical edges the tapered helices, on their
640    /// cones.
641    #[test]
642    fn a_tapered_screw_sweeps_a_pipe_thread() {
643        a_screw_sweeps_a_thread_tapered(Scalar::from_f64(1.0 / 32.0));
644    }
645
646    fn a_screw_sweeps_a_thread_tapered(taper: Scalar) {
647        use geop_core_geometry::nurb_curve::NurbCurve3D;
648        for handedness in [Handedness::Right, Handedness::Left] {
649            let mut model = NamedModel::new();
650            let namer = Namer::new("screw", "t").unwrap();
651            // Counter-clockwise in `(r, z)`.
652            let corners = [
653                (1.0, -0.2),
654                (1.3, -0.2),
655                (1.3, 0.2),
656                (1.0, 0.2),
657                (1.0, -0.2),
658            ];
659            let points: Vec<Vector2> = corners.iter().map(|&(r, z)| v2(r, z)).collect();
660            let profile = Profile::closed(crate::common::polyline(&points).unwrap());
661            let axes = CoordinateSystem::world_at(Vector3::zero());
662            let pitch = Scalar::ONE;
663            screw(
664                &mut model,
665                &namer,
666                "screw(t)",
667                &axes,
668                &Helix::new(pitch, 1.25, handedness).tapered(taper),
669                &[SweepLoop::plain(profile)],
670            )
671            .unwrap();
672            model.check_names().unwrap();
673            assert_model_valid(model.topology());
674            assert_eq!(model.topology().faces.len(), 4 * 5 + 2);
675            // The edge the inner bottom corner sweeps through the third
676            // span is that stretch of the helix of radius 1, lowered by 0.2.
677            let edge = model.edge_id("screw(t,p0,q2)").unwrap();
678            let curve = &model.topology().get_edge(edge).unwrap().curve;
679            let lowered = CoordinateSystem::world_at(Vector3::from_array([
680                Scalar::ZERO,
681                Scalar::ZERO,
682                Scalar::from_f64(-0.2),
683            ]));
684            let helix = NurbCurve3D::helix(
685                &lowered,
686                Scalar::ONE,
687                &Helix::new(pitch, 1.25, handedness).tapered(taper),
688            )
689            .unwrap();
690            for k in 0..=4 {
691                let along = Scalar::from_ratio(k, 4).unwrap();
692                let (t0, t1) = curve.domain();
693                let p = curve.evaluate(t0.add(t1.sub(t0).mul(along))).unwrap();
694                let h = helix
695                    .evaluate(
696                        Scalar::from_ratio(2, 5)
697                            .unwrap()
698                            .add(along.div(Scalar::from_f64(5.0)).unwrap()),
699                    )
700                    .unwrap();
701                assert!(p.could_be_equal(&h), "{handedness:?} {k}: {p:?} vs {h:?}");
702            }
703        }
704    }
705}