Skip to main content

geop_ops_extrude_revolve/
cylinder.rs

1//! A capped circular cylinder, built two different ways: [`revolved_cylinder`]
2//! (via [`revolve_at`], an exact circular cross-section) and
3//! [`extruded_cylinder`] (via [`extrude`], an `n`-gon approximating one).
4
5use crate::{
6    common::{Profile, polygon, polyline},
7    extrude::{ExtrudeNames, extrude},
8    revolve::revolve_at_oriented,
9};
10use geop_core_math::{
11    geop_error::GeopResult,
12    primitives::CoordinateSystem,
13    scalars::Scalar,
14    vector::{Vector2, Vector3},
15};
16use geop_core_part::{Namer, Part};
17use geop_core_topology::SolidId;
18
19/// Which axis a cylinder's own axis runs parallel to.
20#[derive(Clone, Copy, Debug, PartialEq, Eq)]
21pub enum Axis {
22    X,
23    Y,
24    Z,
25}
26
27/// A capped cylinder of `radius` and `height`, with its axis parallel to the
28/// z-axis through `base_center` (the center of its *bottom* cap — the solid
29/// spans `z in [base_center.z, base_center.z + height]`), as an exact
30/// circular cross-section swept via `revolve_at`.
31///
32/// A thin wrapper around [`revolved_cylinder_along_axis`] with `axis =
33/// Axis::Z`, its own historical/default orientation.
34pub fn revolved_cylinder<S: Scalar>(
35    part: &mut Part<S>,
36    name: &str,
37    base_center: Vector3<S>,
38    radius: S,
39    height: S,
40) -> GeopResult<SolidId> {
41    revolved_cylinder_along_axis(part, name, base_center, radius, height, Axis::Z)
42}
43
44/// Like [`revolved_cylinder`], but with its axis parallel to `axis` instead
45/// of always z — `base_center` is still the center of the *bottom* cap
46/// (the cap nearer the origin along `axis`'s own positive direction), so
47/// the solid spans `[0, height]` along `axis` from there.
48///
49/// Built as the same 3-segment revolve profile `revolved_cylinder` always
50/// used, just swept via [`revolve_at_oriented`] around a `CoordinateSystem`
51/// oriented to `axis` instead of the always-z-axis `revolve_at`. Each
52/// orientation's `u`/`v`/`w` basis is chosen as a cyclic permutation of the
53/// standard one (`(x, y, z) -> (y, z, x) -> (z, x, y)`), so `u x v = w`
54/// always holds — the same right-handed relationship `revolve_at`'s own
55/// `Axis::Z` case has — and every axis produces a solid with outward-facing
56/// normals, never one revolved "inside out".
57///
58/// Named as the operation `cylinder(name)`, after that profile (see
59/// [`revolve_at_oriented`]): `p0`/`p3` are the centers of the top/bottom
60/// caps, `c0` the top cap's radius, `c1` the side and `c2` the bottom cap's
61/// radius.
62pub fn revolved_cylinder_along_axis<S: Scalar>(
63    part: &mut Part<S>,
64    name: &str,
65    base_center: Vector3<S>,
66    radius: S,
67    height: S,
68    axis: Axis,
69) -> GeopResult<SolidId> {
70    let (zero, one) = (S::ZERO, S::ONE);
71    let x = Vector3::from_array([one, zero, zero]);
72    let y = Vector3::from_array([zero, one, zero]);
73    let z = Vector3::from_array([zero, zero, one]);
74    let (u, v, w) = match axis {
75        Axis::X => (y, z, x),
76        Axis::Y => (z, x, y),
77        Axis::Z => (x, y, z),
78    };
79    let coordinate_system = CoordinateSystem::try_new(base_center, u, v, w)?;
80
81    // Top-down, matching `revolve_at`'s own convention (its sphere profile
82    // runs north pole to south). Running it bottom-up instead revolves the
83    // solid inside-out: structurally perfect, with every face's normal
84    // pointing into the material.
85    let profile = polyline(&[
86        Vector2::from_array([S::ZERO, height]),
87        Vector2::from_array([radius, height]),
88        Vector2::from_array([radius, S::ZERO]),
89        Vector2::from_array([S::ZERO, S::ZERO]),
90    ])?;
91    let namer = Namer::new("cylinder", name)?;
92    revolve_at_oriented(
93        part,
94        &namer,
95        &namer.root(),
96        &Profile::open(profile),
97        &coordinate_system,
98    )
99}
100
101/// A capped cylinder of `radius` and `height`, with its axis parallel to the
102/// z-axis through `base_center`, as a regular `segments`-gon prism
103/// approximating a circular cross-section, swept via `extrude`.
104///
105/// The coordinate system is left-handed (`w` points the opposite way `u x
106/// v` would for a right-handed one) — `extrude` needs that for a CCW
107/// boundary's normals to face outward (see its own module doc).
108///
109/// Named as the operation `cylinder(name)`, after the polygon's corners
110/// `p0..` and sides `c0..` (see [`ExtrudeNames`]).
111pub fn extruded_cylinder<S: Scalar>(
112    part: &mut Part<S>,
113    name: &str,
114    base_center: Vector3<S>,
115    radius: S,
116    height: S,
117    segments: usize,
118) -> GeopResult<SolidId> {
119    if segments < 3 {
120        return Err(geop_core_math::geop_error::GeopError::new(
121            "extruded_cylinder: need at least 3 segments",
122        ));
123    }
124    let u = Vector3::from_array([radius, S::ZERO, S::ZERO]);
125    let v = Vector3::from_array([S::ZERO, radius, S::ZERO]);
126    let w = Vector3::from_array([S::ZERO, S::ZERO, height.mul(S::from_f64(-1.0))]);
127    let coordinate_system = CoordinateSystem::try_new(base_center, u, v, w)?;
128
129    let outer: Vec<Vector2<S>> = (0..segments)
130        .map(|k| {
131            let angle = std::f64::consts::TAU * (k as f64) / (segments as f64);
132            Vector2::from_array([S::from_f64(angle.cos()), S::from_f64(angle.sin())])
133        })
134        .collect();
135
136    let namer = Namer::new("cylinder", name)?;
137    extrude(
138        part,
139        &ExtrudeNames::single(&namer),
140        &coordinate_system,
141        &Profile::closed(polygon(&outer)?),
142        &[],
143    )
144}
145
146#[cfg(test)]
147mod tests {
148    use super::*;
149    use geop_core_math::for_all_scalars;
150    use geop_core_topology::validation::{ValidationParameters, validate, validate_manifold};
151
152    fn check_revolved_cylinder_is_valid<S: Scalar>() {
153        let mut part = Part::<S>::new();
154        revolved_cylinder(
155            &mut part,
156            "t2",
157            Vector3::from_array([S::ZERO, S::ZERO, S::ZERO]),
158            S::ONE,
159            S::TWO,
160        )
161        .unwrap();
162        let model = part.topology();
163        assert_eq!(model.faces.len(), 12);
164
165        let params = ValidationParameters::default();
166        if let Err(e) = validate(&params, &model) {
167            panic!("{e:?}");
168        }
169        if let Err(e) = validate_manifold(&params, &model) {
170            panic!("{e:?}");
171        }
172    }
173    #[test]
174    fn revolved_cylinder_is_valid() {
175        for_all_scalars!(check_revolved_cylinder_is_valid);
176    }
177
178    fn check_extruded_cylinder_is_valid<S: Scalar>() {
179        let mut part = Part::<S>::new();
180        extruded_cylinder(
181            &mut part,
182            "t4",
183            Vector3::from_array([S::ZERO, S::ZERO, S::ZERO]),
184            S::ONE,
185            S::TWO,
186            12,
187        )
188        .unwrap();
189        let model = part.topology();
190
191        let params = ValidationParameters::default();
192        if let Err(e) = validate(&params, &model) {
193            panic!("{e:?}");
194        }
195        if let Err(e) = validate_manifold(&params, &model) {
196            panic!("{e:?}");
197        }
198        assert_eq!(model.faces.len(), 12 + 2);
199    }
200    #[test]
201    fn extruded_cylinder_is_valid() {
202        for_all_scalars!(check_extruded_cylinder_is_valid);
203    }
204
205    fn check_rasterize_topology_revolved_cylinder<S: Scalar>() {
206        let mut part = Part::<S>::new();
207        revolved_cylinder(
208            &mut part,
209            "t3",
210            Vector3::from_array([S::ZERO, S::ZERO, S::ZERO]),
211            S::ONE,
212            S::TWO,
213        )
214        .unwrap();
215        let model = part.topology();
216
217        let scene = geop_ops_rasterize::rasterize_topology(&model, 32).unwrap();
218        assert!(!scene.points.is_empty());
219        assert!(!scene.lines.is_empty());
220        assert!(!scene.triangles_transparent.is_empty());
221        assert!(!scene.labels.is_empty());
222
223        std::fs::create_dir_all("outputs").unwrap();
224        scene
225            .save_to_file("outputs/revolved_cylinder_topology.html")
226            .unwrap();
227    }
228    #[test]
229    fn rasterize_topology_revolved_cylinder() {
230        for_all_scalars!(check_rasterize_topology_revolved_cylinder);
231    }
232
233    fn check_rasterize_topology_extruded_cylinder<S: Scalar>() {
234        let mut part = Part::<S>::new();
235        extruded_cylinder(
236            &mut part,
237            "t5",
238            Vector3::from_array([S::ZERO, S::ZERO, S::ZERO]),
239            S::ONE,
240            S::TWO,
241            12,
242        )
243        .unwrap();
244        let model = part.topology();
245
246        let scene = geop_ops_rasterize::rasterize_topology(&model, 8).unwrap();
247        assert!(!scene.points.is_empty());
248        assert!(!scene.lines.is_empty());
249        assert!(!scene.triangles_transparent.is_empty());
250        assert!(!scene.labels.is_empty());
251
252        std::fs::create_dir_all("outputs").unwrap();
253        scene
254            .save_to_file("outputs/extruded_cylinder_topology.html")
255            .unwrap();
256    }
257    #[test]
258    fn rasterize_topology_extruded_cylinder() {
259        for_all_scalars!(check_rasterize_topology_extruded_cylinder);
260    }
261
262    /// An `X`/`Y`-axis cylinder must be structurally identical to the
263    /// (already-covered) `Z`-axis one — same face count, valid, manifold —
264    /// and its vertices must actually extend along the requested axis
265    /// (`height`) with the circular cross-section in the *other* two
266    /// coordinates, not still sitting on `z`.
267    fn check_revolved_cylinder_along_axis_is_valid<S: Scalar>() {
268        let params = ValidationParameters::default();
269        for (axis, extent_axis) in [(Axis::X, 0), (Axis::Y, 1), (Axis::Z, 2)] {
270            let mut part = Part::<S>::new();
271            revolved_cylinder_along_axis(
272                &mut part,
273                "t1",
274                Vector3::from_array([S::ZERO, S::ZERO, S::ZERO]),
275                S::ONE,
276                S::TWO,
277                axis,
278            )
279            .unwrap_or_else(|e| panic!("{axis:?}: {e}"));
280            let model = part.topology();
281            assert_eq!(model.faces.len(), 12, "{axis:?}");
282
283            validate(&params, &model).unwrap_or_else(|e| panic!("{axis:?}: {e:?}"));
284            validate_manifold(&params, &model).unwrap_or_else(|e| panic!("{axis:?}: {e:?}"));
285
286            // Every vertex's coordinate along the *other* two axes stays
287            // within the radius; `extent_axis` alone reaches all the way
288            // to `height`.
289            let max_extent = model
290                .vertices
291                .values()
292                .map(|v| v.point[extent_axis].to_f64())
293                .fold(0.0_f64, f64::max);
294            assert!(
295                (max_extent - 2.0).abs() < 1e-6,
296                "{axis:?}: max extent along its own axis = {max_extent}"
297            );
298            for other in 0..3 {
299                if other == extent_axis {
300                    continue;
301                }
302                let max_other = model
303                    .vertices
304                    .values()
305                    .map(|v| v.point[other].to_f64().abs())
306                    .fold(0.0_f64, f64::max);
307                assert!(
308                    max_other <= 1.0 + 1e-6,
309                    "{axis:?}: vertex strayed to {max_other} on axis {other}, radius is 1"
310                );
311            }
312        }
313    }
314    #[test]
315    fn revolved_cylinder_along_axis_is_valid() {
316        for_all_scalars!(check_revolved_cylinder_along_axis_is_valid);
317    }
318}