Skip to main content

geop_ops_sheetmetal/
base_flange.rs

1//! [`BaseFlange`]: the first piece of a sheet-metal body — a plate from a
2//! sketch's area, or a bent strip from a chain of lines and arcs.
3
4use geop_core_editor::{
5    edit_form::{EditForm, FormState},
6    editable::{Context, OperationGroup, OperationInfo, OperationTier},
7    editing::{Choice, Number, Unit},
8    part_operation,
9    part_operation::PartOperation,
10};
11use geop_core_math::{
12    geop_error::{GeopError, GeopResult, WithContext},
13    scalars::Scalar,
14    vector::{Vector2, Vector3},
15    with_context,
16};
17use geop_core_program::{numeric::NumericInput, part::StepContext, reader::Reader};
18use geop_core_sketch::{CurveKind, ProfileLoop, Shape};
19use geop_ops_model::sketch::{PlacedSketch, SketchRef, shape_profiles};
20use geop_ops_model::{ModelOperation, NamedModel, Namer, Profile};
21use serde::{Deserialize, Serialize};
22
23use crate::{
24    body::{self, Made, Making, SheetStep},
25    sheet::{Bend, BendFrame, Flat, FlatEdge, Placement, Relief, Sheet, SheetMetalRules, straight},
26};
27
28/// Starts a sheet-metal body from a sketch, for the operation `B`: a solid
29/// named `base_flange(B)`, with the sheet-metal `rules` every later flange
30/// and the flat pattern of it follow.
31///
32/// - A sketch's **area** becomes a plate `thickness` thick, from the
33///   sketch's plane along its normal (against it, if `flip`). Its faces are
34///   `base_flange(B,plate,a)` on the sketch's plane and
35///   `base_flange(B,plate,b)` across, and the edge of the area swept by
36///   piece `X` of sketch `K` (`c3`, `c3#1`) gives the wall
37///   `base_flange(B,K,X)` and the edges `base_flange(B,K,X,a)` /
38///   `base_flange(B,K,X,b)` around it.
39/// - A sketch's **chain** of lines and arcs becomes a bent strip, its lines
40///   flat and every corner between two of them a bend of the inner radius
41///   `bend_radius` — the lines are where the faces would meet if the bends
42///   were sharp — every arc, tangent to its lines, a bend of its own radius.
43///   The chain is the strip's A side, the material on its right (seen from
44///   the sketch's normal; its left if `flip`), and it runs `depth` along the
45///   sketch's normal. Line `c3` of sketch `K` gives the flat faces
46///   `base_flange(B,K,c3,a)` / `(...,b)`, its edges at either end of the
47///   strip `base_flange(B,K,c3,start)` / `(...,end)` and where it ends at
48///   point `p2` `base_flange(B,K,c3,p2)`; the bend at corner `p2` is
49///   `base_flange(B,K,p2,a)` / `(...,b)`, the one of arc `c4`
50///   `base_flange(B,K,c4,...)`.
51///
52/// Every edge `E` named so has its wall `E` if it lies on the sheet's
53/// outline, and the vertex it starts at is `E,v,a` / `E,v,b` (see
54/// [`crate::thicken`]).
55#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
56pub struct BaseFlange;
57
58#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
59pub struct BaseFlangeArgs {
60    /// The sketch: one area, or one chain of lines and arcs.
61    pub sketch: SketchRef,
62    pub rules: SheetMetalRules,
63    /// How far a chain's strip runs along the sketch's normal: a number,
64    /// or a formula of the part's parameters.
65    pub depth: NumericInput,
66    /// Put the material on the other side: of the sketch's plane, for an
67    /// area; of the chain, for a chain.
68    #[serde(default)]
69    pub flip: bool,
70}
71
72impl SheetStep for BaseFlange {
73    fn plan(reader: &Reader, step: &str, args: &BaseFlangeArgs) -> GeopResult<Made> {
74        let ctx = with_context!("base_flange({step}, {args:?})");
75        let namer = Namer::new("base_flange", step)?;
76        args.rules.check().with_context(ctx)?;
77        let placed = reader.retrieve_element(&args.sketch).with_context(ctx)?;
78        let sheet = match placed.sketch.shape().with_context(ctx)? {
79            Shape::Regions(regions) if regions.len() == 1 => {
80                plate(&namer, placed, Shape::Regions(regions), args)
81            }
82            Shape::Regions(regions) => Err(GeopError::new(format!(
83                "the sketch has {} areas apart, and a base flange is one sheet: draw each in a sketch of its own",
84                regions.len()
85            ))),
86            Shape::Chain(chain) => {
87                let depth = args.depth.resolve(reader).with_context(ctx)?;
88                strip(&namer, placed, &chain, depth, args)
89            }
90        }
91        .with_context(ctx)?;
92        Ok(Made {
93            consumes: None,
94            solid: namer.root(),
95            making: Making::Bent(sheet),
96        })
97    }
98}
99
100part_operation!(BaseFlange(BaseFlangeArgs));
101
102impl PartOperation for BaseFlange {
103    const KIND: &'static str = "base_flange";
104    const INFO: OperationInfo = OperationInfo::new(
105        "Base flange",
106        "Start a sheet-metal body from a sketch: a plate from its area, or a bent strip from a chain of lines and arcs.",
107        OperationGroup::SheetMetal,
108        OperationTier::Big,
109    )
110    .icon(crate::icons::BASE_FLANGE);
111    type Args = BaseFlangeArgs;
112    type Session = ();
113
114    /// The newest sketch, with the default rules, a unit deep.
115    fn new_args(context: &Context) -> BaseFlangeArgs {
116        let newest = context.before.targets_of::<PlacedSketch>().pop();
117        BaseFlangeArgs {
118            sketch: SketchRef::whole(newest.unwrap_or_else(|| geop_ops_model::sketch::sketch(""))),
119            rules: SheetMetalRules::default(),
120            depth: NumericInput::Plain(1.0),
121            flip: false,
122        }
123    }
124
125    fn run(ctx: &mut StepContext, args: &BaseFlangeArgs) -> GeopResult<()> {
126        let _step = ctx.step();
127        body::run::<Self>(ctx, args)
128    }
129
130    /// The sketch, picked; for a chain how deep; whether flipped; and the
131    /// rules.
132    fn form<'a>(
133        context: &'a Context<'a>,
134        args: &BaseFlangeArgs,
135        _: &FormState<()>,
136    ) -> EditForm<'a, BaseFlangeArgs> {
137        let before = &context.before;
138        let mut f = EditForm::<BaseFlangeArgs>::default();
139        f.pick_one(
140            "sketch",
141            "sketch",
142            before,
143            Some(&args.sketch),
144            |edit, sketch| edit.args.sketch = sketch,
145        );
146        let chain = (before.retrieve_element(&args.sketch))
147            .and_then(|placed| placed.sketch.shape())
148            .is_ok_and(|shape| matches!(shape, Shape::Chain(_)));
149        if chain {
150            let depth = Number::numeric(&args.depth, "depth", before, Unit::Length);
151            f.numeric("depth", depth.range(0.0, 10.0), |edit, depth| {
152                edit.args.depth = depth
153            });
154        }
155        f.checkbox("flip", "flip side", args.flip, |edit, b| edit.args.flip = b);
156        rules_fields(&mut f, &args.rules);
157        f
158    }
159}
160
161impl ModelOperation for BaseFlange {
162    fn build(
163        model: &mut NamedModel,
164        reader: &Reader,
165        step: &str,
166        _: &BaseFlangeArgs,
167    ) -> GeopResult<()> {
168        body::build(model, reader, step)
169    }
170}
171
172/// The fields of the sheet-metal rules.
173fn rules_fields(f: &mut EditForm<'_, BaseFlangeArgs>, rules: &SheetMetalRules) {
174    let thickness = Number::new("thickness", rules.thickness, Unit::Length).range(0.0, 1.0);
175    f.number("thickness", thickness, |edit, t| {
176        edit.args.rules.thickness = t
177    });
178    let radius = Number::new("bend radius", rules.bend_radius, Unit::Length).range(0.0, 1.0);
179    f.number("bend_radius", radius, |edit, r| {
180        edit.args.rules.bend_radius = r
181    });
182    let k_factor = Number::new("K-factor", rules.k_factor, Unit::Fraction).range(0.0, 1.0);
183    f.number("k_factor", k_factor, |edit, k| edit.args.rules.k_factor = k);
184    f.select(
185        "relief",
186        "relief",
187        match rules.relief {
188            Relief::Rectangular => "rectangular",
189            Relief::Tear => "tear",
190        },
191        vec![
192            Choice::new("rectangular", "Rectangular"),
193            Choice::new("tear", "Tear"),
194        ],
195        false,
196        |edit, choice| {
197            edit.args.rules.relief = match choice {
198                "tear" => Relief::Tear,
199                _ => Relief::Rectangular,
200            }
201        },
202    );
203    if rules.relief == Relief::Rectangular {
204        let ratio = Number::new("relief ratio", rules.relief_ratio, Unit::Fraction).range(0.0, 2.0);
205        f.number("relief_ratio", ratio, |edit, r| {
206            edit.args.rules.relief_ratio = r
207        });
208    }
209    let gap = Number::new("corner gap", rules.corner_gap, Unit::Length).range(0.0, 1.0);
210    f.number("corner_gap", gap, |edit, g| edit.args.rules.corner_gap = g);
211}
212
213/// The sketch's area as one flat plate: on the sketch's plane, its B side
214/// along the plane's normal — or, flipped, its B side on the plane and its
215/// A side behind it.
216fn plate(
217    namer: &Namer,
218    placed: &PlacedSketch,
219    region: Shape,
220    args: &BaseFlangeArgs,
221) -> GeopResult<Sheet> {
222    let sketch = &placed.sketch;
223    let geometry = sketch.enclose()?;
224    let loops = shape_profiles(&placed.name, sketch, &geometry, region)?;
225    let plane = &placed.plane;
226    let n = *plane.w();
227    let origin = if args.flip {
228        plane
229            .origin()
230            .sub(&n.prod_scalar(Scalar::from_f64(args.rules.thickness)))
231    } else {
232        *plane.origin()
233    };
234    let place = Placement {
235        origin,
236        e1: *plane.u(),
237        e2: *plane.v(),
238        n,
239    };
240    let edges = |lp: &Profile| -> Vec<FlatEdge> {
241        lp.curves
242            .iter()
243            .zip(&lp.curve_names)
244            .map(|(curve, name)| FlatEdge {
245                curve: curve.clone(),
246                name: namer.scoped(name),
247            })
248            .collect()
249    };
250    let mut loops = loops.iter().map(edges);
251    let outer = loops
252        .next()
253        .ok_or_else(|| GeopError::new("the sketch has no area"))?;
254    Ok(Sheet {
255        rules: args.rules.clone(),
256        flats: vec![Flat {
257            place,
258            outer,
259            holes: loops.collect(),
260            name: namer.scoped("plate"),
261        }],
262        bends: Vec::new(),
263        cuts: Vec::new(),
264    })
265}
266
267/// A piece of a chain, from `a` to `b` in sketch coordinates: a line, or
268/// an arc turning by `sweep` (counter-clockwise if positive); named after
269/// its curve, and its ends after their points.
270struct Piece {
271    a: Vector2,
272    b: Vector2,
273    sweep: Option<Scalar>,
274    name: String,
275    start: String,
276    end: String,
277}
278
279impl Piece {
280    fn reversed(self) -> Self {
281        Self {
282            a: self.b,
283            b: self.a,
284            sweep: self.sweep.map(Scalar::neg),
285            name: self.name,
286            start: self.end,
287            end: self.start,
288        }
289    }
290}
291
292/// How a strip turns from one line to the next: through a sharp corner
293/// at a point, bent at the default radius, or along an arc.
294struct Turn {
295    name: String,
296    angle: Scalar,
297    radius: Scalar,
298    toward_b: bool,
299    /// How far each line's flat stops short of the corner.
300    setback: Scalar,
301}
302
303/// The sketch's chain of lines and arcs as a bent strip, `depth` deep (see
304/// [`BaseFlange`]).
305fn strip(
306    namer: &Namer,
307    placed: &PlacedSketch,
308    chain: &ProfileLoop,
309    depth: f64,
310    args: &BaseFlangeArgs,
311) -> GeopResult<Sheet> {
312    let sketch = &placed.sketch;
313    let geometry = sketch.enclose()?;
314    let mut pieces = Vec::new();
315    for edge in &chain.edges {
316        let curve = &sketch.curves[&edge.curve];
317        let point = |id| {
318            geometry
319                .points
320                .get(&id)
321                .copied()
322                .ok_or_else(|| GeopError::new(format!("the sketch has no point {id}")))
323        };
324        let (a, b, sweep) = match curve.kind {
325            CurveKind::Line { start, end } => (start, end, None),
326            CurveKind::Arc { start, end, .. } => (
327                start,
328                end,
329                Some(geometry.params.get(&edge.curve).copied().ok_or_else(|| {
330                    GeopError::new(format!("the sketch has no sweep for arc {}", edge.curve))
331                })?),
332            ),
333            _ => {
334                return Err(GeopError::new(format!(
335                    "{} is neither a line nor an arc: a sheet-metal strip is drawn with lines, and arcs for its bends",
336                    edge.curve
337                )));
338            }
339        };
340        let piece = Piece {
341            a: point(a)?,
342            b: point(b)?,
343            sweep,
344            name: edge.curve.to_string(),
345            start: a.to_string(),
346            end: b.to_string(),
347        };
348        pieces.push(if edge.reversed {
349            piece.reversed()
350        } else {
351            piece
352        });
353    }
354    if args.flip {
355        pieces = pieces.into_iter().rev().map(Piece::reversed).collect();
356    }
357    if !(depth.is_finite() && depth > 0.0) {
358        return Err(GeopError::new(format!(
359            "the strip's depth must be positive, not {depth}"
360        )));
361    }
362
363    let plane = &placed.plane;
364    let w = *plane.w();
365    let t = Scalar::from_f64(args.rules.thickness);
366    let direction =
367        |p: &Piece| -> GeopResult<Vector3> { plane.uv_direction(&p.b.sub(&p.a)).normalize() };
368    // The lines, and the turn from each to the next.
369    let mut lines: Vec<&Piece> = Vec::new();
370    let mut turns: Vec<Turn> = Vec::new();
371    let mut arc: Option<&Piece> = None;
372    for piece in &pieces {
373        if piece.sweep.is_none() {
374            if let Some(&last) = lines.last() {
375                turns.push(turn(
376                    last,
377                    piece,
378                    arc.take(),
379                    &direction,
380                    &w,
381                    t,
382                    &args.rules,
383                )?);
384            }
385            lines.push(piece);
386            continue;
387        }
388        if lines.is_empty() || arc.is_some() {
389            return Err(GeopError::new(format!(
390                "arc {} does not lie between two lines: a sheet-metal strip starts and ends flat, and bends from one line to the next",
391                piece.name
392            )));
393        }
394        arc = Some(piece);
395    }
396    if let Some(arc) = arc {
397        return Err(GeopError::new(format!(
398            "the strip ends in arc {}: a sheet-metal strip starts and ends flat",
399            arc.name
400        )));
401    }
402
403    let depth = Scalar::from_f64(depth);
404    let sketch_namer = namer.scoped(&placed.name);
405    let mut flats: Vec<Flat> = Vec::new();
406    let mut bends: Vec<Bend> = Vec::new();
407    let mut x0 = Scalar::ZERO;
408    let mut place = {
409        let e1 = direction(lines[0])?;
410        Placement {
411            origin: plane.uv_to_xyz(&lines[0].a),
412            n: e1.prod_cross(&w),
413            e1,
414            e2: w,
415        }
416    };
417    for (i, line) in lines.iter().enumerate() {
418        let before = i.checked_sub(1).map_or(Scalar::ZERO, |j| turns[j].setback);
419        let after = turns.get(i).map_or(Scalar::ZERO, |turn| turn.setback);
420        let length = line.b.sub(&line.a).norm().sub(before).sub(after);
421        if !length.definitely_greater(Scalar::ZERO) {
422            return Err(GeopError::new(format!(
423                "line {} is too short for its bends: {:?} of it is left flat",
424                line.name, length
425            )));
426        }
427        let x1 = x0.add(length);
428        let p = |x: Scalar, y: Scalar| Vector2::from_array([x, y]);
429        let flat = sketch_namer.scoped(&line.name);
430        let outer = vec![
431            straight(
432                p(x0, Scalar::ZERO),
433                p(x1, Scalar::ZERO),
434                flat.scoped("start"),
435            )?,
436            straight(p(x1, Scalar::ZERO), p(x1, depth), flat.scoped(&line.end))?,
437            straight(p(x1, depth), p(x0, depth), flat.scoped("end"))?,
438            straight(p(x0, depth), p(x0, Scalar::ZERO), flat.scoped(&line.start))?,
439        ];
440        flats.push(Flat {
441            place: place.clone(),
442            outer,
443            holes: Vec::new(),
444            name: flat.clone(),
445        });
446        if let Some(turn) = turns.get(i) {
447            let frame = BendFrame::new(
448                &place,
449                [p(x1, Scalar::ZERO), p(x1, depth)],
450                turn.angle,
451                turn.radius,
452                t,
453                turn.toward_b,
454            )?;
455            let next = sketch_namer.scoped(&lines[i + 1].name);
456            bends.push(Bend {
457                parent: i,
458                parent_edge: flat.scoped(&line.end).root(),
459                child: i + 1,
460                child_edge: next.scoped(&lines[i + 1].start).root(),
461                angle: turn.angle,
462                radius: turn.radius,
463                toward_b: turn.toward_b,
464                name: sketch_namer.scoped(&turn.name),
465            });
466            place = frame.child_placement(&place);
467        }
468        x0 = x1;
469    }
470    Ok(Sheet {
471        rules: args.rules.clone(),
472        flats,
473        bends,
474        cuts: Vec::new(),
475    })
476}
477
478/// How the strip turns from line `from` to line `to`: along `arc`, which
479/// must be tangent to both, or else through the sharp corner between them,
480/// bent at the default radius.
481fn turn(
482    from: &Piece,
483    to: &Piece,
484    arc: Option<&Piece>,
485    direction: &dyn Fn(&Piece) -> GeopResult<Vector3>,
486    w: &Vector3,
487    t: Scalar,
488    rules: &SheetMetalRules,
489) -> GeopResult<Turn> {
490    let (d0, d1) = (direction(from)?, direction(to)?);
491    let n = d0.prod_cross(w);
492    let side = d1.prod_dot(&n);
493    let toward_b = if side.definitely_greater(Scalar::ZERO) {
494        true
495    } else if side.definitely_less(Scalar::ZERO) {
496        false
497    } else {
498        return Err(GeopError::new(format!(
499            "lines {} and {} could run straight on: join them into one line, or draw a corner between them",
500            from.name, to.name
501        )));
502    };
503    let cos = d0.prod_dot(&d1);
504    if !cos.definitely_greater(Scalar::ONE.neg()) {
505        return Err(GeopError::new(format!(
506            "line {} could fold straight back onto line {}: a bend turns by less than 180 degrees",
507            to.name, from.name
508        )));
509    }
510    let Some(arc) = arc else {
511        let angle = cos.acos()?;
512        let radius = Scalar::from_f64(rules.bend_radius);
513        let outer = if toward_b { radius.add(t) } else { radius };
514        let half = angle.div(Scalar::TWO)?;
515        return Ok(Turn {
516            name: from.end.clone(),
517            angle,
518            radius,
519            toward_b,
520            setback: outer.mul(half.sin().div(half.cos())?),
521        });
522    };
523    // An arc tangent to both lines: it starts where the first ends, along
524    // it, and ends where the next starts, along that.
525    let sweep = arc.sweep.expect("an arc has a sweep");
526    let angle = sweep.abs();
527    let half = sweep.div(Scalar::TWO)?;
528    let chord = arc.b.sub(&arc.a);
529    let r_a = chord.norm().div(Scalar::TWO.mul(half.sin().abs()))?;
530    let chord = chord.normalize()?;
531    let turned = |by: Scalar| {
532        let (c, s) = (by.cos(), by.sin());
533        Vector2::from_array([
534            chord[0].mul(c).sub(chord[1].mul(s)),
535            chord[0].mul(s).add(chord[1].mul(c)),
536        ])
537    };
538    let along = |d: Vector2, line: &Piece| -> GeopResult<bool> {
539        let l = line.b.sub(&line.a).normalize()?;
540        Ok(d.prod_cross(&l).could_be_equal(Scalar::ZERO)
541            && d.prod_dot(&l).definitely_greater(Scalar::ZERO))
542    };
543    if !(from.b.could_be_equal(&arc.a) && arc.b.could_be_equal(&to.a)) {
544        return Err(GeopError::new(format!(
545            "arc {} does not join lines {} and {}",
546            arc.name, from.name, to.name
547        )));
548    }
549    if !(along(turned(half.neg()), from)? && along(turned(half), to)?) {
550        return Err(GeopError::new(format!(
551            "arc {} is not tangent to lines {} and {}: a bend runs on smoothly from one flat to the next",
552            arc.name, from.name, to.name
553        )));
554    }
555    let radius = if toward_b { r_a.sub(t) } else { r_a };
556    if !radius.definitely_greater(Scalar::ZERO) {
557        return Err(GeopError::new(format!(
558            "arc {} has a radius of {:?}, no more than the thickness on the inside of its bend",
559            arc.name, r_a
560        )));
561    }
562    Ok(Turn {
563        name: arc.name.clone(),
564        angle,
565        radius,
566        toward_b,
567        setback: Scalar::ZERO,
568    })
569}