Skip to main content

ogeom_mesh/
thicken.rs

1//! Thickening an open mesh into a closed one: two offset layers of it
2//! joined by a wall along each rim.
3//!
4//! The sheet's faces face the way their corners wind counter-clockwise.
5//! Each layer is the sheet offset by [`Editable::offset`], folds repaired:
6//! the front layer, which keeps the sheet's winding, by the larger
7//! distance, and the back layer, wound the other way, by the smaller. The
8//! layers' rims are the sheet's, less any rim vertex a fold repair merged
9//! along the rim, so each rim of one layer is walked beside the same rim of
10//! the other, by where its vertices came from on the sheet's rim, and the
11//! strip between them is closed with triangles, each joining an edge of one
12//! layer to a vertex of the other.
13//!
14//! The result is checked exactly as [`boolean`](crate::boolean()) checks
15//! an operand, so layers or walls that run into each other, where the sheet
16//! bends tighter than the distance or its rim nearly touches itself, are
17//! refused rather than returned.
18
19use ogeom_core::{OgeomResult, ogeom_bail};
20use ogeom_topo::Triangulation;
21
22use crate::boolean::check_solid;
23use crate::editable::{Editable, OffsetReport};
24
25/// Which side of a sheet a thickening grows to.
26#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
27pub enum Thicken {
28    /// The side the sheet's faces face away from: the sheet is the front.
29    Inward,
30    /// The side the sheet's faces face: the sheet is the back.
31    Outward,
32    /// Half to each side.
33    Both,
34}
35
36/// A thickened sheet.
37#[derive(Debug, Clone, PartialEq)]
38pub struct Thickened {
39    /// The solid's boundary: the front layer, then the back layer, then the
40    /// walls; closed, 2-manifold and wound outward. It carries no normals
41    /// and no parameters.
42    pub mesh: Triangulation,
43    /// What the offset did to the front layer, the one the sheet's faces
44    /// face out of the solid on.
45    pub front: OffsetReport,
46    /// What it did to the back layer.
47    pub back: OffsetReport,
48}
49
50const NOTHING: OffsetReport = OffsetReport {
51    merged: 0,
52    eased: 0,
53    capped: 0,
54    folded: 0,
55};
56
57/// Thicken an open mesh by `distance` to `side`.
58///
59/// The sheet must be 2-manifold and have a rim; its vertices are taken as
60/// numbered, not welded.
61///
62/// # Errors
63///
64/// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if
65/// `distance` is not a positive distance, the sheet is not 2-manifold or is
66/// closed, or the result does not bound a solid as
67/// [`boolean`](crate::boolean()) asks of an operand, named "thickened";
68/// [`OgeomError::NullObject`](ogeom_core::OgeomError::NullObject) if it has
69/// no triangles; and as [`Editable::from`] and [`Editable::offset`].
70pub fn thicken(mesh: &Triangulation, distance: f64, side: Thicken) -> OgeomResult<Thickened> {
71    if !(distance.is_finite() && distance > 0.0) {
72        ogeom_bail!(Construction, "a thickness of {distance} is not a distance");
73    }
74    if mesh.triangles.is_empty() {
75        ogeom_bail!(NullObject, "the sheet has no triangles");
76    }
77    let sheet = Editable::from(mesh, 0.0)?;
78    if let Some(v) = sheet.non_manifold().first() {
79        ogeom_bail!(
80            Construction,
81            "the sheet is not 2-manifold at vertex {}",
82            sheet.source_vertex(*v).unwrap_or(*v)
83        );
84    }
85    let rims = sheet.rims();
86    if rims.is_empty() {
87        ogeom_bail!(
88            Construction,
89            "the sheet is closed: it bounds a solid already, to hollow rather than thicken"
90        );
91    }
92    let (back, front) = match side {
93        Thicken::Inward => (-distance, 0.0),
94        Thicken::Outward => (0.0, distance),
95        Thicken::Both => (-0.5 * distance, 0.5 * distance),
96    };
97    let layer = |d: f64| -> OgeomResult<(Editable, OffsetReport)> {
98        let mut e = sheet.clone();
99        let report = if d == 0.0 {
100            NOTHING
101        } else {
102            e.offset(d, None)?
103        };
104        Ok((e, report))
105    };
106    let (front_layer, front_report) = layer(front)?;
107    let (back_layer, back_report) = layer(back)?;
108
109    // Each sheet rim vertex's rim and place along it.
110    let mut place = vec![None; sheet.vertex_count()];
111    let mut lengths = Vec::with_capacity(rims.len());
112    for (r, rim) in rims.iter().enumerate() {
113        for (at, &h) in rim.edges.iter().enumerate() {
114            if let Some([v, _]) = sheet.edge(h) {
115                place[v as usize] = Some((r, at));
116            }
117        }
118        lengths.push(rim.edges.len());
119    }
120
121    let mut out = Triangulation::new();
122    let front_numbers = take(&front_layer, &mut out, false);
123    let back_numbers = take(&back_layer, &mut out, true);
124    let front_rims = along(&front_layer, &front_numbers, &place, rims.len())?;
125    let back_rims = along(&back_layer, &back_numbers, &place, rims.len())?;
126    for r in 0..rims.len() {
127        zip(&mut out, &back_rims[r], &front_rims[r], lengths[r])?;
128    }
129    out.deflection_met = false;
130    check_solid(&out, "thickened", 0)?;
131    Ok(Thickened {
132        mesh: out,
133        front: front_report,
134        back: back_report,
135    })
136}
137
138/// Add a layer's faces to `out`, wound the other way when `flip`; returns
139/// each vertex's number in `out`.
140fn take(e: &Editable, out: &mut Triangulation, flip: bool) -> Vec<Option<u32>> {
141    let mut number = vec![None; e.vertex_count()];
142    for f in 0..u32::try_from(e.face_count()).unwrap_or(u32::MAX) {
143        let Some(corners) = e.face(f) else {
144            continue;
145        };
146        let mapped = corners.map(|v| {
147            *number[v as usize].get_or_insert_with(|| {
148                let n = u32::try_from(out.positions.len()).unwrap_or(u32::MAX);
149                out.positions.push(e.position(v).unwrap_or_default());
150                n
151            })
152        });
153        out.triangles.push(if flip {
154            [mapped[0], mapped[2], mapped[1]]
155        } else {
156            mapped
157        });
158    }
159    number
160}
161
162/// Each sheet rim's vertices on a layer, as their places along the sheet's
163/// rim and their numbers in the output, in the rim's order from the lowest
164/// place.
165fn along(
166    e: &Editable,
167    number: &[Option<u32>],
168    place: &[Option<(usize, usize)>],
169    count: usize,
170) -> OgeomResult<Vec<Vec<(usize, u32)>>> {
171    let mut found: Vec<Vec<(usize, u32)>> = vec![Vec::new(); count];
172    for rim in e.rims() {
173        let mut walk = Vec::with_capacity(rim.edges.len());
174        let mut which = None;
175        for &h in &rim.edges {
176            let Some([v, _]) = e.edge(h) else {
177                continue;
178            };
179            let at = e
180                .source_vertex(v)
181                .and_then(|s| place.get(s as usize).copied().flatten());
182            let (Some((r, at)), Some(n)) = (at, number[v as usize]) else {
183                ogeom_bail!(
184                    Invariant,
185                    "an offset layer's rim has a vertex off the sheet's rim"
186                );
187            };
188            if which.is_some_and(|w| w != r) {
189                ogeom_bail!(
190                    Invariant,
191                    "an offset layer's rim joins two of the sheet's rims"
192                );
193            }
194            which = Some(r);
195            walk.push((at, n));
196        }
197        let Some(r) = which else { continue };
198        if !found[r].is_empty() {
199            ogeom_bail!(
200                Invariant,
201                "an offset layer parted one of the sheet's rims in two"
202            );
203        }
204        let start = walk
205            .iter()
206            .enumerate()
207            .min_by_key(|(_, (at, _))| *at)
208            .map_or(0, |(k, _)| k);
209        walk.rotate_left(start);
210        if walk.windows(2).any(|w| w[0].0 >= w[1].0) {
211            ogeom_bail!(
212                Invariant,
213                "an offset layer's rim does not keep the order of the sheet's"
214            );
215        }
216        found[r] = walk;
217    }
218    if found.iter().any(Vec::is_empty) {
219        ogeom_bail!(Invariant, "an offset layer lost one of the sheet's rims");
220    }
221    Ok(found)
222}
223
224/// Close the strip between a rim of the back layer and the same rim of the
225/// front, each given by its vertices' places along the sheet's rim of
226/// `length` vertices. Each triangle takes the next edge of whichever layer
227/// reaches the lesser place next.
228fn zip(
229    out: &mut Triangulation,
230    back: &[(usize, u32)],
231    front: &[(usize, u32)],
232    length: usize,
233) -> OgeomResult<()> {
234    let (nb, nf) = (back.len(), front.len());
235    if nb < 2 || nf < 2 {
236        ogeom_bail!(
237            Invariant,
238            "an offset layer's rim has fewer than two vertices"
239        );
240    }
241    // The place of the `k`th vertex along, counted past the start.
242    let at = |side: &[(usize, u32)], k: usize| {
243        let n = side.len();
244        side[k % n].0 + length * (k / n)
245    };
246    let vertex = |side: &[(usize, u32)], k: usize| side[k % side.len()].1;
247    let (mut i, mut j) = (0, 0);
248    while i < nb || j < nf {
249        let take_back = j == nf || (i < nb && at(back, i + 1) <= at(front, j + 1));
250        if take_back {
251            out.triangles
252                .push([vertex(back, i), vertex(back, i + 1), vertex(front, j)]);
253            i += 1;
254        } else {
255            out.triangles
256                .push([vertex(back, i), vertex(front, j + 1), vertex(front, j)]);
257            j += 1;
258        }
259    }
260    Ok(())
261}
262
263#[cfg(test)]
264#[path = "thicken/tests.rs"]
265mod tests;