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/printcheck.py ADDED
@@ -0,0 +1,263 @@
1
+ """
2
+ Can it be printed as it stands, in this orientation, without supports?
3
+
4
+ verify._overhang counts down-facing surface by its normal. That is the right
5
+ first number, but it calls every flat ceiling a defect, and FDM prints many
6
+ of them fine: a ceiling whose every point is held at both ends by walls
7
+ already printed is a bridge, and a slicer strings filament across it. It
8
+ also says nothing about the first layer, which is where most failed prints
9
+ fail. This module slices the part along the build direction and reads each
10
+ layer the way a slicer would:
11
+
12
+ support - cells of a layer with nothing under them within 45 degrees
13
+ (Langelaar's stencil, the same one am.AMFilter designs to),
14
+ grouped into regions per layer. A region is a bridge when a
15
+ straight span through every one of its cells, in one common
16
+ direction, lands on supported material at both ends and is no
17
+ longer than `bridge_span`; otherwise it needs support. Short
18
+ ledges (`ledge` mm, a few extrusion widths) print as overhang.
19
+ islands - regions with no printed material beside them at all: a new
20
+ start in mid-air, which no slicer can print unsupported.
21
+ bed - the first layer: contact area, how many separate pieces touch
22
+ the bed and the smallest, whether the centre of mass sits over
23
+ the contact's hull (and by how much), and how tall the part
24
+ stands over its narrowest base width. A part that tips, or a
25
+ 2 mm^2 foot, fails before anything else matters.
26
+ support volume - what a slicer would add, column by column from every
27
+ unbridged cell down to the part or the bed: a cost to compare
28
+ orientations by, not a slicer's exact number.
29
+
30
+ The part is voxelized at `pitch` (the layer here is a cell, not the printer's
31
+ layer height: 45-degree geometry is scale-free, bridges and ledges are
32
+ measured in mm). Results carry the pitch they are good to.
33
+ """
34
+ from __future__ import annotations
35
+
36
+ import numpy as np
37
+ from scipy import ndimage
38
+
39
+ __all__ = ["check_print", "rotation_to_z", "BRIDGE_SPAN"]
40
+
41
+ # Unsupported spans a slicer bridges reliably, mm: PLA/PETG with part
42
+ # cooling manage 10-20 mm cleanly and sag past ~25-50. Conservative default.
43
+ # Powder-bed metal barely bridges: a flat span past ~2 mm warps or drosses.
44
+ # Resin holds short spans between supports; past ~5 mm the slicer adds more.
45
+ BRIDGE_SPAN = {"fdm": 15.0, "lpbf": 2.0, "sla": 5.0, "dlp": 5.0}
46
+ # A ledge this deep prints as an overhang off the wall below it.
47
+ _LEDGE = 1.0
48
+ # Bed pieces smaller than this lift: a foot under ~3x3 mm rarely holds.
49
+ _MIN_FOOT = 9.0
50
+ _CROSS = np.array([[0, 1, 0], [1, 1, 1], [0, 1, 0]], bool)
51
+ _EIGHT = np.ones((3, 3), bool)
52
+
53
+
54
+ def rotation_to_z(direction) -> np.ndarray:
55
+ """Rotation matrix taking the build direction to +z (proper, det +1)."""
56
+ from .am import build_vector
57
+
58
+ u = build_vector(direction)
59
+ z = np.array([0.0, 0.0, 1.0])
60
+ c = float(u @ z)
61
+ if c > 1 - 1e-12:
62
+ return np.eye(3)
63
+ if c < -1 + 1e-12:
64
+ return np.diag([1.0, -1.0, -1.0])
65
+ v = np.cross(u, z)
66
+ s = np.linalg.norm(v)
67
+ k = np.array([[0, -v[2], v[1]], [v[2], 0, -v[0]], [-v[1], v[0], 0]]) / s
68
+ ang = np.arctan2(s, c)
69
+ return np.eye(3) + np.sin(ang) * k + (1 - np.cos(ang)) * (k @ k)
70
+
71
+
72
+ def _spans(region, anchor, pitch, max_steps):
73
+ """For each direction (0..165 deg), the chord through every cell of a
74
+ layer region and whether both ends land on anchor cells. Returns the
75
+ best direction's (fraction of cells spanned, longest chord in mm)."""
76
+ cells = np.argwhere(region)
77
+ shape = np.array(region.shape)
78
+ best = (0.0, np.inf)
79
+ for th in np.radians(np.arange(0, 180, 15)):
80
+ d = np.array([np.cos(th), np.sin(th)])
81
+ ok = np.ones(len(cells), bool)
82
+ length = np.zeros(len(cells))
83
+ for sgn in (1.0, -1.0):
84
+ done = np.zeros(len(cells), bool)
85
+ hit = np.zeros(len(cells), bool)
86
+ for step in range(1, max_steps + 1):
87
+ q = np.rint(cells + sgn * step * d).astype(int)
88
+ inside = np.all((q >= 0) & (q < shape), axis=1)
89
+ qc = np.clip(q, 0, shape - 1)
90
+ in_reg = inside & region[qc[:, 0], qc[:, 1]]
91
+ on_anchor = inside & anchor[qc[:, 0], qc[:, 1]]
92
+ newly = ~done & ~in_reg
93
+ hit |= newly & on_anchor
94
+ length += np.where(~done & in_reg, 1.0, 0.0)
95
+ done |= newly
96
+ if done.all():
97
+ break
98
+ ok &= done & hit
99
+ frac = float(ok.mean())
100
+ span = float((length[ok].max() + 1.0) * pitch) if ok.any() else np.inf
101
+ if frac > best[0] + 1e-9 or (abs(frac - best[0]) < 1e-9 and span < best[1]):
102
+ best = (frac, span)
103
+ return best
104
+
105
+
106
+ def _hull_margin(pts2, com2):
107
+ """Signed distance from the centre of mass to the contact's convex hull
108
+ edge (positive inside)."""
109
+ from scipy.spatial import ConvexHull, QhullError
110
+
111
+ if len(pts2) < 3:
112
+ return -float(np.min(np.linalg.norm(pts2 - com2, axis=1))) if len(pts2) else -np.inf
113
+ try:
114
+ hull = ConvexHull(pts2)
115
+ except QhullError:
116
+ return 0.0
117
+ # hull.equations: n.x + d <= 0 inside
118
+ return float(-(hull.equations[:, :2] @ com2 + hull.equations[:, 2]).max())
119
+
120
+
121
+ def check_print(mesh, direction, process: str = "fdm", pitch: float | None = None,
122
+ bridge_span: float | None = None, ledge: float = _LEDGE, material=None,
123
+ max_cells: int = 1_500_000) -> dict:
124
+ """Slice `mesh` (trimesh, mm) along the build `direction` ((axis, sign)
125
+ or a vector, the way layers stack) and report support, bridges, islands
126
+ and bed contact. Nothing here passes or fails a part: the numbers are
127
+ costs and risks an orientation choice weighs (orient.py)."""
128
+ import trimesh
129
+
130
+ from .verify import voxelize
131
+
132
+ rot = rotation_to_z(direction)
133
+ m = mesh.copy()
134
+ m.apply_transform(np.block([[rot, np.zeros((3, 1))], [np.zeros((1, 3)), np.ones((1, 1))]]))
135
+ m.apply_translation([0.0, 0.0, -m.bounds[0, 2]])
136
+ ext = m.extents
137
+ vol = float(abs(m.volume))
138
+ if pitch is None:
139
+ pitch = max(0.4, float((np.prod(ext) / max_cells) ** (1.0 / 3.0)))
140
+ span_mm = float(bridge_span if bridge_span is not None else BRIDGE_SPAN.get(process, 15.0))
141
+ # whole cells from the bed up, so no cell centre sits on the bottom face
142
+ lo = m.bounds[0]
143
+ n_cells = np.maximum(np.ceil((m.extents - 1e-6) / pitch), 1)
144
+ solid, origin = voxelize(m, pitch, box=(lo, lo + n_cells * pitch))
145
+ # first layer at the bed: drop empty layers the grid padding left below
146
+ zs = np.nonzero(solid.any(axis=(0, 1)))[0]
147
+ solid = solid[:, :, zs[0]:zs[-1] + 1]
148
+ nx, ny, nz = solid.shape
149
+ a_cell = pitch * pitch
150
+ out = {"pitch": pitch, "height": float(ext[2]), "volume": vol,
151
+ "buildDirection": [float(c) for c in rot.T @ np.array([0.0, 0.0, 1.0])]}
152
+
153
+ max_steps = int(np.ceil(span_mm / pitch)) + 2
154
+ ledge_cells = ledge / pitch
155
+ need = np.zeros_like(solid) # unbridged, unsupported cells
156
+ bridges, n_islands, island_area = [], 0, 0.0
157
+ bridged_area = 0.0
158
+ for k in range(1, nz):
159
+ below = ndimage.binary_dilation(solid[:, :, k - 1], _CROSS)
160
+ free = solid[:, :, k] & ~below
161
+ if not free.any():
162
+ continue
163
+ anchor = solid[:, :, k] & ~free
164
+ lbl, n = ndimage.label(free, _EIGHT)
165
+ for r in range(1, n + 1):
166
+ reg = lbl == r
167
+ ring = ndimage.binary_dilation(reg, _EIGHT) & ~reg
168
+ if not (ring & anchor).any():
169
+ n_islands += 1
170
+ island_area += reg.sum() * a_cell
171
+ need[:, :, k] |= reg
172
+ continue
173
+ # depth of the region from its nearest anchor: a narrow ledge
174
+ # off a wall prints as overhang, no bridge needed
175
+ depth = ndimage.distance_transform_edt(~anchor)[reg]
176
+ if depth.max() <= ledge_cells + 1.0:
177
+ continue
178
+ frac, span = _spans(reg, anchor, pitch, max_steps)
179
+ if frac >= 0.97 and span <= span_mm:
180
+ bridges.append({"z": float((k + 0.5) * pitch), "span": span, "area": float(reg.sum() * a_cell)})
181
+ bridged_area += reg.sum() * a_cell
182
+ else:
183
+ need[:, :, k] |= reg
184
+ # support columns: from each unbridged cell straight down to the part or the bed
185
+ sup_cells = 0
186
+ run = np.zeros((nx, ny), bool)
187
+ for k in range(nz - 1, 0, -1):
188
+ run = (run | need[:, :, k]) & ~solid[:, :, k - 1]
189
+ sup_cells += int(run.sum())
190
+ out["supportArea"] = float(need.sum() * a_cell)
191
+ out["supportVolume"] = float(sup_cells * a_cell * pitch)
192
+ out["islands"] = n_islands
193
+ out["islandArea"] = float(island_area)
194
+ out["bridges"] = len(bridges)
195
+ out["bridgedArea"] = float(bridged_area)
196
+ out["maxBridgeSpan"] = max((b["span"] for b in bridges), default=0.0)
197
+ out["bridgeList"] = sorted(bridges, key=lambda b: -b["span"])[:8]
198
+ out["supportFree"] = bool(out["supportArea"] == 0.0)
199
+ # support inside a sealed cavity can never come out: the cells that
200
+ # need support over a void that does not reach the outside (the grid's
201
+ # faces, or the open top above the part)
202
+ void = ~solid
203
+ lbl_v, _ = ndimage.label(void)
204
+ border = np.unique(np.concatenate([lbl_v[0].ravel(), lbl_v[-1].ravel(), lbl_v[:, 0].ravel(),
205
+ lbl_v[:, -1].ravel(), lbl_v[:, :, 0].ravel(), lbl_v[:, :, -1].ravel()]))
206
+ sealed = void & ~np.isin(lbl_v, border)
207
+ under = np.zeros_like(need)
208
+ under[:, :, 1:] = sealed[:, :, :-1]
209
+ trapped = need & under
210
+ out["trappedSupportArea"] = float(trapped.sum() * a_cell)
211
+ out["sealedVoidVolume"] = float(sealed.sum() * a_cell * pitch)
212
+
213
+ first = solid[:, :, 0]
214
+ lbl, n = ndimage.label(first, _EIGHT)
215
+ feet = np.bincount(lbl.ravel())[1:] * a_cell if n else np.zeros(0)
216
+ xy = (np.argwhere(first) + 0.5) * pitch + origin[:2]
217
+ try:
218
+ com = m.center_mass if m.is_volume else m.centroid
219
+ except Exception: # noqa: BLE001 - a bad mass property is not worth failing a check
220
+ com = m.centroid
221
+ margin = _hull_margin(xy, np.asarray(com[:2])) if len(xy) else -np.inf
222
+ if len(xy) >= 3:
223
+ # narrowest width of the contact (minimum over directions of its extent)
224
+ th = np.radians(np.arange(0, 180, 5))
225
+ widths = [np.ptp(xy @ np.array([np.cos(t), np.sin(t)])) + pitch for t in th]
226
+ base_w = float(min(widths))
227
+ else:
228
+ base_w = pitch
229
+ out["bed"] = {
230
+ "contactArea": float(first.sum() * a_cell),
231
+ "contactFraction": float(first.sum() * a_cell / max(vol ** (2 / 3), 1e-9)),
232
+ "feet": int(n),
233
+ "smallestFoot": float(feet.min()) if n else 0.0,
234
+ "comMargin": margin,
235
+ "baseWidth": base_w,
236
+ "aspect": float(ext[2] / base_w) if base_w > 0 else np.inf,
237
+ "footprint": [float(ext[0]), float(ext[1])],
238
+ }
239
+ warnings = []
240
+ bed = out["bed"]
241
+ if bed["comMargin"] < 0:
242
+ warnings.append(f"the centre of mass is {-bed['comMargin']:.1f} mm outside the bed contact: it tips over "
243
+ "unless supports or a brim hold it")
244
+ if n and bed["smallestFoot"] < _MIN_FOOT:
245
+ warnings.append(f"a {bed['smallestFoot']:.1f} mm^2 foot on the bed will likely lift: add a brim or "
246
+ "pick a face with more contact")
247
+ if bed["aspect"] > 4.0:
248
+ warnings.append(f"stands {bed['aspect']:.1f}x taller than its narrowest base: it will wobble late in the "
249
+ "print; a brim or a lower orientation helps")
250
+ if out["supportArea"] > 0 and not n_islands and process not in ("sls", "mjf", "binder", "lpbf"):
251
+ # (powder holds a polymer part up; LPBF counts supports by its own rules)
252
+ warnings.append(f"needs build supports under {out['supportArea']:.0f} mm^2 of downward faces "
253
+ f"({out['supportVolume']:.0f} mm^3 of support; spans over {span_mm:g} mm count as "
254
+ "unbridgeable, set process.bridgeSpan for your printer): reorient, chamfer the "
255
+ "overhangs, or accept the supports")
256
+ if n_islands:
257
+ warnings.append(f"{n_islands} region(s) start in mid-air ({island_area:.0f} mm^2): they need support")
258
+ mat = getattr(material, "name", material)
259
+ if mat in ("abs", "asa", "pa-cf", "pa12") and max(ext[0], ext[1]) > 120 and process == "fdm":
260
+ warnings.append(f"{max(ext[0], ext[1]):.0f} mm across the bed in {mat}: warp risk; use an enclosure "
261
+ "and a brim")
262
+ out["warnings"] = warnings
263
+ return out
loadpath/printsetup.py ADDED
@@ -0,0 +1,207 @@
1
+ """
2
+ Pick the print setup for a finished FDM part: build orientation, material,
3
+ bed temperature and brim, each candidate checked for strength (verify_part
4
+ with that material and layer direction) and for warp (buildsim, on the bed
5
+ at the end of the print, along the build). The search is on the delivered
6
+ shape: it does not re-optimize the part for each setup, so a material or
7
+ orientation that wins here may do better still once the part is
8
+ re-optimized for it (search_print_setup reports what to set; run the study
9
+ again with it).
10
+
11
+ Why: on a bed-printed part the warp is set by the material, the height on
12
+ the bed and how the bed holds it far more than by the topology (on the
13
+ 120 mm PA12 beam of bench/buildsim_fdm_warp_limit.py every design lifted
14
+ 2.5-3.7 mm), so these are the levers a warp limit needs.
15
+
16
+ Stage 1 runs every (material, orientation) at the material's usual bed
17
+ temperature without a brim; stage 2 sweeps bed temperature and brim on the
18
+ best few that meet the safety factor. Ranking: meets the goal and the warp
19
+ limit first, then lightest, then least warp, then no brim.
20
+ """
21
+ from __future__ import annotations
22
+
23
+ import dataclasses
24
+ import time
25
+
26
+ import numpy as np
27
+
28
+ AXES = ("+z", "-z", "+x", "-x", "+y", "-y")
29
+ BED_MAX = 110.0 # most hobby beds top out near here
30
+ BED_STEP = 10.0
31
+ TALL = 4.0 # height over narrowest base past which a print needs a brim to stand
32
+ TALL_MAX = 10.0 # and past which not even a brim keeps it steady
33
+ ROOM = 0.8 # a lift above this share of the limit counts as tight
34
+
35
+
36
+ def _bed_candidates(material, setup, max_bed):
37
+ from .buildsim import BuildSetup, _fdm_params
38
+ p = _fdm_params(dataclasses.replace(setup, material=material))
39
+ base = p["bed"]
40
+ # above Tg an amorphous first layer sags (elephant's foot), so stop
41
+ # short of it; semi-crystalline (Tc far above) stop at the bed's limit
42
+ top = min(max_bed, p["tg"] - 5.0)
43
+ out = sorted({float(b) for b in (base - 2 * BED_STEP, base - BED_STEP, base, base + BED_STEP) if 40.0 <= b <= top})
44
+ return out or [min(base, top)]
45
+
46
+
47
+ def _warp(mesh, setup, build, cells):
48
+ from .buildsim import simulate_part
49
+ r = simulate_part(mesh, dataclasses.replace(setup, release="none"), build=build, cells=cells)
50
+ m = r.mesh
51
+ on = np.zeros(m.n_nodes, bool)
52
+ on[m.enode[r.part].ravel()] = True
53
+ lift = float(np.abs(r.u_built[on, 2]).max())
54
+ return {"lift": lift, "peel": (r.report.get("peel") or {}).get("meanPeelStress"),
55
+ "bedFailed": bool(r.report.get("peel", {}).get("bedFailed", False))}
56
+
57
+
58
+ def _strength(study, mesh, material, build, max_cells):
59
+ from .am import parse_build_direction
60
+ from .materials import material_from_spec
61
+ from .verify import verify_part
62
+ from .study import _margin
63
+ mat = material_from_spec(material)
64
+ b = parse_build_direction(build)
65
+ proc = dataclasses.replace(study.process, build=b) if study.process is not None else None
66
+ st = dataclasses.replace(study, material=mat, materials=[mat], process=proc,
67
+ print_direction=b if study.print_direction is not None else None)
68
+ rep = verify_part(st, mesh, max_cells=max_cells)
69
+ # the goal's margin without the warp limits (those are checked here)
70
+ goal = _margin(st, {k: v for k, v in rep.items() if k != "warp"})
71
+ return {"mass": rep.get("mass"), "safetyFactor": rep.get("safetyFactor"), "margin": goal,
72
+ "supportVolume": (rep.get("print") or {}).get("supportVolume"),
73
+ "aspect": ((rep.get("print") or {}).get("bed") or {}).get("aspect"),
74
+ "comMargin": ((rep.get("print") or {}).get("bed") or {}).get("comMargin")}
75
+
76
+
77
+ def search_print_setup(study, mesh, materials=None, builds=None, beds=None, brims=(0.0, 5.0), max_bed=BED_MAX,
78
+ limit=None, cells=4000, verify_cells=30_000, top=2, progress=None, reoptimize=True) -> dict:
79
+ """Rank FDM print setups for the delivered part `mesh` (trimesh, study
80
+ frame). materials: names (default the study's); builds: build
81
+ directions (default the six grid axes); beds: bed temperatures (default
82
+ a few around each material's usual one, below its Tg); brims: brim
83
+ widths, mm; limit: maxDistortion, mm (default buildSim.maxDistortion).
84
+ Returns {"best": row, "rows": [...], "seconds", "notes"}; each row has
85
+ material, build, bed, brim, lift (mm), mass, safetyFactor, margin, and
86
+ passes (goal and warp limit)."""
87
+ t0 = time.time()
88
+ bs = study.build_sim or {}
89
+ from .buildsim import BuildSetup
90
+ setup = bs.get("setup") or BuildSetup(process="fdm", material=study.material.name)
91
+ del reoptimize # used by the study runner (study._print_setup_rescue)
92
+ if setup.process.lower() != "fdm":
93
+ raise ValueError("search_print_setup is for FDM parts (buildSim with process fdm)")
94
+ if limit is None:
95
+ limit = bs.get("maxDistortion")
96
+ mats = list(materials or [m.name for m in (study.materials or [study.material])])
97
+ builds = list(builds or AXES)
98
+
99
+ def note(**kw):
100
+ if progress is not None:
101
+ progress(kw)
102
+
103
+ strength = {}
104
+ for mat in mats:
105
+ for b in builds:
106
+ note(stage="strength", material=mat, build=b)
107
+ try:
108
+ strength[(mat, b)] = _strength(study, mesh, mat, b, verify_cells)
109
+ except (RuntimeError, ValueError) as exc:
110
+ strength[(mat, b)] = {"error": str(exc), "margin": 0.0}
111
+
112
+ rows = []
113
+
114
+ def run(mat, b, bed, brim):
115
+ st = dataclasses.replace(setup, material=mat, bed=bed, brim=brim)
116
+ note(stage="warp", material=mat, build=b, bed=bed, brim=brim)
117
+ w = _warp(mesh, st, b, cells)
118
+ s = strength[(mat, b)]
119
+ ok_goal = s.get("margin") is None or s["margin"] >= 1.0
120
+ ok_warp = limit is None or w["lift"] <= limit
121
+ # a part standing over 4x taller than its narrowest base wobbles
122
+ # late in the print (printcheck), and one whose centre of mass is
123
+ # off its contact tips: both need a brim to stand
124
+ asp = s.get("aspect") or 0.0
125
+ tall = asp > TALL or (s.get("comMargin") or 0.0) < 0
126
+ ok_stand = asp <= TALL_MAX and (not tall or brim > 0)
127
+ row = {"material": mat, "build": b, "bed": bed, "brim": brim, **w,
128
+ **{k: s.get(k) for k in ("mass", "safetyFactor", "margin", "supportVolume", "aspect")},
129
+ "meetsGoal": bool(ok_goal), "meetsWarp": bool(ok_warp), "stands": bool(ok_stand),
130
+ "passes": bool(ok_goal and ok_warp and ok_stand)}
131
+ rows.append(row)
132
+ return row
133
+
134
+ from .buildsim import _fdm_params
135
+ first = []
136
+ for mat in mats:
137
+ base = _fdm_params(dataclasses.replace(setup, material=mat))["bed"]
138
+ for b in builds:
139
+ first.append(run(mat, b, base, 0.0))
140
+ # stage 2: bed and brim on the best few that hold the load
141
+ # (per material, so a lighter material is not crowded out by a flatter one)
142
+ can = [r for r in first if (r.get("aspect") or 0.0) <= TALL_MAX]
143
+ strong = []
144
+ for mat in mats:
145
+ mine = [r for r in can if r["material"] == mat]
146
+ mine = [r for r in mine if r["meetsGoal"]] or mine
147
+ strong += sorted(mine, key=lambda r: (r["lift"], r["mass"] or 0))[:top]
148
+ for r in strong:
149
+ for bed in (beds or _bed_candidates(r["material"], setup, max_bed)):
150
+ for brim in brims:
151
+ if bed == r["bed"] and brim == r["brim"]:
152
+ continue
153
+ run(r["material"], r["build"], float(bed), float(brim))
154
+
155
+ def rank(r):
156
+ # among equals: room under the warp limit first (the FDM warp model
157
+ # is good to tens of percent), then less support, no brim, less lift
158
+ tight = limit is not None and r["lift"] > ROOM * limit
159
+ return (not r["passes"], not r["meetsGoal"], round(r["mass"] or 0.0, 2) if r["passes"] else 0.0,
160
+ tight, round(r["supportVolume"] or 0.0, -1), r["brim"], r["lift"], abs(r["bed"]))
161
+
162
+ rows.sort(key=rank)
163
+ best = rows[0] if rows else None
164
+ cur = next((r for r in rows if r["material"] == study.material.name and r["brim"] == 0.0
165
+ and r["build"] == _build_name(study)), None)
166
+ return {"best": best, "current": cur, "rerun": rerun_candidate(rows, limit), "rows": rows, "limit": limit, "seconds": round(time.time() - t0, 1),
167
+ "notes": ["the delivered shape is re-checked, not re-optimized, for each setup: re-run the study with "
168
+ "the best setup to let the shape follow it",
169
+ "brim is an estimate (more bed hold under the footprint's edge), not validated; bed "
170
+ "temperature and material are in the validated FDM model"]}
171
+
172
+
173
+ def _build_name(study):
174
+ b = study.process.build if study.process is not None else None
175
+ if isinstance(b, tuple) and len(b) == 2 and isinstance(b[0], (int, np.integer)):
176
+ return ("+" if b[1] > 0 else "-") + "xyz"[b[0]]
177
+ return "+z"
178
+
179
+
180
+ def rerun_candidate(rows, limit, room=ROOM):
181
+ """The setup to re-optimize the part for when none passes as it is:
182
+ among the setups whose lift leaves room under the limit (room x limit,
183
+ else any under it), the most safety factor per gram (a re-optimized
184
+ part trades the two), then no brim, then the least lift."""
185
+ if limit is None:
186
+ return None
187
+ ok = [r for r in rows if r["lift"] <= room * limit] or [r for r in rows if r["lift"] <= limit]
188
+ ok = [r for r in ok if r.get("margin") is not None and r.get("mass") and r.get("stands", True)]
189
+ if not ok:
190
+ return None
191
+ return min(ok, key=lambda r: (-round(r["margin"] / r["mass"], 4), r["brim"], r["lift"]))
192
+
193
+
194
+ def study_for(study, row):
195
+ """The study set up to print as `row` says: material, build direction
196
+ and the build sim's bed temperature and brim (printSetup off, so a
197
+ re-run does not search again)."""
198
+ from .am import parse_build_direction
199
+ from .materials import material_from_spec
200
+ mat = material_from_spec(row["material"])
201
+ b = parse_build_direction(row["build"])
202
+ proc = dataclasses.replace(study.process, build=b)
203
+ bs = dict(study.build_sim)
204
+ bs["setup"] = dataclasses.replace(bs["setup"], material=mat.name, bed=row["bed"], brim=row["brim"])
205
+ bs["printSetup"] = None
206
+ pd = b if study.print_direction is not None else None
207
+ return dataclasses.replace(study, material=mat, materials=[mat], process=proc, build_sim=bs, print_direction=pd)