git.lucas.co / cce-designer
graphic design tool
git clone https://git.lucas.co/cce-designer.git

commit3d145e14337a82249ffe8d23117504896d69cd1f
parentc1e35e05a3
authorLucas Galante <[email protected]>
date2026-09-19 07:08
feat(mold): GEM Mold Shell, thickness driven by curvature

Phase 6's first GEM operator, ported from the plugin's gem_mold_shell. Its
four parameters are that node's — Maximum Thickness, Minimum Thickness,
Remesh Division Size, Thickness Ramp — and the template's defaults are the
numbers the production notes record for the cast that actually worked
(0.75 / 0.6 / 0.9, linear).

Thickness varies with CURVATURE, which is the whole point of the operator
and the reason the volume node's uniform shell will not do. The plugin
does it with an im_ramp_scalar named curvature_to_thickness; this does
the same three steps: remesh to the division size so thickness is carried
on evenly spaced points, measure curvature per point, map it through a
ramp into the thickness range.

The curvature measure is signed and DIMENSIONLESS — the mean of
dot(normalize(neighbour - p), n), negative convex, positive concave.
Every term is a dot product of two unit vectors, so it does not move when
the model is scaled or re-tessellated. That is the property that matters
here: thickness is chosen from it, and a measure that shifted with the
remesh division size would give a shell whose thickness changed every
time you re-tessellated. A true mean curvature in 1/length would do
exactly that, which is why this is not one.

The map into the range is affine over a fixed -1..1 rather than
normalized over the model's own range. Normalizing would make one part's
thickness depend on how curved the REST of it is, so adding a sharp
corner somewhere would thin the whole shell. Concave regions get the
maximum: a mould is weakest where it cups inward, with least material
behind it and most leverage on it when the cast is pulled.

The inner surface is a displacement along each point's normal, not a
field offset — an SDF offsets by a constant and cannot vary per point.
The cost is the usual one for offset-by-displacement: where thickness
exceeds the local radius of curvature the inner surface folds through
itself. That is what the min/max range is for. It is a range because the
geometry constrains it, not because one number was hard to pick.

No ramp PARAMETER type exists in this app (cce-ui has the widget, nothing
wires it as a node parameter), so the free-form float ramp is ported as
the three-way choice the falloff parameters already use, defaulting to
the linear the working cast used.

Co-Authored-By: Claude Opus 5 <[email protected]>

 CLAUDE.md             |  41 ++++++++++++
 nodes/mold_shell.json |  13 ++++
 shapeshifter.md       |  23 +++++++
 src/geometry.rs       |  35 ++++++++++
 src/main.rs           | 149 +++++++++++++++++++++++++++++++++++++++++++
 src/mold.rs           | 173 ++++++++++++++++++++++++++++++++++++++++++++++++++
 6 files changed, 434 insertions(+)

diff --git a/CLAUDE.md b/CLAUDE.md
index 8fa8ec2..2d840c6 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -333,6 +333,47 @@ Buttons dispatch through `execute_menu_action` by LABEL, which carries no node
 — `run_export` resolves the node from the current selection, which is sound
 because the pressed button can only be on the node the pane is showing.
 
