Skip to main content

geop_ops_plastic/
operation.rs

1//! The plastic features as operations of a program: [`Rib`], [`Lip`],
2//! [`Groove`] and [`Draft`].
3
4use geop_core_editor::{
5    edit_form::{EditForm, FormState, choices, chosen},
6    editable::{Context, OperationGroup, OperationInfo, OperationTier},
7    editing::{Number, Unit},
8    part_operation,
9    part_operation::PartOperation,
10};
11use geop_core_geometry::shape::Plane;
12use geop_core_math::{
13    geop_error::{GeopError, GeopResult, WithContext},
14    scalars::Scalar,
15};
16use geop_core_program::{element::ElementReference, part::StepContext, reader::Reader};
17use geop_core_sketch::Shape;
18use geop_core_topology::{EdgeId, FaceId};
19use geop_ops_model::sketch::shape_profiles;
20use geop_ops_model::sketch::{PlacedSketch, SketchPlane, SketchRef};
21use geop_ops_model::{
22    Edge, Face, ModelOperation, NamedModel, Namer, build_model, model, read_model,
23};
24use geop_ops_model::{SolidRef, solid};
25use serde::{Deserialize, Serialize};
26
27use crate::{
28    draft::draft,
29    lip::{LipSize, groove, lip},
30    rib::{Growth, rib},
31};
32
33/// How the plastic features are offered: rarely used, and not yet robust
34/// enough to offer by default.
35const fn info(label: &'static str, doc: &'static str) -> OperationInfo {
36    OperationInfo::new(label, doc, OperationGroup::Features, OperationTier::Menu).experimental()
37}
38
39/// A face of the model, by name.
40type FaceRef = ElementReference<NamedModel, Face>;
41
42/// An edge of the model, by name.
43type EdgeRef = ElementReference<NamedModel, Edge>;
44
45/// Fails unless a size typed in is a number.
46fn finite(what: &str, value: f64) -> GeopResult<()> {
47    if value.is_finite() {
48        Ok(())
49    } else {
50        Err(GeopError::new(format!("the {what} is not a number")))
51    }
52}
53
54/// Tilts the planar faces `faces` of a solid by `angle` degrees about the
55/// plane `neutral`, so the part comes out of a mould pulled along the
56/// neutral plane's normal — into the solid, for a face of it picked as the
57/// neutral plane — or the other way if `reversed`: past the neutral plane,
58/// that way, the solid gets narrower. The solid is consumed and the result
59/// named `draft(D)` for the operation `D`; every face, edge and vertex keeps
60/// its name (see [`crate::draft::draft`]).
61#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
62pub struct Draft;
63
64#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
65pub struct DraftArgs {
66    /// The faces to tilt, all planar, all of one solid.
67    pub faces: Vec<FaceRef>,
68    /// The plane they tilt about: a planar face, a datum plane, a sketch's
69    /// plane.
70    pub neutral: Option<SketchPlane>,
71    /// How far they tilt, in degrees.
72    pub angle: f64,
73    /// Pull the other way.
74    #[serde(default)]
75    pub reversed: bool,
76}
77
78part_operation!(
79    Draft(DraftArgs),
80    Lip(LipArgs),
81    Groove(GrooveArgs),
82    Rib(RibArgs)
83);
84
85impl PartOperation for Draft {
86    const KIND: &'static str = "draft";
87    const INFO: OperationInfo = info(
88        "Draft",
89        "Tilt planar faces about a neutral plane, so the part comes out of its mould.",
90    )
91    .icon(crate::icons::DRAFT);
92    type Args = DraftArgs;
93    type Session = ();
94
95    /// No faces yet, no neutral plane, and three degrees.
96    fn new_args(_: &Context) -> DraftArgs {
97        DraftArgs {
98            faces: Vec::new(),
99            neutral: None,
100            angle: 3.0,
101            reversed: false,
102        }
103    }
104
105    fn run(ctx: &mut StepContext, args: &DraftArgs) -> GeopResult<()> {
106        let _step = ctx.step();
107        build_model::<Self>(ctx, args)
108    }
109
110    /// The faces and the neutral plane, picked, the angle and the pull
111    /// direction.
112    fn form<'a>(
113        context: &'a Context<'a>,
114        args: &DraftArgs,
115        _: &FormState<()>,
116    ) -> EditForm<'a, DraftArgs> {
117        let before = &context.before;
118        let mut f = EditForm::<DraftArgs>::default();
119        f.pick(
120            "faces",
121            "faces",
122            before,
123            &args.faces,
124            true,
125            |edit, faces| edit.args.faces = faces,
126        );
127        f.pick_one(
128            "neutral",
129            "neutral plane",
130            before,
131            args.neutral.as_ref(),
132            |edit, neutral| edit.args.neutral = Some(neutral),
133        );
134        let angle = Number::new("angle", args.angle, Unit::Angle).range(-45.0, 45.0);
135        f.number("angle", angle, |edit, angle| edit.args.angle = angle);
136        f.checkbox(
137            "reversed",
138            "reverse pull direction",
139            args.reversed,
140            |edit, b| edit.args.reversed = b,
141        );
142        f
143    }
144}
145
146impl ModelOperation for Draft {
147    fn build(
148        model: &mut NamedModel,
149        reader: &Reader,
150        step: &str,
151        args: &DraftArgs,
152    ) -> GeopResult<()> {
153        let namer = Namer::new("draft", step)?;
154        let faces = (args.faces.iter())
155            .map(|face| model.face_id(face.name()))
156            .collect::<GeopResult<Vec<_>>>()?;
157        let Some(neutral) = &args.neutral else {
158            return Err(GeopError::new("pick the neutral plane"));
159        };
160        let frame = neutral.resolve(reader)?;
161        // A face's frame points out of its solid; the pull goes into it.
162        let into_solid = matches!(neutral, SketchPlane::Face(_));
163        let pull = if into_solid != args.reversed {
164            frame.w().neg()
165        } else {
166            *frame.w()
167        };
168        let plane = Plane::try_new(*frame.origin(), pull)?;
169        finite("angle", args.angle)?;
170        let angle = Scalar::from_f64(args.angle.to_radians());
171        draft(model, &namer, &faces, &plane, angle)?;
172        Ok(())
173    }
174}
175
176/// The fields of the rim face `face` and its edges `edges`, of a lip or a
177/// groove whose arguments `rim` says where they are held, in the part
178/// `before` the step: the edges picked go with the face they were picked
179/// on.
180fn rim_fields<'a, A: 'a>(
181    f: &mut EditForm<'a, A>,
182    before: &Reader<'a>,
183    face: &FaceRef,
184    edges: &[EdgeRef],
185    rim: fn(&mut A) -> (&mut FaceRef, &mut Vec<EdgeRef>),
186) {
187    f.pick_one(
188        "face",
189        "rim face",
190        before,
191        Some(face),
192        move |edit, picked| {
193            let (face, edges) = rim(edit.args);
194            if picked != *face {
195                edges.clear();
196            }
197            *face = picked;
198        },
199    );
200    f.pick(
201        "edges",
202        "edges",
203        before,
204        edges,
205        true,
206        move |edit, picked| *rim(edit.args).1 = picked,
207    );
208    f.form.dialog.optional("edges");
209}
210
211/// The rim face and edges, resolved.
212fn resolve_rim(
213    model: &NamedModel,
214    face: &FaceRef,
215    edges: &[EdgeRef],
216) -> GeopResult<(FaceId, Vec<EdgeId>)> {
217    if face.name().is_empty() {
218        return Err(GeopError::new("pick the rim face"));
219    }
220    Ok((
221        model.face_id(face.name())?,
222        (edges.iter())
223            .map(|edge| model.edge_id(edge.name()))
224            .collect::<GeopResult<_>>()?,
225    ))
226}
227
228/// Raises a lip `width` wide and `height` high along the edges `edges` of
229/// the planar rim face `face` — all along its hole if none are picked: the
230/// inside of a shelled enclosure's rim — flush with the wall they are on
231/// and joined to the solid (see [`crate::lip::lip`]), for the operation
232/// `L`: the result is named `lip(L)`, the lip's side on the wall along an
233/// edge `E` `lip(L,E)`, its side on the rim `lip(L,E,far)`, its top
234/// `lip(L,end)`.
235#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
236pub struct Lip;
237
238#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
239pub struct LipArgs {
240    /// The rim: a planar face.
241    pub face: FaceRef,
242    /// Edges of the rim to run along, one chain; none for all of its hole.
243    #[serde(default)]
244    pub edges: Vec<EdgeRef>,
245    pub width: f64,
246    pub height: f64,
247}
248
249impl PartOperation for Lip {
250    const KIND: &'static str = "lip";
251    const INFO: OperationInfo = info(
252        "Lip",
253        "Raise a lip along the rim of one half of an enclosure.",
254    )
255    .icon(crate::icons::LIP);
256    type Args = LipArgs;
257    type Session = ();
258
259    /// No rim yet, a lip 0.05 wide and 0.1 high.
260    fn new_args(_: &Context) -> LipArgs {
261        LipArgs {
262            face: ElementReference::new(model(), ""),
263            edges: Vec::new(),
264            width: 0.05,
265            height: 0.1,
266        }
267    }
268
269    fn run(ctx: &mut StepContext, args: &LipArgs) -> GeopResult<()> {
270        let _step = ctx.step();
271        build_model::<Self>(ctx, args)
272    }
273
274    /// The rim and its edges, picked, the width and the height.
275    fn form<'a>(
276        context: &'a Context<'a>,
277        args: &LipArgs,
278        _: &FormState<()>,
279    ) -> EditForm<'a, LipArgs> {
280        let mut f = EditForm::<LipArgs>::default();
281        rim_fields(&mut f, &context.before, &args.face, &args.edges, |a| {
282            (&mut a.face, &mut a.edges)
283        });
284        let length = |label: &str, value: f64| Number::new(label, value, Unit::Length);
285        f.number(
286            "width",
287            length("width", args.width).range(0.0, 1.0),
288            |edit, w| edit.args.width = w,
289        );
290        f.number(
291            "height",
292            length("height", args.height).range(0.0, 1.0),
293            |edit, h| edit.args.height = h,
294        );
295        f
296    }
297}
298
299impl ModelOperation for Lip {
300    fn build(model: &mut NamedModel, _: &Reader, step: &str, args: &LipArgs) -> GeopResult<()> {
301        let namer = Namer::new("lip", step)?;
302        let (face, edges) = resolve_rim(model, &args.face, &args.edges)?;
303        finite("width", args.width)?;
304        finite("height", args.height)?;
305        let size = LipSize {
306            width: Scalar::from_f64(args.width),
307            height: Scalar::from_f64(args.height),
308        };
309        lip(model, &namer, step, face, &edges, size)
310    }
311}
312
313/// Cuts the groove that takes a [`Lip`] `width` wide and `height` high,
314/// `clearance` wider and deeper, along the edges `edges` of the planar rim
315/// face `face` — all along its hole if none are picked — from the solid
316/// (see [`crate::lip::groove`]), for the operation `G`: the result is named
317/// `groove(G)`.
318#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
319pub struct Groove;
320
321#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
322pub struct GrooveArgs {
323    /// The rim: a planar face.
324    pub face: FaceRef,
325    /// Edges of the rim to run along, one chain; none for all of its hole.
326    #[serde(default)]
327    pub edges: Vec<EdgeRef>,
328    /// The lip's width.
329    pub width: f64,
330    /// The lip's height.
331    pub height: f64,
332    /// How much wider and deeper the groove is than the lip.
333    pub clearance: f64,
334}
335
336impl PartOperation for Groove {
337    const KIND: &'static str = "groove";
338    const INFO: OperationInfo = info(
339        "Groove",
340        "Cut the groove that takes a lip into the rim of the other half.",
341    )
342    .icon(crate::icons::GROOVE);
343    type Args = GrooveArgs;
344    type Session = ();
345
346    /// No rim yet, for a lip 0.05 wide and 0.1 high, 0.01 clearance.
347    fn new_args(_: &Context) -> GrooveArgs {
348        GrooveArgs {
349            face: ElementReference::new(model(), ""),
350            edges: Vec::new(),
351            width: 0.05,
352            height: 0.1,
353            clearance: 0.01,
354        }
355    }
356
357    fn run(ctx: &mut StepContext, args: &GrooveArgs) -> GeopResult<()> {
358        let _step = ctx.step();
359        build_model::<Self>(ctx, args)
360    }
361
362    /// The rim and its edges, picked, the lip's width and height, and the
363    /// clearance.
364    fn form<'a>(
365        context: &'a Context<'a>,
366        args: &GrooveArgs,
367        _: &FormState<()>,
368    ) -> EditForm<'a, GrooveArgs> {
369        let mut f = EditForm::<GrooveArgs>::default();
370        rim_fields(&mut f, &context.before, &args.face, &args.edges, |a| {
371            (&mut a.face, &mut a.edges)
372        });
373        let length = |label: &str, value: f64| Number::new(label, value, Unit::Length);
374        f.number(
375            "width",
376            length("lip width", args.width).range(0.0, 1.0),
377            |edit, w| edit.args.width = w,
378        );
379        f.number(
380            "height",
381            length("lip height", args.height).range(0.0, 1.0),
382            |edit, h| edit.args.height = h,
383        );
384        let clearance = length("clearance", args.clearance).range(0.0, 0.1);
385        f.number("clearance", clearance, |edit, c| edit.args.clearance = c);
386        f
387    }
388}
389
390impl ModelOperation for Groove {
391    fn build(model: &mut NamedModel, _: &Reader, step: &str, args: &GrooveArgs) -> GeopResult<()> {
392        let namer = Namer::new("groove", step)?;
393        let (face, edges) = resolve_rim(model, &args.face, &args.edges)?;
394        finite("width", args.width)?;
395        finite("height", args.height)?;
396        finite("clearance", args.clearance)?;
397        let size = LipSize {
398            width: Scalar::from_f64(args.width),
399            height: Scalar::from_f64(args.height),
400        };
401        let clearance = Scalar::from_f64(args.clearance);
402        groove(model, &namer, step, face, &edges, size, clearance)
403    }
404}
405
406/// Which side of its profile a rib is thick on: both alike, its first side
407/// — to the profile's left, growing normal to the sketch; along the
408/// sketch plane's normal, growing parallel to it — or its second.
409#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
410#[serde(rename_all = "snake_case")]
411pub enum RibSide {
412    #[default]
413    Symmetric,
414    First,
415    Second,
416}
417
418impl RibSide {
419    const ALL: [(RibSide, &'static str, &'static str); 3] = [
420        (RibSide::Symmetric, "symmetric", "Symmetric"),
421        (RibSide::First, "first", "First side"),
422        (RibSide::Second, "second", "Second side"),
423    ];
424}
425
426/// Which way a rib grows from its profile: along the sketch plane's normal,
427/// or in the sketch plane, square to the chord between the profile's ends.
428#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
429#[serde(rename_all = "snake_case")]
430pub enum RibDirection {
431    #[default]
432    Parallel,
433    Normal,
434}
435
436impl RibDirection {
437    const ALL: [(RibDirection, &'static str, &'static str); 2] = [
438        (RibDirection::Parallel, "parallel", "Parallel to sketch"),
439        (RibDirection::Normal, "normal", "Normal to sketch"),
440    ];
441}
442
443/// Grows a rib `thickness` thick from the open profile of a sketch — lines
444/// and arcs — until it meets the solid `solid`, its ends run on into the
445/// walls they point at, and joins it (see [`crate::rib::rib`]), for the
446/// operation `R`: the result is named `rib(R)`. Growing normal to the
447/// sketch, the rib's caps are `rib(R,start)` on the sketch's plane; its
448/// sides along a profile piece `K,X` are `rib(R,K,X)` and `rib(R,K,X,far)`.
449/// Parallel, its sides on either side of the sketch's plane are
450/// `rib(R,start)` and `rib(R,end)`, its edge along the profile `rib(R,K,X)`.
451#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
452pub struct Rib;
453
454#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
455pub struct RibArgs {
456    /// The sketch of the profile: one open chain.
457    pub sketch: SketchRef,
458    /// The solid it grows up to and joins.
459    pub solid: SolidRef,
460    pub thickness: f64,
461    #[serde(default)]
462    pub side: RibSide,
463    #[serde(default)]
464    pub direction: RibDirection,
465    /// Grow the other way.
466    #[serde(default)]
467    pub flipped: bool,
468}
469
470impl PartOperation for Rib {
471    const KIND: &'static str = "rib";
472    const INFO: OperationInfo = info(
473        "Rib",
474        "Grow a thin wall from an open sketch profile up to a solid's faces, and join it.",
475    )
476    .icon(crate::icons::RIB);
477    type Args = RibArgs;
478    type Session = ();
479
480    /// The newest sketch and solid, 0.05 thick, symmetric, parallel to the
481    /// sketch.
482    fn new_args(context: &Context) -> RibArgs {
483        let before = &context.before;
484        let newest = before.targets_of::<PlacedSketch>().pop();
485        let solid_name = read_model(before)
486            .ok()
487            .and_then(|model| model.newest_solid());
488        RibArgs {
489            sketch: SketchRef::whole(newest.unwrap_or_else(|| geop_ops_model::sketch::sketch(""))),
490            solid: solid(solid_name.unwrap_or_default()),
491            thickness: 0.05,
492            side: RibSide::Symmetric,
493            direction: RibDirection::Parallel,
494            flipped: false,
495        }
496    }
497
498    fn run(ctx: &mut StepContext, args: &RibArgs) -> GeopResult<()> {
499        let _step = ctx.step();
500        build_model::<Self>(ctx, args)
501    }
502
503    /// The sketch and the solid, picked, the thickness and its side, which
504    /// way it grows.
505    fn form<'a>(
506        context: &'a Context<'a>,
507        args: &RibArgs,
508        _: &FormState<()>,
509    ) -> EditForm<'a, RibArgs> {
510        let before = &context.before;
511        let mut f = EditForm::<RibArgs>::default();
512        f.pick_one(
513            "sketch",
514            "sketch",
515            before,
516            Some(&args.sketch),
517            |edit, sketch| edit.args.sketch = sketch,
518        );
519        f.pick_one(
520            "solid",
521            "solid",
522            before,
523            Some(&args.solid),
524            |edit, solid| edit.args.solid = solid,
525        );
526        let thickness = Number::new("thickness", args.thickness, Unit::Length).range(0.0, 1.0);
527        f.number("thickness", thickness, |edit, t| edit.args.thickness = t);
528        let (side, sides) = choices(&RibSide::ALL, &args.side);
529        f.select(
530            "side",
531            "thickness side",
532            side,
533            sides,
534            false,
535            |edit, value| {
536                if let Some(side) = chosen(&RibSide::ALL, value) {
537                    edit.args.side = side;
538                }
539            },
540        );
541        let (direction, directions) = choices(&RibDirection::ALL, &args.direction);
542        f.select(
543            "direction",
544            "direction",
545            direction,
546            directions,
547            false,
548            |edit, value| {
549                if let Some(direction) = chosen(&RibDirection::ALL, value) {
550                    edit.args.direction = direction;
551                }
552            },
553        );
554        f.checkbox("flipped", "flip direction", args.flipped, |edit, b| {
555            edit.args.flipped = b
556        });
557        f
558    }
559}
560
561impl ModelOperation for Rib {
562    fn build(
563        model: &mut NamedModel,
564        reader: &Reader,
565        step: &str,
566        args: &RibArgs,
567    ) -> GeopResult<()> {
568        let namer = Namer::new("rib", step)?;
569        let placed = reader.retrieve_element(&args.sketch).concerning("sketch")?;
570        let target = model.solid_id(args.solid.name())?;
571        let sketch = &placed.sketch;
572        let geometry = sketch.enclose()?;
573        let chain = match sketch.shape()? {
574            chain @ Shape::Chain(_) => chain,
575            Shape::Regions(_) => {
576                return Err(GeopError::new(format!(
577                    "sketch {:?} encloses an area: a rib grows from an open profile",
578                    placed.name
579                )));
580            }
581        };
582        let profile = shape_profiles(&placed.name, sketch, &geometry, chain)?.remove(0);
583        finite("thickness", args.thickness)?;
584        let t = args.thickness;
585        let (near, far) = match args.side {
586            RibSide::Symmetric => (-t / 2.0, t / 2.0),
587            RibSide::First => (0.0, t),
588            RibSide::Second => (-t, 0.0),
589        };
590        let thickness = (Scalar::from_f64(near), Scalar::from_f64(far));
591        let growth = match args.direction {
592            RibDirection::Normal => Growth::Normal {
593                flipped: args.flipped,
594            },
595            RibDirection::Parallel => Growth::Parallel {
596                flipped: args.flipped,
597            },
598        };
599        rib(
600            model,
601            &namer,
602            step,
603            &placed.plane,
604            &profile,
605            target,
606            thickness,
607            growth,
608        )
609    }
610}