Skip to main content

fast_mesh_ops/
offset.rs

1//! Offsetting a surface along its normals and solidifying it into a shell.
2//!
3//! [`offset`] moves every live vertex along its smooth vertex normal
4//! ([`compute_vertex_normals`](fast_mesh::compute_vertex_normals)), by
5//! `distance` in local units. A positive distance follows the normals, which
6//! is outward on an outward-wound closed mesh; a negative distance sinks the
7//! surface inward. A zero distance writes nothing.
8//!
9//! [`shell`] solidifies the mesh: it keeps the input surface, adds a copy
10//! moved by `thickness` against the smooth normals with reversed winding, and
11//! stitches every boundary rim to its copy with a flat wall band, so an open
12//! mesh comes out closed. A closed mesh has no rims to stitch; it comes out
13//! as two nested closed components. Triangles with a repeated vertex id are
14//! not copied: they have no surface to solidify.
15//!
16//! Both operations read their whole plan from the untouched mesh and only
17//! then write, so an error leaves the mesh as it was. After writing, both
18//! run [`remove_degenerate`](crate::remove_degenerate) with the default
19//! options over the result and report what it removed: sharp concave offsets
20//! pinch triangles down to nothing, and those are dropped here. A fold that
21//! keeps full area (a flipped but stretched triangle) is not degenerate and
22//! is left alone; [`analyze`](crate::analyze) with a BVH reports such
23//! self-intersections.
24
25use fast_mesh::compute_vertex_normals;
26use fast_mesh_store::{Mesh, Transaction, UV_CHANNEL, VertexId};
27
28use crate::topology::{EdgeMap, boundary_loops, proper_triangle};
29use crate::{DegenerateOptions, OpsError, Result, filled, push, remove_degenerate, reserve};
30
31/// Options for [`offset`].
32#[derive(Debug, Clone, Copy, Default, PartialEq)]
33pub struct OffsetOptions {
34    /// How far to move the surface along its smooth normals, in local units.
35    /// Positive follows the normals (outward on an outward-wound closed
36    /// mesh); negative sinks inward. Must be finite; zero writes nothing.
37    pub distance: f32,
38}
39
40impl OffsetOptions {
41    /// A builder for [`OffsetOptions`]: a zero distance until set.
42    pub fn builder() -> OffsetOptionsBuilder {
43        OffsetOptionsBuilder::default()
44    }
45}
46
47/// A builder for [`OffsetOptions`], see [`OffsetOptions::builder`].
48#[derive(Debug, Clone, Default)]
49pub struct OffsetOptionsBuilder {
50    options: OffsetOptions,
51}
52
53impl OffsetOptionsBuilder {
54    /// How far to move the surface along its smooth normals, in local units.
55    pub fn distance(mut self, distance: f32) -> Self {
56        self.options.distance = distance;
57        self
58    }
59
60    /// The options as set.
61    pub fn build(self) -> OffsetOptions {
62        self.options
63    }
64}
65
66/// What [`offset`] did.
67#[derive(Debug, Clone, Copy, Default, PartialEq)]
68pub struct OffsetReport {
69    /// How many vertices moved (displacement above 0).
70    pub moved: usize,
71    /// The largest displacement applied, in local units; 0 when nothing
72    /// moved.
73    pub max_displacement: f32,
74    /// Degenerate triangles the cleanup pass removed.
75    pub degenerate_removed: usize,
76    /// Vertices the cleanup pass removed.
77    pub vertices_removed: usize,
78}
79
80/// Moves every live vertex along its smooth normal by `distance`.
81///
82/// - Region: every live vertex, displaced by `distance * normal` where
83///   `normal` is its smooth normal. Vertices with a zero normal (used by no
84///   non-degenerate triangle) or a non-finite displacement stay put.
85/// - The move is written once, in a single `set_positions` call over the
86///   vertices whose displacement is above 0; an empty move writes nothing.
87/// - Cleanup: [`remove_degenerate`](crate::remove_degenerate) with the
88///   default options runs over the result and drops the triangles the offset
89///   pinched down to nothing (and the vertices left unused), reported as
90///   `degenerate_removed` and `vertices_removed`. History is untouched.
91///
92/// # Errors
93/// - [`OpsError::InvalidInput`] when the distance is not finite, or the mesh
94///   buffers are not a valid mesh.
95/// - [`OpsError::Store`] when memory runs out.
96///
97/// Nothing is written in any of these cases.
98pub fn offset(tx: &mut Transaction, options: &OffsetOptions) -> Result<OffsetReport> {
99    let distance = options.distance;
100    if !distance.is_finite() {
101        return Err(OpsError::InvalidInput("offset distance must be finite"));
102    }
103    let mut report = OffsetReport::default();
104    if distance == 0.0 {
105        return Ok(report);
106    }
107    let mesh = tx.mesh();
108    if mesh.triangle_count() == 0 {
109        return Ok(report);
110    }
111    let normals = smooth_normals(mesh)?;
112    let positions = mesh.positions();
113    let mut ids: Vec<VertexId> = Vec::new();
114    let mut moved_to: Vec<[f32; 3]> = Vec::new();
115    for (v, p) in positions.iter().enumerate() {
116        let v = v as VertexId;
117        if !mesh.is_vertex_alive(v) {
118            continue;
119        }
120        let n = [
121            normals[3 * v as usize],
122            normals[3 * v as usize + 1],
123            normals[3 * v as usize + 2],
124        ];
125        let displaced = [
126            p[0] + distance * n[0],
127            p[1] + distance * n[1],
128            p[2] + distance * n[2],
129        ];
130        let length = n.iter().map(|n| (distance * n).powi(2)).sum::<f32>().sqrt();
131        if !length.is_finite() || length <= 0.0 {
132            continue;
133        }
134        push(&mut ids, v)?;
135        push(&mut moved_to, displaced)?;
136        report.max_displacement = report.max_displacement.max(length);
137    }
138    report.moved = ids.len();
139    if !ids.is_empty() {
140        tx.set_positions(&ids, &moved_to)?;
141    }
142    let cleanup = remove_degenerate(tx, &DegenerateOptions::default())?;
143    report.degenerate_removed = cleanup.removed_triangles;
144    report.vertices_removed = cleanup.removed_vertices;
145    Ok(report)
146}
147
148/// Options for [`shell`].
149#[derive(Debug, Clone, Copy, PartialEq)]
150pub struct ShellOptions {
151    /// Wall thickness in local units: the copy sits this far against the
152    /// smooth normals. Must be positive and finite.
153    pub thickness: f32,
154}
155
156impl Default for ShellOptions {
157    fn default() -> Self {
158        ShellOptions { thickness: 1.0 }
159    }
160}
161
162impl ShellOptions {
163    /// A builder for [`ShellOptions`]: a unit thickness until set.
164    pub fn builder() -> ShellOptionsBuilder {
165        ShellOptionsBuilder::default()
166    }
167}
168
169/// A builder for [`ShellOptions`], see [`ShellOptions::builder`].
170#[derive(Debug, Clone, Default)]
171pub struct ShellOptionsBuilder {
172    options: ShellOptions,
173}
174
175impl ShellOptionsBuilder {
176    /// Wall thickness in local units.
177    pub fn thickness(mut self, thickness: f32) -> Self {
178        self.options.thickness = thickness;
179        self
180    }
181
182    /// The options as set.
183    pub fn build(self) -> ShellOptions {
184        self.options
185    }
186}
187
188/// What [`shell`] did.
189#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
190pub struct ShellReport {
191    /// Copy vertices added.
192    pub vertices_added: usize,
193    /// Copy and wall triangles added.
194    pub triangles_added: usize,
195    /// Boundary rims stitched to their copy. A closed mesh has none.
196    pub loops_closed: usize,
197    /// Degenerate triangles the cleanup pass removed.
198    pub degenerate_removed: usize,
199    /// Vertices the cleanup pass removed.
200    pub vertices_removed: usize,
201}
202
203/// A corner of a planned triangle: a kept live vertex or a copy vertex by
204/// its index in the planned copy positions.
205#[derive(Debug, Clone, Copy)]
206enum Corner {
207    Kept(VertexId),
208    Copy(usize),
209}
210
211/// Solidifies the mesh into a printable shell of wall `thickness`.
212///
213/// - The input surface stays. Every live vertex gains a copy moved by
214///   `thickness` against its smooth normal; every proper triangle gains a
215///   reversed copy over the copied vertices, so the copy faces outward from
216///   the wall interior.
217/// - Every boundary edge (an edge of exactly one proper triangle) gains a
218///   flat wall quad over its copy: for the edge the outer triangle uses as
219///   `x -> y`, the quad is `(y, x, x')` and `(y, x', y')`, which agrees with
220///   both sheets along their shared edges. Rims through non-manifold
221///   vertices or orientation seams are stitched edge by edge on a
222///   best-effort basis and may stay open.
223/// - Writes happen in order: copy vertices, then copy and wall triangles.
224///   Then [`remove_degenerate`](crate::remove_degenerate) with the default
225///   options drops collapsed triangles (a wall thinner than the local
226///   curvature pinches its copy to nothing) and unused vertices, reported as
227///   `degenerate_removed` and `vertices_removed`. History is untouched.
228///
229/// # Errors
230/// - [`OpsError::InvalidInput`] when the thickness is not positive and
231///   finite, when a vertex position or a copy position is not finite, or
232///   when the mesh buffers are not a valid mesh.
233/// - [`OpsError::Store`] when memory runs out.
234///
235/// Nothing is written in any of these cases.
236pub fn shell(tx: &mut Transaction, options: &ShellOptions) -> Result<ShellReport> {
237    let thickness = options.thickness;
238    if !thickness.is_finite() || thickness <= 0.0 {
239        return Err(OpsError::InvalidInput(
240            "shell thickness must be positive and finite",
241        ));
242    }
243    let mut report = ShellReport::default();
244    let mesh = tx.mesh();
245    if mesh.triangle_count() == 0 {
246        return Ok(report);
247    }
248    let normals = smooth_normals(mesh)?;
249    let positions = mesh.positions();
250    // The copy of every live vertex, in ascending id order, with each live
251    // vertex's index in it. Planned from the untouched mesh, before writing.
252    let mut copied: Vec<[f32; 3]> = Vec::new();
253    // On a mesh with UVs, each copy keeps its original's.
254    let source_uvs = mesh.uvs();
255    let mut copied_uvs: Vec<f32> = Vec::new();
256    let mut at: Vec<Option<usize>> = filled(mesh.vertex_slots(), None)?;
257    for (v, p) in positions.iter().enumerate() {
258        let v = v as VertexId;
259        if !mesh.is_vertex_alive(v) {
260            continue;
261        }
262        if !p.iter().all(|c| c.is_finite()) {
263            return Err(OpsError::InvalidInput(
264                "shell needs finite vertex positions",
265            ));
266        }
267        let n = [
268            normals[3 * v as usize],
269            normals[3 * v as usize + 1],
270            normals[3 * v as usize + 2],
271        ];
272        let q = [
273            p[0] - thickness * n[0],
274            p[1] - thickness * n[1],
275            p[2] - thickness * n[2],
276        ];
277        if !q.iter().all(|c| c.is_finite()) {
278            return Err(OpsError::InvalidInput("shell thickness leaves f32 range"));
279        }
280        push(&mut copied, q)?;
281        if let Some(uv) = source_uvs {
282            reserve(&mut copied_uvs, 2)?;
283            copied_uvs.extend_from_slice(&uv[2 * v as usize..2 * v as usize + 2]);
284        }
285        at[v as usize] = Some(copied.len() - 1);
286    }
287    // The reversed copies and the wall quads, in ascending triangle and edge
288    // order. Triangles with a repeated vertex id are not copied.
289    let mut planned: Vec<[Corner; 3]> = Vec::new();
290    for t in 0..mesh.triangle_slots() as u32 {
291        if let Some([a, b, c]) = proper_triangle(mesh, t) {
292            let [ia, ib, ic] = [a, b, c].map(|v| at[v as usize].expect("live corner"));
293            push(
294                &mut planned,
295                [Corner::Copy(ic), Corner::Copy(ib), Corner::Copy(ia)],
296            )?;
297        }
298    }
299    let edges = EdgeMap::build(mesh)?;
300    for ((u, v), tris) in edges.edges() {
301        if tris.len() != 1 {
302            continue;
303        }
304        let [a, b, c] = mesh.triangle(tris[0]).expect("mapped triangle is alive");
305        let (x, y) = [(a, b), (b, c), (c, a)]
306            .into_iter()
307            .find(|&(p, q)| (p == u && q == v) || (p == v && q == u))
308            .expect("mapped edge uses its triangle");
309        let (ix, iy) = (
310            at[x as usize].expect("live corner"),
311            at[y as usize].expect("live corner"),
312        );
313        push(
314            &mut planned,
315            [Corner::Kept(y), Corner::Kept(x), Corner::Copy(ix)],
316        )?;
317        push(
318            &mut planned,
319            [Corner::Kept(y), Corner::Copy(ix), Corner::Copy(iy)],
320        )?;
321    }
322    report.loops_closed = boundary_loops(mesh)?.len();
323    let has_uvs = source_uvs.is_some();
324    let fresh = tx.add_vertices(&copied)?;
325    if has_uvs {
326        tx.write_channel(UV_CHANNEL, &fresh, &copied_uvs)?;
327    }
328    let resolve = |c: Corner| match c {
329        Corner::Kept(v) => v,
330        Corner::Copy(i) => fresh[i],
331    };
332    let mut added: Vec<[VertexId; 3]> = Vec::new();
333    reserve(&mut added, planned.len())?;
334    for tri in planned {
335        added.push(tri.map(resolve));
336    }
337    tx.add_triangles(&added)?;
338    report.vertices_added = fresh.len();
339    report.triangles_added = added.len();
340    let cleanup = remove_degenerate(tx, &DegenerateOptions::default())?;
341    report.degenerate_removed = cleanup.removed_triangles;
342    report.vertices_removed = cleanup.removed_vertices;
343    Ok(report)
344}
345
346/// The smooth vertex normals of `mesh`, slot-aligned, as
347/// [`inflate`](crate::deform::inflate) computes them.
348fn smooth_normals(mesh: &Mesh) -> Result<Vec<f32>> {
349    let positions = mesh.positions();
350    let mut flat: Vec<f32> = Vec::new();
351    reserve(&mut flat, positions.len() * 3)?;
352    flat.extend(positions.iter().flatten().copied());
353    let mut indices: Vec<u32> = Vec::new();
354    reserve(&mut indices, mesh.triangle_slots() * 3)?;
355    for t in 0..mesh.triangle_slots() as u32 {
356        if let Some(tri) = mesh.triangle(t) {
357            indices.extend(tri);
358        }
359    }
360    compute_vertex_normals(&flat, &indices)
361        .map_err(|_| OpsError::InvalidInput("mesh buffers are not a valid mesh"))
362}
363
364#[cfg(test)]
365mod tests {
366    use super::*;
367    use crate::test_util::{box_mesh, edit, grid, mesh_of, parts};
368
369    /// The distance of every live vertex from `c`.
370    fn distances(mesh: &Mesh, c: [f32; 3]) -> Vec<f32> {
371        let mut out = Vec::new();
372        for (v, p) in mesh.positions().iter().enumerate() {
373            if !mesh.is_vertex_alive(v as VertexId) {
374                continue;
375            }
376            out.push(
377                ((p[0] - c[0]).powi(2) + (p[1] - c[1]).powi(2) + (p[2] - c[2]).powi(2)).sqrt(),
378            );
379        }
380        out
381    }
382
383    #[test]
384    fn offset_moves_along_normals_by_distance() {
385        let mut mesh = grid(2);
386        let old = parts(&mesh).0;
387        let options = OffsetOptions::builder().distance(0.5).build();
388        let (report, empty) = edit(&mut mesh, |tx| offset(tx, &options));
389        assert!(!empty);
390        // A flat grid wound from +z: every normal is +z, so every vertex
391        // moves exactly 0.5 up.
392        assert_eq!(report.moved, 9);
393        assert!((report.max_displacement - 0.5).abs() < 1e-6);
394        assert_eq!(report.degenerate_removed, 0);
395        assert_eq!(report.vertices_removed, 0);
396        let new = parts(&mesh).0;
397        for v in 0..9 {
398            assert!((new[v * 3] - old[v * 3]).abs() < 1e-6);
399            assert!((new[v * 3 + 1] - old[v * 3 + 1]).abs() < 1e-6);
400            assert!((new[v * 3 + 2] - old[v * 3 + 2] - 0.5).abs() < 1e-6);
401        }
402    }
403
404    #[test]
405    fn offset_sign_follows_winding() {
406        // A closed, outward-wound box: a positive offset moves every corner
407        // exactly `distance` along its own (unit) smooth normal, which points
408        // outward, so every corner leaves the centroid behind; a negative
409        // distance sinks the corners back inward. (Box corners are shared by
410        // an uneven triangulation, so their normals are not exact diagonals:
411        // only the length and the outward sense are asserted.)
412        let mut mesh = box_mesh([0.0; 3], [1.0; 3]);
413        let c = [0.5; 3];
414        let before = distances(&mesh, c);
415        let (report, _) = edit(&mut mesh, |tx| {
416            offset(tx, &OffsetOptions::builder().distance(0.25).build())
417        });
418        assert_eq!(report.moved, 8);
419        assert!((report.max_displacement - 0.25).abs() < 1e-6);
420        let grown = distances(&mesh, c);
421        for (a, b) in before.iter().zip(&grown) {
422            assert!(*b > *a, "{a} -> {b}");
423            assert!((b - a) <= 0.25 + 1e-6, "{a} -> {b}");
424        }
425        // Every corner moved exactly 0.25: smooth normals are unit.
426        let prev = parts(&box_mesh([0.0; 3], [1.0; 3])).0;
427        let next = parts(&mesh).0;
428        for v in 0..8 {
429            let d = ((next[v * 3] - prev[v * 3]).powi(2)
430                + (next[v * 3 + 1] - prev[v * 3 + 1]).powi(2)
431                + (next[v * 3 + 2] - prev[v * 3 + 2]).powi(2))
432            .sqrt();
433            assert!((d - 0.25).abs() < 1e-5, "{d}");
434        }
435        // And a negative distance sinks the surface back past its start.
436        edit(&mut mesh, |tx| {
437            offset(tx, &OffsetOptions::builder().distance(-0.5).build()).map(drop)
438        });
439        let sunk = distances(&mesh, c);
440        for (a, b) in before.iter().zip(&sunk) {
441            assert!(*b < *a, "{a} -> {b}");
442        }
443    }
444
445    #[test]
446    fn offset_zero_distance_and_empty_mesh_are_noops() {
447        let mut mesh = grid(2);
448        let before = parts(&mesh);
449        let mut tx = mesh.begin_edit();
450        let report = offset(&mut tx, &OffsetOptions::builder().distance(0.0).build()).unwrap();
451        assert_eq!(report, OffsetReport::default());
452        assert!(tx.commit().unwrap().is_empty());
453        assert_eq!(parts(&mesh), before);
454        // No triangles: nothing to move along, and no cleanup either.
455        let mut mesh = Mesh::new();
456        let mut tx = mesh.begin_edit();
457        let report = offset(&mut tx, &OffsetOptions::builder().distance(1.0).build()).unwrap();
458        assert_eq!(report, OffsetReport::default());
459        assert!(tx.commit().unwrap().is_empty());
460    }
461
462    #[test]
463    fn offset_rejects_nonfinite_distance() {
464        for distance in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY] {
465            let mut mesh = grid(2);
466            let before = parts(&mesh);
467            let mut tx = mesh.begin_edit();
468            assert!(matches!(
469                offset(
470                    &mut tx,
471                    &OffsetOptions::builder().distance(distance).build()
472                ),
473                Err(OpsError::InvalidInput(_))
474            ));
475            assert!(tx.commit().unwrap().is_empty());
476            assert_eq!(parts(&mesh), before);
477        }
478    }
479
480    #[test]
481    fn offset_collapse_without_pinch_stays_valid() {
482        // A regular tetrahedron offset inward by its circumradius moves every
483        // vertex onto the centroid. The four triangles shrink to specks, but
484        // they stay well-shaped, so the cleanup keeps them: only exact
485        // pinches and pre-existing degenerates are removed, never valid
486        // triangles, however small. (Near-coincident vertices are not
487        // merged either; welding stays an explicit operation.)
488        let mut mesh = mesh_of(
489            &[1., 1., 1., 1., -1., -1., -1., 1., -1., -1., -1., 1.],
490            &[[0, 1, 2], [0, 3, 1], [0, 2, 3], [1, 3, 2]],
491        );
492        assert_eq!(mesh.vertex_count(), 4);
493        let c = [0.0; 3];
494        let radius = distances(&mesh, c)[0];
495        let (report, _) = edit(&mut mesh, |tx| {
496            offset(tx, &OffsetOptions::builder().distance(-radius).build())
497        });
498        assert_eq!(report.moved, 4);
499        assert!((report.max_displacement - radius).abs() < 1e-4);
500        assert_eq!(report.degenerate_removed, 0);
501        assert_eq!(mesh.triangle_count(), 4);
502        for d in distances(&mesh, c) {
503            assert!(d < 1e-6, "{d}");
504        }
505    }
506
507    #[test]
508    fn offset_cleans_preexisting_degenerate_triangles() {
509        // A grid with an extra collapsed triangle: the offset moves the grid
510        // and the cleanup drops the collapsed triangle, reporting it.
511        let mut mesh = grid(2);
512        let (flat, mut tris) = parts(&mesh);
513        tris.push([0, 0, 1]);
514        mesh = mesh_of(&flat, &tris);
515        assert_eq!(mesh.triangle_count(), 9);
516        let options = OffsetOptions::builder().distance(0.5).build();
517        let (report, _) = edit(&mut mesh, |tx| offset(tx, &options));
518        assert_eq!(report.moved, 9);
519        assert_eq!(report.degenerate_removed, 1);
520        assert_eq!(mesh.triangle_count(), 8);
521    }
522
523    #[test]
524    fn shell_stitches_an_open_triangle() {
525        // One triangle in z = 0, wound from +z: the copy lands at z = -t and
526        // three wall quads stitch the rim.
527        let mut mesh = mesh_of(&[0., 0., 0., 1., 0., 0., 0., 1., 0.], &[[0, 1, 2]]);
528        let (report, empty) = edit(&mut mesh, |tx| {
529            shell(tx, &ShellOptions::builder().thickness(0.5).build())
530        });
531        assert!(!empty);
532        assert_eq!(
533            report,
534            ShellReport {
535                vertices_added: 3,
536                triangles_added: 1 + 6,
537                loops_closed: 1,
538                degenerate_removed: 0,
539                vertices_removed: 0,
540            }
541        );
542        assert_eq!(mesh.vertex_count(), 6);
543        assert_eq!(mesh.triangle_count(), 8);
544        // The outer sheet stayed, the copy sits exactly one thickness below.
545        let (flat, _) = parts(&mesh);
546        assert_eq!(&flat[0..9], &[0., 0., 0., 1., 0., 0., 0., 1., 0.][..]);
547        assert_eq!(
548            &flat[9..18],
549            &[0., 0., -0.5, 1., 0., -0.5, 0., 1., -0.5][..]
550        );
551        // Closed and consistently wound: no boundary, no conflicts.
552        let analysis = crate::analyze(&mesh, &crate::AnalyzeOptions::default()).unwrap();
553        assert!(analysis.loops.is_empty());
554        assert_eq!(analysis.orientation_conflicts, 0);
555        assert!(analysis.components.iter().all(|c| c.closed));
556    }
557
558    #[test]
559    fn shell_nests_a_closed_cube() {
560        // A closed box has no rims: the reversed copy nests inside it.
561        let mut mesh = box_mesh([0.0; 3], [2.0; 3]);
562        let (report, _) = edit(&mut mesh, |tx| {
563            shell(tx, &ShellOptions::builder().thickness(0.25).build())
564        });
565        assert_eq!(report.vertices_added, 8);
566        assert_eq!(report.triangles_added, 12);
567        assert_eq!(report.loops_closed, 0);
568        assert_eq!(report.degenerate_removed, 0);
569        assert_eq!(mesh.triangle_count(), 24);
570        let analysis = crate::analyze(&mesh, &crate::AnalyzeOptions::default()).unwrap();
571        assert!(analysis.loops.is_empty());
572        assert_eq!(analysis.orientation_conflicts, 0);
573        assert_eq!(analysis.components.len(), 2);
574        assert!(analysis.components.iter().all(|c| c.closed));
575        // The outer sheet stayed exactly where it was.
576        let (flat, _) = parts(&mesh);
577        assert_eq!(flat.len(), 16 * 3);
578        let outer: Vec<f32> = (0..8)
579            .flat_map(|i| (0..3).map(move |k| if i >> k & 1 == 1 { 2.0 } else { 0.0 }))
580            .collect();
581        assert_eq!(&flat[0..24], &outer[..]);
582        // The inner copy sits one thickness inside: every copy vertex is
583        // exactly `thickness` from its original (unit smooth normals) and
584        // strictly inside the box (the thickness is smaller than its
585        // half-size, so nothing overshoots the far side).
586        for v in 0..8 {
587            let (o, q) = (&flat[v * 3..v * 3 + 3], &flat[(v + 8) * 3..(v + 8) * 3 + 3]);
588            let d = ((q[0] - o[0]).powi(2) + (q[1] - o[1]).powi(2) + (q[2] - o[2]).powi(2)).sqrt();
589            assert!((d - 0.25).abs() < 1e-5, "{d}");
590            for k in 0..3 {
591                assert!(q[k] > 0.0 && q[k] < 2.0, "{q:?}");
592            }
593        }
594    }
595
596    #[test]
597    fn shell_rejects_bad_thickness() {
598        for thickness in [0.0, -1.0, f32::NAN, f32::INFINITY] {
599            let mut mesh = grid(2);
600            let before = parts(&mesh);
601            let mut tx = mesh.begin_edit();
602            assert!(matches!(
603                shell(
604                    &mut tx,
605                    &ShellOptions::builder().thickness(thickness).build()
606                ),
607                Err(OpsError::InvalidInput(_))
608            ));
609            assert!(tx.commit().unwrap().is_empty());
610            assert_eq!(parts(&mesh), before);
611        }
612    }
613
614    #[test]
615    fn shell_empty_mesh_is_a_noop() {
616        let mut mesh = Mesh::new();
617        let mut tx = mesh.begin_edit();
618        let report = shell(&mut tx, &ShellOptions::builder().thickness(0.5).build()).unwrap();
619        assert_eq!(report, ShellReport::default());
620        assert!(tx.commit().unwrap().is_empty());
621    }
622
623    #[test]
624    fn shell_first_failure_writes_nothing() {
625        // The plan is read before the first write: failing the first
626        // reservation leaves the mesh untouched.
627        let mut mesh = grid(2);
628        let before = parts(&mesh);
629        let mut tx = mesh.begin_edit();
630        crate::fail_allocations_after(Some(0));
631        let r = shell(&mut tx, &ShellOptions::builder().thickness(0.5).build());
632        crate::fail_allocations_after(None);
633        assert_eq!(
634            r,
635            Err(OpsError::Store(fast_mesh_store::StoreError::OutOfMemory))
636        );
637        assert!(tx.commit().unwrap().is_empty());
638        assert_eq!(parts(&mesh), before);
639    }
640}