Skip to content

Commit 738f578

Browse files
committed
Add capped TPMS solid meshing
1 parent dc7fefb commit 738f578

4 files changed

Lines changed: 224 additions & 9 deletions

File tree

examples/readme_renders.rs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -274,9 +274,9 @@ fn render_readme_meshes() {
274274
);
275275

276276
let tpms_box = Mesh::<()>::cube(2.0, ());
277-
render_mesh("gyroid", &tpms_box.gyroid(20, 2.0, 0.0, ()));
278-
render_mesh("schwarz_p", &tpms_box.schwarz_p(20, 2.0, 0.0, ()));
279-
render_mesh("schwarz_d", &tpms_box.schwarz_d(20, 2.0, 0.0, ()));
277+
render_mesh("gyroid", &tpms_box.gyroid_solid(24, 2.0, 0.0, 0.18, ()));
278+
render_mesh("schwarz_p", &tpms_box.schwarz_p_solid(24, 2.0, 0.0, 0.18, ()));
279+
render_mesh("schwarz_d", &tpms_box.schwarz_d_solid(24, 2.0, 0.0, 0.18, ()));
280280
render_mesh(
281281
"spur_gear_involute",
282282
&Mesh::<()>::spur_gear_involute(0.18, 22, 20.0, 0.0, 0.0, 8, 0.35, ()),

readme.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -239,9 +239,9 @@ If either radius is within EPSILON of 0.0, a cone terminating at a point is cons
239239
- <img src="docs/metaballs_3d.png" width="128"/> **`Mesh::metaballs(balls: &[MetaBall], resolution: (usize, usize, usize), iso_value: Real, padding: Real, metadata: Option<S>)`**
240240
- <img src="docs/sdf.png" width="128"/> **`Mesh::sdf<F>(sdf: F, resolution: (usize, usize, usize), min_pt: Point3, max_pt: Point3, iso_value: Real, metadata: Option<S>)`** - Return a CSG created by meshing a signed distance field within a bounding box
241241
- <img src="docs/mesh_arrow.png" width="128"/> **`Mesh::arrow(start: Point3, direction: Vector3, segments: usize, orientation: bool, metadata: Option<S>)`** - Create an arrow at start, pointing along direction
242-
- <img src="docs/gyroid.png" width="128"/> **`Mesh::gyroid(resolution: usize, period: Real, iso_value: Real, metadata: Option<S>)`** - Generate a Triply Periodic Minimal Surface (Gyroid) inside the volume of `self`
243-
- <img src="docs/schwarz_p.png" width="128"/> **`Mesh::schwarz_p(resolution: usize, period: Real, iso_value: Real, metadata: Option<S>)`** - Generate a Triply Periodic Minimal Surface (Schwarz P) inside the volume of `self`
244-
- <img src="docs/schwarz_d.png" width="128"/> **`Mesh::schwarz_d(resolution: usize, period: Real, iso_value: Real, metadata: Option<S>)`** - Generate a Triply Periodic Minimal Surface (Schwarz D) inside the volume of `self`
242+
- <img src="docs/gyroid.png" width="128"/> **`Mesh::gyroid_solid(resolution: usize, period: Real, iso_value: Real, thickness: Real, metadata: Option<S>)`** - Generate a capped, finite-thickness Gyroid solid inside the bounding box of `self`
243+
- <img src="docs/schwarz_p.png" width="128"/> **`Mesh::schwarz_p_solid(resolution: usize, period: Real, iso_value: Real, thickness: Real, metadata: Option<S>)`** - Generate a capped, finite-thickness Schwarz P solid inside the bounding box of `self`
244+
- <img src="docs/schwarz_d.png" width="128"/> **`Mesh::schwarz_d_solid(resolution: usize, period: Real, iso_value: Real, thickness: Real, metadata: Option<S>)`** - Generate a capped, finite-thickness Schwarz D solid inside the bounding box of `self`
245245
- <img src="docs/spur_gear_involute.png" width="128"/> **`Mesh::spur_gear_involute(module: Real, teeth: usize, pressure_angle_deg: Real, clearance: Real, backlash: Real, segments_per_flank: usize, thickness: Real, helix_angle_deg: Real, slices: usize, metadata: Option<S>,)`** - Generate an involute spur gear
246246
- **`Mesh::helical_involute_gear(module_: Real, teeth: usize, pressure_angle_deg: Real, clearance: Real, backlash: Real, segments_per_flank: usize, thickness: Real, helix_angle_deg: Real, slices: usize, metadata: Option<S>)`** - under construction
247247

src/mesh/tpms.rs

Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,54 @@ impl<M: Clone + Debug + Send + Sync> Mesh<M> {
3535
Mesh::sdf(sdf_fn, resolution, min_pt, max_pt, iso_value, metadata)
3636
}
3737

38+
/// Build a capped, finite-thickness TPMS solid inside `self`'s bounding box.
39+
///
40+
/// The implicit solid is:
41+
///
42+
/// `max(abs(f(p) - iso_value) - thickness / 2, box_sdf(p)) <= 0`
43+
///
44+
/// This creates a wall around the TPMS sheet and caps it where it meets the
45+
/// bounding box. That makes a closed mesh suitable for solid workflows,
46+
/// unlike the raw zero-level TPMS sheet.
47+
#[inline]
48+
fn tpms_solid_from_sdf<F>(
49+
&self,
50+
sdf_fn: F,
51+
resolution: (usize, usize, usize),
52+
iso_value: Real,
53+
thickness: Real,
54+
metadata: M,
55+
) -> Mesh<M>
56+
where
57+
F: Fn(&Point3<Real>) -> Real + Send + Sync,
58+
{
59+
if !thickness.is_finite() || thickness <= 0.0 {
60+
return Mesh::empty(metadata);
61+
}
62+
63+
let aabb = self.bounding_box();
64+
let min_pt = aabb.mins;
65+
let max_pt = aabb.maxs;
66+
let half_thickness = thickness * 0.5;
67+
let step = max_axis_step(&min_pt, &max_pt, resolution);
68+
let padding = step.max(thickness);
69+
let sample_min = Point3::from(min_pt.coords - nalgebra::Vector3::repeat(padding));
70+
let sample_max = Point3::from(max_pt.coords + nalgebra::Vector3::repeat(padding));
71+
72+
Mesh::sdf(
73+
move |p: &Point3<Real>| {
74+
let sheet = (sdf_fn(p) - iso_value).abs() - half_thickness;
75+
let bounds = axis_aligned_box_sdf(p, &min_pt, &max_pt);
76+
sheet.max(bounds)
77+
},
78+
resolution,
79+
sample_min,
80+
sample_max,
81+
0.0,
82+
metadata,
83+
)
84+
}
85+
3886
// ------------ Specific minimal‑surface flavours --------------------
3987

4088
/// Gyroid surface: `sin x cos y + sin y cos z + sin z cos x = iso`
@@ -75,6 +123,35 @@ impl<M: Clone + Debug + Send + Sync> Mesh<M> {
75123
)
76124
}
77125

