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

src/mold.rs (7.2K)

  1 //! Mold tooling: the shell a cast is poured into.
  2 //!
  3 //! The first GEM operator, ported from `gem_mold_shell`. Its four parameters
  4 //! are the plugin's — Maximum Thickness, Minimum Thickness, Remesh Division
  5 //! Size, Thickness Ramp — and the production notes from the original cast give
  6 //! the numbers that worked: max 0.75, min 0.6, division 0.9, ramp linear.
  7 //!
  8 //! **Thickness varies with CURVATURE**, which is the whole point of the
  9 //! operator and the reason a uniform `volume` shell will not do. The plugin
 10 //! does it with an `im_ramp_scalar` node named `curvature_to_thickness`; this
 11 //! does the same three steps:
 12 //!
 13 //! 1. remesh to the division size, so thickness is carried on evenly spaced
 14 //!    points rather than on whatever triangulation arrived;
 15 //! 2. measure curvature per point;
 16 //! 3. map it through a ramp into the thickness range, and displace a copy of
 17 //!    the surface inward by that much.
 18 //!
 19 //! The inner surface is a DISPLACEMENT, not a field offset. A `volume` shell
 20 //! offsets a signed distance field by a constant, which cannot vary per point;
 21 //! displacing each point along its own normal by its own thickness can. The
 22 //! cost is the usual one for offset-by-displacement: where the thickness
 23 //! exceeds the local radius of curvature the inner surface folds through
 24 //! itself. That is exactly what the minimum/maximum range is for — it is a
 25 //! range because the geometry constrains it, not because a single number was
 26 //! hard to choose.
 27 
 28 use crate::detail::Detail;
 29 use glam::Vec3;
 30 
 31 /// How the curvature measure is shaped before it lands in the thickness range.
 32 ///
 33 /// The plugin uses a free-form float ramp. There is no ramp PARAMETER type in
 34 /// this app yet — `cce-ui` has the widget, but nothing wires it as a node
 35 /// parameter — so this ports the three shapes the falloff choices already use
 36 /// elsewhere (`soft_transform`'s Falloff). The production notes say the cast
 37 /// that worked used a linear ramp, so the default is the one that was actually
 38 /// printed.
 39 #[derive(Debug, Clone, Copy, PartialEq)]
 40 pub enum Ramp {
 41     Linear,
 42     Smooth,
 43     Constant,
 44 }
 45 
 46 impl Ramp {
 47     pub fn parse(s: &str) -> Ramp {
 48         match s.trim().to_ascii_lowercase().as_str() {
 49             "smooth" => Ramp::Smooth,
 50             "constant" => Ramp::Constant,
 51             _ => Ramp::Linear,
 52         }
 53     }
 54 
 55     /// Shape `t` in 0..1.
 56     pub fn apply(self, t: f32) -> f32 {
 57         let t = t.clamp(0.0, 1.0);
 58         match self {
 59             Ramp::Linear => t,
 60             // Smoothstep: flat at both ends, so the thickest and thinnest
 61             // regions are even rather than knife-edged into their neighbours.
 62             Ramp::Smooth => t * t * (3.0 - 2.0 * t),
 63             // Everything at the maximum — the uniform shell, reachable without
 64             // leaving the node.
 65             Ramp::Constant => 1.0,
 66         }
 67     }
 68 }
 69 
 70 /// Per-point curvature, as a dimensionless signed measure in roughly -1..1.
 71 ///
 72 /// For each point: the mean of `dot(normalize(neighbour - p), n)`. A neighbour
 73 /// lying exactly in the tangent plane contributes zero; one below it (the
 74 /// surface bulging out, CONVEX) contributes negative; one above it (the
 75 /// surface cupping in, CONCAVE) contributes positive.
 76 ///
 77 /// Dimensionless on purpose. Every term is a dot product of two unit vectors,
 78 /// so the measure does not change when the model is scaled or when the remesh
 79 /// division size changes — which matters here, because thickness is chosen
 80 /// from it and a thickness that moved when you re-tessellated would be
 81 /// unusable. A true mean curvature in 1/length would do the opposite.
 82 ///
 83 /// Points with no neighbours (an isolated point, a stray primitive) read zero:
 84 /// flat, which puts them in the middle of the ramp rather than at an extreme.
 85 pub fn curvature(d: &Detail) -> Vec<f32> {
 86     let normals = crate::geometry::point_normals(d);
 87     (0..d.num_points())
 88         .map(|p| {
 89             let here = d.pos(p);
 90             let n = normals.get(p).copied().unwrap_or(Vec3::Y);
 91             let neighbours = d.point_neighbours(p);
 92             if neighbours.is_empty() {
 93                 return 0.0;
 94             }
 95             let sum: f32 = neighbours
 96                 .iter()
 97                 .map(|&q| {
 98                     let to = d.pos(q as usize) - here;
 99                     let len = to.length();
100                     // A coincident neighbour has no direction to contribute.
101                     if len < 1e-6 { 0.0 } else { (to / len).dot(n) }
102                 })
103                 .sum();
104             sum / neighbours.len() as f32
105         })
106         .collect()
107 }
108 
109 /// Thickness per point, from curvature through the ramp.
110 ///
111 /// The measure is mapped `-1..1 -> 0..1` by a fixed affine step rather than by
112 /// normalizing over the range present in this particular model. Normalizing
113 /// would make the thickness of one part depend on how curved the REST of it
114 /// is, so adding a sharp corner somewhere would thin the whole shell.
115 ///
116 /// Concave regions get the maximum. A mould is weakest where it cups inward —
117 /// that is where it has least material behind it and where it is levered on
118 /// when the cast is pulled — so that is where the thickness goes.
119 pub fn thickness_from_curvature(curv: &[f32], min: f32, max: f32, ramp: Ramp) -> Vec<f32> {
120     let (lo, hi) = (min.min(max), min.max(max));
121     curv.iter()
122         .map(|&c| {
123             let t = ramp.apply((c * 0.5 + 0.5).clamp(0.0, 1.0));
124             lo + (hi - lo) * t
125         })
126         .collect()
127 }
128 
129 /// Build the mold shell: the surface, and an inner surface displaced inward by
130 /// a per-point thickness, wound to face the cavity.
131 ///
132 /// Returns `None` when the input has no primitives — there is no surface to
133 /// thicken, and a shell of nothing is not an empty shell.
134 pub fn mold_shell(input: &Detail, min: f32, max: f32, division: f32, ramp: Ramp) -> Option<Detail> {
135     if input.num_prims() == 0 {
136         return None;
137     }
138     // Remesh first: thickness is carried per POINT, so the points have to be
139     // spaced evenly or the shell's thickness resolution follows whatever
140     // triangulation happened to arrive.
141     let base = if division > 0.0 {
142         crate::remesh::remesh(
143             input,
144             crate::remesh::Settings { target: division, ..Default::default() },
145         )
146     } else {
147         input.clone()
148     };
149     if base.num_prims() == 0 {
150         return None;
151     }
152 
153     let normals = crate::geometry::point_normals(&base);
154     let thickness = thickness_from_curvature(&curvature(&base), min, max, ramp);
155 
156     // The outer surface as it is, then the inner one: the same points pushed
157     // along -normal, the same faces wound backwards so they look into the
158     // cavity rather than out of it. Two shells facing opposite ways is what
159     // makes the pair a solid rather than two surfaces in the same place.
160     let mut out = base.clone();
161     let inner_start = out.num_points();
162     for p in 0..base.num_points() {
163         let n = normals.get(p).copied().unwrap_or(Vec3::Y);
164         out.add_point(base.pos(p) - n * thickness[p]);
165     }
166     for prim in 0..base.num_prims() {
167         let mut pts: Vec<u32> =
168             base.prim_points(prim).iter().map(|&i| i + inner_start as u32).collect();
169         pts.reverse();
170         out.add_prim(&pts);
171     }
172     Some(out)
173 }