loadpath 0.1.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (220) hide show
  1. loadpath/README.md +294 -0
  2. loadpath/SKILL.md +584 -0
  3. loadpath/__init__.py +17 -0
  4. loadpath/__main__.py +1202 -0
  5. loadpath/am.py +316 -0
  6. loadpath/amgfast.py +326 -0
  7. loadpath/assembly.py +985 -0
  8. loadpath/benchmarks/__init__.py +0 -0
  9. loadpath/benchmarks/beams.py +266 -0
  10. loadpath/benchmarks/cases/ge-bracket.json +380 -0
  11. loadpath/benchmarks/evaluate.py +172 -0
  12. loadpath/benchmarks/mechanisms.py +416 -0
  13. loadpath/benchmarks/problems.py +140 -0
  14. loadpath/benchmarks/reference/bridge-3d.top3d.npz +0 -0
  15. loadpath/benchmarks/reference/cantilever-3d.top3d.npz +0 -0
  16. loadpath/benchmarks/reference/lbracket-2d.top88-ft2.npz +0 -0
  17. loadpath/benchmarks/reference/mbb-2d.top88-ft1.npz +0 -0
  18. loadpath/benchmarks/reference/mbb-2d.top88-ft2.npz +0 -0
  19. loadpath/benchmarks/reference.py +138 -0
  20. loadpath/benchmarks/run.py +301 -0
  21. loadpath/brep.py +1470 -0
  22. loadpath/bucklingopt.py +300 -0
  23. loadpath/buildsim.py +1490 -0
  24. loadpath/cadimport.py +904 -0
  25. loadpath/calibration.py +885 -0
  26. loadpath/cavities.py +211 -0
  27. loadpath/checks/__init__.py +0 -0
  28. loadpath/checks/dfm.py +354 -0
  29. loadpath/checks/exchanger.py +7 -0
  30. loadpath/checks/fea.py +212 -0
  31. loadpath/checks/fit.py +475 -0
  32. loadpath/checks/networks.py +260 -0
  33. loadpath/checks/opening.py +67 -0
  34. loadpath/checks/sdf_kit.py +7 -0
  35. loadpath/conjugate.py +267 -0
  36. loadpath/constrained.py +329 -0
  37. loadpath/contact.py +691 -0
  38. loadpath/coopt.py +342 -0
  39. loadpath/cores.py +163 -0
  40. loadpath/crease.py +1138 -0
  41. loadpath/critic.py +212 -0
  42. loadpath/crosscheck.py +1107 -0
  43. loadpath/cutcell.py +510 -0
  44. loadpath/data/lattice_buckling.json +439 -0
  45. loadpath/data/lattice_tensors.json +27449 -0
  46. loadpath/data/published_experiments.json +71 -0
  47. loadpath/decimate.py +177 -0
  48. loadpath/delivery.py +190 -0
  49. loadpath/directions.py +262 -0
  50. loadpath/domain.py +630 -0
  51. loadpath/drains.py +615 -0
  52. loadpath/durability.py +413 -0
  53. loadpath/editor.html +3135 -0
  54. loadpath/editor.py +610 -0
  55. loadpath/escape.py +354 -0
  56. loadpath/evals/__init__.py +0 -0
  57. loadpath/evals/baselines/coarse.json +1543 -0
  58. loadpath/evals/baselines/full.json +1497 -0
  59. loadpath/evals/brief_kinds/__init__.py +1 -0
  60. loadpath/evals/brief_kinds/heatx.py +387 -0
  61. loadpath/evals/brief_kinds/lattice.py +571 -0
  62. loadpath/evals/brief_kinds/multimat.py +593 -0
  63. loadpath/evals/briefs/assets/bracket.step +1201 -0
  64. loadpath/evals/briefs/assets/desk_arm.urdf +22 -0
  65. loadpath/evals/briefs/assets/elbow-gearbox.stl +0 -0
  66. loadpath/evals/briefs/assets/motor-body.stl +0 -0
  67. loadpath/evals/briefs/assets/sketch.png +0 -0
  68. loadpath/evals/briefs/base-adapter-fdm.json +492 -0
  69. loadpath/evals/briefs/bell-crank-lpbf.json +180 -0
  70. loadpath/evals/briefs/belt-tensioner-fdm.json +151 -0
  71. loadpath/evals/briefs/camera-boom-mjf.json +339 -0
  72. loadpath/evals/briefs/camera-mount-pla.json +203 -0
  73. loadpath/evals/briefs/coolant-isolator-petg.json +100 -0
  74. loadpath/evals/briefs/elbow-bearing-bracket-sls.json +300 -0
  75. loadpath/evals/briefs/elbow-gearbox-forearm-mjf.json +558 -0
  76. loadpath/evals/briefs/elbow-link-fatigue-fdm.json +251 -0
  77. loadpath/evals/briefs/elbow-stop-pad-tpu.json +39 -0
  78. loadpath/evals/briefs/forearm-fdm.json +146 -0
  79. loadpath/evals/briefs/forearm-servo-link-fdm.json +327 -0
  80. loadpath/evals/briefs/grip-pad-finger-mm.json +354 -0
  81. loadpath/evals/briefs/gripper-bracket-creep-fdm.json +340 -0
  82. loadpath/evals/briefs/gripper-finger-fdm.json +155 -0
  83. loadpath/evals/briefs/gripper-jaw-fatigue-mjf.json +328 -0
  84. loadpath/evals/briefs/gripper-palm-fdm.json +715 -0
  85. loadpath/evals/briefs/hot-stepper-mount-asa-fdm.json +471 -0
  86. loadpath/evals/briefs/hpu-oil-cooler-lpbf.json +121 -0
  87. loadpath/evals/briefs/isolated-camera-mount-mm.json +675 -0
  88. loadpath/evals/briefs/kart-charge-cooler-lpbf.json +93 -0
  89. loadpath/evals/briefs/lighten-step-bracket.json +220 -0
  90. loadpath/evals/briefs/nema17-mount-stl.json +429 -0
  91. loadpath/evals/briefs/pillow-block-sls.json +220 -0
  92. loadpath/evals/briefs/polyjet-fingertip-mm.json +305 -0
  93. loadpath/evals/briefs/rod-hook-pla.json +154 -0
  94. loadpath/evals/briefs/sensor-arm-resin.json +150 -0
  95. loadpath/evals/briefs/shelf-bracket-pla.json +158 -0
  96. loadpath/evals/briefs/shoulder-riser-mjf.json +487 -0
  97. loadpath/evals/briefs/sketch-clevis.json +229 -0
  98. loadpath/evals/briefs/spring-bracket-pacf.json +294 -0
  99. loadpath/evals/briefs/tool-flange-ti64.json +280 -0
  100. loadpath/evals/briefs/tray-arm-lattice-fdm.json +260 -0
  101. loadpath/evals/briefs/turret-fdm.json +281 -0
  102. loadpath/evals/briefs/upper-arm-sls.json +179 -0
  103. loadpath/evals/briefs/urdf-forearm.json +233 -0
  104. loadpath/evals/briefs/wrist-link-mjf.json +278 -0
  105. loadpath/evals/briefs/wrist-yoke-mjf.json +245 -0
  106. loadpath/evals/briefs.py +393 -0
  107. loadpath/evals/cases/arm-link-cast.json +157 -0
  108. loadpath/evals/cases/arm-link-driven.json +171 -0
  109. loadpath/evals/cases/arm-link-sandwich.json +315 -0
  110. loadpath/evals/cases/arm-link.json +111 -0
  111. loadpath/evals/cases/bell-crank.json +116 -0
  112. loadpath/evals/cases/grip-turret.json +479 -0
  113. loadpath/evals/cases/grip-upper-arm.json +253 -0
  114. loadpath/evals/cases/grip-wrist-link-mjf.json +929 -0
  115. loadpath/evals/cases/grip-wrist-yoke.json +831 -0
  116. loadpath/evals/cases/lpbf-intercooler-core.json +59 -0
  117. loadpath/evals/cases/mbb-beam.json +61 -0
  118. loadpath/evals/cases/motor-body.stl +0 -0
  119. loadpath/evals/cases/motor-mount.json +265 -0
  120. loadpath/evals/cases/printed-hook.json +77 -0
  121. loadpath/evals/cases/wall-bracket-fasteners.json +125 -0
  122. loadpath/evals/cases/wall-bracket.json +126 -0
  123. loadpath/evals/corpus.py +179 -0
  124. loadpath/evals/pareto.py +90 -0
  125. loadpath/evals/run.py +860 -0
  126. loadpath/examples/arm-link.json +25 -0
  127. loadpath/examples/bumpered-bracket-compression.json +225 -0
  128. loadpath/examples/bumpered-bracket-lattice.json +204 -0
  129. loadpath/examples/bumpered-bracket.json +34 -0
  130. loadpath/examples/gripper-finger-mm.json +185 -0
  131. loadpath/examples/heat-exchanger-counterflow.json +21 -0
  132. loadpath/examples/heat-exchanger-crossflow-tuned.json +48 -0
  133. loadpath/examples/heat-exchanger-crossflow.json +20 -0
  134. loadpath/examples/pedal-lattice.json +23 -0
  135. loadpath/examples/pressure-plenum.json +12 -0
  136. loadpath/examples/shelf-bracket.json +27 -0
  137. loadpath/exchanger.py +831 -0
  138. loadpath/fasteners.py +199 -0
  139. loadpath/fem.py +668 -0
  140. loadpath/femops.py +70 -0
  141. loadpath/fieldjit.py +981 -0
  142. loadpath/fieldops.py +119 -0
  143. loadpath/fields.py +3697 -0
  144. loadpath/fieldtorch.py +464 -0
  145. loadpath/flow.py +984 -0
  146. loadpath/flowtopo.py +300 -0
  147. loadpath/gpu.py +521 -0
  148. loadpath/heatx.py +1067 -0
  149. loadpath/heatx_thermal.py +432 -0
  150. loadpath/homogenized.py +886 -0
  151. loadpath/ibrep.py +552 -0
  152. loadpath/impact.py +693 -0
  153. loadpath/infill.py +376 -0
  154. loadpath/interface_library.py +340 -0
  155. loadpath/interlock.py +186 -0
  156. loadpath/isolator.py +149 -0
  157. loadpath/knn.py +204 -0
  158. loadpath/lattice.py +1065 -0
  159. loadpath/lattice3mf.py +760 -0
  160. loadpath/lattice_buckling.py +245 -0
  161. loadpath/lattice_grade.py +325 -0
  162. loadpath/lattice_orient.py +595 -0
  163. loadpath/lattice_print.py +159 -0
  164. loadpath/lattice_tensors.py +558 -0
  165. loadpath/latticestep.py +1210 -0
  166. loadpath/manufacture.py +446 -0
  167. loadpath/material_library.py +489 -0
  168. loadpath/materials.py +144 -0
  169. loadpath/mcp_server.py +786 -0
  170. loadpath/mechanism.py +218 -0
  171. loadpath/mechanism_nl.py +811 -0
  172. loadpath/meshops.py +452 -0
  173. loadpath/mg.py +853 -0
  174. loadpath/mma.py +214 -0
  175. loadpath/modal.py +210 -0
  176. loadpath/multimat.py +1788 -0
  177. loadpath/narrowband.py +418 -0
  178. loadpath/nlkernel.py +241 -0
  179. loadpath/nonlinear.py +1095 -0
  180. loadpath/orient.py +334 -0
  181. loadpath/porous.py +994 -0
  182. loadpath/pressure.py +171 -0
  183. loadpath/preview.py +335 -0
  184. loadpath/printcheck.py +263 -0
  185. loadpath/printsetup.py +207 -0
  186. loadpath/processcheck.py +376 -0
  187. loadpath/quick.py +113 -0
  188. loadpath/recall.py +406 -0
  189. loadpath/reconstruct.py +797 -0
  190. loadpath/render.py +436 -0
  191. loadpath/resolved.py +189 -0
  192. loadpath/schema.py +1701 -0
  193. loadpath/sdf_kit.py +843 -0
  194. loadpath/selfx.py +292 -0
  195. loadpath/shapes.py +584 -0
  196. loadpath/sharp.py +717 -0
  197. loadpath/simp.py +1170 -0
  198. loadpath/sizing.py +312 -0
  199. loadpath/slicer.py +321 -0
  200. loadpath/spec.py +1072 -0
  201. loadpath/step.py +806 -0
  202. loadpath/struts.py +639 -0
  203. loadpath/study.py +1937 -0
  204. loadpath/tasks.py +822 -0
  205. loadpath/thermal.py +1691 -0
  206. loadpath/threemf.py +487 -0
  207. loadpath/tune.py +437 -0
  208. loadpath/validation.py +145 -0
  209. loadpath/verify.py +1215 -0
  210. loadpath/viewer.html +694 -0
  211. loadpath/viewer.py +446 -0
  212. loadpath/viscoelastic.py +106 -0
  213. loadpath/voxelprint.py +582 -0
  214. loadpath/voxels.py +402 -0
  215. loadpath-0.1.1.dist-info/METADATA +319 -0
  216. loadpath-0.1.1.dist-info/RECORD +220 -0
  217. loadpath-0.1.1.dist-info/WHEEL +5 -0
  218. loadpath-0.1.1.dist-info/entry_points.txt +2 -0
  219. loadpath-0.1.1.dist-info/licenses/LICENSE +21 -0
  220. loadpath-0.1.1.dist-info/top_level.txt +1 -0