126+
/// Generate a capped, finite-thickness gyroid solid inside `self`'s
127+
/// bounding box.
128+
pub fn gyroid_solid(
129+
&self,
130+
resolution: usize,
131+
period: Real,
132+
iso_value: Real,
133+
thickness: Real,
134+
metadata: M,
135+
) -> Mesh<M> {
136+
let res = (resolution.max(2), resolution.max(2), resolution.max(2));
137+
let scale = std::f64::consts::TAU as Real / period;
138+
self.tpms_solid_from_sdf(
139+
move |p: &Point3<Real>| {
140+
let x_scaled = p.x * scale;
141+
let y_scaled = p.y * scale;
142+
let z_scaled = p.z * scale;
143+
let (sin_x, cos_x) = x_scaled.sin_cos();
144+
let (sin_y, cos_y) = y_scaled.sin_cos();
145+
let (sin_z, cos_z) = z_scaled.sin_cos();
146+
(sin_x * cos_y) + (sin_y * cos_z) + (sin_z * cos_x)
147+
},
148+
res,
149+
iso_value,
150+
thickness,
151+
metadata,
152+
)
153+
}
154+
78155
/// Schwarz‑P surface: `cos x + cos y + cos z = iso` (default iso = 0)
79156
/// after scaling coordinates by `2π / period`.
80157
/// **Mathematical Foundation**: Schwarz P-surface has constant mean curvature and cubic symmetry.
@@ -105,6 +182,32 @@ impl<M: Clone + Debug + Send + Sync> Mesh<M> {
105182
)
106183
}
107184

