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/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
|