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.
- loadpath/README.md +294 -0
- loadpath/SKILL.md +584 -0
- loadpath/__init__.py +17 -0
- loadpath/__main__.py +1202 -0
- loadpath/am.py +316 -0
- loadpath/amgfast.py +326 -0
- loadpath/assembly.py +985 -0
- loadpath/benchmarks/__init__.py +0 -0
- loadpath/benchmarks/beams.py +266 -0
- loadpath/benchmarks/cases/ge-bracket.json +380 -0
- loadpath/benchmarks/evaluate.py +172 -0
- loadpath/benchmarks/mechanisms.py +416 -0
- loadpath/benchmarks/problems.py +140 -0
- loadpath/benchmarks/reference/bridge-3d.top3d.npz +0 -0
- loadpath/benchmarks/reference/cantilever-3d.top3d.npz +0 -0
- loadpath/benchmarks/reference/lbracket-2d.top88-ft2.npz +0 -0
- loadpath/benchmarks/reference/mbb-2d.top88-ft1.npz +0 -0
- loadpath/benchmarks/reference/mbb-2d.top88-ft2.npz +0 -0
- loadpath/benchmarks/reference.py +138 -0
- loadpath/benchmarks/run.py +301 -0
- loadpath/brep.py +1470 -0
- loadpath/bucklingopt.py +300 -0
- loadpath/buildsim.py +1490 -0
- loadpath/cadimport.py +904 -0
- loadpath/calibration.py +885 -0
- loadpath/cavities.py +211 -0
- loadpath/checks/__init__.py +0 -0
- loadpath/checks/dfm.py +354 -0
- loadpath/checks/exchanger.py +7 -0
- loadpath/checks/fea.py +212 -0
- loadpath/checks/fit.py +475 -0
- loadpath/checks/networks.py +260 -0
- loadpath/checks/opening.py +67 -0
- loadpath/checks/sdf_kit.py +7 -0
- loadpath/conjugate.py +267 -0
- loadpath/constrained.py +329 -0
- loadpath/contact.py +691 -0
- loadpath/coopt.py +342 -0
- loadpath/cores.py +163 -0
- loadpath/crease.py +1138 -0
- loadpath/critic.py +212 -0
- loadpath/crosscheck.py +1107 -0
- loadpath/cutcell.py +510 -0
- loadpath/data/lattice_buckling.json +439 -0
- loadpath/data/lattice_tensors.json +27449 -0
- loadpath/data/published_experiments.json +71 -0
- loadpath/decimate.py +177 -0
- loadpath/delivery.py +190 -0
- loadpath/directions.py +262 -0
- loadpath/domain.py +630 -0
- loadpath/drains.py +615 -0
- loadpath/durability.py +413 -0
- loadpath/editor.html +3135 -0
- loadpath/editor.py +610 -0
- loadpath/escape.py +354 -0
- loadpath/evals/__init__.py +0 -0
- loadpath/evals/baselines/coarse.json +1543 -0
- loadpath/evals/baselines/full.json +1497 -0
- loadpath/evals/brief_kinds/__init__.py +1 -0
- loadpath/evals/brief_kinds/heatx.py +387 -0
- loadpath/evals/brief_kinds/lattice.py +571 -0
- loadpath/evals/brief_kinds/multimat.py +593 -0
- loadpath/evals/briefs/assets/bracket.step +1201 -0
- loadpath/evals/briefs/assets/desk_arm.urdf +22 -0
- loadpath/evals/briefs/assets/elbow-gearbox.stl +0 -0
- loadpath/evals/briefs/assets/motor-body.stl +0 -0
- loadpath/evals/briefs/assets/sketch.png +0 -0
- loadpath/evals/briefs/base-adapter-fdm.json +492 -0
- loadpath/evals/briefs/bell-crank-lpbf.json +180 -0
- loadpath/evals/briefs/belt-tensioner-fdm.json +151 -0
- loadpath/evals/briefs/camera-boom-mjf.json +339 -0
- loadpath/evals/briefs/camera-mount-pla.json +203 -0
- loadpath/evals/briefs/coolant-isolator-petg.json +100 -0
- loadpath/evals/briefs/elbow-bearing-bracket-sls.json +300 -0
- loadpath/evals/briefs/elbow-gearbox-forearm-mjf.json +558 -0
- loadpath/evals/briefs/elbow-link-fatigue-fdm.json +251 -0
- loadpath/evals/briefs/elbow-stop-pad-tpu.json +39 -0
- loadpath/evals/briefs/forearm-fdm.json +146 -0
- loadpath/evals/briefs/forearm-servo-link-fdm.json +327 -0
- loadpath/evals/briefs/grip-pad-finger-mm.json +354 -0
- loadpath/evals/briefs/gripper-bracket-creep-fdm.json +340 -0
- loadpath/evals/briefs/gripper-finger-fdm.json +155 -0
- loadpath/evals/briefs/gripper-jaw-fatigue-mjf.json +328 -0
- loadpath/evals/briefs/gripper-palm-fdm.json +715 -0
- loadpath/evals/briefs/hot-stepper-mount-asa-fdm.json +471 -0
- loadpath/evals/briefs/hpu-oil-cooler-lpbf.json +121 -0
- loadpath/evals/briefs/isolated-camera-mount-mm.json +675 -0
- loadpath/evals/briefs/kart-charge-cooler-lpbf.json +93 -0
- loadpath/evals/briefs/lighten-step-bracket.json +220 -0
- loadpath/evals/briefs/nema17-mount-stl.json +429 -0
- loadpath/evals/briefs/pillow-block-sls.json +220 -0
- loadpath/evals/briefs/polyjet-fingertip-mm.json +305 -0
- loadpath/evals/briefs/rod-hook-pla.json +154 -0
- loadpath/evals/briefs/sensor-arm-resin.json +150 -0
- loadpath/evals/briefs/shelf-bracket-pla.json +158 -0
- loadpath/evals/briefs/shoulder-riser-mjf.json +487 -0
- loadpath/evals/briefs/sketch-clevis.json +229 -0
- loadpath/evals/briefs/spring-bracket-pacf.json +294 -0
- loadpath/evals/briefs/tool-flange-ti64.json +280 -0
- loadpath/evals/briefs/tray-arm-lattice-fdm.json +260 -0
- loadpath/evals/briefs/turret-fdm.json +281 -0
- loadpath/evals/briefs/upper-arm-sls.json +179 -0
- loadpath/evals/briefs/urdf-forearm.json +233 -0
- loadpath/evals/briefs/wrist-link-mjf.json +278 -0
- loadpath/evals/briefs/wrist-yoke-mjf.json +245 -0
- loadpath/evals/briefs.py +393 -0
- loadpath/evals/cases/arm-link-cast.json +157 -0
- loadpath/evals/cases/arm-link-driven.json +171 -0
- loadpath/evals/cases/arm-link-sandwich.json +315 -0
- loadpath/evals/cases/arm-link.json +111 -0
- loadpath/evals/cases/bell-crank.json +116 -0
- loadpath/evals/cases/grip-turret.json +479 -0
- loadpath/evals/cases/grip-upper-arm.json +253 -0
- loadpath/evals/cases/grip-wrist-link-mjf.json +929 -0
- loadpath/evals/cases/grip-wrist-yoke.json +831 -0
- loadpath/evals/cases/lpbf-intercooler-core.json +59 -0
- loadpath/evals/cases/mbb-beam.json +61 -0
- loadpath/evals/cases/motor-body.stl +0 -0
- loadpath/evals/cases/motor-mount.json +265 -0
- loadpath/evals/cases/printed-hook.json +77 -0
- loadpath/evals/cases/wall-bracket-fasteners.json +125 -0
- loadpath/evals/cases/wall-bracket.json +126 -0
- loadpath/evals/corpus.py +179 -0
- loadpath/evals/pareto.py +90 -0
- loadpath/evals/run.py +860 -0
- loadpath/examples/arm-link.json +25 -0
- loadpath/examples/bumpered-bracket-compression.json +225 -0
- loadpath/examples/bumpered-bracket-lattice.json +204 -0
- loadpath/examples/bumpered-bracket.json +34 -0
- loadpath/examples/gripper-finger-mm.json +185 -0
- loadpath/examples/heat-exchanger-counterflow.json +21 -0
- loadpath/examples/heat-exchanger-crossflow-tuned.json +48 -0
- loadpath/examples/heat-exchanger-crossflow.json +20 -0
- loadpath/examples/pedal-lattice.json +23 -0
- loadpath/examples/pressure-plenum.json +12 -0
- loadpath/examples/shelf-bracket.json +27 -0
- loadpath/exchanger.py +831 -0
- loadpath/fasteners.py +199 -0
- loadpath/fem.py +668 -0
- loadpath/femops.py +70 -0
- loadpath/fieldjit.py +981 -0
- loadpath/fieldops.py +119 -0
- loadpath/fields.py +3697 -0
- loadpath/fieldtorch.py +464 -0
- loadpath/flow.py +984 -0
- loadpath/flowtopo.py +300 -0
- loadpath/gpu.py +521 -0
- loadpath/heatx.py +1067 -0
- loadpath/heatx_thermal.py +432 -0
- loadpath/homogenized.py +886 -0
- loadpath/ibrep.py +552 -0
- loadpath/impact.py +693 -0
- loadpath/infill.py +376 -0
- loadpath/interface_library.py +340 -0
- loadpath/interlock.py +186 -0
- loadpath/isolator.py +149 -0
- loadpath/knn.py +204 -0
- loadpath/lattice.py +1065 -0
- loadpath/lattice3mf.py +760 -0
- loadpath/lattice_buckling.py +245 -0
- loadpath/lattice_grade.py +325 -0
- loadpath/lattice_orient.py +595 -0
- loadpath/lattice_print.py +159 -0
- loadpath/lattice_tensors.py +558 -0
- loadpath/latticestep.py +1210 -0
- loadpath/manufacture.py +446 -0
- loadpath/material_library.py +489 -0
- loadpath/materials.py +144 -0
- loadpath/mcp_server.py +786 -0
- loadpath/mechanism.py +218 -0
- loadpath/mechanism_nl.py +811 -0
- loadpath/meshops.py +452 -0
- loadpath/mg.py +853 -0
- loadpath/mma.py +214 -0
- loadpath/modal.py +210 -0
- loadpath/multimat.py +1788 -0
- loadpath/narrowband.py +418 -0
- loadpath/nlkernel.py +241 -0
- loadpath/nonlinear.py +1095 -0
- loadpath/orient.py +334 -0
- loadpath/porous.py +994 -0
- loadpath/pressure.py +171 -0
- loadpath/preview.py +335 -0
- loadpath/printcheck.py +263 -0
- loadpath/printsetup.py +207 -0
- loadpath/processcheck.py +376 -0
- loadpath/quick.py +113 -0
- loadpath/recall.py +406 -0
- loadpath/reconstruct.py +797 -0
- loadpath/render.py +436 -0
- loadpath/resolved.py +189 -0
- loadpath/schema.py +1701 -0
- loadpath/sdf_kit.py +843 -0
- loadpath/selfx.py +292 -0
- loadpath/shapes.py +584 -0
- loadpath/sharp.py +717 -0
- loadpath/simp.py +1170 -0
- loadpath/sizing.py +312 -0
- loadpath/slicer.py +321 -0
- loadpath/spec.py +1072 -0
- loadpath/step.py +806 -0
- loadpath/struts.py +639 -0
- loadpath/study.py +1937 -0
- loadpath/tasks.py +822 -0
- loadpath/thermal.py +1691 -0
- loadpath/threemf.py +487 -0
- loadpath/tune.py +437 -0
- loadpath/validation.py +145 -0
- loadpath/verify.py +1215 -0
- loadpath/viewer.html +694 -0
- loadpath/viewer.py +446 -0
- loadpath/viscoelastic.py +106 -0
- loadpath/voxelprint.py +582 -0
- loadpath/voxels.py +402 -0
- loadpath-0.1.1.dist-info/METADATA +319 -0
- loadpath-0.1.1.dist-info/RECORD +220 -0
- loadpath-0.1.1.dist-info/WHEEL +5 -0
- loadpath-0.1.1.dist-info/entry_points.txt +2 -0
- loadpath-0.1.1.dist-info/licenses/LICENSE +21 -0
- 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)
|