Skip to main content

geop_ops_extrude_revolve/
figure8_profile.rs

1//! A "figure-8" / dumbbell solid with two rectangular holes running all the
2//! way through it.
3//!
4//! This is just an outer footprint and two hole footprints (a dumbbell
5//! outline — two square "lobes" joined by a narrow neck — with a small
6//! square hole in each lobe) handed to [`extrude`], which does all the
7//! actual euler-operator work (including the holes, all the way through
8//! both caps and their own side walls).
9
10use crate::{
11    common::{Profile, polygon},
12    extrude::{ExtrudeNames, extrude},
13};
14use geop_core_math::{
15    geop_error::GeopResult,
16    primitives::CoordinateSystem,
17    scalars::Scalar,
18    vector::{Vector2, Vector3},
19};
20use geop_core_part::{Namer, Part};
21use geop_core_topology::SolidId;
22
23/// The outer boundary of the figure-8 profile, as a CCW polygon in `(u, v) ∈
24/// [0, 1]^2` parameter space.
25pub fn outer_polygon<S: Scalar>() -> Vec<Vector2<S>> {
26    let c = |x: f64, y: f64| Vector2::from_array([S::from_f64(x), S::from_f64(y)]);
27    vec![
28        c(0.0, 0.0),
29        c(3.0 / 8.0, 0.0),
30        c(3.0 / 8.0, 1.0 / 3.0),
31        c(5.0 / 8.0, 1.0 / 3.0),
32        c(5.0 / 8.0, 0.0),
33        c(1.0, 0.0),
34        c(1.0, 1.0),
35        c(5.0 / 8.0, 1.0),
36        c(5.0 / 8.0, 2.0 / 3.0),
37        c(3.0 / 8.0, 2.0 / 3.0),
38        c(3.0 / 8.0, 1.0),
39        c(0.0, 1.0),
40    ]
41}
42
43/// The two square holes (one per lobe), each as a CW polygon in `(u, v)`
44/// parameter space.
45pub fn hole_polygons<S: Scalar>() -> [Vec<Vector2<S>>; 2] {
46    let c = |x: f64, y: f64| Vector2::from_array([S::from_f64(x), S::from_f64(y)]);
47    [
48        vec![
49            c(0.125, 1.0 / 3.0),
50            c(0.125, 2.0 / 3.0),
51            c(0.25, 2.0 / 3.0),
52            c(0.25, 1.0 / 3.0),
53        ],
54        vec![
55            c(0.75, 1.0 / 3.0),
56            c(0.75, 2.0 / 3.0),
57            c(0.875, 2.0 / 3.0),
58            c(0.875, 1.0 / 3.0),
59        ],
60    ]
61}
62
63/// Build the figure-8-with-2-holes solid: `outer_polygon` extruded by one
64/// unit along `+z`, with `hole_polygons` cut all the way through. Returns
65/// the new solid.
66///
67/// Named as the operation `figure8(name)`, after the outline's corners
68/// `p0..` and sides `c0..` and the holes' `h0p0..`, `h1c0..` (see
69/// [`ExtrudeNames`]).
70pub fn figure8_profile<S: Scalar>(part: &mut Part<S>, name: &str) -> GeopResult<SolidId> {
71    // Left-handed (`u x v = -w`): `extrude` extrudes a CCW `outer` polygon
72    // backwards along `w`, so a CCW boundary with outward-facing normals
73    // needs `w` pointing the opposite way `u x v` (i.e. a right-handed
74    // basis's own `+z`) would.
75    let origin = Vector3::from_array([S::ZERO, S::ZERO, S::ZERO]);
76    let u = Vector3::from_array([S::ONE, S::ZERO, S::ZERO]);
77    let v = Vector3::from_array([S::ZERO, S::ONE, S::ZERO]);
78    let w = Vector3::from_array([S::ZERO, S::ZERO, S::from_f64(-1.0)]);
79    let coordinate_system = CoordinateSystem::try_new(origin, u, v, w)?;
80
81    let outer = Profile::closed(polygon(&outer_polygon::<S>())?);
82    let holes = hole_polygons::<S>()
83        .iter()
84        .enumerate()
85        .map(|(k, h)| Ok(Profile::closed(polygon(h)?).with_prefix(&format!("h{k}"))))
86        .collect::<GeopResult<Vec<_>>>()?;
87    let namer = Namer::new("figure8", name)?;
88    extrude(
89        part,
90        &ExtrudeNames::single(&namer),
91        &coordinate_system,
92        &outer,
93        &holes,
94    )
95}
96
97#[cfg(test)]
98mod tests {
99    use super::*;
100    use geop_core_math::for_all_scalars;
101    use geop_core_topology::validation::{ValidationParameters, validate, validate_manifold};
102
103    /// How many faces the figure-8 has, so the orientation counts below can
104    /// be read as a fraction rather than a bare number.
105    fn check_figure8_face_count<S: Scalar>() {
106        let mut part = Part::<S>::new();
107        figure8_profile(&mut part, "t").unwrap();
108        let model = part.topology();
109        assert_eq!(model.faces.len(), 22);
110    }
111    #[test]
112    fn figure8_face_count() {
113        for_all_scalars!(check_figure8_face_count);
114    }
115
116    fn check_figure8_cap_normals_point_outward<S: Scalar>() {
117        let mut part = Part::<S>::new();
118        figure8_profile(&mut part, "t").unwrap();
119        let model = part.topology();
120        for face in model.faces.values() {
121            let (u0, u1) = face.surface.domain_u();
122            let (v0, v1) = face.surface.domain_v();
123            let mid_u = u0.add(u1).div(S::from_f64(2.0)).unwrap();
124            let mid_v = v0.add(v1).div(S::from_f64(2.0)).unwrap();
125            let p = face.surface.evaluate(mid_u, mid_v).unwrap();
126            let n = face.surface.normal(mid_u, mid_v).unwrap();
127            // Only the flat caps are exactly horizontal (z is ~constant
128            // across the whole surface) — side walls aren't.
129            let p_at_00 = face.surface.evaluate(u0, v0).unwrap();
130            if !p[2].could_be_equal(p_at_00[2]) {
131                continue;
132            }
133            // bottom cap (z=0): outward normal should point +z (material
134            // is below, at z<0). top cap (z=-1): outward should point -z.
135            let expect_positive_z = p[2].to_f64().abs() < 0.5;
136            assert_eq!(
137                n[2].to_f64() > 0.0,
138                expect_positive_z,
139                "cap at z={} has normal z-component {}",
140                p[2].to_f64(),
141                n[2].to_f64()
142            );
143        }
144    }
145    #[test]
146    fn figure8_cap_normals_point_outward() {
147        for_all_scalars!(check_figure8_cap_normals_point_outward);
148    }
149
150    fn check_figure8_profile_is_valid<S: Scalar>() {
151        let mut part = Part::<S>::new();
152        figure8_profile(&mut part, "t").unwrap();
153        let model = part.topology();
154
155        let params = ValidationParameters::default();
156        if let Err(e) = validate(&params, &model) {
157            panic!("{e:?}");
158        }
159        if let Err(e) = validate_manifold(&params, &model) {
160            panic!("{e:?}");
161        }
162    }
163    #[test]
164    fn figure8_profile_is_valid() {
165        for_all_scalars!(check_figure8_profile_is_valid);
166    }
167
168    fn check_rasterize_topology_figure8<S: Scalar>() {
169        let mut part = Part::<S>::new();
170        figure8_profile(&mut part, "t").unwrap();
171        let model = part.topology();
172
173        let scene = geop_ops_rasterize::rasterize_topology(&model, 8).unwrap();
174        assert!(!scene.points.is_empty());
175        assert!(!scene.lines.is_empty());
176        assert!(!scene.triangles_transparent.is_empty());
177        assert!(!scene.labels.is_empty());
178
179        std::fs::create_dir_all("outputs").unwrap();
180        scene.save_to_file("outputs/figure8_topology.html").unwrap();
181    }
182    #[test]
183    fn rasterize_topology_figure8() {
184        for_all_scalars!(check_rasterize_topology_figure8);
185    }
186}