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/struts.py ADDED
@@ -0,0 +1,639 @@
1
+ """
2
+ Strut (beam) lattices as distance fields: the periodic truss cells nTop and
3
+ every AM lattice library ship (simple cubic, BCC, BCCz, FCC, FCCz, octet,
4
+ Kelvin, diamond), and stochastic Voronoi foams.
5
+
6
+ A strut lattice is a set of segments; the solid is every point within
7
+ `thickness / 2` of one, so its field is the exact distance to the nearest
8
+ segment less the radius (Lipschitz 1, which the narrow-band mesher and the
9
+ Newton projection both like). The radius can vary per point, so a strut
10
+ thickens where a field says, and `blend` rounds every node with a smooth
11
+ minimum (nTop's node fillets): thin struts break at their nodes first.
12
+
13
+ Periodic cells are evaluated in cell units (period 1). A point is folded
14
+ into the cell's fundamental region first, by the mirrors and axis swaps the
15
+ cell is invariant under (found numerically, so a new cell needs no hand
16
+ work), and only the few segments that can be nearest to that region are
17
+ tested. For the cubic cells that is 1/48 of the cell and a handful of
18
+ segments; BCCz and FCCz keep z special (no swaps with z), diamond has no
19
+ mirrors through its nodes, so it tests more.
20
+
21
+ Voronoi foams are not periodic: seeds are scattered over a box at a mean
22
+ spacing that may vary (a cell field: denser seeds where the cell is small),
23
+ relaxed a few steps so the cells are even, and the Voronoi edges are the
24
+ struts. A periodic Voronoi unit cell (seeds tiled 3x3x3) stands in for the
25
+ foam in homogenization.
26
+
27
+ Strut directions matter for FDM: a strut is self-supporting when it rises
28
+ at least `angle` (45 degrees) from the bed; flatter ones are bridges.
29
+ `overhang_report` says how much strut length bridges, for a cell in any
30
+ orientation, or through any coordinate map (graded or conformal frames).
31
+ """
32
+ from __future__ import annotations
33
+
34
+ import itertools
35
+ from functools import lru_cache
36
+
37
+ import numpy as np
38
+
39
+ __all__ = ["STRUT_KINDS", "is_strut", "cell_segments", "strut_distance", "strut_density_table",
40
+ "strut_relative_density", "strut_diameter_for_density", "VoronoiStruts", "overhang_report",
41
+ "unit_cell_distance", "best_orientation", "lattice_printability"]
42
+
43
+ _CHUNK = 65_536
44
+
45
+
46
+ def _fcc():
47
+ return [(0, 0, 0), (0.5, 0.5, 0), (0.5, 0, 0.5), (0, 0.5, 0.5)]
48
+
49
+
50
+ def _nodes_pairs(nodes, length, tol=1e-6):
51
+ """Every pair of the periodic node set (`nodes` in [0,1)^3, tiled) at
52
+ `length` apart, as segments with the first end in the home cell."""
53
+ base = np.asarray(nodes, float)
54
+ offs = np.array(list(itertools.product((-1, 0, 1), repeat=3)), float)
55
+ allp = (base[None] + offs[:, None]).reshape(-1, 3)
56
+ seg = []
57
+ for a in base:
58
+ d = np.linalg.norm(allp - a, axis=1)
59
+ for b in allp[np.abs(d - length) < tol]:
60
+ seg.append((a, b))
61
+ return seg
62
+
63
+
64
+ def _axis_edges(axis):
65
+ """Cube edges along one axis (period 1: one per cell)."""
66
+ e = np.zeros(3)
67
+ e["xyz".index(axis)] = 1.0
68
+ return [(np.zeros(3), e)]
69
+
70
+
71
+ def _kelvin():
72
+ # bitruncated cubic honeycomb: truncated octahedra on a BCC lattice,
73
+ # vertices the permutations of (0, +-1/4, +-1/2) round each centre
74
+ v = set()
75
+ for p in set(itertools.permutations((0, 1, 2))):
76
+ for s1 in (-1, 1):
77
+ for s2 in (-1, 1):
78
+ q = np.zeros(3)
79
+ q[p[1]] = 0.25 * s1
80
+ q[p[2]] = 0.5 * s2
81
+ for c in ((0, 0, 0), (0.5, 0.5, 0.5)):
82
+ w = tuple(np.round((q + c) % 1.0, 6))
83
+ v.add(w)
84
+ return _nodes_pairs(sorted(v), np.sqrt(2) / 4)
85
+
86
+
87
+ def _diamond():
88
+ nodes = _fcc() + [tuple(np.add(p, 0.25)) for p in _fcc()]
89
+ return _nodes_pairs(nodes, np.sqrt(3) / 4)
90
+
91
+
92
+ # segments of each unit cell, period 1
93
+ _CELLS = {
94
+ "cubic": lambda: _axis_edges("x") + _axis_edges("y") + _axis_edges("z"),
95
+ "bcc": lambda: _nodes_pairs([(0, 0, 0), (0.5, 0.5, 0.5)], np.sqrt(3) / 2),
96
+ "bccz": lambda: _nodes_pairs([(0, 0, 0), (0.5, 0.5, 0.5)], np.sqrt(3) / 2) + _axis_edges("z"),
97
+ "fcc": lambda: [s for s in _nodes_pairs(_fcc(), np.sqrt(2) / 2)
98
+ if np.count_nonzero(np.abs(np.asarray(s[0]) % 1.0) < 1e-9) >= 2
99
+ or np.count_nonzero(np.abs(np.asarray(s[1]) % 1.0) < 1e-9) >= 2],
100
+ "fccz": lambda: [s for s in _nodes_pairs(_fcc(), np.sqrt(2) / 2)
101
+ if np.count_nonzero(np.abs(np.asarray(s[0]) % 1.0) < 1e-9) >= 2
102
+ or np.count_nonzero(np.abs(np.asarray(s[1]) % 1.0) < 1e-9) >= 2] + _axis_edges("z"),
103
+ "octet": lambda: _nodes_pairs(_fcc(), np.sqrt(2) / 2),
104
+ "kelvin": _kelvin,
105
+ "diamond-strut": _diamond,
106
+ }
107
+ STRUT_KINDS = tuple(_CELLS) + ("voronoi",)
108
+ _ALIASES = {"simple-cubic": "cubic", "bcc-z": "bccz", "fcc-z": "fccz", "octet-truss": "octet",
109
+ "tetrakaidecahedron": "kelvin", "diamond-beam": "diamond-strut", "strut-diamond": "diamond-strut"}
110
+
111
+
112
+ def canonical(kind):
113
+ k = str(kind).lower()
114
+ return _ALIASES.get(k, k)
115
+
116
+
117
+ def is_strut(kind) -> bool:
118
+ return canonical(kind) in STRUT_KINDS
119
+
120
+
121
+ def _dedupe(segs):
122
+ """Unique segments (either direction)."""
123
+ out, seen = [], set()
124
+ for a, b in segs:
125
+ a, b = np.asarray(a, float), np.asarray(b, float)
126
+ key = tuple(np.round(np.concatenate([a, b]), 6)), tuple(np.round(np.concatenate([b, a]), 6))
127
+ if key[0] in seen or key[1] in seen:
128
+ continue
129
+ seen.add(key[0])
130
+ out.append((a, b))
131
+ return out
132
+
133
+
134
+ @lru_cache(maxsize=None)
135
+ def cell_segments(kind) -> np.ndarray:
136
+ """(S, 2, 3) the unit cell's struts, each once (period 1), so one cell's
137
+ worth of strut length; translate by whole cells for the rest."""
138
+ kind = canonical(kind)
139
+ if kind == "voronoi":
140
+ return _periodic_voronoi(8, 0)
141
+ segs = _CELLS[kind]()
142
+ # one representative per periodic class: shift each so its midpoint is in [0,1)
143
+ reps = []
144
+ for a, b in segs:
145
+ m = 0.5 * (np.asarray(a) + np.asarray(b))
146
+ sh = np.floor(m + 1e-9)
147
+ reps.append((np.asarray(a) - sh, np.asarray(b) - sh))
148
+ return np.asarray(_dedupe(reps), float)
149
+
150
+
151
+ def _seg_dist(P, A, B):
152
+ """(N, S) distance from points to segments."""
153
+ AB = B - A
154
+ L2 = np.maximum(np.einsum("ij,ij->i", AB, AB), 1e-18)
155
+ AP = P[:, None, :] - A[None]
156
+ t = np.clip(np.einsum("nsj,sj->ns", AP, AB) / L2, 0.0, 1.0)
157
+ D = AP - t[..., None] * AB[None]
158
+ return np.sqrt(np.einsum("nsj,nsj->ns", D, D))
159
+
160
+
161
+ def _all_translates(segs, reach=1):
162
+ offs = np.array(list(itertools.product(range(-reach, reach + 1), repeat=3)), float)
163
+ return (segs[None, :, :, :] + offs[:, None, None, :]).reshape(-1, 2, 3)
164
+
165
+
166
+ @lru_cache(maxsize=None)
167
+ def _symmetry(kind):
168
+ """(mirror, swaps) the cell is invariant under, about its corner node:
169
+ mirror x -> -x on every axis (and through the cell's 1/2 planes, which
170
+ follows with period 1), and which axis swaps (all, xy only, none)."""
171
+ segs = _all_translates(cell_segments(kind), 2)
172
+ rng = np.random.default_rng(1)
173
+ P = rng.uniform(-0.5, 0.5, (600, 3))
174
+
175
+ def field(Q):
176
+ return _seg_dist(Q, segs[:, 0], segs[:, 1]).min(axis=1)
177
+
178
+ f0 = field(P)
179
+ mirror = all(np.allclose(field(P * np.where(np.arange(3) == a, -1, 1)), f0, atol=1e-9) for a in range(3))
180
+ swap_xy = np.allclose(field(P[:, [1, 0, 2]]), f0, atol=1e-9)
181
+ swap_xz = np.allclose(field(P[:, [2, 1, 0]]), f0, atol=1e-9)
182
+ return mirror, ("all" if swap_xy and swap_xz else "xy" if swap_xy else "none")
183
+
184
+
185
+ @lru_cache(maxsize=None)
186
+ def _candidates(kind, reach=0.32, aspect=None):
187
+ """Segments that can be nearest to a point of the folded region (within
188
+ `reach` of it, cell units: a strut up to 0.6 cell across is right).
189
+ With a stretched cell (`aspect`, per axis) distances are measured in
190
+ the stretched space and only the mirrors fold (no axis swaps)."""
191
+ mirror, swaps = _symmetry(kind)
192
+ if aspect is not None:
193
+ swaps = "none"
194
+ segs = _all_translates(cell_segments(kind), 2)
195
+ n = 18
196
+ c = (np.arange(n) + 0.5) / n
197
+ if mirror:
198
+ g = np.stack(np.meshgrid(*[0.5 * c] * 3, indexing="ij"), -1).reshape(-1, 3)
199
+ if swaps == "all":
200
+ g = g[(g[:, 0] <= g[:, 1] + 1e-9) & (g[:, 1] <= g[:, 2] + 1e-9)]
201
+ elif swaps == "xy":
202
+ g = g[g[:, 0] <= g[:, 1] + 1e-9]
203
+ h = 0.5 / n
204
+ else:
205
+ g = np.stack(np.meshgrid(*[c - 0.5] * 3, indexing="ij"), -1).reshape(-1, 3)
206
+ h = 1.0 / n
207
+ near = np.zeros(len(segs), bool)
208
+ best = np.full(len(g), np.inf)
209
+ if aspect is not None:
210
+ a = np.asarray(aspect, float)
211
+ segs, g, h = segs * a, g * a, h * float(a.max())
212
+ for i in range(0, len(segs), 64):
213
+ d = _seg_dist(g, segs[i:i + 64, 0], segs[i:i + 64, 1])
214
+ best = np.minimum(best, d.min(axis=1))
215
+ for i in range(0, len(segs), 64):
216
+ d = _seg_dist(g, segs[i:i + 64, 0], segs[i:i + 64, 1])
217
+ # nearest to some sample by less than the cell's reach (plus the
218
+ # sample spacing), or within reach of the strut surface at all
219
+ near[i:i + 64] = (d <= np.minimum(best[:, None] + 2 * h * np.sqrt(3), reach + h * np.sqrt(3))).any(axis=0)
220
+ if aspect is not None:
221
+ segs = segs / np.asarray(aspect, float)
222
+ return segs[near], mirror, swaps
223
+
224
+
225
+ def _fold(U, mirror, swaps):
226
+ f = U - np.round(U)
227
+ if not mirror:
228
+ return f
229
+ f = np.abs(f)
230
+ if swaps == "all":
231
+ f = np.sort(f, axis=1)
232
+ elif swaps == "xy":
233
+ f[:, :2] = np.sort(f[:, :2], axis=1)
234
+ return f
235
+
236
+
237
+ def _smin_many(D, k):
238
+ """Smooth minimum over the columns of D, polynomial, radius k (scalar
239
+ or per row): sharp mins where k is 0."""
240
+ D = np.sort(D, axis=1)
241
+ out = D[:, 0].copy()
242
+ k = np.broadcast_to(np.asarray(k, float), (len(D),))
243
+ if not np.any(k > 0):
244
+ return out
245
+ kk = np.maximum(k, 1e-12)
246
+ for j in range(1, min(D.shape[1], 6)):
247
+ b = D[:, j]
248
+ h = np.clip(0.5 + 0.5 * (b - out) / kk, 0.0, 1.0)
249
+ s = b * (1 - h) + out * h - kk * h * (1 - h)
250
+ out = np.where(k > 0, s, out)
251
+ return out
252
+
253
+
254
+ try:
255
+ import numba as _nb
256
+ except ImportError: # pragma: no cover - numba is a dependency; numpy without it
257
+ _nb = None
258
+
259
+ if _nb is not None:
260
+
261
+ @_nb.njit(cache=True, nogil=True)
262
+ def _strut_kernel(f, A, B, blend, out):
263
+ """_seg_dist + (_smin_many or min) per point, without the (N, S)
264
+ intermediates: ~10x the numpy path. nogil, so peval's threads run
265
+ it in parallel."""
266
+ S = A.shape[0]
267
+ m = min(S, 6)
268
+ best = np.empty(6)
269
+ AB = B - A
270
+ L2 = np.empty(S)
271
+ for s in range(S):
272
+ L2[s] = max(AB[s, 0] * AB[s, 0] + AB[s, 1] * AB[s, 1] + AB[s, 2] * AB[s, 2], 1e-18)
273
+ for i in range(f.shape[0]):
274
+ for j in range(6):
275
+ best[j] = np.inf
276
+ for s in range(S):
277
+ p0 = f[i, 0] - A[s, 0]
278
+ p1 = f[i, 1] - A[s, 1]
279
+ p2 = f[i, 2] - A[s, 2]
280
+ t = (p0 * AB[s, 0] + p1 * AB[s, 1] + p2 * AB[s, 2]) / L2[s]
281
+ t = min(max(t, 0.0), 1.0)
282
+ d0 = p0 - t * AB[s, 0]
283
+ d1 = p1 - t * AB[s, 1]
284
+ d2 = p2 - t * AB[s, 2]
285
+ d = d0 * d0 + d1 * d1 + d2 * d2 # squared: sqrt only the kept ones
286
+ if d < best[m - 1]:
287
+ j = m - 1
288
+ while j > 0 and best[j - 1] > d:
289
+ best[j] = best[j - 1]
290
+ j -= 1
291
+ best[j] = d
292
+ for j in range(m):
293
+ best[j] = np.sqrt(best[j])
294
+ o = best[0]
295
+ k = blend[i]
296
+ if k > 0:
297
+ for j in range(1, m):
298
+ b = best[j]
299
+ h = min(max(0.5 + 0.5 * (b - o) / k, 0.0), 1.0)
300
+ o = b * (1 - h) + o * h - k * h * (1 - h)
301
+ out[i] = o
302
+
303
+
304
+ def unit_cell_distance(kind, U, blend=0.0, aspect=None):
305
+ """Distance (cell units) from lattice coordinates U (period 1) to the
306
+ nearest strut centreline of a periodic cell; `blend` (cell units, per
307
+ point or scalar) smooth-unions the struts at the nodes. `aspect` (per
308
+ axis) stretches the cell: U is in stretched cells, the distance in
309
+ units of the unstretched cell."""
310
+ kind = canonical(kind)
311
+ a = None
312
+ if aspect is not None and not np.allclose(aspect, 1.0):
313
+ a = tuple(float(x) for x in aspect)
314
+ segs, mirror, swaps = _candidates(kind, 0.32 * (max(a) / min(a) if a else 1.0), a)
315
+ U = np.asarray(U, float)
316
+ out = np.empty(len(U))
317
+ blend = np.broadcast_to(np.asarray(blend, float), (len(U),))
318
+ sc = np.asarray(a if a else (1.0, 1.0, 1.0))
319
+ A, B = segs[:, 0] * sc, segs[:, 1] * sc
320
+ if _nb is not None and len(U):
321
+ f = np.ascontiguousarray(_fold(U, mirror, swaps) * sc)
322
+ _strut_kernel(f, np.ascontiguousarray(A), np.ascontiguousarray(B),
323
+ np.ascontiguousarray(blend, np.float64), out)
324
+ return out
325
+ step = max(1, _CHUNK * 8 // max(len(segs), 1))
326
+ for i in range(0, len(U), step):
327
+ f = _fold(U[i:i + step], mirror, swaps) * sc
328
+ D = _seg_dist(f, A, B)
329
+ bl = blend[i:i + step]
330
+ out[i:i + step] = _smin_many(D, bl) if np.any(bl > 0) else D.min(axis=1)
331
+ return out
332
+
333
+
334
+ def strut_distance(kind, U, diameter_over_cell, blend=0.0):
335
+ """Signed distance (cell units) of the strut solid: centreline distance
336
+ less the radius."""
337
+ return unit_cell_distance(kind, U, blend) - 0.5 * np.asarray(diameter_over_cell, float)
338
+
339
+
340
+ # ---- density ------------------------------------------------------------------------
341
+
342
+ @lru_cache(maxsize=None)
343
+ def strut_density_table(kind, blend=0.0):
344
+ """(diameter/cell, relative density) on one cell, sampled at 2^20
345
+ scrambled Sobol points (a regular grid aliases with axis-aligned struts:
346
+ 12% light on thin cubic rods)."""
347
+ from scipy.stats import qmc
348
+
349
+ g = qmc.Sobol(3, scramble=True, seed=7).random_base2(20)
350
+ d = np.sort(unit_cell_distance(kind, g, blend))
351
+ t = np.linspace(0.0, 0.8, 161)
352
+ rho = np.searchsorted(d, 0.5 * t) / d.size
353
+ return t, rho
354
+
355
+
356
+ def _blend_key(blend):
357
+ return round(float(blend), 2)
358
+
359
+
360
+ # the periodic box of a cell, in cells: a Voronoi foam's `cell` is its seed
361
+ # spacing, and its periodic stand-in holds 8 seeds a box, 2 spacings across
362
+ _BOX = {"voronoi": 2.0}
363
+
364
+
365
+ def strut_relative_density(kind, diameter, cell, blend=0.0):
366
+ kind = canonical(kind)
367
+ box = _BOX.get(kind, 1.0)
368
+ t, rho = strut_density_table(kind, _blend_key(blend / box))
369
+ return np.interp(np.asarray(diameter, float) / (box * np.asarray(cell, float)), t, rho)
370
+
371
+
372
+ def strut_diameter_for_density(kind, rho, cell, blend=0.0):
373
+ kind = canonical(kind)
374
+ box = _BOX.get(kind, 1.0)
375
+ t, r = strut_density_table(kind, _blend_key(blend / box))
376
+ # the table is non-decreasing; interp wants strictly increasing x
377
+ r, idx = np.unique(r, return_index=True)
378
+ return np.interp(np.asarray(rho, float), r, t[idx]) * box * np.asarray(cell, float)
379
+
380
+
381
+ # ---- Voronoi foams ------------------------------------------------------------------
382
+
383
+ def _voronoi_edges(seeds, keep_lo, keep_hi):
384
+ from scipy.spatial import Voronoi
385
+
386
+ vor = Voronoi(seeds)
387
+ V = vor.vertices
388
+ edges = set()
389
+ for ridge in vor.ridge_vertices:
390
+ if -1 in ridge or len(ridge) < 2:
391
+ continue
392
+ for a, b in zip(ridge, ridge[1:] + ridge[:1]):
393
+ edges.add((min(a, b), max(a, b)))
394
+ E = np.array(sorted(edges), int).reshape(-1, 2)
395
+ if not len(E):
396
+ return np.zeros((0, 2, 3))
397
+ S = V[E]
398
+ m = S.mean(axis=1)
399
+ inside = np.all((m >= keep_lo) & (m <= keep_hi), axis=1)
400
+ return S[inside]
401
+
402
+
403
+ @lru_cache(maxsize=None)
404
+ def _periodic_voronoi(n_seeds, seed):
405
+ """A periodic Voronoi cell: `n_seeds` relaxed seeds in the unit box,
406
+ tiled 3x3x3; the edges whose midpoints are in the home box."""
407
+ rng = np.random.default_rng(seed)
408
+ s = rng.uniform(0, 1, (n_seeds, 3))
409
+ offs = np.array(list(itertools.product((-1, 0, 1), repeat=3)), float)
410
+ for _ in range(30): # periodic repulsion: even cells
411
+ allp = (s[None] + offs[:, None]).reshape(-1, 3)
412
+ d = s[:, None, :] - allp[None]
413
+ r = np.linalg.norm(d, axis=2)
414
+ r[r < 1e-9] = np.inf
415
+ h = n_seeds ** (-1 / 3)
416
+ w = np.clip(1.0 - r / h, 0, None) ** 2
417
+ s = (s + 0.1 * h * (w[..., None] * d / r[..., None]).sum(axis=1)) % 1.0
418
+ allp = (s[None] + offs[:, None]).reshape(-1, 3)
419
+ return _voronoi_edges(allp, np.zeros(3), np.ones(3) - 1e-12)
420
+
421
+
422
+ class VoronoiStruts:
423
+ """A stochastic Voronoi strut foam over a box: seeds `cell` mm apart on
424
+ average (a callable gives a per-point spacing), relaxed so cells come out
425
+ even, Voronoi edges as struts. `distance(P)` is the exact distance to the
426
+ nearest edge (edges are split so a k-nearest query on piece midpoints
427
+ finds it)."""
428
+
429
+ def __init__(self, lo, hi, cell, seed=0, relax=8):
430
+ from scipy.spatial import cKDTree
431
+
432
+ lo, hi = np.asarray(lo, float), np.asarray(hi, float)
433
+ cell_fn = cell if callable(cell) else (lambda P, c=float(cell): np.full(len(P), c))
434
+ rng = np.random.default_rng(seed)
435
+ pad = 2.0 * float(np.max(cell_fn(np.array([0.5 * (lo + hi)]))))
436
+ # rejection sampling at the finest spacing, density ~ 1 / cell^3
437
+ probe = rng.uniform(lo - pad, hi + pad, (20_000, 3))
438
+ cmin = float(np.min(cell_fn(probe)))
439
+ vol = float(np.prod(hi - lo + 2 * pad))
440
+ n_try = int(min(4e6, vol / cmin ** 3 * 1.0))
441
+ C = rng.uniform(lo - pad, hi + pad, (n_try, 3))
442
+ c = np.asarray(cell_fn(C), float)
443
+ S = C[rng.uniform(0, 1, n_try) < (cmin / c) ** 3]
444
+ if len(S) < 8:
445
+ raise ValueError("voronoi lattice: the box holds fewer than 8 cells; make the cell smaller")
446
+ for _ in range(relax): # push neighbours apart by the local spacing
447
+ tree = cKDTree(S)
448
+ h = np.asarray(cell_fn(S), float)
449
+ d, j = tree.query(S, k=min(9, len(S)))
450
+ d, j = d[:, 1:], j[:, 1:]
451
+ vec = S[:, None, :] - S[j]
452
+ w = np.clip(1.0 - d / h[:, None], 0, None) ** 2
453
+ S = S + 0.15 * h[:, None] * (w[..., None] * vec / np.maximum(d, 1e-9)[..., None]).sum(axis=1)
454
+ self.seeds = S
455
+ segs = _voronoi_edges(S, lo - 0.5 * pad, hi + 0.5 * pad)
456
+ # split long edges so each piece is short against the spacing
457
+ L = np.linalg.norm(segs[:, 1] - segs[:, 0], axis=1)
458
+ piece = 0.2 * cmin
459
+ k = np.maximum(1, np.ceil(L / piece).astype(int))
460
+ A, B, E = [], [], []
461
+ for n in np.unique(k):
462
+ ids = np.flatnonzero(k == n)
463
+ sel = segs[ids]
464
+ t = np.linspace(0, 1, n + 1)
465
+ pa = sel[:, None, 0] + (sel[:, None, 1] - sel[:, None, 0]) * t[None, :-1, None]
466
+ pb = sel[:, None, 0] + (sel[:, None, 1] - sel[:, None, 0]) * t[None, 1:, None]
467
+ A.append(pa.reshape(-1, 3))
468
+ B.append(pb.reshape(-1, 3))
469
+ E.append(np.repeat(ids, n))
470
+ self.edges = segs
471
+ self.A, self.B = np.concatenate(A), np.concatenate(B)
472
+ self.eid = np.concatenate(E) # each piece's edge
473
+ self.tree = cKDTree(0.5 * (self.A + self.B))
474
+ self.piece = piece
475
+ self.cell_min = cmin
476
+
477
+ def distance(self, P, blend=0.0, k=None):
478
+ """Distance to the nearest strut centreline; with `blend`, the
479
+ smooth minimum over struts, each strut once (its pieces' nearest:
480
+ smoothing over pieces beaded every strut where its pieces join)."""
481
+ P = np.asarray(P, float)
482
+ out = np.empty(len(P))
483
+ blend = np.broadcast_to(np.asarray(blend, float), (len(P),))
484
+ if k is None:
485
+ # a blend reads every strut within it: 32 nearest pieces keep it
486
+ # continuous (16 left 0.01 mm steps where struts crowd a node)
487
+ k = 32 if np.any(blend > 0) else 12
488
+ for i in range(0, len(P), _CHUNK):
489
+ Q = P[i:i + _CHUNK]
490
+ _, j = self.tree.query(Q, k=k)
491
+ A, B = self.A[j], self.B[j]
492
+ AB = B - A
493
+ AP = Q[:, None] - A
494
+ t = np.clip(np.einsum("nkj,nkj->nk", AP, AB) / np.maximum(np.einsum("nkj,nkj->nk", AB, AB), 1e-18),
495
+ 0, 1)
496
+ D = np.linalg.norm(AP - t[..., None] * AB, axis=2)
497
+ bl = blend[i:i + _CHUNK]
498
+ if np.any(bl > 0):
499
+ o = np.argsort(D, axis=1)
500
+ D = np.take_along_axis(D, o, 1)
501
+ e = np.take_along_axis(self.eid[j], o, 1)
502
+ dup = np.zeros(D.shape, bool)
503
+ for c in range(1, D.shape[1]):
504
+ dup[:, c] = (e[:, :c] == e[:, c:c + 1]).any(axis=1)
505
+ out[i:i + _CHUNK] = _smin_many(np.where(dup, 1e9, D), bl)
506
+ else:
507
+ out[i:i + _CHUNK] = D.min(axis=1)
508
+ return out
509
+
510
+
511
+ # ---- printability -------------------------------------------------------------------
512
+
513
+ def _rotation(build):
514
+ b = np.asarray(build, float)
515
+ return b / np.linalg.norm(b)
516
+
517
+
518
+ def overhang_report(kind, cell, diameter=None, build=(0, 0, 1), angle=45.0, jacobian=None,
519
+ max_bridge=None) -> dict:
520
+ """How a strut cell prints on FDM: each strut's rise from the bed
521
+ (degrees), the strut length below `angle` (bridging, unsupported), and
522
+ the longest such bridge in mm. `jacobian` (M, 3, 3) are dx/du of the
523
+ lattice's coordinate map sampled through the part (graded and conformal
524
+ frames bend struts; None is the plain cell, du = dx / cell): every
525
+ strut is mapped through each sample, so the fractions are over the part,
526
+ not the cell. `max_bridge` (mm) is what the printer bridges cleanly;
527
+ flat struts longer than that are flagged."""
528
+ kind = canonical(kind)
529
+ segs = cell_segments(kind)
530
+ du = segs[:, 1] - segs[:, 0]
531
+ up = _rotation(build)
532
+ if jacobian is None:
533
+ J = np.eye(3)[None] * float(np.mean(cell))
534
+ else:
535
+ J = np.asarray(jacobian, float)
536
+ dx = np.einsum("mij,sj->msi", J, du) # (M, S, 3) mm
537
+ L = np.linalg.norm(dx, axis=2)
538
+ rise = np.degrees(np.arcsin(np.clip(np.abs(dx @ up) / np.maximum(L, 1e-12), 0, 1)))
539
+ flat = rise < angle - 1e-6
540
+ total = float(L.sum())
541
+ rep = {"kind": kind, "angle": float(angle), "build": up.tolist(),
542
+ "strutsPerCell": int(len(segs)),
543
+ "minRise": float(rise.min()), "unsupportedFraction": float(L[flat].sum() / max(total, 1e-12)),
544
+ "horizontalFraction": float(L[rise < 5.0].sum() / max(total, 1e-12)),
545
+ "longestBridge": float(L[flat].max()) if flat.any() else 0.0,
546
+ "selfSupporting": bool(not flat.any())}
547
+ if diameter is not None:
548
+ rep["diameter"] = [float(np.min(diameter)), float(np.max(diameter))]
549
+ if max_bridge is not None:
550
+ rep["maxBridge"] = float(max_bridge)
551
+ rep["bridgesTooLong"] = bool(rep["longestBridge"] > max_bridge)
552
+ return rep
553
+
554
+
555
+ def best_orientation(kind, angle=45.0, n=600, seed=0):
556
+ """The cell rotation (3x3, rows the cell axes in part coordinates) that
557
+ leaves the least strut length below `angle` with build +z: random
558
+ rotations plus the cube's special axes. Returns (R, report)."""
559
+ from scipy.spatial.transform import Rotation
560
+
561
+ rots = list(Rotation.random(n, random_state=seed).as_matrix())
562
+ # z along [001], [011], [111] and their spins
563
+ for ax in ((0, 0, 1), (0, 1, 1), (1, 1, 1)):
564
+ a = np.asarray(ax, float) / np.linalg.norm(ax)
565
+ r0 = Rotation.align_vectors([[0, 0, 1]], [a])[0]
566
+ for spin in np.linspace(0, np.pi / 2, 7):
567
+ rots.append((Rotation.from_rotvec([0, 0, spin]) * r0).as_matrix())
568
+ best = None
569
+ for R in rots:
570
+ rep = overhang_report(kind, 1.0, jacobian=R[None], angle=angle)
571
+ key = (rep["unsupportedFraction"], -rep["minRise"])
572
+ if best is None or key < best[0]:
573
+ best = (key, R, rep)
574
+ return best[1], best[2]
575
+
576
+
577
+ def lattice_printability(graph, lattice, solid=None, build=(0, 0, 1), angle=45.0, samples=300, seed=0,
578
+ max_bridge=None) -> dict:
579
+ """overhang_report for a FieldGraph's strut lattice node as it sits in
580
+ the part: the coordinate map's Jacobian is sampled at points inside
581
+ `solid` (default the graph's output), so graded, stretched and
582
+ conformal cells are judged as they print. Voronoi foams are judged on
583
+ their actual edges inside the solid."""
584
+ nd = graph.nodes[lattice]
585
+ kind = canonical(nd.spec.get("type", "gyroid"))
586
+ solid = solid or graph.output
587
+ b = graph.bounds(solid)
588
+ if b is None:
589
+ raise ValueError(f"{solid!r} is unbounded")
590
+ lo, hi = b
591
+ rng = np.random.default_rng(seed)
592
+ P = rng.uniform(lo, hi, (samples * 20, 3))
593
+ P = P[graph.evaluate(P, solid) < 0][:samples]
594
+ if not len(P):
595
+ raise ValueError(f"no points inside {solid!r} to sample the lattice at")
596
+ up = _rotation(build)
597
+ if kind == "voronoi":
598
+ vor = graph._resource(nd, lambda: graph._voronoi(nd))
599
+ E = vor.edges
600
+ off = np.asarray(nd.spec.get("origin", (0, 0, 0)), float)
601
+ mid = E.mean(axis=1) + off
602
+ E = E[graph.evaluate(mid, solid) < 0]
603
+ dx = E[:, 1] - E[:, 0]
604
+ L = np.linalg.norm(dx, axis=1)
605
+ rise = np.degrees(np.arcsin(np.clip(np.abs(dx @ up) / np.maximum(L, 1e-12), 0, 1)))
606
+ flat = rise < angle - 1e-6
607
+ rep = {"kind": kind, "angle": float(angle), "build": up.tolist(), "struts": int(len(E)),
608
+ "minRise": float(rise.min()) if len(E) else 90.0,
609
+ "unsupportedFraction": float(L[flat].sum() / max(L.sum(), 1e-12)),
610
+ "horizontalFraction": float(L[rise < 5.0].sum() / max(L.sum(), 1e-12)),
611
+ "longestBridge": float(L[flat].max()) if flat.any() else 0.0, "selfSupporting": bool(not flat.any())}
612
+ else:
613
+ eps = 1e-3
614
+ U0, cell = graph.lattice_coords(nd, P)
615
+ Jinv = np.empty((len(P), 3, 3))
616
+ for a in range(3):
617
+ dP = np.zeros(3)
618
+ dP[a] = eps
619
+ U1, _ = graph.lattice_coords(nd, P + dP)
620
+ du = U1 - U0
621
+ du -= np.round(du) # phases wrap
622
+ Jinv[:, :, a] = du / eps # du/dx
623
+ ok = np.abs(np.linalg.det(Jinv)) > 1e-9
624
+ J = np.zeros_like(Jinv)
625
+ J[ok] = np.linalg.inv(Jinv[ok]) # dx/du
626
+ # a conformal map's phase defects (and the medial axis, where the
627
+ # layer coordinate folds) have no cell to speak of: leave them out
628
+ span = np.linalg.norm(J, axis=1) / cell[:, None]
629
+ ok &= np.all((span > 0.3) & (span < 3.0), axis=1)
630
+ if not ok.any():
631
+ raise ValueError(f"lattice {lattice!r}: no regular cell among the samples")
632
+ rep = overhang_report(kind, float(np.mean(cell)), jacobian=J[ok], build=build, angle=angle)
633
+ rep["irregularSamples"] = int((~ok).sum())
634
+ rep["cell"] = [float(cell.min()), float(cell.max())]
635
+ if max_bridge is not None:
636
+ rep["maxBridge"] = float(max_bridge)
637
+ rep["bridgesTooLong"] = bool(rep["longestBridge"] > max_bridge)
638
+ rep["samples"] = int(len(P))
639
+ return rep