Skip to main content

geop_ops_extrude_revolve/
extrude.rs

1//! Extrude a closed planar profile — an outer loop plus any number of holes,
2//! each a closed chain of NURBS curves in the `(u, v)` plane of a coordinate
3//! system — one unit along its `w` to produce a solid, entirely from euler
4//! operations.
5//!
6//! **Orientation contract:** `outer` must wind counter-clockwise, each hole
7//! clockwise, and `coordinate_system` must be **left-handed** (`u x v = -w`).
8//! Every side wall is parametrized `(height, profile)`, so the normal it ends
9//! up with is `w x d` for a wall along profile direction `d`; that is the
10//! outward normal exactly under this combination. Hand it a right-handed
11//! basis and the whole solid comes out inside-out — still structurally
12//! valid, still renders, and rejected by
13//! `validation::face_orientation::check_normals_point_outward`.
14//! [`extrude_from_plane`] takes a right-handed plane instead and arranges
15//! this itself.
16//!
17//! **Profile curves:** every curve must be clamped (start at its first
18//! control point, end at its last), have the domain `[0, 1]`, and start
19//! exactly where the previous one in its loop ends; each loop needs at least
20//! two curves (every vertex of the solid sits at a curve joint). The curves
21//! may lie anywhere in the plane: the flat caps are sized to the profile.
22//!
23//! [`grow_ring`] (`mvfs`/`mvr` + `mve_from_vertex` + `mve`) builds the outer
24//! ring directly on a placeholder (`NurbSurface3D::everything()`) face;
25//! `mef` (like `mer`, but the split-off ring is moved onto a brand new,
26//! real-surfaced face instead of staying as a second boundary of the same
27//! face) turns that ring into the bottom cap, leaving the *same* ring,
28//! traced the other way, on the placeholder face. Each hole is grown the
29//! same way, directly on the (now real) bottom cap instead — `mvr` gives it
30//! a fresh bare-vertex boundary there, and `mer` (like `mef`, but the
31//! split-off ring moves onto an already-existing face instead of a new one)
32//! moves its "other side" onto the placeholder face too, alongside the
33//! outer ring's own. [`build_side_walls`] then advances every one of these
34//! rings straight up by `w`, closing off a side wall per curve — since the
35//! pcurves `grow_ring`/`mer` leave it were only ever valid for whatever face
36//! the ring was *grown* on, the very first thing it does is swap them all
37//! out (`replace_pcurve`) for the side walls' own reusable convention — and
38//! leaves their top rings on the placeholder face, so `replace_face` can
39//! give it the real top surface (with the same holes) as the very last step.
40
41use crate::common::{
42    Profile, bilinear, embed_curve, embed_point, end_point, line2, line3, start_point,
43};
44use geop_core_geometry::{
45    nurb_curve::{NurbCurve, NurbCurve2D},
46    nurb_surface::{NurbSurface, NurbSurface3D},
47};
48use geop_core_math::{
49    geop_error::{GeopError, GeopResult, WithContext},
50    primitives::CoordinateSystem,
51    scalars::Scalar,
52    vector::{Vector2, Vector3},
53    with_context,
54};
55use geop_core_part::{Namer, Part};
56use geop_core_topology::{CoedgeId, FaceId, SolidId, VertexId};
57
58/// Where the profile sits in `(u, v)`, and the pcurves and surfaces that
59/// follow from that: the two flat caps span the profile's bounding box, so a
60/// profile point maps to cap parameters by one affine map.
61struct Caps<'a, S: Scalar> {
62    coordinate_system: &'a CoordinateSystem<S>,
63    lo: Vector2<S>,
64    size: Vector2<S>,
65}
66
67impl<'a, S: Scalar> Caps<'a, S> {
68    fn new(
69        coordinate_system: &'a CoordinateSystem<S>,
70        loops: impl Iterator<Item = &'a NurbCurve2D<S>>,
71    ) -> GeopResult<Self> {
72        let mut lo = [f64::INFINITY; 2];
73        let mut hi = [f64::NEG_INFINITY; 2];
74        for curve in loops {
75            for cp in &curve.control_points {
76                for k in 0..2 {
77                    let x = cp[k].div(cp[2])?;
78                    lo[k] = lo[k].min(x.lower().to_f64());
79                    hi[k] = hi[k].max(x.upper().to_f64());
80                }
81            }
82        }
83        // The caps span exactly this box. It encloses the profile: a NURBS
84        // curve stays within the convex hull of its control points, and the
85        // bounds above are the outer bounds of their enclosures.
86        let size = Vector2::from_array([S::from_f64(hi[0] - lo[0]), S::from_f64(hi[1] - lo[1])]);
87        let lo = Vector2::from_array([S::from_f64(lo[0]), S::from_f64(lo[1])]);
88        Ok(Caps {
89            coordinate_system,
90            lo,
91            size,
92        })
93    }
94
95    /// `curve` in the bottom cap's parameters.
96    fn bottom_pcurve(&self, curve: &NurbCurve2D<S>) -> GeopResult<NurbCurve2D<S>> {
97        let control_points = curve
98            .control_points
99            .iter()
100            .map(|cp| {
101                Ok(Vector3::from_array([
102                    cp[0].sub(cp[2].mul(self.lo[0])).div(self.size[0])?,
103                    cp[1].sub(cp[2].mul(self.lo[1])).div(self.size[1])?,
104                    cp[2],
105                ]))
106            })
107            .collect::<GeopResult<Vec<_>>>()?;
108        NurbCurve::try_new(curve.degree, control_points, curve.knot_vector.clone())
109    }
110
111    /// `curve` in the top cap's parameters, which run along `(v, u)`.
112    fn top_pcurve(&self, curve: &NurbCurve2D<S>) -> GeopResult<NurbCurve2D<S>> {
113        Ok(self.bottom_pcurve(curve)?.swap_xy())
114    }
115
116    fn corner(&self, i: usize, j: usize, height: S) -> Vector3<S> {
117        let pick = |k: usize, far: usize| {
118            if far == 1 {
119                self.lo[k].add(self.size[k])
120            } else {
121                self.lo[k]
122            }
123        };
124        self.coordinate_system
125            .to_xyz(&Vector3::from_array([pick(0, i), pick(1, j), height]))
126    }
127
128    fn bottom_surface(&self) -> GeopResult<NurbSurface3D<S>> {
129        let h = S::ZERO;
130        bilinear(
131            self.corner(0, 0, h),
132            self.corner(1, 0, h),
133            self.corner(1, 1, h),
134            self.corner(0, 1, h),
135        )
136    }
137
138    fn top_surface(&self) -> GeopResult<NurbSurface3D<S>> {
139        let h = S::ONE;
140        bilinear(
141            self.corner(0, 0, h),
142            self.corner(0, 1, h),
143            self.corner(1, 1, h),
144            self.corner(1, 0, h),
145        )
146    }
147
148    /// A profile point at `height` (0 = bottom cap, 1 = top cap).
149    fn point(&self, p: &Vector2<S>, height: S) -> Vector3<S> {
150        self.coordinate_system
151            .to_xyz(&Vector3::from_array([p[0], p[1], height]))
152    }
153
154    /// A profile curve at `height`.
155    fn curve(
156        &self,
157        curve: &NurbCurve2D<S>,
158        height: S,
159    ) -> GeopResult<geop_core_geometry::nurb_curve::NurbCurve3D<S>> {
160        let cs = self.coordinate_system;
161        embed_curve(
162            curve,
163            &cs.origin().add(&cs.w().prod_scalar(height)),
164            cs.u(),
165            cs.v(),
166        )
167    }
168
169    /// The side wall swept by `curve`: degree 1 in `u` (height 0 to 1),
170    /// `curve`'s own degree and knots in `v`.
171    fn wall(&self, curve: &NurbCurve2D<S>) -> GeopResult<NurbSurface3D<S>> {
172        let cs = self.coordinate_system;
173        let top = cs.origin().add(cs.w());
174        let control_points = [cs.origin(), &top]
175            .into_iter()
176            .flat_map(|origin| {
177                curve
178                    .control_points
179                    .iter()
180                    .map(move |cp| embed_point(cp, origin, cs.u(), cs.v()))
181            })
182            .collect();
183        NurbSurface::try_new(
184            1,
185            curve.degree,
186            control_points,
187            vec![S::ZERO, S::ZERO, S::ONE, S::ONE],
188            curve.knot_vector.clone(),
189        )
190    }
191}
192
193/// The names one extrude gives to what it builds from one region, following
194/// `geop_core_part`'s scheme: `N` is the operation's [`Namer`], `X` a profile
195/// curve's name and `P` a joint's (see [`Profile`]).
196///
197/// | entity | name |
198/// |---|---|
199/// | side face swept by curve `X` | `N(X)` |
200/// | edge along `X` on the start / end cap | `N(X,start)` / `N(X,end)` |
201/// | edge swept by joint `P` | `N(P)` |
202/// | vertex at `P` on the start / end cap | `N(P,start)` / `N(P,end)` |
203/// | start / end cap | `N(start)` / `N(end)`, or `N(start,R)` / `N(end,R)` |
204///
205/// The start cap lies on the profile's plane, the end cap `w` away from it.
206/// `region` qualifies the caps (`R`) when one operation extrudes several
207/// regions, each of which has caps of its own.
208pub struct ExtrudeNames<'a> {
209    pub namer: &'a Namer,
210    pub region: Option<&'a str>,
211    /// The name of the solid built.
212    pub solid: String,
213}
214
215impl<'a> ExtrudeNames<'a> {
216    /// The names for an operation that extrudes a single region: its solid
217    /// is the operation's own name, its caps are unqualified.
218    pub fn single(namer: &'a Namer) -> Self {
219        Self {
220            namer,
221            region: None,
222            solid: namer.root(),
223        }
224    }
225
226    fn curve(&self, profile: &Profile<impl Scalar>, i: usize, role: &str) -> String {
227        self.namer.name(&[&profile.curve_names[i], role])
228    }
229    fn joint(&self, profile: &Profile<impl Scalar>, i: usize, role: &str) -> String {
230        self.namer.name(&[&profile.joint_names[i], role])
231    }
232    fn side_face(&self, profile: &Profile<impl Scalar>, i: usize) -> String {
233        self.namer.name(&[&profile.curve_names[i]])
234    }
235    fn lateral_edge(&self, profile: &Profile<impl Scalar>, i: usize) -> String {
236        self.namer.name(&[&profile.joint_names[i]])
237    }
238    fn cap(&self, role: &str) -> String {
239        match self.region {
240            None => self.namer.name(&[role]),
241            Some(region) => self.namer.name(&[role, region]),
242        }
243    }
244}
245
246/// Grow a `curves.len() - 1`-edge chain from an existing bare-vertex
247/// boundary `v0` of `face_id` (as added by `mvfs` or `mvr`), along
248/// `curves[..n - 1]`, via `mve_from_vertex` then `mve` — both directions'
249/// pcurves are the bottom cap's, valid against `face_id`'s own current
250/// surface (be it still the generic placeholder, for the outer ring, or the
251/// already-real bottom cap, for a hole).
252/// Returns `(first_forward, first_mirror, last_forward, last_mirror)`.
253fn grow_ring<S: Scalar>(
254    part: &mut Part<S>,
255    names: &ExtrudeNames,
256    face_id: FaceId,
257    v0: VertexId,
258    caps: &Caps<S>,
259    profile: &Profile<S>,
260) -> GeopResult<(CoedgeId, CoedgeId, CoedgeId, CoedgeId)> {
261    let curves = &profile.curves;
262    let n = curves.len();
263    let bottom = |i: usize| -> GeopResult<_> {
264        let pcurve = caps.bottom_pcurve(&curves[i])?;
265        Ok((
266            caps.curve(&curves[i], S::ZERO)?,
267            pcurve.clone(),
268            pcurve.reverse(),
269            caps.point(&end_point(&curves[i])?, S::ZERO),
270        ))
271    };
272
273    let (curve, pcurve, pcurve_reversed, end) = bottom(0)?;
274    let (_, first_forward, first_mirror, _) = part.mve_from_vertex(
275        face_id,
276        v0,
277        curve,
278        pcurve,
279        pcurve_reversed,
280        end,
281        names.joint(profile, 1, "start"),
282        names.curve(profile, 0, "start"),
283    )?;
284
285    let mut cursor = first_forward;
286    let mut last_mirror = first_mirror;
287    for i in 1..n - 1 {
288        let (curve, pcurve, pcurve_reversed, end) = bottom(i)?;
289        let (_, next_forward, next_mirror, _) = part.mve(
290            cursor,
291            curve,
292            pcurve,
293            pcurve_reversed,
294            end,
295            names.joint(profile, i + 1, "start"),
296            names.curve(profile, i, "start"),
297        )?;
298        cursor = next_forward;
299        last_mirror = next_mirror;
300    }
301
302    Ok((first_forward, first_mirror, cursor, last_mirror))
303}
304
305/// The reusable pcurve every "mirror" (base-level) coedge on the
306/// placeholder face gets, whichever ring it belongs to: every side wall
307/// shares the same `(height, profile)` layout, so its base-level edge is
308/// always `(u, v) = (0, 1) -> (0, 0)`, regardless of which curve or which
309/// ring it is.
310fn base_level_pcurve<S: Scalar>() -> GeopResult<NurbCurve2D<S>> {
311    line2(
312        Vector2::from_array([S::ZERO, S::ONE]),
313        Vector2::from_array([S::ZERO, S::ZERO]),
314    )
315}
316
317/// Sweep the ring anchored at `down_face_coedge` straight up by one unit in
318/// `w`, one curve of `profile` at a time, closing off a side wall per curve.
319/// `down_face_coedge` must be a coedge of a ring tracing `profile` the
320/// *opposite* way (i.e. exactly what `mef`/`mer` leave behind on the face
321/// they didn't peel the "real" ring off onto) — whatever pcurves it carries
322/// in from `grow_ring` are only ever valid for the face it was *grown* on,
323/// so the very first thing this function does is stamp every one of its
324/// coedges with [`base_level_pcurve`] instead, the one every side wall it
325/// goes on to build actually expects. Leaves the fully-swept (topmost) ring
326/// in `down_face_coedge`'s ring's place, on whichever face it was already on.
327fn build_side_walls<S: Scalar>(
328    part: &mut Part<S>,
329    names: &ExtrudeNames,
330    caps: &Caps<S>,
331    profile: &Profile<S>,
332    mut down_face_coedge: CoedgeId,
333) -> GeopResult<()> {
334    let curves = &profile.curves;
335    let n = curves.len();
336    let points = curves
337        .iter()
338        .map(start_point)
339        .collect::<GeopResult<Vec<_>>>()?;
340
341    for coedge_id in part
342        .topology()
343        .iterate_loop_coedges(down_face_coedge)
344        .collect::<Vec<_>>()
345    {
346        part.replace_pcurve(coedge_id, base_level_pcurve()?)
347            .with_context(with_context!(
348                "build_side_walls: replace_pcurve for coedge {coedge_id} failed"
349            ))?;
350    }
351
352    // The straight edge up from profile vertex `i`, with its pcurves on the
353    // walls before (`v = 1`) and after (`v = 0`) it.
354    let upwards = |part: &mut Part<S>, at: CoedgeId, i: usize| {
355        let top = caps.point(&points[i], S::ONE);
356        part.mve(
357            at,
358            line3(caps.point(&points[i], S::ZERO), top)?,
359            line2(
360                Vector2::from_array([S::ZERO, S::ZERO]),
361                Vector2::from_array([S::ONE, S::ZERO]),
362            )?,
363            line2(
364                Vector2::from_array([S::ONE, S::ONE]),
365                Vector2::from_array([S::ZERO, S::ONE]),
366            )?,
367            top,
368            names.joint(profile, i, "end"),
369            names.lateral_edge(profile, i),
370        )
371    };
372    // Close the wall of `curves[i]` along its top edge.
373    let close_wall = |part: &mut Part<S>, down: CoedgeId, up: CoedgeId, i: usize| {
374        part.mef(
375            down,
376            up,
377            caps.curve(&curves[i], S::ONE)?,
378            line2(
379                Vector2::from_array([S::ONE, S::ZERO]),
380                Vector2::from_array([S::ONE, S::ONE]),
381            )?,
382            caps.top_pcurve(&curves[i])?.reverse(),
383            caps.wall(&curves[i])?,
384            names.curve(profile, i, "end"),
385            names.side_face(profile, i),
386        )
387    };
388
389    let (_, mut coedge_down, mut coedge_up, _) = upwards(part, down_face_coedge, 0)
390        .with_context("build_side_walls: first upwards edge mve failed")?;
391
392    let mut prev_coedge_down = coedge_down;
393    let final_coedge_up = coedge_up;
394
395    for i in 1..n {
396        down_face_coedge = part.topology().get_coedge(down_face_coedge)?.prev;
397        (_, coedge_down, coedge_up, _) = upwards(part, down_face_coedge, i).with_context(
398            with_context!("build_side_walls: upwards edge mve failed (i={i})"),
399        )?;
400        close_wall(part, prev_coedge_down, coedge_up, i - 1).with_context(with_context!(
401            "build_side_walls: closing side face mef failed (i={i})"
402        ))?;
403        prev_coedge_down = coedge_down;
404    }
405
406    // The last wall closes against the very first upwards edge.
407    close_wall(part, prev_coedge_down, final_coedge_up, n - 1)
408        .with_context("build_side_walls: closing mef for the last side face failed")?;
409
410    Ok(())
411}
412
413/// Check that every loop is a closed chain of at least two clamped curves on
414/// `[0, 1]`, with a name for every curve and joint.
415fn validate_loop<S: Scalar>(profile: &Profile<S>) -> GeopResult<()> {
416    profile.check_names()?;
417    if !profile.is_closed() {
418        return Err(GeopError::new(
419            "extrude: every loop must be closed, but has a name for an end joint",
420        ));
421    }
422    let curves = &profile.curves;
423    if curves.len() < 2 {
424        return Err(GeopError::new(
425            "extrude: every loop needs at least 2 curves",
426        ));
427    }
428    for (i, curve) in curves.iter().enumerate() {
429        let (t0, t1) = curve.domain();
430        if !(t0.could_be_equal(S::ZERO) && t1.could_be_equal(S::ONE)) {
431            return Err(GeopError::new(format!(
432                "extrude: curve {i} has domain ({t0:?}, {t1:?}), not [0, 1]"
433            )));
434        }
435        let next = &curves[(i + 1) % curves.len()];
436        let (end, start) = (end_point(curve)?, start_point(next)?);
437        if !end.could_be_equal(&start) {
438            return Err(GeopError::new(format!(
439                "extrude: curve {i} ends at {end:?}, but the next one starts at {start:?}"
440            )));
441        }
442    }
443    Ok(())
444}
445
446/// Extrude `outer` (counter-clockwise) with `holes` (clockwise) one unit
447/// along `coordinate_system`'s `w`, which must be left-handed (see the module
448/// docs). Everything built is named after `outer`'s and the holes' curves
449/// and joints, see [`ExtrudeNames`].
450pub fn extrude<S: Scalar>(
451    part: &mut Part<S>,
452    names: &ExtrudeNames,
453    coordinate_system: &CoordinateSystem<S>,
454    outer: &Profile<S>,
455    holes: &[Profile<S>],
456) -> GeopResult<SolidId> {
457    // Not a closure capturing `part` directly: that would hold an immutable
458    // borrow of it alive for the whole function, conflicting with every
459    // mutable `part.mve`/`part.mef` call below. Each `.with_context(&|e|
460    // ctx(...))` call site instead builds a fresh, short-lived closure that
461    // only borrows `part` for that one statement.
462    fn ctx<S: Scalar>(
463        coordinate_system: &CoordinateSystem<S>,
464        outer: &Profile<S>,
465        holes: &[Profile<S>],
466        part: &Part<S>,
467        e: GeopError,
468    ) -> GeopError {
469        e.with_context(format!(
470            "extrude(
471    coordinate_system={coordinate_system}
472    outer={outer:?}
473    holes={holes:?}
474    model={}
475)",
476            part.topology()
477        ))
478    }
479
480    validate_loop(outer)?;
481    for hole in holes {
482        validate_loop(hole)?;
483    }
484    let caps = Caps::new(
485        coordinate_system,
486        outer
487            .curves
488            .iter()
489            .chain(holes.iter().flat_map(|h| h.curves.iter())),
490    )?;
491    let n = outer.curves.len();
492
493    // The placeholder face becomes the end cap, via `replace_face` at the
494    // very end.
495    let (start_vertex, any_face, solid_id) = part.mvfs(
496        caps.point(&start_point(&outer.curves[0])?, S::ZERO),
497        names.joint(outer, 0, "start"),
498        names.cap("end"),
499        names.solid.clone(),
500    )?;
501
502    // Grow the outer ring directly on the placeholder face (the same
503    // `grow_ring` helper each hole uses below).
504    let (first_base_face_coedge, down_face_coedge, cursor_coedge_out, _) =
505        grow_ring(part, names, any_face, start_vertex, &caps, outer)
506            .with_context("extrude: growing the outer ring failed")
507            .with_context(&|e| ctx(coordinate_system, outer, holes, part, e))?;
508
509    // last edge splits the bottom cap off, onto a new, real-surfaced face
510    let (_, bottom_face_id, _, _) = part
511        .mef(
512            cursor_coedge_out,
513            first_base_face_coedge,
514            caps.curve(&outer.curves[n - 1], S::ZERO)?,
515            caps.bottom_pcurve(&outer.curves[n - 1])?,
516            base_level_pcurve()?,
517            caps.bottom_surface()?,
518            names.curve(outer, n - 1, "start"),
519            names.cap("start"),
520        )
521        .with_context("extrude: closing mef for the bottom cap failed")
522        .with_context(&|e| ctx(coordinate_system, outer, holes, part, e))?;
523
524    // Grow every hole directly on the (now real) bottom cap, then move its
525    // mirror ring onto the placeholder face (alongside the outer ring's
526    // own), ready for `build_side_walls` — collecting the placeholder-side
527    // anchor for each so their side walls can be swept once every hole has
528    // been attached.
529    let mut hole_scaffolds: Vec<(&Profile<S>, CoedgeId)> = Vec::new();
530    for hole in holes {
531        let last = hole.curves.len() - 1;
532        let hv0 = part
533            .mvr(
534                bottom_face_id,
535                caps.point(&start_point(&hole.curves[0])?, S::ZERO),
536                names.joint(hole, 0, "start"),
537            )
538            .with_context("extrude: mvr for a hole's starting vertex failed")
539            .with_context(&|e| ctx(coordinate_system, outer, holes, part, e))?;
540        let (_, first_mirror, _, last_mirror) =
541            grow_ring(part, names, bottom_face_id, hv0, &caps, hole)
542                .with_context("extrude: growing a hole's ring failed")
543                .with_context(&|e| ctx(coordinate_system, outer, holes, part, e))?;
544
545        // Close the hole's ring by moving its mirror sub-chain onto the
546        // placeholder face — the complementary (forward) sub-chain, the
547        // hole's own proper boundary, stays behind on the bottom cap.
548        part.mer(
549            first_mirror,
550            last_mirror,
551            caps.curve(&hole.curves[last], S::ZERO)?.reverse(),
552            base_level_pcurve()?,
553            caps.bottom_pcurve(&hole.curves[last])?,
554            any_face,
555            names.curve(hole, last, "start"),
556        )
557        .with_context("extrude: mer for a hole's ring failed")
558        .with_context(&|e| ctx(coordinate_system, outer, holes, part, e))?;
559
560        hole_scaffolds.push((hole, first_mirror));
561    }
562
563    // Sweep the outer ring's own mirror chain, then each hole's, straight up.
564    build_side_walls(part, names, &caps, outer, down_face_coedge)
565        .with_context("extrude: building the outer ring's side walls failed")
566        .with_context(&|e| ctx(coordinate_system, outer, holes, part, e))?;
567    for (hole, hole_down_face_coedge) in hole_scaffolds {
568        build_side_walls(part, names, &caps, hole, hole_down_face_coedge)
569            .with_context("extrude: building a hole's side walls failed")
570            .with_context(&|e| ctx(coordinate_system, outer, holes, part, e))?;
571    }
572
573    // Now close the top face using a replace_face operation
574    part.replace_face(any_face, caps.top_surface()?)
575        .with_context("extrude: replace_face for the top cap failed")
576        .with_context(&|e| ctx(coordinate_system, outer, holes, part, e))?;
577
578    Ok(solid_id)
579}
580
581/// Extrude a profile drawn on a right-handed `plane` (`u x v` along its
582/// normal `w`) by `distance` along `w` — backwards for a negative distance.
583/// `outer` winds counter-clockwise and the holes clockwise, as seen in the
584/// plane's own `(u, v)`. The start cap lies on `plane`.
585///
586/// Translates to [`extrude`]'s left-handed contract: a negative distance
587/// already gives a left-handed `(u, v, distance w)`; a positive one mirrors
588/// the plane's `u` and `v` (and so every curve's `x` and `y`, then reverses
589/// the loops to restore their winding — the names travel with their curves
590/// and joints).
591pub fn extrude_from_plane<S: Scalar>(
592    part: &mut Part<S>,
593    names: &ExtrudeNames,
594    plane: &CoordinateSystem<S>,
595    outer: &Profile<S>,
596    holes: &[Profile<S>],
597    distance: S,
598) -> GeopResult<SolidId> {
599    let w = plane.w().prod_scalar(distance);
600    if distance.definitely_less(S::ZERO) {
601        let cs = CoordinateSystem::try_new(*plane.origin(), *plane.u(), *plane.v(), w)?;
602        extrude(part, names, &cs, outer, holes)
603    } else if distance.definitely_greater(S::ZERO) {
604        let cs = CoordinateSystem::try_new(*plane.origin(), *plane.v(), *plane.u(), w)?;
605        let mirror = |profile: &Profile<S>| profile.map_curves(|c| c.swap_xy()).reversed();
606        let holes: Vec<_> = holes.iter().map(mirror).collect();
607        extrude(part, names, &cs, &mirror(outer), &holes)
608    } else {
609        Err(GeopError::new(format!(
610            "extrude_from_plane: distance {distance:?} could be zero"
611        )))
612    }
613}
614
615#[cfg(test)]
616mod tests {
617    use super::*;
618    use crate::common::{arc2, polygon, sqrt2_over_2};
619    use geop_core_math::for_all_scalars;
620    use geop_core_topology::{
621        Model,
622        validation::{ValidationParameters, validate, validate_manifold},
623    };
624
625    fn v2<S: Scalar>(x: f64, y: f64) -> Vector2<S> {
626        Vector2::from_array([S::from_f64(x), S::from_f64(y)])
627    }
628
629    fn unit_square<S: Scalar>() -> Vec<NurbCurve2D<S>> {
630        polygon(&[v2(0.0, 0.0), v2(1.0, 0.0), v2(1.0, 1.0), v2(0.0, 1.0)]).unwrap()
631    }
632
633    /// A square hole (traced the *opposite* winding from `unit_square`, so
634    /// the material — the region between the outer boundary and this hole
635    /// — genuinely has the hole cut out of it), spanning `[x0, x0 + size] x
636    /// [y0, y0 + size]`.
637    fn square_hole<S: Scalar>(x0: f64, y0: f64, size: f64) -> Vec<NurbCurve2D<S>> {
638        polygon(&[
639            v2(x0, y0),
640            v2(x0, y0 + size),
641            v2(x0 + size, y0 + size),
642            v2(x0 + size, y0),
643        ])
644        .unwrap()
645    }
646
647    /// A circle of radius `r` around `(cx, cy)` as four quarter arcs,
648    /// counter-clockwise (reversed: clockwise, for a hole).
649    fn circle<S: Scalar>(cx: f64, cy: f64, r: f64, clockwise: bool) -> Vec<NurbCurve2D<S>> {
650        let q = [(r, 0.0), (0.0, r), (-r, 0.0), (0.0, -r)];
651        let mut arcs: Vec<NurbCurve2D<S>> = (0..4)
652            .map(|i| {
653                let (a, b) = (q[i], q[(i + 1) % 4]);
654                arc2(
655                    v2(cx + a.0, cy + a.1),
656                    v2(cx + a.0 + b.0, cy + a.1 + b.1),
657                    v2(cx + b.0, cy + b.1),
658                    sqrt2_over_2(),
659                )
660                .unwrap()
661            })
662            .collect();
663        if clockwise {
664            arcs = arcs.iter().rev().map(|c| c.reverse()).collect();
665        }
666        arcs
667    }
668
669    /// Left-handed (`u x v = -w`), which is what `extrude` requires of a CCW
670    /// outer polygon for the resulting faces to point outward — see the
671    /// module doc. `figure8_profile` builds its own the same way. A
672    /// right-handed basis here produces a solid that is entirely inside-out:
673    /// structurally perfect, and rejected by
674    /// `validation::face_orientation::check_normals_point_outward`.
675    fn axis_aligned_coordinate_system<S: Scalar>(origin: Vector3<S>) -> CoordinateSystem<S> {
676        let u = Vector3::from_array([S::ONE, S::ZERO, S::ZERO]);
677        let v = Vector3::from_array([S::ZERO, S::ONE, S::ZERO]);
678        let w = Vector3::from_array([S::ZERO, S::ZERO, S::from_f64(-1.0)]);
679        CoordinateSystem::try_new(origin, u, v, w).unwrap()
680    }
681
682    /// Extrude into a fresh part, as the single-region operation `e`.
683    fn extruded<S: Scalar>(
684        cs: &CoordinateSystem<S>,
685        outer: Vec<NurbCurve2D<S>>,
686        holes: Vec<Vec<NurbCurve2D<S>>>,
687    ) -> Part<S> {
688        let mut part = Part::<S>::new();
689        let namer = Namer::new("extrude", "e").unwrap();
690        let holes: Vec<_> = holes
691            .into_iter()
692            .enumerate()
693            .map(|(k, h)| Profile::closed(h).with_prefix(&format!("h{k}")))
694            .collect();
695        extrude(
696            &mut part,
697            &ExtrudeNames::single(&namer),
698            cs,
699            &Profile::closed(outer),
700            &holes,
701        )
702        .unwrap();
703        part.check_names().unwrap();
704        part
705    }
706
707    fn assert_valid<S: Scalar>(model: &Model<S>) {
708        let params = ValidationParameters::default();
709        if let Err(e) = validate(&params, model) {
710            panic!("{e:?}");
711        }
712        if let Err(e) = validate_manifold(&params, model) {
713            panic!("{e:?}");
714        }
715        for edge_id in model.edges.keys() {
716            assert_eq!(model.coedges_of_edge(*edge_id).len(), 2);
717        }
718    }
719
720    fn check_extruded_square_is_valid<S: Scalar>() {
721        let cs = axis_aligned_coordinate_system(Vector3::from_array([S::ZERO; 3]));
722        let part = extruded(&cs, unit_square::<S>(), vec![]);
723        let model = part.topology();
724        assert_valid(model);
725        assert_eq!(model.faces.len(), 6);
726        assert_eq!(model.vertices.len(), 8);
727        assert_eq!(model.edges.len(), 12);
728    }
729    #[test]
730    fn extruded_square_is_valid() {
731        for_all_scalars!(check_extruded_square_is_valid);
732    }
733
734    fn check_extruded_square_with_hole_is_valid<S: Scalar>() {
735        let hole = square_hole::<S>(0.25, 0.25, 0.5);
736        let cs = axis_aligned_coordinate_system(Vector3::from_array([S::ZERO; 3]));
737        let part = extruded(&cs, unit_square::<S>(), vec![hole]);
738        let model = part.topology();
739        assert_valid(model);
740        // 6 outer faces (4 sides + top + bottom) + 4 hole side walls; 8
741        // outer vertices + 8 hole vertices (4 on the bottom cap, 4 on top).
742        assert_eq!(model.faces.len(), 6 + 4);
743        assert_eq!(model.vertices.len(), 8 + 8);
744    }
745    #[test]
746    fn extruded_square_with_hole_is_valid() {
747        for_all_scalars!(check_extruded_square_with_hole_is_valid);
748    }
749
750    fn check_extrude_offsets_footprint<S: Scalar>() {
751        let origin = Vector3::from_array([S::from_f64(2.0), S::from_f64(-1.0), S::from_f64(0.5)]);
752        let cs = axis_aligned_coordinate_system(origin);
753        let part = extruded(&cs, unit_square::<S>(), vec![]);
754        let model = part.topology();
755        assert_valid(model);
756        // `w` points along `-z` (see `axis_aligned_coordinate_system`), so
757        // the profile sits at the origin's own `z` and the far face one unit
758        // *below* it.
759        for vertex in model.vertices.values() {
760            assert!(
761                vertex.point[2].could_be_equal(S::from_f64(0.5))
762                    || vertex.point[2].could_be_equal(S::from_f64(-0.5)),
763                "vertex at unexpected height: {:?}",
764                vertex.point
765            );
766        }
767    }
768    #[test]
769    fn extrude_offsets_footprint() {
770        for_all_scalars!(check_extrude_offsets_footprint);
771    }
772
773    /// A profile anywhere in the plane, not just the unit square: the caps
774    /// are sized to it.
775    fn check_extrude_profile_away_from_origin<S: Scalar>() {
776        let cs = axis_aligned_coordinate_system(Vector3::from_array([S::ZERO; 3]));
777        let outer = polygon(&[v2(-3.0, 2.0), v2(-1.0, 2.0), v2(-2.0, 5.0)]).unwrap();
778        let part = extruded(&cs, outer, vec![]);
779        let model = part.topology();
780        assert_valid(model);
781        assert_eq!(model.faces.len(), 5);
782    }
783    #[test]
784    fn extrude_profile_away_from_origin() {
785        for_all_scalars!(check_extrude_profile_away_from_origin);
786    }
787
788    /// A disc with a round hole: every wall is a quarter of a cylinder.
789    fn check_extruded_ring_is_valid<S: Scalar>() {
790        let cs = axis_aligned_coordinate_system(Vector3::from_array([S::ZERO; 3]));
791        let outer = circle::<S>(0.0, 0.0, 2.0, false);
792        let hole = circle::<S>(0.3, 0.0, 1.0, true);
793        let part = extruded(&cs, outer, vec![hole]);
794        let model = part.topology();
795        assert_valid(model);
796        assert_eq!(model.faces.len(), 2 + 4 + 4);
797    }
798    #[test]
799    fn extruded_ring_is_valid() {
800        for_all_scalars!(check_extruded_ring_is_valid);
801    }
802
803    /// Both directions off a right-handed plane give valid solids on the
804    /// expected side.
805    fn check_extrude_from_plane_both_directions<S: Scalar>() {
806        let plane = CoordinateSystem::try_new(
807            Vector3::from_array([S::ZERO, S::ZERO, S::ONE]),
808            Vector3::from_array([S::ONE, S::ZERO, S::ZERO]),
809            Vector3::from_array([S::ZERO, S::ONE, S::ZERO]),
810            Vector3::from_array([S::ZERO, S::ZERO, S::ONE]),
811        )
812        .unwrap();
813        let mut outer = polygon(&[v2(0.0, 0.0), v2(1.0, 0.0), v2(1.0, 1.0)]).unwrap();
814        outer.push(arc2(v2(1.0, 1.0), v2(0.0, 1.0), v2(0.0, 0.0), sqrt2_over_2()).unwrap());
815        outer.remove(2);
816        let namer = Namer::new("extrude", "e").unwrap();
817        let outer = Profile::closed(outer);
818        for (distance, far) in [(0.5, 1.5), (-0.5, 0.5)] {
819            let mut part = Part::<S>::new();
820            let names = ExtrudeNames::single(&namer);
821            extrude_from_plane(
822                &mut part,
823                &names,
824                &plane,
825                &outer,
826                &[],
827                S::from_f64(distance),
828            )
829            .unwrap();
830            part.check_names().unwrap();
831            let model = part.topology();
832            assert_valid(model);
833            assert!(
834                model
835                    .vertices
836                    .values()
837                    .any(|v| v.point[2].could_be_equal(S::from_f64(far)))
838            );
839            // Whichever way the profile had to be mirrored to extrude it,
840            // every name still sits where it says: `p0` at the profile's
841            // first point, the end cap `distance` away from the plane.
842            let p0_start = part.vertex_id("extrude(e,p0,start)").unwrap();
843            let p0_end = part.vertex_id("extrude(e,p0,end)").unwrap();
844            let point = |v| model.get_vertex(v).unwrap().point;
845            assert!(point(p0_start).could_be_equal(&Vector3::from_array([
846                S::ZERO,
847                S::ZERO,
848                S::ONE
849            ])));
850            assert!(point(p0_end)[2].could_be_equal(S::from_f64(far)));
851            let end_cap = part.face_id("extrude(e,end)").unwrap();
852            assert!(model.iterate_face_coedges(end_cap).all(|c| {
853                model.coedge_start_vertex(c).unwrap().point[2].could_be_equal(S::from_f64(far))
854            }));
855        }
856    }
857    #[test]
858    fn extrude_from_plane_both_directions() {
859        for_all_scalars!(check_extrude_from_plane_both_directions);
860    }
861}
862
863#[cfg(test)]
864mod naming_tests {
865    use super::*;
866    use crate::common::polygon;
867    use geop_core_math::scalars::ScalInF64 as S;
868
869    /// Every entity of an extruded square is named after the profile element
870    /// it was swept from, and the names hang together topologically: the
871    /// side face of `c0` is bounded by `c0`'s two cap edges and the lateral
872    /// edges of its two joints.
873    #[test]
874    fn extruded_square_names_follow_the_profile() {
875        let square = polygon(&[
876            Vector2::from_array([S::ZERO, S::ZERO]),
877            Vector2::from_array([S::ONE, S::ZERO]),
878            Vector2::from_array([S::ONE, S::ONE]),
879            Vector2::from_array([S::ZERO, S::ONE]),
880        ])
881        .unwrap();
882        let plane = CoordinateSystem::try_new(
883            Vector3::zero(),
884            Vector3::from_array([S::ONE, S::ZERO, S::ZERO]),
885            Vector3::from_array([S::ZERO, S::ONE, S::ZERO]),
886            Vector3::from_array([S::ZERO, S::ZERO, S::ONE]),
887        )
888        .unwrap();
889        let mut part = Part::<S>::new();
890        let namer = Namer::new("extrude", "box").unwrap();
891        extrude_from_plane(
892            &mut part,
893            &ExtrudeNames::single(&namer),
894            &plane,
895            &Profile::closed(square),
896            &[],
897            S::ONE,
898        )
899        .unwrap();
900        part.check_names().unwrap();
901
902        let description = geop_core_part::PartDescription::of(&part).unwrap();
903        assert_eq!(description.solids.len(), 1);
904        assert_eq!(description.solids["extrude(box)"][0].len(), 6);
905        let mut side = description.faces["extrude(box,c0)"].outer.clone();
906        side.sort();
907        let mut expected: Vec<String> = [
908            "extrude(box,c0,end)",
909            "extrude(box,c0,start)",
910            "extrude(box,p0)",
911            "extrude(box,p1)",
912        ]
913        .iter()
914        .map(|e| e.to_string())
915        .collect();
916        expected.sort();
917        let unsigned: Vec<String> = side.iter().map(|e| e[1..].to_string()).collect();
918        let mut unsigned = unsigned;
919        unsigned.sort();
920        assert_eq!(unsigned, expected);
921        assert_eq!(
922            description.edges["extrude(box,p1)"].start,
923            "extrude(box,p1,start)"
924        );
925        assert_eq!(
926            description.edges["extrude(box,p1)"].end,
927            "extrude(box,p1,end)"
928        );
929        let corner = part.vertex_id("extrude(box,p2,end)").unwrap();
930        assert!(
931            part.topology()
932                .get_vertex(corner)
933                .unwrap()
934                .point
935                .could_be_equal(&Vector3::from_array([S::ONE; 3]))
936        );
937    }
938}