loadpath 0.1.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (220) hide show
  1. loadpath/README.md +294 -0
  2. loadpath/SKILL.md +584 -0
  3. loadpath/__init__.py +17 -0
  4. loadpath/__main__.py +1202 -0
  5. loadpath/am.py +316 -0
  6. loadpath/amgfast.py +326 -0
  7. loadpath/assembly.py +985 -0
  8. loadpath/benchmarks/__init__.py +0 -0
  9. loadpath/benchmarks/beams.py +266 -0
  10. loadpath/benchmarks/cases/ge-bracket.json +380 -0
  11. loadpath/benchmarks/evaluate.py +172 -0
  12. loadpath/benchmarks/mechanisms.py +416 -0
  13. loadpath/benchmarks/problems.py +140 -0
  14. loadpath/benchmarks/reference/bridge-3d.top3d.npz +0 -0
  15. loadpath/benchmarks/reference/cantilever-3d.top3d.npz +0 -0
  16. loadpath/benchmarks/reference/lbracket-2d.top88-ft2.npz +0 -0
  17. loadpath/benchmarks/reference/mbb-2d.top88-ft1.npz +0 -0
  18. loadpath/benchmarks/reference/mbb-2d.top88-ft2.npz +0 -0
  19. loadpath/benchmarks/reference.py +138 -0
  20. loadpath/benchmarks/run.py +301 -0
  21. loadpath/brep.py +1470 -0
  22. loadpath/bucklingopt.py +300 -0
  23. loadpath/buildsim.py +1490 -0
  24. loadpath/cadimport.py +904 -0
  25. loadpath/calibration.py +885 -0
  26. loadpath/cavities.py +211 -0
  27. loadpath/checks/__init__.py +0 -0
  28. loadpath/checks/dfm.py +354 -0
  29. loadpath/checks/exchanger.py +7 -0
  30. loadpath/checks/fea.py +212 -0
  31. loadpath/checks/fit.py +475 -0
  32. loadpath/checks/networks.py +260 -0
  33. loadpath/checks/opening.py +67 -0
  34. loadpath/checks/sdf_kit.py +7 -0
  35. loadpath/conjugate.py +267 -0
  36. loadpath/constrained.py +329 -0
  37. loadpath/contact.py +691 -0
  38. loadpath/coopt.py +342 -0
  39. loadpath/cores.py +163 -0
  40. loadpath/crease.py +1138 -0
  41. loadpath/critic.py +212 -0
  42. loadpath/crosscheck.py +1107 -0
  43. loadpath/cutcell.py +510 -0
  44. loadpath/data/lattice_buckling.json +439 -0
  45. loadpath/data/lattice_tensors.json +27449 -0
  46. loadpath/data/published_experiments.json +71 -0
  47. loadpath/decimate.py +177 -0
  48. loadpath/delivery.py +190 -0
  49. loadpath/directions.py +262 -0
  50. loadpath/domain.py +630 -0
  51. loadpath/drains.py +615 -0
  52. loadpath/durability.py +413 -0
  53. loadpath/editor.html +3135 -0
  54. loadpath/editor.py +610 -0
  55. loadpath/escape.py +354 -0
  56. loadpath/evals/__init__.py +0 -0
  57. loadpath/evals/baselines/coarse.json +1543 -0
  58. loadpath/evals/baselines/full.json +1497 -0
  59. loadpath/evals/brief_kinds/__init__.py +1 -0
  60. loadpath/evals/brief_kinds/heatx.py +387 -0
  61. loadpath/evals/brief_kinds/lattice.py +571 -0
  62. loadpath/evals/brief_kinds/multimat.py +593 -0
  63. loadpath/evals/briefs/assets/bracket.step +1201 -0
  64. loadpath/evals/briefs/assets/desk_arm.urdf +22 -0
  65. loadpath/evals/briefs/assets/elbow-gearbox.stl +0 -0
  66. loadpath/evals/briefs/assets/motor-body.stl +0 -0
  67. loadpath/evals/briefs/assets/sketch.png +0 -0
  68. loadpath/evals/briefs/base-adapter-fdm.json +492 -0
  69. loadpath/evals/briefs/bell-crank-lpbf.json +180 -0
  70. loadpath/evals/briefs/belt-tensioner-fdm.json +151 -0
  71. loadpath/evals/briefs/camera-boom-mjf.json +339 -0
  72. loadpath/evals/briefs/camera-mount-pla.json +203 -0
  73. loadpath/evals/briefs/coolant-isolator-petg.json +100 -0
  74. loadpath/evals/briefs/elbow-bearing-bracket-sls.json +300 -0
  75. loadpath/evals/briefs/elbow-gearbox-forearm-mjf.json +558 -0
  76. loadpath/evals/briefs/elbow-link-fatigue-fdm.json +251 -0
  77. loadpath/evals/briefs/elbow-stop-pad-tpu.json +39 -0
  78. loadpath/evals/briefs/forearm-fdm.json +146 -0
  79. loadpath/evals/briefs/forearm-servo-link-fdm.json +327 -0
  80. loadpath/evals/briefs/grip-pad-finger-mm.json +354 -0
  81. loadpath/evals/briefs/gripper-bracket-creep-fdm.json +340 -0
  82. loadpath/evals/briefs/gripper-finger-fdm.json +155 -0
  83. loadpath/evals/briefs/gripper-jaw-fatigue-mjf.json +328 -0
  84. loadpath/evals/briefs/gripper-palm-fdm.json +715 -0
  85. loadpath/evals/briefs/hot-stepper-mount-asa-fdm.json +471 -0
  86. loadpath/evals/briefs/hpu-oil-cooler-lpbf.json +121 -0
  87. loadpath/evals/briefs/isolated-camera-mount-mm.json +675 -0
  88. loadpath/evals/briefs/kart-charge-cooler-lpbf.json +93 -0
  89. loadpath/evals/briefs/lighten-step-bracket.json +220 -0
  90. loadpath/evals/briefs/nema17-mount-stl.json +429 -0
  91. loadpath/evals/briefs/pillow-block-sls.json +220 -0
  92. loadpath/evals/briefs/polyjet-fingertip-mm.json +305 -0
  93. loadpath/evals/briefs/rod-hook-pla.json +154 -0
  94. loadpath/evals/briefs/sensor-arm-resin.json +150 -0
  95. loadpath/evals/briefs/shelf-bracket-pla.json +158 -0
  96. loadpath/evals/briefs/shoulder-riser-mjf.json +487 -0
  97. loadpath/evals/briefs/sketch-clevis.json +229 -0
  98. loadpath/evals/briefs/spring-bracket-pacf.json +294 -0
  99. loadpath/evals/briefs/tool-flange-ti64.json +280 -0
  100. loadpath/evals/briefs/tray-arm-lattice-fdm.json +260 -0
  101. loadpath/evals/briefs/turret-fdm.json +281 -0
  102. loadpath/evals/briefs/upper-arm-sls.json +179 -0
  103. loadpath/evals/briefs/urdf-forearm.json +233 -0
  104. loadpath/evals/briefs/wrist-link-mjf.json +278 -0
  105. loadpath/evals/briefs/wrist-yoke-mjf.json +245 -0
  106. loadpath/evals/briefs.py +393 -0
  107. loadpath/evals/cases/arm-link-cast.json +157 -0
  108. loadpath/evals/cases/arm-link-driven.json +171 -0
  109. loadpath/evals/cases/arm-link-sandwich.json +315 -0
  110. loadpath/evals/cases/arm-link.json +111 -0
  111. loadpath/evals/cases/bell-crank.json +116 -0
  112. loadpath/evals/cases/grip-turret.json +479 -0
  113. loadpath/evals/cases/grip-upper-arm.json +253 -0
  114. loadpath/evals/cases/grip-wrist-link-mjf.json +929 -0
  115. loadpath/evals/cases/grip-wrist-yoke.json +831 -0
  116. loadpath/evals/cases/lpbf-intercooler-core.json +59 -0
  117. loadpath/evals/cases/mbb-beam.json +61 -0
  118. loadpath/evals/cases/motor-body.stl +0 -0
  119. loadpath/evals/cases/motor-mount.json +265 -0
  120. loadpath/evals/cases/printed-hook.json +77 -0
  121. loadpath/evals/cases/wall-bracket-fasteners.json +125 -0
  122. loadpath/evals/cases/wall-bracket.json +126 -0
  123. loadpath/evals/corpus.py +179 -0
  124. loadpath/evals/pareto.py +90 -0
  125. loadpath/evals/run.py +860 -0
  126. loadpath/examples/arm-link.json +25 -0
  127. loadpath/examples/bumpered-bracket-compression.json +225 -0
  128. loadpath/examples/bumpered-bracket-lattice.json +204 -0
  129. loadpath/examples/bumpered-bracket.json +34 -0
  130. loadpath/examples/gripper-finger-mm.json +185 -0
  131. loadpath/examples/heat-exchanger-counterflow.json +21 -0
  132. loadpath/examples/heat-exchanger-crossflow-tuned.json +48 -0
  133. loadpath/examples/heat-exchanger-crossflow.json +20 -0
  134. loadpath/examples/pedal-lattice.json +23 -0
  135. loadpath/examples/pressure-plenum.json +12 -0
  136. loadpath/examples/shelf-bracket.json +27 -0
  137. loadpath/exchanger.py +831 -0
  138. loadpath/fasteners.py +199 -0
  139. loadpath/fem.py +668 -0
  140. loadpath/femops.py +70 -0
  141. loadpath/fieldjit.py +981 -0
  142. loadpath/fieldops.py +119 -0
  143. loadpath/fields.py +3697 -0
  144. loadpath/fieldtorch.py +464 -0
  145. loadpath/flow.py +984 -0
  146. loadpath/flowtopo.py +300 -0
  147. loadpath/gpu.py +521 -0
  148. loadpath/heatx.py +1067 -0
  149. loadpath/heatx_thermal.py +432 -0
  150. loadpath/homogenized.py +886 -0
  151. loadpath/ibrep.py +552 -0
  152. loadpath/impact.py +693 -0
  153. loadpath/infill.py +376 -0
  154. loadpath/interface_library.py +340 -0
  155. loadpath/interlock.py +186 -0
  156. loadpath/isolator.py +149 -0
  157. loadpath/knn.py +204 -0
  158. loadpath/lattice.py +1065 -0
  159. loadpath/lattice3mf.py +760 -0
  160. loadpath/lattice_buckling.py +245 -0
  161. loadpath/lattice_grade.py +325 -0
  162. loadpath/lattice_orient.py +595 -0
  163. loadpath/lattice_print.py +159 -0
  164. loadpath/lattice_tensors.py +558 -0
  165. loadpath/latticestep.py +1210 -0
  166. loadpath/manufacture.py +446 -0
  167. loadpath/material_library.py +489 -0
  168. loadpath/materials.py +144 -0
  169. loadpath/mcp_server.py +786 -0
  170. loadpath/mechanism.py +218 -0
  171. loadpath/mechanism_nl.py +811 -0
  172. loadpath/meshops.py +452 -0
  173. loadpath/mg.py +853 -0
  174. loadpath/mma.py +214 -0
  175. loadpath/modal.py +210 -0
  176. loadpath/multimat.py +1788 -0
  177. loadpath/narrowband.py +418 -0
  178. loadpath/nlkernel.py +241 -0
  179. loadpath/nonlinear.py +1095 -0
  180. loadpath/orient.py +334 -0
  181. loadpath/porous.py +994 -0
  182. loadpath/pressure.py +171 -0
  183. loadpath/preview.py +335 -0
  184. loadpath/printcheck.py +263 -0
  185. loadpath/printsetup.py +207 -0
  186. loadpath/processcheck.py +376 -0
  187. loadpath/quick.py +113 -0
  188. loadpath/recall.py +406 -0
  189. loadpath/reconstruct.py +797 -0
  190. loadpath/render.py +436 -0
  191. loadpath/resolved.py +189 -0
  192. loadpath/schema.py +1701 -0
  193. loadpath/sdf_kit.py +843 -0
  194. loadpath/selfx.py +292 -0
  195. loadpath/shapes.py +584 -0
  196. loadpath/sharp.py +717 -0
  197. loadpath/simp.py +1170 -0
  198. loadpath/sizing.py +312 -0
  199. loadpath/slicer.py +321 -0
  200. loadpath/spec.py +1072 -0
  201. loadpath/step.py +806 -0
  202. loadpath/struts.py +639 -0
  203. loadpath/study.py +1937 -0
  204. loadpath/tasks.py +822 -0
  205. loadpath/thermal.py +1691 -0
  206. loadpath/threemf.py +487 -0
  207. loadpath/tune.py +437 -0
  208. loadpath/validation.py +145 -0
  209. loadpath/verify.py +1215 -0
  210. loadpath/viewer.html +694 -0
  211. loadpath/viewer.py +446 -0
  212. loadpath/viscoelastic.py +106 -0
  213. loadpath/voxelprint.py +582 -0
  214. loadpath/voxels.py +402 -0
  215. loadpath-0.1.1.dist-info/METADATA +319 -0
  216. loadpath-0.1.1.dist-info/RECORD +220 -0
  217. loadpath-0.1.1.dist-info/WHEEL +5 -0
  218. loadpath-0.1.1.dist-info/entry_points.txt +2 -0
  219. loadpath-0.1.1.dist-info/licenses/LICENSE +21 -0
  220. loadpath-0.1.1.dist-info/top_level.txt +1 -0
loadpath/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