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/domain.py ADDED
@@ -0,0 +1,630 @@
1
+ """
2
+ Turn a study's shapes into the voxel problem the solver works on: which
3
+ cells exist, which are pinned solid (preserve), and which nodes carry the
4
+ supports and loads.
5
+
6
+ Classification is by supersampling each cell at 8 interior points, so a
7
+ cell's membership is its covered fraction rather than whether its center
8
+ happens to fall inside:
9
+
10
+ - preserve wins over design space, and is pinned to full density;
11
+ - a keep-out removes any design cell it touches at all (the final part must
12
+ clear it), but only removes a preserve cell when it covers most of it, so
13
+ a thin bearing boss around a bore keeps its wall in the analysis;
14
+ - islands of material that cannot reach a support are dropped: they carry
15
+ no load and would make the stiffness matrix singular.
16
+
17
+ The exact keep-out and preserve geometry is restored after optimization by
18
+ mesh booleans, so the voxel classification only has to be right about load
19
+ paths, not about surfaces.
20
+ """
21
+ from __future__ import annotations
22
+
23
+ from dataclasses import dataclass, field
24
+
25
+ import numpy as np
26
+ from scipy import ndimage
27
+
28
+ from .fem import HexMesh
29
+ from .shapes import Shape
30
+ from .spec import Study
31
+
32
+ __all__ = ["Domain", "build_domain", "choose_pitch"]
33
+
34
+ _SUB = np.array([[sx, sy, sz] for sx in (-0.25, 0.25) for sy in (-0.25, 0.25) for sz in (-0.25, 0.25)])
35
+ _CONN26 = np.ones((3, 3, 3), bool)
36
+
37
+
38
+ @dataclass
39
+ class Domain:
40
+ pitch: float
41
+ origin: np.ndarray # min corner of cell (0, 0, 0), mm
42
+ dims: tuple # cells per axis
43
+ mesh: HexMesh
44
+ preserve: np.ndarray # bool per active element: pinned solid
45
+ fixed: np.ndarray # bool per dof
46
+ loads: list # per load case: force vector per dof (N)
47
+ case_names: list
48
+ case_weights: list
49
+ support_nodes: np.ndarray # bool per node
50
+ load_nodes: np.ndarray # bool per node (any case)
51
+ mirror: dict = field(default_factory=dict) # axis -> element index of the mirror cell (-1 none)
52
+ warnings: list = field(default_factory=list)
53
+ # Pin supports as penalty springs at unit modulus (scale by E), or None.
54
+ springs: object = None
55
+ # Self-weight: N per mm^3 of solid, or None (see BoundaryConditions).
56
+ gravity: np.ndarray | None = None
57
+ # per case, directions.Spread or None (see BoundaryConditions)
58
+ spreads: list = field(default_factory=list)
59
+ # The study's thermal block on this mesh (thermal.StudyHeat), or None.
60
+ thermal: object = None
61
+ # buildSim warp design (WarpSpec: warpWeight and/or limits), or None.
62
+ warp: object = None
63
+
64
+ @property
65
+ def n_elem(self):
66
+ return self.mesh.n_elem
67
+
68
+ def full_grid(self, values: np.ndarray, fill=0.0) -> np.ndarray:
69
+ g = np.full(self.dims, fill, float)
70
+ ijk = self.mesh.elem_ijk
71
+ g[ijk[:, 0], ijk[:, 1], ijk[:, 2]] = values
72
+ return g
73
+
74
+
75
+ def _fraction(shapes: list[Shape], centers: np.ndarray, pitch: float) -> np.ndarray:
76
+ if not shapes:
77
+ return np.zeros(len(centers))
78
+ hit = np.zeros((len(centers), len(_SUB)), bool)
79
+ for k, off in enumerate(_SUB):
80
+ pts = centers + off * pitch
81
+ for s in shapes:
82
+ hit[:, k] |= s.contains(pts)
83
+ return hit.mean(axis=1)
84
+
85
+
86
+ def cell_cap(study: Study) -> int:
87
+ """The most cells a study's grid may have: maxElements, or up to 4x it
88
+ for the constrained method, whose members need 2.5 cells across (see
89
+ choose_pitch)."""
90
+ if study.goal.method == "constrained" and study.min_member:
91
+ return 4 * int(study.max_elements)
92
+ return int(study.max_elements)
93
+
94
+
95
+ def choose_pitch(study: Study) -> float:
96
+ """Cell size: explicit, else the size that puts roughly maxElements cells
97
+ in the design space (estimated from a coarse occupancy sample)."""
98
+ if study.pitch:
99
+ return float(study.pitch)
100
+ lo, hi = study.bounds()
101
+ vol = float(np.prod(hi - lo))
102
+ # Coarse 24^3 sample of what fraction of the box is actually design space.
103
+ n = 24
104
+ axes = [np.linspace(lo[i], hi[i], n) for i in range(3)]
105
+ pts = np.stack(np.meshgrid(*axes, indexing="ij"), -1).reshape(-1, 3)
106
+ inside = np.zeros(len(pts), bool)
107
+ for s in study.design_space + [r.shape for r in study.preserve]:
108
+ inside |= s.contains(pts)
109
+ frac = max(float(inside.mean()), 0.05)
110
+ pitch = (vol * frac / study.max_elements) ** (1.0 / 3.0)
111
+ budget = pitch
112
+ if study.min_member:
113
+ # No finer than two cells per filter radius (1.3 x minMember, see
114
+ # simp.filter_radius): the filter sets the feature scale, so finer
115
+ # cells only cost time. On the arm link this cut the cells from 35k to
116
+ # 18k and the study from ~255s to ~155s, and the design came out
117
+ # stiffer (0.58mm vs 1.06mm deflection at the same 30% volume): the
118
+ # finer grid had settled in a worse local optimum.
119
+ pitch = max(pitch, 1.3 * float(study.min_member) / 2.0)
120
+ if study.goal.method == "constrained":
121
+ # The stress-constrained method sits its members right at the
122
+ # minimum, and at two cells per filter radius that is 1.5 cells:
123
+ # the wall bracket's 4 mm members on a 2.6 mm grid meshed three
124
+ # times floppier than their voxels, its first run verified at SF
125
+ # 0.1 with the optimizer reading the limit, and four targets
126
+ # 2.45-2.6 ended at 55-75 g. With the member 2.5 cells across
127
+ # (1.6 mm) the optimizer's reading holds on the mesh (first run
128
+ # SF 1.7) and the four land at 29.2-30.1 g, in about 4x the time.
129
+ # Up to 4x maxElements cells for it, no more.
130
+ pitch = min(pitch, max(float(study.min_member) / 2.5, budget / 4.0 ** (1.0 / 3.0)))
131
+ return pitch
132
+
133
+
134
+ def _region_nodes(study: Study, mesh: HexMesh, region: str | None, shape: Shape | None, label: str,
135
+ tol_floor: float = 0.0):
136
+ if shape is None:
137
+ shape, kind = study.region_shape(region)
138
+ else:
139
+ kind = "inline"
140
+ # A keep-out is a hole: the nodes that load or hold it are the ones
141
+ # lining it, up to one cell away. Solid regions take nodes inside or on
142
+ # their surface.
143
+ # `tol_floor`: a verification grid finer than the optimization's still
144
+ # reaches as far as the optimization did. On the printed hook the design
145
+ # grid's support took the node plane 0.75 mm off the wall, so the part
146
+ # was (correctly) designed to start there, and a 0.6 mm verification
147
+ # grid then found nothing at the wall to hold.
148
+ # Only as a fallback: widening it everywhere clamped more of every bolt
149
+ # ring in verification and read the GRIP upper arm at SF 10 instead of 5.
150
+ tol = mesh.pitch * (1.0 if kind == "keepOut" else 0.5)
151
+ d = shape.sdf(mesh.node_xyz)
152
+ nodes = d <= tol
153
+ if kind != "keepOut" and (d <= 1e-9).sum() >= 4:
154
+ # A solid region with nodes inside it holds or loads those, not a
155
+ # plane half a cell outside it too. The extra plane let the printed
156
+ # hook's optimizer hang the part off nodes 0.75 mm short of the wall:
157
+ # it was designed not to touch the wall it is screwed to.
158
+ nodes = d <= 1e-9
159
+ if not nodes.any() and tol_floor > tol:
160
+ nodes = d <= tol_floor
161
+ if not nodes.any():
162
+ name = region or "inline shape"
163
+ raise ValueError(f"{label} on {name!r} touches no material: it lies outside the design space, "
164
+ "or every cell around it was removed. Move it onto the part.")
165
+ return nodes
166
+
167
+
168
+ # Pin springs, relative to a cell's own stiffness: stiff enough to hold the
169
+ # pin to well under a percent of the part's deflection, soft enough not to
170
+ # wreck the solver's conditioning.
171
+ _PIN_STIFFNESS = 100.0
172
+ # Supports and loads touching at most this share of the cells are pinned
173
+ # solid (see build_domain).
174
+ _ANCHOR_MAX_FRACTION = 0.03
175
+
176
+
177
+ @dataclass
178
+ class BoundaryConditions:
179
+ fixed: np.ndarray
180
+ springs: object # scipy CSR at unit modulus, or None
181
+ support_nodes: np.ndarray
182
+ loads: list # per case, force per dof (N)
183
+ case_load_nodes: list # per case, bool per node
184
+ load_nodes: np.ndarray
185
+ gravity: np.ndarray | None # N per mm^3 of solid material
186
+ regions: list = field(default_factory=list) # node mask of every support and load region
187
+ # per case, bool per node: the walls a pressure acts on (pressure.py).
188
+ # Not contact: a pressure is a real distributed load, and the peak stress
189
+ # of a pressure vessel is on its wetted wall.
190
+ case_pressure_nodes: list = field(default_factory=list)
191
+ # per case, |net force| / sum |nodal force| of its pressures (0 when sealed)
192
+ pressure_imbalance: list = field(default_factory=list)
193
+ # per case, directions.Spread for a force of unknown direction, or None;
194
+ # `loads` then holds the force as written
195
+ spreads: list = field(default_factory=list)
196
+
197
+
198
+ def _cylinder_of(study: Study, region, shape, label):
199
+ sh = shape if shape is not None else study.region_shape(region)[0]
200
+ if not hasattr(sh, "axis"):
201
+ raise ValueError(f"{label} needs a cylinder region (a bore keep-out or a cylinder) for its axis")
202
+ return sh.p0, sh.axis
203
+
204
+
205
+ def _wetted(study: Study, mesh: HexMesh) -> dict:
206
+ """Per pressurized keep-out: (nodal force per unit pressure, node mask)."""
207
+ from .pressure import pressure_force
208
+
209
+ out = {}
210
+ for case in study.load_cases:
211
+ for ld in case.loads:
212
+ if ld.pressure and ld.region not in out:
213
+ out[ld.region] = pressure_force(mesh, study.region_shape(ld.region)[0], 1.0)
214
+ return out
215
+
216
+
217
+ # A pressure-only study is held by a 3-2-1 support, which is right only for a
218
+ # balanced load: a sealed cavity's balances to rounding, an open duct's does
219
+ # not (the thrust on its missing end caps).
220
+ _IMBALANCE_MAX = 0.02
221
+
222
+
223
+ class _HeldPreload(ValueError):
224
+ """The fasteners' preload case lands only on supported nodes."""
225
+
226
+
227
+ def boundary_conditions(study: Study, mesh: HexMesh, check_loads=True, tol_floor: float = 0.0) -> BoundaryConditions:
228
+ """Supports, pins and loads on a mesh's nodes. Shared by the optimizer
229
+ and the verification, so both analyze exactly the same problem."""
230
+ xyz = mesh.node_xyz
231
+ support_nodes = np.zeros(mesh.n_nodes, bool)
232
+ fixed = np.zeros(mesh.n_dof, bool)
233
+ spring_blocks = []
234
+ regions = []
235
+ wet = _wetted(study, mesh)
236
+ for rid, (_, nodes) in wet.items():
237
+ if not nodes.any():
238
+ raise ValueError(f"pressure in {rid!r} pushes on no material: the keep-out is not lined by the part")
239
+ if study.self_equilibrated:
240
+ from .pressure import stabilize
241
+
242
+ fixed, support_nodes = stabilize(mesh, np.any([n for _, n in wet.values()], axis=0))
243
+ for i, s in enumerate(study.supports):
244
+ label = f"supports[{i}]"
245
+ nodes = _region_nodes(study, mesh, s.region, s.shape, label, tol_floor)
246
+ regions.append(nodes)
247
+ support_nodes |= nodes
248
+ idx = np.nonzero(nodes)[0]
249
+ for a in range(3):
250
+ if s.fix[a]:
251
+ fixed[3 * idx + a] = True
252
+ if s.pin:
253
+ p0, ax = _cylinder_of(study, s.region, s.shape, label)
254
+ d = xyz[idx] - p0
255
+ rad = d - (d @ ax)[:, None] * ax
256
+ rad /= np.maximum(np.linalg.norm(rad, axis=1, keepdims=True), 1e-12)
257
+ blocks = rad[:, :, None] * rad[:, None, :] + np.outer(ax, ax)[None]
258
+ spring_blocks.append((idx, blocks * _PIN_STIFFNESS * mesh.pitch))
259
+ if study.supports and all(s.pin for s in study.supports) and not study.self_equilibrated:
260
+ lines = [_cylinder_of(study, s.region, s.shape, f"supports[{i}]") for i, s in enumerate(study.supports)]
261
+ p0, ax = lines[0]
262
+ coaxial = all(abs(abs(float(a @ ax)) - 1) < 1e-3 and
263
+ np.linalg.norm((q - p0) - ((q - p0) @ ax) * ax) < mesh.pitch for q, a in lines[1:])
264
+ if coaxial:
265
+ # a brief-eval bell crank pinned at its pivot alone read SF 0.0004:
266
+ # nothing holds it from turning, so the solve is singular
267
+ raise ValueError("every support is a pin on one axis, so nothing stops the part turning about it and "
268
+ "the stiffness is singular (safety factors come out near zero). Hold what really "
269
+ "stops it turning: fix the link, arm or stop that reacts the load (\"fix\": \"all\" "
270
+ "on that bore), or fix the pivot itself with \"fix\": \"all\"")
271
+ springs = None
272
+ if spring_blocks:
273
+ import scipy.sparse as sp
274
+ rows, cols, vals = [], [], []
275
+ for idx, blocks in spring_blocks:
276
+ for i in range(3):
277
+ for j in range(3):
278
+ rows.append(3 * idx + i)
279
+ cols.append(3 * idx + j)
280
+ vals.append(blocks[:, i, j])
281
+ rows, cols, vals = (np.concatenate(v) for v in (rows, cols, vals))
282
+ keep = ~(fixed[rows] | fixed[cols])
283
+ springs = sp.csr_matrix((vals[keep], (rows[keep], cols[keep])), shape=(mesh.n_dof, mesh.n_dof))
284
+
285
+ load_nodes = np.zeros(mesh.n_nodes, bool)
286
+ loads, case_nodes, case_wet, imbalance, spreads = [], [], [], [], []
287
+ for ci, case in enumerate(study.load_cases):
288
+ f = np.zeros(mesh.n_dof)
289
+ spread = None
290
+ cn = np.zeros(mesh.n_nodes, bool)
291
+ cw = np.zeros(mesh.n_nodes, bool)
292
+ fp = np.zeros(mesh.n_dof)
293
+ for li, ld in enumerate(case.loads):
294
+ label = f"loadCases[{ci}].loads[{li}]"
295
+ if ld.pressure:
296
+ unit, nodes = wet[ld.region]
297
+ fp += ld.pressure * unit
298
+ cw |= nodes
299
+ continue
300
+ nodes = _region_nodes(study, mesh, ld.region, ld.shape, label, tol_floor)
301
+ regions.append(nodes)
302
+ idx = np.nonzero(nodes)[0]
303
+ cn[idx] = True
304
+ pts = xyz[idx]
305
+ force = ld.force
306
+ if ld.direction is not None:
307
+ # the uncertain force, spread evenly, one vector per axis
308
+ size = float(np.linalg.norm(force))
309
+ basis = []
310
+ for a in range(3):
311
+ b = np.zeros(mesh.n_dof)
312
+ b.reshape(-1, 3)[idx, a] = size / len(idx)
313
+ basis.append(b)
314
+ cos_half = -1.0 if ld.direction == "any" else float(np.cos(np.radians(ld.cone)))
315
+ spread = (basis, force / size, cos_half)
316
+ force = np.zeros(3)
317
+ if ld.distribution == "bearing" and np.any(force):
318
+ # A shaft pushes on the half of the bore that faces the force,
319
+ # most at the centre of that half: a cosine over it.
320
+ p0, ax = _cylinder_of(study, ld.region, ld.shape, label)
321
+ axial = (force @ ax) * ax
322
+ radial = force - axial
323
+ if np.any(radial):
324
+ d = pts - p0
325
+ rad = d - (d @ ax)[:, None] * ax
326
+ rad /= np.maximum(np.linalg.norm(rad, axis=1, keepdims=True), 1e-12)
327
+ w = np.maximum(rad @ (radial / np.linalg.norm(radial)), 0.0)
328
+ if w.sum() <= 0:
329
+ raise ValueError(f"{label}: no material on the loaded side of the bore")
330
+ f.reshape(-1, 3)[idx] += (w / w.sum())[:, None] * radial[None]
331
+ force = axial
332
+ if np.any(force):
333
+ f.reshape(-1, 3)[idx] += force / len(idx)
334
+ if np.any(ld.moment):
335
+ # Forces that carry the moment and nothing else: the rigid
336
+ # rotation field w x r, with w from the nodes' polar inertia.
337
+ r = pts - pts.mean(axis=0)
338
+ inertia = (r * r).sum() * np.eye(3) - r.T @ r
339
+ omega = np.linalg.lstsq(inertia, ld.moment, rcond=None)[0]
340
+ f.reshape(-1, 3)[idx] += np.cross(omega, r)
341
+ if cw.any():
342
+ fv = fp.reshape(-1, 3)
343
+ imb = float(np.linalg.norm(fv.sum(axis=0)) / max(np.abs(fv).sum(), 1e-300))
344
+ if study.self_equilibrated and imb > _IMBALANCE_MAX and check_loads:
345
+ raise ValueError(
346
+ f"load case {case.name!r}: the pressure does not balance on itself (net force "
347
+ f"{np.linalg.norm(fv.sum(axis=0)):.1f} N): the cavity is open, so its ends' thrust has "
348
+ "to go somewhere. Add supports where the part is held (a flange, a clamp).")
349
+ imbalance.append(imb)
350
+ f += fp
351
+ else:
352
+ imbalance.append(0.0)
353
+ applied = bool(np.any(f))
354
+ f[fixed] = 0.0
355
+ if spread is not None:
356
+ from .directions import Spread
357
+
358
+ for b in spread[0]:
359
+ b[fixed] = 0.0
360
+ spread = Spread(f.copy(), spread[0], spread[1], spread[2])
361
+ f = spread.load(spread.nominal)
362
+ spreads.append(spread)
363
+ if check_loads and not np.any(f) and study.gravity is None:
364
+ if not applied and spread is None:
365
+ raise ValueError(f"load case {case.name!r} adds up to nothing at pitch {mesh.pitch:.3g} mm: its "
366
+ "forces cancel on the same grid nodes (a fastener's seat and face rings closer "
367
+ "than a cell, or equal and opposite loads on one region). Lengthen the grip, "
368
+ "use a finer pitch, or drop the case")
369
+ if case.name == "preload" and study.raw.get("fasteners"):
370
+ raise _HeldPreload(case.name)
371
+ raise ValueError(f"load case {case.name!r} acts only on fixed nodes; "
372
+ "a load applied where the part is held does nothing: load a region the supports "
373
+ "do not cover")
374
+ loads.append(f)
375
+ case_nodes.append(cn)
376
+ case_wet.append(cw)
377
+ load_nodes |= cn
378
+
379
+ gravity = None
380
+ if study.gravity is not None:
381
+ # g/cm^3 -> kg/mm^3 is 1e-6; times m/s^2 gives N per mm^3.
382
+ gravity = study.material.density * 1e-6 * study.gravity
383
+ return BoundaryConditions(fixed, springs, support_nodes, loads, case_nodes, load_nodes, gravity, regions,
384
+ case_wet, imbalance, spreads)
385
+
386
+
387
+ def _near_cells(mesh: HexMesh, nodes: np.ndarray, layers: int = 2) -> np.ndarray:
388
+ """Cells within `layers` cells of the given nodes: the band round
389
+ supports and loads that verification leaves out of the peak stress."""
390
+ near = np.asarray(nodes, bool).copy()
391
+ cells = np.zeros(mesh.n_elem, bool)
392
+ for _ in range(layers):
393
+ cells = near[mesh.enode].any(axis=1)
394
+ near[mesh.enode[cells].ravel()] = True
395
+ return cells
396
+
397
+
398
+ def gravity_force(mesh: HexMesh, density: np.ndarray, gravity: np.ndarray, fixed: np.ndarray) -> np.ndarray:
399
+ """Nodal self-weight for per-element solid fraction `density`."""
400
+ per_node = (np.asarray(density, float) * mesh.pitch ** 3 / 8.0)[:, None] * gravity[None, :]
401
+ f = np.zeros(mesh.n_dof)
402
+ for a in range(3):
403
+ f += np.bincount(mesh.edof[:, a::3].ravel(), weights=np.repeat(per_node[:, a], 8),
404
+ minlength=mesh.n_dof)
405
+ f[fixed] = 0.0
406
+ return f
407
+
408
+
409
+ def build_domain(study: Study, pitch: float | None = None) -> Domain:
410
+ if pitch or study.pitch:
411
+ return _build_domain(study, float(pitch) if pitch else float(study.pitch))
412
+ # maxElements is a cap: the pitch is estimated from a coarse occupancy
413
+ # sample, which came out 2-5% over in the brief evals (20988 cells for
414
+ # 20000); grow the cells until the grid fits
415
+ # (cell_cap: the constrained method's finer grid is allowed past it; the
416
+ # cap had undone that, and the arm link's first constrained run went back
417
+ # to verifying 1.32 mm against the optimizer's 0.48 mm)
418
+ p = choose_pitch(study)
419
+ cap = cell_cap(study)
420
+ for _ in range(4):
421
+ d = _build_domain(study, p)
422
+ if d.n_elem <= cap:
423
+ return d
424
+ p *= (d.n_elem / cap) ** (1.0 / 3.0) * 1.003
425
+ return d
426
+
427
+
428
+ def _build_domain(study: Study, pitch: float) -> Domain:
429
+ lo, hi = study.bounds()
430
+ center = 0.5 * (lo + hi)
431
+ dims = np.maximum(np.ceil((hi - lo) / pitch - 1e-9).astype(int), 1)
432
+ # Centered grid, so a symmetry plane through the design space center is
433
+ # also a plane between cells and every cell has an exact mirror. (Starting
434
+ # the grid on the design space's minimum face along non-symmetric axes
435
+ # was tried for the printed hook's half-cell overhang at its wall; it
436
+ # moved every case's search path and lost 3 of 8 evals. Not worth it.)
437
+ origin = center - 0.5 * dims * pitch
438
+ ii, jj, kk = np.meshgrid(*[np.arange(d) for d in dims], indexing="ij")
439
+ ijk = np.stack([ii.ravel(), jj.ravel(), kk.ravel()], axis=1)
440
+ centers = origin + (ijk + 0.5) * pitch
441
+
442
+ design_f = _fraction(study.design_space, centers, pitch)
443
+ pres_f = _fraction([r.shape for r in study.preserve], centers, pitch)
444
+ keep_f = _fraction([r.shape for r in study.keep_out], centers, pitch)
445
+ preserve = (pres_f >= 0.5) & (keep_f < 0.5)
446
+ design = (design_f >= 0.5) & ~preserve & (keep_f == 0)
447
+ active = preserve | design
448
+ if not active.any():
449
+ raise ValueError("the design space is empty after removing keep-outs")
450
+
451
+ warnings = []
452
+ for rid, t in study.shells:
453
+ if t < 2.0 * pitch:
454
+ warnings.append(
455
+ f"{rid} is {t:g}mm thick, {t / pitch:.1f} cells: a wall under two cells thick is too stiff in "
456
+ f"bending on this grid (and under one is not there at all). Set resolution.pitch to {t / 2:.2g}mm "
457
+ "or less, or raise resolution.maxElements.")
458
+ # Only for a minMember the study asked for: a process's own floor is what
459
+ # the printer can make, not a member the design needs, and at a first-look
460
+ # resolution it would warn on every powder or FDM study.
461
+ if study.min_member is not None and study.min_member < 1.5 * pitch and study.raw.get("minMember") is not None:
462
+ warnings.append(
463
+ f"minMember {study.min_member:g}mm is under 1.5 cells of this {pitch:.2f}mm grid ({1.5 * pitch:.2f}mm); "
464
+ "members that thin cannot be resolved. Raise resolution.maxElements or set resolution.pitch "
465
+ "(raising minMember coarsens the default pitch, which follows it).")
466
+
467
+ if study.max_member is not None and study.max_member < 3.0 * pitch:
468
+ warnings.append(
469
+ f"maxMember {study.max_member:g}mm is under 3 cells ({3 * pitch:.2f}mm); the thickness limit is too "
470
+ "coarse to hold on this grid. Raise resolution.maxElements or set resolution.pitch.")
471
+ access = getattr(study, "access", None)
472
+ if access is not None and access.tool_radius and access.tool_radius < pitch:
473
+ warnings.append(
474
+ f"milling.toolDiameter {2 * access.tool_radius:g}mm is under two cells ({2 * pitch:.2f}mm); slots "
475
+ "that narrow are left to the grid, which is coarser.")
476
+
477
+ # Support nodes on a provisional mesh, then drop islands that do not
478
+ # reach one, then rebuild the mesh over what remains.
479
+ mesh = HexMesh(ijk[active], pitch, origin)
480
+ support_nodes = np.zeros(mesh.n_nodes, bool)
481
+ for i, s in enumerate(study.supports):
482
+ support_nodes |= _region_nodes(study, mesh, s.region, s.shape, f"supports[{i}]")
483
+ if study.self_equilibrated:
484
+ # held by its own wetted walls (pressure.stabilize)
485
+ for _, nodes in _wetted(study, mesh).values():
486
+ support_nodes |= nodes
487
+ grid = np.zeros(dims, bool)
488
+ grid[tuple(ijk[active].T)] = True
489
+ lbl, _ = ndimage.label(grid, structure=_CONN26)
490
+ el_lbl = lbl[tuple(mesh.elem_ijk.T)]
491
+ touches = np.zeros(mesh.n_elem, bool)
492
+ touches |= support_nodes[mesh.enode].any(axis=1)
493
+ keep_lbls = np.unique(el_lbl[touches])
494
+ keep = np.isin(el_lbl, keep_lbls)
495
+ if (~keep).any():
496
+ dropped = int((~keep).sum())
497
+ if preserve[active][~keep].any():
498
+ warnings.append("some preserved geometry is not connected to any support and was left out of the "
499
+ "analysis; it will still be in the final part but carries no load")
500
+ warnings.append(f"{dropped} cells not connected to a support were removed")
501
+ act_idx = np.nonzero(active)[0]
502
+ active[act_idx[~keep]] = False
503
+ mesh = HexMesh(ijk[active], pitch, origin)
504
+
505
+ pres_el = preserve[active]
506
+ try:
507
+ bc = boundary_conditions(study, mesh)
508
+ except _HeldPreload:
509
+ # Supports that hold a fastener's rings along the screw (a bolt hole
510
+ # fixed in every direction, a bearing face under a thin flange) take
511
+ # the clamp force themselves, so the preload case would load nothing.
512
+ # Drop it and say how to keep it, rather than refuse a study `check`
513
+ # passed and the user never wrote that case for.
514
+ study.load_cases = [c for c in study.load_cases if c.name != "preload"]
515
+ warnings.append("the fasteners' preload case was dropped: the supports hold its seat and face rings along "
516
+ "the screw, so it would load nothing. To design for the clamp, hold the bolt holes across "
517
+ "the screw only (fix 'xy' for a screw along z) or support elsewhere")
518
+ bc = boundary_conditions(study, mesh)
519
+ support_nodes, fixed, load_nodes = bc.support_nodes, bc.fixed, bc.load_nodes
520
+ loads = bc.loads
521
+ names = [c.name for c in study.load_cases]
522
+ weights = [c.weight for c in study.load_cases]
523
+
524
+ # A small load or support (a bolt hole, a pin, a corner support) needs
525
+ # material to act through: the cells touching it are pinned solid, as if
526
+ # preserved, or the boundary-padded filter (simp.density_filter) makes
527
+ # them too expensive to fill and the support is optimized away (the
528
+ # textbook MBB beam's corners). A large one (a whole wall face, a shelf
529
+ # surface) is left to the optimizer: pinning it forced a full skin over
530
+ # the face, which read as part of the design not being optimized at all.
531
+ # A face is told apart by its shape as well as its share of the cells: a
532
+ # region at most two cells thick that spans more than two filter
533
+ # diameters both ways is a face however small the part is around it. By
534
+ # share alone the printed hook's wall face (1.6%) and the wall bracket's
535
+ # shelf (2.5%) were pinned one cell thick: the optimizer never had to
536
+ # thicken that plate, and it was delivered 1.5 and 1.9 mm thick against a
537
+ # 2.4 and 3.0 mm minimum member. A supporting face is left to the
538
+ # optimizer, like any large one. A loaded face is pinned at the minimum
539
+ # member's thickness: its load is spread over every node of it, so the
540
+ # part has to cover it anyway, and left unpinned the optimizer could
541
+ # empty it (the fastener bracket's shelf, which the part then no longer
542
+ # reached). The MBB beam's load line and the motor mount's pads are
543
+ # under two diameters and stay pinned as they were.
544
+ radius = 1.3 * float(study.min_member) if study.min_member else 1.5 * pitch
545
+ active_grid = np.zeros(dims, bool)
546
+ active_grid[tuple(mesh.elem_ijk.T)] = True
547
+ for ri, nodes in enumerate(bc.regions):
548
+ touch = nodes[mesh.enode].any(axis=1)
549
+ if not touch.any():
550
+ continue
551
+ ext = np.ptp(mesh.elem_ijk[touch], axis=0) + 1
552
+ thin = int(np.argmin(ext))
553
+ face = ext[thin] <= 2 and np.sort(ext)[1] * pitch > 4.0 * radius
554
+ # A loaded face is pinned however much of the part it is: a deck
555
+ # under a spread load. Capped like the rest, the 3D bridge benchmark
556
+ # (a load over its whole top, 6% of the cells) left deck nodes on
557
+ # void and its compliance went up a million-fold.
558
+ if touch.sum() > _ANCHOR_MAX_FRACTION * mesh.n_elem and not (face and ri >= len(study.supports)):
559
+ continue
560
+ if face:
561
+ if ri < len(study.supports):
562
+ continue
563
+ layers = int(np.ceil(float(study.min_member or 0.0) / pitch))
564
+ if layers > ext[thin]:
565
+ g = np.zeros(dims, bool)
566
+ g[tuple(mesh.elem_ijk[touch].T)] = True
567
+ step = np.zeros((3, 3, 3), bool)
568
+ step[1, 1, 1] = True
569
+ step[tuple(np.where(np.arange(3) == thin, [0, 0, 0], 1))] = True
570
+ step[tuple(np.where(np.arange(3) == thin, [2, 2, 2], 1))] = True
571
+ g = ndimage.binary_dilation(g, structure=step, iterations=layers - int(ext[thin])) & active_grid
572
+ touch = g[tuple(mesh.elem_ijk.T)]
573
+ pres_el = pres_el | touch
574
+
575
+ mirror = {}
576
+ for axis in study.symmetry:
577
+ a = "xyz".index(axis)
578
+ m_ijk = mesh.elem_ijk.copy()
579
+ m_ijk[:, a] = dims[a] - 1 - m_ijk[:, a]
580
+ lut = np.full(dims, -1, np.int64)
581
+ lut[tuple(mesh.elem_ijk.T)] = np.arange(mesh.n_elem)
582
+ mirror[axis] = lut[tuple(m_ijk.T)]
583
+ unmatched = float((mirror[axis] < 0).mean())
584
+ if unmatched > 0.02:
585
+ warnings.append(f"symmetry '{axis}': {unmatched:.0%} of cells have no mirror; the design space, "
586
+ "preserve or keep-outs are not symmetric about that plane")
587
+
588
+ from .thermal import study_heat
589
+
590
+ return Domain(pitch=pitch, origin=origin, dims=tuple(int(d) for d in dims), mesh=mesh,
591
+ preserve=pres_el, fixed=fixed, loads=loads, case_names=names, case_weights=weights,
592
+ support_nodes=support_nodes, load_nodes=load_nodes, mirror=mirror, warnings=warnings,
593
+ springs=bc.springs, gravity=bc.gravity, spreads=bc.spreads,
594
+ thermal=study_heat(study, mesh, study.elastic(design=True), fixed,
595
+ near=_near_cells(mesh, support_nodes | load_nodes)),
596
+ warp=_warp_term(study, mesh))
597
+
598
+
599
+ def _warp_term(study, mesh):
600
+ """buildSim.warpWeight > 0 or a warp limit (maxDistortion,
601
+ maxRecoaterLift): the as-built build model the optimizer carries
602
+ (buildsim.WarpTerm, on a coarser grid when the mesh is big) and what it
603
+ does with it (WarpSpec)."""
604
+ bs = getattr(study, "build_sim", None)
605
+ if not bs or not (bs.get("warpWeight") or bs.get("maxDistortion") or bs.get("maxRecoaterLift")):
606
+ return None
607
+ if bs["setup"].process.lower() == "fdm" and not bs.get("warpWeight") and not bs.get("warpInOptimizer"):
608
+ # FDM limits are checked on the delivered part (study.py); pushing
609
+ # them through the optimizer cost the load path and did not lower
610
+ # the warp (docs/BUILDSIM.md)
611
+ return None
612
+ from .buildsim import CoarseWarp, WarpTerm
613
+ nu = float(study.elastic(design=True))
614
+ f = bs.get("warpCoarsen") or (2 if mesh.n_elem >= 4000 else 1)
615
+ layer = bs.get("warpLayer")
616
+ if f > 1:
617
+ term = CoarseWarp(mesh, bs["setup"], f, build=study.process.build, layer=layer, nu=nu)
618
+ else:
619
+ term = WarpTerm(mesh, bs["setup"], build=study.process.build, layer=layer, nu=nu)
620
+ return WarpSpec(term, float(bs.get("warpWeight") or 0.0), bs.get("maxDistortion"), bs.get("maxRecoaterLift"),
621
+ f)
622
+
623
+
624
+ @dataclass
625
+ class WarpSpec:
626
+ term: object # buildsim.WarpTerm or CoarseWarp: measures(e, de, weights)
627
+ weight: float # buildSim.warpWeight
628
+ max_distortion: float | None # mm limits, held as constraints
629
+ max_lift: float | None
630
+ coarsen: int = 1