+### Mold tooling
+
+`src/mold.rs` is the first GEM operator, ported from the plugin's
+`gem_mold_shell`. Its four parameters are that node's — Maximum Thickness,
+Minimum Thickness, Remesh Division Size, Thickness Ramp — and the production
+notes from the original cast give the numbers that worked (0.75 / 0.6 / 0.9,
+linear), which are the template's defaults.
+
+**Thickness varies with curvature**, which is the whole point and the reason
+the `volume` node's uniform shell will not do. The plugin does it with an
+`im_ramp_scalar` named `curvature_to_thickness`; this does the same three
+steps — remesh to the division size, measure curvature per point, map it
+through a ramp into the thickness range.
+
+`curvature` is a signed DIMENSIONLESS measure in roughly -1..1: the mean of
+`dot(normalize(neighbour - p), n)`. Negative is convex, positive concave. Every
+term is a dot product of two unit vectors, so it does not move when the model
+is scaled or re-tessellated — which matters because thickness is chosen from
+it, and a measure that shifted with the remesh division size would give a shell
+whose thickness changed every time you re-tessellated. A true mean curvature in
+1/length would do exactly that.
+
+The curvature-to-thickness map is affine over a FIXED -1..1, not normalized
+over the range present in the model. Normalizing would make one part's
+thickness depend on how curved the rest of it is, so adding a sharp corner
+somewhere would thin the whole shell. Concave regions get the maximum: a mould
+is weakest where it cups inward, with least material behind it and the most
+leverage on it when the cast is pulled.
+
+The inner surface is a DISPLACEMENT along each point's normal, not a field
+offset — a signed distance field offsets by a constant and cannot vary per
+point. The cost is the usual one: where thickness exceeds the local radius of
+curvature the inner surface folds through itself. That is what the
+minimum/maximum range is for; it is a range because the geometry constrains it,
+not because one number was hard to pick.
+
+There is no ramp PARAMETER type in this app (cce-ui has the widget, nothing
+wires it as a node parameter), so the free-form float ramp is ported as the
+three-way choice the falloff parameters already use. Linear is the default
+because linear is what the cast that worked used.
+
 ### The volume representation
 
 `src/volume.rs` is a dense signed distance field — `Volume { origin, voxel,
diff --git a/nodes/mold_shell.json b/nodes/mold_shell.json
new file mode 100644
index 0000000..b0e549d
--- /dev/null
+++ b/nodes/mold_shell.json
@@ -0,0 +1,13 @@
+{
+ "name": "Mold Shell",
+ "type": "mold_shell",
+ "inputs": 1,
+ "outputs": 1,
+ "params": [
+  { "name": "Input", "type": "text", "default": "" },
+  { "name": "Maximum Thickness", "type": "slider", "default": "0.75", "min": 0.001, "max": 5.0, "step": 0.01 },
+  { "name": "Minimum Thickness", "type": "slider", "default": "0.60", "min": 0.001, "max": 5.0, "step": 0.01 },
+  { "name": "Remesh Division Size", "type": "slider", "default": "0.90", "min": 0.01, "max": 5.0, "step": 0.01 },
+  { "name": "Ramp", "type": "choice:Linear,Smooth,Constant", "default": "Linear" }
+ ]
+}
diff --git a/shapeshifter.md b/shapeshifter.md
index 72a9c90..ecd6582 100644
--- a/shapeshifter.md
+++ b/shapeshifter.md
@@ -513,6 +513,29 @@ Touches: `shortcut.rs`, `app.rs`, `slots.rs`, `cce-ui`.
 > another's.
 >
 > Still outstanding: nothing in Phase 4.
+>
+> **Phase 6's first GEM operator landed.** `src/mold.rs` ports
+> `gem_mold_shell`: remesh to a division size, measure curvature per point, map
+> it through a ramp into a thickness range, and displace a copy of the surface
+> inward by that much. Its four parameters are the plugin's, and the template's
+> defaults are the numbers from the production notes for the cast that worked.
+>
+> The measure is deliberately dimensionless — the mean of
+> `dot(normalize(neighbour - p), n)` — so it does not move when the model is
+> scaled or re-tessellated. Thickness is chosen from it, and a measure that
+> shifted with the remesh division size would give a shell whose thickness
+> changed every time you re-tessellated. The map into the range is affine over
+> a fixed -1..1 rather than normalized over the model, so adding a sharp corner
+> somewhere cannot thin the whole shell.
+>
+> A rendering check turned up an unrelated hole: the `box` node has a template
+> and is listed as a geometry node type, but no resolver was ever written for
+> it, so it silently produces nothing and any chain reading from it resolves to
+> nothing. Raised separately rather than fixed here.
+>
+> The volume representation, mesh export and the 2D page context — the three
+> things this phase named as prerequisites — all landed earlier. What remains
+> is the rest of the GEM set: sprue, supports, build area, partition, orient.
 
 Furthest out because it needs infrastructure nothing else does: a **volume
 representation** (SDF or sparse grid) for shelling, offsetting and boolean work,
diff --git a/src/geometry.rs b/src/geometry.rs
index 3f9e4ac..c9aebc5 100644
--- a/src/geometry.rs
+++ b/src/geometry.rs
@@ -733,6 +733,8 @@ pub fn generate_single_node_geometry_with_errors(
         resolve_deform_geometry_with_errors(root, target, visited, ocl_error, sim)
     } else if target.node_type.eq_ignore_ascii_case("volume") {
         resolve_volume_geometry_with_errors(root, target, visited, ocl_error, sim)
+    } else if target.node_type.eq_ignore_ascii_case("mold_shell") {
+        resolve_mold_shell_geometry_with_errors(root, target, visited, ocl_error, sim)
     } else if target.node_type.eq_ignore_ascii_case("boolean") {
         resolve_boolean_geometry_with_errors(root, target, visited, ocl_error, sim)
     } else if target.node_type.eq_ignore_ascii_case("export") {
@@ -1751,6 +1753,29 @@ pub fn resolve_boolean_geometry_with_errors(
     Some(va.to_mesh())
 }
 
+/// The Mold Shell node: a cast's shell, thickened by curvature.
+pub fn resolve_mold_shell_geometry_with_errors(
+    root: &FsNode,
+    target: &FsNode,
+    visited: &mut Vec<String>,
+    ocl_error: &mut Option<String>,
+    sim: &mut EvalSim,
+) -> Option<Detail> {
+    let input_node = find_node_by_name(root, &node_param_str(target, "Input", ""))?;
+    let input = generate_single_node_geometry_with_errors(root, input_node, visited, ocl_error, sim)?;
+    let shell = crate::mold::mold_shell(
+        &input,
+        node_param_f32(target, "Minimum Thickness", 0.6),
+        node_param_f32(target, "Maximum Thickness", 0.75),
+        node_param_f32(target, "Remesh Division Size", 0.9),
+        crate::mold::Ramp::parse(&node_param_str(target, "Ramp", "Linear")),
+    );
+    // A node with nothing to thicken passes its input through rather than
+    // vanishing: an empty result in the middle of a chain reads as a broken
+    // node, and the thing that is actually wrong is upstream.
+    Some(shell.unwrap_or(input))
+}
+
 /// The Export node: geometry out of the app.
 ///
 /// A pass-through in the chain — it hands its input straight on, so it can sit
@@ -4919,6 +4944,7 @@ pub fn is_geometry_node_type(node_type: &str) -> bool {
         || nt == "subdivide"
         || nt == "export"
         || nt == "boolean"
+        || nt == "mold_shell"
         || nt == "volume"
         || nt == "deform"
         || nt == "valence"
@@ -5197,6 +5223,15 @@ pub fn network_sphere_vertices_with_errors(
                     out.merge(&geom);
                 }
             }
+        } else if node.node_type.eq_ignore_ascii_case("mold_shell") {
+            let _idx = *count;
+            *count += 1;
+            if is_visible {
+                let mut visited = Vec::new();
+                if let Some(geom) = resolve_mold_shell_geometry_with_errors(root, node, &mut visited, ocl_error, sim) {
+                    out.merge(&geom);
+                }
+            }
         } else if node.node_type.eq_ignore_ascii_case("export") {
             let _idx = *count;
             *count += 1;
diff --git a/src/main.rs b/src/main.rs
index c14df08..101cbfc 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -27,6 +27,7 @@ pub mod shortcut;
 pub mod slots;
 pub mod command;
 pub mod layout;
+pub mod mold;
 pub mod page;
 pub mod thumbnail;
 
@@ -4767,6 +4768,154 @@ mod tests {
         );
     }
 
+    /// Curvature is signed, dimensionless, and does not move when the model
+    /// is scaled or re-tessellated.
+    ///
+    /// That last property is the one that matters: thickness is chosen from
+    /// this measure, so a measure that changed with the remesh division size
+    /// would give a shell whose thickness moved every time you re-tessellated.
+    #[test]
+    fn test_curvature_is_signed_and_scale_free() {
+        use crate::mold::curvature;
+
+        // A sphere is convex everywhere, so every point reads negative, and
+        // every point reads the SAME — it has one curvature.
+        let sphere = crate::geometry::sphere_detail(glam::Vec3::ZERO, 1.0, 24, 32);
+        let c = curvature(&sphere);
+        assert!(c.iter().all(|v| *v < 0.0), "a sphere should read convex everywhere");
+        let (lo, hi) = c.iter().fold((f32::MAX, f32::MIN), |(l, h), v| (l.min(*v), h.max(*v)));
+        assert!(hi - lo < 0.08, "a sphere's curvature is not uniform: {lo}..{hi}");
+
+        // Ten times the size, same measure — this is what "dimensionless"
+        // buys, and it is why the thickness range means the same thing on a
+        // model of any size.
+        let big = crate::geometry::sphere_detail(glam::Vec3::ZERO, 10.0, 24, 32);
+        let cb = curvature(&big);
+        let mean = |v: &[f32]| v.iter().sum::<f32>() / v.len() as f32;
+        assert!(
+            (mean(&c) - mean(&cb)).abs() < 1e-3,
+            "curvature changed with scale: {} vs {}",
+            mean(&c),
+            mean(&cb)
+        );
+
+        // And it is finite on a mesh with isolated points — those read flat
+        // rather than NaN, which would poison the whole thickness range.
+        let mut stray = sphere.clone();
+        stray.add_point(glam::Vec3::new(50.0, 0.0, 0.0));
+        let cs = curvature(&stray);
+        assert!(cs.iter().all(|v| v.is_finite()), "curvature went non-finite");
+        assert_eq!(cs[cs.len() - 1], 0.0, "an isolated point should read flat");
+    }
+
+    /// The ramp maps curvature into the thickness range, and the range is
+    /// honoured whichever way round its ends are given.
+    #[test]
+    fn test_thickness_stays_inside_the_range() {
+        use crate::mold::{thickness_from_curvature, Ramp};
+        let curv = [-1.0, -0.5, 0.0, 0.5, 1.0];
+
+        let t = thickness_from_curvature(&curv, 0.6, 0.75, Ramp::Linear);
+        assert!(t.iter().all(|v| (0.6..=0.75).contains(v)), "{t:?} left the range");
+        assert!(t[0] < t[4], "concave should be thicker than convex");
+        assert!((t[0] - 0.6).abs() < 1e-6 && (t[4] - 0.75).abs() < 1e-6, "{t:?}");
+
+        // Constant is the uniform shell, reachable without leaving the node.
+        let t = thickness_from_curvature(&curv, 0.6, 0.75, Ramp::Constant);
+        assert!(t.iter().all(|v| (*v - 0.75).abs() < 1e-6), "{t:?}");
+
+        // Smooth flattens both ends rather than changing where they land.
+        let t = thickness_from_curvature(&curv, 0.0, 1.0, Ramp::Smooth);
+        assert!((t[0] - 0.0).abs() < 1e-6 && (t[4] - 1.0).abs() < 1e-6);
+        assert!(t[1] < 0.25 && t[3] > 0.75, "smooth did not flatten the ends: {t:?}");
+
+        // A range given backwards is still a range — min and max are the two
+        // ends, not an ordering the caller has to get right.
+        let a = thickness_from_curvature(&curv, 0.75, 0.6, Ramp::Linear);
+        let b = thickness_from_curvature(&curv, 0.6, 0.75, Ramp::Linear);
+        assert_eq!(a, b);
+    }
+
+    /// The shell end to end, through the resolver the viewport calls.
+    #[test]
+    fn test_the_mold_shell_node_builds_a_two_sided_shell() {
+        use crate::geometry::resolve_mold_shell_geometry_with_errors;
+        fn mnode(id: &str, name: &str, ty: &str, params: &[(&str, &str)]) -> FsNode {
+            FsNode {
+                id: id.to_string(),
+                name: name.to_string(),
+                node_type: ty.to_string(),
+                children: vec![],
+                params: params
+                    .iter()
+                    .map(|(n, v)| crate::app::ParamDef {
+                        name: n.to_string(),
+                        label: String::new(),
+                        param_type: "text".to_string(),
+                        default: v.to_string(),
+                        options: vec![],
+                        min: None,
+                        max: None,
+                        step: None,
+                        show_when: String::new(),
+                    })
+                    .collect(),
+                geometry_visible: true,
+                position: (0.0, 0.0),
+                inputs: 1,
+                outputs: 1,
+            }
+        }
+        let sphere = mnode("id-s", "sphere1", "sphere", &[("Radius", "0.8")]);
+        let shell = mnode(
+            "id-m",
+            "mold1",
+            "mold_shell",
+            &[
+                ("Input", "sphere1"),
+                ("Maximum Thickness", "0.20"),
+                ("Minimum Thickness", "0.10"),
+                ("Remesh Division Size", "0.30"),
+                ("Ramp", "Linear"),
+            ],
+        );
+        let mut root = mnode("id-root", "root", "node", &[]);
+        root.children = vec![sphere, shell];
+
+        let mut err = None;
+        let mut cache = crate::geometry::SimCache::default();
+        let mut sim = crate::geometry::EvalSim::new(0, 0, &mut cache);
+        let out = resolve_mold_shell_geometry_with_errors(
+            &root,
+            &root.children[1],
+            &mut Vec::new(),
+            &mut err,
+            &mut sim,
+        )
+        .expect("the mold shell resolved to nothing");
+        assert!(err.is_none(), "{err:?}");
+        assert!(out.num_prims() > 0);
+
+        // Two surfaces: the outer one at the sphere's radius, the inner one
+        // pulled in by between the minimum and the maximum thickness.
+        let centre = {
+            let (lo, hi) = out.bounds().unwrap();
+            (lo + hi) * 0.5
+        };
+        let radii: Vec<f32> = (0..out.num_points()).map(|p| (out.pos(p) - centre).length()).collect();
+        let far = radii.iter().cloned().fold(0.0f32, f32::max);
+        let near = radii.iter().cloned().fold(f32::MAX, f32::min);
+        assert!((far - 0.8).abs() < 0.12, "the outer surface is at {far}, not the sphere's 0.8");
+        assert!(
+            near < far - 0.08 && near > far - 0.30,
+            "the inner surface is {near} against an outer {far}; the gap should be the thickness range"
+        );
+
+        // Closed: the pair is a solid, not two loose surfaces. A sphere has no
+        // rim, so the two shells close each other.
+        assert!(out.is_closed(), "the shell is not a closed surface");
+    }
+
     /// A page's raster is its physical size times its resolution — the
     /// property that makes DPI a page parameter rather than an export one.
     #[test]
diff --git a/src/mold.rs b/src/mold.rs
new file mode 100644
index 0000000..de9b6e1
--- /dev/null
+++ b/src/mold.rs
@@ -0,0 +1,173 @@
+//! Mold tooling: the shell a cast is poured into.
+//!
+//! The first GEM operator, ported from `gem_mold_shell`. Its four parameters
+//! are the plugin's — Maximum Thickness, Minimum Thickness, Remesh Division
+//! Size, Thickness Ramp — and the production notes from the original cast give
+//! the numbers that worked: max 0.75, min 0.6, division 0.9, ramp linear.
+//!
+//! **Thickness varies with CURVATURE**, which is the whole point of the
+//! operator and the reason a uniform `volume` shell will not do. The plugin
+//! does it with an `im_ramp_scalar` node named `curvature_to_thickness`; this
+//! does the same three steps:
+//!
+//! 1. remesh to the division size, so thickness is carried on evenly spaced
+//!    points rather than on whatever triangulation arrived;
+//! 2. measure curvature per point;
+//! 3. map it through a ramp into the thickness range, and displace a copy of
+//!    the surface inward by that much.
+//!
+//! The inner surface is a DISPLACEMENT, not a field offset. A `volume` shell
+//! offsets a signed distance field by a constant, which cannot vary per point;
+//! displacing each point along its own normal by its own thickness can. The
+//! cost is the usual one for offset-by-displacement: where the thickness
+//! exceeds the local radius of curvature the inner surface folds through
+//! itself. That is exactly what the minimum/maximum range is for — it is a
+//! range because the geometry constrains it, not because a single number was
+//! hard to choose.
+
+use crate::detail::Detail;
+use glam::Vec3;
+
+/// How the curvature measure is shaped before it lands in the thickness range.
+///
+/// The plugin uses a free-form float ramp. There is no ramp PARAMETER type in
+/// this app yet — `cce-ui` has the widget, but nothing wires it as a node
+/// parameter — so this ports the three shapes the falloff choices already use
+/// elsewhere (`soft_transform`'s Falloff). The production notes say the cast
+/// that worked used a linear ramp, so the default is the one that was actually
+/// printed.
+#[derive(Debug, Clone, Copy, PartialEq)]
+pub enum Ramp {
+    Linear,
+    Smooth,
+    Constant,
+}
+
+impl Ramp {
+    pub fn parse(s: &str) -> Ramp {
+        match s.trim().to_ascii_lowercase().as_str() {
+            "smooth" => Ramp::Smooth,
+            "constant" => Ramp::Constant,
+            _ => Ramp::Linear,
+        }
+    }
+
+    /// Shape `t` in 0..1.
+    pub fn apply(self, t: f32) -> f32 {
+        let t = t.clamp(0.0, 1.0);
+        match self {
+            Ramp::Linear => t,
+            // Smoothstep: flat at both ends, so the thickest and thinnest
+            // regions are even rather than knife-edged into their neighbours.
+            Ramp::Smooth => t * t * (3.0 - 2.0 * t),
+            // Everything at the maximum — the uniform shell, reachable without
+            // leaving the node.
+            Ramp::Constant => 1.0,
+        }
+    }
+}
+
+/// Per-point curvature, as a dimensionless signed measure in roughly -1..1.
+///
+/// For each point: the mean of `dot(normalize(neighbour - p), n)`. A neighbour
+/// lying exactly in the tangent plane contributes zero; one below it (the
+/// surface bulging out, CONVEX) contributes negative; one above it (the
+/// surface cupping in, CONCAVE) contributes positive.
+///
+/// Dimensionless on purpose. Every term is a dot product of two unit vectors,
+/// so the measure does not change when the model is scaled or when the remesh
+/// division size changes — which matters here, because thickness is chosen
+/// from it and a thickness that moved when you re-tessellated would be
+/// unusable. A true mean curvature in 1/length would do the opposite.
+///
+/// Points with no neighbours (an isolated point, a stray primitive) read zero:
+/// flat, which puts them in the middle of the ramp rather than at an extreme.
+pub fn curvature(d: &Detail) -> Vec<f32> {
+    let normals = crate::geometry::point_normals(d);
+    (0..d.num_points())
+        .map(|p| {
+            let here = d.pos(p);
+            let n = normals.get(p).copied().unwrap_or(Vec3::Y);
+            let neighbours = d.point_neighbours(p);
+            if neighbours.is_empty() {
+                return 0.0;
+            }
+            let sum: f32 = neighbours
+                .iter()
+                .map(|&q| {
+                    let to = d.pos(q as usize) - here;
+                    let len = to.length();
+                    // A coincident neighbour has no direction to contribute.
+                    if len < 1e-6 { 0.0 } else { (to / len).dot(n) }
+                })
+                .sum();
+            sum / neighbours.len() as f32
+        })
+        .collect()
+}
+
+/// Thickness per point, from curvature through the ramp.
+///
+/// The measure is mapped `-1..1 -> 0..1` by a fixed affine step rather than by
+/// normalizing over the range present in this particular model. Normalizing
+/// would make the thickness of one part depend on how curved the REST of it
+/// is, so adding a sharp corner somewhere would thin the whole shell.
+///
+/// Concave regions get the maximum. A mould is weakest where it cups inward —
+/// that is where it has least material behind it and where it is levered on
+/// when the cast is pulled — so that is where the thickness goes.
+pub fn thickness_from_curvature(curv: &[f32], min: f32, max: f32, ramp: Ramp) -> Vec<f32> {
+    let (lo, hi) = (min.min(max), min.max(max));
+    curv.iter()
+        .map(|&c| {
+            let t = ramp.apply((c * 0.5 + 0.5).clamp(0.0, 1.0));
+            lo + (hi - lo) * t
+        })
+        .collect()
+}
+
+/// Build the mold shell: the surface, and an inner surface displaced inward by
+/// a per-point thickness, wound to face the cavity.
+///
+/// Returns `None` when the input has no primitives — there is no surface to
+/// thicken, and a shell of nothing is not an empty shell.
+pub fn mold_shell(input: &Detail, min: f32, max: f32, division: f32, ramp: Ramp) -> Option<Detail> {
+    if input.num_prims() == 0 {
+        return None;
+    }
+    // Remesh first: thickness is carried per POINT, so the points have to be
+    // spaced evenly or the shell's thickness resolution follows whatever
+    // triangulation happened to arrive.
+    let base = if division > 0.0 {
+        crate::remesh::remesh(
+            input,
+            crate::remesh::Settings { target: division, ..Default::default() },
+        )
+    } else {
+        input.clone()
+    };
+    if base.num_prims() == 0 {
+        return None;
+    }
+
+    let normals = crate::geometry::point_normals(&base);
+    let thickness = thickness_from_curvature(&curvature(&base), min, max, ramp);
+
+    // The outer surface as it is, then the inner one: the same points pushed
+    // along -normal, the same faces wound backwards so they look into the
+    // cavity rather than out of it. Two shells facing opposite ways is what
+    // makes the pair a solid rather than two surfaces in the same place.
+    let mut out = base.clone();
+    let inner_start = out.num_points();
+    for p in 0..base.num_points() {
+        let n = normals.get(p).copied().unwrap_or(Vec3::Y);
+        out.add_point(base.pos(p) - n * thickness[p]);
+    }
+    for prim in 0..base.num_prims() {
+        let mut pts: Vec<u32> =
+            base.prim_points(prim).iter().map(|&i| i + inner_start as u32).collect();
+        pts.reverse();
+        out.add_prim(&pts);
+    }
+    Some(out)
+}