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/cavities.py ADDED
@@ -0,0 +1,211 @@
1
+ """
2
+ Internal ceilings: the overhangs inside a part that no support can reach.
3
+
4
+ In powder-bed metal (LPBF: AlSi10Mg, Ti64, 316L) an overhang outside the
5
+ part gets a support that is cut off afterwards. An overhang inside a closed
6
+ channel or plenum cannot: a support there stays in the part, and without
7
+ one the ceiling curls, drosses or collapses. So a heat exchanger, a manifold
8
+ or anything with internal flow paths must be self-supporting inside, along
9
+ the direction it is built, and the same part built another way up can go
10
+ from hundreds of square centimetres of flat ceiling to almost none (a box
11
+ plenum built along +z has a flat roof; built along the cube diagonal every
12
+ face of it leans 54.7 degrees).
13
+
14
+ `internal_ceilings` measures that: every face of the part whose material is
15
+ above it and whose cavity below it is enclosed (not open to the outside once
16
+ the ports are plugged), leaning flatter than `limit` from level. Faces deep
17
+ inside a TPMS core can be left out with `exclude`: a sheet gyroid or diamond
18
+ prints unsupported at the usual cell sizes, though its surface has
19
+ down-facing points.
20
+
21
+ `rank_build_directions` measures it for many directions at once (the mesh
22
+ and the cavity labelling are done once; each direction is a dot product), so
23
+ an agent can be told which way up to print before it designs for one.
24
+
25
+ The part can be a field (`field(P) -> sdf`, negative = solid, mm) over
26
+ bounds, or a mesh.
27
+
28
+ rep = internal_ceilings(field, bounds, up="+z", plugs=plugs, pitch=0.5)
29
+ best = rank_build_directions(field, bounds, plugs=plugs, pitch=0.5)[0]
30
+ """
31
+ from __future__ import annotations
32
+
33
+ import numpy as np
34
+ from scipy import ndimage
35
+
36
+ __all__ = ["CavitySurface", "cavity_surface", "internal_ceilings", "rank_build_directions",
37
+ "candidate_directions", "LIMIT"]
38
+
39
+ # self-supporting limit, degrees from level, for powder-bed metal and FDM alike
40
+ LIMIT = 45.0
41
+ # "flat": within this many degrees of level, the ceilings that fail first
42
+ FLAT = 20.0
43
+
44
+
45
+ class CavitySurface:
46
+ """The part's surface split once into faces: centres, unit normals
47
+ (out of the material), areas, and whether the space just off each face
48
+ is an enclosed cavity. Every ceiling question below is a dot product on
49
+ these."""
50
+
51
+ def __init__(self, centers, normals, areas, internal, pitch, cavity_volume):
52
+ self.centers = centers
53
+ self.normals = normals
54
+ self.areas = areas
55
+ self.internal = internal
56
+ self.pitch = pitch
57
+ self.cavity_volume = cavity_volume
58
+
59
+ def ceilings(self, up, limit=LIMIT, flat=FLAT) -> dict:
60
+ from .am import build_vector, parse_build_direction
61
+
62
+ u = build_vector(parse_build_direction(up) if isinstance(up, str) else up)
63
+ down = -(self.normals @ u) # 1 for a face looking straight down
64
+ over = self.internal & (down > np.cos(np.radians(90.0 - limit)))
65
+ flat_ = self.internal & (down > np.cos(np.radians(flat)))
66
+ out = {"up": np.round(u, 4).tolist(),
67
+ "internalOverhangArea": float(self.areas[over].sum()),
68
+ "internalFlatArea": float(self.areas[flat_].sum()),
69
+ "limit": limit, "pitch": self.pitch}
70
+ if over.any():
71
+ # where the worst of it is: the 10 mm cube holding the most flat ceiling, else overhang
72
+ from .heatx import _densest
73
+
74
+ sel = flat_ if flat_.any() else over
75
+ out["worstAt"] = _densest(self.centers[sel], self.areas[sel], 10.0)
76
+ return out
77
+
78
+
79
+ def _plugged(solid, lo, pitch, plugs):
80
+ if not plugs:
81
+ return solid
82
+ from .checks.networks import _refill_plugs
83
+
84
+ s = solid.copy()
85
+ _refill_plugs(s, np.asarray(lo, float), pitch, plugs)
86
+ return s
87
+
88
+
89
+ def _enclosed(solid):
90
+ """Void not connected to the grid's border (6-connected: a cavity behind
91
+ a one-voxel wall stays a cavity)."""
92
+ lbl, _ = ndimage.label(~solid)
93
+ border = np.unique(np.concatenate([lbl[0].ravel(), lbl[-1].ravel(), lbl[:, 0].ravel(), lbl[:, -1].ravel(),
94
+ lbl[:, :, 0].ravel(), lbl[:, :, -1].ravel()]))
95
+ return (lbl > 0) & ~np.isin(lbl, border)
96
+
97
+
98
+ def cavity_surface(part, bounds=None, pitch=None, plugs=None, exclude=None, max_cells=40_000_000) -> CavitySurface:
99
+ """Split `part` (a field with `bounds` = (lo, hi), or a trimesh) into
100
+ faces and label the ones that face an enclosed cavity. `plugs` (as for
101
+ check_networks) close the ports first, so the fluid spaces count as
102
+ enclosed; `exclude(points) -> bool` drops faces (e.g. the TPMS core's
103
+ own sheet)."""
104
+ if callable(part):
105
+ from skimage.measure import marching_cubes
106
+
107
+ lo, hi = (np.asarray(b, float) for b in bounds)
108
+ if pitch is None:
109
+ pitch = max(float(np.prod(hi - lo) / max_cells) ** (1 / 3), 0.2)
110
+ lo = lo - 0.0137 * pitch
111
+ dims = np.maximum(np.ceil((hi - lo) / pitch).astype(int) + 1, 2)
112
+ if np.prod(dims) > max_cells:
113
+ raise ValueError(f"a {pitch:g} mm grid over this part is {np.prod(dims) / 1e6:.0f}M cells; "
114
+ f"use pitch >= {float(np.prod(hi - lo) / max_cells) ** (1 / 3):.2f}")
115
+ axes = [lo[i] + np.arange(dims[i]) * pitch for i in range(3)]
116
+ F = np.empty(tuple(dims), np.float32)
117
+ for i, x in enumerate(axes[0]): # a slab at a time: the field's temporaries stay small
118
+ Y, Z = np.meshgrid(axes[1], axes[2], indexing="ij")
119
+ F[i] = np.asarray(part(np.stack([np.full(Y.size, x), Y.ravel(), Z.ravel()], 1)), float).reshape(Y.shape)
120
+ F = np.pad(F, 1, constant_values=float(3 * pitch))
121
+ lo = lo - pitch
122
+ F[F == 0] = 1e-6
123
+ verts, faces, _, _ = marching_cubes(F, 0.0, spacing=(pitch,) * 3, allow_degenerate=False)
124
+ verts += lo
125
+ tri = verts[faces]
126
+ solid = F < 0
127
+ else:
128
+ from .sdf_kit import _solid_voxels
129
+
130
+ mesh = part
131
+ if pitch is None:
132
+ pitch = max(float(np.prod(mesh.extents) / max_cells) ** (1 / 3), 0.2)
133
+ solid, origin = _solid_voxels(mesh, pitch, pad=2)
134
+ lo = origin
135
+ tri = np.asarray(mesh.triangles)
136
+ cross = np.cross(tri[:, 1] - tri[:, 0], tri[:, 2] - tri[:, 0])
137
+ areas = 0.5 * np.linalg.norm(cross, axis=1)
138
+ keep = areas > 0
139
+ normals = np.zeros_like(cross)
140
+ normals[keep] = cross[keep] / (2.0 * areas[keep, None])
141
+ centers = tri.mean(axis=1)
142
+ if callable(part):
143
+ # marching cubes on (negative = solid) winds its faces into the material: flip
144
+ normals = -normals
145
+ # vertex-averaged normals: the facets of marching cubes scatter a few degrees
146
+ g = np.stack(np.gradient(F, pitch), -1)
147
+ idx = np.clip(np.round((centers - lo) / pitch).astype(int), 0, np.asarray(F.shape) - 1)
148
+ gn = g[idx[:, 0], idx[:, 1], idx[:, 2]]
149
+ gl = np.linalg.norm(gn, axis=1)
150
+ ok = gl > 1e-9
151
+ normals[ok] = gn[ok] / gl[ok, None]
152
+ del g
153
+ cav = _enclosed(_plugged(solid, lo, pitch, plugs))
154
+ probe = centers + normals * (1.0 * pitch)
155
+ ijk = np.round((probe - lo) / pitch).astype(int)
156
+ inb = np.all((ijk >= 0) & (ijk < np.asarray(cav.shape)), axis=1)
157
+ internal = np.zeros(len(centers), bool)
158
+ internal[inb] = cav[ijk[inb, 0], ijk[inb, 1], ijk[inb, 2]]
159
+ internal &= keep
160
+ if exclude is not None:
161
+ internal &= ~np.asarray(exclude(centers), bool)
162
+ return CavitySurface(centers, normals, areas, internal, float(pitch), float(cav.sum()) * pitch ** 3)
163
+
164
+
165
+ def internal_ceilings(part, bounds=None, up="+z", pitch=None, plugs=None, exclude=None,
166
+ limit=LIMIT, flat=FLAT) -> dict:
167
+ """Area (mm^2) of the part's internal faces that lean flatter than
168
+ `limit` degrees from level when built along `up` (internalOverhangArea),
169
+ and of those within `flat` degrees of level (internalFlatArea)."""
170
+ return cavity_surface(part, bounds, pitch, plugs, exclude).ceilings(up, limit, flat)
171
+
172
+
173
+ def candidate_directions(extra: int = 0):
174
+ """The 6 axes, 12 edge diagonals and 8 corner diagonals of a cube, both
175
+ ways up, plus `extra` more spread over the sphere (Fibonacci)."""
176
+ d = []
177
+ for v in np.array(np.meshgrid([-1, 0, 1], [-1, 0, 1], [-1, 0, 1])).reshape(3, -1).T:
178
+ if v.any():
179
+ d.append(v / np.linalg.norm(v))
180
+ if extra:
181
+ k = np.arange(extra) + 0.5
182
+ phi = np.arccos(1 - 2 * k / extra)
183
+ th = np.pi * (1 + 5 ** 0.5) * k
184
+ d.extend(np.stack([np.cos(th) * np.sin(phi), np.sin(th) * np.sin(phi), np.cos(phi)], 1))
185
+ return np.asarray(d)
186
+
187
+
188
+ def rank_build_directions(part, bounds=None, pitch=None, plugs=None, exclude=None, candidates=None,
189
+ limit=LIMIT, flat=FLAT, surface: CavitySurface | None = None) -> list:
190
+ """Every candidate build direction, least internal overhang first (ties:
191
+ least flat ceiling). Each entry is internal_ceilings' report plus
192
+ "label"."""
193
+ from .am import direction_label, parse_build_direction
194
+
195
+ s = surface or cavity_surface(part, bounds, pitch, plugs, exclude)
196
+ cands = candidate_directions() if candidates is None else [
197
+ parse_build_direction(c) if isinstance(c, str) else np.asarray(c, float) for c in candidates]
198
+ out = []
199
+ for c in cands:
200
+ if isinstance(c, tuple):
201
+ from .am import build_vector
202
+ v, lab = build_vector(c), direction_label(c)
203
+ else:
204
+ v = np.asarray(c, float) / np.linalg.norm(c)
205
+ pd = parse_build_direction(v.tolist())
206
+ lab = direction_label(pd)
207
+ r = s.ceilings(v, limit, flat)
208
+ r["label"] = lab
209
+ out.append(r)
210
+ out.sort(key=lambda r: (round(r["internalOverhangArea"], 1), r["internalFlatArea"]))
211
+ return out
File without changes
loadpath/checks/dfm.py ADDED
@@ -0,0 +1,354 @@
1
+ """
2
+ Printability checks on a final part: wall thickness, overhangs, and trapped
3
+ volumes.
4
+
5
+ Watertight and manifold tell you the mesh is a valid solid. They say nothing
6
+ about whether it can be MADE. These three decide that:
7
+
8
+ min wall — a 0.4 mm rib is watertight and unprintable. Measured on a voxel
9
+ grid: twice the distance to void along the medial ridge (the
10
+ same reading verify.py uses), from a distance transform run in
11
+ tiles, so memory stays flat however many triangles the part
12
+ has. Reported in mm with the grid's tolerance (+/- one pitch).
13
+ `wallMethod: "ray"` keeps the older ray cast from each sampled
14
+ face along -normal: not quantized, but it goes through
15
+ trimesh's ray broad phase, which on a 6.6M-triangle TPMS pod
16
+ reached 63 GB. Use it only on small meshes.
17
+ overhangs — a downward-facing surface shallower than ~45 deg from
18
+ horizontal needs support. Reported as area and as a fraction
19
+ of total area, not a pass/fail, because "needs some support"
20
+ is a cost, not a defect.
21
+ trapped — an enclosed void cannot drain resin or release powder. Found by
22
+ labeling void space and keeping the components that never
23
+ reach the grid's boundary.
24
+
25
+ The part is a trimesh, or an implicit field f(P) < 0 inside (pass
26
+ spec["bounds"] = (lo, hi)); a field skips meshing and voxelizing altogether.
27
+ Overhangs need a mesh.
28
+
29
+ Resolution honesty, same contract as networks.py: the voxel probes are
30
+ verified at the reported `pitch`. A trapped void smaller than a voxel, or an
31
+ escape channel narrower than one, is below this check's resolution, and a
32
+ wall reading carries +/- pitch. When the voxel budget forces a pitch coarser
33
+ than half the minimum wall, the wall verdict is None (not checked), never a
34
+ guess.
35
+ """
36
+ import os
37
+
38
+ import numpy as np
39
+ from scipy import ndimage
40
+
41
+ from ..voxels import choose_pitch, solid_grid, wall_thickness
42
+
43
+ __all__ = ["check_dfm"]
44
+
45
+
46
+ def _env(name, default):
47
+ """LOADPATH_<name>, falling back to the older CAD_<name>."""
48
+ return os.environ.get(f"LOADPATH_{name}", os.environ.get(f"CAD_{name}", default))
49
+
50
+
51
+ _DEFAULT_MIN_WALL_MM = 1.5
52
+ _DEFAULT_OVERHANG_DEG = 45.0
53
+
54
+ # Ray budget for the optional ray-cast thickness probe (wallMethod "ray").
55
+ _MAX_THICKNESS_RAYS = int(_env("DFM_MAX_RAYS", "4000"))
56
+
57
+ # Voxel ceiling for the grid (1 byte each, plus a 4-byte label per voxel
58
+ # while trapped voids are labeled; the distance transform runs in tiles).
59
+ _VOXEL_BUDGET = int(_env("DFM_VOXEL_BUDGET", str(100_000_000)))
60
+
61
+ # Cells across the minimum wall the pitch aims for, and the fewest it will
62
+ # accept before calling the wall reading inconclusive.
63
+ _CELLS_PER_WALL = 3.0
64
+ _MIN_CELLS_PER_WALL = 2.0
65
+
66
+ # 26-connectivity for the void: the LEAKIEST choice, so a diagonal voxel
67
+ # chain counts as an escape path. Better to miss a trapped void than invent
68
+ # one: a false "trapped" reading has someone drilling a drain hole through a
69
+ # part that never needed one.
70
+ _CONN = np.ones((3, 3, 3), bool)
71
+
72
+ # A void component this small is voxelization aliasing between shell voxels,
73
+ # not a pocket.
74
+ _TINY_VOID_VOX = 32
75
+
76
+
77
+ def _choose_pitch(extents, min_wall, requested=None):
78
+ """Voxel pitch that fits the budget. Honors `requested` (else aims for
79
+ three cells across the minimum wall) unless it would blow the grid, in
80
+ which case it coarsens and the report says so."""
81
+ extents = np.asarray(extents, float)
82
+ span = float(max(extents.max(), 1e-6))
83
+ target = float(requested) if requested else max(min_wall / _CELLS_PER_WALL, span / 2048.0, 0.02)
84
+ return choose_pitch(extents, target, _VOXEL_BUDGET)
85
+
86
+
87
+ def _voxel_wall(solid, origin, pitch, min_wall):
88
+ """Grid wall thickness: (min, p1, thin area, thin fraction, spots)."""
89
+ w = wall_thickness(solid, pitch, cap_mm=_cap_mm(min_wall, pitch), limit_mm=min_wall, origin=origin)
90
+ if w["minMm"] is None:
91
+ return None, None, 0.0, 0.0, []
92
+ return w["minMm"], w["p1Mm"], w["thinAreaMm2"], w["thinSurfaceFraction"], w.get("thinSpots", [])
93
+
94
+
95
+ def _cap_mm(min_wall, pitch):
96
+ """Thickest wall the voxel probe resolves; thicker reads as this cap. It
97
+ sets the tile overlap, so it stays a few walls deep, not the whole part."""
98
+ return max(8.0 * min_wall, 16.0 * pitch)
99
+
100
+
101
+ def _ray_wall(mesh, min_wall):
102
+ """Ray-cast wall thickness (wallMethod "ray"), in mm, sampled over faces.
103
+
104
+ From each sampled face centroid, step just inside the surface and cast
105
+ along -normal; the first hit is the far wall. Returns (min, p1, area
106
+ below `min_wall`, sampled area) — p1 as well as min because a single
107
+ degenerate sliver triangle drags the minimum down and says nothing about
108
+ the part, while the 1st percentile tracks real thin regions."""
109
+ faces = np.asarray(mesh.faces)
110
+ if len(faces) == 0:
111
+ return None, None, 0.0, 0.0
112
+
113
+ areas = np.asarray(mesh.area_faces, float)
114
+ normals = np.asarray(mesh.face_normals, float)
115
+ centers = np.asarray(mesh.triangles_center, float)
116
+
117
+ n = min(len(faces), _MAX_THICKNESS_RAYS)
118
+ if n < len(faces):
119
+ # Area-weighted without replacement: sample where the surface is.
120
+ weights = areas / areas.sum() if areas.sum() > 0 else None
121
+ rng = np.random.default_rng(0) # deterministic runs
122
+ idx = rng.choice(len(faces), size=n, replace=False, p=weights)
123
+ else:
124
+ idx = np.arange(len(faces))
125
+
126
+ # Step inside by a fraction of the local triangle scale, so the ray does
127
+ # not immediately re-hit its own source face.
128
+ eps = np.maximum(np.sqrt(areas[idx]) * 1e-3, 1e-6)[:, None]
129
+ origins = centers[idx] - normals[idx] * eps
130
+ directions = -normals[idx]
131
+
132
+ # Deliberately NOT caught here. trimesh's ray engine needs `rtree`, and a
133
+ # missing optional dep must surface as a recorded probe error, never as a
134
+ # quiet "no thin walls found" — the same silent-degradation trap
135
+ # requirements.txt already warns about for shapely/rtree/mapbox-earcut.
136
+ hits, ray_idx, _tri = mesh.ray.intersects_location(
137
+ origins, directions, multiple_hits=False
138
+ )
139
+ if len(ray_idx) == 0:
140
+ return None, None, 0.0, 0.0
141
+
142
+ dist = np.linalg.norm(hits - origins[ray_idx], axis=1)
143
+ sampled_area = float(areas[idx][ray_idx].sum())
144
+ thin = dist < min_wall
145
+ return (
146
+ float(dist.min()),
147
+ float(np.percentile(dist, 1)),
148
+ float(areas[idx][ray_idx][thin].sum()),
149
+ sampled_area,
150
+ )
151
+
152
+
153
+ def _wall_thickness(part, min_wall, method="voxel", grid=None):
154
+ """Wall thickness by `method`: (min, p1, thin area mm2, thin fraction,
155
+ thin spots). `grid` = (solid, origin, pitch) for the voxel probe."""
156
+ if method == "ray":
157
+ if callable(part) and not hasattr(part, "faces"):
158
+ raise ValueError("the ray probe needs a mesh; use the voxel probe for a field")
159
+ lo, p1, thin, sampled = _ray_wall(part, min_wall)
160
+ return lo, p1, thin, (thin / sampled if sampled > 0 else 0.0), []
161
+ solid, origin, pitch = grid
162
+ return _voxel_wall(solid, origin, pitch, min_wall)
163
+
164
+
165
+ def _overhangs(mesh, overhang_deg, build_dir):
166
+ """Downward-facing area shallower than `overhang_deg` from horizontal.
167
+
168
+ A face's inclination from horizontal is acos(-n . build_dir): a normal
169
+ pointing straight down is a 0-degree (horizontal ceiling) overhang, the
170
+ worst case. Faces sitting ON the build plate are excluded — the plate
171
+ supports them."""
172
+ normals = np.asarray(mesh.face_normals, float)
173
+ areas = np.asarray(mesh.area_faces, float)
174
+ total = float(areas.sum())
175
+ if total <= 0:
176
+ return 0.0, 0.0
177
+
178
+ down = -(normals @ build_dir)
179
+ with np.errstate(invalid="ignore"):
180
+ incline = np.degrees(np.arccos(np.clip(down, -1.0, 1.0)))
181
+
182
+ # Exclude the footprint: faces whose centroid sits within one layer of
183
+ # the lowest point along the build direction rest on the plate.
184
+ heights = np.asarray(mesh.triangles_center, float) @ build_dir
185
+ on_plate = heights <= heights.min() + 1e-3
186
+
187
+ flagged = (down > 0) & (incline < overhang_deg) & ~on_plate
188
+ return float(areas[flagged].sum()), total
189
+
190
+
191
+ def _trapped_voids(solid, origin, pitch):
192
+ """Enclosed voids that cannot drain resin or release powder: label the
193
+ void with 26-connectivity and keep the components that never touch the
194
+ padded boundary. The scanline grid keeps internal cavities void (parity
195
+ sees the inner shell), which is what makes them findable here."""
196
+ lbl, count = ndimage.label(~solid, structure=_CONN)
197
+ if count == 0:
198
+ return [], 0.0
199
+ outside = set()
200
+ for face in (lbl[0], lbl[-1], lbl[:, 0], lbl[:, -1], lbl[:, :, 0], lbl[:, :, -1]):
201
+ outside.update(np.unique(face).tolist())
202
+ outside.discard(0)
203
+ sizes = np.bincount(lbl.ravel(), minlength=count + 1)
204
+ keep = [k for k in range(1, count + 1) if k not in outside and sizes[k] >= _TINY_VOID_VOX]
205
+ voxel_mm3 = pitch ** 3
206
+ voids = []
207
+ if keep:
208
+ centers = ndimage.center_of_mass(np.ones(lbl.shape, np.uint8), lbl, keep)
209
+ for k, c in zip(keep, centers):
210
+ voids.append({
211
+ "volumeMm3": round(float(sizes[k]) * voxel_mm3, 3),
212
+ "center": [round(float(v), 2) for v in (origin + np.asarray(c, float) * pitch)],
213
+ "voxels": int(sizes[k]),
214
+ })
215
+ voids.sort(key=lambda v: v["volumeMm3"], reverse=True)
216
+ return voids, float(sum(v["volumeMm3"] for v in voids))
217
+
218
+
219
+ def check_dfm(part, spec=None):
220
+ """Printability report for a final part (a trimesh, or an implicit field
221
+ with spec["bounds"]). Never raises for a geometry it cannot measure: an
222
+ unmeasurable field comes back None/empty with the rest of the report
223
+ intact, so one bad probe never fails a run.
224
+
225
+ spec: minWall (mm, 1.5), overhangDeg (45), build (vector, +z), pitch (mm,
226
+ default a third of minWall within the voxel budget), wallMethod ("voxel"
227
+ or "ray"), bounds ((lo, hi) mm, required for a field)."""
228
+ spec = spec or {}
229
+ min_wall = float(spec.get("minWall", _DEFAULT_MIN_WALL_MM))
230
+ overhang_deg = float(spec.get("overhangDeg", _DEFAULT_OVERHANG_DEG))
231
+ build_dir = np.asarray(spec.get("build", (0.0, 0.0, 1.0)), float)
232
+ norm = np.linalg.norm(build_dir)
233
+ build_dir = build_dir / norm if norm > 0 else np.array([0.0, 0.0, 1.0])
234
+ method = str(spec.get("wallMethod", "voxel"))
235
+ is_field = callable(part) and not hasattr(part, "faces")
236
+ bounds = spec.get("bounds")
237
+ if is_field:
238
+ if bounds is None:
239
+ raise ValueError("check_dfm on a field needs spec['bounds'] = (lo, hi) in mm")
240
+ bounds = tuple(np.asarray(b, float) for b in bounds)
241
+ extents = bounds[1] - bounds[0]
242
+ else:
243
+ extents = np.asarray(part.extents, float) if len(part.faces) else np.ones(3)
244
+
245
+ pitch = _choose_pitch(extents, min_wall, spec.get("pitch"))
246
+
247
+ report = {
248
+ "pitch": round(pitch, 4),
249
+ "source": "field" if is_field else "mesh",
250
+ "minWallTargetMm": min_wall,
251
+ "overhangLimitDeg": overhang_deg,
252
+ "wallMethod": method,
253
+ }
254
+ if is_field:
255
+ report["watertight"] = True # an implicit solid is closed by construction
256
+ else:
257
+ report.update(watertight=bool(part.is_watertight), triangles=int(len(part.faces)),
258
+ volumeMm3=round(float(abs(part.volume)), 3) if len(part.faces) else 0.0)
259
+
260
+ grid = None
261
+ grid_error = None
262
+ try:
263
+ solid, origin, info = solid_grid(part, pitch, pad=2, bounds=bounds)
264
+ grid = (solid, origin, pitch)
265
+ report["gridCells"] = [int(n) for n in solid.shape]
266
+ if is_field:
267
+ report["volumeMm3"] = round(float(solid.sum()) * pitch ** 3, 3)
268
+ else:
269
+ if any(info.get("unclosedColumns", {}).values()):
270
+ report["unclosedColumns"] = info["unclosedColumns"]
271
+ if info.get("overlapMm3", 0.0) > 0.0:
272
+ # bodies concatenated without a union (or a cavity shell
273
+ # facing out): parity reads their overlap as void
274
+ report["overlapMm3"] = round(info["overlapMm3"], 3)
275
+ report["overlapWarning"] = (
276
+ "the mesh has overlapping shells (bodies joined without a boolean union) or an "
277
+ "outward-facing cavity; their overlap reads as void, so walls and voids there are "
278
+ "wrong. Union the bodies first.")
279
+ except Exception as err: # noqa: BLE001 — failure-isolated per probe
280
+ grid_error = f"voxelizing failed: {err}"
281
+
282
+ try:
283
+ if method != "ray" and pitch > min_wall / _MIN_CELLS_PER_WALL + 1e-9:
284
+ # Too coarse to resolve the wall: say so rather than guess.
285
+ report["minWallOk"] = None
286
+ report["minWallInconclusive"] = (
287
+ f"pitch {pitch:g} mm (voxel budget) is coarser than half the {min_wall:g} mm minimum "
288
+ "wall; pass a smaller part, a finer pitch with a larger LOADPATH_DFM_VOXEL_BUDGET, or "
289
+ "bounds around the region to check")
290
+ else:
291
+ if method != "ray" and grid is None:
292
+ raise RuntimeError(grid_error)
293
+ thin_min, thin_p1, thin_area, thin_frac, spots = _wall_thickness(part, min_wall, method, grid)
294
+ report["minWallMm"] = None if thin_min is None else round(thin_min, 3)
295
+ report["wallP1Mm"] = None if thin_p1 is None else round(thin_p1, 3)
296
+ # the voxel reading is within half a cell of the true thickness,
297
+ # on a grid that itself staircases by half a cell
298
+ report["wallToleranceMm"] = 0.0 if method == "ray" else round(pitch, 4)
299
+ if method != "ray":
300
+ # members thicker than this all read as about this
301
+ report["wallCapMm"] = round(_cap_mm(min_wall, pitch), 3)
302
+ report["thinAreaMm2"] = round(thin_area, 3)
303
+ report["thinAreaFraction"] = round(thin_frac, 4)
304
+ if spots:
305
+ report["thinSpots"] = spots
306
+ # Judge on the 1st percentile, not the raw minimum: one sliver
307
+ # must not condemn an otherwise printable part. Give the voxel
308
+ # reading its half-cell estimator error, not the whole tolerance.
309
+ slack = 0.0 if method == "ray" else 0.5 * pitch
310
+ report["minWallOk"] = thin_p1 is None or thin_p1 + slack >= min_wall
311
+ if thin_p1 is not None and method != "ray" and abs(thin_p1 - min_wall) <= pitch:
312
+ report["minWallBorderline"] = True
313
+ except Exception as err: # noqa: BLE001
314
+ # Unknown, NOT pass. Only checks that RAN are ever counted as verified.
315
+ report["minWallError"] = str(err)
316
+ report["minWallOk"] = None
317
+
318
+ if is_field:
319
+ report["overhangError"] = "overhangs need a mesh; pass the meshed part"
320
+ else:
321
+ try:
322
+ over_area, total_area = _overhangs(part, overhang_deg, build_dir)
323
+ report["overhangAreaMm2"] = round(over_area, 3)
324
+ report["overhangFraction"] = (
325
+ round(over_area / total_area, 4) if total_area > 0 else 0.0
326
+ )
327
+ except Exception as err: # noqa: BLE001
328
+ report["overhangError"] = str(err)
329
+
330
+ try:
331
+ if grid is None:
332
+ raise RuntimeError(grid_error)
333
+ voids, trapped_mm3 = _trapped_voids(*grid)
334
+ report["trappedVoids"] = voids[:10]
335
+ report["trappedVoidCount"] = len(voids)
336
+ report["trappedVolumeMm3"] = round(trapped_mm3, 3)
337
+ report["drainsOk"] = len(voids) == 0
338
+ except Exception as err: # noqa: BLE001
339
+ report["trappedError"] = str(err)
340
+ report["drainsOk"] = None
341
+
342
+ # `ok` is "every probe RAN and passed": an unrun probe (None) makes the
343
+ # part unverified, not fine. `ok: False` with a *Error or *Inconclusive
344
+ # field beside it means "could not check", not a geometry defect; that is
345
+ # what the explicit `probesRan` flag is for.
346
+ report["probesRan"] = (
347
+ report.get("minWallOk") is not None and report.get("drainsOk") is not None
348
+ )
349
+ report["ok"] = bool(
350
+ report.get("watertight")
351
+ and report.get("minWallOk") is True
352
+ and report.get("drainsOk") is True
353
+ )
354
+ return report
@@ -0,0 +1,7 @@
1
+ """Moved to `loadpath.exchanger` (it generates geometry; it is not a check). This
2
+ alias keeps `loadpath.checks.exchanger` imports working."""
3
+ import sys
4
+
5
+ from .. import exchanger as _moved
6
+
7
+ sys.modules[__name__] = _moved