185+
/// Generate a capped, finite-thickness Schwarz-P solid inside `self`'s
186+
/// bounding box.
187+
pub fn schwarz_p_solid(
188+
&self,
189+
resolution: usize,
190+
period: Real,
191+
iso_value: Real,
192+
thickness: Real,
193+
metadata: M,
194+
) -> Mesh<M> {
195+
let res = (resolution.max(2), resolution.max(2), resolution.max(2));
196+
let scale = std::f64::consts::TAU as Real / period;
197+
self.tpms_solid_from_sdf(
198+
move |p: &Point3<Real>| {
199+
let x_scaled = p.x * scale;
200+
let y_scaled = p.y * scale;
201+
let z_scaled = p.z * scale;
202+
x_scaled.cos() + y_scaled.cos() + z_scaled.cos()
203+
},
204+
res,
205+
iso_value,
206+
thickness,
207+
metadata,
208+
)
209+
}
210+
108211
/// Schwarz‑D (Diamond) surface: `sin x sin y sin z + sin x cos y cos z + ... = iso`
109212
/// after scaling coordinates by `2π / period`.
110213
/// **Mathematical Foundation**: Diamond surface exhibits tetrahedral symmetry and is self-intersecting.
@@ -142,4 +245,57 @@ impl<M: Clone + Debug + Send + Sync> Mesh<M> {
142245
metadata,
143246
)
144247
}
248+
249+
/// Generate a capped, finite-thickness Schwarz-D solid inside `self`'s
250+
/// bounding box.
251+
pub fn schwarz_d_solid(
252+
&self,
253+
resolution: usize,
254+
period: Real,
255+
iso_value: Real,
256+
thickness: Real,
257+
metadata: M,
258+
) -> Mesh<M> {
259+
let res = (resolution.max(2), resolution.max(2), resolution.max(2));
260+
let scale = std::f64::consts::TAU as Real / period;
261+
self.tpms_solid_from_sdf(
262+
move |p: &Point3<Real>| {
263+
let x_scaled = p.x * scale;
264+
let y_scaled = p.y * scale;
265+
let z_scaled = p.z * scale;
266+
let (sin_x, cos_x) = x_scaled.sin_cos();
267+
let (sin_y, cos_y) = y_scaled.sin_cos();
268+
let (sin_z, cos_z) = z_scaled.sin_cos();
269+
(sin_x * sin_y * sin_z)
270+
+ (sin_x * cos_y * cos_z)
271+
+ (cos_x * sin_y * cos_z)
272+
+ (cos_x * cos_y * sin_z)
273+
},
274+
res,
275+
iso_value,
276+
thickness,
277+
metadata,
278+
)
279+
}
280+
}
281+
282+
fn axis_aligned_box_sdf(p: &Point3<Real>, min: &Point3<Real>, max: &Point3<Real>) -> Real {
283+
let center = Point3::from((min.coords + max.coords) * 0.5);
284+
let half = (max.coords - min.coords) * 0.5;
285+
let q = (p - center).abs() - half;
286+
let outside = q.map(|component| component.max(0.0)).norm();
287+
let inside = q.x.max(q.y).max(q.z).min(0.0);
288+
outside + inside
289+
}
290+
291+
fn max_axis_step(
292+
min: &Point3<Real>,
293+
max: &Point3<Real>,
294+
resolution: (usize, usize, usize),
295+
) -> Real {
296+
let span = max - min;
297+
let dx = span.x.abs() / (resolution.0.max(2) as Real - 1.0);
298+
let dy = span.y.abs() / (resolution.1.max(2) as Real - 1.0);
299+
let dz = span.z.abs() / (resolution.2.max(2) as Real - 1.0);
300+
dx.max(dy).max(dz)
145301
}

