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/shapes.py ADDED
@@ -0,0 +1,584 @@
1
+ """
2
+ Primitive shapes for study regions: the design space, preserved geometry,
3
+ keep-outs, and where supports and loads attach.
4
+
5
+ Every shape answers two questions, and they are deliberately separate:
6
+
7
+ - `sdf(points)`: signed distance in mm (negative inside). The voxel solver
8
+ classifies elements and picks load/support nodes with it, so it must be
9
+ vectorized over (N, 3) arrays.
10
+ - `to_manifold()`: the exact solid, for the final booleans. Marching cubes
11
+ cannot hold a crease, so a bore cut from the density field comes out
12
+ rounded and undersized; subtracting the exact cylinder afterwards is what
13
+ keeps a bearing seat a bearing seat.
14
+
15
+ Shapes are plain JSON dicts on the wire (`shape_from_spec`), so an agent can
16
+ write a study without importing anything.
17
+ """
18
+ from __future__ import annotations
19
+
20
+ import numpy as np
21
+
22
+ __all__ = [
23
+ "Box", "Cylinder", "Sphere", "MeshShape", "Union", "Offset", "Shell", "grow", "shrink", "shape_from_spec",
24
+ "manifold_from_trimesh", "trimesh_from_manifold",
25
+ ]
26
+
27
+ # Segments per full circle for exact cylinders. 96 keeps a 22mm bore's
28
+ # polygonal error at ~0.012mm, well inside any printer's tolerance.
29
+ _CIRCLE_SEGMENTS = 96
30
+
31
+
32
+ def _vec(v, n=3, name="value"):
33
+ a = np.asarray(v, float).reshape(-1)
34
+ if a.shape != (n,) or not np.all(np.isfinite(a)):
35
+ raise ValueError(f"{name} must be {n} finite numbers, got {v!r}")
36
+ return a
37
+
38
+
39
+ class Shape:
40
+ kind = "shape"
41
+
42
+ def sdf(self, p: np.ndarray) -> np.ndarray: # pragma: no cover - abstract
43
+ raise NotImplementedError
44
+
45
+ def bounds(self) -> tuple[np.ndarray, np.ndarray]: # pragma: no cover
46
+ raise NotImplementedError
47
+
48
+ def to_manifold(self): # pragma: no cover - abstract
49
+ raise NotImplementedError
50
+
51
+ def contains(self, p: np.ndarray) -> np.ndarray:
52
+ return self.sdf(p) <= 0.0
53
+
54
+ def to_spec(self) -> dict: # pragma: no cover - abstract
55
+ raise NotImplementedError
56
+
57
+
58
+ class Box(Shape):
59
+ """Axis-aligned box: {"type": "box", "min": [..], "max": [..]} or
60
+ {"type": "box", "center": [..], "size": [..]}."""
61
+
62
+ kind = "box"
63
+
64
+ def __init__(self, lo, hi):
65
+ self.lo = _vec(lo, name="box min")
66
+ self.hi = _vec(hi, name="box max")
67
+ if np.any(self.hi <= self.lo):
68
+ raise ValueError(f"box max {self.hi.tolist()} must exceed min {self.lo.tolist()}")
69
+
70
+ def sdf(self, p):
71
+ # column by column: ~10x faster than the (n, 3) array form, and the
72
+ # field graphs evaluate primitives tens of millions of times
73
+ p = np.asarray(p, float)
74
+ c = 0.5 * (self.lo + self.hi)
75
+ h = 0.5 * (self.hi - self.lo)
76
+ q0 = np.abs(p[..., 0] - c[0]) - h[0]
77
+ q1 = np.abs(p[..., 1] - c[1]) - h[1]
78
+ q2 = np.abs(p[..., 2] - c[2]) - h[2]
79
+ a, b, d = np.maximum(q0, 0.0), np.maximum(q1, 0.0), np.maximum(q2, 0.0)
80
+ return np.sqrt(a * a + b * b + d * d) + np.minimum(np.maximum(np.maximum(q0, q1), q2), 0.0)
81
+
82
+ def bounds(self):
83
+ return self.lo.copy(), self.hi.copy()
84
+
85
+ def to_manifold(self):
86
+ import manifold3d as m3d
87
+ return m3d.Manifold.cube(tuple(self.hi - self.lo)).translate(tuple(self.lo))
88
+
89
+ def to_spec(self):
90
+ return {"type": "box", "min": self.lo.tolist(), "max": self.hi.tolist()}
91
+
92
+
93
+ class Cylinder(Shape):
94
+ """Flat-capped cylinder from p0 to p1 with radius r. With `rInner` it is a
95
+ tube (a bearing boss, a bolt collar): material between rInner and r.
96
+ {"type": "cylinder", "p0": [..], "p1": [..], "r": 11, "rInner"?: 6}.
97
+ {"center", "axis": "x"|"y"|"z"|[..], "length", "r"} is accepted too."""
98
+
99
+ kind = "cylinder"
100
+
101
+ def __init__(self, p0, p1, r, r_inner=0.0):
102
+ self.p0 = _vec(p0, name="cylinder p0")
103
+ self.p1 = _vec(p1, name="cylinder p1")
104
+ self.r = float(r)
105
+ self.r_inner = float(r_inner or 0.0)
106
+ self.length = float(np.linalg.norm(self.p1 - self.p0))
107
+ if self.length <= 0:
108
+ raise ValueError("cylinder p0 and p1 must differ")
109
+ if self.r <= 0 or not (0 <= self.r_inner < self.r):
110
+ raise ValueError("cylinder needs r > 0 and 0 <= rInner < r")
111
+ self.axis = (self.p1 - self.p0) / self.length
112
+
113
+ def sdf(self, p):
114
+ p = np.asarray(p, float)
115
+ ax = self.axis
116
+ k = int(np.argmax(np.abs(ax)))
117
+ if abs(ax[k]) == 1.0:
118
+ # along x, y or z (the usual bore, boss or channel): two columns
119
+ i, j = [x for x in range(3) if x != k]
120
+ t = (p[..., k] - self.p0[k]) * ax[k]
121
+ u, v = p[..., i] - self.p0[i], p[..., j] - self.p0[j]
122
+ radial = np.sqrt(u * u + v * v)
123
+ else:
124
+ d = p - self.p0
125
+ t = d @ ax
126
+ radial = np.sqrt(np.maximum(np.einsum("...i,...i->...", d, d) - t * t, 0.0))
127
+ half = 0.5 * self.length
128
+ # 2D (radial, axial) box SDF; a tube is the band rInner..r.
129
+ if self.r_inner > 0:
130
+ mid = 0.5 * (self.r + self.r_inner)
131
+ rad_q = np.abs(radial - mid) - 0.5 * (self.r - self.r_inner)
132
+ else:
133
+ rad_q = radial - self.r
134
+ ax_q = np.abs(t - half) - half
135
+ a, b = np.maximum(rad_q, 0.0), np.maximum(ax_q, 0.0)
136
+ return np.sqrt(a * a + b * b) + np.minimum(np.maximum(rad_q, ax_q), 0.0)
137
+
138
+ def bounds(self):
139
+ # Exact AABB of a capped cylinder: each cap disc extends r*sqrt(1-a_i^2)
140
+ # along axis i.
141
+ ext = self.r * np.sqrt(np.clip(1.0 - self.axis ** 2, 0.0, 1.0))
142
+ lo = np.minimum(self.p0, self.p1) - ext
143
+ hi = np.maximum(self.p0, self.p1) + ext
144
+ return lo, hi
145
+
146
+ def _align(self, man):
147
+ """Map a +Z cylinder standing on the origin onto p0 -> p1."""
148
+ z = np.array([0.0, 0.0, 1.0])
149
+ a = self.axis
150
+ v = np.cross(z, a)
151
+ s = np.linalg.norm(v)
152
+ c = float(z @ a)
153
+ if s < 1e-12:
154
+ rot = np.eye(3) if c > 0 else np.diag([1.0, -1.0, -1.0])
155
+ else:
156
+ vx = np.array([[0, -v[2], v[1]], [v[2], 0, -v[0]], [-v[1], v[0], 0]])
157
+ rot = np.eye(3) + vx + vx @ vx * ((1 - c) / s ** 2)
158
+ mat = np.hstack([rot, self.p0[:, None]])
159
+ return man.transform(mat)
160
+
161
+ def to_manifold(self):
162
+ import manifold3d as m3d
163
+ outer = m3d.Manifold.cylinder(self.length, self.r, self.r, _CIRCLE_SEGMENTS)
164
+ if self.r_inner > 0:
165
+ # Overshoot the inner cut so its caps never coincide with the outer.
166
+ inner = m3d.Manifold.cylinder(self.length + 2.0, self.r_inner, self.r_inner,
167
+ _CIRCLE_SEGMENTS).translate((0, 0, -1.0))
168
+ outer = outer - inner
169
+ return self._align(outer)
170
+
171
+ def to_spec(self):
172
+ out = {"type": "cylinder", "p0": self.p0.tolist(), "p1": self.p1.tolist(), "r": self.r}
173
+ if self.r_inner > 0:
174
+ out["rInner"] = self.r_inner
175
+ return out
176
+
177
+
178
+ class Sphere(Shape):
179
+ kind = "sphere"
180
+
181
+ def __init__(self, center, r):
182
+ self.center = _vec(center, name="sphere center")
183
+ self.r = float(r)
184
+ if self.r <= 0:
185
+ raise ValueError("sphere r must be > 0")
186
+
187
+ def sdf(self, p):
188
+ p = np.asarray(p, float)
189
+ x, y, z = (p[..., i] - self.center[i] for i in range(3))
190
+ return np.sqrt(x * x + y * y + z * z) - self.r
191
+
192
+ def bounds(self):
193
+ return self.center - self.r, self.center + self.r
194
+
195
+ def to_manifold(self):
196
+ import manifold3d as m3d
197
+ return m3d.Manifold.sphere(self.r, _CIRCLE_SEGMENTS).translate(tuple(self.center))
198
+
199
+ def to_spec(self):
200
+ return {"type": "sphere", "center": self.center.tolist(), "r": self.r}
201
+
202
+
203
+ class MeshShape(Shape):
204
+ """An arbitrary closed mesh (STL/3MF/OBJ path, or a trimesh in code): the
205
+ way an agent hands over geometry it already modeled — a motor body as a
206
+ keep-out, a flange it drew in build123d as preserved material.
207
+
208
+ Signed distance comes from a voxel grid built once (ray-parity occupancy
209
+ plus distance transforms, trilinearly interpolated), so it costs the same
210
+ per point as the analytic shapes. Accuracy is about half a grid cell,
211
+ 1/160 of the mesh's largest extent, which is far below the optimizer's
212
+ resolution; the exact mesh is still what the final booleans use."""
213
+
214
+ kind = "mesh"
215
+ _GRID_CELLS = 160
216
+
217
+ def __init__(self, mesh, path=None):
218
+ import trimesh
219
+ if not isinstance(mesh, trimesh.Trimesh):
220
+ raise ValueError("MeshShape needs a trimesh.Trimesh")
221
+ if not mesh.is_watertight:
222
+ raise ValueError("mesh shape must be watertight (closed) to have an inside")
223
+ self.mesh = mesh
224
+ self.path = path
225
+ self._grid = None
226
+
227
+ def _build_grid(self):
228
+ from scipy import ndimage
229
+ from .verify import voxelize
230
+
231
+ pitch = float(np.max(self.mesh.extents)) / self._GRID_CELLS
232
+ solid, origin = voxelize(self.mesh, pitch)
233
+ pad = 4
234
+ solid = np.pad(solid, pad)
235
+ origin = origin - pad * pitch
236
+ dist = (ndimage.distance_transform_edt(~solid) - ndimage.distance_transform_edt(solid)) * pitch
237
+ dist -= 0.5 * pitch * np.sign(dist) # EDT is centre to centre; the surface is between
238
+ self._grid = (dist, origin, pitch)
239
+
240
+ def sdf(self, p):
241
+ from scipy import ndimage
242
+ if self._grid is None:
243
+ self._build_grid()
244
+ dist, origin, pitch = self._grid
245
+ p = np.asarray(p, float).reshape(-1, 3)
246
+ coords = ((p - origin) / pitch - 0.5).T
247
+ inside_grid = np.all((coords >= 0) & (coords <= (np.asarray(dist.shape)[:, None] - 1)), axis=0)
248
+ out = ndimage.map_coordinates(dist, coords, order=1, mode="nearest")
249
+ # Beyond the padded grid, distance to the box is a fine lower bound.
250
+ lo, hi = self.bounds()
251
+ q = np.maximum(np.maximum(lo - p, p - hi), 0.0)
252
+ return np.where(inside_grid, out, np.maximum(np.linalg.norm(q, axis=1), 4 * pitch))
253
+
254
+ def contains(self, p):
255
+ return self.sdf(p) <= 0.0
256
+
257
+ def bounds(self):
258
+ b = np.asarray(self.mesh.bounds, float)
259
+ return b[0].copy(), b[1].copy()
260
+
261
+ def to_manifold(self):
262
+ return manifold_from_trimesh(self.mesh)
263
+
264
+ def to_spec(self):
265
+ if self.path is None:
266
+ raise ValueError("an in-memory mesh shape has no JSON form")
267
+ return {"type": "mesh", "path": self.path}
268
+
269
+
270
+ _AXES = {"x": [1.0, 0.0, 0.0], "y": [0.0, 1.0, 0.0], "z": [0.0, 0.0, 1.0]}
271
+
272
+
273
+ class Union(Shape):
274
+ """Several shapes as one region: {"type": "union", "shapes": [..]}. For
275
+ an interface that is several separate pieces, such as the four screw
276
+ bosses a servo mounts on, so one load or support can act on all of them."""
277
+
278
+ kind = "union"
279
+
280
+ def __init__(self, shapes):
281
+ if not shapes:
282
+ raise ValueError("union needs at least one shape in 'shapes'")
283
+ self.shapes = list(shapes)
284
+
285
+ def sdf(self, p):
286
+ out = self.shapes[0].sdf(p)
287
+ for s in self.shapes[1:]:
288
+ out = np.minimum(out, s.sdf(p))
289
+ return out
290
+
291
+ def bounds(self):
292
+ b = [s.bounds() for s in self.shapes]
293
+ return np.min([x[0] for x in b], axis=0), np.max([x[1] for x in b], axis=0)
294
+
295
+ def to_manifold(self):
296
+ import manifold3d as m3d
297
+ mans = [s.to_manifold() for s in self.shapes]
298
+ return m3d.Manifold.batch_boolean(mans, m3d.OpType.Add) if len(mans) > 1 else mans[0]
299
+
300
+ def to_spec(self):
301
+ return {"type": "union", "shapes": [s.to_spec() for s in self.shapes]}
302
+
303
+
304
+ class Boolean(Shape):
305
+ """The overlap of shapes ({"type": "intersect", "shapes": [..]}) or the
306
+ first with the rest cut away ({"type": "subtract", "shapes": [..]}): a
307
+ half ring is a tube intersected with a box, a ring with a gap a tube
308
+ minus a box. Brief-eval agents built these as STLs by hand."""
309
+
310
+ def __init__(self, op, shapes):
311
+ if op not in ("intersect", "subtract"):
312
+ raise ValueError(f"unknown boolean {op!r}")
313
+ if len(shapes) < 2:
314
+ raise ValueError(f"{op} needs at least two shapes in 'shapes'")
315
+ self.kind = op
316
+ self.parts = list(shapes)
317
+
318
+ def sdf(self, p):
319
+ out = self.parts[0].sdf(p)
320
+ for s in self.parts[1:]:
321
+ out = np.maximum(out, s.sdf(p)) if self.kind == "intersect" else np.maximum(out, -s.sdf(p))
322
+ return out
323
+
324
+ def bounds(self):
325
+ if self.kind == "subtract":
326
+ return self.parts[0].bounds()
327
+ b = [s.bounds() for s in self.parts]
328
+ lo, hi = np.max([x[0] for x in b], axis=0), np.min([x[1] for x in b], axis=0)
329
+ if np.any(hi <= lo):
330
+ raise ValueError("intersect: the shapes do not overlap")
331
+ return lo, hi
332
+
333
+ def to_manifold(self):
334
+ import manifold3d as m3d
335
+ mans = [s.to_manifold() for s in self.parts]
336
+ if self.kind == "intersect":
337
+ return m3d.Manifold.batch_boolean(mans, m3d.OpType.Intersect)
338
+ return mans[0] - m3d.Manifold.batch_boolean(mans[1:], m3d.OpType.Add)
339
+
340
+ def to_spec(self):
341
+ return {"type": self.kind, "shapes": [s.to_spec() for s in self.parts]}
342
+
343
+
344
+ class Offset(Shape):
345
+ """Another shape grown (offset > 0) or shrunk (offset < 0) by a distance:
346
+ the level set sdf = offset. `open` lists bounding-box faces, as
347
+ (axis, sign), that a shrunk shape runs out through instead of stopping
348
+ short of: the shape is extruded straight out of each one from a cross
349
+ section taken 2|offset| inside it. That is how a skin is left off the
350
+ face a cover's ribs stand on.
351
+
352
+ Used where no exact primitive does it (`grow`, `shrink`); the exact solid
353
+ is marching cubes of the level set at ~1/200 of the shape's size."""
354
+
355
+ kind = "offset"
356
+ _CELLS = 200
357
+
358
+ def __init__(self, base: Shape, offset: float, open=()):
359
+ self.base = base
360
+ self.offset = float(offset)
361
+ self.open = [(int(a), int(np.sign(s))) for a, s in open]
362
+
363
+ def _clamp(self, p):
364
+ if not self.open:
365
+ return p
366
+ p = np.array(p, float, copy=True)
367
+ lo, hi = self.base.bounds()
368
+ inset = 2.0 * abs(self.offset)
369
+ for a, s in self.open:
370
+ if s < 0:
371
+ p[..., a] = np.maximum(p[..., a], lo[a] + inset)
372
+ else:
373
+ p[..., a] = np.minimum(p[..., a], hi[a] - inset)
374
+ return p
375
+
376
+ def sdf(self, p):
377
+ return self.base.sdf(self._clamp(np.asarray(p, float))) - self.offset
378
+
379
+ def bounds(self):
380
+ lo, hi = self.base.bounds()
381
+ lo, hi = lo - self.offset, hi + self.offset
382
+ reach = 2.0 * abs(self.offset) + 1.0
383
+ for a, s in self.open:
384
+ if s < 0:
385
+ lo[a] -= reach
386
+ else:
387
+ hi[a] += reach
388
+ return lo, hi
389
+
390
+ def to_manifold(self):
391
+ import trimesh
392
+ from skimage import measure
393
+
394
+ lo, hi = self.bounds()
395
+ step = float(np.max(hi - lo)) / self._CELLS
396
+ pad = 2
397
+ dims = np.ceil((hi - lo) / step).astype(int) + 1 + 2 * pad
398
+ origin = lo - pad * step
399
+ axes = [origin[i] + np.arange(dims[i]) * step for i in range(3)]
400
+ pts = np.stack(np.meshgrid(*axes, indexing="ij"), -1).reshape(-1, 3)
401
+ # the grid's own border counts as outside, so the surface closes
402
+ vals = self.sdf(pts).reshape(tuple(dims))
403
+ vals[[0, -1], :, :] = vals[:, [0, -1], :] = vals[:, :, [0, -1]] = step
404
+ verts, faces, _, _ = measure.marching_cubes(vals, level=0.0, spacing=(step, step, step))
405
+ mesh = trimesh.Trimesh(verts + origin, faces[:, ::-1], process=True)
406
+ if mesh.volume < 0:
407
+ mesh.invert()
408
+ return manifold_from_trimesh(mesh)
409
+
410
+ def to_spec(self):
411
+ raise ValueError("an offset shape is derived from the study and has no JSON form of its own")
412
+
413
+
414
+ class Shell(Shape):
415
+ """The material of `outer` that is not in `inner`, kept inside `clip`:
416
+ a keep-out's wall (its grown copy minus itself, inside the design space)
417
+ or a design space's skin (itself minus its shrunk copy)."""
418
+
419
+ kind = "shell"
420
+
421
+ def __init__(self, outer: Shape, inner: Shape, clip: Shape | None = None):
422
+ self.outer = outer
423
+ self.inner = inner
424
+ self.clip = clip
425
+
426
+ def sdf(self, p):
427
+ d = np.maximum(self.outer.sdf(p), -self.inner.sdf(p))
428
+ return d if self.clip is None else np.maximum(d, self.clip.sdf(p))
429
+
430
+ def bounds(self):
431
+ lo, hi = self.outer.bounds()
432
+ if self.clip is not None:
433
+ clo, chi = self.clip.bounds()
434
+ lo, hi = np.maximum(lo, clo), np.minimum(hi, chi)
435
+ return lo, hi
436
+
437
+ def to_manifold(self):
438
+ man = self.outer.to_manifold() - self.inner.to_manifold()
439
+ return man if self.clip is None else man ^ self.clip.to_manifold()
440
+
441
+ def to_spec(self):
442
+ raise ValueError("a shell is derived from the study and has no JSON form of its own")
443
+
444
+
445
+ def grow(shape: Shape, t: float) -> Shape:
446
+ """`shape` grown outward by t mm. Boxes and cylinders stay sharp-edged
447
+ (a little more than a true offset at their edges, never less)."""
448
+ if isinstance(shape, Box):
449
+ return Box(shape.lo - t, shape.hi + t)
450
+ if isinstance(shape, Cylinder):
451
+ a = shape.axis
452
+ return Cylinder(shape.p0 - t * a, shape.p1 + t * a, shape.r + t, max(shape.r_inner - t, 0.0))
453
+ if isinstance(shape, Sphere):
454
+ return Sphere(shape.center, shape.r + t)
455
+ if isinstance(shape, Union):
456
+ return Union([grow(s, t) for s in shape.shapes])
457
+ return Offset(shape, t)
458
+
459
+
460
+ def shrink(shape: Shape, t: float, open=()) -> Shape:
461
+ """`shape` shrunk inward by t mm, running out through the `open`
462
+ bounding-box faces ((axis, sign) pairs)."""
463
+ open = list(open)
464
+ if isinstance(shape, Box):
465
+ lo, hi = shape.lo + t, shape.hi - t
466
+ for a, s in open:
467
+ if s < 0:
468
+ lo[a] = shape.lo[a] - t
469
+ else:
470
+ hi[a] = shape.hi[a] + t
471
+ if np.any(hi <= lo):
472
+ raise ValueError(f"a {t:g} mm skin fills the whole box; make it thinner")
473
+ return Box(lo, hi)
474
+ if isinstance(shape, Sphere) and not open:
475
+ if shape.r <= t:
476
+ raise ValueError(f"a {t:g} mm skin fills the whole sphere; make it thinner")
477
+ return Sphere(shape.center, shape.r - t)
478
+ return Offset(shape, -t, open)
479
+
480
+
481
+ def shape_from_spec(spec, base_dir=None) -> Shape:
482
+ """Build a Shape from its JSON dict. Raises ValueError with a message an
483
+ agent can act on."""
484
+ if isinstance(spec, Shape):
485
+ return spec
486
+ if not isinstance(spec, dict) or "type" not in spec:
487
+ raise ValueError(f"shape must be an object with a 'type', got {spec!r}")
488
+ try:
489
+ return _shape(spec, base_dir)
490
+ except KeyError as exc:
491
+ raise ValueError(f"{spec['type']} shape is missing {exc.args[0]!r}; "
492
+ "see `loadpath schema study` for each shape's fields") from None
493
+
494
+
495
+ def _shape(spec, base_dir):
496
+ kind = spec["type"]
497
+ if kind == "box":
498
+ if "min" in spec:
499
+ return Box(spec["min"], spec["max"])
500
+ c, s = _vec(spec["center"], name="box center"), _vec(spec["size"], name="box size")
501
+ return Box(c - s / 2, c + s / 2)
502
+ if kind in ("cylinder", "tube"):
503
+ r_inner = spec.get("rInner", spec.get("r_inner", 0.0))
504
+ if "p0" in spec:
505
+ return Cylinder(spec["p0"], spec["p1"], spec["r"], r_inner)
506
+ axis = spec.get("axis", "z")
507
+ a = np.asarray(_AXES[axis] if isinstance(axis, str) else axis, float)
508
+ a = a / np.linalg.norm(a)
509
+ c = _vec(spec["center"], name="cylinder center")
510
+ half = 0.5 * float(spec["length"]) * a
511
+ return Cylinder(c - half, c + half, spec["r"], r_inner)
512
+ if kind == "sphere":
513
+ return Sphere(spec["center"], spec["r"])
514
+ if kind == "union":
515
+ parts = spec.get("shapes")
516
+ if not isinstance(parts, list) or not parts:
517
+ raise ValueError("union needs a non-empty 'shapes' list")
518
+ return Union([shape_from_spec(x, base_dir) for x in parts])
519
+ if kind in ("intersect", "subtract"):
520
+ parts = spec.get("shapes")
521
+ if not isinstance(parts, list) or len(parts) < 2:
522
+ raise ValueError(f"{kind} needs a 'shapes' list of at least two shapes")
523
+ return Boolean(kind, [shape_from_spec(x, base_dir) for x in parts])
524
+ if kind == "mesh":
525
+ import base64
526
+ import io
527
+ import os
528
+ import trimesh
529
+ if "stlBase64" in spec:
530
+ # Inline geometry: how a remote caller (a remote endpoint, an
531
+ # agent over HTTP) hands over a mesh without touching our disk.
532
+ data = base64.b64decode(spec["stlBase64"], validate=True)
533
+ mesh = trimesh.load(io.BytesIO(data), file_type="stl", force="mesh")
534
+ return MeshShape(mesh)
535
+ path = spec.get("path")
536
+ if not path:
537
+ raise ValueError("mesh shape needs 'path' (STL/3MF/OBJ/STEP) or 'stlBase64'")
538
+ if base_dir is not None:
539
+ # Paths resolve inside the study's own folder and never escape it,
540
+ # so a study file cannot read arbitrary files off the host.
541
+ root = os.path.realpath(base_dir)
542
+ full = os.path.realpath(os.path.join(root, path))
543
+ if os.path.commonpath([root, full]) != root:
544
+ raise ValueError(f"mesh path {path!r} must stay inside the study's folder")
545
+ path = full
546
+ if not os.path.isfile(path):
547
+ where = os.path.realpath(base_dir) if base_dir is not None else os.getcwd()
548
+ raise ValueError(f"mesh file {spec['path']!r} not found in {where}: relative paths resolve against "
549
+ "the study file's folder (CLI) or base_dir (MCP tools)")
550
+ if str(path).lower().endswith((".step", ".stp")):
551
+ # CAD straight in: tessellated, healed if it was an open shell
552
+ from .cadimport import load_part
553
+ return MeshShape(load_part(path).mesh, spec["path"])
554
+ mesh = trimesh.load(path, force="mesh")
555
+ return MeshShape(mesh, spec["path"])
556
+ raise ValueError(f"unknown shape type {kind!r} (box, cylinder, tube, sphere, mesh, union)")
557
+
558
+
559
+ def manifold_from_trimesh(mesh):
560
+ """A manifold3d Manifold from a trimesh, in double precision where the
561
+ library has it (Mesh64): rounding a lattice to float32 moves a vertex
562
+ up to ~5e-6 mm at 100 mm, enough to push one sheet of a wall a few
563
+ microns thick through the other."""
564
+ import manifold3d as m3d
565
+ if hasattr(m3d, "Mesh64"):
566
+ # copies: the binding refuses read-only arrays (trimesh's caches)
567
+ verts = np.array(mesh.vertices, dtype=np.float64, order="C", copy=True)
568
+ faces = np.array(mesh.faces, dtype=np.uint64, order="C", copy=True).reshape(-1, 3)
569
+ man = m3d.Manifold(m3d.Mesh64(vert_properties=verts, tri_verts=faces))
570
+ else:
571
+ verts = np.ascontiguousarray(mesh.vertices, dtype=np.float32)
572
+ faces = np.ascontiguousarray(mesh.faces, dtype=np.uint32)
573
+ man = m3d.Manifold(m3d.Mesh(vert_properties=verts, tri_verts=faces))
574
+ if man.status() != m3d.Error.NoError:
575
+ raise ValueError(f"mesh is not a valid manifold ({man.status()})")
576
+ return man
577
+
578
+
579
+ def trimesh_from_manifold(man):
580
+ import trimesh
581
+ mesh = man.to_mesh64() if hasattr(man, "to_mesh64") else man.to_mesh()
582
+ verts = np.asarray(mesh.vert_properties, float)[:, :3]
583
+ faces = np.asarray(mesh.tri_verts, np.int64)
584
+ return trimesh.Trimesh(verts, faces, process=False)