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/lattice3mf.py
ADDED
|
@@ -0,0 +1,760 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Lattice parts as 3MF with the lattice kept as a lattice, so a part goes to
|
|
3
|
+
another tool without a 40-65 MB STL of lattice triangles:
|
|
4
|
+
|
|
5
|
+
- TPMS lattices (gyroid, diamond, Schwarz P, sheet or network) as a level
|
|
6
|
+
set of the 3MF Volumetric & Implicit extension: the TPMS formula itself
|
|
7
|
+
as an implicit function (the expression loadpath meshes). What a field
|
|
8
|
+
grades is sampled into 3D images (16-bit PNG stacks) the function reads:
|
|
9
|
+
the wall, an offset, and for a graded cell (warp grading) or a conformal
|
|
10
|
+
frame the lattice coordinates themselves, which are smooth, so trilinear
|
|
11
|
+
samples of them keep the surface within a few microns;
|
|
12
|
+
- strut lattices (cubic, BCC, octet, Kelvin, ...) as a beam lattice of the
|
|
13
|
+
Beam Lattice extension: every strut a beam, its radius at each end from
|
|
14
|
+
the thickness field. The beam object also carries the struts as
|
|
15
|
+
octagonal prisms inside the beams, so a reader without beam support (a
|
|
16
|
+
mesh-only slicer) still gets a printable lattice, about 10% thinner.
|
|
17
|
+
|
|
18
|
+
Each lattice is clipped by its own region: the part with that lattice solid
|
|
19
|
+
and the others gone. The rest of the part, skin, bosses and bores, is an
|
|
20
|
+
ordinary mesh object beside them. Where the graph blends a lattice into the
|
|
21
|
+
body, its level set is the whole blend, max(smin(body, lattice clipped,
|
|
22
|
+
r), region) with the body and region as signed distances to their meshes
|
|
23
|
+
(the extension's mesh node) and r the graph's blend radius, so the fillets
|
|
24
|
+
carry over. Strut lattices are written without their fillets, and with a
|
|
25
|
+
constant cell in the cartesian frame only. Written with lib3mf
|
|
26
|
+
(pip install "loadpath[3mf]"), which also reads the file back as a check.
|
|
27
|
+
"""
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import io
|
|
31
|
+
import os
|
|
32
|
+
|
|
33
|
+
import numpy as np
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def available() -> bool:
|
|
37
|
+
try:
|
|
38
|
+
import lib3mf # noqa: F401
|
|
39
|
+
except ImportError:
|
|
40
|
+
return False
|
|
41
|
+
return True
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _need():
|
|
45
|
+
try:
|
|
46
|
+
import lib3mf
|
|
47
|
+
except ImportError as e:
|
|
48
|
+
raise RuntimeError('lattice 3MF needs lib3mf: pip install "loadpath[3mf]"') from e
|
|
49
|
+
return lib3mf
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
# ---- a small expression graph: evaluated with numpy, written as implicit nodes
|
|
53
|
+
|
|
54
|
+
class X:
|
|
55
|
+
"""A scalar expression of the position (x, y, z), mm."""
|
|
56
|
+
|
|
57
|
+
def __init__(self, op, args=(), value=None):
|
|
58
|
+
self.op, self.args, self.value = op, tuple(args), value
|
|
59
|
+
|
|
60
|
+
def __add__(self, o):
|
|
61
|
+
return X("add", (self, _x(o)))
|
|
62
|
+
|
|
63
|
+
__radd__ = __add__
|
|
64
|
+
|
|
65
|
+
def __sub__(self, o):
|
|
66
|
+
return X("sub", (self, _x(o)))
|
|
67
|
+
|
|
68
|
+
def __rsub__(self, o):
|
|
69
|
+
return X("sub", (_x(o), self))
|
|
70
|
+
|
|
71
|
+
def __mul__(self, o):
|
|
72
|
+
return X("mul", (self, _x(o)))
|
|
73
|
+
|
|
74
|
+
__rmul__ = __mul__
|
|
75
|
+
|
|
76
|
+
def __truediv__(self, o):
|
|
77
|
+
return X("div", (self, _x(o)))
|
|
78
|
+
|
|
79
|
+
def __neg__(self):
|
|
80
|
+
return X("sub", (_x(0.0), self))
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _x(v):
|
|
84
|
+
return v if isinstance(v, X) else X("const", value=float(v))
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def sin(a):
|
|
88
|
+
return X("sin", (a,))
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def cos(a):
|
|
92
|
+
return X("cos", (a,))
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def sqrt(a):
|
|
96
|
+
return X("sqrt", (a,))
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def fabs(a):
|
|
100
|
+
return X("abs", (a,))
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def fmax(a, b):
|
|
104
|
+
return X("max", (_x(a), _x(b)))
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def fmin(a, b):
|
|
108
|
+
return X("min", (_x(a), _x(b)))
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def image(name, lo, size):
|
|
112
|
+
"""3D image `name` (registered with the writer) over the box lo .. lo +
|
|
113
|
+
size, as a scalar of the position."""
|
|
114
|
+
return X("image", value=(name, np.asarray(lo, float), np.asarray(size, float)))
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def mesh(name):
|
|
118
|
+
"""Signed distance (negative inside) to mesh `name` (registered with the
|
|
119
|
+
writer)."""
|
|
120
|
+
return X("mesh", value=name)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def smin(a, b, k):
|
|
124
|
+
"""fields._smin (polynomial smooth minimum, radius k), as an expression."""
|
|
125
|
+
a, b, k = _x(a), _x(b), _x(k)
|
|
126
|
+
h = fmin(fmax(0.5 + 0.5 * (b - a) / k, 0.0), 1.0)
|
|
127
|
+
return b * (1.0 - h) + a * h - k * h * (1.0 - h)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
POS = {k: X("pos", value=i) for i, k in enumerate("xyz")}
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def evaluate(e, P, images=None, meshes=None, memo=None):
|
|
134
|
+
"""e at points P (n, 3); `images` {name: (grid[z, y, x], offset, scale)}
|
|
135
|
+
sampled the way the extension says (trilinear between voxel centres,
|
|
136
|
+
clamped), `meshes` {name: trimesh} for mesh distances."""
|
|
137
|
+
memo = {} if memo is None else memo
|
|
138
|
+
if id(e) in memo:
|
|
139
|
+
return memo[id(e)]
|
|
140
|
+
a = [evaluate(x, P, images, meshes, memo) for x in e.args]
|
|
141
|
+
op = e.op
|
|
142
|
+
if op == "pos":
|
|
143
|
+
r = P[:, e.value]
|
|
144
|
+
elif op == "const":
|
|
145
|
+
r = np.full(len(P), e.value)
|
|
146
|
+
elif op == "image":
|
|
147
|
+
name, lo, size = e.value
|
|
148
|
+
grid, off, scale = images[name]
|
|
149
|
+
r = _sample(grid, (P - lo) / size) * scale + off
|
|
150
|
+
elif op == "mesh":
|
|
151
|
+
from .sharp import mesh_distance
|
|
152
|
+
|
|
153
|
+
# exact near the surface; beyond `cap` only the side matters here
|
|
154
|
+
m, cap = meshes[e.value], 4.0
|
|
155
|
+
r = mesh_distance(m, P, cap=cap, signed=True)
|
|
156
|
+
far = ~(np.abs(r) < cap)
|
|
157
|
+
if far.any():
|
|
158
|
+
r[far] = np.where(m.contains(P[far]), -cap, cap)
|
|
159
|
+
else:
|
|
160
|
+
r = {"add": lambda: a[0] + a[1], "sub": lambda: a[0] - a[1], "mul": lambda: a[0] * a[1],
|
|
161
|
+
"div": lambda: a[0] / a[1], "sin": lambda: np.sin(a[0]), "cos": lambda: np.cos(a[0]),
|
|
162
|
+
"sqrt": lambda: np.sqrt(a[0]), "abs": lambda: np.abs(a[0]),
|
|
163
|
+
"max": lambda: np.maximum(a[0], a[1]), "min": lambda: np.minimum(a[0], a[1])}[op]()
|
|
164
|
+
memo[id(e)] = r
|
|
165
|
+
return r
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _sample(grid, uvw):
|
|
169
|
+
"""Trilinear sample of grid[z, y, x] at normalized uvw (voxel centres at
|
|
170
|
+
(i + 0.5) / n), clamped."""
|
|
171
|
+
from scipy.ndimage import map_coordinates
|
|
172
|
+
|
|
173
|
+
nz, ny, nx = grid.shape
|
|
174
|
+
c = np.stack([np.clip(uvw[:, 2] * nz - 0.5, 0, nz - 1), np.clip(uvw[:, 1] * ny - 0.5, 0, ny - 1),
|
|
175
|
+
np.clip(uvw[:, 0] * nx - 0.5, 0, nx - 1)])
|
|
176
|
+
return map_coordinates(grid, c, order=1, mode="nearest")
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _quantize(grid):
|
|
180
|
+
"""The grid as 16-bit levels in [0, 1], and the offset and scale back."""
|
|
181
|
+
lo_, hi_ = float(grid.min()), float(grid.max())
|
|
182
|
+
span = hi_ - lo_ if hi_ > lo_ else 1.0
|
|
183
|
+
return np.round((grid - lo_) / span * 65535) / 65535, lo_, span
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
class _Emitter:
|
|
187
|
+
"""X expressions as nodes of one lib3mf implicit function."""
|
|
188
|
+
|
|
189
|
+
def __init__(self, lib, fn, images, meshes):
|
|
190
|
+
self.lib, self.fn, self.images, self.meshes = lib, fn, images, meshes
|
|
191
|
+
self.n = 0
|
|
192
|
+
self.memo = {}
|
|
193
|
+
pos = fn.AddInput("pos", "position", lib.ImplicitPortType.Vector)
|
|
194
|
+
self.dec = fn.AddDecomposeVectorNode(self._id("pos"), "position", "")
|
|
195
|
+
fn.AddLink(pos, self.dec.GetInputA())
|
|
196
|
+
self.posport = pos
|
|
197
|
+
|
|
198
|
+
def _id(self, s):
|
|
199
|
+
self.n += 1
|
|
200
|
+
return f"{s}_{self.n}"
|
|
201
|
+
|
|
202
|
+
def port(self, e):
|
|
203
|
+
if id(e) in self.memo:
|
|
204
|
+
return self.memo[id(e)]
|
|
205
|
+
fn, S = self.fn, self.lib.ImplicitNodeConfiguration.ScalarToScalar
|
|
206
|
+
if e.op == "pos":
|
|
207
|
+
out = (self.dec.GetOutputX, self.dec.GetOutputY, self.dec.GetOutputZ)[e.value]()
|
|
208
|
+
elif e.op == "const":
|
|
209
|
+
nd = fn.AddConstantNode(self._id("c"), "", "")
|
|
210
|
+
nd.SetConstant(e.value)
|
|
211
|
+
out = nd.GetOutputValue()
|
|
212
|
+
elif e.op == "image":
|
|
213
|
+
out = self._image(e)
|
|
214
|
+
elif e.op == "mesh":
|
|
215
|
+
rid = fn.AddResourceIdNode(self._id("meshid"), "", "")
|
|
216
|
+
rid.SetResource(self.meshes[e.value])
|
|
217
|
+
mn = fn.AddMeshNode(self._id("meshdist"), e.value, "")
|
|
218
|
+
fn.AddLink(rid.GetOutputValue(), mn.GetInputMesh())
|
|
219
|
+
fn.AddLink(self.posport, mn.GetInputPos())
|
|
220
|
+
out = mn.GetOutputDistance()
|
|
221
|
+
else:
|
|
222
|
+
add = {"add": fn.AddAdditionNode, "sub": fn.AddSubtractionNode, "mul": fn.AddMultiplicationNode,
|
|
223
|
+
"div": fn.AddDivisionNode, "sin": fn.AddSinNode, "cos": fn.AddCosNode, "sqrt": fn.AddSqrtNode,
|
|
224
|
+
"abs": fn.AddAbsNode, "max": fn.AddMaxNode, "min": fn.AddMinNode}[e.op]
|
|
225
|
+
nd = add(self._id(e.op), S, "", "")
|
|
226
|
+
fn.AddLink(self.port(e.args[0]), nd.GetInputA())
|
|
227
|
+
if len(e.args) > 1:
|
|
228
|
+
fn.AddLink(self.port(e.args[1]), nd.GetInputB())
|
|
229
|
+
out = nd.GetOutputResult()
|
|
230
|
+
self.memo[id(e)] = out
|
|
231
|
+
return out
|
|
232
|
+
|
|
233
|
+
def _image(self, e):
|
|
234
|
+
name, lo, size = e.value
|
|
235
|
+
lib, fn = self.lib, self.fn
|
|
236
|
+
V = lib.ImplicitNodeConfiguration.VectorToVector
|
|
237
|
+
# uvw = (pos - lo) / size, then the image function's red channel
|
|
238
|
+
lo_n = fn.AddConstVecNode(self._id("lo"), "", "")
|
|
239
|
+
lo_n.SetVector(lib.Vector((float(lo[0]), float(lo[1]), float(lo[2]))))
|
|
240
|
+
sz = fn.AddConstVecNode(self._id("size"), "", "")
|
|
241
|
+
sz.SetVector(lib.Vector((float(size[0]), float(size[1]), float(size[2]))))
|
|
242
|
+
sub = fn.AddSubtractionNode(self._id("rel"), V, "", "")
|
|
243
|
+
fn.AddLink(self.posport, sub.GetInputA())
|
|
244
|
+
fn.AddLink(lo_n.GetOutputVector(), sub.GetInputB())
|
|
245
|
+
div = fn.AddDivisionNode(self._id("uvw"), V, "", "")
|
|
246
|
+
fn.AddLink(sub.GetOutputResult(), div.GetInputA())
|
|
247
|
+
fn.AddLink(sz.GetOutputVector(), div.GetInputB())
|
|
248
|
+
rid = fn.AddResourceIdNode(self._id("img"), "", "")
|
|
249
|
+
rid.SetResource(self.images[name])
|
|
250
|
+
call = fn.AddFunctionCallNode(self._id("call"), "", "")
|
|
251
|
+
fn.AddLink(rid.GetOutputValue(), call.GetInputFunctionID())
|
|
252
|
+
inp = call.AddInput("pos", "uvw")
|
|
253
|
+
inp.SetType(lib.ImplicitPortType.Vector)
|
|
254
|
+
fn.AddLink(div.GetOutputResult(), inp)
|
|
255
|
+
out = call.AddOutput("red", "value")
|
|
256
|
+
out.SetType(lib.ImplicitPortType.Scalar)
|
|
257
|
+
return out
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
# ---- lattices
|
|
261
|
+
|
|
262
|
+
_TPMS = ("gyroid", "diamond", "schwarz-p")
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def tpms_unit(kind, x, y, z):
|
|
266
|
+
"""loadpath.lattice.tpms at period 1 over coordinate expressions x, y, z
|
|
267
|
+
(cell units): value over gradient magnitude (floored at 0.3 k), in
|
|
268
|
+
cell units."""
|
|
269
|
+
k = 2.0 * np.pi
|
|
270
|
+
return tpms_unit_trig(kind, [(sin(u * k), cos(u * k)) for u in (x, y, z)])
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def tpms_unit_trig(kind, trig):
|
|
274
|
+
"""tpms_unit from each coordinate's (sin, cos) of 2 pi u instead of u:
|
|
275
|
+
a phase that wraps (a conformal frame's) is sampled continuously as its
|
|
276
|
+
sine and cosine, where an image of u itself jumps by a cycle."""
|
|
277
|
+
k = 2.0 * np.pi
|
|
278
|
+
(sx, cx), (sy, cy), (sz, cz) = trig
|
|
279
|
+
if kind == "gyroid":
|
|
280
|
+
f = sx * cy + sy * cz + sz * cx
|
|
281
|
+
g = (cx * cy - sz * sx, cy * cz - sx * sy, cz * cx - sy * sz)
|
|
282
|
+
elif kind == "diamond":
|
|
283
|
+
f = sx * sy * sz + sx * cy * cz + cx * sy * cz + cx * cy * sz
|
|
284
|
+
g = (cx * sy * sz + cx * cy * cz - sx * sy * cz - sx * cy * sz,
|
|
285
|
+
sx * cy * sz - sx * sy * cz + cx * cy * cz - cx * sy * sz,
|
|
286
|
+
sx * sy * cz - sx * cy * sz - cx * sy * sz + cx * cy * cz)
|
|
287
|
+
elif kind == "schwarz-p":
|
|
288
|
+
f = cx + cy + cz
|
|
289
|
+
g = (-1.0 * sx, -1.0 * sy, -1.0 * sz)
|
|
290
|
+
else:
|
|
291
|
+
raise ValueError(f"TPMS kind must be one of {', '.join(_TPMS)}")
|
|
292
|
+
return f / fmax(sqrt(g[0] * g[0] + g[1] * g[1] + g[2] * g[2]) * k, 0.3 * k)
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def tpms_expr(kind, cell, thickness, form="sheet", origin=(0, 0, 0)):
|
|
296
|
+
"""Signed distance (mm, negative inside) of a TPMS sheet or network with
|
|
297
|
+
a constant cell, as an expression."""
|
|
298
|
+
c = float(cell)
|
|
299
|
+
U = [(POS[a] - float(o)) / c for a, o in zip("xyz", origin)]
|
|
300
|
+
return _wall(tpms_unit(kind, *U) * c, thickness, form)
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def _wall(d, thickness, form):
|
|
304
|
+
half = 0.5 * thickness
|
|
305
|
+
if form == "sheet":
|
|
306
|
+
return fabs(d) - half
|
|
307
|
+
if form == "network":
|
|
308
|
+
return d - half
|
|
309
|
+
raise ValueError("TPMS form must be 'sheet' or 'network'")
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def lattice_beams(kind, cell, lo, hi, origin=(0, 0, 0), aspect=(1, 1, 1)):
|
|
313
|
+
"""The struts of a periodic strut cell tiled over the box lo..hi (mm):
|
|
314
|
+
(vertices (n, 3), beams (m, 2) vertex index pairs), each strut once."""
|
|
315
|
+
from .struts import canonical, cell_segments
|
|
316
|
+
|
|
317
|
+
seg = np.asarray(cell_segments(canonical(kind)), float) # (s, 2, 3), period 1
|
|
318
|
+
size = float(cell) * np.asarray(aspect, float)
|
|
319
|
+
o = np.asarray(origin, float)
|
|
320
|
+
i0 = np.floor((np.asarray(lo, float) - o) / size).astype(int) - 1
|
|
321
|
+
i1 = np.ceil((np.asarray(hi, float) - o) / size).astype(int) + 1
|
|
322
|
+
ijk = np.stack(np.meshgrid(*[np.arange(a, b) for a, b in zip(i0, i1)], indexing="ij"), -1).reshape(-1, 3)
|
|
323
|
+
ends = (seg[None] + ijk[:, None, None, :]).reshape(-1, 2, 3) # in cell units
|
|
324
|
+
key = np.round(ends * 1e4).astype(np.int64)
|
|
325
|
+
# each strut once whichever way round: ends in lexicographic order
|
|
326
|
+
d = key[:, 1] - key[:, 0]
|
|
327
|
+
r = np.arange(len(d))
|
|
328
|
+
back = d[r, np.argmax(d != 0, axis=1)] < 0
|
|
329
|
+
key[back] = key[back][:, ::-1]
|
|
330
|
+
ends[back] = ends[back][:, ::-1]
|
|
331
|
+
_, uniq = np.unique(key.reshape(len(key), -1), axis=0, return_index=True)
|
|
332
|
+
E = ends[uniq] * size + o
|
|
333
|
+
lo_, hi_ = np.asarray(lo, float), np.asarray(hi, float)
|
|
334
|
+
keep = ((E.max(axis=1) >= lo_) & (E.min(axis=1) <= hi_)).all(1)
|
|
335
|
+
E = E[keep]
|
|
336
|
+
V, inv = np.unique(np.round(E.reshape(-1, 3), 6), axis=0, return_inverse=True)
|
|
337
|
+
B = inv.reshape(-1, 2)
|
|
338
|
+
B = B[B[:, 0] != B[:, 1]]
|
|
339
|
+
return V, B
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def strut_prisms(V, B, R, clip=None, sides=8):
|
|
343
|
+
"""The beams as `sides`-gonal frustums inside them (radius R at each
|
|
344
|
+
end, vertices on the beam's circle), unioned and clipped by `clip` (a
|
|
345
|
+
trimesh): a closed mesh a reader without beam support can print."""
|
|
346
|
+
import manifold3d as m3d
|
|
347
|
+
import trimesh
|
|
348
|
+
|
|
349
|
+
from .shapes import manifold_from_trimesh
|
|
350
|
+
|
|
351
|
+
parts = []
|
|
352
|
+
for a, b in B:
|
|
353
|
+
p, q = V[a], V[b]
|
|
354
|
+
L = float(np.linalg.norm(q - p))
|
|
355
|
+
if L < 1e-6:
|
|
356
|
+
continue
|
|
357
|
+
c = m3d.Manifold.cylinder(L, float(R[a]), float(R[b]), sides)
|
|
358
|
+
z = (q - p) / L
|
|
359
|
+
x = np.cross(z, (1.0, 0, 0) if abs(z[0]) < 0.9 else (0, 1.0, 0))
|
|
360
|
+
x /= np.linalg.norm(x)
|
|
361
|
+
y = np.cross(z, x)
|
|
362
|
+
M = np.column_stack([x, y, z, p]) # 3 x 4
|
|
363
|
+
parts.append(c.transform(M.tolist()))
|
|
364
|
+
if not parts:
|
|
365
|
+
return trimesh.Trimesh()
|
|
366
|
+
u = m3d.Manifold.batch_boolean(parts, m3d.OpType.Add)
|
|
367
|
+
if clip is not None:
|
|
368
|
+
u = u ^ manifold_from_trimesh(clip)
|
|
369
|
+
return _solid(u)
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def _solid(u):
|
|
373
|
+
"""A manifold3d result as a trimesh, without the specks (under 0.01
|
|
374
|
+
mm3: pinholes where prisms meet at a node, slivers where a strut
|
|
375
|
+
grazes the region) that a slicer would count as parts."""
|
|
376
|
+
import trimesh
|
|
377
|
+
|
|
378
|
+
me = u.to_mesh()
|
|
379
|
+
t = trimesh.Trimesh(np.asarray(me.vert_properties)[:, :3], np.asarray(me.tri_verts), process=False)
|
|
380
|
+
parts = t.split(only_watertight=False)
|
|
381
|
+
if len(parts) > 1:
|
|
382
|
+
keep = [x for x in parts if abs(x.volume) > 1e-2]
|
|
383
|
+
t = trimesh.util.concatenate(keep) if keep else trimesh.Trimesh()
|
|
384
|
+
return t
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
# ---- the file
|
|
388
|
+
|
|
389
|
+
def _union(a, b):
|
|
390
|
+
from .shapes import manifold_from_trimesh
|
|
391
|
+
|
|
392
|
+
if b is None or not len(b.faces):
|
|
393
|
+
return a
|
|
394
|
+
return _solid(manifold_from_trimesh(a) + manifold_from_trimesh(b))
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
def write_lattice_3mf(path, body, lattices, name="part", check=True, prisms=True, mesh_readers=False):
|
|
398
|
+
"""Write the 3MF: `body` (trimesh or None, the part without its
|
|
399
|
+
lattices) and per lattice (dicts from lattice_from_graph, each with its
|
|
400
|
+
`clip` trimesh, the region it fills) a level set or a beam lattice
|
|
401
|
+
clipped by that region; a lattice with a `blend` radius becomes the
|
|
402
|
+
blend of body and lattice. Returns a report.
|
|
403
|
+
|
|
404
|
+
The region meshes are objects of type "model" outside the build, as
|
|
405
|
+
the Beam Lattice spec asks of a clipping mesh. PrusaSlicer 2.x refuses
|
|
406
|
+
any file with an object outside the build; `mesh_readers` marks the
|
|
407
|
+
regions type "other" instead, which it skips, so it opens a beam
|
|
408
|
+
lattice file (printing the prisms), and puts the body into the (first)
|
|
409
|
+
beam lattice's mesh, so it gets one object rather than two it would
|
|
410
|
+
lay apart on the bed. lib3mf reads either."""
|
|
411
|
+
lib = _need()
|
|
412
|
+
w = lib.get_wrapper()
|
|
413
|
+
model = w.CreateModel()
|
|
414
|
+
model.SetUnit(lib.ModelUnit.MilliMeter)
|
|
415
|
+
rep = {"path": path, "objects": [], "lattices": []}
|
|
416
|
+
|
|
417
|
+
def mesh_object(m, label):
|
|
418
|
+
mo = model.AddMeshObject()
|
|
419
|
+
mo.SetName(label)
|
|
420
|
+
V = np.asarray(m.vertices, float)
|
|
421
|
+
F = np.asarray(m.faces, np.int64)
|
|
422
|
+
mo.SetGeometry([lib.Position((float(a), float(b), float(c))) for a, b, c in V],
|
|
423
|
+
[lib.Triangle((int(a), int(b), int(c))) for a, b, c in F])
|
|
424
|
+
return mo
|
|
425
|
+
|
|
426
|
+
ident = lib.Transform()
|
|
427
|
+
for i in range(4):
|
|
428
|
+
for j in range(3):
|
|
429
|
+
ident.Fields[i][j] = 1.0 if i == j else 0.0
|
|
430
|
+
bo, items = None, []
|
|
431
|
+
# for mesh-only readers, the body goes into the first beam lattice's
|
|
432
|
+
# mesh (a beam lattice object is its triangles and its beams), as one
|
|
433
|
+
# shell with the prisms: a slicer lays separate objects (or a components
|
|
434
|
+
# object's parts) apart on the bed
|
|
435
|
+
merge = (mesh_readers and body is not None and len(body.faces) and prisms
|
|
436
|
+
and lattices and all(x["kind"] not in _TPMS for x in lattices))
|
|
437
|
+
if body is not None and len(body.faces) and not merge:
|
|
438
|
+
bo = mesh_object(body, f"{name} body")
|
|
439
|
+
items.append(bo)
|
|
440
|
+
rep["objects"].append({"name": f"{name} body", "triangles": int(len(body.faces))})
|
|
441
|
+
for lat in lattices:
|
|
442
|
+
clip = mesh_object(lat["clip"], f"{name} {lat['name']} region")
|
|
443
|
+
if mesh_readers:
|
|
444
|
+
clip.SetType(lib.ObjectType.Other)
|
|
445
|
+
if lat["kind"] in _TPMS:
|
|
446
|
+
images = {nm: _image3d(lib, model, nm, *im) for nm, im in lat.get("images", {}).items()}
|
|
447
|
+
meshes = {"region": clip}
|
|
448
|
+
e = lat["expr"]
|
|
449
|
+
blend = lat.get("blend")
|
|
450
|
+
if blend is not None and bo is not None:
|
|
451
|
+
meshes["body"] = bo
|
|
452
|
+
rg = mesh("region")
|
|
453
|
+
e = fmax(smin(mesh("body"), fmax(e, rg), blend), rg)
|
|
454
|
+
fn = model.AddImplicitFunction()
|
|
455
|
+
fn.SetDisplayName(f"{lat['name']} ({lat['kind']})")
|
|
456
|
+
em = _Emitter(lib, fn, images, meshes)
|
|
457
|
+
out = fn.AddOutput("distance", "signed distance, mm", lib.ImplicitPortType.Scalar)
|
|
458
|
+
fn.AddLink(em.port(e), out)
|
|
459
|
+
ls = model.AddLevelSet()
|
|
460
|
+
ls.SetName(f"{name} {lat['name']}")
|
|
461
|
+
ls.SetFunction(fn)
|
|
462
|
+
ls.SetChannelName("distance")
|
|
463
|
+
ls.SetMesh(clip)
|
|
464
|
+
ls.SetMeshBBoxOnly(False)
|
|
465
|
+
ls.SetMinFeatureSize(float(lat.get("minFeature", 0.0)))
|
|
466
|
+
ls.SetFallBackValue(1.0)
|
|
467
|
+
items.append(ls)
|
|
468
|
+
rep["lattices"].append({"name": lat["name"], "as": "levelset", "kind": lat["kind"], "nodes": em.n,
|
|
469
|
+
"images": sorted(images), "blended": blend is not None and bo is not None})
|
|
470
|
+
else:
|
|
471
|
+
V, B, R = lat["vertices"], lat["beams"], lat["radii"]
|
|
472
|
+
pm = strut_prisms(V, B, R, lat["clip"]) if prisms else None
|
|
473
|
+
if merge and not rep["objects"]:
|
|
474
|
+
pm = _union(body, pm)
|
|
475
|
+
rep["objects"].append({"name": f"{name} {lat['name']}", "triangles": int(len(pm.faces)),
|
|
476
|
+
"with": "body"})
|
|
477
|
+
mo = mesh_object(pm, f"{name} {lat['name']}") if pm is not None and len(pm.faces) else None
|
|
478
|
+
if mo is None:
|
|
479
|
+
mo = model.AddMeshObject()
|
|
480
|
+
mo.SetName(f"{name} {lat['name']}")
|
|
481
|
+
nv = mo.GetVertexCount()
|
|
482
|
+
for p in V:
|
|
483
|
+
mo.AddVertex(lib.Position((float(p[0]), float(p[1]), float(p[2]))))
|
|
484
|
+
bl = mo.BeamLattice()
|
|
485
|
+
bl.SetMinLength(1e-4)
|
|
486
|
+
bl.SetClipping(lib.BeamLatticeClipMode.Inside, clip.GetUniqueResourceID())
|
|
487
|
+
beams = []
|
|
488
|
+
for (a, b) in B:
|
|
489
|
+
bm = lib.Beam()
|
|
490
|
+
bm.Indices[0], bm.Indices[1] = int(a) + nv, int(b) + nv
|
|
491
|
+
bm.Radii[0], bm.Radii[1] = float(R[a]), float(R[b])
|
|
492
|
+
bm.CapModes[0] = bm.CapModes[1] = lib.BeamLatticeCapMode.Sphere
|
|
493
|
+
beams.append(bm)
|
|
494
|
+
bl.SetBeams(beams)
|
|
495
|
+
items.append(mo)
|
|
496
|
+
rep["lattices"].append({"name": lat["name"], "as": "beamlattice", "kind": lat["kind"],
|
|
497
|
+
"beams": int(len(B)), "nodes": int(len(V)),
|
|
498
|
+
"prismTriangles": int(len(pm.faces)) if pm is not None else 0})
|
|
499
|
+
for it in items:
|
|
500
|
+
model.AddBuildItem(it, ident)
|
|
501
|
+
wr = model.QueryWriter("3mf")
|
|
502
|
+
wr.WriteToFile(path)
|
|
503
|
+
if any(x["as"] == "levelset" for x in rep["lattices"]):
|
|
504
|
+
_require_volumetric(path)
|
|
505
|
+
rep["bytes"] = os.path.getsize(path)
|
|
506
|
+
if check:
|
|
507
|
+
rep["readBack"] = read_back(path)
|
|
508
|
+
return rep
|
|
509
|
+
|
|
510
|
+
|
|
511
|
+
def _require_volumetric(path):
|
|
512
|
+
"""Mark the volumetric extension required as well as the implicit one:
|
|
513
|
+
the spec says a producer of a level set MUST, and lib3mf 2.5 marks
|
|
514
|
+
only the implicit namespace."""
|
|
515
|
+
import re
|
|
516
|
+
import zipfile
|
|
517
|
+
|
|
518
|
+
tmp = path + ".tmp"
|
|
519
|
+
with zipfile.ZipFile(path) as zi, zipfile.ZipFile(tmp, "w", zipfile.ZIP_DEFLATED) as zo:
|
|
520
|
+
for it in zi.infolist():
|
|
521
|
+
data = zi.read(it.filename)
|
|
522
|
+
if it.filename == "3D/3dmodel.model":
|
|
523
|
+
head, rest = data[:4096].decode("utf-8"), data[4096:]
|
|
524
|
+
|
|
525
|
+
def fix(m):
|
|
526
|
+
req = m.group(1).split()
|
|
527
|
+
return 'requiredextensions="' + " ".join(dict.fromkeys(["v", *req])) + '"'
|
|
528
|
+
|
|
529
|
+
head = re.sub(r'requiredextensions="([^"]*)"', fix, head, count=1)
|
|
530
|
+
data = head.encode("utf-8") + rest
|
|
531
|
+
zo.writestr(it, data)
|
|
532
|
+
os.replace(tmp, path)
|
|
533
|
+
|
|
534
|
+
|
|
535
|
+
def _image3d(lib, model, nm, grid, off, scale):
|
|
536
|
+
"""grid[z, y, x] in [0, 1] as a 16-bit greyscale image stack; returns
|
|
537
|
+
its function (the red channel, scaled and offset back to the values)."""
|
|
538
|
+
from PIL import Image
|
|
539
|
+
|
|
540
|
+
nz, ny, nx = grid.shape
|
|
541
|
+
st = model.AddImageStack(nx, ny, nz) # columns (u = x), rows (v = y), sheets (w = z)
|
|
542
|
+
st.SetColumnCount(nx)
|
|
543
|
+
st.SetRowCount(ny)
|
|
544
|
+
for k in range(nz):
|
|
545
|
+
im = Image.fromarray(np.round(np.clip(grid[k], 0, 1) * 65535).astype(np.uint16), mode="I;16")
|
|
546
|
+
buf = io.BytesIO()
|
|
547
|
+
im.save(buf, format="PNG")
|
|
548
|
+
st.CreateSheetFromBuffer(k, f"/3D/volumetric/{nm}/sheet{k:04d}.png", list(buf.getvalue()))
|
|
549
|
+
f = model.AddFunctionFromImage3D(st)
|
|
550
|
+
f.SetDisplayName(nm)
|
|
551
|
+
f.SetFilter(lib.TextureFilter.Linear)
|
|
552
|
+
f.SetTileStyles(lib.TextureTileStyle.Clamp, lib.TextureTileStyle.Clamp, lib.TextureTileStyle.Clamp)
|
|
553
|
+
f.SetOffset(float(off))
|
|
554
|
+
f.SetScale(float(scale))
|
|
555
|
+
return f
|
|
556
|
+
|
|
557
|
+
|
|
558
|
+
def read_back(path):
|
|
559
|
+
"""Read the file with lib3mf and count what is in it."""
|
|
560
|
+
lib = _need()
|
|
561
|
+
w = lib.get_wrapper()
|
|
562
|
+
m = w.CreateModel()
|
|
563
|
+
m.QueryReader("3mf").ReadFromFile(path)
|
|
564
|
+
out = {"meshes": 0, "triangles": 0, "beams": 0, "levelsets": 0, "functions": 0, "build": 0}
|
|
565
|
+
it = m.GetMeshObjects()
|
|
566
|
+
while it.MoveNext():
|
|
567
|
+
mo = it.GetCurrentMeshObject()
|
|
568
|
+
out["meshes"] += 1
|
|
569
|
+
out["triangles"] += mo.GetTriangleCount()
|
|
570
|
+
out["beams"] += mo.BeamLattice().GetBeamCount()
|
|
571
|
+
for get, key in ((m.GetLevelSets, "levelsets"), (m.GetFunctions, "functions"), (m.GetBuildItems, "build")):
|
|
572
|
+
it = get()
|
|
573
|
+
while it.MoveNext():
|
|
574
|
+
out[key] += 1
|
|
575
|
+
return out
|
|
576
|
+
|
|
577
|
+
|
|
578
|
+
# ---- from a field graph
|
|
579
|
+
|
|
580
|
+
def _grid(lo, hi, h):
|
|
581
|
+
lo, hi = np.asarray(lo, float), np.asarray(hi, float)
|
|
582
|
+
n = np.maximum(np.ceil((hi - lo) / h).astype(int), 1)
|
|
583
|
+
ax = [lo[i] + (np.arange(n[i]) + 0.5) * h for i in range(3)]
|
|
584
|
+
Z, Y, Xg = np.meshgrid(ax[2], ax[1], ax[0], indexing="ij")
|
|
585
|
+
return np.stack([Xg.ravel(), Y.ravel(), Z.ravel()], axis=1), Z.shape, n * h
|
|
586
|
+
|
|
587
|
+
|
|
588
|
+
def lattice_from_graph(g, name, lo, hi, grid=None):
|
|
589
|
+
"""A lattice node of FieldGraph `g` as the writer's dict: the TPMS
|
|
590
|
+
expression, what a field grades in it sampled over lo..hi into 3D images
|
|
591
|
+
at `grid` mm (default the smaller of cell / 6 and 1 mm); or the beams
|
|
592
|
+
tiled over lo..hi with a radius per node. Raises ValueError for what the
|
|
593
|
+
formats cannot say."""
|
|
594
|
+
from .struts import canonical, is_strut
|
|
595
|
+
|
|
596
|
+
nd = g.nodes[name]
|
|
597
|
+
s, r = nd.spec, nd.refs
|
|
598
|
+
kind = s.get("type", "gyroid")
|
|
599
|
+
tp = {"gyroid": "gyroid", "diamond": "diamond", "schwarz-p": "schwarz-p", "schwarz_p": "schwarz-p",
|
|
600
|
+
"schwarzP": "schwarz-p"}.get(kind)
|
|
601
|
+
strut = is_strut(kind)
|
|
602
|
+
ftype = (s.get("frame") or {}).get("type", "cartesian")
|
|
603
|
+
graded = isinstance(r.get("cell"), str)
|
|
604
|
+
if tp is None and not strut:
|
|
605
|
+
raise ValueError(f"lattice {name!r}: unknown lattice type {kind!r}")
|
|
606
|
+
if strut and canonical(kind) == "voronoi":
|
|
607
|
+
raise ValueError(f"lattice {name!r}: a Voronoi foam does not go into 3MF as a beam lattice")
|
|
608
|
+
if strut and (graded or ftype != "cartesian" or "offset" in s):
|
|
609
|
+
raise ValueError(f"lattice {name!r}: a strut lattice goes into 3MF with a constant cell in the "
|
|
610
|
+
"cartesian frame and no offset")
|
|
611
|
+
if ftype in ("cylindrical", "spherical"):
|
|
612
|
+
raise ValueError(f"lattice {name!r}: a {ftype} frame's coordinates wrap round, which a 3D image "
|
|
613
|
+
"cannot sample; ship the STL")
|
|
614
|
+
if graded and s.get("grading", "warp") != "warp":
|
|
615
|
+
raise ValueError(f"lattice {name!r}: blend grading does not go into 3MF (use the default warp)")
|
|
616
|
+
origin = tuple(float(v) for v in s.get("origin", (0, 0, 0)))
|
|
617
|
+
lo, hi = np.asarray(lo, float), np.asarray(hi, float)
|
|
618
|
+
cell0 = float(s["cell"]) if isinstance(s.get("cell"), (int, float)) else None
|
|
619
|
+
|
|
620
|
+
def local_cell(P):
|
|
621
|
+
if cell0 is not None:
|
|
622
|
+
return np.full(len(P), cell0)
|
|
623
|
+
return np.broadcast_to(np.asarray(g._val(r["cell"], P, {}), float), (len(P),))
|
|
624
|
+
|
|
625
|
+
def wall(P):
|
|
626
|
+
memo = {}
|
|
627
|
+
if "thickness" in r:
|
|
628
|
+
return np.broadcast_to(np.asarray(g._val(r["thickness"], P, memo), float), (len(P),))
|
|
629
|
+
if "density" in r:
|
|
630
|
+
rho = np.clip(np.broadcast_to(np.asarray(g._val(r["density"], P, memo), float), (len(P),)),
|
|
631
|
+
0.02 if strut else 0.05, 0.95)
|
|
632
|
+
if strut:
|
|
633
|
+
from .struts import strut_diameter_for_density
|
|
634
|
+
|
|
635
|
+
return strut_diameter_for_density(kind, rho, local_cell(P))
|
|
636
|
+
from .lattice import wall_for_density
|
|
637
|
+
|
|
638
|
+
return wall_for_density(tp, rho, local_cell(P))
|
|
639
|
+
return np.ones(len(P))
|
|
640
|
+
|
|
641
|
+
out = {"name": name, "kind": tp or canonical(kind)}
|
|
642
|
+
if strut:
|
|
643
|
+
V, B = lattice_beams(kind, cell0 or 8.0, lo, hi, origin, s.get("aspect", (1, 1, 1)))
|
|
644
|
+
out.update(vertices=V, beams=B, radii=0.5 * wall(V), cell=cell0 or 8.0)
|
|
645
|
+
return out
|
|
646
|
+
|
|
647
|
+
images = {}
|
|
648
|
+
cmin = cell0 or float(np.min(local_cell(_grid(lo, hi, 2.0)[0])))
|
|
649
|
+
# a conformal frame's phases turn faster than one cycle a cell where the
|
|
650
|
+
# pattern bends, and its layer coordinate kinks on the medial axis:
|
|
651
|
+
# interpolated at cell / 8 the part's inside/outside differs from the
|
|
652
|
+
# field over 2.7% of the volume (tube, box), at cell / 12 1.4%, cell / 16
|
|
653
|
+
# 0.9%; cell / 12 (8x the voxels of a graded cell's images)
|
|
654
|
+
h = float(grid or min(cmin / (12.0 if ftype == "conformal" else 6.0), 1.0))
|
|
655
|
+
lo, hi = lo - h, hi + h # a voxel of margin: clamped samples hold still past the outer voxel centres
|
|
656
|
+
P, shape, size = _grid(lo, hi, h)
|
|
657
|
+
|
|
658
|
+
def field(tag, v, const_ok=True):
|
|
659
|
+
"""v at the grid: a constant, or an image expression."""
|
|
660
|
+
v = np.asarray(v, float).reshape(shape)
|
|
661
|
+
if const_ok and float(np.ptp(v)) <= 1e-6 * max(float(np.abs(v).max()), 1e-9):
|
|
662
|
+
return float(v.flat[0]), None
|
|
663
|
+
q, off, span = _quantize(v)
|
|
664
|
+
images[f"{name}-{tag}"] = (q, off, span)
|
|
665
|
+
return image(f"{name}-{tag}", lo, size), {"min": round(float(v.min()), 4), "max": round(float(v.max()), 4)}
|
|
666
|
+
|
|
667
|
+
if ftype == "conformal":
|
|
668
|
+
# the two in-plane coordinates are phases (mod 1): an image of the
|
|
669
|
+
# phase jumps by a cycle at every wrap and trilinear sampling
|
|
670
|
+
# smears it across a voxel, so ship each one's sine and cosine
|
|
671
|
+
# (renormalized after sampling) and the layer coordinate as is;
|
|
672
|
+
# the defect fill (solid nodes at phase defects) as its own image
|
|
673
|
+
# (a frame field's layer coordinate is a phase too: all three)
|
|
674
|
+
memo = {}
|
|
675
|
+
U, cl = g.lattice_coords(nd, P, memo)
|
|
676
|
+
fill = memo.get(("_defect_fill", nd.name))
|
|
677
|
+
nphase = 3 if g._resource(nd, lambda: g._conformal(nd)).frame_field else 2
|
|
678
|
+
trig = []
|
|
679
|
+
for i in range(nphase):
|
|
680
|
+
se = field(f"s{i}", np.sin(2 * np.pi * U[:, i]), const_ok=False)[0]
|
|
681
|
+
ce = field(f"c{i}", np.cos(2 * np.pi * U[:, i]), const_ok=False)[0]
|
|
682
|
+
nrm = fmax(sqrt(se * se + ce * ce), 1e-3)
|
|
683
|
+
trig.append((se / nrm, ce / nrm))
|
|
684
|
+
if nphase == 2:
|
|
685
|
+
u2 = field("u2", U[:, 2], const_ok=False)[0] * (2.0 * np.pi)
|
|
686
|
+
trig.append((sin(u2), cos(u2)))
|
|
687
|
+
cexp, cinfo = field("cell", cl)
|
|
688
|
+
d = tpms_unit_trig(tp, trig)
|
|
689
|
+
if fill is not None:
|
|
690
|
+
fexp, _ = field("fill", fill)
|
|
691
|
+
d = d * fexp
|
|
692
|
+
if "aspect" in s:
|
|
693
|
+
d = d * float(np.min(s["aspect"]))
|
|
694
|
+
d = d * cexp
|
|
695
|
+
out["cell"] = cinfo or round(cexp, 4)
|
|
696
|
+
out["coordinates"] = {"grid": h, "voxels": list(shape[::-1]), "phases": "sin/cos"}
|
|
697
|
+
elif graded:
|
|
698
|
+
# the lattice's own coordinates (cell units) and its cell, as images
|
|
699
|
+
U, cl = g.lattice_coords(nd, P)
|
|
700
|
+
Ux = [field(f"u{i}", U[:, i], const_ok=False)[0] for i in range(3)]
|
|
701
|
+
cexp, cinfo = field("cell", cl)
|
|
702
|
+
d = tpms_unit(tp, *Ux)
|
|
703
|
+
if "aspect" in s:
|
|
704
|
+
d = d * float(np.min(s["aspect"]))
|
|
705
|
+
d = d * cexp
|
|
706
|
+
out["cell"] = cinfo or round(cexp, 4)
|
|
707
|
+
out["coordinates"] = {"grid": h, "voxels": list(shape[::-1])}
|
|
708
|
+
else:
|
|
709
|
+
if "aspect" in s:
|
|
710
|
+
a = np.asarray(s["aspect"], float)
|
|
711
|
+
Uc = [(POS[ax] - o) / (cell0 * a_) for ax, o, a_ in zip("xyz", origin, a)]
|
|
712
|
+
d = tpms_unit(tp, *Uc) * float(np.min(a)) * cell0
|
|
713
|
+
else:
|
|
714
|
+
Uc = [(POS[ax] - o) / cell0 for ax, o in zip("xyz", origin)]
|
|
715
|
+
d = tpms_unit(tp, *Uc) * cell0
|
|
716
|
+
out["cell"] = cell0
|
|
717
|
+
if "offset" in r:
|
|
718
|
+
oexp, _ = field("offset", np.broadcast_to(np.asarray(g._val(r["offset"], P, {}), float), (len(P),)))
|
|
719
|
+
d = d - oexp
|
|
720
|
+
t = wall(P)
|
|
721
|
+
texp, tinfo = field("wall", t)
|
|
722
|
+
out["thickness"] = tinfo or round(texp, 4)
|
|
723
|
+
out["expr"] = _wall(d, texp, s.get("form", "sheet"))
|
|
724
|
+
out["images"] = images
|
|
725
|
+
out["minFeature"] = round(0.5 * float(np.min(t)), 4)
|
|
726
|
+
return out
|
|
727
|
+
|
|
728
|
+
|
|
729
|
+
def blend_of(g, name):
|
|
730
|
+
"""The blend radius where the graph unions lattice `name` into the
|
|
731
|
+
rest: the first blended union with an input that depends on the lattice
|
|
732
|
+
and one that does not. (number, or a node name, or None)."""
|
|
733
|
+
memo = {}
|
|
734
|
+
|
|
735
|
+
def deps(n):
|
|
736
|
+
if n in memo:
|
|
737
|
+
return memo[n]
|
|
738
|
+
memo[n] = False
|
|
739
|
+
nd = g.nodes.get(n)
|
|
740
|
+
if nd is None:
|
|
741
|
+
return False
|
|
742
|
+
if n == name:
|
|
743
|
+
memo[n] = True
|
|
744
|
+
return True
|
|
745
|
+
hit = False
|
|
746
|
+
for v in (nd.refs or {}).values():
|
|
747
|
+
for x in (v if isinstance(v, list) else v.values() if isinstance(v, dict) else [v]):
|
|
748
|
+
if isinstance(x, str) and deps(x):
|
|
749
|
+
hit = True
|
|
750
|
+
memo[n] = hit
|
|
751
|
+
return hit
|
|
752
|
+
|
|
753
|
+
for nd in g.nodes.values():
|
|
754
|
+
if nd.op != "union" or "blend" not in nd.spec:
|
|
755
|
+
continue
|
|
756
|
+
ins = [x for x in nd.refs.get("inputs", []) if isinstance(x, str)]
|
|
757
|
+
with_ = [deps(x) for x in ins]
|
|
758
|
+
if any(with_) and not all(with_):
|
|
759
|
+
return nd.refs["blend"]
|
|
760
|
+
return None
|