graphic design tool
git clone https://git.lucas.co/cce-designer.git
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)
+}