Skip to main content

geop_lib_sweep/
extrude.rs

1//! Extruding: sweeping a planar profile along a straight [`Path`] (see
2//! [`crate::sweep`]) — a solid between two flat caps, or a sheet of walls.
3
4use crate::sweep::{Frame, Path, Span, SweepLoop, sweep_drawn};
5use geop_core_math::{
6    geop_error::{GeopError, GeopResult},
7    primitives::CoordinateSystem,
8    scalars::Scalar,
9};
10use geop_core_topology::build::BuiltBody;
11use geop_ops_model::{NamedModel, Namer};
12
13/// The straight path along `plane.w()` through `heights` (in multiples of
14/// `w`, two or more, each further the same way than the one before): the
15/// profile, drawn in the plane's `(u, v)`, at stations `start`, `l1`,
16/// `l2`, ..., `end`, and a span between each two, named `q0`, `q1`, ...
17/// where there are several.
18pub fn extrusion(plane: &CoordinateSystem, heights: &[Scalar]) -> GeopResult<Path> {
19    let n = heights.len();
20    let ways: Vec<Option<bool>> = heights
21        .windows(2)
22        .map(|h| h[1].sub(h[0]).definite_sign())
23        .collect();
24    if n < 2 || ways.iter().any(|way| way.is_none() || *way != ways[0]) {
25        return Err(GeopError::new(format!(
26            "extrusion: heights {heights:?} that do not each go further one way"
27        )));
28    }
29    let station = |h: Scalar| Frame {
30        origin: plane.origin().add(&plane.w().prod_scalar(h)),
31        e1: *plane.u(),
32        e2: *plane.v(),
33    };
34    let along = plane
35        .u()
36        .prod_cross(plane.v())
37        .prod_dot(plane.w())
38        .mul(heights[n - 1].sub(heights[0]));
39    let along_normal = along.definite_sign().ok_or_else(|| {
40        GeopError::new(format!(
41            "extrusion: which way {heights:?} go along the plane's normal could be either"
42        ))
43    })?;
44    Ok(Path {
45        stations: heights.iter().map(|&h| station(h)).collect(),
46        spans: vec![Span::Line; n - 1],
47        closed: false,
48        along_normal,
49        station_names: (0..n)
50            .map(|s| match s {
51                0 => "start".to_string(),
52                s if s + 1 == n => "end".to_string(),
53                s => format!("l{s}"),
54            })
55            .collect(),
56        span_names: if n == 2 {
57            vec![None]
58        } else {
59            (0..n - 1).map(|j| Some(format!("q{j}"))).collect()
60        },
61    })
62}
63
64/// Extrudes `loops` — the first the outer loop, counter-clockwise in
65/// `plane`'s `(u, v)`, the rest holes in it, clockwise — along
66/// `plane.w()` through `heights` (see [`extrusion`]): into a solid named
67/// `solid`, its start cap at the first height, or, without one, into
68/// sheets. Either way round the solid comes out with its faces pointing
69/// outwards, whichever way the plane is handed.
70///
71/// Named after the profiles' curves `X` and joints `P` (see
72/// [`Profile`](geop_ops_model::Profile) and [`sweep`]): the walls `N(X)`,
73/// their edges on the caps `N(X,start)` / `N(X,end)`, the edges between
74/// them `N(P)`, their vertices `N(P,start)` / `N(P,end)`, the caps
75/// `N(start)` / `N(end)`. Through more than two heights each span's walls
76/// and edges are `N(X,q0)`, `N(P,q0)`, ..., and the edges and vertices at
77/// the heights between `N(X,l1)`, `N(P,l1)`, ....
78pub fn extrude(
79    model: &mut NamedModel,
80    namer: &Namer,
81    solid: Option<&str>,
82    plane: &CoordinateSystem,
83    heights: &[Scalar],
84    loops: &[SweepLoop],
85) -> GeopResult<BuiltBody> {
86    let path = extrusion(plane, heights)?;
87    sweep_drawn(model, namer, &path, loops, solid)
88}
89
90#[cfg(test)]
91mod tests {
92    use super::*;
93    use crate::common::polygon;
94    use crate::shapes::{circle, circle_at, v2};
95    use geop_core_geometry::nurb_curve::{NurbCurve2D, sqrt2_over_2};
96    use geop_core_math::vector::Vector3;
97    use geop_ops_model::Profile;
98    use geop_ops_model::testing::{assert_model_valid, assert_model_valid_sheets};
99
100    fn unit_square() -> Vec<NurbCurve2D> {
101        polygon(&[v2(0.0, 0.0), v2(1.0, 0.0), v2(1.0, 1.0), v2(0.0, 1.0)]).unwrap()
102    }
103
104    /// A square hole (traced the *opposite* winding from `unit_square`, so
105    /// the material — the region between the outer boundary and this hole
106    /// — genuinely has the hole cut out of it), spanning `[x0, x0 + size] x
107    /// [y0, y0 + size]`.
108    fn square_hole(x0: f64, y0: f64, size: f64) -> Vec<NurbCurve2D> {
109        polygon(&[
110            v2(x0, y0),
111            v2(x0, y0 + size),
112            v2(x0 + size, y0 + size),
113            v2(x0 + size, y0),
114        ])
115        .unwrap()
116    }
117
118    /// Left-handed (`u x v = -w`), as `figure8_profile` builds its own:
119    /// `extrude` points the faces outwards either way round, which
120    /// `validation::face_orientation::check_normals_point_outward` checks.
121    fn axis_aligned_coordinate_system(origin: Vector3) -> CoordinateSystem {
122        let u = Vector3::from_array([Scalar::ONE, Scalar::ZERO, Scalar::ZERO]);
123        let v = Vector3::from_array([Scalar::ZERO, Scalar::ONE, Scalar::ZERO]);
124        let w = Vector3::from_array([Scalar::ZERO, Scalar::ZERO, Scalar::from_f64(-1.0)]);
125        CoordinateSystem::try_new(origin, u, v, w).unwrap()
126    }
127
128    /// Extrude into a fresh part, as the single-region operation `e`.
129    fn extruded(
130        cs: &CoordinateSystem,
131        outer: Vec<NurbCurve2D>,
132        holes: Vec<Vec<NurbCurve2D>>,
133    ) -> NamedModel {
134        let mut model = NamedModel::new();
135        let namer = Namer::new("extrude", "e").unwrap();
136        let loops: Vec<_> = std::iter::once(Profile::closed(outer))
137            .chain(
138                holes
139                    .into_iter()
140                    .enumerate()
141                    .map(|(k, h)| Profile::closed(h).with_prefix(&format!("h{k}"))),
142            )
143            .map(SweepLoop::plain)
144            .collect();
145        extrude(
146            &mut model,
147            &namer,
148            Some(&namer.root()),
149            cs,
150            &[Scalar::ZERO, Scalar::ONE],
151            &loops,
152        )
153        .unwrap();
154        model.check_names().unwrap();
155        model
156    }
157
158    #[test]
159    fn extruded_square_is_valid() {
160        let cs = axis_aligned_coordinate_system(Vector3::from_array([Scalar::ZERO; 3]));
161        let model = extruded(&cs, unit_square(), vec![]);
162        let topology = model.topology();
163        assert_model_valid(topology);
164        assert_eq!(topology.faces.len(), 6);
165        assert_eq!(topology.vertices.len(), 8);
166        assert_eq!(topology.edges.len(), 12);
167    }
168
169    /// A curve kinked at a knot inside it is cut there: its wall is two
170    /// faces, `c0` and `c0#k1`, an edge where the kink is. A curve only
171    /// C1 in its parametrization at a knot, a circle's quarters as one
172    /// rational curve, turning on smoothly, is not cut.
173    #[test]
174    fn a_kinked_curve_is_swept_into_a_face_per_leg() {
175        let mut model = NamedModel::new();
176        let prism = crate::shapes::creased_prism(&mut model, "p");
177        assert_model_valid(model.topology());
178        let names: Vec<String> = (model.topology().solid_faces(prism).unwrap().iter())
179            .map(|&f| model.name(f).unwrap().to_string())
180            .collect();
181        assert_eq!(names.len(), 3 + 2, "{names:?}");
182        assert!(model.face_id("prism(p,c0)").is_ok() && model.face_id("prism(p,c0#k1)").is_ok());
183
184        let whole = NurbCurve2D::join(&crate::shapes::circle(1.0)).unwrap();
185        let (t0, t1) = whole.domain();
186        let (a, b) = whole.split(t0.add(t1).div(Scalar::TWO).unwrap()).unwrap();
187        let cut = SweepLoop::plain(Profile::closed(vec![a, b]))
188            .at_corners()
189            .unwrap();
190        assert_eq!(cut.profile.curves.len(), 2);
191    }
192
193    #[test]
194    fn extruded_square_with_hole_is_valid() {
195        let hole = square_hole(0.25, 0.25, 0.5);
196        let cs = axis_aligned_coordinate_system(Vector3::from_array([Scalar::ZERO; 3]));
197        let model = extruded(&cs, unit_square(), vec![hole]);
198        let topology = model.topology();
199        assert_model_valid(topology);
200        // 6 outer faces (4 sides + top + bottom) + 4 hole side walls; 8
201        // outer vertices + 8 hole vertices (4 on the bottom cap, 4 on top).
202        assert_eq!(topology.faces.len(), 6 + 4);
203        assert_eq!(topology.vertices.len(), 8 + 8);
204    }
205
206    #[test]
207    fn extrude_offsets_footprint() {
208        let origin = Vector3::from_array([
209            Scalar::from_f64(2.0),
210            Scalar::from_f64(-1.0),
211            Scalar::from_f64(0.5),
212        ]);
213        let cs = axis_aligned_coordinate_system(origin);
214        let model = extruded(&cs, unit_square(), vec![]);
215        let topology = model.topology();
216        assert_model_valid(topology);
217        // `w` points along `-z` (see `axis_aligned_coordinate_system`), so
218        // the profile sits at the origin's own `z` and the far face one unit
219        // *below* it.
220        for vertex in topology.vertices.values() {
221            assert!(
222                vertex.point[2].could_be_equal(Scalar::from_f64(0.5))
223                    || vertex.point[2].could_be_equal(Scalar::from_f64(-0.5)),
224                "vertex at unexpected height: {:?}",
225                vertex.point
226            );
227        }
228    }
229
230    /// A profile anywhere in the plane, not just the unit square: the caps
231    /// are sized to it.
232    #[test]
233    fn extrude_profile_away_from_origin() {
234        let cs = axis_aligned_coordinate_system(Vector3::from_array([Scalar::ZERO; 3]));
235        let outer = polygon(&[v2(-3.0, 2.0), v2(-1.0, 2.0), v2(-2.0, 5.0)]).unwrap();
236        let model = extruded(&cs, outer, vec![]);
237        let topology = model.topology();
238        assert_model_valid(topology);
239        assert_eq!(topology.faces.len(), 5);
240    }
241
242    /// A disc with a round hole: every wall is a quarter of a cylinder.
243    #[test]
244    fn extruded_ring_is_valid() {
245        let cs = axis_aligned_coordinate_system(Vector3::from_array([Scalar::ZERO; 3]));
246        let outer = circle(2.0);
247        let hole = circle_at(0.3, 0.0, 1.0, true);
248        let model = extruded(&cs, outer, vec![hole]);
249        let topology = model.topology();
250        assert_model_valid(topology);
251        assert_eq!(topology.faces.len(), 2 + 4 + 4);
252    }
253
254    /// Both directions off a right-handed plane give valid solids on the
255    /// expected side.
256    #[test]
257    fn extrude_both_directions() {
258        let plane = CoordinateSystem::try_new(
259            Vector3::from_array([Scalar::ZERO, Scalar::ZERO, Scalar::ONE]),
260            Vector3::from_array([Scalar::ONE, Scalar::ZERO, Scalar::ZERO]),
261            Vector3::from_array([Scalar::ZERO, Scalar::ONE, Scalar::ZERO]),
262            Vector3::from_array([Scalar::ZERO, Scalar::ZERO, Scalar::ONE]),
263        )
264        .unwrap();
265        let mut outer = polygon(&[v2(0.0, 0.0), v2(1.0, 0.0), v2(1.0, 1.0)]).unwrap();
266        outer.push(
267            NurbCurve2D::arc(v2(1.0, 1.0), v2(0.0, 1.0), v2(0.0, 0.0), sqrt2_over_2()).unwrap(),
268        );
269        outer.remove(2);
270        let namer = Namer::new("extrude", "e").unwrap();
271        let outer = SweepLoop::plain(Profile::closed(outer));
272        for (distance, far) in [(0.5, 1.5), (-0.5, 0.5)] {
273            let mut model = NamedModel::new();
274            extrude(
275                &mut model,
276                &namer,
277                Some(&namer.root()),
278                &plane,
279                &[Scalar::ZERO, Scalar::from_f64(distance)],
280                std::slice::from_ref(&outer),
281            )
282            .unwrap();
283            model.check_names().unwrap();
284            let topology = model.topology();
285            assert_model_valid(topology);
286            assert!(
287                topology
288                    .vertices
289                    .values()
290                    .any(|v| v.point[2].could_be_equal(Scalar::from_f64(far)))
291            );
292            // Whichever way the profile had to be mirrored to extrude it,
293            // every name still sits where it says: `p0` at the profile's
294            // first point, the end cap `distance` away from the plane.
295            let p0_start = model.vertex_id("extrude(e,p0,start)").unwrap();
296            let p0_end = model.vertex_id("extrude(e,p0,end)").unwrap();
297            let point = |v| topology.get_vertex(v).unwrap().point;
298            assert!(point(p0_start).could_be_equal(&Vector3::from_array([
299                Scalar::ZERO,
300                Scalar::ZERO,
301                Scalar::ONE
302            ])));
303            assert!(point(p0_end)[2].could_be_equal(Scalar::from_f64(far)));
304            let end_cap = model.face_id("extrude(e,end)").unwrap();
305            assert!(topology.iterate_face_coedges(end_cap).all(|c| {
306                topology.coedge_start_vertex(c).unwrap().point[2]
307                    .could_be_equal(Scalar::from_f64(far))
308            }));
309        }
310    }
311
312    fn xy_plane() -> CoordinateSystem {
313        CoordinateSystem::world_at(Vector3::zero())
314    }
315
316    /// An open chain extrudes into a sheet: a wall per curve, the edges
317    /// along its rims and its two ends each used by that one wall alone.
318    #[test]
319    fn open_chain_extrudes_into_a_sheet() {
320        let mut model = NamedModel::new();
321        let namer = Namer::new("extrude", "e").unwrap();
322        let chain = crate::common::polyline(&[v2(0.0, 0.0), v2(1.0, 0.0), v2(1.0, 2.0)]).unwrap();
323        let built = extrude(
324            &mut model,
325            &namer,
326            None,
327            &xy_plane(),
328            &[Scalar::ZERO, Scalar::ONE],
329            &[SweepLoop::plain(Profile::open(chain))],
330        )
331        .unwrap();
332        model.check_names().unwrap();
333        assert!(built.solid.is_none());
334        let topology = model.topology();
335        assert_model_valid_sheets(topology);
336        assert_eq!(topology.faces.len(), 2);
337        let free = |e: &geop_core_topology::EdgeId| topology.coedges_of_edge(*e).len() == 1;
338        assert_eq!(topology.edges.keys().filter(|e| free(e)).count(), 6);
339        assert!(model.edge_id("extrude(e,p1)").is_ok_and(|e| !free(&e)));
340    }
341
342    /// Between any two ends, either way round: the start cap at the first.
343    #[test]
344    fn extrude_between_two_ends() {
345        for (from, to) in [(-0.5, 1.0), (1.0, -0.5), (0.25, 2.0)] {
346            let mut model = NamedModel::new();
347            let namer = Namer::new("extrude", "e").unwrap();
348            extrude(
349                &mut model,
350                &namer,
351                Some("extrude(e)"),
352                &xy_plane(),
353                &[Scalar::from_f64(from), Scalar::from_f64(to)],
354                &[SweepLoop::plain(Profile::closed(unit_square()))],
355            )
356            .unwrap();
357            let topology = model.topology();
358            assert_model_valid(topology);
359            let height = |name: &str| {
360                topology
361                    .get_vertex(model.vertex_id(name).unwrap())
362                    .unwrap()
363                    .point[2]
364            };
365            assert!(height("extrude(e,p0,start)").could_be_equal(Scalar::from_f64(from)));
366            assert!(height("extrude(e,p2,end)").could_be_equal(Scalar::from_f64(to)));
367        }
368    }
369}
370
371#[cfg(test)]
372mod naming_tests {
373    use super::*;
374    use crate::common::polygon;
375    use geop_core_math::scalars::Scalar;
376    use geop_core_math::vector::{Vector2, Vector3};
377    use geop_ops_model::Profile;
378
379    /// Every entity of an extruded square is named after the profile element
380    /// it was swept from, and the names hang together topologically: the
381    /// side face of `c0` is bounded by `c0`'s two cap edges and the lateral
382    /// edges of its two joints.
383    #[test]
384    fn extruded_square_names_follow_the_profile() {
385        let square = polygon(&[
386            Vector2::from_array([Scalar::ZERO, Scalar::ZERO]),
387            Vector2::from_array([Scalar::ONE, Scalar::ZERO]),
388            Vector2::from_array([Scalar::ONE, Scalar::ONE]),
389            Vector2::from_array([Scalar::ZERO, Scalar::ONE]),
390        ])
391        .unwrap();
392        let plane = CoordinateSystem::try_new(
393            Vector3::zero(),
394            Vector3::from_array([Scalar::ONE, Scalar::ZERO, Scalar::ZERO]),
395            Vector3::from_array([Scalar::ZERO, Scalar::ONE, Scalar::ZERO]),
396            Vector3::from_array([Scalar::ZERO, Scalar::ZERO, Scalar::ONE]),
397        )
398        .unwrap();
399        let mut model = NamedModel::new();
400        let namer = Namer::new("extrude", "box").unwrap();
401        extrude(
402            &mut model,
403            &namer,
404            Some(&namer.root()),
405            &plane,
406            &[Scalar::ZERO, Scalar::ONE],
407            &[SweepLoop::plain(Profile::closed(square))],
408        )
409        .unwrap();
410        model.check_names().unwrap();
411
412        let description = geop_ops_model::PartDescription::of(&model).unwrap();
413        assert_eq!(description.solids.len(), 1);
414        assert_eq!(description.solids["extrude(box)"][0].len(), 6);
415        let mut side = description.faces["extrude(box,c0)"].outer.clone();
416        side.sort();
417        let mut expected: Vec<String> = [
418            "extrude(box,c0,end)",
419            "extrude(box,c0,start)",
420            "extrude(box,p0)",
421            "extrude(box,p1)",
422        ]
423        .iter()
424        .map(|e| e.to_string())
425        .collect();
426        expected.sort();
427        let unsigned: Vec<String> = side.iter().map(|e| e[1..].to_string()).collect();
428        let mut unsigned = unsigned;
429        unsigned.sort();
430        assert_eq!(unsigned, expected);
431        assert_eq!(
432            description.edges["extrude(box,p1)"].start,
433            "extrude(box,p1,start)"
434        );
435        assert_eq!(
436            description.edges["extrude(box,p1)"].end,
437            "extrude(box,p1,end)"
438        );
439        let corner = model.vertex_id("extrude(box,p2,end)").unwrap();
440        assert!(
441            model
442                .topology()
443                .get_vertex(corner)
444                .unwrap()
445                .point
446                .could_be_equal(&Vector3::from_array([Scalar::ONE; 3]))
447        );
448    }
449}