Skip to main content

geop_ops_extrude_revolve/
sphere.rs

1//! A sphere as exactly 8 octant faces: 2 rows (north pole to equator, and
2//! equator to south pole) x 4 quadrants, each an exact rational quarter of a
3//! hemisphere. No approximation — the surfaces *are* spheres, so a point
4//! sampled anywhere on one is exactly `radius` from the centre.
5//!
6//! Built quadrant-column by quadrant-column rather than row by row, which is
7//! what makes the count come out at exactly 8. `mef` always leaves something
8//! behind on the face it split from, so a row-by-row construction ends with
9//! the starting placeholder still holding an extra, invisible zero-area cap
10//! at one pole. Going column-wise makes the *last* quadrant's own closing
11//! land back on the placeholder itself, consuming it.
12//!
13//! That matters beyond tidiness. A leftover placeholder carries
14//! [`NurbSurface::everything`], whose every coordinate is `ENTIRE`, so it
15//! `could_be_equal`s any point and silently swallows every containment and
16//! intersection query aimed at it; and a zero-area face has no interior
17//! point to classify, which is precisely what a boolean needs from every
18//! face. Both are asserted against in this module's own tests.
19
20use crate::{
21    common::{arc3, line2, pt3, sqrt2_over_2},
22    revolve::{close_bottom_pole_gap, close_top_pole_gap},
23};
24use geop_core_geometry::nurb_surface::NurbSurface;
25use geop_core_math::{
26    geop_error::GeopResult,
27    scalars::Scalar,
28    vector::{Vector2, Vector3, Vector4},
29};
30use geop_core_part::{Namer, Part};
31use geop_core_topology::SolidId;
32
33// `sphere_quadrant_surface`'s own `u` (equator direction) runs `eq1 -> eq0`
34// (`u = 0` at `eq1`, `u = 1` at `eq0`) — backwards from the naive "eq0 is
35// u = 0" reading — so that its normal comes out pointing outward, not
36// inward; `v` (meridian direction) is unaffected. Every pcurve below whose
37// `u` isn't fixed at a single value (or whose fixed value depends on which
38// meridian it's on) has that `u` component flipped (`u -> 1 - u`) from
39// what the naive reading would suggest, to match.
40
41/// The pcurve a meridian (pole `->` equator) coedge carries, on *whichever*
42/// quadrant it ends up on: `u = 1` (its own quadrant's "eq0 side" edge —
43/// swapped from the naive "u = 0"), `v: 0 -> 1` (pole to equator). Every
44/// meridian coedge here plays this exact role, so one constant works for
45/// all of them — including a fresh one's `pcurve_reversed`, since it stays
46/// behind on the placeholder face as *next* quadrant's own anchor, needing
47/// this same role there too.
48fn meridian_pcurve<S: Scalar>() -> GeopResult<geop_core_geometry::nurb_curve::NurbCurve2D<S>> {
49    line2(
50        Vector2::from_array([S::ONE, S::ZERO]),
51        Vector2::from_array([S::ONE, S::ONE]),
52    )
53}
54/// The pcurve an equator beam carries as a north quadrant's own `v = 1`
55/// edge (`u: 1 -> 0`, quadrant `k` to quadrant `k + 1`, swapped from the
56/// naive `u: 0 -> 1`).
57fn beam_pcurve_north<S: Scalar>() -> GeopResult<geop_core_geometry::nurb_curve::NurbCurve2D<S>> {
58    line2(
59        Vector2::from_array([S::ONE, S::ONE]),
60        Vector2::from_array([S::ZERO, S::ONE]),
61    )
62}
63/// The pcurve the same beam carries (reversed) as a south quadrant's own
64/// `v = 0` edge (`u: 0 -> 1`, quadrant `k + 1` back to quadrant `k`,
65/// swapped from the naive `u: 1 -> 0`).
66fn beam_pcurve_south<S: Scalar>() -> GeopResult<geop_core_geometry::nurb_curve::NurbCurve2D<S>> {
67    line2(
68        Vector2::from_array([S::ZERO, S::ZERO]),
69        Vector2::from_array([S::ONE, S::ZERO]),
70    )
71}
72/// The pcurve the meridian `mef` itself closes off with carries, on the new
73/// quadrant it belongs to: `u = 0` (its own quadrant's "eq1 side" edge —
74/// swapped from the naive `u = 1`), `v: 1 -> 0` (equator back to
75/// pole — the boundary loop runs the opposite way around this side, same
76/// reason a plain rectangle's own right edge runs top-to-bottom
77/// even though its top edge ran left-to-right.
78fn meridian_closing_pcurve<S: Scalar>() -> GeopResult<geop_core_geometry::nurb_curve::NurbCurve2D<S>>
79{
80    line2(
81        Vector2::from_array([S::ZERO, S::ONE]),
82        Vector2::from_array([S::ZERO, S::ZERO]),
83    )
84}
85
86/// A degree-(2, 2) patch spanning from `pole` (a degenerate row, `v = 0` if
87/// `pole_at_v0` else `v = 1`) to the exact 90-degree equator arc `eq0 ->
88/// eq1` (the other row) — doubly-curved (both `u`, the equator direction,
89/// *and* `v`, the meridian direction, are exact arcs, unlike a plain
90/// pole-to-arc ruled patch, whose straight meridian direction would cut
91/// inside the sphere everywhere except right at the pole and the equator):
92/// every `(u, v)` sample, not just the boundary, lands exactly `radius`
93/// from `center`. Same tensor-product-of-two-arcs construction as
94/// `torus`'s own grid patches, specialized for one degenerate (pole) row —
95/// its own "row" arc has 3 coincident control points, same as
96/// `revolve`'s/`row_cps`'s degenerate case.
97fn sphere_quadrant_surface<S: Scalar>(
98    pole: Vector3<S>,
99    eq0: Vector3<S>,
100    eq1: Vector3<S>,
101    center: Vector3<S>,
102    w: S,
103    pole_at_v0: bool,
104) -> GeopResult<NurbSurface<S, 4>> {
105    let eq_mid = eq0.add(&eq1).sub(&center);
106    let mer0_mid = pole.add(&eq0).sub(&center);
107    let mer1_mid = pole.add(&eq1).sub(&center);
108    // NOT `pole + eq_mid - 2*center` (that double-subtracts `center`,
109    // invisible only when `center` happens to be the origin): relative to
110    // `center`, the correct interior point is `(pole - center) + (eq0 -
111    // center) + (eq1 - center)`, translated back by `+ center`.
112    let interior = pole.add(&eq0).add(&eq1).sub(&center).sub(&center);
113    let m = w.mul(w);
114
115    let wpt =
116        |p: Vector3<S>, wt: S| Vector4::from_array([p[0].mul(wt), p[1].mul(wt), p[2].mul(wt), wt]);
117
118    // Each triple is one `u`-slice, `[v = 0, v = 0.5, v = 1]`, assuming the
119    // pole sits at `v = 0` — reversed below if it's actually at `v = 1`.
120    // `u0`/`u2` (the true `u = 0`/`u = 1` boundary slices) hit the pole as
121    // an genuine unweighted corner; `u1` (the interior slice) instead hits
122    // it as the *middle* control point of its own (degenerate) `v`-arc,
123    // which — like every other arc's own middle control point here — needs
124    // weight `w`, not `1`, even though its position also happens to be the
125    // pole.
126    let mut u0 = [pt3(pole), wpt(mer0_mid, w), pt3(eq0)];
127    let mut u1 = [wpt(pole, w), wpt(interior, m), wpt(eq_mid, w)];
128    let mut u2 = [pt3(pole), wpt(mer1_mid, w), pt3(eq1)];
129    if !pole_at_v0 {
130        u0.reverse();
131        u1.reverse();
132        u2.reverse();
133    }
134
135    // `u = 0`/`u = 1` (equator direction) swapped on purpose — `∂S/∂u x
136    // ∂S/∂v` with `u`/`v` as built above (equator direction / meridian
137    // direction, in that natural order) pointed inward, not outward, and
138    // reversing which end of the equator arc is "u = 0" negates the cross
139    // product without changing the surface's own shape or touching `v`
140    // (the already-verified pole placement above). Every pcurve that
141    // depends on `u` is written in this same reversed convention (`u: 0 ->
142    // 1` swapped for `u: 1 -> 0` from what "eq0 is u = 0" would naively
143    // suggest) to match.
144    let knots = vec![S::ZERO, S::ZERO, S::ZERO, S::ONE, S::ONE, S::ONE];
145    let cps = vec![
146        u2[0], u2[1], u2[2], u1[0], u1[1], u1[2], u0[0], u0[1], u0[2],
147    ];
148    NurbSurface::try_new(2, 2, cps, knots.clone(), knots)
149}
150
151/// A sphere of `radius` centered at `center`, built as exactly 8 real
152/// degree-(2, 2) quadrant faces (no leftover degenerate one) — one column
153/// (a north + a south quadrant) at a time, around 4 meridians: `mvfs` plus
154/// two `mve`s bootstrap the very first meridian (pole `->` equator `->`
155/// pole, i.e. the profile, each half an exact 90-degree arc), then per
156/// remaining meridian, one `mve` grows the equator "beam" (also an exact
157/// arc) to it and two `mef`s close off that column's north and south
158/// quadrant. The very last meridian is the first one again (its own
159/// closing `mef`'s new edge *is* the last beam) closing the loop, so its
160/// own closing needs only a single `mef` (the north quadrant) — the south
161/// one is already exactly the boundary still left on the placeholder face,
162/// which just needs `replace_face` to become real.
163///
164/// Named as the operation `sphere(name)`: the poles `N(north)`/`N(south)`,
165/// the equator's vertices `N(equator,a0..a3)` at angles `a0` (`+x`), `a1`
166/// (`+y`), ..., its quarter arcs `N(equator,q0..q3)` (`q0` from `a0` to
167/// `a1`, ...), the meridian arcs `N(north,a0..)`/`N(south,a0..)` from the
168/// equator to each pole, and the eight quadrant faces
169/// `N(north,q0..)`/`N(south,q0..)`.
170pub fn sphere_solid<S: Scalar>(
171    part: &mut Part<S>,
172    name: &str,
173    center: Vector3<S>,
174    radius: S,
175) -> GeopResult<SolidId> {
176    let namer = Namer::new("sphere", name)?;
177    let n = |args: &[&str]| namer.name(args);
178    let w = sqrt2_over_2::<S>();
179    let north = center.add(&Vector3::from_array([S::ZERO, S::ZERO, radius]));
180    let south = center.add(&Vector3::from_array([
181        S::ZERO,
182        S::ZERO,
183        S::ZERO.sub(radius),
184    ]));
185    let one = S::ONE;
186    let zero = S::ZERO;
187    let cos_t = [one, zero, zero.sub(one), zero];
188    let sin_t = [zero, one, zero, zero.sub(one)];
189    let equator = |k: usize| {
190        center.add(&Vector3::from_array([
191            radius.mul(cos_t[k]),
192            radius.mul(sin_t[k]),
193            zero,
194        ]))
195    };
196    // An exact 90-degree arc between 2 points on the sphere, around `center`.
197    let arc = |p0: Vector3<S>, p1: Vector3<S>| {
198        let mid = p0.add(&p1).sub(&center);
199        arc3(p0, mid, p1, w)
200    };
201    let arc_beam = |k: usize, k1: usize| arc(equator(k), equator(k1));
202    let north_quadrant = |k: usize, k1: usize| {
203        sphere_quadrant_surface(north, equator(k), equator(k1), center, w, true)
204    };
205    let south_quadrant = |k: usize, k1: usize| {
206        sphere_quadrant_surface(south, equator(k), equator(k1), center, w, false)
207    };
208
209    // The placeholder face ends up as the last south quadrant.
210    let (v_north, face0, solid_id) =
211        part.mvfs(north, n(&["north"]), n(&["south", "q3"]), namer.root())?;
212
213    // The profile: north -> equator(0) -> south, meridian 0's own two
214    // halves (each an exact 90-degree arc) — `north_anchor`/`south_anchor`
215    // always end at the equator point the *next* beam should grow from.
216    // `mirror_north0`/`mirror_south0` (meridian 0's *other* two coedges)
217    // stay unused until the very last quadrant, which closes back onto
218    // them instead of minting a new meridian 4 (= meridian 0).
219    let (_, north_anchor0, mirror_north0, _) = part.mve_from_vertex(
220        face0,
221        v_north,
222        arc(north, equator(0))?,
223        meridian_pcurve()?,
224        meridian_closing_pcurve()?,
225        equator(0),
226        n(&["equator", "a0"]),
227        n(&["north", "a0"]),
228    )?;
229    let (_, south_anchor0, mirror_south0, _) = part.mve(
230        north_anchor0,
231        arc(equator(0), south)?,
232        meridian_pcurve()?,
233        meridian_closing_pcurve()?,
234        south,
235        n(&["south"]),
236        n(&["south", "a0"]),
237    )?;
238
239    let mut north_anchor = north_anchor0;
240    let mut south_anchor = south_anchor0;
241    for k in 0..3 {
242        let k1 = k + 1;
243        let (q, a1) = (format!("q{k}"), format!("a{k1}"));
244        let (_, beam_fwd, beam_rev, _) = part.mve(
245            north_anchor,
246            arc_beam(k, k1)?,
247            beam_pcurve_north()?,
248            beam_pcurve_south()?,
249            equator(k1),
250            n(&["equator", &a1]),
251            n(&["equator", &q]),
252        )?;
253
254        let (_, _, _, next_north_anchor) = part.mef(
255            beam_fwd,
256            north_anchor,
257            arc(equator(k1), north)?,
258            meridian_closing_pcurve()?,
259            meridian_pcurve()?,
260            north_quadrant(k, k1)?,
261            n(&["north", &a1]),
262            n(&["north", &q]),
263        )?;
264        close_top_pole_gap(part, north_anchor)?;
265        let (_, _, _, next_south_anchor) = part.mef(
266            south_anchor,
267            beam_rev,
268            arc(south, equator(k1))?,
269            meridian_closing_pcurve()?,
270            meridian_pcurve()?,
271            south_quadrant(k, k1)?,
272            n(&["south", &a1]),
273            n(&["south", &q]),
274        )?;
275        close_bottom_pole_gap(part, south_anchor)?;
276
277        north_anchor = next_north_anchor;
278        south_anchor = next_south_anchor;
279    }
280
281    // The last quadrant's own closing edge *is* the last beam (equator(3)
282    // -> equator(0)) — no separate `mve` needed, since `mirror_north0`
283    // (meridian 0's own reversed coedge) already reaches back to
284    // `equator(0)` on its own. Only the north quadrant needs this `mef` —
285    // the south one (`south_anchor`, `mirror_south0`, and this same beam,
286    // reversed) is already exactly what's left on the placeholder face.
287    part.mef(
288        north_anchor,
289        mirror_north0,
290        arc_beam(3, 0)?,
291        beam_pcurve_north()?,
292        beam_pcurve_south()?,
293        north_quadrant(3, 0)?,
294        n(&["equator", "q3"]),
295        n(&["north", "q3"]),
296    )?;
297    close_top_pole_gap(part, north_anchor)?;
298    let _ = mirror_south0;
299
300    // The placeholder's own remaining ring is now exactly the south
301    // quadrant's boundary — give it the real surface to match.
302    close_bottom_pole_gap(part, south_anchor)?;
303    part.replace_face(face0, south_quadrant(3, 0)?)?;
304
305    Ok(solid_id)
306}
307
308#[cfg(test)]
309mod tests {
310    use super::*;
311    use geop_core_math::for_all_scalars;
312    use geop_core_topology::validation::{ValidationParameters, validate, validate_manifold};
313
314    fn check_offset_sphere_is_valid<S: Scalar>() {
315        let mut part = Part::<S>::new();
316        let center = Vector3::from_array([S::from_f64(1.0), S::from_f64(-0.5), S::from_f64(0.5)]);
317        sphere_solid(&mut part, "t1", center, S::from_f64(2.0)).unwrap();
318        let model = part.topology();
319
320        let params = ValidationParameters::default();
321        if let Err(e) = validate(&params, &model) {
322            panic!("{e:?}");
323        }
324        if let Err(e) = validate_manifold(&params, &model) {
325            panic!("{e:?}");
326        }
327    }
328    #[test]
329    fn offset_sphere_is_valid() {
330        for_all_scalars!(check_offset_sphere_is_valid);
331    }
332
333    fn check_sphere_solid_normals_point_outward<S: Scalar>() {
334        let mut part = Part::<S>::new();
335        let center = Vector3::from_array([S::ZERO; 3]);
336        sphere_solid(&mut part, "t2", center, S::ONE).unwrap();
337        let model = part.topology();
338
339        for face in model.faces.values() {
340            let (u0, u1) = face.surface.domain_u();
341            let (v0, v1) = face.surface.domain_v();
342            let mid_u = u0.add(u1.sub(u0).mul(S::from_f64(0.5)));
343            let mid_v = v0.add(v1.sub(v0).mul(S::from_f64(0.5)));
344            let p = face.surface.evaluate(mid_u, mid_v).unwrap();
345            let n = face.surface.normal(mid_u, mid_v).unwrap();
346            let outward = p.sub(&center);
347            let dot = n.prod_dot(&outward).to_f64();
348            assert!(dot > 0.0, "p={p:?}, n={n:?}, dot={dot}");
349        }
350    }
351    #[test]
352    fn sphere_solid_normals_point_outward() {
353        for_all_scalars!(check_sphere_solid_normals_point_outward);
354    }
355
356    fn check_sphere_solid_is_valid<S: Scalar>() {
357        let mut part = Part::<S>::new();
358        sphere_solid(&mut part, "t3", Vector3::from_array([S::ZERO; 3]), S::ONE).unwrap();
359        let model = part.topology();
360        assert_eq!(model.faces.len(), 8);
361
362        let params = ValidationParameters::default();
363        if let Err(e) = validate(&params, &model) {
364            panic!("{e:?}");
365        }
366        if let Err(e) = validate_manifold(&params, &model) {
367            panic!("{e:?}");
368        }
369    }
370    #[test]
371    fn sphere_solid_is_valid() {
372        for_all_scalars!(check_sphere_solid_is_valid);
373    }
374
375    /// Every face must have a genuine interior — a point strictly inside its
376    /// trim — and that point must lie exactly `radius` from the centre.
377    ///
378    /// This is the assertion that a zero-area face cannot pass. A degenerate
379    /// cap left behind at a pole is structurally valid (its loop closes, its
380    /// pcurves are continuous) and every other check accepts it; but it has
381    /// no interior, so `face_interior_point` cannot find one, and a boolean
382    /// classifying faces by an interior sample would fail on it later and far
383    /// from the cause. Sampling the interior rather than the domain corners
384    /// is what makes it bite: the corners of a degenerate patch still sit on
385    /// the sphere.
386    fn check_sphere_solid_face_midpoints_are_on_the_sphere<S: Scalar>() {
387        let mut part = Part::<S>::new();
388        let center = Vector3::from_array([S::from_f64(1.0), S::from_f64(-0.5), S::from_f64(0.5)]);
389        let radius = S::from_f64(2.0);
390        let solid = sphere_solid(&mut part, "t4", center, radius).unwrap();
391        let model = part.topology();
392
393        let faces = model.solid_faces(solid).unwrap();
394        assert_eq!(faces.len(), 8, "a sphere is exactly 8 octants");
395
396        let radius_sq = radius.mul(radius);
397        for face_id in faces {
398            let face = model.get_face(face_id).unwrap();
399            assert!(
400                !face.surface.is_everything(),
401                "face {face_id} still carries the `everything` placeholder surface"
402            );
403
404            let (u, v) = geop_core_topology::contains::face::face_interior_point(
405                &model,
406                face_id,
407                20000,
408                S::from_f64(1e-4),
409                0x5EED,
410            )
411            .unwrap_or_else(|e| panic!("face {face_id} has no interior point: {e}"));
412
413            let p = face.surface.evaluate(u, v).unwrap();
414            let d_sq = p.sub(&center).norm_sq();
415            assert!(
416                d_sq.could_be_equal(radius_sq),
417                "face {face_id}'s interior point {p:?} is |p - center|^2={d_sq:?} from the centre, expected {radius_sq:?}"
418            );
419        }
420    }
421    #[test]
422    fn sphere_solid_face_midpoints_are_on_the_sphere() {
423        for_all_scalars!(check_sphere_solid_face_midpoints_are_on_the_sphere);
424    }
425
426    fn check_sphere_quadrant_surface_is_exact<S: Scalar>() {
427        let center = Vector3::from_array([S::from_f64(1.0), S::from_f64(-0.5), S::from_f64(0.5)]);
428        let radius = S::from_f64(2.0);
429        let north = center.add(&Vector3::from_array([S::ZERO, S::ZERO, radius]));
430        let south = center.add(&Vector3::from_array([
431            S::ZERO,
432            S::ZERO,
433            S::ZERO.sub(radius),
434        ]));
435        let one = S::ONE;
436        let zero = S::ZERO;
437        let cos_t = [one, zero, zero.sub(one), zero];
438        let sin_t = [zero, one, zero, zero.sub(one)];
439        let equator = |k: usize| {
440            center.add(&Vector3::from_array([
441                radius.mul(cos_t[k]),
442                radius.mul(sin_t[k]),
443                zero,
444            ]))
445        };
446        let w = crate::common::sqrt2_over_2::<S>();
447        let radius_sq = radius.mul(radius);
448
449        for k in 0..4 {
450            let k1 = (k + 1) % 4;
451            for (pole, pole_at_v0) in [(north, true), (south, false)] {
452                let surface =
453                    sphere_quadrant_surface(pole, equator(k), equator(k1), center, w, pole_at_v0)
454                        .unwrap();
455                for i in 0..=4 {
456                    for j in 0..=4 {
457                        let u = S::from_ratio(i as i64, 4).unwrap();
458                        let v = S::from_ratio(j as i64, 4).unwrap();
459                        let p = surface.evaluate(u, v).unwrap();
460                        let d_sq = p.sub(&center).norm_sq();
461                        let err = d_sq.sub(radius_sq).abs();
462                        assert!(
463                            !err.definitely_greater(S::from_f64(1e-9)),
464                            "k={k}, pole_at_v0={pole_at_v0}, u={u}, v={v}, p={p:?}, |p-center|^2={d_sq}, expected={radius_sq}"
465                        );
466                    }
467                }
468            }
469        }
470    }
471    #[test]
472    fn sphere_quadrant_surface_is_exact() {
473        for_all_scalars!(check_sphere_quadrant_surface_is_exact);
474    }
475
476    /// Every edge is an exact meridian (straight line through `center`'s
477    /// axis) or an exact 90-degree equator arc, and every quadrant face is
478    /// an exact degree-(2, 1) patch built from the same two kinds of curve
479    /// — so, sampled anywhere (not just at control points), both must land
480    /// exactly `radius` away from `center`.
481    fn check_sphere_solid_points_on_sphere<S: Scalar>() {
482        let mut part = Part::<S>::new();
483        let center = Vector3::from_array([S::from_f64(1.0), S::from_f64(-0.5), S::from_f64(0.5)]);
484        let radius = S::from_f64(2.0);
485        sphere_solid(&mut part, "t5", center, radius).unwrap();
486        let model = part.topology();
487
488        let radius_sq = radius.mul(radius);
489        let assert_on_sphere = |p: Vector3<S>, ctx: &str| {
490            let d_sq = p.sub(&center).norm_sq();
491            let err = d_sq.sub(radius_sq).abs();
492            assert!(
493                !err.definitely_greater(S::from_f64(1e-9)),
494                "{ctx}: p={p:?}, |p - center|^2={d_sq}, expected={radius_sq}"
495            );
496        };
497
498        for edge in model.edges.values() {
499            let (t0, t1) = edge.curve.domain();
500            for i in 0..=8 {
501                let t = t0.add(t1.sub(t0).mul(S::from_ratio(i as i64, 8).unwrap()));
502                let p = edge.curve.evaluate(t).unwrap();
503                assert_on_sphere(p, "edge sample");
504            }
505        }
506
507        for face in model.faces.values() {
508            let (u0, u1) = face.surface.domain_u();
509            let (v0, v1) = face.surface.domain_v();
510            for i in 0..=8 {
511                for j in 0..=8 {
512                    let u = u0.add(u1.sub(u0).mul(S::from_ratio(i as i64, 8).unwrap()));
513                    let v = v0.add(v1.sub(v0).mul(S::from_ratio(j as i64, 8).unwrap()));
514                    let p = face.surface.evaluate(u, v).unwrap();
515                    assert_on_sphere(p, "surface sample");
516                }
517            }
518        }
519    }
520    #[test]
521    fn sphere_solid_points_on_sphere() {
522        for_all_scalars!(check_sphere_solid_points_on_sphere);
523    }
524
525    fn check_rasterize_topology_sphere<S: Scalar>() {
526        let mut part = Part::<S>::new();
527        sphere_solid(&mut part, "t6", Vector3::from_array([S::ZERO; 3]), S::ONE).unwrap();
528        let model = part.topology();
529
530        let scene = geop_ops_rasterize::rasterize_topology(&model, 32).unwrap();
531        assert!(!scene.points.is_empty());
532        assert!(!scene.lines.is_empty());
533        assert!(!scene.triangles_transparent.is_empty());
534        assert!(!scene.labels.is_empty());
535
536        std::fs::create_dir_all("outputs").unwrap();
537        scene.save_to_file("outputs/sphere_topology.html").unwrap();
538    }
539    #[test]
540    fn rasterize_topology_sphere() {
541        for_all_scalars!(check_rasterize_topology_sphere);
542    }
543
544    fn check_rasterize_topology_offset_sphere<S: Scalar>() {
545        let mut part = Part::<S>::new();
546        let center = Vector3::from_array([S::from_f64(1.0), S::from_f64(-0.5), S::from_f64(0.5)]);
547        sphere_solid(&mut part, "t7", center, S::from_f64(2.0)).unwrap();
548        let model = part.topology();
549
550        let scene = geop_ops_rasterize::rasterize_topology(&model, 32).unwrap();
551        assert!(!scene.points.is_empty());
552        assert!(!scene.lines.is_empty());
553        assert!(!scene.triangles_transparent.is_empty());
554        assert!(!scene.labels.is_empty());
555
556        std::fs::create_dir_all("outputs").unwrap();
557        scene
558            .save_to_file("outputs/offset_sphere_topology.html")
559            .unwrap();
560    }
561    #[test]
562    fn rasterize_topology_offset_sphere() {
563        for_all_scalars!(check_rasterize_topology_offset_sphere);
564    }
565}