Skip to main content

geop_ops_extrude_revolve/
revolve.rs

1//! Revolve a planar profile, given as `(r, z)` points in the half-plane `r
2//! >= 0` (whose first and last points must be poles, `r = 0`), 360 degrees
3//! around a vertical axis, entirely from euler operations — built one
4//! angular quadrant *column* at a time (all `P = profile.len() - 1` row
5//! faces of a given 90-degree wedge, before moving to the next wedge),
6//! exactly the same strategy [`sphere_octants`](super::sphere::sphere_octants)
7//! uses for its own dedicated (exact, doubly-curved) construction, just
8//! generalized to an arbitrary profile instead of a fixed 2-segment
9//! pole-equator-pole one.
10//!
11//! One persistent face (`mvfs`'s own, playing the same role `extrude`'s
12//! placeholder does) always holds whatever's still left to close off. The
13//! very first column (meridian 0, the profile's own `P`-edge chain, straight
14//! in the profile direction) is bootstrapped directly from `mvfs`'s vertex.
15//! Each of the next 2 columns grows a fresh meridian one angular "beam" arc
16//! at a time (one per non-pole row) and immediately closes every row's own
17//! degree-(2, 1) quadrant face (`mve` for the beam, `mef` — minting that
18//! row's new meridian edge as it goes — for the face) against the *previous*
19//! meridian. The 4th and last column closes back onto the very first
20//! meridian's own reversed edges instead of growing a new one — only the
21//! interior beams (angle 3 `->` 0) are still new, one per non-last row's own
22//! closing `mef` — and its very last row needs no `mef` at all: by the time
23//! every other row of that column has closed, its own boundary is already
24//! sitting complete on the placeholder face, so `replace_face` alone turns
25//! it real. No separate degenerate cap is ever needed, unlike a naive
26//! row-by-row sweep (which always leaves one extra zero-area face behind at
27//! the final pole).
28//!
29//! A profile row at `r = 0` (only ever the first/last, both required poles)
30//! collapses to a single shared vertex — no angular beam is grown for it at
31//! all, and its neighboring rows' own meridian edges connect straight to
32//! that one shared vertex instead, both euler ops here (`mve`, `mef`) taking
33//! existing vertices as-is rather than minting fresh ones.
34
35use crate::common::{
36    Profile, arc3, embed_curve, embed_point, end_point, line2, sqrt2_over_2, start_point,
37};
38use geop_core_geometry::{
39    nurb_curve::{NurbCurve2D, NurbCurve3D},
40    nurb_surface::NurbSurface,
41};
42use geop_core_math::{
43    geop_error::{GeopError, GeopResult, WithContext},
44    primitives::CoordinateSystem,
45    scalars::Scalar,
46    vector::{Vector2, Vector3, Vector4},
47    with_context,
48};
49use geop_core_part::{Namer, Part};
50use geop_core_topology::{CoedgeId, SolidId};
51
52/// Bridges a quadrant face's own `(u, v)` boundary-loop gap at a pole row
53/// it touches on its `v = 0` (top) side — the row's two meridian edges meet
54/// directly at the shared pole vertex (a real, correctly-shared 3-D vertex,
55/// nothing wrong there), but nothing was ever built to represent that
56/// row's own zero-length angular span in *parameter* space, so the loop
57/// jumps straight from `u = 0` to `u = 1` without a pcurve covering the
58/// gap between them — invisible to per-edge/per-coedge validation (every
59/// individual coedge's own pcurve is perfectly valid), but fatal to
60/// `contains::face::face_contains`'s ray-casting, which relies on the
61/// coedges' pcurves alone tracing a fully closed 2-D polygon.
62///
63/// `at` is the coedge immediately *after* the gap in its current loop (the
64/// row's own pre-existing meridian coedge, reused directly in place of a
65/// real angular beam since a pole has no angular extent) — the bridge is
66/// inserted right before it, via [`Part::add_vertex_coedge`] onto
67/// `at.prev` (whatever's on the gap's other side), sitting at the same
68/// shared pole vertex the whole way rather than minting any new edge or
69/// vertex of its own.
70pub(crate) fn close_top_pole_gap<S: Scalar>(part: &mut Part<S>, at: CoedgeId) -> GeopResult<()> {
71    let before = part.topology().get_coedge(at)?.prev;
72    let pole = part.topology().coedge_end_vertex_id(before)?;
73    part.add_vertex_coedge(before, pole, beam_pcurve_top()?)
74        .with_context("revolve_at: closing top-pole pcurve gap failed")?;
75    Ok(())
76}
77/// Like [`close_top_pole_gap`], but for a pole row touched on a quadrant's
78/// `v = 1` (bottom) side: the gap sits right *after* `at` (the row's own
79/// meridian coedge) instead of right before it, so the bridge grows from
80/// `at` itself.
81pub(crate) fn close_bottom_pole_gap<S: Scalar>(part: &mut Part<S>, at: CoedgeId) -> GeopResult<()> {
82    let pole = part.topology().coedge_end_vertex_id(at)?;
83    part.add_vertex_coedge(at, pole, beam_pcurve_bottom()?)
84        .with_context("revolve_at: closing bottom-pole pcurve gap failed")?;
85    Ok(())
86}
87
88const N: usize = 4;
89
90/// The pcurve an "old" (already-built, at whichever angle is currently
91/// leading) meridian coedge carries, on *whichever* quadrant it ends up
92/// on: `u = 1` (the angularly-later side of that quadrant's own arc), `v: 0
93/// -> 1` (top row to bottom row). Every meridian coedge here plays this
94/// exact role, so one constant works for all of them.
95fn meridian_pcurve<S: Scalar>() -> GeopResult<geop_core_geometry::nurb_curve::NurbCurve2D<S>> {
96    line2(
97        Vector2::from_array([S::ONE, S::ZERO]),
98        Vector2::from_array([S::ONE, S::ONE]),
99    )
100}
101/// The pcurve a `mef`'s own new meridian edge carries, on the quadrant it
102/// closes off: `u = 0` (the angularly-earlier side), `v: 1 -> 0` (bottom
103/// row back to top row — the boundary loop runs the opposite way around
104/// this side, same reason a plain rectangle's own right edge runs
105/// top-to-bottom even though its top edge ran left-to-right).
106fn meridian_closing_pcurve<S: Scalar>() -> GeopResult<geop_core_geometry::nurb_curve::NurbCurve2D<S>>
107{
108    line2(
109        Vector2::from_array([S::ZERO, S::ONE]),
110        Vector2::from_array([S::ZERO, S::ZERO]),
111    )
112}
113/// The pcurve a row's angular beam carries as the *upper* quadrant's own
114/// `v = 1` (bottom) edge: `u: 1 -> 0` (old angle to new).
115fn beam_pcurve_bottom<S: Scalar>() -> GeopResult<geop_core_geometry::nurb_curve::NurbCurve2D<S>> {
116    line2(
117        Vector2::from_array([S::ONE, S::ONE]),
118        Vector2::from_array([S::ZERO, S::ONE]),
119    )
120}
121/// The pcurve the same beam carries (reversed) as the *lower* quadrant's
122/// own `v = 0` (top) edge: `u: 0 -> 1` (new angle back to old).
123fn beam_pcurve_top<S: Scalar>() -> GeopResult<geop_core_geometry::nurb_curve::NurbCurve2D<S>> {
124    line2(
125        Vector2::from_array([S::ZERO, S::ZERO]),
126        Vector2::from_array([S::ONE, S::ZERO]),
127    )
128}
129
130/// An exact 90-degree arc, around `center`, from `p0` to `p1` — one row's
131/// own angular "beam" edge between two adjacent quadrant columns.
132fn beam_curve<S: Scalar>(
133    p0: Vector3<S>,
134    p1: Vector3<S>,
135    center: Vector3<S>,
136    w: S,
137) -> GeopResult<NurbCurve3D<S>> {
138    let mid = p0.add(&p1).sub(&center);
139    arc3(p0, mid, p1, w)
140}
141
142/// The quadrant of the surface of revolution swept by the profile curve
143/// `curve` (in `(r, z)`) from angle `angle_new` back to `angle_old`, where
144/// `dirs[k]` is the in-plane direction of angle `k`.
145///
146/// The tensor product of `curve` with an exact 90-degree arc: `u` runs along
147/// the arc (degree 2, from `angle_new` to `angle_old`), `v` along `curve`
148/// (its own degree and knots). A profile control point `(w r, w z, w)` sweeps
149/// the arc `P(angle_new), P(angle_new) + P(angle_old) - center, P(angle_old)`
150/// with weights `w, w √2/2, w`; at `r = 0` all three collapse onto the axis,
151/// so poles need no special case.
152fn quadrant_patch<S: Scalar>(
153    curve: &NurbCurve2D<S>,
154    coordinate_system: &CoordinateSystem<S>,
155    dirs: &[Vector3<S>; N],
156    angle_new: usize,
157    angle_old: usize,
158) -> GeopResult<NurbSurface<S, 4>> {
159    let (o, axis) = (coordinate_system.origin(), coordinate_system.w());
160    let (d_new, d_old) = (dirs[angle_new], dirs[angle_old]);
161    let w = sqrt2_over_2::<S>();
162    let mid = d_new.add(&d_old);
163    let rows = [(d_new, S::ONE), (mid, w), (d_old, S::ONE)];
164    let control_points = rows
165        .iter()
166        .flat_map(|(d, weight)| {
167            curve.control_points.iter().map(move |cp| {
168                let p = embed_point(cp, o, d, axis);
169                Vector4::from_array([
170                    p[0].mul(*weight),
171                    p[1].mul(*weight),
172                    p[2].mul(*weight),
173                    p[3].mul(*weight),
174                ])
175            })
176        })
177        .collect();
178    NurbSurface::try_new(
179        2,
180        curve.degree,
181        control_points,
182        vec![S::ZERO, S::ZERO, S::ZERO, S::ONE, S::ONE, S::ONE],
183        curve.knot_vector.clone(),
184    )
185}
186
187/// Revolve `profile` — an open chain of curves in the `(r, z)` half-plane
188/// `r >= 0`, starting and ending on the axis (`r = 0`), each clamped and on
189/// the domain `[0, 1]` — 360 degrees around a vertical axis through `origin`
190/// (parallel to the z-axis), producing a closed, manifold solid of exactly
191/// `4 * profile.len()` faces (no leftover degenerate one) — `origin` is added
192/// to every generated point, so the profile's own `z` values are relative to
193/// `origin`'s `z`. See the module doc for the overall column-by-column
194/// strategy, and [`revolve_at_oriented`] for how the result is named.
195///
196/// The profile runs "top-down": walked from its first point to its last,
197/// the region it bounds together with the axis lies on its right (e.g.
198/// `(0, h) -> (r, h) -> (r, 0) -> (0, 0)` for a cylinder). Walked the other
199/// way, the solid comes out inside-out.
200///
201/// A thin wrapper around [`revolve_at_oriented`] with the identity
202/// (z-axis) coordinate system — see that function to revolve around an
203/// arbitrary axis (e.g. [`super::cylinder::revolved_cylinder_along_axis`]).
204pub fn revolve_at<S: Scalar>(
205    part: &mut Part<S>,
206    namer: &Namer,
207    profile: &Profile<S>,
208    origin: Vector3<S>,
209) -> GeopResult<SolidId> {
210    let identity = CoordinateSystem::try_new(
211        origin,
212        Vector3::from_array([S::ONE, S::ZERO, S::ZERO]),
213        Vector3::from_array([S::ZERO, S::ONE, S::ZERO]),
214        Vector3::from_array([S::ZERO, S::ZERO, S::ONE]),
215    )
216    .expect("axis-aligned basis is never degenerate");
217    revolve_at_oriented(part, namer, &namer.root(), profile, &identity)
218}
219
220/// Like [`revolve_at`], but revolves around `coordinate_system`'s own `w`
221/// axis instead of always the ambient z-axis: a profile point `(r, z)` maps
222/// to `coordinate_system.to_xyz([r cos, r sin, z])` — `u`/`v` span the
223/// equatorial plane (the angle-0 direction and its 90-degree-around-`w`
224/// follower, respectively) and `w` is the revolution axis, exactly the
225/// same role `extrude`'s own coordinate system's `w` plays as its sweep
226/// direction. Every position this builds still ultimately comes from this
227/// one `pos`/`centers` pair — the rest of the function (euler operations,
228/// pcurves) has no notion of x/y/z at all, so generalizing the axis needed
229/// no changes anywhere else.
230///
231/// Everything built is named after the profile's curves `X` and joints `P`
232/// (see [`Profile`]), the angles `a0..a3` at which the meridians lie
233/// (`a0` along `u`, `a1` along `v`, ...), and the quadrants `q0..q3` between
234/// them (`q0` from `a0` to `a1`, ...), following `geop_core_part`'s scheme:
235///
236/// | entity | name |
237/// |---|---|
238/// | the solid | `solid` (usually `N` itself) |
239/// | face swept by `X` through quadrant `q` | `N(X,q)` |
240/// | meridian edge: `X` at angle `a` | `N(X,a)` |
241/// | circular edge swept by `P` through `q` | `N(P,q)` |
242/// | vertex of `P` at angle `a` | `N(P,a)` |
243/// | vertex of `P` on the axis (a pole) | `N(P)` |
244pub fn revolve_at_oriented<S: Scalar>(
245    part: &mut Part<S>,
246    namer: &Namer,
247    solid: &str,
248    profile: &Profile<S>,
249    coordinate_system: &CoordinateSystem<S>,
250) -> GeopResult<SolidId> {
251    profile.check_names()?;
252    if profile.is_closed() {
253        return Err(GeopError::new(
254            "revolve: the profile must be an open chain with a name for its end joint",
255        ));
256    }
257    let curves = &profile.curves;
258    if curves.is_empty() {
259        return Err(GeopError::new(
260            "revolve: profile must have at least 1 curve",
261        ));
262    }
263    let p_segments = curves.len();
264    // Row `i`: the profile's `i`-th vertex, where curve `i` starts.
265    let mut rows = curves
266        .iter()
267        .map(start_point)
268        .collect::<GeopResult<Vec<_>>>()?;
269    rows.push(end_point(&curves[p_segments - 1])?);
270    for i in 0..p_segments - 1 {
271        if !end_point(&curves[i])?.could_be_equal(&rows[i + 1]) {
272            return Err(GeopError::new(format!(
273                "revolve: profile curve {i} does not end where curve {} starts",
274                i + 1
275            )));
276        }
277    }
278    let m = rows.len();
279
280    let zero = S::ZERO;
281    let one = S::ONE;
282    let (u, v) = (coordinate_system.u(), coordinate_system.v());
283    let dirs = [
284        *u,
285        *v,
286        u.prod_scalar(zero.sub(one)),
287        v.prod_scalar(zero.sub(one)),
288    ];
289
290    let degenerate: Vec<bool> = rows.iter().map(|p| p[0].could_be_equal(zero)).collect();
291    if !degenerate[0] {
292        return Err(GeopError::new(
293            "revolve: profile must start at r = 0 (a pole)",
294        ));
295    }
296    if !degenerate[m - 1] {
297        return Err(GeopError::new(
298            "revolve: profile must end at r = 0 (a pole)",
299        ));
300    }
301
302    // Names, see the table above.
303    let face_name = |i: usize, k: usize| namer.name(&[&profile.curve_names[i], &format!("q{k}")]);
304    let meridian_name =
305        |i: usize, k: usize| namer.name(&[&profile.curve_names[i], &format!("a{k}")]);
306    let beam_name =
307        |row: usize, k: usize| namer.name(&[&profile.joint_names[row], &format!("q{k}")]);
308    let vertex_name = |row: usize, k: usize| {
309        if degenerate[row] {
310            namer.name(&[&profile.joint_names[row]])
311        } else {
312            namer.name(&[&profile.joint_names[row], &format!("a{k}")])
313        }
314    };
315
316    let centers: Vec<Vector3<S>> = rows
317        .iter()
318        .map(|p| coordinate_system.to_xyz(&Vector3::from_array([zero, zero, p[1]])))
319        .collect();
320    let pos = |i: usize, k: usize| -> Vector3<S> {
321        if degenerate[i] {
322            centers[i]
323        } else {
324            centers[i].add(&dirs[k].prod_scalar(rows[i][0]))
325        }
326    };
327    // Curve `i` of the profile at angle `k`, from row `i` to row `i + 1`.
328    let meridian = |i: usize, k: usize| {
329        embed_curve(
330            &curves[i],
331            coordinate_system.origin(),
332            &dirs[k],
333            coordinate_system.w(),
334        )
335    };
336    let w = sqrt2_over_2::<S>();
337
338    // The placeholder face becomes the last segment's last quadrant, via
339    // `replace_face` at the very end.
340    let (v0, face_id, solid_id) = part.mvfs(
341        centers[0],
342        vertex_name(0, 0),
343        face_name(p_segments - 1, N - 1),
344        solid,
345    )?;
346
347    // Bootstrap meridian 0 (angle index 0): the `p_segments`-edge profile
348    // chain from `v0` through every other row, straight in 3-D (the user's
349    // own profile segments). `anchors[i]` (forward) gets rebuilt as
350    // segment `i`'s own "old" meridian every time a new angle is grown;
351    // `mirrors[i]` (reversed) stays untouched, needed only once more, to
352    // close the very last angle back onto this first one.
353    let mut anchors = Vec::with_capacity(p_segments);
354    let mut mirrors = Vec::with_capacity(p_segments);
355    let (_, a0, r0, _) = part
356        .mve_from_vertex(
357            face_id,
358            v0,
359            meridian(0, 0)?,
360            meridian_pcurve()?,
361            meridian_closing_pcurve()?,
362            pos(1, 0),
363            vertex_name(1, 0),
364            meridian_name(0, 0),
365        )
366        .with_context("revolve_at: bootstrap segment 0 failed")?;
367    anchors.push(a0);
368    mirrors.push(r0);
369    for i in 1..p_segments {
370        let (_, a, r, _) = part
371            .mve(
372                anchors[i - 1],
373                meridian(i, 0)?,
374                meridian_pcurve()?,
375                meridian_closing_pcurve()?,
376                pos(i + 1, 0),
377                vertex_name(i + 1, 0),
378                meridian_name(i, 0),
379            )
380            .with_context(with_context!("revolve_at: bootstrap segment {i} failed"))?;
381        anchors.push(a);
382        mirrors.push(r);
383    }
384
385    // Grow meridians 1..N-1 (angle indices 1, 2, 3), closing all
386    // `p_segments` quadrant faces of each new column as it's grown: for
387    // every non-pole interior row, one new "beam" (angular arc) edge grows
388    // that row across to the new angle (`mve`); each segment's own closing
389    // `mef` then mints its own new meridian edge (straight, at the new
390    // angle) using whichever of its two rows' beams exist, or the row's
391    // shared vertex directly (via the *old* meridian coedge) if a row is a
392    // pole.
393    for k in 0..N - 1 {
394        let k1 = k + 1;
395        let mut beam_fwd: Vec<Option<CoedgeId>> = vec![None; p_segments + 1];
396        let mut beam_rev: Vec<Option<CoedgeId>> = vec![None; p_segments + 1];
397        for row in 1..p_segments {
398            if !degenerate[row] {
399                let (_, bf, br, _) = part
400                    .mve(
401                        anchors[row - 1],
402                        beam_curve(pos(row, k), pos(row, k1), centers[row], w)?,
403                        beam_pcurve_bottom()?,
404                        beam_pcurve_top()?,
405                        pos(row, k1),
406                        vertex_name(row, k1),
407                        beam_name(row, k),
408                    )
409                    .with_context(with_context!(
410                        "revolve_at: beam at row {row}, angle {k} -> {k1} failed"
411                    ))?;
412                beam_fwd[row] = Some(bf);
413                beam_rev[row] = Some(br);
414            }
415        }
416
417        let mut new_anchors = Vec::with_capacity(p_segments);
418        for i in 0..p_segments {
419            let coedge1 = if degenerate[i + 1] {
420                anchors[i]
421            } else {
422                beam_fwd[i + 1].unwrap()
423            };
424            let coedge2 = if degenerate[i] {
425                anchors[i]
426            } else {
427                beam_rev[i].unwrap()
428            };
429            let curve = meridian(i, k1)?.reverse();
430            let surface = quadrant_patch(&curves[i], coordinate_system, &dirs, k1, k)?;
431            let (_, _, _, coedge_backward) = part
432                .mef(
433                    coedge1,
434                    coedge2,
435                    curve,
436                    meridian_closing_pcurve()?,
437                    meridian_pcurve()?,
438                    surface,
439                    meridian_name(i, k1),
440                    face_name(i, k),
441                )
442                .with_context(with_context!(
443                    "revolve_at: closing segment {i}, angle {k} -> {k1} failed"
444                ))?;
445            if degenerate[i] {
446                close_top_pole_gap(part, coedge2)?;
447            }
448            if degenerate[i + 1] {
449                close_bottom_pole_gap(part, coedge1)?;
450            }
451            new_anchors.push(coedge_backward);
452        }
453        anchors = new_anchors;
454    }
455
456    // Close the last column (angle 3 -> 0) back onto the very first
457    // meridian's own mirrors, reusing `anchors`/`mirrors` directly instead
458    // of growing anything new on the meridian side — only the interior
459    // beams (angle 3 -> 0) are actually new, one per non-last row's own
460    // closing `mef`. The very last segment needs no `mef` at all: after
461    // all the others close, its own boundary is already exactly what's
462    // left on the placeholder face, so it only needs `replace_face` to
463    // become real — the same trick `sphere_octants` uses, no separate
464    // degenerate cap required.
465    let (k, k1) = (N - 1, 0);
466    for i in 0..p_segments - 1 {
467        let row = i + 1;
468        let curve = beam_curve(pos(row, k), pos(row, k1), centers[row], w)?;
469        let surface = quadrant_patch(&curves[i], coordinate_system, &dirs, k1, k)?;
470        part.mef(
471            anchors[i],
472            mirrors[i],
473            curve,
474            beam_pcurve_bottom()?,
475            beam_pcurve_top()?,
476            surface,
477            beam_name(row, k),
478            face_name(i, k),
479        )
480        .with_context(with_context!(
481            "revolve_at: closing final beam at row {row} failed"
482        ))?;
483        if degenerate[i] {
484            close_top_pole_gap(part, anchors[i])?;
485        }
486    }
487    let last = p_segments - 1;
488    let surface = quadrant_patch(&curves[last], coordinate_system, &dirs, k1, k)?;
489    if degenerate[last] {
490        close_top_pole_gap(part, anchors[last])?;
491    }
492    if degenerate[last + 1] {
493        close_bottom_pole_gap(part, anchors[last])?;
494    }
495    part.replace_face(face_id, surface)
496        .with_context("revolve_at: final replace_face failed")?;
497
498    Ok(solid_id)
499}
500
501/// `revolve_at` around the z-axis itself (`origin = (0, 0, 0)`).
502pub fn revolve<S: Scalar>(
503    part: &mut Part<S>,
504    namer: &Namer,
505    profile: &Profile<S>,
506) -> GeopResult<SolidId> {
507    revolve_at(part, namer, profile, Vector3::from_array([S::ZERO; 3]))
508}
509
510#[cfg(test)]
511mod tests {
512    use super::*;
513    use crate::common::{arc2, polyline};
514    use geop_core_math::for_all_scalars;
515    use geop_core_topology::{
516        Model,
517        validation::{ValidationParameters, validate, validate_manifold},
518    };
519
520    /// Revolve `curves` around the z-axis into a fresh part, as operation `r`.
521    fn revolved<S: Scalar>(curves: Vec<NurbCurve2D<S>>) -> Part<S> {
522        let mut part = Part::<S>::new();
523        let namer = Namer::new("revolve", "r").unwrap();
524        revolve(&mut part, &namer, &Profile::open(curves)).unwrap();
525        part.check_names().unwrap();
526        part
527    }
528
529    fn v2<S: Scalar>(x: f64, y: f64) -> Vector2<S> {
530        Vector2::from_array([S::from_f64(x), S::from_f64(y)])
531    }
532
533    fn assert_valid<S: Scalar>(model: &Model<S>) {
534        let params = ValidationParameters::default();
535        if let Err(e) = validate(&params, model) {
536            panic!("{e:?}");
537        }
538        if let Err(e) = validate_manifold(&params, model) {
539            panic!("{e:?}");
540        }
541    }
542
543    /// An exact sphere from two quarter arcs: curved profile edges become
544    /// doubly curved quadrant patches.
545    fn check_sphere_from_arcs_is_valid<S: Scalar>() {
546        let w = sqrt2_over_2::<S>();
547        let profile = vec![
548            arc2(v2(0.0, 1.0), v2(1.0, 1.0), v2(1.0, 0.0), w).unwrap(),
549            arc2(v2(1.0, 0.0), v2(1.0, -1.0), v2(0.0, -1.0), w).unwrap(),
550        ];
551        let part = revolved(profile);
552        let model = part.topology();
553        assert_valid(model);
554        assert_eq!(model.faces.len(), 8);
555        // Two poles, and the equator's ring of four vertices named after the
556        // joint between the arcs.
557        for name in [
558            "revolve(r,p0)",
559            "revolve(r,p2)",
560            "revolve(r,p1,a0)",
561            "revolve(r,p1,a3)",
562        ] {
563            part.vertex_id(name).unwrap();
564        }
565        for name in ["revolve(r,c0,a0)", "revolve(r,c1,a2)", "revolve(r,p1,q3)"] {
566            part.edge_id(name).unwrap();
567        }
568        part.face_id("revolve(r,c1,q3)").unwrap();
569    }
570    #[test]
571    fn sphere_from_arcs_is_valid() {
572        for_all_scalars!(check_sphere_from_arcs_is_valid);
573    }
574
575    /// A vase: a line up the side and a cubic spline bulging out, capped by
576    /// lines back to the axis.
577    fn check_vase_with_spline_is_valid<S: Scalar>() {
578        let hom = |x: f64, y: f64| {
579            geop_core_math::vector::Vector3::from_array([S::from_f64(x), S::from_f64(y), S::ONE])
580        };
581        let spline = geop_core_geometry::nurb_curve::NurbCurve::try_new(
582            3,
583            vec![hom(0.5, 2.0), hom(1.5, 1.5), hom(0.2, 0.7), hom(1.0, 0.0)],
584            vec![
585                S::ZERO,
586                S::ZERO,
587                S::ZERO,
588                S::ZERO,
589                S::ONE,
590                S::ONE,
591                S::ONE,
592                S::ONE,
593            ],
594        )
595        .unwrap();
596        let mut profile = polyline(&[v2(0.0, 2.0), v2(0.5, 2.0)]).unwrap();
597        profile.push(spline);
598        profile.extend(polyline(&[v2(1.0, 0.0), v2(0.0, 0.0)]).unwrap());
599        let part = revolved(profile);
600        let model = part.topology();
601        assert_valid(model);
602        assert_eq!(model.faces.len(), 12);
603    }
604    #[test]
605    fn vase_with_spline_is_valid() {
606        for_all_scalars!(check_vase_with_spline_is_valid);
607    }
608
609    fn check_cone_is_valid<S: Scalar>() {
610        let profile = polyline(&[v2::<S>(0.0, 1.0), v2(1.0, 0.0), v2(0.0, 0.0)]).unwrap();
611        assert_valid(revolved(profile).topology());
612    }
613    #[test]
614    fn cone_is_valid() {
615        for_all_scalars!(check_cone_is_valid);
616    }
617
618    fn check_sphere_is_valid<S: Scalar>() {
619        let n = 6;
620        let points: Vec<Vector2<S>> = (0..=n)
621            .map(|k| {
622                if k == 0 || k == n {
623                    return v2(0.0, if k == 0 { 1.0 } else { -1.0 });
624                }
625                let t = std::f64::consts::PI * (k as f64) / (n as f64);
626                v2(t.sin(), t.cos())
627            })
628            .collect();
629        assert_valid(revolved(polyline(&points).unwrap()).topology());
630    }
631    #[test]
632    fn sphere_is_valid() {
633        for_all_scalars!(check_sphere_is_valid);
634    }
635}