Skip to content

Commit 534f229

Browse files
authored
TPMS microstructures (#7)
1 parent eb09abc commit 534f229

19 files changed

+859
-902
lines changed

MSUtils/TPMS/__init__.py

Whitespace-only changes.

MSUtils/TPMS/tpms.py

Lines changed: 298 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,298 @@
1+
import numpy as np
2+
from typing import Iterable, Optional
3+
4+
from MSUtils.general.MicrostructureImage import MicrostructureImage
5+
from MSUtils.general.h52xdmf import write_xdmf
6+
7+
8+
def _to_angle(x, L):
9+
return (x / L) * 2.0 * np.pi
10+
11+
12+
class TPMS:
13+
"""
14+
TPMS generator.
15+
16+
Parameters
17+
----------
18+
tpms_type : str
19+
Type of TPMS surface (e.g., 'gyroid', 'schwarz_p', 'diamond', 'neovius', 'iwp', 'lidinoid').
20+
resolution : tuple of int
21+
Number of voxels in each direction (Nx, Ny, Nz).
22+
L : tuple of float
23+
Physical size in each direction (Lx, Ly, Lz).
24+
threshold : float
25+
Level-set value at which to threshold the implicit function.
26+
unitcell_frequency : tuple of int
27+
Number of unit cell repeats in each direction.
28+
invert : bool
29+
If True, swap the solid/void assignment (invert phases).
30+
mode : str
31+
'solid' (default) for classic TPMS, 'shell' for a shell of finite thickness.
32+
shell_thickness : float
33+
If mode='shell', the thickness of the shell (in field units, not physical units).
34+
"""
35+
36+
def __init__(
37+
self,
38+
tpms_type,
39+
resolution: Optional[Iterable[int]] = (128, 128, 128),
40+
L: Optional[Iterable[float]] = (1.0, 1.0, 1.0),
41+
threshold: Optional[float] = 0.5,
42+
unitcell_frequency: Optional[Iterable[int]] = (1, 1, 1),
43+
invert: bool = False,
44+
mode: str = "solid",
45+
shell_thickness: float = 0.1,
46+
):
47+
self.kind = tpms_type.lower()
48+
self.resolution = tuple(int(v) for v in resolution)
49+
self.L = tuple(float(v) for v in L)
50+
if isinstance(unitcell_frequency, int):
51+
unitcell_frequency = (
52+
unitcell_frequency,
53+
unitcell_frequency,
54+
unitcell_frequency,
55+
)
56+
self.frequency = tuple(int(v) for v in unitcell_frequency)
57+
self.threshold = threshold
58+
self.invert = invert
59+
self.mode = mode
60+
self.shell_thickness = shell_thickness
61+
62+
self._field = None # cache for field
63+
self.image = self.generate()
64+
65+
def implicit_function(
66+
self, x: np.ndarray, y: np.ndarray, z: np.ndarray
67+
) -> np.ndarray:
68+
# Map by frequency: scale coordinates before mapping to angle
69+
kx, ky, kz = self.frequency
70+
X = _to_angle(x * kx, self.L[0])
71+
Y = _to_angle(y * ky, self.L[1])
72+
Z = _to_angle(z * kz, self.L[2])
73+
74+
kind = self.kind
75+
# Standard references: https://minimalsurfaces.blog/home/repository/triply-periodic/
76+
# https://kenbrakke.com/evolver/examples/periodic/periodic.html
77+
if kind in ("gyroid",):
78+
# Gyroid: sin(x)cos(y) + sin(y)cos(z) + sin(z)cos(x)
79+
return np.sin(X) * np.cos(Y) + np.sin(Y) * np.cos(Z) + np.sin(Z) * np.cos(X)
80+
if kind in ("schwarz_p", "p"):
81+
# Schwarz Primitive: cos(x) + cos(y) + cos(z)
82+
return np.cos(X) + np.cos(Y) + np.cos(Z)
83+
if kind in ("schwarz_d", "diamond", "d"):
84+
# Diamond: sin(x)sin(y)sin(z) + sin(x)cos(y)cos(z) + cos(x)sin(y)cos(z) + cos(x)cos(y)sin(z)
85+
return (
86+
np.sin(X) * np.sin(Y) * np.sin(Z)
87+
+ np.sin(X) * np.cos(Y) * np.cos(Z)
88+
+ np.cos(X) * np.sin(Y) * np.cos(Z)
89+
+ np.cos(X) * np.cos(Y) * np.sin(Z)
90+
)
91+
if kind in ("neovius",):
92+
# Neovius: 3 * (cos(x) + cos(y) + cos(z)) + 4 * cos(x)*cos(y)*cos(z)
93+
return 3 * (np.cos(X) + np.cos(Y) + np.cos(Z)) + 4 * np.cos(X) * np.cos(
94+
Y
95+
) * np.cos(Z)
96+
if kind in ("iwp"):
97+
# I-WP : 2 * (cos(x)cos(y) + cos(y)cos(z) + cos(z)cos(x)) - (cos(2x) + cos(2y) + cos(2z))
98+
return 2 * (
99+
np.cos(X) * np.cos(Y) + np.cos(Y) * np.cos(Z) + np.cos(Z) * np.cos(X)
100+
) - (np.cos(2 * X) + np.cos(2 * Y) + np.cos(2 * Z))
101+
if kind in ("lidinoid",):
102+
# Lidinoid: 0.5 * (sin(2x)cos(y)sin(z) + sin(2y)cos(z)sin(x) + sin(2z)cos(x)sin(y)) - 0.5 * (cos(2x)cos(2y) + cos(2y)cos(2z) + cos(2z)cos(2x)) + 0.15
103+
return (
104+
0.5
105+
* (
106+
np.sin(2 * X) * np.cos(Y) * np.sin(Z)
107+
+ np.sin(2 * Y) * np.cos(Z) * np.sin(X)
108+
+ np.sin(2 * Z) * np.cos(X) * np.sin(Y)
109+
)
110+
- 0.5
111+
* (
112+
np.cos(2 * X) * np.cos(2 * Y)
113+
+ np.cos(2 * Y) * np.cos(2 * Z)
114+
+ np.cos(2 * Z) * np.cos(2 * X)
115+
)
116+
+ 0.15
117+
)
118+
raise ValueError(f"Unknown or unsupported TPMS kind: {self.kind}")
119+
120+
def _compute_field(self):
121+
# Compute and cache the field
122+
Nx, Ny, Nz = self.resolution
123+
Lx, Ly, Lz = self.L
124+
xs = np.linspace(0.0, Lx, Nx, endpoint=False)
125+
ys = np.linspace(0.0, Ly, Ny, endpoint=False)
126+
zs = np.linspace(0.0, Lz, Nz, endpoint=False)
127+
X = xs[:, None, None]
128+
Y = ys[None, :, None]
129+
Z = zs[None, None, :]
130+
self._field = self.implicit_function(X, Y, Z)
131+
# range normalize to [0, 1]
132+
self._field = (self._field - np.min(self._field)) / (
133+
np.max(self._field) - np.min(self._field)
134+
)
135+
return self._field
136+
137+
def generate(self, threshold: Optional[float] = None) -> np.ndarray:
138+
"""
139+
Generate the binary microstructure.
140+
Returns a 3D numpy array (1=solid, 0=void). If invert=True, phases are swapped.
141+
If mode='shell', produces a shell of given thickness (in field units).
142+
"""
143+
if self._field is None:
144+
field = self._compute_field()
145+
else:
146+
field = self._field
147+
if threshold is None:
148+
threshold = self.threshold
149+
if self.mode == "solid":
150+
img = (field > threshold).astype(np.uint8)
151+
elif self.mode == "shell":
152+
t = abs(self.shell_thickness)
153+
img = (np.abs(field - threshold) < t).astype(np.uint8)
154+
else:
155+
raise ValueError(f"Unknown mode: {self.mode}")
156+
if self.invert:
157+
img = 1 - img
158+
return img
159+
160+
def find_threshold_for_volume_fraction(
161+
self,
162+
target_vf: float,
163+
tol: float = 1e-3,
164+
max_iter: int = 30,
165+
n_thresh: int = 50,
166+
optimize: str = "both",
167+
) -> tuple:
168+
"""
169+
Find threshold (and shell thickness if mode='shell') for target volume fraction.
170+
Parameters:
171+
target_vf: target volume fraction (fraction of solid voxels)
172+
tol: tolerance for volume fraction
173+
max_iter: max iterations for bisection
174+
n_thresh: number of threshold samples (for shell mode, if optimizing threshold)
175+
optimize: 'threshold', 'shell_thickness', or 'both' (shell mode only)
176+
- 'threshold': optimize threshold, keep shell_thickness fixed
177+
- 'shell_thickness': optimize shell_thickness, keep threshold fixed
178+
- 'both': jointly optimize both (default)
179+
Returns:
180+
- solid mode: (threshold, None)
181+
- shell mode: (threshold, shell_thickness)
182+
"""
183+
if self._field is None:
184+
field = self._compute_field()
185+
else:
186+
field = self._field
187+
flat = field.ravel()
188+
n_vox = flat.size
189+
if self.mode == "solid":
190+
# For solid: threshold at quantile
191+
k = int(np.round((1 - target_vf) * n_vox))
192+
sorted_field = np.partition(flat, k)
193+
thr = sorted_field[k]
194+
self.threshold = thr
195+
return thr, None
196+
elif self.mode == "shell":
197+
minf, maxf = float(np.min(flat)), float(np.max(flat))
198+
if optimize == "shell_thickness":
199+
# Only optimize shell_thickness, keep threshold fixed
200+
thr = self.threshold
201+
lo, hi = 0.0, max(maxf - thr, thr - minf)
202+
for _ in range(max_iter):
203+
mid = 0.5 * (lo + hi)
204+
vf = np.mean(np.abs(flat - thr) < mid)
205+
err = abs(vf - target_vf)
206+
if err < tol:
207+
break
208+
if vf > target_vf:
209+
hi = mid
210+
else:
211+
lo = mid
212+
self.shell_thickness = mid
213+
return thr, mid
214+
elif optimize == "threshold":
215+
# Only optimize threshold, keep shell_thickness fixed
216+
t = abs(self.shell_thickness)
217+
best_err = float("inf")
218+
best_thr = None
219+
for thr in np.linspace(minf, maxf, n_thresh):
220+
vf = np.mean(np.abs(flat - thr) < t)
221+
err = abs(vf - target_vf)
222+
if err < best_err:
223+
best_err = err
224+
best_thr = thr
225+
if best_err <= tol:
226+
break
227+
self.threshold = best_thr
228+
return best_thr, t
229+
elif optimize == "both":
230+
# Jointly optimize threshold and shell_thickness
231+
best_err = float("inf")
232+
best_thr = None
233+
best_t = None
234+
for thr in np.linspace(minf, maxf, n_thresh):
235+
lo, hi = 0.0, max(maxf - thr, thr - minf)
236+
for _ in range(max_iter):
237+
mid = 0.5 * (lo + hi)
238+
vf = np.mean(np.abs(flat - thr) < mid)
239+
err = abs(vf - target_vf)
240+
if err < tol:
241+
break
242+
if vf > target_vf:
243+
hi = mid
244+
else:
245+
lo = mid
246+
vf = np.mean(np.abs(flat - thr) < mid)
247+
err = abs(vf - target_vf)
248+
if err < best_err:
249+
best_err = err
250+
best_thr = thr
251+
best_t = mid
252+
if best_err <= tol:
253+
break
254+
self.threshold = best_thr
255+
self.shell_thickness = best_t
256+
return best_thr, best_t
257+
else:
258+
raise ValueError(f"Unknown optimize mode: {optimize}")
259+
else:
260+
raise ValueError(f"Unknown mode: {self.mode}")
261+
262+
263+
def main():
264+
N = 512, 256, 128
265+
L = 4.0, 2.0, 1.0
266+
tpms_types = ["gyroid", "schwarz_p", "diamond", "neovius", "iwp", "lidinoid"]
267+
h5_filename = "data/tpms.h5"
268+
unitcell_frequency = (4, 2, 1)
269+
invert = True
270+
271+
for tpms_type in tpms_types:
272+
tpms = TPMS(
273+
tpms_type=tpms_type,
274+
resolution=N,
275+
L=L,
276+
unitcell_frequency=unitcell_frequency,
277+
invert=invert,
278+
mode="solid",
279+
)
280+
MS = MicrostructureImage(image=tpms.image)
281+
MS.write(
282+
h5_filename=h5_filename,
283+
dset_name=tpms_type,
284+
order="zyx",
285+
compression_level=9,
286+
)
287+
288+
write_xdmf(
289+
h5_filepath=h5_filename,
290+
xdmf_filepath="data/tpms.xdmf",
291+
microstructure_length=L[::-1],
292+
time_series=False,
293+
verbose=True,
294+
)
295+
296+
297+
if __name__ == "__main__":
298+
main()

MSUtils/TPMS/tpms_example.py

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
"""
2+
Example script demonstrating the generation and optimization of a Triply Periodic Minimal Surface (TPMS) microstructure.
3+
This script creates a TPMS microstructure of type 'iwp' in shell mode with specified resolution and dimensions.
4+
It first generates an initial microstructure, computes volume fractions, and writes it to an HDF5 file.
5+
Then, it optimizes the shell thickness to achieve a target volume fraction of 0.2 for phase 0,
6+
regenerates the microstructure with optimized parameters, recomputes volume fractions, and writes the optimized
7+
result to HDF5.
8+
"""
9+
from MSUtils.TPMS.tpms import TPMS
10+
from MSUtils.general.MicrostructureImage import MicrostructureImage
11+
from MSUtils.general.h52xdmf import write_xdmf
12+
13+
if __name__ == "__main__":
14+
N = 256, 256, 256
15+
L = 1.0, 1.0, 1.0
16+
tpms_type = "iwp"
17+
h5_filename = "data/tpms_opt.h5"
18+
unitcell_frequency = (1, 1, 1)
19+
invert = False
20+
21+
tpms = TPMS(
22+
tpms_type=tpms_type,
23+
resolution=N,
24+
L=L,
25+
unitcell_frequency=unitcell_frequency,
26+
invert=invert,
27+
mode="shell",
28+
shell_thickness=0.1,
29+
)
30+
MS = MicrostructureImage(image=tpms.image)
31+
print(f"Volume fraction of phase 0: {MS.volume_fractions[0]:.4f}")
32+
print(f"Volume fraction of phase 1: {MS.volume_fractions[1]:.4f}")
33+
MS.write(
34+
h5_filename=h5_filename,
35+
dset_name="threshold_opt/" + tpms_type + "_thresh_0",
36+
order="zyx",
37+
compression_level=9,
38+
)
39+
40+
# Optimize shell thickness to achieve target volume fraction for phase 1
41+
vf_target_phase_1 = 0.2
42+
threshold_opt, thickness_opt = tpms.find_threshold_for_volume_fraction(
43+
vf_target_phase_1, optimize="shell_thickness"
44+
)
45+
print(
46+
f"New threshold and shell thickness for volume fraction {vf_target_phase_1}: {threshold_opt}, {thickness_opt}"
47+
)
48+
49+
# Regenerate TPMS with optimized parameters
50+
tpms = TPMS(
51+
tpms_type=tpms_type,
52+
resolution=N,
53+
L=L,
54+
unitcell_frequency=unitcell_frequency,
55+
invert=invert,
56+
mode="shell",
57+
threshold=threshold_opt,
58+
shell_thickness=thickness_opt,
59+
)
60+
MS = MicrostructureImage(image=tpms.image)
61+
print(f"Volume fraction of phase 0: {MS.volume_fractions[0]:.4f}")
62+
print(f"Volume fraction of phase 1: {MS.volume_fractions[1]:.4f}")
63+
MS.write(
64+
h5_filename=h5_filename,
65+
dset_name="threshold_opt/" + tpms_type + "_thresh_opt",
66+
order="zyx",
67+
compression_level=9,
68+
)
69+
70+
# Write XDMF for visualization
71+
write_xdmf(
72+
h5_filepath=h5_filename,
73+
xdmf_filepath="data/tpms_opt.xdmf",
74+
microstructure_length=L[::-1],
75+
time_series=False,
76+
verbose=True,
77+
)

0 commit comments

Comments
 (0)