1use ogeom_core::{OgeomResult, ogeom_bail};
20use ogeom_topo::Triangulation;
21
22use crate::boolean::check_solid;
23use crate::editable::{Editable, OffsetReport};
24
25#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
27pub enum Thicken {
28 Inward,
30 Outward,
32 Both,
34}
35
36#[derive(Debug, Clone, PartialEq)]
38pub struct Thickened {
39 pub mesh: Triangulation,
43 pub front: OffsetReport,
46 pub back: OffsetReport,
48}
49
50const NOTHING: OffsetReport = OffsetReport {
51 merged: 0,
52 eased: 0,
53 capped: 0,
54 folded: 0,
55};
56
57pub 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 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
138fn 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
162fn 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
224fn 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 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;