Skip to main content

geop_ops_sheetmetal/
sheet.rs

1//! The sheet-metal model a body is built from and unfolded with: its
2//! [`SheetMetalRules`], and its [`Sheet`] — flat faces joined by bends —
3//! recorded for the solid it builds (see [`crate::body::sheets`]).
4//!
5//! **Sheet coordinates.** Every flat is drawn in one plane of coordinates
6//! `(x, y)`, the *sheet coordinates*, and placed in space by a
7//! [`Placement`] of its own. A bend joins an edge `P` of its parent flat to
8//! an edge `C` of its child flat, and the child's placement is the
9//! parent's turned about the bend's axis: so `C` *is* `P` in sheet
10//! coordinates, and the child lies on the far side of it. Laid flat, every
11//! flat keeps its shape and only moves away from its parent by the bend's
12//! developed length (see [`Sheet::unfolded`]), which is what makes the flat
13//! pattern exact and the unfolding topological: which faces are flat and
14//! which are bends is recorded here, never guessed from the geometry.
15//!
16//! **Sides.** The *A side* is the side drawn — the sketch's plane or line —
17//! and the *B side* a thickness further along each flat's normal `n`. A
18//! bend turns towards the B side (`toward_b`), which is then its inside,
19//! or away from it.
20
21use geop_core_geometry::{
22    aabb::union_aabb,
23    nurb_curve::{NurbCurve2D, NurbCurve3D},
24    nurb_surface::{NurbSurface, NurbSurface3D},
25};
26use geop_core_math::{
27    geop_error::{GeopError, GeopResult},
28    primitives::CoordinateSystem,
29    scalars::Scalar,
30    vector::{Vector2, Vector3, Vector4},
31};
32use geop_core_topology::Body;
33use geop_ops_model::{NamedModel, Namer};
34use serde::{Deserialize, Serialize};
35
36use crate::thicken::thicken;
37
38/// How a body's sheet metal is made and bent: what the base flange sets
39/// and every flange and the flat pattern of the body follow.
40#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
41pub struct SheetMetalRules {
42    /// How thick the sheet is.
43    pub thickness: f64,
44    /// The inner radius of a bend, unless a flange gives its own.
45    pub bend_radius: f64,
46    /// Where the neutral surface lies across the thickness, from the inside
47    /// of a bend (`0`) to its outside (`1`): what a bend's developed length
48    /// is measured on, `angle * (radius + k_factor * thickness)`.
49    pub k_factor: f64,
50    /// What is cut beside a flange narrower than its edge.
51    #[serde(default)]
52    pub relief: Relief,
53    /// A relief's width, and how far it reaches past the bend, in
54    /// thicknesses.
55    pub relief_ratio: f64,
56    /// The gap a flange keeps from a bend that already meets its edge at a
57    /// corner, so that the two stay apart: an open corner.
58    pub corner_gap: f64,
59}
60
61impl Default for SheetMetalRules {
62    fn default() -> Self {
63        Self {
64            thickness: 0.1,
65            bend_radius: 0.1,
66            k_factor: 0.44,
67            relief: Relief::Rectangular,
68            relief_ratio: 0.5,
69            corner_gap: 0.1,
70        }
71    }
72}
73
74impl SheetMetalRules {
75    /// Checks that the rules describe sheet metal that can be built.
76    pub fn check(&self) -> GeopResult<()> {
77        let positive = [
78            ("thickness", self.thickness),
79            ("bend radius", self.bend_radius),
80            ("relief ratio", self.relief_ratio),
81        ];
82        for (what, value) in positive {
83            if !(value.is_finite() && value > 0.0) {
84                return Err(GeopError::new(format!(
85                    "the sheet's {what} must be positive, not {value}"
86                )));
87            }
88        }
89        if !(0.0..=1.0).contains(&self.k_factor) {
90            return Err(GeopError::new(format!(
91                "the K-factor must lie between 0 and 1, not {}",
92                self.k_factor
93            )));
94        }
95        if !(self.corner_gap.is_finite() && self.corner_gap >= 0.0) {
96            return Err(GeopError::new(format!(
97                "the corner gap must not be negative, not {}",
98                self.corner_gap
99            )));
100        }
101        Ok(())
102    }
103
104    /// How wide a relief is, and how far past the bend it reaches; none
105    /// for a tear.
106    pub fn relief_size(&self) -> Option<f64> {
107        match self.relief {
108            Relief::Rectangular => Some(self.relief_ratio * self.thickness),
109            Relief::Tear => None,
110        }
111    }
112}
113
114/// What is cut beside a flange narrower than its edge, where the bend would
115/// otherwise tear the sheet.
116#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
117#[serde(rename_all = "snake_case")]
118pub enum Relief {
119    /// A rectangular notch on either side of the bend.
120    #[default]
121    Rectangular,
122    /// Nothing: the sheet is cut straight along the bend's ends.
123    Tear,
124}
125
126/// Where a flat lies: sheet point `(x, y)` at `origin + x e1 + y e2`, its
127/// unit normal `n = e1 × e2` pointing from its A side to its B side.
128/// `e1`, `e2` and `n` are orthonormal.
129#[derive(Clone, Debug, PartialEq)]
130pub struct Placement {
131    pub origin: Vector3,
132    pub e1: Vector3,
133    pub e2: Vector3,
134    pub n: Vector3,
135}
136
137impl Placement {
138    /// The sheet point `p` in space.
139    pub fn point(&self, p: &Vector2) -> Vector3 {
140        self.origin
141            .add(&self.e1.prod_scalar(p[0]))
142            .add(&self.e2.prod_scalar(p[1]))
143    }
144
145    /// The sheet direction `d` in space.
146    pub fn direction(&self, d: &Vector2) -> Vector3 {
147        self.e1.prod_scalar(d[0]).add(&self.e2.prod_scalar(d[1]))
148    }
149
150    /// The sheet curve `curve` in space: exact, an affine map of its
151    /// control points.
152    pub fn curve(&self, curve: &NurbCurve2D) -> GeopResult<NurbCurve3D> {
153        curve.embed(&self.origin, &self.e1, &self.e2)
154    }
155
156    /// The same placement `t` further along the normal: the B side's.
157    pub fn offset(&self, t: Scalar) -> Self {
158        Self {
159            origin: self.origin.add(&self.n.prod_scalar(t)),
160            ..self.clone()
161        }
162    }
163
164    /// As a coordinate system: `u = e1`, `v = e2`, `w = n`.
165    pub fn coordinate_system(&self) -> GeopResult<CoordinateSystem> {
166        CoordinateSystem::try_new(self.origin, self.e1, self.e2, self.n)
167    }
168
169    /// The plane through the placement, as a bilinear patch parametrized by
170    /// sheet coordinates over the box around `curves`: so a curve drawn in
171    /// sheet coordinates is its own pcurve on it.
172    pub(crate) fn plane(&self, curves: &[&NurbCurve2D]) -> GeopResult<NurbSurface3D> {
173        let bounds = union_aabb(curves.iter().map(|c| c.aabb()))
174            .ok_or_else(|| GeopError::new("Placement::plane: no curves to span"))?;
175        // The box's corners are a free choice — any box around the curves
176        // will do — so sharp: and it encloses them, a NURBS curve lying in
177        // the hull of its control points.
178        let lo: [Scalar; 2] = std::array::from_fn(|k| Scalar::from_f64(bounds[k].lower().to_f64()));
179        let hi: [Scalar; 2] = std::array::from_fn(|k| Scalar::from_f64(bounds[k].upper().to_f64()));
180        let corner = |x: Scalar, y: Scalar| {
181            let p = self.point(&Vector2::from_array([x, y]));
182            Vector4::from_array([p[0], p[1], p[2], Scalar::ONE])
183        };
184        NurbSurface::try_new(
185            1,
186            1,
187            vec![
188                corner(lo[0], lo[1]),
189                corner(lo[0], hi[1]),
190                corner(hi[0], lo[1]),
191                corner(hi[0], hi[1]),
192            ],
193            vec![lo[0], lo[0], hi[0], hi[0]],
194            vec![lo[1], lo[1], hi[1], hi[1]],
195        )
196    }
197}
198
199/// One edge of a flat's loop, in sheet coordinates, running the way the
200/// loop does. The vertex where it starts is named after it.
201#[derive(Clone, Debug, PartialEq)]
202pub struct FlatEdge {
203    pub curve: NurbCurve2D,
204    /// The edge's names: `N(a)`, `N(b)`, its wall `N`, the vertices it
205    /// starts at `N(v,a)`, `N(v,b)` and the edge between them `N(v)`.
206    pub name: Namer,
207}
208
209impl FlatEdge {
210    /// What a bend calls it by: its wall's name.
211    pub fn key(&self) -> String {
212        self.name.root()
213    }
214
215    /// Its ends, in sheet coordinates, if it is straight.
216    pub fn line(&self) -> Option<GeopResult<[Vector2; 2]>> {
217        (self.curve.degree == 1 && self.curve.control_points.len() == 2)
218            .then(|| Ok([self.curve.start()?, self.curve.end()?]))
219    }
220}
221
222/// A flat face of the sheet: its loops in sheet coordinates — the outer
223/// one counter-clockwise about its normal, holes clockwise — and where it
224/// lies.
225#[derive(Clone, Debug, PartialEq)]
226pub struct Flat {
227    pub place: Placement,
228    pub outer: Vec<FlatEdge>,
229    pub holes: Vec<Vec<FlatEdge>>,
230    /// Its faces are `N(a)` and `N(b)`.
231    pub name: Namer,
232}
233
234/// A bend: a cylindrical strip from the straight edge `parent_edge` of
235/// flat `parent` to the edge `child_edge` of flat `child` — the same
236/// segment in sheet coordinates, run the other way.
237#[derive(Clone, Debug, PartialEq)]
238pub struct Bend {
239    pub parent: usize,
240    pub parent_edge: String,
241    pub child: usize,
242    pub child_edge: String,
243    /// How far it turns, in radians: in `(0, π)`.
244    pub angle: Scalar,
245    /// Its inner radius.
246    pub radius: Scalar,
247    /// Whether it turns towards the parent's B side.
248    pub toward_b: bool,
249    /// Its faces are `N(a)` and `N(b)`; its edges across from parent to
250    /// child, where `parent_edge` starts and where it ends, `N(s0)` and
251    /// `N(s1)`, named like a [`FlatEdge`]'s.
252    pub name: Namer,
253}
254
255/// A sheet-metal body as it is built: flat faces, the first the one the
256/// rest hang off, and the bends joining them into a tree, each child built
257/// after its parent.
258#[derive(Clone, Debug, PartialEq)]
259pub struct Sheet {
260    pub rules: SheetMetalRules,
261    pub flats: Vec<Flat>,
262    pub bends: Vec<Bend>,
263    /// The cuts through it, in the order they were made, each taken out of
264    /// the sheet as it is laid out flat (see [`crate::layout`]).
265    pub cuts: Vec<Cut>,
266}
267
268/// A cut through the sheet (see [`crate::SheetCut`]): closed loops, each
269/// counter-clockwise around what it takes away, drawn in flat `flat`'s
270/// sheet coordinates — and so, where they reach past the flat, across its
271/// bends unrolled.
272#[derive(Clone, Debug, PartialEq)]
273pub struct Cut {
274    pub flat: usize,
275    pub loops: Vec<Vec<FlatEdge>>,
276    /// The cut's own name, `C` for the operation `sheet_cut(C)`.
277    pub name: Namer,
278}
279
280/// How a bend turns, worked out from its parent flat: the frame of its
281/// first edge and its radii.
282pub struct BendFrame {
283    /// Along the parent's edge, in the direction its loop runs it.
284    pub tau: Vector3,
285    /// Out of the parent, across the edge, in its plane.
286    pub m: Vector3,
287    /// The parent's normal.
288    pub n: Vector3,
289    /// `1` turning towards the B side, `-1` away from it.
290    pub sigma: Scalar,
291    /// The radius of its A side and of its B side.
292    pub r_a: Scalar,
293    pub r_b: Scalar,
294    pub cos: Scalar,
295    pub sin: Scalar,
296    /// A point on the axis.
297    pub center: Vector3,
298}
299
300impl BendFrame {
301    /// A bend from the straight parent edge `a -> b` of a flat placed at
302    /// `place`, turning by `angle` with inner radius `radius` in a sheet
303    /// `thickness` thick.
304    pub fn new(
305        place: &Placement,
306        [a, b]: [Vector2; 2],
307        angle: Scalar,
308        radius: Scalar,
309        thickness: Scalar,
310        toward_b: bool,
311    ) -> GeopResult<Self> {
312        let tau = place.direction(&b.sub(&a)).normalize()?;
313        let n = place.n;
314        let m = tau.prod_cross(&n);
315        let (sigma, r_a, r_b) = if toward_b {
316            (Scalar::ONE, radius.add(thickness), radius)
317        } else {
318            (Scalar::ONE.neg(), radius, radius.add(thickness))
319        };
320        Ok(Self {
321            center: place.point(&a).add(&n.prod_scalar(sigma.mul(r_a))),
322            tau,
323            m,
324            n,
325            sigma,
326            r_a,
327            r_b,
328            cos: angle.cos(),
329            sin: angle.sin(),
330        })
331    }
332
333    /// The vector `x` turned by the bend: `m` towards the side it turns
334    /// to, about its axis.
335    pub fn rotate(&self, x: &Vector3) -> Vector3 {
336        let (along_m, along_tau, along_n) = (
337            x.prod_dot(&self.m),
338            x.prod_dot(&self.tau),
339            x.prod_dot(&self.n),
340        );
341        let sigma_sin = self.sigma.mul(self.sin);
342        let m = self
343            .m
344            .prod_scalar(self.cos)
345            .add(&self.n.prod_scalar(sigma_sin));
346        let n = self
347            .n
348            .prod_scalar(self.cos)
349            .sub(&self.m.prod_scalar(sigma_sin));
350        m.prod_scalar(along_m)
351            .add(&self.tau.prod_scalar(along_tau))
352            .add(&n.prod_scalar(along_n))
353    }
354
355    /// Where the child flat lies: the parent's placement turned about the
356    /// bend's axis. Its edge where the bend ends is the parent's where it
357    /// starts, in sheet coordinates.
358    pub fn child_placement(&self, parent: &Placement) -> Placement {
359        Placement {
360            origin: self
361                .center
362                .add(&self.rotate(&parent.origin.sub(&self.center))),
363            e1: self.rotate(&parent.e1),
364            e2: self.rotate(&parent.e2),
365            n: self.rotate(&parent.n),
366        }
367    }
368}
369
370impl Sheet {
371    /// The flat and position in its outer loop of the edge `key`.
372    pub fn outer_edge(&self, key: &str) -> Option<(usize, usize)> {
373        self.flats.iter().enumerate().find_map(|(f, flat)| {
374            flat.outer
375                .iter()
376                .position(|e| e.key() == key)
377                .map(|k| (f, k))
378        })
379    }
380
381    /// Whether a bend starts or ends at the edge `key`.
382    pub fn is_bent(&self, key: &str) -> bool {
383        self.bends
384            .iter()
385            .any(|b| b.parent_edge == key || b.child_edge == key)
386    }
387
388    /// The flat edge — `(flat, position in its outer loop)` — whose A side
389    /// (`false`) or B side (`true`) edge of the body is named `name`.
390    pub fn edge_named(&self, name: &str) -> Option<(usize, usize, bool)> {
391        self.flats.iter().enumerate().find_map(|(f, flat)| {
392            flat.outer.iter().enumerate().find_map(|(k, e)| {
393                if e.name.name(&["a"]) == name {
394                    Some((f, k, false))
395                } else if e.name.name(&["b"]) == name {
396                    Some((f, k, true))
397                } else {
398                    None
399                }
400            })
401        })
402    }
403
404    /// Where bend `bend` starts: its parent edge's ends.
405    pub(crate) fn parent_line(&self, bend: &Bend) -> GeopResult<[Vector2; 2]> {
406        let (f, k) = self.outer_edge(&bend.parent_edge).ok_or_else(|| {
407            GeopError::new(format!(
408                "bend {} starts at {}, which is no edge of a flat's outline",
409                bend.name.root(),
410                bend.parent_edge
411            ))
412        })?;
413        self.flats[f].outer[k].line().ok_or_else(|| {
414            GeopError::new(format!(
415                "bend {} starts at {}, which is not straight",
416                bend.name.root(),
417                bend.parent_edge
418            ))
419        })?
420    }
421
422    /// How bend `bend` turns.
423    pub fn bend_frame(&self, bend: &Bend) -> GeopResult<BendFrame> {
424        BendFrame::new(
425            &self.flats[bend.parent].place,
426            self.parent_line(bend)?,
427            bend.angle,
428            bend.radius,
429            Scalar::from_f64(self.rules.thickness),
430            bend.toward_b,
431        )
432    }
433
434    /// Bend `bend`'s developed length: its angle times the radius of the
435    /// neutral surface, `radius + k_factor * thickness`.
436    pub fn developed_length(&self, bend: &Bend) -> Scalar {
437        let neutral = bend
438            .radius
439            .add(Scalar::from_f64(self.rules.k_factor).mul(Scalar::from_f64(self.rules.thickness)));
440        bend.angle.mul(neutral)
441    }
442
443    /// Where each bend lies in the flat pattern, in the order the bends
444    /// were made: the line down the middle of its strip, from where its
445    /// parent edge starts to where it ends, in the first flat's sheet
446    /// coordinates.
447    pub fn bend_lines(&self) -> GeopResult<Vec<[Vector2; 2]>> {
448        let shifts = self.flat_shifts()?;
449        self.bends
450            .iter()
451            .map(|bend| {
452                let [a, b] = self.parent_line(bend)?;
453                // Halfway across the strip, which reaches out of the parent.
454                let tau = b.sub(&a).normalize()?;
455                let out = Vector2::from_array([tau[1], tau[0].neg()]);
456                let half = out.prod_scalar(self.developed_length(bend).div(Scalar::TWO)?);
457                let shift = shifts[bend.parent].add(&half);
458                Ok([a.add(&shift), b.add(&shift)])
459            })
460            .collect()
461    }
462
463    /// Where every flat lies in the flat pattern, in sheet coordinates:
464    /// the first where it is, each child moved away from its parent, out
465    /// across the bend's first edge, by the bend's developed length.
466    pub fn flat_shifts(&self) -> GeopResult<Vec<Vector2>> {
467        let mut shifts: Vec<Option<Vector2>> = vec![None; self.flats.len()];
468        shifts[0] = Some(Vector2::zero());
469        for bend in &self.bends {
470            let [a, b] = self.parent_line(bend)?;
471            let tau = b.sub(&a).normalize()?;
472            let out = Vector2::from_array([tau[1], tau[0].neg()]);
473            let parent = shifts[bend.parent].ok_or_else(|| {
474                GeopError::new(format!(
475                    "bend {} hangs off a flat that is not laid out before it",
476                    bend.name.root()
477                ))
478            })?;
479            shifts[bend.child] = Some(parent.add(&out.prod_scalar(self.developed_length(bend))));
480        }
481        shifts
482            .into_iter()
483            .enumerate()
484            .map(|(f, s)| {
485                s.ok_or_else(|| {
486                    GeopError::new(format!(
487                        "flat {} is joined to the rest by no bend",
488                        self.flats[f].name.root()
489                    ))
490                })
491            })
492            .collect()
493    }
494}
495
496impl Sheet {
497    /// Builds the solid of this sheet, as it is bent, in `model` in place
498    /// of the solid `old`, if any — named `name`.
499    pub(crate) fn build(
500        &self,
501        model: &mut NamedModel,
502        old: Option<&str>,
503        name: &str,
504    ) -> GeopResult<()> {
505        let folded = self.folded()?;
506        if let Some(old) = old {
507            let id = model.solid_id(old)?;
508            model.delete_bodies(&[Body::Solid(id)])?;
509        }
510        thicken(model, &folded, name, &|n| n)?;
511        Ok(())
512    }
513}
514
515/// A straight flat edge from `a` to `b`, named `name`.
516pub fn straight(a: Vector2, b: Vector2, name: Namer) -> GeopResult<FlatEdge> {
517    Ok(FlatEdge {
518        curve: NurbCurve2D::line(a, b)?,
519        name,
520    })
521}