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 }