Skip to main content

geop_ops_extrude_revolve/
extrude.rs

1//! [`Extrude`]: sweep a sketch's area along its plane's normal, and how a
2//! sketch becomes the named [`Profile`](geop_ops_model::Profile)s extrude and revolve sweep.
3
4use geop_core_editor::{
5    edit_form::{EditForm, FormState},
6    editable::{Context, OperationGroup, OperationInfo, OperationTier},
7    editing::{Number, Track, Unit},
8    part_operation,
9    part_operation::PartOperation,
10};
11use geop_core_geometry::nurb_surface::NurbSurface3D;
12use geop_core_math::{
13    geop_error::{GeopError, GeopResult, WithContext},
14    primitives::CoordinateSystem,
15    scalars::Scalar,
16    vector::{Vector2, Vector3, Vector4},
17    with_context,
18};
19use geop_core_program::{numeric::NumericInput, part::StepContext, reader::Reader};
20use geop_core_sketch::{Shape, Sketch, profile::curve_polylines};
21use geop_core_topology::{Body, build::BuiltBody};
22use geop_lib_shell::{rebuilt, room};
23use geop_ops_model::sketch::{PlacedSketch, SketchRef, shape_profiles};
24use geop_ops_model::{
25    BodyNames, Extent, ModelOperation, NamedModel, Namer, build_model, reach_past, read_model,
26};
27use serde::{Deserialize, Serialize};
28
29use geop_lib_sweep::{SweepLoop, extrude};
30
31use crate::sweeping::{Extents, Output, Sweeping, build_extents};
32
33/// Extrudes the areas of a sketch — each a lump, as a text's letters are —
34/// along the sketch plane's normal, into
35/// a solid named `extrude(E)` for the operation `E` — kept as a new body,
36/// or combined with another solid, see [`Combine`](geop_ops_model::Combine); the result is
37/// `extrude(E)` either way. Or, as faces ([`Output::Faces`]), sweeps the
38/// sketch's curves into faces standing on their own — the area's outline,
39/// or a chain of curves enclosing nothing.
40///
41/// How far it goes is an [`Extents`]: a length, or up to the next face of
42/// the solid it is joined to or cut from, on one side of the plane, both
43/// alike, or each its own way. With a draft angle its walls lean in away
44/// from the plane, each by the angle (see [`drafted`]); both ways from the
45/// plane, a ridge there.
46///
47/// The faces, edges and vertices are named after the sketch elements they
48/// are swept from (see [`geop_lib_sweep::extrude`]), with `X` a piece of a
49/// sketch curve (`c3`, or `c3#1` for the second piece of an arc or circle
50/// split into several) and `P` a joint (`p2` for a sketch point, `c3@1`
51/// where a curve was split) of the sketch `K`:
52///
53/// - `extrude(E,start)` / `extrude(E,end)`: the caps, at either end;
54/// - `extrude(E,K,X)`: the side face swept by `X`; `extrude(E,K,X,start)` /
55///   `extrude(E,K,X,end)` its edges on the two caps;
56/// - `extrude(E,K,P)`: the edge swept by `P`; `extrude(E,K,P,start)` /
57///   `extrude(E,K,P,end)` its vertices.
58///
59/// A side going up to the next face is built on its own, from the sketch's
60/// plane — its caps `start` on the plane — and the second of two sides
61/// built so is named within `side2`: `extrude(E,side2,K,X)`.
62#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
63pub struct Extrude;
64
65#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
66pub struct ExtrudeArgs {
67    /// The sketch to extrude.
68    pub sketch: SketchRef,
69    /// How far, along the sketch plane's normal — backwards for a negative
70    /// length.
71    pub extent: Extents,
72    /// A solid, kept as a new body or combined with another, or faces.
73    #[serde(default)]
74    pub output: Output,
75    /// The draft angle of the walls, in degrees: positive narrows the
76    /// area away from the sketch's plane, on either side of it, negative
77    /// widens it (see [`drafted`]).
78    #[serde(default, skip_serializing_if = "is_zero")]
79    pub draft: f64,
80}
81
82fn is_zero(angle: &f64) -> bool {
83    *angle == 0.0
84}
85
86/// Where a side's length is dragged: at the centre of the sketch, that far
87/// along the sketch plane's normal — backwards for the second side. None
88/// while the sketch cannot be found.
89fn distance_handle(
90    reader: &Reader,
91    args: &ExtrudeArgs,
92    length: f64,
93    second: bool,
94) -> Option<Track> {
95    let placed = reader.retrieve_element(&args.sketch).ok()?;
96    let plane = &placed.plane;
97    let center = plane.uv_to_xyz(&sketch_center(&placed.sketch)?);
98    // A symmetric extrude goes half its length either way; the second side
99    // the other way.
100    let scale = match (args.extent.symmetric, second) {
101        (true, _) => 0.5,
102        (false, false) => 1.0,
103        (false, true) => -1.0,
104    };
105    let normal = plane.w();
106    Some(Track {
107        at: center.add(&normal.prod_scalar(Scalar::from_f64(length * scale))),
108        direction: normal.prod_scalar(Scalar::from_f64(scale)),
109    })
110}
111
112part_operation!(Extrude(ExtrudeArgs));
113
114impl PartOperation for Extrude {
115    const KIND: &'static str = "extrude";
116    const INFO: OperationInfo = OperationInfo::new(
117        "Extrude",
118        "Sweep a sketch's area along its plane's normal into a solid, or its curves into faces.",
119        OperationGroup::Solid,
120        OperationTier::Big,
121    )
122    .shortcut("E")
123    .icon(crate::icons::EXTRUDE);
124    type Args = ExtrudeArgs;
125    type Session = ();
126
127    /// The newest sketch, 10 up — a size that reads in millimetres —
128    /// joined to the newest solid if there is one.
129    fn new_args(context: &Context) -> ExtrudeArgs {
130        let before = &context.before;
131        let newest = before.targets_of::<PlacedSketch>().pop();
132        ExtrudeArgs {
133            sketch: SketchRef::whole(newest.unwrap_or_else(|| geop_ops_model::sketch::sketch(""))),
134            extent: Extents::blind(10.0),
135            output: Output::new_for(&read_model(before).unwrap_or_default()),
136            draft: 0.0,
137        }
138    }
139
140    fn run(ctx: &mut StepContext, args: &ExtrudeArgs) -> GeopResult<()> {
141        let _step = ctx.step();
142        build_model::<Self>(ctx, args)
143    }
144
145    /// The sketch, picked; how far, each length typed or dragged as a
146    /// handle; whether a face; and how to combine. A length typed or
147    /// dragged across the sketch plane turns a join into a cut, and back
148    /// (see [`Combine::follow_sign`](geop_ops_model::Combine::follow_sign));
149    /// a formula is followed, not steered by.
150    fn form<'a>(
151        context: &'a Context<'a>,
152        args: &ExtrudeArgs,
153        _: &FormState<()>,
154    ) -> EditForm<'a, ExtrudeArgs> {
155        let before = &context.before;
156        let mut f = EditForm::<ExtrudeArgs>::default();
157        f.pick_one(
158            "sketch",
159            "sketch",
160            before,
161            Some(&args.sketch),
162            |edit, sketch| edit.args.sketch = sketch,
163        );
164        let amount = |length: &NumericInput, second| {
165            let label = if second { "distance 2" } else { "distance" };
166            let number = Number::numeric(length, label, before, Unit::Length);
167            let at = distance_handle(before, args, number.value, second);
168            number.range(-10.0, 10.0).handle(at)
169        };
170        args.extent.fields(
171            &mut f,
172            ("distance", 1.0),
173            amount,
174            |args| &mut args.extent,
175            |edit, length| {
176                let to = length.plain();
177                let from = std::mem::replace(&mut edit.args.extent.side1, Extent::Blind(length));
178                if let (Extent::Blind(NumericInput::Plain(from)), Some(to)) = (from, to)
179                    && let Output::Solid(combine) = &mut edit.args.output
180                {
181                    combine.follow_sign(from, to);
182                }
183            },
184        );
185        let draft = Number::new("draft", args.draft, Unit::Angle).range(-45.0, 45.0);
186        f.number("draft", draft, |edit, v| edit.args.draft = v);
187        let reaches = args.extent.reaches_target();
188        let output = |args: &mut ExtrudeArgs, output| args.output = output;
189        (args.output).fields(&mut f, before, reaches, output);
190        f
191    }
192}
193
194impl ModelOperation for Extrude {
195    fn build(
196        model: &mut NamedModel,
197        reader: &Reader,
198        step: &str,
199        args: &ExtrudeArgs,
200    ) -> GeopResult<()> {
201        let namer = Namer::new("extrude", step)?;
202        let placed = reader.retrieve_element(&args.sketch).concerning("sketch")?;
203        let sketch = &placed.sketch;
204        let geometry = sketch.enclose()?;
205        let shape = args.output.shape(sketch)?;
206        args.extent.sweeps(&shape)?;
207        let slope = match args.draft {
208            0.0 => None,
209            angle => Some(draft_slope(angle, &args.output, &shape).concerning("draft")?),
210        };
211        let loops: Vec<_> = (shape_profiles(&placed.name, sketch, &geometry, shape)?.into_iter())
212            .map(SweepLoop::plain)
213            .collect();
214        let extruding = Extruding {
215            plane: &placed.plane,
216            loops: &loops,
217            slope,
218        };
219        let how = (&args.extent, &args.output);
220        build_extents(model, reader, &namer, step, how, &extruding)
221    }
222}
223
224/// The tangent of the draft `angle`, in degrees, by which a wall leans in
225/// per unit of height: refused, saying why, where `output` and `shape`
226/// are no solid with an area to narrow, or the angle no lean.
227fn draft_slope(angle: f64, output: &Output, shape: &Shape) -> GeopResult<Scalar> {
228    if matches!(shape, Shape::Chain(_)) {
229        return Err(GeopError::new(
230            "a draft narrows an area: an open chain of curves encloses none",
231        ));
232    }
233    if matches!(output, Output::Faces { .. }) {
234        return Err(GeopError::new(
235            "a draft tapers a solid's walls between its caps: extrude faces without one",
236        ));
237    }
238    if angle.is_nan() || angle.abs() >= 90.0 {
239        return Err(GeopError::new(format!(
240            "a draft of {angle} degrees: walls drafted by less than 90 degrees only"
241        )));
242    }
243    let radians = Scalar::from_f64(angle).degrees_to_radians()?;
244    radians.sin().div(radians.cos())
245}
246
247/// An extrude's loops, swept along the normal of their sketch's `plane`
248/// (see [`Sweeping`]): its measure is the distance from the plane.
249struct Extruding<'a> {
250    plane: &'a CoordinateSystem,
251    loops: &'a [SweepLoop],
252    /// How far its walls lean in per unit of height, if it is drafted (see
253    /// [`drafted`]).
254    slope: Option<Scalar>,
255}
256
257impl Sweeping for Extruding<'_> {
258    fn loops(&self) -> &[SweepLoop] {
259        self.loops
260    }
261
262    fn sweep(
263        &self,
264        model: &mut NamedModel,
265        namer: &Namer,
266        name: Option<&str>,
267        from: f64,
268        to: f64,
269    ) -> GeopResult<BuiltBody> {
270        let s = Scalar::from_f64;
271        // Drafted through the sketch's plane, the walls lean in away from
272        // it either way: a ridge there.
273        let heights = match self.slope {
274            Some(_) if from * to < 0.0 => vec![s(from), Scalar::ZERO, s(to)],
275            _ => vec![s(from), s(to)],
276        };
277        let built = extrude(model, namer, name, self.plane, &heights, self.loops)?;
278        match self.slope {
279            Some(slope) => drafted(model, namer, &built, self.plane, slope),
280            None => Ok(built),
281        }
282    }
283
284    fn past(&self, hull: &[Vector3], sign: f64) -> GeopResult<f64> {
285        reach_past(hull, self.plane, sign)
286    }
287
288    fn ends(&self, _: f64) -> GeopResult<(String, String)> {
289        Ok(("start".into(), "end".into()))
290    }
291
292    fn all_the_way(&self, far: f64) -> f64 {
293        far
294    }
295
296    fn full_turn(&self) -> Option<f64> {
297        None
298    }
299
300    fn measure(&self, sign: f64, p: &Vector3) -> Option<f64> {
301        let along = (p.sub(self.plane.origin()))
302            .prod_dot(self.plane.w())
303            .mul(Scalar::from_f64(sign));
304        along
305            .definitely_greater(Scalar::ZERO)
306            .then(|| along.to_f64())
307    }
308}
309
310/// The solid `built`, extruded straight along the normal of `plane` by
311/// `namer`, drafted: every wall leaning in by `slope` per unit of its
312/// distance from the plane, on either side of it — the outer loop's walls
313/// in, the holes' out — each on its own surface offset by that distance
314/// times `slope` (see [`drafted_wall`]): a line's wall a plane at the
315/// draft angle, an arc's a cone about the arc's axis. The caps stay where
316/// they are, and the solid is rebuilt around the drafted walls
317/// ([`rebuilt`]): each corner where the walls and a cap now meet, each edge
318/// where its two faces do — a hyperbola where an arc's cone meets a line's
319/// plane at a sharp corner. Every entity keeps its name.
320///
321/// Refused, naming the face, where a wall is drafted past its axis — an
322/// arc narrower than the draft narrows it — and, naming the edge, where
323/// the draft narrows a wall away between its neighbours.
324fn drafted(
325    model: &mut NamedModel,
326    namer: &Namer,
327    built: &BuiltBody,
328    plane: &CoordinateSystem,
329    slope: Scalar,
330) -> GeopResult<BuiltBody> {
331    let ctx = with_context!("drafted({}, slope={slope:?})", namer.root());
332    let solid = built
333        .solid
334        .ok_or_else(|| GeopError::new("only a solid is drafted"))
335        .with_context(ctx)?;
336    let (mut spec, sources) = model
337        .topology()
338        .body_spec(&built.faces, true)
339        .with_context(ctx)?;
340    let names = BodyNames {
341        solid: Some(model.name(solid)?.to_string()),
342        ..model.source_names(&sources)?
343    };
344    let caps = [namer.name(&["start"]), namer.name(&["end"])];
345    let walls: Vec<bool> = names.faces.iter().map(|f| !caps.contains(f)).collect();
346    for ((face, name), &wall) in spec.faces.iter_mut().zip(&names.faces).zip(&walls) {
347        if wall {
348            face.surface = drafted_wall(&face.surface, plane, slope).map_err(|e| {
349                GeopError::new(format!(
350                    "the draft cannot lean face {name} in that far: {}",
351                    e.root_message()
352                ))
353            })?;
354        }
355    }
356    let reseat = vec![false; spec.vertices.len()];
357    let spec = rebuilt(&spec, &names, &walls, &reseat).with_context(ctx)?;
358    model
359        .delete_bodies(&[Body::Solid(solid)])
360        .with_context(ctx)?;
361    model.build_body(spec, names).with_context(ctx)
362}
363
364/// `wall`, a surface standing square to `plane` on one side of it, with
365/// its rows of control points each at one height, leant in by `slope` per
366/// unit of height away from the plane: given room first ([`room`], which
367/// the rebuild asks of a new surface — a corner leant out moves past where
368/// the wall ended), each control point where the wall's offset against its
369/// normal by its height times `slope` has it ([`NurbSurface3D::offset`],
370/// which keeps the parametrization), the height counted the way the wall
371/// lies from the plane, so the room on the other side carries the drafted
372/// wall on. The rows' offsets are what the wall is at their heights, and
373/// between them it is ruled, so the result is the drafted wall exactly
374/// wherever the offset is exact.
375fn drafted_wall(
376    wall: &NurbSurface3D,
377    plane: &CoordinateSystem,
378    slope: Scalar,
379) -> GeopResult<NurbSurface3D> {
380    let height = |point: &Vector4| -> GeopResult<Scalar> {
381        Ok(point
382            .dehomogenized()?
383            .sub(plane.origin())
384            .prod_dot(plane.w()))
385    };
386    let mut side = None;
387    for point in &wall.control_points {
388        side = side.or(height(point)?.definite_sign());
389    }
390    let slope = match side {
391        Some(true) => slope,
392        Some(false) => slope.neg(),
393        None => return Err(GeopError::new("a wall of no height")),
394    };
395    let wall = room(wall.clone())?;
396    let mut drafted = wall.clone();
397    for (k, point) in drafted.control_points.iter_mut().enumerate() {
398        let height = height(point)?;
399        if height.is_zero() {
400            continue;
401        }
402        // Where the offset has it, at the weight it had: offsetting a wall
403        // square to the plane keeps its weights, and a row's weights kept
404        // the same numbers keep the wall a polynomial along its height.
405        let weight = point[3];
406        let offset = wall
407            .offset(height.mul(slope).neg())?
408            .ok_or_else(|| GeopError::new("the draft makes a wall collapse at this height"))?;
409        // The offset is on the wall's own net where it can be (a plane's, a
410        // cylinder's); a free-form wall's is fitted on a finer one, and has
411        // no control point of the wall's to take.
412        if (offset.degree_u, offset.degree_v, offset.num_u, offset.num_v)
413            != (wall.degree_u, wall.degree_v, wall.num_u, wall.num_v)
414        {
415            return Err(GeopError::new(
416                "a wall whose offset is not on its own net, a free-form one, is not drafted",
417            ));
418        }
419        let moved = offset.control_points[k];
420        let at = moved.dehomogenized()?.prod_scalar(weight);
421        *point = Vector4::from_array([at[0], at[1], at[2], weight]);
422    }
423    drafted.recompute_aabb();
424    Ok(drafted)
425}
426
427/// The centre of the box around what `sketch` draws — its profile curves,
428/// or its points if it has none — in sketch coordinates.
429fn sketch_center(sketch: &Sketch) -> Option<Vector2> {
430    let mut drawn: Vec<Vector2> = sketch
431        .curves
432        .iter()
433        .filter(|(_, c)| !c.construction)
434        .flat_map(|(&id, _)| curve_polylines(sketch, id).unwrap_or_default())
435        .flatten()
436        .collect();
437    if drawn.is_empty() {
438        drawn = sketch.positions().into_values().collect();
439    }
440    let hull = drawn.into_iter().reduce(|a, b| a.union(&b))?;
441    // The middle of the box is a free choice: sharp.
442    Some(hull.map(Scalar::midpoint))
443}