@@ -0,0 +1,797 @@
1
+ """
2
+ From a density field to a part you can print.
3
+
4
+ 1. Smooth the projected density on a 2x finer grid and take the 0.5
5
+ isosurface (marching cubes). Upsampling first is what turns voxel steps
6
+ into the organic surface; smoothing the field (not the mesh) keeps it
7
+ watertight. Before the blur, along members (rods) the surface is
8
+ averaged along the member's own axis (_axial_smooth), which takes out
9
+ the grid's staircase on members tilted a little off the grid.
10
+ 2. Drop specks: components too small to matter are optimizer noise.
11
+ 3. Exact booleans with manifold3d: union the preserved primitives, then
12
+ subtract the keep-outs. The isosurface can only approximate a bore to
13
+ within a cell; after the subtract, a 22mm bearing seat is 22mm.
14
+ 4. Keep the bodies connected to the preserved geometry (and the largest
15
+ body), decimate to a triangle budget, and return a closed manifold mesh.
16
+ """
17
+ from __future__ import annotations
18
+
19
+ import numpy as np
20
+ from scipy import ndimage
21
+
22
+ from .domain import Domain
23
+ from .shapes import manifold_from_trimesh, trimesh_from_manifold
24
+ from .spec import Study
25
+
26
+ __all__ = ["reconstruct", "ReconstructOptions"]
27
+
28
+
29
+ class ReconstructOptions:
30
+ def __init__(self, upsample=3, smooth_sigma=0.9, level=0.5, max_triangles=250_000,
31
+ min_body_fraction=0.002, taubin_iterations=25, blend=None, blend_k=0.8, fillet=True, seat=True,
32
+ close=None, axial=True, axial_debias=None):
33
+ self.upsample = upsample # field refinement before meshing
34
+ self.smooth_sigma = smooth_sigma # in fine cells
35
+ self.level = level
36
+ self.max_triangles = max_triangles
37
+ self.min_body_fraction = min_body_fraction
38
+ # Volume-preserving Taubin smoothing of the organic surface, before
39
+ # the exact booleans (which then restore every crisp face and bore).
40
+ self.taubin_iterations = taubin_iterations
41
+ # Fillet between the organic body and preserved geometry: its radius
42
+ # in mm (None: 1.5 x minMember, at least 3), and the smooth-max
43
+ # strength of the older density blend (fillet=False). At 3 mm the
44
+ # motor mount's pads, which stand 1 mm proud of the plate round them,
45
+ # kept 108 mm of crease; at 4.5 (its minMember is 3) about 4.
46
+ self.blend = blend
47
+ self.blend_k = blend_k
48
+ # Fillet in distance space (True) or the older density smooth max.
49
+ self.fillet = fillet
50
+ # Move material at flush faces onto the face (_seat_on_flush_faces).
51
+ self.seat = seat
52
+ # Round every concave edge of the organic body itself, not only where
53
+ # it meets preserved geometry: a morphological closing in distance
54
+ # space by this radius in mm (None: half the minMember, at least 1;
55
+ # 0 turns it off). It only ever adds material, into corners and into
56
+ # slots narrower than twice the radius (below the minimum member,
57
+ # so unprintable anyway).
58
+ self.close = close
59
+ # Average the field along each member's own axis before smoothing
60
+ # (_axial_smooth): a member tilted a few degrees off the grid
61
+ # otherwise keeps the grid's staircase as a row of bumps and necks.
62
+ self.axial = axial
63
+ # Debias that average (see _axial_smooth) so thin members keep their
64
+ # volume; None follows resolution.keepThinMembers (off by default).
65
+ self.axial_debias = axial_debias
66
+
67
+
68
+ def _smooth_max(a, b, k):
69
+ """Polynomial smooth maximum: max(a, b) plus a bump of up to k/4 where the
70
+ two are close. On density fields this is a fillet where they meet."""
71
+ h = np.maximum(k - np.abs(a - b), 0.0) / k
72
+ return np.maximum(a, b) + h * h * k * 0.25
73
+
74
+
75
+ def _smooth_min(a, b, k):
76
+ h = np.maximum(k - np.abs(a - b), 0.0) / k
77
+ return np.minimum(a, b) - h * h * k * 0.25
78
+
79
+
80
+ def _smooth_union(a, b, k):
81
+ """Polynomial smooth minimum of two signed distances: their union with a
82
+ fillet where the surfaces meet. The fillet's sag from the sharp corner is
83
+ k/4; at a right angle it reaches about k (1 - 1/sqrt(2)) out along each
84
+ surface, roughly a round of radius k / (2 (sqrt(2) - 1)) = 1.2 k."""
85
+ h = np.maximum(k - np.abs(a - b), 0.0) / k
86
+ return np.minimum(a, b) - h * h * k * 0.25
87
+
88
+
89
+ def _field_distance(fine, step, level=0.5):
90
+ """Signed distance (mm, negative inside) to the `level` surface of a
91
+ smooth density field: exact distance transforms away from the surface,
92
+ and the field's own gradient within a couple of cells of it, where a
93
+ distance transform would only resolve whole cells."""
94
+ inside = fine >= level
95
+ d = (ndimage.distance_transform_edt(~inside) - ndimage.distance_transform_edt(inside)) * step
96
+ d += np.where(inside, 0.5 * step, -0.5 * step)
97
+ g = np.linalg.norm(np.stack(np.gradient(fine, step)), axis=0)
98
+ near = (np.abs(fine - level) < 0.45) & (g > 1e-6)
99
+ lin = (level - fine) / np.maximum(g, 1e-6)
100
+ ok = near & (np.abs(lin) < 2.0 * step)
101
+ d[ok] = lin[ok]
102
+ return d
103
+
104
+
105
+ class _GridSDF:
106
+ """The primitives' signed distances on one fine grid, each group (the
107
+ flush faces, preserved shapes, design space, keep-outs) evaluated once
108
+ and kept: _isosurface asked for the same ones up to three times, and
109
+ each was a min over a stacked (shapes, cells) array. A running minimum
110
+ gives the same values."""
111
+
112
+ def __init__(self, shape, origin, step):
113
+ self.shape, self.origin, self.step = tuple(shape), origin, step
114
+ self._pts = None
115
+ self._min = {}
116
+
117
+ @property
118
+ def pts(self):
119
+ if self._pts is None:
120
+ idx = np.stack(np.meshgrid(*[np.arange(n) for n in self.shape], indexing="ij"), -1).reshape(-1, 3)
121
+ self._pts = self.origin + idx * self.step
122
+ return self._pts
123
+
124
+ def min(self, shapes):
125
+ key = tuple(id(s) for s in shapes)
126
+ if key not in self._min:
127
+ self._min[key] = _min_sdf(shapes, self.pts)
128
+ return self._min[key]
129
+
130
+
131
+ def _min_sdf(shapes, pts):
132
+ """min over `shapes` of their signed distance at `pts`."""
133
+ out = None
134
+ for sh in shapes:
135
+ d = sh.sdf(pts)
136
+ out = np.array(d, float) if out is None else np.minimum(out, d, out=out)
137
+ return out
138
+
139
+
140
+ def _seat_on_flush_faces(fine, domain: Domain, upsample: int, pad: int, flush, design, grid=None):
141
+ """Move material at a flush face (a wall the part is held against) onto
142
+ the face itself.
143
+
144
+ The grid is centered on the design space, for exact symmetry, so along an
145
+ axis whose length is not a whole number of cells the outer cell layer
146
+ hangs past each end face by up to half a cell. A plate the optimizer
147
+ builds against a wall occupies that layer: it was analyzed as a full cell
148
+ thick, but the part is trimmed at the real face, so it was delivered
149
+ thinner by the overhang (the printed hook's wall plate, 1.46 mm p5 against
150
+ a 1.95 mm cell; the wall bracket's seat). Near a box face that a flush
151
+ region touches, the field is translated inward by that face's overhang,
152
+ so the plate keeps its analyzed thickness from the face in, easing back
153
+ to the field as it was over the next cells."""
154
+ from .shapes import Box
155
+
156
+ step = domain.pitch / upsample
157
+ origin = domain.origin - pad * domain.pitch + 0.5 * step
158
+ shape = fine.shape
159
+ grid = grid if grid is not None else _GridSDF(shape, origin, step)
160
+ pts = grid.pts
161
+ near = None
162
+ out = fine
163
+ grid_lo = domain.origin
164
+ grid_hi = domain.origin + np.asarray(domain.dims) * domain.pitch
165
+ for b in design:
166
+ if not isinstance(b, Box):
167
+ continue
168
+ for a in range(3):
169
+ for sign, face, hang in ((1, b.lo[a], b.lo[a] - grid_lo[a]), (-1, b.hi[a], grid_hi[a] - b.hi[a])):
170
+ if hang < 0.5 * step:
171
+ continue
172
+ if near is None:
173
+ near = grid.min(flush) < 1.5 * domain.pitch
174
+ inward = sign * (pts[:, a] - face) # mm inside the face
175
+ w = np.clip((2.5 * domain.pitch - inward) / (1.5 * domain.pitch), 0.0, 1.0) * near
176
+ if not w.any():
177
+ continue
178
+ shift = np.zeros(3)
179
+ shift[a] = sign * hang / step
180
+ moved = ndimage.shift(out, shift, order=1, mode="nearest")
181
+ w = w.reshape(shape)
182
+ out = w * moved + (1.0 - w) * out
183
+ return out
184
+
185
+
186
+ FINE_CELLS = 8e6
187
+
188
+ # _axial_smooth: the Hessian's scale and the axis tensor's spread (Gaussian
189
+ # sigmas in grid cells), the smoothing length along the axis (sigma, cells),
190
+ # the coherence range over which it fades in, and the bilateral range (cells):
191
+ # samples whose surface lies further than that from the point's own are
192
+ # another feature (a branch, a member's end), not a step of the staircase.
193
+ AXIAL_SCALE = 1.5
194
+ AXIAL_SPREAD = 1.5
195
+ AXIAL_LENGTH = 3.0
196
+ AXIAL_COHERENCE = (0.5, 0.8)
197
+ AXIAL_RANGE = 1.5
198
+
199
+
200
+ def _rod_axes(g, scale=AXIAL_SCALE, spread=AXIAL_SPREAD):
201
+ """Where the density `g` has rods, and along which axis: the tensor
202
+ sum of s e e^T spread over `spread` cells, with e the Hessian's flat
203
+ direction at `scale` and s its rod strength (the smaller of the two
204
+ strong curvatures, both negative: a ridge line, not a plate or a blob)."""
205
+ H = np.empty(g.shape + (3, 3))
206
+ for i in range(3):
207
+ for j in range(i, 3):
208
+ order = [0, 0, 0]
209
+ order[i] += 1
210
+ order[j] += 1
211
+ H[..., i, j] = H[..., j, i] = ndimage.gaussian_filter(g, scale, order=order)
212
+ lam, vec = np.linalg.eigh(H)
213
+ del H
214
+ o = np.argsort(np.abs(lam), axis=-1)
215
+ lam = np.take_along_axis(lam, o, -1)
216
+ e = np.take_along_axis(vec, o[..., None, :], -1)[..., :, 0]
217
+ del vec
218
+ l1, l2, l3 = lam[..., 0], lam[..., 1], lam[..., 2]
219
+ s = np.where((l2 < 0) & (l3 < 0), np.clip(-l2 - np.abs(l1), 0.0, None), 0.0)
220
+ del lam
221
+ T = {}
222
+ for i in range(3):
223
+ for j in range(i, 3):
224
+ T[i, j] = ndimage.gaussian_filter(s * e[..., i] * e[..., j], spread)
225
+ return T
226
+
227
+
228
+ def _axial_smooth(fine, coarse, upsample, length=AXIAL_LENGTH, coherence=AXIAL_COHERENCE, rng=AXIAL_RANGE,
229
+ reach=2.5, samples=21, debias=False):
230
+ """Average the surface's position along the local member axis.
231
+
232
+ A member tilted slightly off the grid is voxelized as a staircase: its
233
+ top and bottom step a cell at a time, a cell / tan(tilt) apart along it,
234
+ so its section swings by about a cell, out of phase top and bottom. The
235
+ isotropic upsample and blur below only round each step off, and the part
236
+ came out with a row of bumps and necks along every long stem (GRIP's
237
+ upper arm: +-0.6 mm on an 11 mm stem every 19 mm, about 9% less bending
238
+ stiffness than the same mass at constant section, and the stress
239
+ concentrated at every neck). The steps carry no design intent: the
240
+ optimizer's filter blurs over more than a cell, and its sensitivities saw
241
+ the staircase only as the grid's error.
242
+
243
+ Where the material round a point is a rod with one clear axis
244
+ (_rod_axes: coherent, and strong against the part's own rods), the
245
+ field's signed distance there is replaced by its Gaussian average along
246
+ that axis over `length` cells, and the field's 0.5 level moved to the
247
+ averaged surface (averaging the density instead shrank thin members,
248
+ whose upsampled density never reaches 1). The average is bilateral:
249
+ samples whose distance differs from the point's by much more than a
250
+ step (`rng` cells) count less, so a branch leaving the member, or the
251
+ member's own end, is not smeared along it. Plates, blobs and junctions
252
+ of several directions keep the field as it was; only voxels within a
253
+ cell and a half of the surface change.
254
+
255
+ Averaging a round member's distance along a line that is not exactly
256
+ its axis is biased outward: the distance to a convex surface is convex,
257
+ so every sample off the axis reads further out than the point (about
258
+ offset^2 / 2r, so the thinner the member the worse), and the member
259
+ shrank. Thin members lost the most: a graded rod two cells across lost
260
+ 15% of its volume, 1.5 cells 38%, and the MJF wrist link (sub-cell
261
+ struts on 2.44 mm cells) meshed 2-4% light at every density. The bias
262
+ is a steady offset where the staircase is a ripple, so the average is
263
+ taken twice and extrapolated back (2 A d - A A d, as Taubin's inflate
264
+ step undoes Laplacian shrinkage): the bias cancels to first order and
265
+ the ripple, already nearly gone after one pass, stays small. The rods
266
+ keep their volume to 0.5-2%.
267
+
268
+ It is opt-in (debias, on with resolution.keepThinMembers), like the
269
+ volume-keeping surface smoothing, and for the same reason: the shrinking
270
+ one-pass average reads better on the evals. Debiased on everywhere, the
271
+ full tier went mbb-beam 13.8 -> 15.0 g at target, motor-mount 19.8 ->
272
+ 22.1, hook 23.9 -> 24.8, wrist yoke 16.7 -> 17.2: those parts verify
273
+ lighter with their thin members slightly shrunk. The wrist link went
274
+ 34.2 -> 33.2 (31.4 with the pass off), but its verified SF swings 2-3x
275
+ between near-identical meshes of the same density (its peak sits on
276
+ sub-cell struts), so that case's 31 vs 34 g is mostly search noise."""
277
+ g = np.asarray(coarse, float)
278
+ T = _rod_axes(g)
279
+ size = 2 * int(np.ceil(1.5 * upsample)) + 1
280
+ band = (ndimage.maximum_filter(fine, size=size) > 0.02) & (ndimage.minimum_filter(fine, size=size) < 0.98)
281
+ fi = np.argwhere(band)
282
+ del band
283
+ if not len(fi):
284
+ return fine
285
+ # fine index i sits at coarse index (i + 0.5) / upsample - 0.5 (see _isosurface)
286
+ cc = ((fi + 0.5) / upsample - 0.5).T
287
+ t = np.empty((len(fi), 3, 3))
288
+ for (i, j), a in T.items():
289
+ t[:, i, j] = t[:, j, i] = ndimage.map_coordinates(a, cc, order=1, mode="nearest")
290
+ del cc
291
+ mu, vec = np.linalg.eigh(t)
292
+ del t
293
+ top = np.maximum(mu[:, 2], 1e-30)
294
+ coh = (mu[:, 2] - mu[:, 1]) / top
295
+ # strength against the part's own rods, so a design's scale does not matter
296
+ ref = np.percentile(top, 90)
297
+ lo, hi = coherence
298
+ w = np.clip((coh - lo) / (hi - lo), 0.0, 1.0) * np.clip(2.0 * top / max(ref, 1e-30), 0.0, 1.0)
299
+ keep = w > 0.0
300
+ fi, axis, w = fi[keep], vec[keep, :, 2].T, w[keep]
301
+ if not len(fi):
302
+ return fine
303
+ z = np.linspace(-reach, reach, samples)
304
+ gz = np.exp(-0.5 * z * z)
305
+ base = fi.T.astype(float)
306
+ dist = _field_distance(fine, 1.0, 0.5) # in fine cells
307
+ i, j, k = fi.T
308
+ d0 = dist[i, j, k]
309
+ r2 = (rng * upsample) ** 2
310
+
311
+ def along(field, ref):
312
+ num = np.zeros(len(fi))
313
+ den = np.zeros(len(fi))
314
+ for zk, gk in zip(z, gz):
315
+ dk = ndimage.map_coordinates(field, base + (w * (zk * length * upsample)) * axis, order=1, mode="nearest")
316
+ wk = gk * np.exp(-0.5 * (dk - ref) ** 2 / r2)
317
+ num += wk * dk
318
+ den += wk
319
+ return num / den
320
+
321
+ avg = along(dist, d0)
322
+ if debias:
323
+ A = dist.copy()
324
+ A[i, j, k] = avg
325
+ avg2 = along(A, avg)
326
+ del A
327
+ avg = 2.0 * avg - avg2
328
+ # move the field's 0.5 level by the change in distance, keeping its ramp
329
+ grad = np.linalg.norm(np.stack(np.gradient(fine)), axis=0)[i, j, k]
330
+ out = fine.copy()
331
+ out[i, j, k] = np.clip(fine[i, j, k] + (d0 - avg) * np.maximum(grad, 0.25), 0.0, 1.0)
332
+ return out
333
+
334
+
335
+ def _isosurface(domain: Domain, xphys: np.ndarray, o: ReconstructOptions, keep_out=(), min_member=None,
336
+ flush=(), preserve=(), design=()):
337
+ import trimesh
338
+ from skimage.measure import marching_cubes
339
+
340
+ grid = domain.full_grid(xphys)
341
+ pad = 3
342
+ # Pad with void, so material that reaches the design space's boundary
343
+ # rounds off just inside it: the exact intersection with the design
344
+ # space then has nothing to cut, and no flat sawn-off faces appear where
345
+ # the box happened to end (GRIP's wrist yoke had several). Only where a
346
+ # part must sit flush against something (`flush`: its supports, a wall)
347
+ # is the edge replicated, so the material runs on through the boundary
348
+ # and the intersection cuts it off flat there, on purpose (the printed
349
+ # hook's wall face). Two cells of that, then one of void so marching
350
+ # cubes still closes the surface.
351
+ padded = np.pad(grid, pad)
352
+ if flush:
353
+ edge = np.pad(np.pad(grid, pad - 1, mode="edge"), 1)
354
+ idx = np.stack(np.meshgrid(*[np.arange(n) for n in padded.shape], indexing="ij"), -1).reshape(-1, 3)
355
+ centers = domain.origin + (idx - pad + 0.5) * domain.pitch
356
+ near = (_min_sdf(flush, centers) < 1.5 * domain.pitch).reshape(padded.shape)
357
+ padded = np.where(near, edge, padded)
358
+ grid = padded
359
+ # Cubic upsampling: linear interpolation of a voxel field leaves the
360
+ # staircase in the isosurface as faceted terraces.
361
+ fine = np.clip(ndimage.zoom(grid, o.upsample, order=3, mode="nearest", grid_mode=True), 0.0, 1.0)
362
+ # the fine grid's cell centers (see `origin` below), and the shapes'
363
+ # distances on them, computed once for all the steps that follow
364
+ sdfs = _GridSDF(fine.shape, domain.origin - pad * domain.pitch + 0.5 * (domain.pitch / o.upsample),
365
+ domain.pitch / o.upsample)
366
+ if flush and design and o.seat:
367
+ fine = _seat_on_flush_faces(fine, domain, o.upsample, pad, flush, design, grid=sdfs)
368
+ if o.axial:
369
+ fine = _axial_smooth(fine, grid, o.upsample, debias=bool(o.axial_debias))
370
+ if o.smooth_sigma > 0:
371
+ fine = ndimage.gaussian_filter(fine, o.smooth_sigma)
372
+ if min_member:
373
+ # Morphological opening with a ball of the minimum member's diameter:
374
+ # anything thinner than that cannot fit the ball and goes. Smoothing a
375
+ # member that ends inside the design space tapers it to a knife edge,
376
+ # and that sliver is both unprintable and a stress raiser that grew
377
+ # with every refinement of the verification grid (14 -> 58 MPa on the
378
+ # arm link at a flange tip).
379
+ step_ = domain.pitch / o.upsample
380
+ r = 0.5 * float(min_member) / step_
381
+ if r >= 1.0:
382
+ k = int(np.ceil(r))
383
+ zz, yy, xx = np.mgrid[-k:k + 1, -k:k + 1, -k:k + 1]
384
+ ball = (xx * xx + yy * yy + zz * zz) <= r * r
385
+ fine = ndimage.grey_opening(fine, footprint=ball)
386
+ step = domain.pitch / o.upsample
387
+ # zoom(grid_mode=True) maps fine cell centers to coarse cell centers, so
388
+ # fine index i sits at coarse-cell coordinate (i + 0.5) / upsample - 0.5.
389
+ origin = domain.origin - pad * domain.pitch + 0.5 * step
390
+ if preserve and o.blend > 0 and not o.fillet:
391
+ # Fillet the organic body into the preserved geometry: the preserved
392
+ # shapes as a density field that crosses 0.5 at their surface and
393
+ # ramps over `blend` mm, smooth-max'd into the optimized field. Where
394
+ # a member meets a boss the bump fills the crease; elsewhere the
395
+ # preserve field stays under 0.5 and changes nothing. (The exact union
396
+ # in reconstruct() still restores the crisp faces and bores.)
397
+ sd = sdfs.min(preserve).reshape(fine.shape)
398
+ pf = np.clip(0.5 - sd / (2.0 * o.blend), 0.0, 1.0)
399
+ fine = _smooth_max(fine, pf, o.blend_k)
400
+ if design:
401
+ # Round material off where it meets the design space's boundary: a
402
+ # smooth minimum with the envelope's own density field, which is 0.5
403
+ # on the boundary and 1 a cell inside it. Material that runs into the
404
+ # boundary ends there with rounded edges instead of a sawn-off face
405
+ # with sharp ones; material lying along it keeps its full thickness.
406
+ # (A multiplicative fade, tried first, thinned every member lying on
407
+ # a boundary face by half a cell: the arm link's flange plates went
408
+ # from 3.4 to 1.5 mm and failed the member check.)
409
+ sd = sdfs.min(design)
410
+ env = np.clip(0.5 - sd / (2.0 * domain.pitch), 0.0, 1.0)
411
+ if flush:
412
+ near = sdfs.min(flush) < 1.5 * domain.pitch
413
+ env[near] = 1.0
414
+ if preserve and o.fillet and o.blend > 0:
415
+ # Not round a preserved shape either: where a boss runs the full
416
+ # height of the design space, material beside it rounded off
417
+ # just under the boss's crisp end face and left a lip all round
418
+ # its rim (the bell crank's bosses, 40 mm of crease). Within
419
+ # `blend` of preserved geometry the material runs on to the
420
+ # boundary and ends flush with the boss's face, easing back to
421
+ # the rounding further out.
422
+ dp = sdfs.min(preserve)
423
+ env = np.maximum(env, np.clip(2.0 - dp / o.blend, 0.0, 1.0))
424
+ fine = _smooth_min(fine, env.reshape(fine.shape), 0.5)
425
+ if preserve and o.blend > 0 and o.fillet:
426
+ # (after the boundary rounding: a fillet made before it was rounded
427
+ # away again where a member's top met a full-height boss, bell crank)
428
+ # Fillet the organic body into the preserved geometry in distance
429
+ # space: the organic surface and the preserved shapes as signed
430
+ # distances, joined by a smooth union of radius about `blend` mm, then
431
+ # back to a density with its 0.5 level on the blended surface. A
432
+ # density-space smooth max (fillet=False, above) only bumps the
433
+ # field by k/4 within a cell or two of the junction, so members still
434
+ # met bosses in a hard crease (GRIP's wrist yoke, QA 10/28). The
435
+ # exact union in reconstruct() then adds the preserved shapes, which
436
+ # the fillet meets tangentially, so it leaves no crease of its own.
437
+ sd = sdfs.min(preserve).reshape(fine.shape)
438
+ k = o.blend / 1.2
439
+ du = _smooth_union(_field_distance(fine, step, o.level), sd, k)
440
+ # back to density, over the same width the smoothing gave the field
441
+ width = 2.0 * step
442
+ filleted = np.clip(o.level - du / (2.0 * width), 0.0, 1.0)
443
+ if design:
444
+ # after the boundary rounding, and never past the boundary itself
445
+ filleted = np.minimum(filleted, env.reshape(fine.shape))
446
+ fine = np.maximum(fine, filleted)
447
+ if o.fillet and o.close and o.close > 0:
448
+ # Closing: grow the surface out by `close`, re-distance, and shrink it
449
+ # back. Where the body is convex or flat that returns it unchanged;
450
+ # into a concave edge between two members it leaves a fillet of that
451
+ # radius. Struts meeting at a sharp angle otherwise keep a crease the
452
+ # preserve fillet never sees (GRIP's upper arm, 51 mm of it once its
453
+ # thrust disk was no longer pinned solid).
454
+ grown = _field_distance(fine, step, o.level) - o.close < 0
455
+ d2 = (ndimage.distance_transform_edt(~grown) - ndimage.distance_transform_edt(grown)) * step
456
+ d2 += np.where(grown, 0.5 * step, -0.5 * step)
457
+ # the re-distance is exact only to the grid's cells: smooth it over a
458
+ # cell, or its staircase lands just outside the body as ridges
459
+ closed = ndimage.gaussian_filter(np.clip(o.level - (d2 + o.close) / (4.0 * step), 0.0, 1.0), 1.0)
460
+ if design:
461
+ closed = np.minimum(closed, env.reshape(fine.shape))
462
+ fine = np.maximum(fine, closed)
463
+ if keep_out:
464
+ # Fade the field to zero at every keep-out surface. Smoothing and
465
+ # upsampling otherwise carry material across a keep-out (a cable
466
+ # channel through the middle of a link), and the exact subtract
467
+ # afterwards leaves a skin a fraction of a millimetre thick wrapped
468
+ # round it: unprintable, and found by the eval suite's member check.
469
+ clearance = 0.5 * domain.pitch
470
+ d = sdfs.min(keep_out)
471
+ fade = np.clip(d / clearance, 0.0, 1.0)
472
+ if preserve and o.fillet and o.blend > 0:
473
+ # ...except beside preserved geometry: a pad that bears on a
474
+ # keep-out (a motor face) ends exactly at it, and the faded
475
+ # material round it ended a clearance short, leaving the pad's
476
+ # end standing proud in a step all round (the motor mount's
477
+ # pads and pilot ring). There the exact subtract does the cut.
478
+ dp = sdfs.min(preserve)
479
+ fade = np.maximum(fade, np.clip(2.0 - dp / o.blend, 0.0, 1.0))
480
+ fine = fine * fade.reshape(fine.shape)
481
+ if fine.max() < o.level:
482
+ raise ValueError("the optimized density never reaches solid; nothing to mesh")
483
+ # One more layer of void all round: marching cubes leaves the surface open
484
+ # wherever the field is solid on the array's edge, and smoothing, the
485
+ # fillet and the closing above can carry it there through the single
486
+ # void layer of padding. Unseen below ~100k cells; at 845k (0.53 mm
487
+ # cells) the arm link's flush faces came out with 1700 open edges and
488
+ # manifold3d refused the mesh. Where the edge was void this changes
489
+ # nothing.
490
+ fine = np.pad(fine, 1)
491
+ origin = origin - step
492
+ verts, faces, _, _ = marching_cubes(fine, o.level, spacing=(step, step, step))
493
+ verts = verts + origin
494
+ mesh = trimesh.Trimesh(verts, faces[:, ::-1], process=True)
495
+ if mesh.volume < 0:
496
+ mesh.invert()
497
+ return mesh
498
+
499
+
500
+ def _flat_faces(shape):
501
+ """The flat faces of a preserved shape, as (point, outward normal,
502
+ lateral) with lateral(p) the distance from p's projection to the face
503
+ (0 on it). Box faces and cylinder end caps; anything else has none."""
504
+ from .shapes import Box, Cylinder
505
+
506
+ out = []
507
+ if isinstance(shape, Cylinder):
508
+ def lateral(p, sh=shape):
509
+ d = p - sh.p0
510
+ radial = np.linalg.norm(d - (d @ sh.axis)[:, None] * sh.axis, axis=1)
511
+ return np.maximum(np.maximum(radial - sh.r, sh.r_inner - radial), 0.0)
512
+ out += [(shape.p0, -shape.axis, lateral), (shape.p1, shape.axis, lateral)]
513
+ elif isinstance(shape, Box):
514
+ for a in range(3):
515
+ others = [i for i in range(3) if i != a]
516
+ def lateral(p, sh=shape, o=others):
517
+ q = p[:, o]
518
+ return np.linalg.norm(np.maximum(np.maximum(sh.lo[o] - q, q - sh.hi[o]), 0.0), axis=1)
519
+ for sign, at in ((-1.0, shape.lo), (1.0, shape.hi)):
520
+ n = np.zeros(3)
521
+ n[a] = sign
522
+ out.append((at, n, lateral))
523
+ return out
524
+
525
+
526
+ def _snap_to_flat_faces(mesh, preserve, reach: float, spread: float):
527
+ """Move the organic surface onto a preserved shape's flat face where it
528
+ runs alongside it within `reach` (mm), facing the same way.
529
+
530
+ The density field resolves a millimetre at best, so where the body runs
531
+ on flush with a boss's end face its surface lands a fraction of a
532
+ millimetre off the face, and the exact union leaves the boss's rim
533
+ standing in a hairline step all round (GRIP's turret: 70 mm of crease
534
+ round its J1 hub's mating face, and the body half a millimetre proud of
535
+ that face where the fillet had bulged past it). On the face's own
536
+ footprint the surface goes onto the face's plane; past its edge the move
537
+ eases off over `spread` mm, so the body rises from the face's rim in a
538
+ slope instead of a step."""
539
+ v = np.asarray(mesh.vertices, float).copy()
540
+ vn = np.asarray(mesh.vertex_normals, float)
541
+ moved = False
542
+ for sh in preserve:
543
+ for at, n, lateral in _flat_faces(sh):
544
+ s = (v - at) @ n
545
+ # only ever out onto the plane, adding material: moving a surface
546
+ # in onto it thins whatever stands on the face (the motor mount's
547
+ # web over a bolt boss: SF 3.35 -> 2.73)
548
+ cand = (s < 0) & (s > -reach) & (vn @ n > np.cos(np.radians(35.0)))
549
+ if not cand.any():
550
+ continue
551
+ i = np.nonzero(cand)[0]
552
+ # (fully onto the plane for a cell past the edge too: a vertex
553
+ # left a hundredth of a millimetre off it is still a step)
554
+ w = np.clip(1.0 - (lateral(v[i]) - reach) / spread, 0.0, 1.0)
555
+ w *= np.clip(2.0 * (reach - np.abs(s[i])) / reach, 0.0, 1.0)
556
+ v[i] -= (w * s[i])[:, None] * n
557
+ moved = moved or bool((w > 0).any())
558
+ if moved:
559
+ mesh.vertices = v
560
+ return mesh
561
+
562
+
563
+ def _attach_plan(mesh, study, organic):
564
+ """Remember how the part was built, exact shapes and all, for STEP
565
+ export (step.py): ((organic & envelope) | preserved) - keep-outs."""
566
+ from .shapes import Union
567
+ from .sharp import Exact, Op, Organic
568
+ from .step import attach_plan
569
+
570
+ def one(shapes):
571
+ return shapes[0] if len(shapes) == 1 else Union(shapes)
572
+
573
+ org = Organic(None, ["organic"], mesh=trimesh_from_manifold(organic)) # a trimesh: parts cross processes
574
+ env = list(study.design_space) + [r.shape for r in study.preserve]
575
+ p = Op("intersect", [org, Exact(one(env), "envelope")])
576
+ if study.preserve:
577
+ pres = one([r.shape for r in study.preserve])
578
+ p = Op("union", [p, Exact(pres, "preserve")])
579
+ p.keep_with = pres
580
+ if study.keep_out:
581
+ keep = getattr(p, "keep_with", None)
582
+ p = Op("subtract", [p, Exact(one([r.shape for r in study.keep_out]), "keep-out")])
583
+ if keep is not None:
584
+ p.keep_with = keep
585
+ attach_plan(mesh, p)
586
+
587
+
588
+ def _drop_slivers(mesh):
589
+ """Drop zero-volume shells (a pair of coincident triangles facing apart)
590
+ that a boolean can leave on a flat face. manifold3d keeps them on vertices
591
+ of their own, so the mesh in memory is closed, but any STL reader welds
592
+ them into the part, where they read as non-manifold edges (the wrist link
593
+ in docs/tutorials/agent-mcp was not watertight once saved). Returns the
594
+ welded mesh without them, or `mesh` itself when there are none."""
595
+ import trimesh
596
+
597
+ m = mesh.copy()
598
+ m.merge_vertices()
599
+ if not len(m.faces):
600
+ return mesh
601
+ lab = trimesh.graph.connected_component_labels(m.face_adjacency, node_count=len(m.faces))
602
+ tri = m.vertices[m.faces]
603
+ vol = np.einsum("ij,ij->i", tri[:, 0], np.cross(tri[:, 1], tri[:, 2])) / 6.0
604
+ per = np.bincount(lab, weights=vol)
605
+ sliver = np.abs(per) < 1e-6 * max(abs(per.sum()), 1e-9)
606
+ if not sliver.any():
607
+ return mesh
608
+ # the welded copy: in manifold3d's own topology the part's faces either
609
+ # side of a sliver are joined through it, so dropping it unwelded opens holes
610
+ m.update_faces(~sliver[lab])
611
+ m.remove_unreferenced_vertices()
612
+ return m
613
+
614
+
615
+ def _smooth_surface(iso, domain: Domain, xphys: np.ndarray, iterations: int, keep_thin=False, lamb=0.5,
616
+ nu=0.53):
617
+ """Smooth the isosurface: alternating steps v += f (L v - v), the first
618
+ shrinking (lamb), the second shrinking on thick members and inflating
619
+ (Taubin's) on members about two cells thick or less.
620
+
621
+ This code long passed nu = -0.53 to trimesh.smoothing.filter_taubin,
622
+ which subtracts its nu, so both steps shrank: plain Laplacian smoothing.
623
+ On thick members that rounds the staircase off in a way the evals are
624
+ tuned to: true Taubin everywhere read worse (mass at target SF +3% to
625
+ +10% on six full-tier cases, the wrist yoke failing on creases). But it
626
+ is curvature flow, and members one or two cells thick shrink until they
627
+ nearly part: the GRIP wrist yoke's stress-constrained design at 15k
628
+ cells lost 20% of its mesh volume (verified peak 33 -> 176 MPa) and the
629
+ camera boom's two-cell walls came out one cell thick (16x softer).
630
+ Capping each vertex's shift instead (0.1 / 0.25 cell) only halved the
631
+ damage. So it is opt-in (resolution.keepThinMembers): the member's
632
+ half-thickness at a vertex is the largest distance to void within two
633
+ cells; Taubin at 1.5 cells or less, as before at 2.5 or more, blended
634
+ between. That keeps the yoke at SF 1.22 and the motor mount's 15k
635
+ design at 1.42 (0.95 before); a narrower window (1.0-1.5) kept only
636
+ 0.43 on the yoke. Off, every vertex smooths as before."""
637
+ import trimesh
638
+
639
+ w = 1.0
640
+ if keep_thin:
641
+ grid = domain.full_grid(np.asarray(xphys, float)) > 0.5
642
+ half = ndimage.maximum_filter(ndimage.distance_transform_edt(grid), size=5)
643
+ ijk = np.floor((np.asarray(iso.vertices) - domain.origin) / domain.pitch).astype(int)
644
+ ijk = np.clip(ijk, 0, np.array(grid.shape) - 1)
645
+ w = np.clip((half[ijk[:, 0], ijk[:, 1], ijk[:, 2]] - THIN[0]) / (THIN[1] - THIN[0]), 0.0, 1.0)[:, None]
646
+ second = nu * (2.0 * w - 1.0) # -nu thin (Taubin), +nu thick (as before)
647
+ lap = trimesh.smoothing.laplacian_calculation(iso)
648
+ v = np.asarray(iso.vertices, float).copy()
649
+ for k in range(iterations):
650
+ v = v + (lamb if k % 2 == 0 else second) * (lap @ v - v)
651
+ iso.vertices = v
652
+
653
+
654
+ THIN = (1.5, 2.5) # member half-thickness (cells): Taubin below the first, shrinking above the second
655
+
656
+
657
+ def _bodies(man):
658
+ return sorted(man.decompose(), key=lambda b: -b.volume())
659
+
660
+
661
+ def reconstruct(study: Study, domain: Domain, xphys: np.ndarray, opts: ReconstructOptions | None = None):
662
+ """Returns (trimesh.Trimesh, info dict)."""
663
+ import manifold3d as m3d
664
+
665
+ o = opts or ReconstructOptions()
666
+ if o.blend is None:
667
+ import copy
668
+ o = copy.copy(o)
669
+ o.blend = max(3.0, 1.5 * float(study.min_member or 0.0))
670
+ if o.close is None:
671
+ import copy
672
+ o = copy.copy(o)
673
+ o.close = max(1.0, 0.5 * float(study.min_member or 0.0))
674
+ if o.axial_debias is None:
675
+ import copy
676
+ o = copy.copy(o)
677
+ o.axial_debias = bool((getattr(study, "raw", None) or {}).get("resolution", {}).get("keepThinMembers", False))
678
+ if o.axial and getattr(study, "access", None) is not None:
679
+ # A cast or milled part's field is already shaped for its draw or
680
+ # cutter directions, and averaging it along a member's tilted axis
681
+ # leans those walls into undercuts: the cast arm link went from
682
+ # 141 to 630 mm^3 of undercut (limit 150) with it on.
683
+ import copy
684
+ o = copy.copy(o)
685
+ o.axial = False
686
+ # Upsampling refines a coarse grid's staircase; a fine grid has none to
687
+ # speak of, and 3x on 845k cells meant a 23M-cell field and 330 s of
688
+ # meshing for 2M triangles. Cap the refined field near FINE_CELLS (eval
689
+ # grids, all under ~150k box cells, keep their 3x).
690
+ box = float(np.prod(domain.dims))
691
+ cap = max(1, int((FINE_CELLS / box) ** (1.0 / 3.0)))
692
+ if o.upsample > cap:
693
+ import copy
694
+ o = copy.copy(o)
695
+ o.upsample = cap
696
+ # where the part must reach the boundary: its supports and its loads
697
+ flush = []
698
+ for sup in study.supports:
699
+ try:
700
+ flush.append(sup.shape if sup.shape is not None else study.region_shape(sup.region)[0])
701
+ except ValueError:
702
+ pass
703
+ for case in study.load_cases:
704
+ for ld in case.loads:
705
+ try:
706
+ flush.append(ld.shape if ld.shape is not None else study.region_shape(ld.region)[0])
707
+ except ValueError:
708
+ pass
709
+ if o.fillet and o.blend > 0:
710
+ # Material against a preserved shape runs on through the design
711
+ # space's boundary like a flush face, so the exact trim below cuts it
712
+ # level with the shape's own end face instead of leaving the end a
713
+ # fraction of a millimetre proud of it (a lip round every full-height
714
+ # boss: the bell crank's).
715
+ flush = flush + [r.shape for r in study.preserve]
716
+ iso = _isosurface(domain, xphys, o, [r.shape for r in study.keep_out], study.min_member,
717
+ flush=flush, preserve=[r.shape for r in study.preserve], design=list(study.design_space))
718
+ info = {"isoTriangles": int(len(iso.faces))}
719
+ if o.taubin_iterations:
720
+ keep_thin = bool((getattr(study, "raw", None) or {}).get("resolution", {}).get("keepThinMembers", False))
721
+ _smooth_surface(iso, domain, xphys, o.taubin_iterations, keep_thin)
722
+ if study.preserve and o.fillet and o.blend > 0:
723
+ _snap_to_flat_faces(iso, [r.shape for r in study.preserve], domain.pitch / o.upsample, o.blend)
724
+ if len(iso.faces) > o.max_triangles:
725
+ # Decimate the organic surface before the booleans, never after: a
726
+ # decimated part shaves the exact preserved faces (a bore wall moved
727
+ # a few hundredths of a millimetre reads as preserved geometry lost).
728
+ try:
729
+ simplified = iso.simplify_quadric_decimation(face_count=o.max_triangles)
730
+ if simplified.is_watertight:
731
+ iso = simplified
732
+ except Exception: # noqa: BLE001 - decimation is an optimization, never fatal
733
+ pass
734
+ try:
735
+ organic = manifold_from_trimesh(iso)
736
+ except ValueError:
737
+ # Marching cubes is closed by construction, but a degenerate sliver can
738
+ # still trip manifold3d; trimesh's repair is enough for those.
739
+ import trimesh
740
+ trimesh.repair.fix_winding(iso)
741
+ trimesh.repair.fill_holes(iso)
742
+ organic = manifold_from_trimesh(iso)
743
+
744
+ total = organic.volume()
745
+ keep = [b for b in organic.decompose() if b.volume() >= o.min_body_fraction * total]
746
+ organic = m3d.Manifold.batch_boolean(keep, m3d.OpType.Add) if len(keep) > 1 else keep[0]
747
+
748
+ # Trim to the design space (and preserved geometry) exactly: flat,
749
+ # flush faces wherever the design reaches its boundary. Circles fine
750
+ # enough to be 0.002 mm true (sharp.exact_manifold): at a fixed 96
751
+ # segments a 48 mm sleeve's facets were 0.026 mm off its cylinder.
752
+ from .sharp import exact_manifold
753
+
754
+ envelope = m3d.Manifold.batch_boolean(
755
+ [exact_manifold(sh) for sh in study.design_space] + [exact_manifold(r.shape) for r in study.preserve],
756
+ m3d.OpType.Add)
757
+ organic_raw = organic
758
+ organic = organic ^ envelope
759
+ part = organic
760
+ if study.preserve:
761
+ pres = m3d.Manifold.batch_boolean([exact_manifold(r.shape) for r in study.preserve], m3d.OpType.Add)
762
+ part = part + pres
763
+ if study.keep_out:
764
+ ko = m3d.Manifold.batch_boolean([exact_manifold(r.shape) for r in study.keep_out], m3d.OpType.Add)
765
+ part = part - ko
766
+
767
+ # decompose() lists an enclosed cavity as a body of negative volume: it
768
+ # is the part's (a sealed plenum's), not a fragment
769
+ bodies = [b for b in _bodies(part) if b.volume() > 0]
770
+ if not bodies:
771
+ raise ValueError("reconstruction produced an empty part")
772
+ # A body is part of the result if it is the main one or it carries
773
+ # preserved geometry; anything else is a fragment the subtract cut loose.
774
+ kept = [bodies[0]]
775
+ loose = []
776
+ dropped = 0.0
777
+ if study.preserve:
778
+ pres_man = m3d.Manifold.batch_boolean([exact_manifold(r.shape) for r in study.preserve], m3d.OpType.Add)
779
+ for b in bodies[1:]:
780
+ if study.preserve and (b ^ pres_man).volume() > 1e-6:
781
+ kept.append(b)
782
+ else:
783
+ loose.append(b)
784
+ dropped += b.volume()
785
+ if loose:
786
+ # cut the fragments away rather than rebuilding from the kept
787
+ # bodies, which would fill every enclosed cavity
788
+ part = part - (m3d.Manifold.batch_boolean(loose, m3d.OpType.Add) if len(loose) > 1 else loose[0])
789
+ info["bodies"] = len(kept)
790
+ info["droppedFragmentVolume"] = float(dropped)
791
+
792
+ mesh = _drop_slivers(trimesh_from_manifold(part))
793
+ _attach_plan(mesh, study, organic_raw)
794
+ info["triangles"] = int(len(mesh.faces))
795
+ info["volume"] = float(mesh.volume)
796
+ info["watertight"] = bool(mesh.is_watertight)
797
+ return mesh, info