tests/sdf_tpms_diagnostics.rs

Lines changed: 62 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -374,9 +374,15 @@ fn readme_sdf_examples_mesh_non_empty_surfaces() {
374374

375375
let box_mesh = Mesh::<&'static str>::cube(2.0, "tpms");
376376
for (name, mesh) in [
377-
("gyroid", box_mesh.gyroid(20, 2.0, 0.0, "gyroid")),
378-
("schwarz_p", box_mesh.schwarz_p(20, 2.0, 0.0, "schwarz_p")),
379-
("schwarz_d", box_mesh.schwarz_d(20, 2.0, 0.0, "schwarz_d")),
377+
("gyroid", box_mesh.gyroid_solid(24, 2.0, 0.0, 0.18, "gyroid")),
378+
(
379+
"schwarz_p",
380+
box_mesh.schwarz_p_solid(24, 2.0, 0.0, 0.18, "schwarz_p"),
381+
),
382+
(
383+
"schwarz_d",
384+
box_mesh.schwarz_d_solid(24, 2.0, 0.0, 0.18, "schwarz_d"),
385+
),
380386
] {
381387
assert_mesh_vertices_finite(&mesh);
382388
assert_sdf_mesh_is_triangular(&mesh);
@@ -385,5 +391,58 @@ fn readme_sdf_examples_mesh_non_empty_surfaces() {
385391
"{name} README TPMS example emitted too few triangles: {}",
386392
mesh.polygons.len()
387393
);
394+
assert_eq!(
395+
boundary_edge_count(&mesh),
396+
0,
397+
"{name} README TPMS solid should be capped and closed"
398+
);
399+
}
400+
}
401+
402+
#[test]
403+
fn tpms_solid_helpers_emit_closed_capped_meshes() {
404+
let cube = Mesh::<&'static str>::cube(2.0, "solid");
405+
for (name, mesh) in [
406+
("gyroid", cube.gyroid_solid(22, 2.0, 0.0, 0.2, "gyroid")),
407+
(
408+
"schwarz_p",
409+
cube.schwarz_p_solid(22, 2.0, 0.0, 0.2, "schwarz_p"),
410+
),
411+
(
412+
"schwarz_d",
413+
cube.schwarz_d_solid(22, 2.0, 0.0, 0.2, "schwarz_d"),
414+
),
415+
] {
416+
assert_mesh_vertices_finite(&mesh);
417+
assert_sdf_mesh_is_triangular(&mesh);
418+
assert!(
419+
mesh.polygons.len() > 100,
420+
"{name} solid emitted too few triangles: {}",
421+
mesh.polygons.len()
422+
);
423+
assert_eq!(
424+
boundary_edge_count(&mesh),
425+
0,
426+
"{name} solid should not have open boundary edges"
427+
);
428+
assert!(
429+
mesh.polygons
430+
.iter()
431+
.all(|poly| triangle_area2(poly) > Real::EPSILON),
432+
"{name} solid emitted degenerate triangles"
433+
);
434+
}
435+
}
436+
437+
#[test]
438+
fn tpms_solid_helpers_reject_non_positive_thickness() {
439+
let cube = Mesh::<&'static str>::cube(2.0, "solid");
440+
for thickness in [0.0, -0.1, Real::NAN, Real::INFINITY] {
441+
assert!(
442+
cube.gyroid_solid(12, 2.0, 0.0, thickness, "gyroid")
443+
.polygons
444+
.is_empty(),
445+
"invalid thickness {thickness:?} should return an empty gyroid solid"
446+
);
388447
}
389448
}

0 commit comments

Comments
 (0)