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/drains.py ADDED
@@ -0,0 +1,615 @@
1
+ """
2
+ Drain and escape holes for field parts with sealed voids.
3
+
4
+ A closed skin over a lattice (or any hollow in a field graph) seals the
5
+ powder in (SLS, MJF, metal powder bed) or the uncured resin (SLA, DLP,
6
+ volumetric). `drain_graph` finds every sealed void of a FieldGraph's
7
+ output on a voxel grid of the field itself and opens each one:
8
+
9
+ - voids too small to hold a hole (under the escape hole's own ball by
10
+ default) are filled solid;
11
+ - every other void gets holes well over the process's minimum escape hole
12
+ (1.5 x it or 2 mm over, whichever is larger: SLS 6 mm, MJF 7.5, resin
13
+ 5.5, volumetric 5; LPBF 6.8, an M8 tap drill, so it can be plugged), two
14
+ per void, then more until no powder is over 60 mm (`maxPath`) from one:
15
+ the calmest flat site first, lowest along the build, then the calmest one
16
+ well away from it, highest along the build, so air or rinse flows
17
+ through (and resin has its drain at the bottom and its vent at the top);
18
+ - a site must be just under the skin with a straight way out, on a face
19
+ flat out past the boss (a hole on an edge loses half its boss and sits
20
+ where the stress gathers), clear of anything named in `keepClear`;
21
+ - each hole gets a solid boss (a hole is a stress raiser, about 3x in a
22
+ plate, and the lattice walls it cuts would otherwise be loose slivers),
23
+ smooth-unioned in;
24
+ - then the grid is labelled again, and voids a boss walled off get the
25
+ same treatment, for up to four rounds.
26
+
27
+ The calm is the graph's own stress: the first `result` node (von Mises),
28
+ or the node named by `stress`. Without one, sites are ranked by height
29
+ along the build alone.
30
+
31
+ The holes and bosses are ordinary graph nodes (cylinders), added to the
32
+ graph, so the drilled part meshes, verifies and saves like any other.
33
+ Verification on the drilled field: no sealed void left (the same grid
34
+ labelling), the powder path from every point of each void to its nearest
35
+ hole through the void (heatx's geodesic; nothing unreached), and
36
+ processcheck's escape check (enclosed voids, chambers that only open
37
+ through gaps narrower than the hole). The mesh run_field writes is checked
38
+ again for sealed shells.
39
+
40
+ Units are millimetres.
41
+ """
42
+ from __future__ import annotations
43
+
44
+ import math
45
+ from dataclasses import dataclass, field
46
+
47
+ import numpy as np
48
+ from scipy import ndimage
49
+
50
+ __all__ = ["DrainOptions", "drain_graph", "drains_wanted", "hole_defaults"]
51
+
52
+ # processes with something to drain, and the ones processcheck has no card for
53
+ _DRAINED = ("sls", "mjf", "sla", "dlp", "volumetric", "lpbf")
54
+ _EXTRA = {"volumetric": 3.0, "lpbf": 4.0} # minimum escape holes processcheck has no card for
55
+ _LPBF_DRILL = 6.8 # an M8 tap drill: big, and it can be plugged
56
+ _TINY = 1.0 # mm^3: smaller is aliasing, not a void
57
+ _ROUNDS = 6 # relabellings; holes in the first four, then fill only
58
+ _DRILL_ROUNDS = 4
59
+ _MAX_HOLES = 8 # a void, with maxPath
60
+ _BUDGET = 16_000_000
61
+
62
+
63
+ @dataclass
64
+ class DrainOptions:
65
+ diameter: float | None = None # mm (default see hole_defaults: SLS 6, MJF 7.5, resin 5.5, LPBF 6.8)
66
+ count: int = 2 # holes per void
67
+ stress: str | None = "auto" # node whose value ranks the sites: "auto" = the first result node, None = none
68
+ boss: float | None = None # mm of solid round each hole (default max(1.5 x min wall, 0.4 x radius))
69
+ blend: float | None = None # mm fillet where the boss meets the part (default the boss wall)
70
+ max_depth: float = 6.0 # mm: sites at most this far under the outside
71
+ fill_below: float | None = None # mm^3: fill smaller voids solid (default the escape ball's volume)
72
+ keep_clear: list = field(default_factory=list) # solid nodes the bosses stay out of
73
+ clearance: float = 1.0 # mm from those
74
+ max_path: float | None = 60.0 # mm: more holes (up to 8 a void) while powder is farther than this from one (None: off)
75
+ pitch: float | None = None # mm grid (default 0.75 x min wall, at most diameter / 8)
76
+
77
+ _KEYS = {"diameter": "diameter", "count": "count", "stress": "stress", "boss": "boss", "blend": "blend",
78
+ "maxDepth": "max_depth", "fillBelow": "fill_below", "keepClear": "keep_clear",
79
+ "clearance": "clearance", "pitch": "pitch",
80
+ "maxPath": "max_path"}
81
+
82
+ @classmethod
83
+ def from_spec(cls, spec) -> "DrainOptions":
84
+ o = cls()
85
+ if spec in (None, True, "auto", "yes"):
86
+ return o
87
+ if not isinstance(spec, dict):
88
+ raise ValueError("drains is 'auto', true, false or an object")
89
+ for k, v in spec.items():
90
+ if k == "enabled":
91
+ continue
92
+ if k not in cls._KEYS:
93
+ raise ValueError(f"drains: unknown key {k!r} ({', '.join(cls._KEYS)})")
94
+ setattr(o, cls._KEYS[k], v)
95
+ if o.diameter is not None and not float(o.diameter) > 0:
96
+ raise ValueError("drains.diameter is a hole diameter in mm, > 0")
97
+ if int(o.count) < 1:
98
+ raise ValueError("drains.count is at least 1")
99
+ o.count = int(o.count)
100
+ if isinstance(o.keep_clear, str):
101
+ o.keep_clear = [o.keep_clear]
102
+ return o
103
+
104
+
105
+ def drains_wanted(spec, process) -> bool:
106
+ """`drains` in a field graph: 'auto' (default) drills for powder and
107
+ resin processes and LPBF; true/an object always; false never."""
108
+ if spec is False or spec == "no":
109
+ return False
110
+ if isinstance(spec, dict):
111
+ return spec.get("enabled", True) is not False
112
+ if spec is True or spec == "yes":
113
+ return True
114
+ return process is not None and process.kind in _DRAINED
115
+
116
+
117
+ def hole_defaults(process):
118
+ """(drilled diameter mm, the process's minimum escape hole mm, holes per
119
+ void) for a process (spec.Process or name).
120
+
121
+ Powder removal is where a part fails in production, so the drilled hole
122
+ is well over the bare minimum: 1.5 x the minimum or 2 mm over it,
123
+ whichever is larger, rounded up to half a millimetre (SLS 6, MJF 7.5,
124
+ SLA/DLP 5.5, volumetric 5). A hole at the minimum is a narrow exit to
125
+ processcheck's escape ball on any grid, and to powder that has caked or
126
+ resin that has thickened. LPBF drills 6.8 mm, an M8 tap drill, so it
127
+ can still be plugged."""
128
+ from .processcheck import RULES
129
+
130
+ kind = str(getattr(process, "kind", process) or "").lower()
131
+ esc = getattr(process, "escape_hole", None)
132
+ if kind in RULES:
133
+ least, per = float(esc or RULES[kind]["escapeHole"]), int(RULES[kind]["escapeHoles"])
134
+ else:
135
+ least, per = float(esc or _EXTRA.get(kind, 4.0)), 2
136
+ if kind == "lpbf" and esc is None:
137
+ return _LPBF_DRILL, least, per
138
+ return math.ceil(2.0 * max(1.5 * least, least + 2.0)) / 2.0, least, per
139
+
140
+
141
+ def _as_process(process):
142
+ if process is None or hasattr(process, "kind"):
143
+ return process
144
+ from .spec import _process
145
+
146
+ return _process(process)
147
+
148
+
149
+ def _up(build, process):
150
+ from .am import parse_build_direction
151
+
152
+ b = build if build is not None else getattr(process, "build", None)
153
+ if b is None or b == "auto":
154
+ return np.array([0.0, 0.0, 1.0])
155
+ b = parse_build_direction(b) if not (isinstance(b, tuple) and len(b) == 2) else b
156
+ if len(b) == 2:
157
+ u = np.zeros(3)
158
+ u[b[0]] = b[1]
159
+ return u
160
+ return np.asarray(b, float)
161
+
162
+
163
+ def _sample(fn, lo, dims, p, progress=None):
164
+ """fn at lo + ijk * p over the grid, float32."""
165
+ F = np.empty(tuple(dims), np.float32)
166
+ ys = lo[1] + np.arange(dims[1]) * p
167
+ zs = lo[2] + np.arange(dims[2]) * p
168
+ Y, Z = np.meshgrid(ys, zs, indexing="ij")
169
+ per = max(1, 1_000_000 // (dims[1] * dims[2]))
170
+ for i0 in range(0, dims[0], per):
171
+ i1 = min(dims[0], i0 + per)
172
+ xs = lo[0] + np.arange(i0, i1) * p
173
+ P = np.stack([np.repeat(xs, Y.size), np.tile(Y.ravel(), len(xs)), np.tile(Z.ravel(), len(xs))], axis=1)
174
+ F[i0:i1] = np.asarray(fn(P), np.float32).reshape(i1 - i0, dims[1], dims[2])
175
+ if progress:
176
+ progress({"stage": "drains", "done": i1, "of": int(dims[0])})
177
+ return F
178
+
179
+
180
+ def _box(lo, p, dims, a, b, reach):
181
+ """Grid block round segment a-b out to `reach`, and its points."""
182
+ i0 = np.floor((np.minimum(a, b) - reach - lo) / p).astype(int).clip(0)
183
+ i1 = (np.ceil((np.maximum(a, b) + reach - lo) / p).astype(int) + 1).clip(None, np.asarray(dims))
184
+ sl = tuple(slice(x0, x1) for x0, x1 in zip(i0, i1))
185
+ ii = np.stack(np.meshgrid(*[np.arange(x0, x1) for x0, x1 in zip(i0, i1)], indexing="ij"), -1)
186
+ return sl, lo + ii.reshape(-1, 3) * p, ii.shape[:3]
187
+
188
+
189
+ def _mask_sdf(mask, p):
190
+ """Signed distance (negative inside) of a voxel mask, boundary halfway
191
+ between voxel centres."""
192
+ return np.where(mask, -(ndimage.distance_transform_edt(mask) - 0.5) * p,
193
+ (ndimage.distance_transform_edt(~mask) - 0.5) * p).astype(np.float32)
194
+
195
+
196
+ def _labels(F, p):
197
+ """The sealed voids, labelled (6-connected), how many, the outside (a
198
+ mask) and the voids' sizes in voxels. What reaches the outside is
199
+ worked out on the void shrunk by half a voxel, so a wall thinner than a
200
+ voxel (where a lattice wall grazes the skin) still seals as it does in
201
+ the mesh; the fringe of open space (three voxels of void out from it:
202
+ a blend's field is not a true distance, so its fringe runs wider) is
203
+ open too. Everything
204
+ else that is not solid is sealed, down to the thinnest sliver."""
205
+ core, n = ndimage.label(F >= 0.5 * p)
206
+ border = set()
207
+ for face in (core[0], core[-1], core[:, 0], core[:, -1], core[:, :, 0], core[:, :, -1]):
208
+ border.update(np.unique(face).tolist())
209
+ border.discard(0)
210
+ outside = np.isin(core, list(border))
211
+ del core
212
+ void = F >= 0
213
+ outside = ndimage.binary_dilation(outside, iterations=3, mask=void) | outside
214
+ lbl, n = ndimage.label(void & ~outside)
215
+ sizes = np.bincount(lbl.ravel(), minlength=n + 1)
216
+ return lbl, n, outside, sizes
217
+
218
+
219
+ def drain_graph(g, process=None, opts: DrainOptions | None = None, build=None, min_wall=None, bounds=None,
220
+ pitch=None, progress=None) -> dict:
221
+ """Drill the sealed voids of `g`'s output (see the module docstring).
222
+ Adds the fill, boss and bore nodes to `g` and points its output at the
223
+ drilled part ('drained'); returns the report. `process` is a name, a
224
+ study-style process object or a spec.Process; `build` the build
225
+ direction (default the process's, else +z); `pitch` the grid (default
226
+ drains.pitch, else 0.75 x the minimum wall, at most diameter / 8): give
227
+ the mesh's pitch so the specks the mesh would see are seen here too."""
228
+ from .fields import _smin
229
+ from .heatx import _TAPS, _geodesic
230
+ from .processcheck import _escapes
231
+ from .shapes import Cylinder
232
+
233
+ proc = _as_process(process)
234
+ o = opts or DrainOptions()
235
+ dia, least, per_void = hole_defaults(proc)
236
+ if o.diameter is not None:
237
+ dia = float(o.diameter)
238
+ count = int(o.count) if o.count is not None else per_void
239
+ r = 0.5 * dia
240
+ min_wall = float(min_wall if min_wall is not None else getattr(proc, "min_feature", None) or 0.8)
241
+ bw = float(o.boss) if o.boss is not None else max(1.5 * min_wall, 0.4 * r)
242
+ k = float(o.blend) if o.blend is not None else bw
243
+ fill_below = float(o.fill_below) if o.fill_below is not None else 4.0 / 3.0 * np.pi * r ** 3
244
+ up = _up(build, proc)
245
+ for name in o.keep_clear:
246
+ if name not in g.nodes:
247
+ raise ValueError(f"drains.keepClear: {name!r} is not a node")
248
+ sname = o.stress
249
+ if sname == "auto":
250
+ sname = next((n for n, nd in g.nodes.items() if nd.op == "result"
251
+ and nd.spec.get("quantity", "vonMises") == "vonMises"), None)
252
+ elif sname is not None and sname not in g.nodes:
253
+ raise ValueError(f"drains.stress: {sname!r} is not a node")
254
+
255
+ if bounds is None:
256
+ if "bounds" in g.doc:
257
+ bounds = (g.doc["bounds"]["min"], g.doc["bounds"]["max"])
258
+ else:
259
+ bounds = g.bounds()
260
+ if bounds is None:
261
+ raise ValueError("drains need the part's bounds: give the graph 'bounds'")
262
+ blo, bhi = np.asarray(bounds[0], float), np.asarray(bounds[1], float)
263
+ p = float(o.pitch or pitch or min(0.75 * min_wall, dia / 8.0))
264
+ pad = (r + bw + k) + 3 * p
265
+ p = max(p, float((np.prod(bhi - blo + 2 * pad) / _BUDGET) ** (1 / 3)))
266
+ pad = (r + bw + k) + 3 * p
267
+ lo = blo - pad
268
+ dims = np.ceil((bhi + pad - lo) / p).astype(int) + 1
269
+ vox = p ** 3
270
+ base = g.output
271
+ F0 = _sample(g.field(base), lo, dims, p, progress)
272
+
273
+ # the stress the sites are ranked by, and its scale (the part's own peak)
274
+ def stress_at(P):
275
+ return g.evaluate(P, sname) if sname else np.zeros(len(P))
276
+
277
+ s_ref = 1.0
278
+ if sname:
279
+ ijk = np.argwhere(F0 < 0)
280
+ if len(ijk) > 20_000:
281
+ ijk = ijk[np.random.default_rng(0).choice(len(ijk), 20_000, replace=False)]
282
+ s_ref = float(np.percentile(stress_at(lo + ijk * p), 99.5)) or 1.0
283
+
284
+ lbl, n, outside0, sizes = _labels(F0, p)
285
+ every = list(range(1, n + 1))
286
+ sealed0 = [i for i in every if sizes[i] * vox >= _TINY]
287
+ voids = []
288
+ if sealed0:
289
+ cent = ndimage.center_of_mass(np.ones_like(lbl, bool), lbl, sealed0)
290
+ voids = [{"volume": round(float(sizes[i] * vox), 1), "center": np.round(lo + np.asarray(c) * p, 2).tolist(),
291
+ "id": i} for i, c in zip(sealed0, cent)]
292
+ rep = {"process": getattr(proc, "kind", None), "diameter": dia, "minimumHole": least, "holesPerVoid": count, "boss": round(bw, 3),
293
+ "pitch": round(p, 4), "stressNode": sname, "sealedBefore": len(voids),
294
+ "sealedVolumeBefore": round(float(sum(v["volume"] for v in voids)), 1),
295
+ # specks: sealed bits under a cubic millimetre (where a wall grazes the skin), filled
296
+ "specks": len(every) - len(voids)}
297
+ if not every:
298
+ rep.update({"holes": [], "voids": [], "filledVolume": 0.0, "sealedAfter": 0, "sealedVolumeAfter": 0.0,
299
+ "ok": True})
300
+ return rep
301
+
302
+ holes, fill = [], np.zeros(tuple(dims), bool)
303
+ lbl0 = lbl
304
+ F = F0
305
+ rounds = 0
306
+ for rnd in range(_ROUNDS + 1):
307
+ outside = outside0
308
+ if rnd:
309
+ lbl, n, outside, sizes = _labels(F, p)
310
+ sealed = list(range(1, n + 1))
311
+ if not sealed or rnd == _ROUNDS:
312
+ break
313
+ rounds = rnd + 1
314
+ dist_out, near = ndimage.distance_transform_edt(~outside, return_indices=True)
315
+ dist_out = (dist_out * p).astype(np.float32)
316
+ objs = ndimage.find_objects(lbl)
317
+ placed = 0
318
+ for i in sorted(sealed, key=lambda i: -sizes[i]):
319
+ vol = float(sizes[i] * vox)
320
+ if vol < fill_below:
321
+ fill |= lbl == i
322
+ placed += 1
323
+ continue
324
+ if rnd >= _DRILL_ROUNDS: # out of drilling rounds: reported as undrained
325
+ continue
326
+ new = _place(i, lbl, objs[i - 1], F, outside, dist_out, near, lo, p, r, bw, count, o, up,
327
+ stress_at, s_ref, holes, g)
328
+ for h in new:
329
+ h["void"] = int(lbl0[tuple(np.floor((h["site"] - lo) / p + 0.5).astype(int))])
330
+ holes += new
331
+ placed += len(new)
332
+ if not placed:
333
+ break
334
+ F = _apply(F0, fill, holes, lo, p, dims, k, _smin, Cylinder, outside0)
335
+
336
+ # what is left sealed, after everything
337
+ sealed_after = list(range(1, n + 1))
338
+ rep["rounds"] = rounds
339
+ rep["sealedAfter"] = len(sealed_after)
340
+ rep["sealedVolumeAfter"] = round(float(sum(sizes[i] for i in sealed_after) * vox), 1)
341
+ if sealed_after:
342
+ cent = ndimage.center_of_mass(np.ones_like(lbl, bool), lbl, sealed_after)
343
+ rep["undrained"] = [{"volume": round(float(sizes[i] * vox), 1),
344
+ "center": np.round(lo + np.asarray(c) * p, 2).tolist()}
345
+ for i, c in zip(sealed_after, cent)][:10]
346
+ rep["filledVolume"] = round(float(fill.sum() * vox), 1)
347
+
348
+ # powder path: from every point of each original void, through it, to the nearest hole
349
+ mask = (F >= 0) & ~outside0
350
+ starts = []
351
+ for h in holes:
352
+ t = np.arange(0.0, h["depth"], 0.5 * p)
353
+ ij = np.floor((h["site"] + np.outer(t, h["axis"]) - lo) / p + 0.5).astype(int)
354
+ starts += [tuple(x) for x in ij if mask[tuple(x)]]
355
+ geo = _geodesic(mask, starts, p) if starts else np.full(mask.shape, np.inf)
356
+ for v in voids:
357
+ m = (lbl0 == v["id"]) & mask
358
+ reach = geo[m]
359
+ fin = np.isfinite(reach)
360
+ v["holes"] = sum(1 for h in holes if h.get("void") == v["id"])
361
+ v["filled"] = bool(fill[lbl0 == v["id"]].all())
362
+ v["farthestFromOpening"] = round(float(reach[fin].max()), 1) if fin.any() else None
363
+ v["unreachedVolume"] = round(float((~fin).sum() * vox), 1)
364
+ rep["voids"] = [{k_: v_ for k_, v_ in v.items() if k_ != "id"} for v in voids]
365
+ rep["unreachedVolume"] = round(float(sum(v["unreachedVolume"] for v in voids)), 1)
366
+
367
+ # processcheck's escape probe on the drilled field: 26-connected, with
368
+ # the process's minimum escape ball
369
+ esc = _escapes(F < 0, p, lo - 0.5 * p, min(least, dia), np.eye(3))
370
+ rep["escape"] = {k_: esc[k_] for k_ in ("enclosedCount", "enclosedVolumeMm3", "narrowExitCount",
371
+ "narrowExitVolumeMm3")}
372
+
373
+ tap = min(_TAPS, key=lambda t: abs(t[0] - dia))
374
+ out = []
375
+ for j, h in enumerate(holes):
376
+ e = {"at": np.round(h["surface"], 2).tolist(), "axis": np.round(h["axis"], 3).tolist(), "diameter": dia,
377
+ "depth": round(float(h["depth"]), 2), "stress": round(float(h["stress"]), 4),
378
+ "stressRatio": round(float(h["stress"] / s_ref), 3) if sname else None}
379
+ if rep["process"] == "lpbf" and abs(tap[0] - dia) < 0.25:
380
+ e["seal"] = (f"leave open, or tap {tap[1]} into the boss and plug it with a set screw and thread "
381
+ "sealant after de-powdering")
382
+ out.append(e)
383
+ rep["holes"] = out
384
+ rep["ok"] = rep["sealedAfter"] == 0 and rep["unreachedVolume"] < 1.0
385
+
386
+ # into the graph: fill, bosses (smooth union), bores
387
+ cur = base
388
+ if fill.any():
389
+ sl, sdf = _fill_grid(fill, p, outside0)
390
+ org = lo + np.array([s.start for s in sl]) * p
391
+ g.sources["drain-fill"] = {"origin": org, "pitch": p, "values": sdf}
392
+ hi_ = org + (np.array(sdf.shape) - 1) * p
393
+ g._add("drain-fill", {"op": "grid", "source": "drain-fill", "bounds": {"min": org.tolist(),
394
+ "max": hi_.tolist()}})
395
+ cur = g._add("drain-filled", {"op": "union", "inputs": [cur, "drain-fill"]})
396
+ if holes:
397
+ bosses, bores = [], []
398
+ for j, h in enumerate(holes):
399
+ bosses.append(g._add(f"drain-boss-{j + 1}", {"op": "shape", "shape": _cyl(*h["boss"])}))
400
+ bores.append(g._add(f"drain-bore-{j + 1}", {"op": "shape", "shape": _cyl(*h["bore"])}))
401
+ cur = g._add("drain-bosses", {"op": "union", "inputs": [cur] + bosses, "blend": k})
402
+ cur = g._add("drained", {"op": "subtract", "inputs": [cur] + bores})
403
+ elif cur != base:
404
+ cur = g._add("drained", {"op": "union", "inputs": [cur]})
405
+ g.output = cur
406
+ g._check_refs()
407
+ rep["output"] = cur
408
+ return rep
409
+
410
+
411
+ def _cyl(a, b, rad):
412
+ return {"type": "cylinder", "p0": np.round(a, 4).tolist(), "p1": np.round(b, 4).tolist(), "r": round(float(rad), 4)}
413
+
414
+
415
+ def _fill_grid(mask, p, outside=None):
416
+ """The filled pockets as a local signed-distance grid, grown two voxels
417
+ (the labelling's half voxel, and one more) into the walls round them:
418
+ they are sealed, so that stays inside, and a fill that stopped short
419
+ would leave a thin sealed shell of its own."""
420
+ sl = ndimage.find_objects(mask.astype(np.int8))[0]
421
+ sl = tuple(slice(max(s.start - 3, 0), min(s.stop + 3, n)) for s, n in zip(sl, mask.shape))
422
+ sdf = _mask_sdf(mask[sl], p) - 2.0 * p
423
+ if outside is not None and outside[sl].any():
424
+ # never past the part's own outside: grown into a wall thinner than
425
+ # two voxels (a speck where a wall grazes the skin) the fill came
426
+ # through it, and stood 0.4-0.55 mm proud of a box the graph had
427
+ # already clipped the part to (an MJF riser, 2026-11-01)
428
+ sdf = np.maximum(sdf, _mask_sdf(~outside[sl], p))
429
+ return sl, sdf
430
+
431
+
432
+ def _apply(F0, fill, holes, lo, p, dims, k, _smin, Cylinder, outside=None):
433
+ """F0 with the fill, bosses and bores applied, as the graph will do it."""
434
+ F = F0.copy()
435
+ if fill.any():
436
+ sl, sdf = _fill_grid(fill, p, outside)
437
+ F[sl] = np.minimum(F[sl], sdf)
438
+ for h in holes:
439
+ a, b, rad = h["boss"]
440
+ sl, P, shp = _box(lo, p, dims, a, b, rad + k + p)
441
+ F[sl] = _smin(F[sl], Cylinder(a, b, rad).sdf(P).reshape(shp), k)
442
+ for h in holes:
443
+ a, b, rad = h["bore"]
444
+ sl, P, shp = _box(lo, p, dims, a, b, rad + p)
445
+ F[sl] = np.maximum(F[sl], -Cylinder(a, b, rad).sdf(P).reshape(shp))
446
+ return F
447
+
448
+
449
+ def _place(i, lbl, sl, F, outside, dist_out, near, lo, p, r, bw, count, o, up, stress_at, s_ref, holes, g):
450
+ """Up to `count` holes into void i: the calmest site lowest along the
451
+ build, then the calmest well away from it highest along the build, then
452
+ the calmest farthest from both."""
453
+ off = np.array([s.start for s in sl])
454
+ comp = lbl[sl] == i
455
+ dsub = dist_out[sl]
456
+ # sites on the void's own wall: the boss then ends just inside the
457
+ # void, not as a plug half way across a channel
458
+ wall = comp & ndimage.binary_dilation(F[sl] < 0)
459
+ cand = wall & (dsub <= o.max_depth)
460
+ if not cand.any():
461
+ cand = comp & (dsub <= dsub[comp].min() + p)
462
+ idx = np.argwhere(cand) + off
463
+ if len(idx) > 40_000:
464
+ idx = idx[np.sort(np.random.default_rng(0).choice(len(idx), 40_000, replace=False))]
465
+ pts = lo + idx * p
466
+ # the stress the hole sees: the worse of the site's and its mouth's
467
+ # (the nearest outside; a bending part is calm at the core, not at the skin)
468
+ mouth = lo + near[(slice(None),) + tuple(idx.T)].T * p
469
+ sv = np.maximum(stress_at(pts), stress_at(mouth))
470
+ bins = np.floor(np.maximum(sv, 0.0) / (0.05 * s_ref)) if s_ref > 0 else np.zeros(len(pts))
471
+ # height in bands of a hole radius; inside a band, a shuffled order, so
472
+ # the tries spread over the face instead of walking along one edge
473
+ h = np.round(pts @ up / r)
474
+ shuffle = np.random.default_rng(i).random(len(pts))
475
+ extent = float(np.linalg.norm(pts.max(axis=0) - pts.min(axis=0)))
476
+ sep = max(4.0 * 2 * r, 0.5 * extent / max(1, count - 1))
477
+ clear = 2.0 * (r + bw) + p # bosses of two holes do not overlap
478
+ picks = []
479
+ tried = np.zeros(len(pts), bool)
480
+ key = bins * 100_000 + h # a (stress bin, height band) group
481
+ for slot in range(count):
482
+ if slot == 0:
483
+ order = np.lexsort((shuffle, h, bins))
484
+ elif slot == 1:
485
+ order = np.lexsort((shuffle, -h, bins))
486
+ else:
487
+ dmin = np.min([np.linalg.norm(pts - q["site"], axis=1) for q in picks], axis=0)
488
+ order = np.lexsort((shuffle, -np.round(dmin / (2 * r)), bins))
489
+ # calm first (within 10% of the peak of the calmest site left), well
490
+ # apart if it can be, else anywhere the bosses do not touch; only
491
+ # then the rest of the void
492
+ calm = bins <= bins[~tried].min() + 2 if (~tried).any() else bins >= 0
493
+ for subset, apart in ((calm, sep), (calm, clear), (None, sep), (None, clear)):
494
+ site = _first_site(order if subset is None else order[subset[order]], pts, idx, key, tried, picks,
495
+ holes, apart, clear, F, outside, dist_out, lo, p, r, bw, o, g)
496
+ if site is not None:
497
+ break
498
+ if site is None:
499
+ break
500
+ site["stress"] = float(sv[site.pop("j")])
501
+ picks.append(site)
502
+ # powder farther than max_path from every hole (through the void): one
503
+ # more hole, calm first, farthest along the void, until it is not
504
+ while o.max_path and picks and len(picks) < _MAX_HOLES:
505
+ from .heatx import _geodesic
506
+
507
+ geo = _geodesic(comp, [tuple(np.floor((q["site"] - lo) / p + 0.5).astype(int) - off) for q in picks], p)
508
+ reach = geo[comp & np.isfinite(geo)]
509
+ if not reach.size or reach.max() <= o.max_path:
510
+ break
511
+ gc = geo[tuple((idx - off).T)]
512
+ gc = np.where(np.isfinite(gc), gc, 0.0)
513
+ order = np.lexsort((shuffle, -np.round(gc / (2 * r)), bins))
514
+ far = gc > 0.5 * o.max_path
515
+ calm = (bins <= bins[far & ~tried].min() + 2) & far if (far & ~tried).any() else far
516
+ site = _first_site(order[calm[order]], pts, idx, key, tried, picks, holes, clear, clear, F, outside,
517
+ dist_out, lo, p, r, bw, o, g)
518
+ if site is None:
519
+ break
520
+ site["stress"] = float(sv[site.pop("j")])
521
+ picks.append(site)
522
+ return picks
523
+
524
+
525
+ def _first_site(order, pts, idx, key, tried, picks, holes, apart, clear, F, outside, dist_out, lo, p, r, bw, o, g):
526
+ """The first workable site in `order`, at most 40 tries per (stress bin,
527
+ height band) group so a face that cannot take a hole (an edge, a
528
+ keepClear) does not use up the search, 3000 in all."""
529
+ per = {}
530
+ n_try = 0
531
+ for j in order:
532
+ if tried[j] or per.get(key[j], 0) >= 40:
533
+ continue
534
+ q = pts[j]
535
+ if any(np.linalg.norm(q - x["site"]) < apart for x in picks):
536
+ continue
537
+ if any(np.linalg.norm(q - x["site"]) < clear for x in holes):
538
+ continue
539
+ tried[j] = True
540
+ per[key[j]] = per.get(key[j], 0) + 1
541
+ site = _site(tuple(idx[j]), F, outside, dist_out, lo, p, r, bw, o, g)
542
+ if site is not None:
543
+ site["j"] = j
544
+ return site
545
+ n_try += 1
546
+ if n_try >= 3000:
547
+ return None
548
+ return None
549
+
550
+
551
+ def _site(ijk, F, outside, dist_out, lo, p, r, bw, o, g):
552
+ """A hole from void voxel ijk straight out, if there is one: the line
553
+ leaves the part once within the skin's depth and stays out; the face
554
+ round the mouth is flat out past the boss; the boss stays inside the
555
+ part and clear of the keepClear nodes. Returns the hole (site, axis,
556
+ surface point, depth, boss and bore cylinders) or None."""
557
+ dims = np.asarray(F.shape)
558
+ ijk = np.asarray(ijk)
559
+ a0 = np.clip(ijk - 1, 0, dims - 1)
560
+ a1 = np.clip(ijk + 1, 0, dims - 1)
561
+ grad = np.array([dist_out[tuple(np.where(np.arange(3) == ax, a1, ijk))] -
562
+ dist_out[tuple(np.where(np.arange(3) == ax, a0, ijk))] for ax in range(3)], float)
563
+ if np.linalg.norm(grad) < 1e-6:
564
+ return None
565
+ nrm = -grad / np.linalg.norm(grad)
566
+ pt = lo + ijk * p
567
+ depth = float(dist_out[tuple(ijk)])
568
+ t = np.arange(0.0, 1.5 * depth + 2.0 * r + 3.0 * p, 0.5 * p)
569
+ fi = (pt + np.outer(t, nrm) - lo) / p
570
+ past = ((fi < 0) | (fi > dims - 1)).any(axis=1) # off the grid is outside too
571
+ fi = np.clip(fi, 0, dims - 1)
572
+ ii = np.floor(fi + 0.5).astype(int)
573
+ out = outside[tuple(ii.T)] | past
574
+ if not out.any():
575
+ return None
576
+ e = int(np.argmax(out))
577
+ if t[e] > 1.5 * depth + 2.0 * p or not out[e:].all():
578
+ return None
579
+ Fv = ndimage.map_coordinates(F, fi.T, order=1, mode="nearest")
580
+ js = np.nonzero(Fv[:e + 1] < 0)[0]
581
+ if not len(js) or js[-1] + 1 >= len(t):
582
+ return None
583
+ j = js[-1]
584
+ t_surf = t[j] + (t[j + 1] - t[j]) * Fv[j] / (Fv[j] - Fv[j + 1])
585
+ skin = t_surf - t[js[0]] + 0.5 * p
586
+ surf = pt + nrm * t_surf
587
+ cap = 0.5 * skin # the boss ends half way through the skin
588
+ u = np.cross(nrm, [1.0, 0.0, 0.0] if abs(nrm[0]) < 0.9 else [0.0, 1.0, 0.0])
589
+ u /= np.linalg.norm(u)
590
+ v = np.cross(nrm, u)
591
+ ang = np.linspace(0.0, 2.0 * np.pi, 16, endpoint=False)
592
+ circ = np.cos(ang)[:, None] * u + np.sin(ang)[:, None] * v
593
+ reach = r + bw
594
+ ring = surf + reach * circ
595
+ fv = ndimage.map_coordinates(F, ((ring - lo) / p).T, order=1, mode="nearest")
596
+ # convex past the boss's cap pokes it out; concave is a bore, a seat or
597
+ # a fillet: a functional face, or a corner the stress gathers in
598
+ if fv.max() > 0.8 * cap or fv.min() < -0.5 * p:
599
+ return None
600
+ boss = (surf - nrm * cap, pt - nrm * bw, reach)
601
+ bore = (pt - nrm * (bw + r), surf + nrm * (1.0 + p), r)
602
+ # the boss's side and the bore's inner end stay inside the part
603
+ axial = np.linspace(cap, t_surf + bw + r, 6)
604
+ P = (surf[None, None, :] - axial[:, None, None] * nrm + reach * circ[None, :, :]).reshape(-1, 3)
605
+ fp = (P - lo) / p
606
+ if (fp < 0).any() or (fp > dims - 1).any():
607
+ return None
608
+ if outside[tuple(np.floor(fp + 0.5).astype(int).T)].any():
609
+ return None
610
+ if o.keep_clear:
611
+ P2 = np.vstack([P, surf - np.outer(axial, nrm)])
612
+ for name in o.keep_clear:
613
+ if (g.evaluate(P2, name) < o.clearance).any():
614
+ return None
615
+ return {"site": pt, "axis": nrm, "surface": surf, "depth": t_surf, "boss": boss, "bore": bore}