Skip to main content

geop_ops_parts/operation/
extrude.rs

1//! [`Extrude`]: sweep a sketch's regions into a solid, and how sketch
2//! profiles become the named [`Profile`]s extrude and revolve sweep.
3
4use geop_core_math::{
5    geop_error::{GeopError, GeopResult, WithContext},
6    primitives::CoordinateSystem,
7    scalars::Scalar,
8    vector::{Vector2, Vector3},
9    with_context,
10};
11use geop_core_part::{Namer, Part};
12use geop_core_sketch::{ProfilePiece, Sketch, profile::curve_polyline};
13use geop_ops_extrude_revolve::{
14    common::Profile,
15    extrude::{ExtrudeNames, extrude_from_plane},
16};
17use geop_ops_parts_derive::OperationArgs;
18use serde::{Deserialize, Serialize};
19
20use super::{Combine, Handle, HandleGroup, HandleMotion, Operation, arg_path};
21
22/// Extrudes every region of a sketch along the sketch plane's normal, into
23/// one solid named `extrude(E)` for the operation `E`.
24///
25/// The faces, edges and vertices are named after the sketch elements they
26/// are swept from (see `geop_ops_extrude_revolve::extrude::ExtrudeNames`),
27/// with `X` a piece of a sketch curve (`c3`, or `c3#1` for the second piece
28/// of an arc or circle split into several) and `P` a joint (`p2` for a
29/// sketch point, `c3@1` where a curve was split) of the sketch `K`:
30///
31/// - `extrude(E,start)` / `extrude(E,end)`: the caps, on the sketch plane and
32///   `distance` away from it.
33/// - `extrude(E,K,X)`: the side face swept by `X`; `extrude(E,K,X,start)` /
34///   `extrude(E,K,X,end)` its edges on the two caps.
35/// - `extrude(E,K,P)`: the edge swept by `P`; `extrude(E,K,P,start)` /
36///   `extrude(E,K,P,end)` its vertices.
37///
38/// With [`ExtrudeArgs::combine`], the solid can instead be combined with
39/// another one, see [`Combine`] — the result is `extrude(E)` either way.
40///
41/// A sketch of several regions gets one pair of caps per region,
42/// `extrude(E,start,K,c)` / `extrude(E,end,K,c)` with `c` the lowest curve id
43/// on the region's outer boundary. The regions must not share a curve or a
44/// point — every element names what is swept from it, so each can only be
45/// swept once.
46#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
47pub struct Extrude;
48
49#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, OperationArgs)]
50pub struct ExtrudeArgs {
51    /// The sketch to extrude.
52    #[arg(Sketch)]
53    pub sketch: String,
54    /// How far, along the sketch plane's normal; backwards if negative.
55    #[arg(Number { default: 1.0, min: -10.0, max: 10.0 })]
56    pub distance: f64,
57    /// Centre the solid on the sketch plane: extrude half the distance to
58    /// either side.
59    #[serde(default)]
60    #[arg(Bool { default: false })]
61    pub symmetric: bool,
62    /// Keep the solid as a new body, or combine it with another solid.
63    #[serde(default)]
64    #[arg(Combine { sign: Some("distance") })]
65    pub combine: Combine,
66}
67
68impl<S: Scalar> Operation<S> for Extrude {
69    type Args = ExtrudeArgs;
70
71    fn apply(
72        &self,
73        mut part: Part<S>,
74        operation_id: &str,
75        args: &ExtrudeArgs,
76    ) -> GeopResult<Part<S>> {
77        let ctx = with_context!("extrude({operation_id}, {args:?})");
78        let namer = Namer::new("extrude", operation_id)?;
79        let placed = part
80            .sketch(part.sketch_id(&args.sketch).with_context(ctx)?)?
81            .clone();
82        let sketch = &placed.sketch;
83        let distance = S::from_f64(args.distance);
84        let plane = if args.symmetric {
85            let half = distance.div(S::TWO)?;
86            let origin = placed
87                .plane
88                .origin()
89                .sub(&placed.plane.w().prod_scalar(half));
90            CoordinateSystem::try_new(
91                origin,
92                *placed.plane.u(),
93                *placed.plane.v(),
94                *placed.plane.w(),
95            )?
96        } else {
97            placed.plane.clone()
98        };
99
100        let positions = sketch.positions();
101        let regions = sketch.regions().with_context(ctx)?;
102        let mut solid = None;
103        for region in &regions {
104            let outer_pieces = region.outer.to_nurbs::<S>(sketch, &positions)?;
105            let region_name = (regions.len() > 1).then(|| {
106                let lowest = outer_pieces.iter().map(|p| p.source).min();
107                format!("{},{}", args.sketch, lowest.expect("a loop has pieces"))
108            });
109            let outer = sketch_profile(&args.sketch, outer_pieces, true);
110            let holes = region
111                .holes
112                .iter()
113                .map(|h| {
114                    Ok(sketch_profile(
115                        &args.sketch,
116                        h.to_nurbs(sketch, &positions)?,
117                        true,
118                    ))
119                })
120                .collect::<GeopResult<Vec<_>>>()?;
121            let names = ExtrudeNames {
122                namer: &namer,
123                region: region_name.as_deref(),
124                // Every region after the first is merged into the first, so
125                // its own solid name only exists until then.
126                solid: match (&solid, &region_name) {
127                    (Some(_), Some(region)) => namer.name(&["solid", region]),
128                    _ => args.combine.built_name(&namer),
129                },
130            };
131            let built = extrude_from_plane(&mut part, &names, &plane, &outer, &holes, distance)
132                .with_context(ctx)
133                .with_context(with_context!(
134                    "(a sketch's regions are extruded into one solid and must not share curves or points)"
135                ))?;
136            match solid {
137                None => solid = Some(built),
138                Some(first) => part.merge_solids(first, built)?,
139            }
140        }
141        let Some(built) = solid else {
142            return Err(GeopError::new("sketch has no region to extrude")).with_context(ctx);
143        };
144        args.combine
145            .apply(&mut part, &namer, operation_id, built)
146            .with_context(ctx)?;
147        Ok(part)
148    }
149
150    /// The distance, as a handle at the centre of the end cap that slides
151    /// along the sketch plane's normal.
152    fn handles(&self, before: &Part<S>, args: &ExtrudeArgs) -> GeopResult<Vec<Handle>> {
153        let placed = before.sketch(before.sketch_id(&args.sketch)?)?;
154        let Some(center) = sketch_center(&placed.sketch) else {
155            return Ok(Vec::new());
156        };
157        let plane = &placed.plane;
158        let at = plane.uv_to_xyz(&Vector2::from_array(center.map(S::from_f64)));
159        let normal = to_f64(plane.w());
160        // A symmetric extrude's end cap is half the distance off the plane.
161        let scale = if args.symmetric { 0.5 } else { 1.0 };
162        let offset = args.distance * scale;
163        let at = to_f64(&at);
164        Ok(vec![Handle {
165            label: "distance".into(),
166            group: HandleGroup::Feature,
167            position: [0, 1, 2].map(|k| at[k] + normal[k] * offset),
168            motion: HandleMotion::Linear {
169                direction: normal,
170                arg: arg_path(&["distance"]),
171                value: args.distance,
172                scale,
173            },
174        }])
175    }
176}
177
178/// The pieces of a sketch loop or chain as a [`Profile`] named after the
179/// sketch `sketch` and its elements: `sketch,c3` for a piece, `sketch,p2`
180/// for a joint. An open chain also names its end joint.
181pub(crate) fn sketch_profile<S: Scalar>(
182    sketch: &str,
183    pieces: Vec<ProfilePiece<S>>,
184    closed: bool,
185) -> Profile<S> {
186    let curve_names = pieces
187        .iter()
188        .map(|p| format!("{sketch},{}", p.name()))
189        .collect();
190    let mut joint_names: Vec<String> = pieces
191        .iter()
192        .map(|p| format!("{sketch},{}", p.start))
193        .collect();
194    if !closed && let Some(last) = pieces.last() {
195        joint_names.push(format!("{sketch},{}", last.end));
196    }
197    Profile {
198        curves: pieces.into_iter().map(|p| p.curve).collect(),
199        curve_names,
200        joint_names,
201    }
202}
203
204/// The centre of the box around what `sketch` draws — its profile curves,
205/// or its points if it has none — in sketch coordinates.
206fn sketch_center(sketch: &Sketch) -> Option<[f64; 2]> {
207    let positions = sketch.positions();
208    let mut drawn: Vec<[f64; 2]> = sketch
209        .curves
210        .iter()
211        .filter(|(_, c)| !c.construction)
212        .flat_map(|(&id, _)| curve_polyline(sketch, &positions, id))
213        .collect();
214    if drawn.is_empty() {
215        drawn = positions.into_values().collect();
216    }
217    let (lo, hi) = drawn.iter().fold(
218        ([f64::INFINITY; 2], [f64::NEG_INFINITY; 2]),
219        |(lo, hi), p| {
220            (
221                [lo[0].min(p[0]), lo[1].min(p[1])],
222                [hi[0].max(p[0]), hi[1].max(p[1])],
223            )
224        },
225    );
226    (!drawn.is_empty()).then(|| [(lo[0] + hi[0]) / 2.0, (lo[1] + hi[1]) / 2.0])
227}
228
229/// `v` in plain `f64`, for a handle.
230pub(crate) fn to_f64<S: Scalar>(v: &Vector3<S>) -> [f64; 3] {
231    [v[0].to_f64(), v[1].to_f64(), v[2].to_f64()]
232}