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
loadpath/sdf_kit.py ADDED
@@ -0,0 +1,843 @@
1
+ """
2
+ SDF toolkit for the "functional skeleton + organic skin" path: a script
3
+ defines EXACT functional anchors (bosses, holes, faces) and smooth-unions an
4
+ ORGANIC connecting body, then `to_mesh` marching-cubes it to a
5
+ watertight trimesh. This is how you get organic, load-path-looking forms that
6
+ still hold real tolerances — beauty draped over fixed function, not a pile of
7
+ joined primitives (and not an unconstrained generative blob).
8
+
9
+ All primitives are vectorized: they take P, an (N,3) array of sample points, and
10
+ return an (N,) signed distance (negative inside the solid). Compose with smin
11
+ (smooth union), subtract (cut), and smax (smooth intersect).
12
+
13
+ v2 adds TPMS lattice fields (gyroid / schwarz_p / diamond), region masking,
14
+ shell/offset field operators, point-warp transforms, and `from_mesh` — the
15
+ B-rep bridge that turns any trimesh (e.g. a tessellated build123d flange) into
16
+ an SDF you can smin into an implicit body.
17
+
18
+ v3 adds the dual-fluid vocabulary: `tpms_dist` (signed mm distance to a TPMS
19
+ midsurface), `seal_ramp` (0..1 face-seal weights), and `dual_sheet` (the
20
+ two-fluid separator with per-point channel capping). `exchanger.py` composes
21
+ them into a complete manifolded heat-exchanger block.
22
+
23
+ v4 adds the EXACT-FEATURE path: `mesh_cyl` / `mesh_box` build exactly
24
+ tessellated primitives, and `mesh_subtract` / `mesh_union` / `mesh_intersect`
25
+ boolean them into a meshed field with manifold3d. Marching cubes cannot
26
+ represent a crease, so cutting a bore as a FIELD rounds its mouth into a
27
+ chamfer; cutting it as a MESH, after the organic body is meshed, keeps the
28
+ rim sharp and any flat mating face planar. Mesh-level primitives take the
29
+ same arguments as their field counterparts (`mesh_cyl(x, y, r, z0, z1)`
30
+ mirrors `cyl_z(P, x, y, r, z0, z1)`) so swapping one for the other is a
31
+ one-word edit.
32
+
33
+ Meant for scripts that build a part from fields: `from loadpath.sdf_kit import *`
34
+ (`__all__` lists every public primitive).
35
+ """
36
+ import functools
37
+
38
+ import numpy as np
39
+ from scipy import ndimage
40
+ from skimage import measure
41
+ import trimesh
42
+
43
+ __all__ = [
44
+ "smin", "smax", "subtract", "union", "intersect",
45
+ "sphere", "box", "capsule", "tapered_capsule", "spline_tube", "cyl_z",
46
+ "sq_prism", "pocket", "largest_body",
47
+ "superellipsoid",
48
+ "gyroid", "schwarz_p", "diamond",
49
+ "tpms_dist", "seal_ramp", "dual_sheet",
50
+ "mask", "shell_field", "offset_field",
51
+ "translate", "rotate_z",
52
+ "from_mesh", "to_mesh", "split_shell",
53
+ "mesh_union", "mesh_subtract", "mesh_intersect", "mesh_cyl", "mesh_box",
54
+ "mesh_rod",
55
+ ]
56
+
57
+
58
+ # ---- combinators -----------------------------------------------------------
59
+
60
+ def _lift(fn):
61
+ """Let a combinator take field FUNCTIONS as well as evaluated arrays.
62
+
63
+ The combinators and field operators work on evaluated distances
64
+ (`offset_field(sphere(P, c, r), 2)`), while `to_mesh`, `from_mesh` and
65
+ `split_shell` deal in callables `f(P)`. Scripts mix the two up
66
+ constantly, since the field is usually called `f`: passing `f` itself made
67
+ `offset_field` compute `f - d` and fail with "unsupported operand type(s)
68
+ for -: 'function' and 'float'". Now, if any
69
+ argument is callable, the result is a callable that evaluates every
70
+ callable argument at P first. Arrays go through unchanged, so existing
71
+ code is unaffected.
72
+ """
73
+ @functools.wraps(fn)
74
+ def lifted(*args, **kwargs):
75
+ if not any(callable(a) for a in args):
76
+ return fn(*args, **kwargs)
77
+
78
+ def field(P):
79
+ return fn(*(a(P) if callable(a) else a for a in args), **kwargs)
80
+
81
+ return field
82
+
83
+ return lifted
84
+
85
+
86
+ @_lift
87
+ def smin(a, b, k):
88
+ """Smooth union (the organic-blend workhorse). k = blend radius in mm."""
89
+ h = np.clip(0.5 + 0.5 * (b - a) / k, 0.0, 1.0)
90
+ return b * (1 - h) + a * h - k * h * (1 - h)
91
+
92
+
93
+ @_lift
94
+ def smax(a, b, k):
95
+ """Smooth intersection."""
96
+ return -smin(-a, -b, k)
97
+
98
+
99
+ @_lift
100
+ def union(a, b):
101
+ return np.minimum(a, b)
102
+
103
+
104
+ @_lift
105
+ def intersect(a, b):
106
+ return np.maximum(a, b)
107
+
108
+
109
+ @_lift
110
+ def subtract(d, hole):
111
+ """Cut `hole` out of `d` (hard edge — use for exact bores/pockets)."""
112
+ return np.maximum(d, -hole)
113
+
114
+
115
+ # ---- primitives (negative = inside) ----------------------------------------
116
+ def sphere(P, center, r):
117
+ return np.linalg.norm(P - np.asarray(center, float), axis=1) - r
118
+
119
+
120
+ def box(P, center, half):
121
+ """Axis-aligned box. `half` = (hx, hy, hz) half-extents."""
122
+ q = np.abs(P - np.asarray(center, float)) - np.asarray(half, float)
123
+ return (
124
+ np.linalg.norm(np.maximum(q, 0.0), axis=1)
125
+ + np.minimum(np.max(q, axis=1), 0.0)
126
+ )
127
+
128
+
129
+ def capsule(P, a, b, r):
130
+ """Round-capped capsule from a to b (any axis) — the organic strut."""
131
+ a = np.asarray(a, float); b = np.asarray(b, float); ab = b - a
132
+ t = np.clip(((P - a) @ ab) / (ab @ ab), 0.0, 1.0)[:, None]
133
+ return np.linalg.norm(P - (a + t * ab), axis=1) - r
134
+
135
+
136
+ def _gradient_normalized(F, P, h):
137
+ """Turn an implicit function F (level 0 = surface, negative inside) into
138
+ an approximate distance in real mm: F / |grad F|, gradient by central
139
+ differences. Exact to first order near the surface, which is where
140
+ offsets and shells are measured. Sign is always exact."""
141
+ g = np.zeros((len(P), 3))
142
+ for k in range(3):
143
+ e = np.zeros(3)
144
+ e[k] = h
145
+ g[:, k] = (F(P + e) - F(P - e)) / (2.0 * h)
146
+ grad = np.linalg.norm(g, axis=1)
147
+ return F(P) / np.maximum(grad, 1e-9)
148
+
149
+
150
+ def _superellipse_r(x, y, a, b, n):
151
+ return (np.abs(x / a) ** n + np.abs(y / b) ** n) ** (1.0 / n)
152
+
153
+
154
+ def tapered_capsule(P, a, b, ra, rb):
155
+ """A capsule whose radius runs from `ra` at point a to `rb` at point b:
156
+ the organic strut. Chain several (sharing endpoints, radii matching at
157
+ each joint) and smin them with a small k for a prong, handle or limb that
158
+ tapers along a curved spine, a "spline sweep" without a kernel. Exact
159
+ distance (the round-cone SDF), so offsets and shells stay even."""
160
+ a = np.asarray(a, float)
161
+ b = np.asarray(b, float)
162
+ ba = b - a
163
+ l2 = float(ba @ ba)
164
+ rr = ra - rb
165
+ a2 = l2 - rr * rr
166
+ il2 = 1.0 / l2
167
+ pa = P - a
168
+ y = pa @ ba
169
+ z = y - l2
170
+ x = pa * l2 - y[:, None] * ba
171
+ x2 = np.einsum("ij,ij->i", x, x)
172
+ y2 = y * y * l2
173
+ z2 = z * z * l2
174
+ k = np.sign(rr) * rr * rr * x2
175
+ d = np.empty(len(P))
176
+ m_b = np.sign(z) * a2 * z2 > k
177
+ m_a = np.sign(y) * a2 * y2 < k
178
+ d[m_b] = np.sqrt(x2[m_b] + z2[m_b]) * il2 - rb
179
+ d[m_a & ~m_b] = np.sqrt(x2[m_a & ~m_b] + y2[m_a & ~m_b]) * il2 - ra
180
+ rest = ~(m_a | m_b)
181
+ d[rest] = (np.sqrt(x2[rest] * a2 * il2) + y[rest] * rr) * il2 - ra
182
+ return d
183
+
184
+
185
+ def _catmull_rom(points, samples):
186
+ """Centripetal-free uniform Catmull-Rom through `points` (ends clamped),
187
+ returning `samples` points per span plus the last point, and the span
188
+ parameter of each (for interpolating radii the same way)."""
189
+ pts = np.asarray(points, float)
190
+ ext = np.vstack([pts[0], pts, pts[-1]])
191
+ out, ts = [], []
192
+ for i in range(len(pts) - 1):
193
+ p0, p1, p2, p3 = ext[i], ext[i + 1], ext[i + 2], ext[i + 3]
194
+ for t in np.linspace(0.0, 1.0, samples, endpoint=False):
195
+ t2, t3 = t * t, t * t * t
196
+ out.append(0.5 * ((2 * p1) + (-p0 + p2) * t
197
+ + (2 * p0 - 5 * p1 + 4 * p2 - p3) * t2
198
+ + (-p0 + 3 * p1 - 3 * p2 + p3) * t3))
199
+ ts.append(i + t)
200
+ out.append(pts[-1])
201
+ ts.append(float(len(pts) - 1))
202
+ return np.array(out), np.array(ts)
203
+
204
+
205
+ def spline_tube(P, points, radii, samples=12):
206
+ """A tube swept along a smooth spline through `points`, radius running
207
+ through `radii` (one per point): a prong, handle, limb or cable guide
208
+ that flows without kinks. A hand-built chain of straight segments shows
209
+ an elbow at every control point; this samples a Catmull-Rom curve
210
+ finely and joins the pieces with union (not smin, which would bead
211
+ them). Radii are interpolated along the same curve."""
212
+ pts = np.asarray(points, float)
213
+ rs = np.asarray(radii, float)
214
+ if len(pts) != len(rs) or len(pts) < 2:
215
+ raise ValueError("spline_tube needs >= 2 points and one radius per point")
216
+ curve, t = _catmull_rom(pts, samples)
217
+ rad, _ = _catmull_rom(np.c_[rs, np.zeros((len(rs), 2))], samples)
218
+ r = np.maximum(rad[:, 0], 1e-3)
219
+ d = tapered_capsule(P, curve[0], curve[1], r[0], r[1])
220
+ for i in range(1, len(curve) - 1):
221
+ d = np.minimum(d, tapered_capsule(P, curve[i], curve[i + 1], r[i], r[i + 1]))
222
+ return d
223
+
224
+
225
+ def sq_prism(P, a, b, n, z0, z1):
226
+ """Superellipse prism (|x/a|^n + |y/b|^n = 1 cross-section, z-extruded) —
227
+ the measured plan-form language of premium enclosures: n~4.5 reads as a
228
+ squircle desk device, n~1.7-2.2 as a handheld lens/pebble. Use this for
229
+ organic product shells INSTEAD of a box with corner fillets — a
230
+ superellipse has no straight-to-arc tangency breaks, which is what makes
231
+ the form read as designed.
232
+
233
+ Distance in real mm near the surface (gradient-normalized), so
234
+ offset_field and shell_field give UNIFORM walls. It used to be the
235
+ normalized radius scaled by min(a, b), which is only a distance along the
236
+ short axis: a 2mm offset of a 27x14 prism came out 3.9mm thick at the
237
+ ends. Every enclosure built this way had walls nearly twice as thick at
238
+ the ends as at the sides."""
239
+ h = 1e-3 * min(a, b)
240
+ d2 = _gradient_normalized(
241
+ lambda Q: _superellipse_r(Q[:, 0], Q[:, 1], a, b, n) - 1.0, P, h
242
+ )
243
+ return np.maximum(d2, np.maximum(P[:, 2] - z1, z0 - P[:, 2]))
244
+
245
+
246
+ def pocket(P, center, half, floor_z, n=4.0, r=None):
247
+ """An OPEN cavity: squircle plan (half = (a, b) around center (x, y)),
248
+ a floor at floor_z rounded by r, and NO top. It runs up through
249
+ everything above the floor, so subtract(body, pocket(...)) always opens
250
+ at the rim. Containers built from superellipsoid or box cavities kept
251
+ stopping just under the top surface (a closed blob), or had material
252
+ blended back over them; a pocket cannot close.
253
+
254
+ One pocket per compartment; the walls between them are what remains."""
255
+ a, b = half
256
+ r = min(a, b) * 0.35 if r is None else min(r, min(a, b) * 0.95)
257
+ Q = P - np.array([center[0], center[1], 0.0])
258
+ ai, bi = a - r, b - r
259
+ h = 1e-3 * min(ai, bi)
260
+ d2 = _gradient_normalized(
261
+ lambda X: _superellipse_r(X[:, 0], X[:, 1], ai, bi, n) - 1.0, Q, h
262
+ )
263
+ dz = (floor_z + r) - P[:, 2]
264
+ outside = np.sqrt(np.maximum(d2, 0.0) ** 2 + np.maximum(dz, 0.0) ** 2)
265
+ return outside + np.minimum(np.maximum(d2, dz), 0.0) - r
266
+
267
+
268
+ def superellipsoid(P, center, radii, n=3.0, m=3.5):
269
+ """A pebble: superellipse plan (exponent n, like sq_prism) with a rounded
270
+ vertical profile (exponent m). n~2.5-3 plan and m~3-3.5 profile read as
271
+ a soft river stone; m=2 is an ellipsoid, and m>=5 gives slab sides.
272
+ Distance in real mm near the surface, so offset_field / shell_field give
273
+ an even wall: the drape-and-split enclosure recipe with an organic
274
+ cavity instead of a squircle box.
275
+
276
+ Flatten the base with smax(..., z0 - P[:, 2], k) for a stable footprint."""
277
+ c = np.asarray(center, float)
278
+ a, b, cz = (float(v) for v in radii)
279
+
280
+ def F(Q):
281
+ q = Q - c
282
+ rp = _superellipse_r(q[:, 0], q[:, 1], a, b, n)
283
+ return (rp ** m + np.abs(q[:, 2] / cz) ** m) ** (1.0 / m) - 1.0
284
+
285
+ return _gradient_normalized(F, P, 1e-3 * min(a, b, cz))
286
+
287
+
288
+ def cyl_z(P, x, y, r, z0, z1):
289
+ """Flat-capped vertical cylinder (Z axis) — for exact bosses + bolt holes.
290
+ Pass z0<z1 for a boss; extend past the body for a through-hole."""
291
+ dr = np.hypot(P[:, 0] - x, P[:, 1] - y) - r
292
+ dz = np.abs(P[:, 2] - 0.5 * (z0 + z1)) - 0.5 * (z1 - z0)
293
+ outside = np.linalg.norm(np.maximum(np.stack([dr, dz], 1), 0.0), axis=1)
294
+ inside = np.minimum(np.maximum(dr, dz), 0.0)
295
+ return outside + inside
296
+
297
+
298
+ # ---- TPMS lattices ----------------------------------------------------------
299
+ # The raw trig fields are NOT distances — their spatial gradient magnitude is
300
+ # ~2π/cell, not 1, and it varies across the cell. Each field below divides the
301
+ # raw value by its analytic per-point gradient magnitude (first-order Taylor),
302
+ # so `thickness` means real millimeters of wall (within a few % near the
303
+ # surface; verified by measuring meshed sheets in the test suite). The gradient
304
+ # magnitude is floored away from critical points so the far-field stays
305
+ # bounded (sign is always exact; only far-from-surface magnitudes are approx).
306
+
307
+ def _thickness_at(thickness, P):
308
+ """Resolve scalar / per-point array / callable(P) thickness — graded cores
309
+ pass a callable and get a spatially varying wall."""
310
+ if callable(thickness):
311
+ return np.asarray(thickness(P), float)
312
+ return np.asarray(thickness, float)
313
+
314
+
315
+ def _tpms_d(g, gx, gy, gz, w):
316
+ """Raw trig field -> signed real-mm distance to the F=0 midsurface."""
317
+ grad = w * np.sqrt(gx * gx + gy * gy + gz * gz)
318
+ return g / np.maximum(grad, 0.15 * w)
319
+
320
+
321
+ def _tpms(g, gx, gy, gz, w, thickness, P, solid):
322
+ d = _tpms_d(g, gx, gy, gz, w)
323
+ t = _thickness_at(thickness, P)
324
+ if solid:
325
+ # one network: everything on the g<0 side, fattened by t/2 past the
326
+ # level surface. t=0 keeps exactly half of space.
327
+ return d - 0.5 * t
328
+ # sheet: a t-thick wall on the level surface — the two-network separator
329
+ # (heat-exchanger core). The two voids it leaves are fully disjoint.
330
+ return np.abs(d) - 0.5 * t
331
+
332
+
333
+ def _gyroid_field(P, w):
334
+ sx, cx = np.sin(w * P[:, 0]), np.cos(w * P[:, 0])
335
+ sy, cy = np.sin(w * P[:, 1]), np.cos(w * P[:, 1])
336
+ sz, cz = np.sin(w * P[:, 2]), np.cos(w * P[:, 2])
337
+ g = sx * cy + sy * cz + sz * cx
338
+ gx = cx * cy - sz * sx
339
+ gy = cy * cz - sx * sy
340
+ gz = cz * cx - sy * sz
341
+ return g, gx, gy, gz
342
+
343
+
344
+ def _schwarz_p_field(P, w):
345
+ sx, sy, sz = (np.sin(w * P[:, i]) for i in range(3))
346
+ cx, cy, cz = (np.cos(w * P[:, i]) for i in range(3))
347
+ return cx + cy + cz, -sx, -sy, -sz
348
+
349
+
350
+ def _diamond_field(P, w):
351
+ sx, sy, sz = (np.sin(w * P[:, i]) for i in range(3))
352
+ cx, cy, cz = (np.cos(w * P[:, i]) for i in range(3))
353
+ g = sx * sy * sz + sx * cy * cz + cx * sy * cz + cx * cy * sz
354
+ gx = cx * sy * sz + cx * cy * cz - sx * sy * cz - sx * cy * sz
355
+ gy = sx * cy * sz - sx * sy * cz + cx * cy * cz - cx * sy * sz
356
+ gz = sx * sy * cz - sx * cy * sz - cx * sy * sz + cx * cy * cz
357
+ return g, gx, gy, gz
358
+
359
+
360
+ _TPMS_FIELDS = {
361
+ "gyroid": _gyroid_field,
362
+ "schwarz_p": _schwarz_p_field,
363
+ "diamond": _diamond_field,
364
+ }
365
+
366
+
367
+ def gyroid(P, cell, thickness, solid=False):
368
+ """Gyroid TPMS. `cell` = lattice period in mm; `thickness` = wall in mm
369
+ (scalar, per-point array, or callable(P) for graded density). Default sheet
370
+ form is the two-network separator; solid=True keeps one network as solid
371
+ infill. Clip to a region with `mask` — the field fills all of space."""
372
+ P = np.asarray(P, float)
373
+ w = 2.0 * np.pi / cell
374
+ g, gx, gy, gz = _gyroid_field(P, w)
375
+ return _tpms(g, gx, gy, gz, w, thickness, P, solid)
376
+
377
+
378
+ def schwarz_p(P, cell, thickness, solid=False):
379
+ """Schwarz P TPMS (cos x + cos y + cos z) — straighter, boxier channels
380
+ than the gyroid. Same contract as `gyroid`."""
381
+ P = np.asarray(P, float)
382
+ w = 2.0 * np.pi / cell
383
+ g, gx, gy, gz = _schwarz_p_field(P, w)
384
+ return _tpms(g, gx, gy, gz, w, thickness, P, solid)
385
+
386
+
387
+ def diamond(P, cell, thickness, solid=False):
388
+ """Schwarz D (diamond) TPMS — denser strut network than the gyroid at the
389
+ same cell size. Same contract as `gyroid`."""
390
+ P = np.asarray(P, float)
391
+ w = 2.0 * np.pi / cell
392
+ g, gx, gy, gz = _diamond_field(P, w)
393
+ return _tpms(g, gx, gy, gz, w, thickness, P, solid)
394
+
395
+
396
+ def tpms_dist(P, cell, kind="gyroid"):
397
+ """Signed real-mm distance to a TPMS midsurface (F=0). Positive on fluid
398
+ A's side of the sheet, negative on fluid B's — the substrate `dual_sheet`
399
+ builds on, and handy on its own for probing which labyrinth a point is in
400
+ (e.g. placing `fluid_ports` probes clear of the wall)."""
401
+ P = np.asarray(P, float)
402
+ w = 2.0 * np.pi / cell
403
+ g, gx, gy, gz = _TPMS_FIELDS[kind](P, w)
404
+ return _tpms_d(g, gx, gy, gz, w)
405
+
406
+
407
+ def seal_ramp(region, width):
408
+ """Seal weight in [0, 1] from a region field: 1 where region <= 0 (fully
409
+ sealed), smoothstepping to 0 where region >= width mm. Feed the result to
410
+ `dual_sheet`'s seal_a/seal_b; combine seals from several faces with
411
+ np.maximum (NOT +, which can overshoot 1 and is fine but sloppy)."""
412
+ u = np.clip(1.0 - np.asarray(region, float) / max(float(width), 1e-9),
413
+ 0.0, 1.0)
414
+ return u * u * (3.0 - 2.0 * u)
415
+
416
+
417
+ def dual_sheet(P, cell, wall, kind="gyroid", seal_a=0.0, seal_b=0.0, offset=0.0):
418
+ """Two-fluid TPMS separator with per-point channel sealing — the manifold
419
+ capping primitive for heat-exchanger cores.
420
+
421
+ The solid is { -h_b(x) <= d(x) <= h_a(x) } where d is the signed mm
422
+ distance to the midsurface and h = wall/2 + seal * (a bound above max|d|).
423
+ With seal_a = seal_b = 0 this is exactly the sheet form of `gyroid` et al.
424
+ Where a seal weight (0..1, from `seal_ramp`, a per-point array, or a
425
+ callable(P)) approaches 1, that fluid's labyrinth is swallowed by solid —
426
+ so sealing fluid B near fluid A's port face leaves only A's channels
427
+ reaching that face, which is how each manifold stays single-fluid.
428
+
429
+ Isolation is unconditional regardless of seals: the solid always contains
430
+ the d=0 midsurface (h_a, h_b >= wall/2 > 0), so any path between the two
431
+ voids would have to cross solid. Keep `wall` >= the printable minimum —
432
+ the thinnest point of a gyroid sheet runs ~12% under nominal.
433
+
434
+ `offset` (mm) moves the sheet off the mid-surface onto the level d =
435
+ offset, which gives fluid A (d > 0) more of the void when negative."""
436
+ P = np.asarray(P, float)
437
+ d = tpms_dist(P, cell, kind) - offset
438
+ # > max|d| for every kind: |g| <= 3 (schwarz_p worst case) over the
439
+ # 0.15*w gradient floor gives |d| <= 3/(0.15*w) ~= 3.18*cell.
440
+ swallow = 3.5 * cell
441
+ t = _thickness_at(wall, P)
442
+ ha = 0.5 * t + _thickness_at(seal_a, P) * swallow
443
+ hb = 0.5 * t + _thickness_at(seal_b, P) * swallow
444
+ return np.maximum(d - ha, -d - hb)
445
+
446
+
447
+ # ---- field operators ---------------------------------------------------------
448
+ @_lift
449
+ def mask(field_a, region, k=0.0):
450
+ """Solid only where `region` < 0 — the tool for "gyroid only inside this
451
+ jacket". k=0 is a hard clip; k>0 blends the field into the region wall over
452
+ k mm (smooth intersection)."""
453
+ if k <= 0.0:
454
+ return np.maximum(field_a, region)
455
+ return smax(field_a, region, k)
456
+
457
+
458
+ @_lift
459
+ def shell_field(field, t):
460
+ """Hollow a field into a t-mm-thick wall on its surface: |field| - t/2.
461
+ Operates on evaluated field values, like the combinators."""
462
+ return np.abs(field) - 0.5 * t
463
+
464
+
465
+ @_lift
466
+ def offset_field(field, d):
467
+ """Grow (d>0) or shrink (d<0) a field by d mm: field - d. Rounds convex
468
+ edges by d as a side effect — that is usually the point."""
469
+ return field - d
470
+
471
+
472
+ # ---- point-warp transforms ---------------------------------------------------
473
+ # Transforms warp the SAMPLE POINTS, not the field: apply to P *before* passing
474
+ # it to a primitive, and that primitive moves by the transform. e.g.
475
+ # `sphere(translate(P, (10, 0, 0)), (0, 0, 0), 5)` is a sphere centered at
476
+ # x=+10. Compose left-to-right: the outermost warp applies first.
477
+
478
+ def translate(P, offset):
479
+ """Move a primitive by `offset` mm: evaluates it at P - offset."""
480
+ return np.asarray(P, float) - np.asarray(offset, float)
481
+
482
+
483
+ def rotate_z(P, degrees, center=(0, 0, 0)):
484
+ """Rotate a primitive by `degrees` CCW about a vertical axis through
485
+ `center`: evaluates it at inverse-rotated points."""
486
+ P = np.asarray(P, float)
487
+ c = np.asarray(center, float)
488
+ a = -np.radians(degrees)
489
+ ca, sa = np.cos(a), np.sin(a)
490
+ Q = P - c
491
+ out = np.empty_like(P)
492
+ out[:, 0] = ca * Q[:, 0] - sa * Q[:, 1]
493
+ out[:, 1] = sa * Q[:, 0] + ca * Q[:, 1]
494
+ out[:, 2] = Q[:, 2]
495
+ return out + c
496
+
497
+
498
+ # ---- B-rep bridge ------------------------------------------------------------
499
+ def _crossing_parity(mesh, pts, rng):
500
+ """Point-in-solid by ray-crossing parity (odd crossings = inside), pure
501
+ numpy Möller–Trumbore over all triangles. Orientation-INDEPENDENT — it
502
+ survives inconsistent winding and `fix_normals`-flipped cavity shells,
503
+ which vertex/face-normal side tests do not. Random ray directions make
504
+ edge/vertex grazes measure-zero. O(len(pts) * faces): only for the handful
505
+ of classification samples below, never per-voxel."""
506
+ tri = mesh.triangles
507
+ v0 = tri[:, 0]
508
+ e1 = tri[:, 1] - v0
509
+ e2 = tri[:, 2] - v0
510
+ inside = np.zeros(len(pts), bool)
511
+ for i, o in enumerate(pts):
512
+ d = rng.normal(size=3)
513
+ d /= np.linalg.norm(d)
514
+ p = np.cross(d, e2)
515
+ det = np.einsum("ij,ij->i", e1, p)
516
+ ok = np.abs(det) > 1e-12
517
+ inv = np.where(ok, 1.0 / np.where(ok, det, 1.0), 0.0)
518
+ tvec = o - v0
519
+ u = np.einsum("ij,ij->i", tvec, p) * inv
520
+ q = np.cross(tvec, e1)
521
+ v = np.einsum("j,ij->i", d, q) * inv
522
+ t = np.einsum("ij,ij->i", e2, q) * inv
523
+ hits = ok & (u >= 0) & (v >= 0) & (u + v <= 1) & (t > 1e-9)
524
+ inside[i] = (int(hits.sum()) % 2) == 1
525
+ return inside
526
+
527
+
528
+ # Enclosed void pockets under this many voxels are voxelization aliasing,
529
+ # not geometry — filled as material without a ray test (see _solid_voxels).
530
+ _TINY_POCKET_VOX = 32
531
+
532
+
533
+ def _solid_voxels(mesh, pitch, pad=2):
534
+ """Voxel occupancy of a mesh's solid material at `pitch`, padded by `pad`
535
+ void voxels per side. Returns (bool grid, world coords of voxel (0,0,0)
536
+ center). Surface voxels come from trimesh voxelization; each enclosed
537
+ pocket is then classified material-vs-cavity by ray-parity majority vote
538
+ over a few sample voxels, so internal channels stay VOID — a naive
539
+ `fill()` would pave over them and break both `from_mesh` and the network
540
+ verifier."""
541
+ vg = mesh.voxelized(pitch)
542
+ origin = np.asarray(vg.transform, float)[:3, 3] - pad * pitch
543
+ surf = np.pad(np.asarray(vg.matrix, bool), pad)
544
+ # 6-connected labeling of non-surface space: a closed voxel shell can leak
545
+ # diagonally under 26-connectivity, which would misclassify interiors.
546
+ lbl, n = ndimage.label(~surf)
547
+ open_air = set()
548
+ for face in (lbl[0], lbl[-1], lbl[:, 0], lbl[:, -1], lbl[:, :, 0], lbl[:, :, -1]):
549
+ open_air.update(np.unique(face))
550
+ open_air.discard(0)
551
+ solid = surf.copy()
552
+ open_lut = np.zeros(n + 1, bool)
553
+ if open_air:
554
+ open_lut[list(open_air)] = True
555
+ # Sub-resolution pocket triage. A curved surface voxelized at pitch p
556
+ # traps a huge number of MICROSCOPIC enclosed bubbles between shell
557
+ # voxels (measured: a 150mm gyroid at 0.6mm -> 102k pockets, and the old
558
+ # per-pocket full-grid scan + ray test priced that at ~24 minutes). A
559
+ # pocket a few voxels big can neither carry fluid nor matter to an SDF
560
+ # at this pitch — it is aliasing, not geometry — so fill it as material
561
+ # in one vectorized pass and spend ray-parity classification only on
562
+ # real cavities (plenums, labyrinths, deliberate voids). Filling adds
563
+ # solid, which can only ever SEPARATE void networks, and any channel
564
+ # thin enough to thread a sub-32-voxel pocket is already below the
565
+ # check's honest resolution bound (~pitch).
566
+ sizes = np.bincount(lbl.ravel(), minlength=n + 1)
567
+ tiny_lut = (sizes < _TINY_POCKET_VOX) & ~open_lut
568
+ tiny_lut[0] = False
569
+ if tiny_lut.any():
570
+ solid |= tiny_lut[lbl]
571
+ rng = np.random.default_rng(0) # deterministic runs
572
+ enclosed, samples = [], []
573
+ for lab in range(1, n + 1):
574
+ if open_lut[lab] or tiny_lut[lab]:
575
+ continue
576
+ vox = np.argwhere(lbl == lab)
577
+ take = vox[:: max(1, len(vox) // 5)][:5]
578
+ enclosed.append((lab, len(samples), len(take)))
579
+ samples.extend(origin + take * pitch)
580
+ if enclosed:
581
+ inside = _crossing_parity(mesh, np.asarray(samples), rng)
582
+ for lab, start, count in enclosed:
583
+ if np.mean(inside[start:start + count]) >= 0.5:
584
+ solid[lbl == lab] = True
585
+ return solid, origin
586
+
587
+
588
+ def from_mesh(mesh, pitch=1.0):
589
+ """SDF of an arbitrary trimesh — the B-rep bridge. Build a flange in
590
+ build123d, tessellate it, wrap it here, then smin it into an implicit
591
+ body. Precomputes a signed voxel grid at `pitch` (occupancy + euclidean
592
+ distance transform inside and out) and returns a `field(P)` callable that
593
+ trilinearly interpolates it. Accuracy is ~pitch/2 near the surface; points
594
+ outside the grid get a positive bounding-sphere distance (large and
595
+ positive, so smin composition stays sane). Cost is paid once up front —
596
+ queries are cheap."""
597
+ solid, origin = _solid_voxels(mesh, pitch, pad=3)
598
+ grid = (ndimage.distance_transform_edt(~solid)
599
+ - ndimage.distance_transform_edt(solid)) * pitch
600
+ # EDT measures center-to-center; the true surface sits between a solid and
601
+ # a void voxel center, so pull both sides in by half a voxel.
602
+ grid -= 0.5 * pitch * np.sign(grid)
603
+ n = np.asarray(grid.shape, float)
604
+ center = origin + 0.5 * (n - 1) * pitch
605
+ r_bound = 0.5 * pitch * float(np.linalg.norm(n - 1))
606
+
607
+ def field(P):
608
+ P = np.asarray(P, float)
609
+ idx = (P - origin) / pitch
610
+ in_grid = np.all((idx >= 0.0) & (idx <= n - 1), axis=1)
611
+ out = np.maximum(np.linalg.norm(P - center, axis=1) - r_bound, pitch)
612
+ if np.any(in_grid):
613
+ out[in_grid] = ndimage.map_coordinates(
614
+ grid, idx[in_grid].T, order=1, mode="nearest")
615
+ return out
616
+
617
+ return field
618
+
619
+
620
+ def split_shell(body_field, cavity_field, z_split, lip_h=3.0, lip_t=1.1,
621
+ fit_gap=0.25):
622
+ """Split a draped shell into two watertight printed-fit halves — the
623
+ keystone of the drape-and-split enclosure recipe: soft skin outside, component cavity
624
+ inside, two pieces joined on an inner lip. Returns (bottom, top) field
625
+ callables. The bottom keeps a lip ring (the innermost lip_t of the wall,
626
+ rising lip_h above the split, fused 1mm into its own wall); the top gets
627
+ the matching recess grown by fit_gap — a real printed clearance you can
628
+ assert numerically. Keep lip_t comfortably under the wall thickness. Cut port/connector
629
+ apertures AFTER splitting (subtract from both returned fields) so they
630
+ notch the lip like a real product seam — the ring is built from the
631
+ cavity and cannot know about ports."""
632
+ def bottom(P):
633
+ c = cavity_field(P)
634
+ below = np.maximum(body_field(P), P[:, 2] - z_split)
635
+ ring = np.maximum(
636
+ np.maximum(-c, c - lip_t),
637
+ np.maximum(P[:, 2] - (z_split + lip_h), (z_split - 1.0) - P[:, 2]),
638
+ )
639
+ return np.minimum(below, ring)
640
+
641
+ def top(P):
642
+ c = cavity_field(P)
643
+ above = np.maximum(body_field(P), z_split - P[:, 2])
644
+ recess = np.maximum(
645
+ np.maximum(-(c + fit_gap), c - (lip_t + fit_gap)),
646
+ np.maximum(P[:, 2] - (z_split + lip_h + fit_gap),
647
+ (z_split - 0.5) - P[:, 2]),
648
+ )
649
+ return np.maximum(above, -recess)
650
+
651
+ return bottom, top
652
+
653
+
654
+ # ---- meshing ---------------------------------------------------------------
655
+ # Marching-cubes grid ceiling. 60M cells ≈ 480MB for the float64 field (plus
656
+ # ~2x transient inside skimage), comfortable in 8 GB and about three minutes
657
+ # (measured: ~160s to evaluate+march 44M cells of a hooded exchanger field).
658
+ # The old 8M budget forced 150mm-class exchangers to mesh at pitch 1.0 against 1.2mm walls
659
+ # (over the wall/3 contract): visibly stair-stepped hoods and a real pinhole
660
+ # risk in the printed part.
661
+ _CELL_BUDGET = 60_000_000
662
+ def largest_body(mesh, max_debris_frac=0.02):
663
+ """Drop marching-cubes specks: return the largest connected body when
664
+ everything else together is under `max_debris_frac` of the total volume,
665
+ else the mesh unchanged (a real second body still fails the one-body
666
+ gate). For concept blockouts, where a pinhead island beside the part is
667
+ noise, not a defect worth discarding the concept over. Finished parts
668
+ keep the strict gate."""
669
+ try:
670
+ bodies = list(mesh.split(only_watertight=False))
671
+ except Exception: # noqa: BLE001
672
+ return mesh
673
+ if len(bodies) < 2:
674
+ return mesh
675
+ vols = [abs(float(b.volume)) for b in bodies]
676
+ total = sum(vols)
677
+ i = int(np.argmax(vols))
678
+ if total <= 0 or (total - vols[i]) > max_debris_frac * total:
679
+ return mesh
680
+ return bodies[i]
681
+
682
+
683
+ def to_mesh(field, lo, hi, pitch=0.7):
684
+ """Evaluate `field(P)->(N,) sdf` on a grid over [lo,hi] (mm) and
685
+ marching-cubes the level-0 surface into a watertight trimesh. The grid is
686
+ padded with 'void' so the surface always closes. Grids over 8M cells are
687
+ refused before allocation with an error naming a pitch that fits, so the
688
+ caller knows what to change."""
689
+ lo = np.asarray(lo, float); hi = np.asarray(hi, float)
690
+ # Sub-resolution origin shift: a surface that runs EXACTLY along a grid
691
+ # plane with locally-flat field gradient (e.g. a superellipse's x=0
692
+ # tangent zone on a symmetric grid) makes marching cubes emit degenerate
693
+ # slivers that weld non-manifold. An irrational sub-pitch offset makes
694
+ # exact alignment impossible without measurably changing the bounds.
695
+ lo = lo - pitch * 0.013717
696
+ n = [int(np.ceil((hi[i] - lo[i]) / pitch)) + 1 for i in range(3)]
697
+ cells = n[0] * n[1] * n[2]
698
+ if cells > _CELL_BUDGET:
699
+ ext = hi - lo
700
+ p = float(np.prod(ext) / _CELL_BUDGET) ** (1.0 / 3.0)
701
+ p = max(np.ceil(p * 10.0) / 10.0, 0.1)
702
+ while np.prod([int(np.ceil(e / p)) + 1 for e in ext]) > _CELL_BUDGET:
703
+ p = round(p + 0.1, 1)
704
+ raise ValueError(
705
+ f"grid {n[0]}x{n[1]}x{n[2]} = {cells / 1e6:.1f}M cells exceeds "
706
+ f"the {_CELL_BUDGET // 1_000_000}M budget — use pitch >= {p:g}")
707
+ axes = [np.linspace(lo[i], hi[i], n[i]) for i in range(3)]
708
+ X, Y, Z = np.meshgrid(*axes, indexing="ij")
709
+ P = np.stack([X.ravel(), Y.ravel(), Z.ravel()], axis=1)
710
+ F = np.asarray(field(P), float).reshape(*n)
711
+ F = np.pad(F, 1, mode="constant", constant_values=float(pitch * 3 + 1.0))
712
+ # Exact zeros happen whenever a hard clip (mask/subtract) lands on a grid
713
+ # coordinate; marching cubes then emits coincident vertices there and the
714
+ # merge below welds them into non-manifold edges. Nudge them off the level
715
+ # by more than the vertex-merge tolerance (shifts the surface ~1e-4 voxels
716
+ # at those nodes — far below print resolution).
717
+ F[F == 0.0] = -1e-4 * pitch
718
+ verts, faces, _, _ = measure.marching_cubes(F, level=0.0)
719
+ for i in range(3):
720
+ verts[:, i] = lo[i] + (verts[:, i] - 1.0) * (hi[i] - lo[i]) / (n[i] - 1)
721
+ m = trimesh.Trimesh(vertices=verts, faces=faces)
722
+ m.merge_vertices()
723
+ m.fix_normals()
724
+ # Drop sub-voxel debris: hard clips (mask/subtract/split_shell planes) can
725
+ # leave marching-cubes slivers — closed bodies a few cells across with
726
+ # ~zero volume. They are unprintable dust, and a fragment gate
727
+ # rightly fails any part that ships disconnected bodies. Anything at least
728
+ # a few voxels big is kept — genuinely disjoint REAL geometry still reaches
729
+ # the gate and fails loudly there, as it should.
730
+ try:
731
+ bodies = m.split(only_watertight=False)
732
+ if len(bodies) > 1:
733
+ tol = (2.0 * pitch) ** 3
734
+ kept = [b for b in bodies if abs(float(b.volume)) > tol]
735
+ if kept and len(kept) < len(bodies):
736
+ m = trimesh.util.concatenate(kept) if len(kept) > 1 else kept[0]
737
+ except Exception: # noqa: BLE001 — debris filtering is best-effort
738
+ pass
739
+ return m
740
+
741
+
742
+ # ---- exact-feature booleans (manifold3d) ------------------------------------
743
+ # WHY these exist, measured on a flat plate with a through-bore at pitch 0.8:
744
+ #
745
+ # bore r at the rim flat-face planarity
746
+ # field subtract 2.789 mm 0.0281 mm
747
+ # mesh boolean 2.500 mm 0.0000 mm (true r = 2.500)
748
+ #
749
+ # Away from edges both are fine — a field subtract measures 2.488-2.495 across
750
+ # pitch 0.4-1.2, well inside print tolerance. SIZES were never the problem.
751
+ # EDGES are: marching cubes cannot represent a crease, so it rounds a bore
752
+ # mouth into a 0.29mm chamfer, which is a press fit or a fastener seat gone.
753
+ #
754
+ # So the pattern to reach for is: mesh the ORGANIC field once, then boolean
755
+ # the EXACT features into the result. Not the other way around, and not by
756
+ # subtracting exact features as fields.
757
+ #
758
+ # manifold3d also guarantees manifold output by construction, which the
759
+ # marching-cubes path cannot: non-manifold blends are a live failure mode in
760
+ # production (one build shipped euler number 184 against a closed-surface 2,
761
+ # with 425 faces on open boundary edges).
762
+
763
+
764
+ def _to_manifold(mesh):
765
+ import manifold3d
766
+
767
+ return manifold3d.Manifold(
768
+ manifold3d.Mesh(
769
+ vert_properties=np.asarray(mesh.vertices, np.float32),
770
+ tri_verts=np.asarray(mesh.faces, np.uint32),
771
+ )
772
+ )
773
+
774
+
775
+ def _from_manifold(man):
776
+ md = man.to_mesh()
777
+ return trimesh.Trimesh(
778
+ vertices=np.asarray(md.vert_properties[:, :3], np.float64),
779
+ faces=np.asarray(md.tri_verts, np.int64),
780
+ )
781
+
782
+
783
+ def _boolean(a, b, op):
784
+ ma, mb = _to_manifold(a), _to_manifold(b)
785
+ out = {"union": ma + mb, "subtract": ma - mb, "intersect": ma ^ mb}[op]
786
+ if out.is_empty():
787
+ raise ValueError(
788
+ f"mesh_{op} produced an empty solid — check the operands overlap "
789
+ f"and that you are cutting the bore OUT of the body, not the body "
790
+ f"out of the bore")
791
+ return _from_manifold(out)
792
+
793
+
794
+ def mesh_union(a, b):
795
+ """Fuse two meshes into one manifold solid."""
796
+ return _boolean(a, b, "union")
797
+
798
+
799
+ def mesh_subtract(a, b):
800
+ """Cut mesh `b` out of mesh `a`, keeping `b`'s edges crisp. THE exact-hole
801
+ operator: mesh the organic body with to_mesh, then subtract mesh_cyl bores
802
+ so tolerances survive the sampling grid."""
803
+ return _boolean(a, b, "subtract")
804
+
805
+
806
+ def mesh_intersect(a, b):
807
+ """Keep only the volume common to both meshes."""
808
+ return _boolean(a, b, "intersect")
809
+
810
+
811
+ def mesh_cyl(x, y, r, z0, z1, sections=96):
812
+ """Exactly tessellated vertical cylinder — the mesh twin of `cyl_z`, with
813
+ the same arguments. Use for bores, counterbores and pin seats that must
814
+ hold a real tolerance. Extend past the body for a through-hole."""
815
+ if z1 <= z0:
816
+ raise ValueError(f"mesh_cyl needs z1 > z0, got z0={z0}, z1={z1}")
817
+ c = trimesh.creation.cylinder(radius=r, height=z1 - z0, sections=sections)
818
+ c.apply_translation([x, y, 0.5 * (z0 + z1)])
819
+ return c
820
+
821
+
822
+ def mesh_rod(a, b, r, sections=96):
823
+ """Exactly tessellated cylinder from point `a` to point `b`, on any axis:
824
+ the mesh twin of `capsule`, but flat-ended. For holes that aren't
825
+ vertical: radial set screws, cross pins, side ports. Without it there was
826
+ no exact non-vertical cut at all, and a knob's radial M3 set-screw hole
827
+ came out as a square slot. Extend past the body for a through-hole."""
828
+ a = np.asarray(a, float)
829
+ b = np.asarray(b, float)
830
+ if np.linalg.norm(b - a) <= 0:
831
+ raise ValueError(f"mesh_rod needs two distinct points, got a={a}, b={b}")
832
+ return trimesh.creation.cylinder(
833
+ radius=r, segment=np.stack([a, b]), sections=sections
834
+ )
835
+
836
+
837
+ def mesh_box(center, half):
838
+ """Exactly tessellated axis-aligned box — the mesh twin of `box`, with the
839
+ same arguments. Use for flat mating faces, slots and rectangular pockets
840
+ that must stay planar and square."""
841
+ b = trimesh.creation.box(extents=[2.0 * h for h in half])
842
+ b.apply_translation(np.asarray(center, float))
843
+ return b