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
@@ -0,0 +1,376 @@
1
+ """
2
+ Can a powder-bed polymer (SLS, MJF) or resin (SLA, DLP) printer make it?
3
+
4
+ FDM and LPBF parts fail on overhangs and bridges (printcheck.py, cavities.py).
5
+ These processes fail elsewhere:
6
+
7
+ powder bed (SLS, MJF: PA12, PA11, TPU) - the part sits in its own powder,
8
+ so nothing needs support, but
9
+ walls below ~0.8 mm break in depowdering;
10
+ powder inside a cavity has to come out: a fully enclosed void
11
+ keeps it (and the weight), and a chamber that only reaches
12
+ the outside through a slot narrower than an escape hole
13
+ (~4-5 mm) keeps most of it;
14
+ warp long thin flat panels cool unevenly and curl: under ~2 mm
15
+ thick and over ~100 mm long, a plate bows.
16
+ resin vat (SLA, DLP, bottom-up) - layers are peeled off a film, so
17
+ walls below ~0.6 mm (unsupported) tear or droop;
18
+ supports every overhang past 45 degrees and every island (a point
19
+ that starts in mid-air) needs a support, which leaves
20
+ marks; the count and volume come from printcheck;
21
+ peel a large cross-section pulls hard on every peel: tilt it;
22
+ cups a void open only toward the vat, sealed on the plate side
23
+ (a cup), is a suction cup at every peel until it closes: it
24
+ tears layers or the part off the plate. Vent it near the
25
+ plate side, or turn it over;
26
+ resin trapped in a closed cavity, or behind a drain narrower than
27
+ ~3.5 mm, stays liquid inside and cracks the part later.
28
+
29
+ Everything is read off one voxel grid of the part (a trimesh, or an implicit
30
+ field with bounds) at a pitch fine enough for the process's minimum wall,
31
+ within a cell budget. A probe that could not run says so (None), it never
32
+ guesses. The numbers are heuristics with published-range thresholds (RULES),
33
+ meant to flag what a print bureau would reject or a print would lose, not to
34
+ replace the bureau's own check.
35
+
36
+ rep = check_process(mesh, "mjf", build="+z", material="pa12")
37
+ rep["ok"], rep["warnings"]
38
+ """
39
+ from __future__ import annotations
40
+
41
+ import numpy as np
42
+ from scipy import ndimage
43
+
44
+ __all__ = ["RULES", "POWDER", "RESIN", "check_process", "rules_for"]
45
+
46
+ POWDER = ("sls", "mjf")
47
+ RESIN = ("sla", "dlp")
48
+
49
+ # Typical published design-guide values (EOS/Formlabs Fuse/HP MJF and
50
+ # Formlabs/Asiga resin guides), on the conservative side. mm, mm^2.
51
+ RULES = {
52
+ "sls": {"minWall": 0.8, "escapeHole": 4.0, "escapeHoles": 2, "minClearance": 0.5,
53
+ "warpThickness": 2.0, "warpSpan": 100.0,
54
+ "note": "SLS nylon: no supports; leave >= 2 escape holes of >= 4 mm in hollow parts; long flat "
55
+ "panels under 2 mm warp"},
56
+ "mjf": {"minWall": 0.8, "escapeHole": 5.0, "escapeHoles": 2, "minClearance": 0.4,
57
+ "warpThickness": 2.0, "warpSpan": 100.0,
58
+ "note": "HP Multi Jet Fusion: as SLS; HP asks for >= 2 escape holes of >= 5 mm"},
59
+ "sla": {"minWall": 0.6, "minSupportedWall": 0.4, "escapeHole": 3.5, "escapeHoles": 2, "minClearance": 0.5,
60
+ "peelArea": 2500.0, "cupArea": 10.0,
61
+ "note": "resin vat (bottom-up SLA): supports below 45 degrees and at islands; drains >= 3.5 mm "
62
+ "plus a vent near the plate in hollow parts; tilt large cross-sections"},
63
+ "dlp": {"minWall": 0.6, "minSupportedWall": 0.4, "escapeHole": 3.5, "escapeHoles": 2, "minClearance": 0.3,
64
+ "peelArea": 2000.0, "cupArea": 10.0,
65
+ "note": "resin vat (DLP/LCD, bottom-up): as SLA; the whole layer cures at once, so peel force "
66
+ "grows with cross-section faster"},
67
+ }
68
+ # Material-specific changes to a process's rules: TPU powder tears thin walls
69
+ # out in depowdering and does not warp like nylon.
70
+ _MATERIAL_RULES = {
71
+ "tpu88a": {"minWall": 1.0, "warpSpan": None},
72
+ }
73
+ # Cell budget for the grid (one byte per cell, plus float distance fields).
74
+ _BUDGET = 12_000_000
75
+ # Cells per minimum wall the pitch aims for; below 2 the wall verdict is None.
76
+ _CELLS_PER_WALL = 2.5
77
+ _EIGHT = np.ones((3, 3), bool)
78
+ _CONN = np.ones((3, 3, 3), bool)
79
+
80
+
81
+ def rules_for(process: str, material=None, overrides: dict | None = None) -> dict:
82
+ """The rule card for a process (RULES), adjusted for the material and
83
+ any overrides (same keys)."""
84
+ kind = str(getattr(process, "kind", process)).lower()
85
+ if kind not in RULES:
86
+ raise ValueError(f"check_process covers {sorted(RULES)}; got {kind!r} (FDM and LPBF: printcheck, cavities)")
87
+ out = dict(RULES[kind])
88
+ mat = str(getattr(material, "name", material) or "").lower()
89
+ out.update(_MATERIAL_RULES.get(mat, {}))
90
+ if getattr(process, "escape_hole", None):
91
+ out["escapeHole"] = float(process.escape_hole)
92
+ out.update({k: v for k, v in (overrides or {}).items() if v is not None})
93
+ min_feature = getattr(process, "min_feature", None)
94
+ if min_feature is not None:
95
+ out["minWall"] = max(out["minWall"], float(min_feature))
96
+ out["process"] = kind
97
+ return out
98
+
99
+
100
+ def _grid(part, rot, bounds, pitch, pad=2):
101
+ """Solid cells of the part with the build direction turned to +z (k = 0
102
+ at the plate). Returns (solid, origin in the build frame)."""
103
+ from .voxels import solid_grid
104
+
105
+ if callable(part) and not hasattr(part, "faces"):
106
+ lo, hi = (np.asarray(b, float) for b in bounds)
107
+ corners = np.array([[x, y, z] for x in (lo[0], hi[0]) for y in (lo[1], hi[1]) for z in (lo[2], hi[2])])
108
+ rc = corners @ rot.T
109
+ # world = rot^T @ build, so as row vectors P_world = P_build @ rot
110
+ field = part if np.allclose(rot, np.eye(3)) else (lambda p: part(np.asarray(p) @ rot))
111
+ solid, origin, _ = solid_grid(field, pitch, pad=pad, bounds=(rc.min(axis=0), rc.max(axis=0)))
112
+ return solid, origin
113
+ m = part.copy()
114
+ m.apply_transform(np.block([[rot, np.zeros((3, 1))], [np.zeros((1, 3)), np.ones((1, 1))]]))
115
+ solid, origin, _ = solid_grid(m, pitch, pad=pad)
116
+ return solid, origin
117
+
118
+
119
+ def _choose_pitch(extents, min_wall, pitch=None, budget=_BUDGET):
120
+ if pitch:
121
+ return float(pitch)
122
+ want = min_wall / _CELLS_PER_WALL
123
+ floor = float((np.prod(np.asarray(extents, float) + 4 * want) / budget) ** (1.0 / 3.0))
124
+ return max(want, floor)
125
+
126
+
127
+ def _walls(solid, pitch, origin, min_wall):
128
+ from .voxels import wall_thickness
129
+
130
+ if pitch > min_wall / 2.0 + 1e-9:
131
+ return {"minWallMm": None, "ok": None,
132
+ "inconclusive": f"pitch {pitch:.3g} mm (cell budget) is coarser than half the {min_wall:g} mm wall"}
133
+ w = wall_thickness(solid, pitch, cap_mm=4.0 * min_wall, limit_mm=min_wall, spots=5, origin=origin)
134
+ p1 = w.get("p1Mm")
135
+ out = {"minWallMm": w.get("minMm"), "p1WallMm": p1, "thinAreaMm2": w.get("thinAreaMm2", 0.0),
136
+ "thinSpots": w.get("thinSpots", []), "toleranceMm": pitch,
137
+ # judged on the 1st percentile with the half-cell estimator error,
138
+ # as check_dfm does: one sliver does not condemn a part
139
+ "ok": p1 is None or p1 + 0.5 * pitch >= min_wall,
140
+ "basis": "ok is judged on p1WallMm (1st percentile, +/- half a cell); minWallMm is the single "
141
+ "thinnest spot, which can sit under the minimum on a passing part (a sliver at an edge)"}
142
+ return out
143
+
144
+
145
+ def _escapes(solid, pitch, origin, hole, rot):
146
+ """Enclosed voids, and chambers that only reach the outside through
147
+ openings narrower than `hole` mm (a ball that size cannot get out)."""
148
+ void = ~solid
149
+ lbl, n = ndimage.label(void, _CONN)
150
+ border = set()
151
+ for face in (lbl[0], lbl[-1], lbl[:, 0], lbl[:, -1], lbl[:, :, 0], lbl[:, :, -1]):
152
+ border.update(np.unique(face).tolist())
153
+ border.discard(0)
154
+ sizes = np.bincount(lbl.ravel(), minlength=n + 1)
155
+ cell3 = pitch ** 3
156
+ world = lambda ijk: [round(float(c), 2) for c in rot.T @ (origin + (np.asarray(ijk, float) + 0.5) * pitch)] # noqa: E731
157
+ enclosed = []
158
+ tiny = max(8, int(np.ceil(1.0 / cell3))) # under ~1 mm^3 is aliasing, not a pocket
159
+ keep = [k for k in range(1, n + 1) if k not in border and sizes[k] >= tiny]
160
+ if keep:
161
+ for k, c in zip(keep, ndimage.center_of_mass(void, lbl, keep)):
162
+ enclosed.append({"volumeMm3": round(float(sizes[k] * cell3), 2), "center": world(c)})
163
+ enclosed.sort(key=lambda v: -v["volumeMm3"])
164
+
165
+ # balls of the escape hole's size: their centres (wide), the ones that
166
+ # can roll out to the boundary, and the void they sweep
167
+ r = 0.5 * hole / pitch
168
+ dist = ndimage.distance_transform_edt(void)
169
+ wide = dist >= r
170
+ wl, wn = ndimage.label(wide, _CONN)
171
+ wborder = set()
172
+ for face in (wl[0], wl[-1], wl[:, 0], wl[:, -1], wl[:, :, 0], wl[:, :, -1]):
173
+ wborder.update(np.unique(face).tolist())
174
+ wborder.discard(0)
175
+ chambers = []
176
+ if wn:
177
+ inner = [k for k in range(1, wn + 1) if k not in wborder]
178
+ if inner:
179
+ # only chambers in void that does reach the outside (the rest are
180
+ # enclosed, above): a wide component lies in one void component
181
+ host = ndimage.maximum(lbl, wl, inner)
182
+ inner = [k for k, h in zip(inner, np.atleast_1d(host)) if int(h) in border]
183
+ if inner:
184
+ # what the chambers' balls sweep: their centres grown back by r
185
+ sel = np.isin(wl, inner)
186
+ swept = (ndimage.distance_transform_edt(~sel) <= r) & void
187
+ gl, gn = ndimage.label(swept, _CONN)
188
+ vols = np.bincount(gl.ravel(), minlength=gn + 1)
189
+ ball = 4.0 / 3.0 * np.pi * (0.5 * hole) ** 3
190
+ big = [k for k in range(1, gn + 1) if vols[k] * cell3 >= 2.0 * ball]
191
+ if big:
192
+ for k, c in zip(big, ndimage.center_of_mass(swept, gl, big)):
193
+ chambers.append({"volumeMm3": round(float(vols[k] * cell3), 1), "center": world(c)})
194
+ chambers.sort(key=lambda v: -v["volumeMm3"])
195
+ return {
196
+ "escapeHoleMm": hole,
197
+ "enclosedVoids": enclosed[:10], "enclosedCount": len(enclosed),
198
+ "enclosedVolumeMm3": round(float(sum(v["volumeMm3"] for v in enclosed)), 2),
199
+ "narrowExitChambers": chambers[:10], "narrowExitCount": len(chambers),
200
+ "narrowExitVolumeMm3": round(float(sum(v["volumeMm3"] for v in chambers)), 1),
201
+ }
202
+
203
+
204
+ def _panels(solid, pitch, origin, rot, t_warp, span_warn):
205
+ """Thin flat panels: the medial ridge of members thinner than t_warp,
206
+ grouped, kept when flat and filled in (a sheet, not a strut or the
207
+ medial lines running into a thick part's corners)."""
208
+ edt = ndimage.distance_transform_edt(np.pad(solid, 1))[1:-1, 1:-1, 1:-1]
209
+ ridge = solid & (edt >= ndimage.maximum_filter(edt, size=3)) & (edt > 0)
210
+ thick = (2.0 * edt - 0.5) * pitch
211
+ thin = ridge & (thick < t_warp)
212
+ if not thin.any():
213
+ return {"warpThicknessMm": t_warp, "panels": [], "maxSpan": 0.0}
214
+ lbl, n = ndimage.label(ndimage.binary_dilation(thin, _CONN) & solid, _CONN)
215
+ panels = []
216
+ for k, sl in enumerate(ndimage.find_objects(lbl), start=1):
217
+ if sl is None:
218
+ continue
219
+ sub = (lbl[sl] == k) & thin[sl]
220
+ pts = np.argwhere(sub).astype(float)
221
+ if len(pts) < 20:
222
+ continue
223
+ pts = (pts + [s.start for s in sl] + 0.5) * pitch
224
+ c = pts.mean(axis=0)
225
+ _, _, vt = np.linalg.svd(pts - c, full_matrices=False)
226
+ proj = (pts - c) @ vt.T
227
+ ext = np.ptp(proj, axis=0) + pitch
228
+ span, width, depth = float(ext[0]), float(ext[1]), float(ext[2])
229
+ # a ridge sheet is a cell or two thick: area ~ ridge cells x pitch^2
230
+ area = float(len(pts) * pitch * pitch)
231
+ if width < 4 * t_warp or depth > max(2.0 * t_warp, 0.15 * width) or area < 0.3 * span * width:
232
+ continue
233
+ panels.append({"span": round(span, 1), "width": round(width, 1), "area": round(min(area, span * width), 0),
234
+ "thickness": round(float(np.median(thick[sl][sub])), 2),
235
+ "center": [round(float(v), 2) for v in rot.T @ (origin + c)],
236
+ "normal": [round(float(v), 3) for v in rot.T @ vt[2]]})
237
+ panels.sort(key=lambda p: -p["span"])
238
+ out = {"warpThicknessMm": t_warp, "panels": panels[:5], "maxSpan": panels[0]["span"] if panels else 0.0}
239
+ if span_warn is not None:
240
+ out["ok"] = out["maxSpan"] < span_warn
241
+ return out
242
+
243
+
244
+ def _layers(solid, pitch):
245
+ """Per layer from the plate: cross-section area and suction cups (void
246
+ in the layer enclosed by it and sealed below by part, plate or an
247
+ earlier cup)."""
248
+ a = pitch * pitch
249
+ nz = solid.shape[2]
250
+ area = solid.sum(axis=(0, 1)) * a
251
+ sealed_prev = None
252
+ cup_area = np.zeros(nz)
253
+ worst = None
254
+ for k in range(nz):
255
+ layer = solid[:, :, k]
256
+ if not layer.any():
257
+ sealed_prev = np.zeros_like(layer)
258
+ continue
259
+ lbl, n = ndimage.label(~layer, _EIGHT)
260
+ if n == 0:
261
+ sealed_prev = np.zeros_like(layer)
262
+ continue
263
+ edge = set(np.unique(np.concatenate([lbl[0], lbl[-1], lbl[:, 0], lbl[:, -1]])).tolist())
264
+ if k == 0 or sealed_prev is None:
265
+ leak = np.zeros_like(layer) # the plate seals the first layer
266
+ else:
267
+ leak = ~solid[:, :, k - 1] & ~sealed_prev
268
+ leaky = set(np.unique(lbl[leak & (lbl > 0)]).tolist()) | edge
269
+ sealed = (lbl > 0) & ~np.isin(lbl, list(leaky))
270
+ cup_area[k] = sealed.sum() * a
271
+ if sealed.any() and (worst is None or cup_area[k] > worst[1]):
272
+ worst = (k, cup_area[k], np.argwhere(sealed).mean(axis=0))
273
+ sealed_prev = sealed
274
+ return area, cup_area, worst
275
+
276
+
277
+ def check_process(part, process="sls", build=None, material=None, pitch: float | None = None,
278
+ bounds=None, rules: dict | None = None, slicer: bool = True) -> dict:
279
+ """Process checks for powder-bed polymer (sls, mjf) and resin (sla, dlp)
280
+ parts. `part` is a trimesh (mm) or a field f(P) < 0 inside with
281
+ `bounds`. `process` is a name or a spec.Process; `build` the direction
282
+ layers stack ((axis, sign), a vector or '+z'; default the process's,
283
+ else +z), which only resin and the layer report depend on. `rules`
284
+ overrides any RULES entry. slicer=True (meshes, resin) adds printcheck's
285
+ support and island read."""
286
+ from .am import parse_build_direction
287
+ from .printcheck import rotation_to_z
288
+
289
+ kind = str(getattr(process, "kind", process)).lower()
290
+ card = rules_for(process, material, rules)
291
+ if build is None:
292
+ build = getattr(process, "build", None)
293
+ if build is None or build == "auto":
294
+ build = (2, 1)
295
+ if isinstance(build, str):
296
+ build = parse_build_direction(build)
297
+ rot = rotation_to_z(build)
298
+ is_field = callable(part) and not hasattr(part, "faces")
299
+ if is_field:
300
+ if bounds is None:
301
+ raise ValueError("check_process on a field needs bounds = (lo, hi) in mm")
302
+ extents = np.asarray(bounds[1], float) - np.asarray(bounds[0], float)
303
+ else:
304
+ extents = np.asarray(part.extents, float)
305
+ ext_b = np.abs(rot) @ extents
306
+ p = _choose_pitch(ext_b, card["minWall"], pitch)
307
+ # pad past an escape ball's radius, so balls outside the part reach the
308
+ # grid's boundary however the part sits in its box
309
+ solid, origin = _grid(part, rot, bounds, p, pad=int(np.ceil(0.5 * card["escapeHole"] / p)) + 3)
310
+ vol = float(solid.sum() * p ** 3)
311
+ rep = {"process": kind, "rules": card, "pitch": round(p, 4), "volumeMm3": round(vol, 1),
312
+ "buildDirection": [round(float(c), 4) for c in rot.T @ np.array([0.0, 0.0, 1.0])],
313
+ "material": getattr(material, "name", material)}
314
+ warnings = []
315
+ mat = getattr(material, "name", material)
316
+ procs = getattr(material, "processes", None)
317
+ if procs and kind not in procs:
318
+ warnings.append(f"{mat} is not a {kind} material (made by {', '.join(procs)}): pick one of the "
319
+ f"{kind} cards in materials.MATERIALS")
320
+
321
+ rep["walls"] = _walls(solid, p, origin, card["minWall"])
322
+ if rep["walls"]["ok"] is False:
323
+ warnings.append(f"walls down to {rep['walls']['p1WallMm']:.2f} mm (1st percentile) are under the "
324
+ f"{card['minWall']:g} mm {kind} minimum ({rep['walls']['thinAreaMm2']:.0f} mm^2 of surface)")
325
+
326
+ esc = _escapes(solid, p, origin, card["escapeHole"], rot)
327
+ rep["escape"] = esc
328
+ what = "powder" if kind in POWDER else "resin"
329
+ if esc["enclosedCount"]:
330
+ warnings.append(f"{esc['enclosedCount']} enclosed void(s) ({esc['enclosedVolumeMm3']:.0f} mm^3) keep their "
331
+ f"{what}: add >= {card['escapeHoles']} holes of >= {card['escapeHole']:g} mm into each")
332
+ if esc["narrowExitCount"]:
333
+ warnings.append(f"{esc['narrowExitCount']} chamber(s) ({esc['narrowExitVolumeMm3']:.0f} mm^3) open only "
334
+ f"through gaps under {card['escapeHole']:g} mm: {what} will not clear; widen an exit")
335
+ rep["trappedVolumeMm3"] = round(esc["enclosedVolumeMm3"] + esc["narrowExitVolumeMm3"], 1)
336
+
337
+ if kind in POWDER:
338
+ rep["warp"] = _panels(solid, p, origin, rot, card["warpThickness"], card.get("warpSpan"))
339
+ if rep["warp"].get("ok") is False:
340
+ w = rep["warp"]["panels"][0]
341
+ warnings.append(f"a {w['span']:.0f} x {w['width']:.0f} mm panel only {w['thickness']:.1f} mm thick will "
342
+ f"warp in {kind}: thicken it past {card['warpThickness']:g} mm, rib it, or break it up")
343
+ else:
344
+ area, cups, worst = _layers(solid, p)
345
+ rep["peel"] = {"maxLayerArea": round(float(area.max()), 1),
346
+ "atHeight": round(float((int(area.argmax()) + 0.5) * p), 2), "limit": card["peelArea"]}
347
+ if area.max() > card["peelArea"]:
348
+ warnings.append(f"a {area.max():.0f} mm^2 layer pulls hard on every peel: tilt the part 15-30 degrees "
349
+ "off the plate")
350
+ rep["cups"] = {"maxArea": round(float(cups.max()), 1), "layers": int((cups > card["cupArea"]).sum()),
351
+ "volumeMm3": round(float(cups.sum() * p), 1)}
352
+ if worst is not None:
353
+ ij = np.r_[worst[2], worst[0]]
354
+ rep["cups"]["at"] = [round(float(c), 2) for c in rot.T @ (origin + (ij + 0.5) * p)]
355
+ if cups.max() > card["cupArea"]:
356
+ warnings.append(f"a {cups.max():.0f} mm^2 suction cup (a void sealed on the plate side, open to the "
357
+ "vat) pulls at every peel: vent it near the plate, or turn the part over")
358
+ if slicer and not is_field:
359
+ from .printcheck import check_print
360
+
361
+ pc = check_print(part, build, process=kind, material=material,
362
+ bridge_span=getattr(process, "bridge_span", None))
363
+ rep["supports"] = {k: pc[k] for k in ("supportArea", "supportVolume", "islands", "islandArea",
364
+ "maxBridgeSpan")}
365
+ if pc["islands"]:
366
+ warnings.append(f"{pc['islands']} island(s) start in mid-air: the slicer must put a support "
367
+ "under each (check they are not on a mating face)")
368
+ rep["warnings"] = warnings
369
+ probes = [rep["walls"]["ok"]]
370
+ if kind in POWDER and "ok" in rep["warp"]:
371
+ probes.append(rep["warp"]["ok"])
372
+ rep["probesRan"] = all(v is not None for v in probes)
373
+ rep["ok"] = bool(all(v is True for v in probes) and rep["trappedVolumeMm3"] < 1.0
374
+ and (kind in POWDER or (rep["cups"]["maxArea"] <= card["cupArea"]
375
+ and rep["peel"]["maxLayerArea"] <= card["peelArea"])))
376
+ return rep
loadpath/quick.py ADDED
@@ -0,0 +1,113 @@
1
+ """
2
+ Quick mode: every command on a coarse grid, for a first look in a minute or
3
+ two and for the tutorial tests (tests/test_tutorials.py), which run each
4
+ tutorial's commands this way so the docs can't drift from the code.
5
+
6
+ loadpath run study.json --quick # or LOADPATH_QUICK=1 for anything
7
+
8
+ What it lowers: a study's cells, optimizer iterations and outcome-search runs
9
+ (the evals' coarse tier, evals/run.py COARSE), a heat exchanger's grid and
10
+ whole-exchanger simulation, a field graph's mesh pitch (and its drain search's)
11
+ and the analysis behind its `result` nodes (no hotspot submodels), `tune`'s
12
+ analyses, `assembly`'s sampled poses, `verify`'s grid and `buildsim`'s voxels.
13
+ Numbers move (mass a few %, safety factor
14
+ more on thin members): read a quick run for direction, run without it to print.
15
+ """
16
+ from __future__ import annotations
17
+
18
+ import copy
19
+ import os
20
+
21
+ # the evals' coarse tier, so a quick run and `evals.run --tier coarse` agree
22
+ STUDY = {"maxElements": 6000, "maxIterations": 30, "searchRuns": 3, "confirmRuns": 0}
23
+ HX_STEP = 1.6 # x the heat exchanger's grid step (default wall / 3)
24
+ HX_SIM_VOXEL = 1.5 # x the whole-exchanger simulation's voxel
25
+ FIELD_CELLS = 600_000 # mesh grid cells for a field graph without its own pitch
26
+ FIELD_PITCH = 1.6 # x a field graph's own mesh pitch
27
+ RESULT_CELLS = 30_000 # analysis cells behind a field graph's `result` node (default 120k)
28
+ TUNE_EVALUATIONS = 6
29
+ ASSEMBLY_SAMPLES = 150
30
+ VERIFY_CELLS = 20_000 # `verify`'s analysis grid
31
+ BUILDSIM_CELLS = 1_500 # `buildsim`'s part cells (default 60k; the plate adds as many again)
32
+
33
+
34
+ def on() -> bool:
35
+ return os.environ.get("LOADPATH_QUICK", "").strip().lower() not in ("", "0", "false", "no")
36
+
37
+
38
+ def enable():
39
+ """Quick mode for this process and everything it starts."""
40
+ os.environ["LOADPATH_QUICK"] = "1"
41
+
42
+
43
+ def study(d: dict) -> dict:
44
+ """A study dict as quick mode runs it (a copy; `d` itself when off).
45
+ Also lowers study.py's search limits for this process."""
46
+ if not on() or not isinstance(d, dict):
47
+ return d
48
+ d = copy.deepcopy(d)
49
+ cells = STUDY["maxElements"]
50
+ want = ((d.get("expect") or {}).get("coarse") or {}).get("maxElements")
51
+ if want:
52
+ cells = max(cells, int(want))
53
+ res = dict(d.get("resolution") or {})
54
+ res.pop("pitch", None)
55
+ res["maxElements"] = min(int(res.get("maxElements", 80000)), cells)
56
+ d["resolution"] = res
57
+ d["maxIterations"] = min(int(d.get("maxIterations", 120)), STUDY["maxIterations"])
58
+ from . import study as st
59
+ st._MAX_SEARCH_RUNS = min(st._MAX_SEARCH_RUNS, STUDY["searchRuns"])
60
+ st._CONFIRM_RUNS = min(st._CONFIRM_RUNS, STUDY["confirmRuns"])
61
+ return d
62
+
63
+
64
+ def heatx(hx, sim_opts):
65
+ """Coarsen a HeatExchanger's grid in place; the simulation options as
66
+ quick mode runs them."""
67
+ if not on():
68
+ return sim_opts
69
+ # coarser, but at most half the wall: heatx.py refuses a grid past that
70
+ hx.step = min((hx.step or hx.wall / 3) * HX_STEP, hx.wall * 0.45)
71
+ if sim_opts is not None and sim_opts.get("voxel"):
72
+ sim_opts = {**sim_opts, "voxel": float(sim_opts["voxel"]) * HX_SIM_VOXEL}
73
+ return sim_opts
74
+
75
+
76
+ def mesh_pitch(pitch, bounds):
77
+ """A field graph's mesh pitch in quick mode: its own x FIELD_PITCH, or
78
+ the box over FIELD_CELLS cells (the default is 4M)."""
79
+ if not on():
80
+ return pitch
81
+ if pitch:
82
+ return float(pitch) * FIELD_PITCH
83
+ import numpy as np
84
+ ext = np.asarray(bounds[1], float) - np.asarray(bounds[0], float)
85
+ return float((np.prod(ext) / FIELD_CELLS) ** (1 / 3))
86
+
87
+
88
+ def result_cells(n):
89
+ """Analysis cells behind a field graph's `result` node."""
90
+ return min(int(n), RESULT_CELLS) if on() else n
91
+
92
+
93
+ def result_refine(n):
94
+ """Hotspot submodels behind a `result` node (verify_part's refine): none
95
+ in quick mode. They sharpen the reported peak, not the field the graph
96
+ reads, and cost most of the analysis."""
97
+ return 0 if on() else n
98
+
99
+
100
+ def tune_evaluations(n):
101
+ return min(int(n), TUNE_EVALUATIONS) if on() else n
102
+
103
+
104
+ def assembly_samples(n: int) -> int:
105
+ return min(int(n), ASSEMBLY_SAMPLES) if on() else n
106
+
107
+
108
+ def verify_cells(n: int) -> int:
109
+ return min(int(n), VERIFY_CELLS) if on() else n
110
+
111
+
112
+ def buildsim_cells(n: int) -> int:
113
+ return min(int(n), BUILDSIM_CELLS) if on() else n