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/verify.py ADDED
@@ -0,0 +1,1215 @@
1
+ """
2
+ Check the part we actually deliver, not the density field we optimized.
3
+
4
+ The final mesh differs from the optimizer's field (smoothing, the exact
5
+ booleans, dropped fragments), so verification re-voxelizes the mesh,
6
+ runs a fresh linear static analysis at the material's real modulus for every
7
+ load case, and measures geometry with exact booleans:
8
+
9
+ - mass, and how much lighter than the solid design space it is;
10
+ - peak von Mises stress per load case and the safety factor against the
11
+ material's strength;
12
+ - deflection at the loads;
13
+ - fatigue life, creep and bolt-preload relaxation for load cases that
14
+ repeat or are held (durability.py);
15
+ - keep-out intrusion and preserve loss (mm^3, from booleans: both should be
16
+ zero to rounding);
17
+ - body count and thinnest member.
18
+
19
+ Stress next to where loads and supports are applied is excluded from the
20
+ peak. A load spread over a few nodes is a modeling idealization, and the
21
+ stress it produces there is a property of the idealization, not of the
22
+ part; every FEA package has the same caveat. Everything else counts.
23
+ """
24
+ from __future__ import annotations
25
+
26
+ import threading
27
+ import time
28
+
29
+ import numpy as np
30
+ from scipy import ndimage
31
+
32
+ from .domain import boundary_conditions, gravity_force
33
+ from .fem import HexMesh, Solver, Stiffness, von_mises
34
+ from .spec import Study
35
+
36
+ __all__ = ["voxelize", "verify_part"]
37
+
38
+ STRESS_ALLOWANCE = 1.15
39
+
40
+
41
+ def voxelize(mesh, pitch: float, box=None, fractional=False):
42
+ """Solid cells of a closed mesh by ray parity along +z through each
43
+ column of cell centers. Returns (bool grid, origin = min corner of cell
44
+ (0,0,0)). `box` = (lo, hi) voxelizes only that region (the rays still
45
+ start below the whole mesh, so parity is right). `fractional` returns
46
+ each cell's filled share instead (float grid): the z overlap is exact
47
+ from the ray's crossings, averaged over 3 x 3 sub-columns per cell."""
48
+ if fractional:
49
+ return _voxel_fractions(mesh, pitch, box)
50
+ mlo = np.asarray(mesh.bounds, float)[0]
51
+ lo, hi = (np.asarray(b, float) for b in box) if box is not None else np.asarray(mesh.bounds, float)
52
+ # a box a whole number of cells across (a design grid's) must come out
53
+ # that many: (hi - lo) / pitch rounds to just over n at some pitches
54
+ # (the arm link's 57 cells read 58 at 2.887 mm, and its access report
55
+ # failed on the shape mismatch)
56
+ dims = np.maximum(np.ceil((hi - lo) / pitch - 1e-6).astype(int), 1)
57
+ origin = lo - 0.5 * (dims * pitch - (hi - lo))
58
+ xs = origin[0] + (np.arange(dims[0]) + 0.5) * pitch
59
+ ys = origin[1] + (np.arange(dims[1]) + 0.5) * pitch
60
+ gx, gy = np.meshgrid(xs, ys, indexing="ij")
61
+ # A hair of jitter keeps rays off exact edges and vertices, where a hit
62
+ # could be reported twice and flip the parity.
63
+ jit = pitch * 1e-4
64
+ ray_o = np.stack([gx.ravel() + 0.37 * jit, gy.ravel() + 0.61 * jit,
65
+ np.full(gx.size, mlo[2] - pitch)], axis=1)
66
+ ray_d = np.tile([0.0, 0.0, 1.0], (len(ray_o), 1))
67
+ locs, ray_idx, _ = mesh.ray.intersects_location(ray_o, ray_d, multiple_hits=True)
68
+ if len(ray_idx) == 0 and len(mesh.faces):
69
+ # A mesh handed back from a worker process (GRIP's turret) answered
70
+ # no ray at all, where the same mesh saved and reloaded answers
71
+ # 16,740: rebuild it fresh, with a fresh intersector, and ask again.
72
+ import trimesh
73
+
74
+ fresh = trimesh.Trimesh(np.array(mesh.vertices, float), np.array(mesh.faces), process=False)
75
+ locs, ray_idx, _ = fresh.ray.intersects_location(ray_o, ray_d, multiple_hits=True)
76
+ locs = np.asarray(locs, float).reshape(-1, 3)
77
+ ray_idx = np.asarray(ray_idx, int).reshape(-1)
78
+ zc = origin[2] + (np.arange(dims[2]) + 0.5) * pitch
79
+ solid = np.zeros(dims, bool)
80
+ order = np.lexsort((locs[:, 2], ray_idx))
81
+ ray_idx, zs = ray_idx[order], locs[order, 2]
82
+ starts = np.searchsorted(ray_idx, np.arange(len(ray_o)))
83
+ ends = np.searchsorted(ray_idx, np.arange(len(ray_o)), side="right")
84
+ for r in np.nonzero(ends > starts)[0]:
85
+ z = zs[starts[r]:ends[r]]
86
+ # Merge near-duplicate hits (shared edges) before pairing.
87
+ if len(z) > 1:
88
+ z = z[np.concatenate([[True], np.diff(z) > 1e-7])]
89
+ if len(z) % 2:
90
+ z = z[:-1]
91
+ inside = np.zeros(dims[2], bool)
92
+ for a, b in z.reshape(-1, 2):
93
+ inside |= (zc > a) & (zc < b)
94
+ i, j = np.unravel_index(r, (dims[0], dims[1]))
95
+ solid[i, j] = inside
96
+ return solid, origin
97
+
98
+
99
+ def _voxel_fractions(mesh, pitch, box=None, sub=3):
100
+ lo, hi = (np.asarray(b, float) for b in box) if box is not None else np.asarray(mesh.bounds, float)
101
+ dims = np.maximum(np.ceil((hi - lo) / pitch - 1e-6).astype(int), 1)
102
+ origin = lo - 0.5 * (dims * pitch - (hi - lo))
103
+ # the sub-columns are one bool grid at pitch/sub in x and y; each z
104
+ # interval's overlap with each cell is added exactly
105
+ mlo = np.asarray(mesh.bounds, float)[0]
106
+ fine = np.array([dims[0] * sub, dims[1] * sub])
107
+ xs = origin[0] + (np.arange(fine[0]) + 0.5) * pitch / sub
108
+ ys = origin[1] + (np.arange(fine[1]) + 0.5) * pitch / sub
109
+ gx, gy = np.meshgrid(xs, ys, indexing="ij")
110
+ jit = pitch * 1e-4
111
+ ray_o = np.stack([gx.ravel() + 0.37 * jit, gy.ravel() + 0.61 * jit,
112
+ np.full(gx.size, mlo[2] - pitch)], axis=1)
113
+ locs, ray_idx, _ = mesh.ray.intersects_location(ray_o, np.tile([0.0, 0.0, 1.0], (len(ray_o), 1)),
114
+ multiple_hits=True)
115
+ locs = np.asarray(locs, float).reshape(-1, 3)
116
+ ray_idx = np.asarray(ray_idx, int).reshape(-1)
117
+ order = np.lexsort((locs[:, 2], ray_idx))
118
+ ray_idx, zs = ray_idx[order], locs[order, 2]
119
+ starts = np.searchsorted(ray_idx, np.arange(len(ray_o)))
120
+ ends = np.searchsorted(ray_idx, np.arange(len(ray_o)), side="right")
121
+ z0 = origin[2] + np.arange(dims[2]) * pitch
122
+ frac = np.zeros(dims, float)
123
+ for r in np.nonzero(ends > starts)[0]:
124
+ z = zs[starts[r]:ends[r]]
125
+ if len(z) > 1:
126
+ z = z[np.concatenate([[True], np.diff(z) > 1e-7])]
127
+ if len(z) % 2:
128
+ z = z[:-1]
129
+ cover = np.zeros(dims[2])
130
+ for a, b in z.reshape(-1, 2):
131
+ cover += np.clip(np.minimum(b, z0 + pitch) - np.maximum(a, z0), 0.0, pitch)
132
+ i, j = np.unravel_index(r, (fine[0], fine[1]))
133
+ frac[i // sub, j // sub] += cover
134
+ return np.clip(frac / (pitch * sub * sub), 0.0, 1.0), origin
135
+
136
+
137
+ def _thickness(solid, pitch):
138
+ """Member thickness on the medial ridge: twice the distance to void at
139
+ cells that are local maxima of that distance."""
140
+ edt = ndimage.distance_transform_edt(np.pad(solid, 1))[1:-1, 1:-1, 1:-1]
141
+ ridge = solid & (edt >= ndimage.maximum_filter(edt, size=3)) & (edt > 0)
142
+ # A ridge cell's distance to void is ceil(n/2) for a member n cells
143
+ # thick, so 2*edt over-reads odd and 2*edt-1 under-reads even
144
+ # thicknesses by a whole cell; 2*edt-0.5 is within half a cell of both.
145
+ vals = (2.0 * edt[ridge] - 0.5) * pitch
146
+ if vals.size == 0:
147
+ return None, None
148
+ return float(vals.min()), float(np.percentile(vals, 5))
149
+
150
+
151
+ def surface_thickness(mesh, samples: int = 6000, seed: int = 0, study: Study | None = None):
152
+ """Member thickness without a grid: from evenly spread surface points, a
153
+ ray straight in along the inward normal to where it leaves the part (the
154
+ shape diameter). Returns (p1, p5) in mm. The voxel ridge reading
155
+ (_thickness) is quantized to the analysis cell, under-reading a 2.05 mm
156
+ wall at a 0.98 mm pitch as 1.46; this one is not, but it costs a ray
157
+ cast per sample, so it is for scoring a finished part, not every outcome.
158
+ With the study, a ray that both enters and leaves on the user's own
159
+ surfaces (preserved or keep-out) is left out: that thickness is the
160
+ study's, not the optimizer's (GRIP's wrist yoke: its J5 servo sits 1.9 mm
161
+ above the J4 horn, so the preserved horn plate between them is 1.9 mm)."""
162
+ import trimesh
163
+
164
+ # a fresh, merged copy: a part straight from the booleans can carry
165
+ # degenerate slivers whose bounds break the ray tree (the motor mount)
166
+ mesh = trimesh.Trimesh(np.array(mesh.vertices, float), np.array(mesh.faces), process=True)
167
+ mesh.update_faces(mesh.nondegenerate_faces())
168
+ pts, fi = trimesh.sample.sample_surface_even(mesh, samples, seed=seed)
169
+ n = mesh.face_normals[fi]
170
+ ok = np.isfinite(pts).all(axis=1) & (np.linalg.norm(n, axis=1) > 0.5)
171
+ pts, n = pts[ok], n[ok]
172
+ origin = pts - n * 1e-3
173
+ # in batches: without embree the ray tree's candidate pairs for all 6000
174
+ # rays at once took 7 GB on a 75k-face yoke (the cloud box OOMed)
175
+ hits = [mesh.ray.intersects_location(origin[k:k + 500], -n[k:k + 500], multiple_hits=False)[:2]
176
+ for k in range(0, len(origin), 500)]
177
+ loc = np.concatenate([h[0].reshape(-1, 3) for h in hits]) if hits else np.zeros((0, 3))
178
+ ri = np.concatenate([h[1] + k for h, k in zip(hits, range(0, len(origin), 500))]).astype(int) if hits else np.zeros(0, int)
179
+ if len(ri) == 0:
180
+ return None, None
181
+ d = np.linalg.norm(loc - origin[ri], axis=1)
182
+ if study is not None:
183
+ shapes = [r.shape for r in list(study.preserve) + list(study.keep_out)]
184
+ if shapes:
185
+ def on_user(q):
186
+ return np.min([np.abs(sh.sdf(q)) for sh in shapes], axis=0) < 0.05
187
+ mine = ~(on_user(pts[ri]) & on_user(loc))
188
+ if mine.any():
189
+ d = d[mine]
190
+ return float(np.percentile(d, 1)), float(np.percentile(d, 5))
191
+
192
+
193
+ def nodal_von_mises(stiff, u, modulus, thermal=None):
194
+ """Von Mises at the nodes: each element's stress evaluated at its own
195
+ corners, averaged over the elements sharing a node. Element-centre stress
196
+ sits half a cell inside the surface, which is exactly where stress
197
+ concentrates; on a plate with a hole (Peterson Kt_net = 2.43, peak 81 MPa)
198
+ centre values read 64-74 MPa and neighbor-smoothed centres 59-68, while
199
+ nodal averages read 73 at 7 cells per hole radius and 81.7 from 9 cells
200
+ up, converged. Averaging across elements is also what keeps a single
201
+ voxel staircase corner from reading as a spike."""
202
+ return von_mises(nodal_stress(stiff, u, modulus, thermal))
203
+
204
+
205
+ def nodal_stress(stiff, u, modulus, thermal=None):
206
+ """The nodal-averaged stress tensor (sx, sy, sz, txy, tyz, tzx), as
207
+ nodal_von_mises averages it. `modulus` is one number or one per element;
208
+ `thermal` (per element) is a free expansion strain, taken off the strain
209
+ before the stress is formed."""
210
+ from .fem import CORNERS, elastic_c, hex8_b
211
+
212
+ cg = getattr(stiff, "cutgrid", None)
213
+ if cg is not None:
214
+ return cg.nodal_stress(u, modulus, thermal)
215
+ mesh = stiff.mesh
216
+ c = elastic_c(1.0, stiff.nu)
217
+ element = getattr(stiff, "element", None)
218
+ mod = np.broadcast_to(np.asarray(modulus, float), (mesh.n_elem,))[:, None]
219
+ ue = u[mesh.edof]
220
+ acc = np.zeros((mesh.n_nodes, 6))
221
+ for k, (a, b, cc) in enumerate(2.0 * CORNERS - 1.0):
222
+ strain = ue @ (hex8_b(a, b, cc, stiff.nu, element) / mesh.pitch).T
223
+ if thermal is not None:
224
+ strain[:, :3] -= np.asarray(thermal, float)[:, None]
225
+ np.add.at(acc, mesh.enode[:, k], (strain @ c.T) * mod)
226
+ cnt = np.bincount(mesh.enode.ravel(), minlength=mesh.n_nodes)
227
+ return acc / cnt[:, None]
228
+
229
+
230
+ # mm above the lowest point that still counts as standing on the build plate
231
+ _PLATE_BAND = 1.0
232
+
233
+
234
+ def _overhang(mesh, direction, study=None, max_angle_deg=45.0, tolerance_deg=5.0):
235
+ """Down-facing surface that needs support: faces whose normal is within
236
+ (max_angle - tolerance) of straight down, i.e. ceilings and slopes
237
+ shallower than the self-supporting limit, excluding the face on the build
238
+ plate and the rounded bottom edge within 1 mm of it (a few layers, which
239
+ print without support; marching cubes rounds every edge on the plate, and
240
+ on the printed hook the 0.5-1 mm band alone was a point of its 4.3%). The
241
+ tolerance keeps marching-cubes facets on a true 45-degree
242
+ slope from counting. `overhangArea` is everything; `designOverhangArea`
243
+ leaves out faces on preserved or keep-out geometry, which the user drew
244
+ (a horizontal bore's ceiling is theirs, not the optimizer's)."""
245
+ from .am import build_vector
246
+
247
+ up = build_vector(direction)
248
+ down = -(mesh.face_normals @ up)
249
+ centers = mesh.triangles_center
250
+ height = centers @ up
251
+ bad = (down > np.cos(np.radians(max_angle_deg - tolerance_deg))) & (height > height.min() + _PLATE_BAND)
252
+ area = float(mesh.area_faces[bad].sum())
253
+ out = {"overhangArea": area, "overhangFraction": area / float(mesh.area)}
254
+ if study is not None:
255
+ drawn = np.zeros(len(centers), bool)
256
+ for r in study.preserve + study.keep_out:
257
+ drawn |= np.abs(r.shape.sdf(centers)) < 0.6
258
+ d_area = float(mesh.area_faces[bad & ~drawn].sum())
259
+ out["designOverhangArea"] = d_area
260
+ out["designOverhangFraction"] = d_area / float(mesh.area)
261
+ return out
262
+
263
+
264
+ def _boundary_cut_area(study: Study, mesh) -> float:
265
+ """mm^2 of the part's surface lying flat on a face of a box design space,
266
+ away from every support and preserved shape: organic material sawn off
267
+ where the design box happened to end, not where anything mates. Zero on
268
+ a clean result (GRIP's wrist yoke QA, 10/28)."""
269
+ from .shapes import Box
270
+
271
+ boxes = [d for d in study.design_space if isinstance(d, Box)]
272
+ if not boxes:
273
+ return 0.0
274
+ c = mesh.triangles_center
275
+ nrm = mesh.face_normals
276
+ area = mesh.area_faces
277
+ keep = [r.shape for r in study.preserve]
278
+ for sup in study.supports:
279
+ try:
280
+ keep.append(sup.shape if sup.shape is not None else study.region_shape(sup.region)[0])
281
+ except ValueError:
282
+ pass
283
+ near = np.min([k.sdf(c) for k in keep], axis=0) < 1.0 if keep else np.zeros(len(c), bool)
284
+ cut = np.zeros(len(c), bool)
285
+ for b in boxes:
286
+ for ax in range(3):
287
+ for side, plane in ((-1, b.lo[ax]), (1, b.hi[ax])):
288
+ cut |= (np.abs(c[:, ax] - plane) < 0.05) & (nrm[:, ax] * side > 0.99)
289
+ return float(area[cut & ~near].sum())
290
+
291
+
292
+ # A concave edge sharper than this (the faces turn by more than 60 degrees)
293
+ # is a crease: a notch that concentrates stress and that a fillet should have
294
+ # rounded. Marching-cubes facets on an organic surface turn by a few degrees.
295
+ CREASE_ANGLE_DEG = 60.0
296
+
297
+
298
+ def _creases(study: Study, mesh, angle_deg: float = CREASE_ANGLE_DEG) -> dict:
299
+ """Sharp concave edges the optimizer made: where an organic member meets
300
+ preserved geometry without a fillet, or two members meet in a notch.
301
+ GRIP's wrist yoke passed every number with hard creases at its bosses,
302
+ found only by zooming in (QA, 10/28); this measures them. Edges lying on
303
+ keep-out surfaces (a blind bore's floor) or between two faces of the
304
+ user's own preserved geometry are the user's, and are left out.
305
+ Returns crease length (mm) and up to five crease locations."""
306
+ ang = mesh.face_adjacency_angles
307
+ sharp = (~mesh.face_adjacency_convex) & (ang > np.radians(angle_deg))
308
+ out = {"creaseLength": 0.0, "creaseAt": []}
309
+ if not sharp.any():
310
+ return out
311
+ e = mesh.face_adjacency_edges[sharp]
312
+ a, b = mesh.vertices[e[:, 0]], mesh.vertices[e[:, 1]]
313
+ mid = 0.5 * (a + b)
314
+ length = np.linalg.norm(b - a, axis=1)
315
+ users = np.zeros(len(mid), bool)
316
+ if study.keep_out:
317
+ users |= np.min([np.abs(r.shape.sdf(mid)) for r in study.keep_out], axis=0) < 0.3
318
+ if study.preserve:
319
+ fc = mesh.triangles_center[mesh.face_adjacency[sharp]] # (n, 2, 3)
320
+ on = np.min([np.abs(r.shape.sdf(fc.reshape(-1, 3))) for r in study.preserve], axis=0).reshape(-1, 2)
321
+ users |= (on < 0.05).all(axis=1)
322
+ mine = ~users
323
+ out["creaseLength"] = float(length[mine].sum())
324
+ pts = mid[mine]
325
+ w = length[mine]
326
+ while len(pts) and len(out["creaseAt"]) < 5:
327
+ # heaviest cluster first, 5 mm across
328
+ i = int(np.argmax([w[np.linalg.norm(pts - p, axis=1) < 5.0].sum() for p in pts]))
329
+ near = np.linalg.norm(pts - pts[i], axis=1) < 5.0
330
+ out["creaseAt"].append({"at": pts[near].mean(axis=0).round(2).tolist(), "length": float(w[near].sum())})
331
+ pts, w = pts[~near], w[~near]
332
+ return out
333
+
334
+
335
+ def _closed_bores(study: Study, mesh) -> list:
336
+ """Cylindrical keep-outs (bores) with an end the part closes over.
337
+
338
+ A fastener needs its whole path clear: the shank through the bore, and
339
+ the head, washer and driver out past its end. A bore that stops inside
340
+ material is a blind hole no screw can be driven through, the way the
341
+ wall bracket's screw holes came out capped (and GRIP's yoke had material
342
+ over its bolt seats). The fix is in the study: extend the keep-out over
343
+ the head and the driver's reach. Probes a short way past each end cap,
344
+ on the axis and on a ring at 0.3 of the radius; an end is closed when
345
+ most probes land inside the part. A keep-out marked "bore": false (a
346
+ clearance volume that may end against material) is not checked."""
347
+ from .shapes import Cylinder
348
+
349
+ out = []
350
+ for r in study.keep_out:
351
+ if not r.bore:
352
+ continue
353
+ sh = r.shape
354
+ if not isinstance(sh, Cylinder) or sh.r_inner > 0:
355
+ continue
356
+ a = sh.axis
357
+ u = np.cross(a, [1.0, 0.0, 0.0] if abs(a[0]) < 0.9 else [0.0, 1.0, 0.0])
358
+ u /= np.linalg.norm(u)
359
+ v = np.cross(a, u)
360
+ # on and near the axis only: a head-clearance bore ending on its
361
+ # seat is closed round the rim by design, with the screw's own
362
+ # narrower bore running on through the middle (the motor mount's
363
+ # access holes); a capped hole is closed across the middle too
364
+ ring = [np.zeros(3)] + [0.3 * sh.r * (np.cos(t) * u + np.sin(t) * v)
365
+ for t in np.linspace(0, 2 * np.pi, 6, endpoint=False)]
366
+ for end, cap, out_dir in (("p0", sh.p0, -a), ("p1", sh.p1, a)):
367
+ probes = np.array([cap + d * out_dir + o for d in (1.5, 3.0) for o in ring])
368
+ inside = mesh.contains(probes)
369
+ if inside.mean() > 0.5:
370
+ out.append({"id": r.id, "end": end, "at": cap.round(2).tolist()})
371
+ return out
372
+
373
+
374
+ def _union(shapes):
375
+ import manifold3d as m3d
376
+
377
+ from .sharp import exact_manifold
378
+ # the same exact solids reconstruct.py cut and added (circles 0.002 mm true)
379
+ mans = [exact_manifold(s) for s in shapes]
380
+ if not mans:
381
+ return None
382
+ return m3d.Manifold.batch_boolean(mans, m3d.OpType.Add) if len(mans) > 1 else mans[0]
383
+
384
+
385
+ def _hotspots(xyz, vm, body, peak, radius, count=3):
386
+ """Up to `count` local stress maxima at least 2*radius apart, among nodes
387
+ within half of the peak."""
388
+ order = np.argsort(-np.where(body, vm, -1.0))
389
+ picked = []
390
+ for i in order:
391
+ if not body[i] or vm[i] < 0.5 * peak or len(picked) >= count:
392
+ break
393
+ if all(np.linalg.norm(xyz[i] - xyz[j]) >= 2 * radius for j in picked):
394
+ picked.append(i)
395
+ return [xyz[i] for i in picked]
396
+
397
+
398
+ # A wetted wall is a voxel staircase: the stress at its nodes alternates
399
+ # between the step's teeth (unloaded corners, low) and its re-entrant corners
400
+ # (high), and refining the grid does not settle it, the staircase being the
401
+ # same shape at every pitch. On a closed cylinder under pressure (r 20, wall
402
+ # 3, Lamé 7.10 MPa at the bore) the wetted nodes read 5.6 at the median and
403
+ # 8.6-9.2 at the peak from 2.5 to 5 cells through the wall; averaged over the
404
+ # wetted nodes within 1.5 cells of each, the peak reads 7.6-7.9, the real
405
+ # wall's to within the discretization allowance.
406
+ _WET_SMOOTH = 1.5
407
+ # cells through the thinnest pinned wall or skin (study.shells), and the most
408
+ # cells verification will spend getting there
409
+ _SHELL_CELLS = 4.0
410
+ _SHELL_MAX_CELLS = 400_000
411
+
412
+
413
+ def _smooth_wetted(xyz, vm, wet, pitch):
414
+ """von Mises at wetted nodes averaged over the wetted nodes near each."""
415
+ from scipy.spatial import cKDTree
416
+
417
+ idx = np.nonzero(wet)[0]
418
+ if len(idx) == 0:
419
+ return vm
420
+ import scipy.sparse as sp
421
+
422
+ tree = cKDTree(xyz[idx])
423
+ pairs = tree.query_pairs(_WET_SMOOTH * pitch, output_type="ndarray")
424
+ n = len(idx)
425
+ rows = np.concatenate([pairs[:, 0], pairs[:, 1], np.arange(n)])
426
+ cols = np.concatenate([pairs[:, 1], pairs[:, 0], np.arange(n)])
427
+ m = sp.csr_matrix((np.ones(len(rows)), (rows, cols)), shape=(n, n))
428
+ out = vm.copy()
429
+ out[idx] = (m @ vm[idx]) / np.asarray(m.sum(axis=1)).ravel()
430
+ return out
431
+
432
+
433
+ def _rigid_free(xyz, u):
434
+ """u less its best-fit rigid motion (least squares over the nodes): the
435
+ deformation of a part held only by a 3-2-1 support."""
436
+ from .fem import rigid_body_modes
437
+
438
+ b = rigid_body_modes(xyz)
439
+ coef = np.linalg.lstsq(b, u, rcond=None)[0]
440
+ return u - b @ coef
441
+
442
+
443
+ def _user_edges(study: Study, band: float):
444
+ """Where a keep-out's surface meets a preserved body's (a screw-head
445
+ counterbore's floor corner on a boss face, a bore's rim): a sharp edge
446
+ the user drew, which the optimizer cannot round. Its linear-elastic stress
447
+ grows without bound as the grid gets finer, like a clamped edge's, so it
448
+ is left out of the peak over the same band. (The wall bracket's 150k-cell
449
+ confirm check peaked there and read SF 1.6, 2.6, 2.5, 1.9, 3.7 at 11.5-15%
450
+ of the space, out of order, against 2.7-3.8 on the 60k check.)
451
+
452
+ Being near both surfaces is not enough: a pressure vessel's wall, or a
453
+ ring whose bore is the keep-out's, lines the keep-out, and the two
454
+ surfaces coincide over the whole cavity (a thick sphere's bore, Lame's
455
+ peak, was left out entirely). An edge is where the surfaces cross
456
+ (their normals not opposed) or where the keep-out's own surface turns
457
+ within the band (a tunnel's rim on the face it ends on). Returns a
458
+ function of points -> bool, or None when the study has no such edges."""
459
+ keep = [r.shape for r in study.keep_out]
460
+ pres = [r.shape for r in study.preserve]
461
+ if not keep or not pres:
462
+ return None
463
+ h = 0.05 * band
464
+ axes = np.eye(3)
465
+
466
+ def sdf(shapes, xyz):
467
+ return np.min([sh.sdf(xyz) for sh in shapes], axis=0)
468
+
469
+ def normal(shapes, xyz):
470
+ g = np.stack([sdf(shapes, xyz + h * a) - sdf(shapes, xyz - h * a) for a in axes], axis=1)
471
+ return g / np.maximum(np.linalg.norm(g, axis=1, keepdims=True), 1e-12)
472
+
473
+ def near(xyz):
474
+ xyz = np.asarray(xyz, float)
475
+ out = (np.abs(sdf(keep, xyz)) < band) & (np.abs(sdf(pres, xyz)) < band)
476
+ if not out.any():
477
+ return out
478
+ x = xyz[out]
479
+ nk = normal(keep, x)
480
+ edge = np.einsum("ij,ij->i", nk, normal(pres, x)) > -0.9
481
+ for o in np.concatenate([axes, -axes]) * band:
482
+ edge |= np.einsum("ij,ij->i", nk, normal(keep, x + o)) < 0.9
483
+ out[out] = edge
484
+ return out
485
+ return near
486
+
487
+
488
+ # trimesh's ray/contains queries share the mesh's rtree, which is not
489
+ # thread-safe (concurrent queries corrupt its node reads: "RTreeError:
490
+ # readNode"); the submodels' voxelizing takes turns, their solves overlap
491
+ _MESH_QUERY = threading.Lock()
492
+
493
+
494
+ def _submodel_peak(study, mesh, hm, u, center, half, pitch, mat, contact_xyz, band, case=None, edges=None,
495
+ cut=False):
496
+ """Re-solve a small box around a global hotspot at a finer pitch, driven by
497
+ the global displacement field on the box's cut faces (classic FE
498
+ submodeling). Voxel stress converges only with about 8 cells across a
499
+ feature's radius; the global grid rarely affords that, the box does.
500
+ Returns (peak MPa, position) or None."""
501
+ from scipy.spatial import cKDTree
502
+
503
+ lo, hi = center - half, center + half
504
+ cgrid = None
505
+ with _MESH_QUERY:
506
+ if cut:
507
+ from .cutcell import cut_from_mesh
508
+
509
+ cgrid = cut_from_mesh(mesh, pitch, study.elastic(mat), box=(lo, hi))
510
+ solid, origin = cgrid.solid_grid()
511
+ else:
512
+ solid, origin = voxelize(mesh, pitch, box=(lo, hi))
513
+ if solid.sum() < 64:
514
+ return None
515
+ sm = cgrid.mesh if cgrid is not None else HexMesh(np.argwhere(solid), pitch, origin)
516
+ blo = origin
517
+ bhi = origin + np.asarray(solid.shape) * pitch
518
+ eps = 0.25 * pitch
519
+ cut = np.any((sm.node_xyz < blo + eps) | (sm.node_xyz > bhi - eps), axis=1)
520
+ near_contact = np.zeros(sm.n_nodes, bool)
521
+ if len(contact_xyz):
522
+ near_contact = cKDTree(contact_xyz).query(sm.node_xyz, k=1, distance_upper_bound=band)[0] < np.inf
523
+ driven = cut | near_contact
524
+ if driven.all() or not driven.any():
525
+ return None
526
+ # Global displacement at the driven nodes: inverse-distance weights over
527
+ # the nearest global nodes (the field is smooth at this scale).
528
+ gd, gi = cKDTree(hm.node_xyz).query(sm.node_xyz[driven], k=8)
529
+ w = 1.0 / np.maximum(gd, 1e-9) ** 2
530
+ w /= w.sum(axis=1, keepdims=True)
531
+ ug = u.reshape(-1, 3)[gi]
532
+ up = (w[..., None] * ug).sum(axis=1).ravel()
533
+ pd = np.repeat(driven, 3)
534
+ st = Stiffness(sm, study.elastic(mat), np.zeros(sm.n_dof, bool))
535
+ k = (cgrid.assemble(st, mat.E) if cgrid is not None else st.assemble(np.full(sm.n_elem, mat.E))).tocsr()
536
+ free = ~pd
537
+ kff = k[free][:, free]
538
+ rhs = -(k[free][:, pd] @ up)
539
+ if case is not None:
540
+ # a pressure inside the box pushes on its walls here too
541
+ from .pressure import pressure_force
542
+
543
+ fp = np.zeros(sm.n_dof)
544
+ for ld in case.loads:
545
+ if ld.pressure:
546
+ fp += pressure_force(sm, study.region_shape(ld.region)[0], ld.pressure)[0]
547
+ rhs = rhs + fp[free]
548
+ solver = Solver(sm, rtol=1e-7, maxiter=4000)
549
+ # Rigid-body modes are pinned by the driven nodes; AMG still wants a
550
+ # near-null space sized to the free unknowns, so use plain CG+AMG on kff.
551
+ from scipy.sparse.linalg import cg
552
+
553
+ from .fem import amg_solver
554
+ ml = amg_solver(kff, symmetry="hermitian", strength="symmetric", max_coarse=400, coarse_solver="splu")
555
+ uf, info = cg(kff, rhs, rtol=1e-7, maxiter=4000, M=ml.aspreconditioner(cycle="V"))
556
+ del solver
557
+ if info != 0:
558
+ return None
559
+ us = np.zeros(sm.n_dof)
560
+ us[pd] = up
561
+ us[free] = uf
562
+ vm = von_mises(cgrid.nodal_stress(us, mat.E)) if cgrid is not None else nodal_von_mises(st, us, mat.E)
563
+ if case is not None and fp.any():
564
+ # over the global grid's reach: a finer staircase is no smoother
565
+ vm = _smooth_wetted(sm.node_xyz, vm, np.abs(fp.reshape(-1, 3)).sum(axis=1) > 0, hm.pitch)
566
+ # Trust the inside of the box only: two global cells from the cut faces.
567
+ margin = 2.0 * hm.pitch
568
+ inner = np.all((sm.node_xyz > blo + margin) & (sm.node_xyz < bhi - margin), axis=1) & ~near_contact
569
+ if edges is not None:
570
+ inner &= ~edges(sm.node_xyz)
571
+ if not inner.any():
572
+ return None
573
+ i = int(np.argmax(np.where(inner, vm, -1.0)))
574
+ return float(vm[i]), sm.node_xyz[i].tolist()
575
+
576
+
577
+
578
+ def _inside(mesh, pts, rng):
579
+ """Point-in-solid by ray-crossing parity (odd = inside): a ray along +x,
580
+ tilted a little at random so edge and vertex grazes are measure-zero,
581
+ tested exactly (Moller-Trumbore) against the triangles whose y-z box it
582
+ can pass through. trimesh's own test (no embree) took 80 s of a 90 s
583
+ verify on the arm link."""
584
+ tri = np.asarray(mesh.triangles, float)
585
+ v0, e1, e2 = tri[:, 0], tri[:, 1] - tri[:, 0], tri[:, 2] - tri[:, 0]
586
+ lo, hi = tri.min(axis=1), tri.max(axis=1)
587
+ span = float(np.ptp(tri[..., 0])) + 1.0
588
+ out = np.zeros(len(pts), bool)
589
+ for i, o in enumerate(np.asarray(pts, float)):
590
+ tilt = rng.uniform(-1e-3, 1e-3, 2)
591
+ d = np.array([1.0, *tilt]) / np.sqrt(1.0 + tilt @ tilt)
592
+ g = np.abs(tilt) * span + 1e-9
593
+ c = np.flatnonzero((hi[:, 0] >= o[0]) & (lo[:, 1] <= o[1] + g[0]) & (hi[:, 1] >= o[1] - g[0])
594
+ & (lo[:, 2] <= o[2] + g[1]) & (hi[:, 2] >= o[2] - g[1]))
595
+ if not len(c):
596
+ continue
597
+ p = np.cross(d, e2[c])
598
+ det = np.einsum("ij,ij->i", e1[c], p)
599
+ ok = np.abs(det) > 1e-12
600
+ inv = np.where(ok, 1.0 / np.where(ok, det, 1.0), 0.0)
601
+ tv = o - v0[c]
602
+ u = np.einsum("ij,ij->i", tv, p) * inv
603
+ q = np.cross(tv, e1[c])
604
+ v = (q @ d) * inv
605
+ t = np.einsum("ij,ij->i", q, e2[c]) * inv
606
+ out[i] = int((ok & (u >= 0) & (v >= 0) & (u + v <= 1) & (t > 1e-9)).sum()) % 2 == 1
607
+ return out
608
+
609
+
610
+ def _preserve_missing(pres_eff, part, mesh, samples: int = 400) -> float:
611
+ """Volume of preserved geometry the part lacks (mm^3). The boolean finds
612
+ candidate pieces; each is then confirmed by sampling points inside it and
613
+ asking the mesh itself (by ray parity) whether they are solid. Where a
614
+ keep-out shares a preserved surface (a bore the size of a boss's inner
615
+ wall), manifold3d can report slivers of the preserved shape missing from
616
+ a part that, point by point, has them: the grip upper arm's elbow socket
617
+ read 300 mm^3 short with every sampled point inside."""
618
+ from .shapes import trimesh_from_manifold
619
+
620
+ rng = np.random.default_rng(0)
621
+ total = 0.0
622
+ for b in (pres_eff - part).decompose():
623
+ v = b.volume()
624
+ if v <= 1e-3:
625
+ continue
626
+ tb = trimesh_from_manifold(b)
627
+ lo, hi = tb.bounds
628
+ pts = np.empty((0, 3))
629
+ for n in (samples, 20 * samples): # rejection sampling in the piece's box
630
+ cand = lo + rng.random((n, 3)) * (hi - lo)
631
+ pts = cand[_inside(tb, cand, rng)][:samples]
632
+ if len(pts) >= 10:
633
+ break
634
+ if len(pts) < 10:
635
+ # too thin a sliver for sampling to land in: missing only if it
636
+ # is thicker than a print resolves (thickness ~ 2 V / A)
637
+ total += v if 2.0 * v / max(b.surface_area(), 1e-9) > 0.1 else 0.0
638
+ continue
639
+ total += v * float((~_inside(mesh, pts, rng)).mean())
640
+ return total
641
+
642
+
643
+ def use_cut_cells(study, cut_cells=None) -> bool:
644
+ """Whether verification analyzes cut cells: the argument, else
645
+ LOADPATH_CUT_CELLS (1/0), else the study's resolution.cutCells, else
646
+ yes unless the study carries a pressure."""
647
+ import os
648
+
649
+ if cut_cells is not None:
650
+ return bool(cut_cells)
651
+ env = os.environ.get("LOADPATH_CUT_CELLS", "").strip().lower()
652
+ if env in ("1", "true", "yes", "on"):
653
+ return True
654
+ if env in ("0", "false", "no", "off"):
655
+ return False
656
+ asked = (getattr(study, "raw", None) or {}).get("resolution", {}).get("cutCells")
657
+ if asked is not None:
658
+ return bool(asked)
659
+ # Default on, but not under pressure: a pressure's wetted nodes come
660
+ # from the whole-cell staircase, and on a cut grid they sit off the
661
+ # true wall (the thick sphere read Lame's 21 MPa as 105).
662
+ return not any(ld.pressure for case in getattr(study, "load_cases", ()) for ld in case.loads)
663
+
664
+
665
+ def _solve_cut(cg, nu, fixed, springs, e_mat, moduli, rhs):
666
+ """Solve the cut-cell model (cutcell.CutGrid) for each right-hand side:
667
+ the shared-matrix cells as usual, the cut cells' own matrices added
668
+ (assembled), or as the multigrid's extra operator, which its Galerkin
669
+ coarsening carries down exactly. Returns (stiffness object with
670
+ .cutgrid, displacements)."""
671
+ from .simp import _use_mg
672
+
673
+ hm = cg.mesh
674
+ if _use_mg(hm.n_elem):
675
+ from .mg import GridElasticity
676
+
677
+ extra = cg.cut_operator(moduli)
678
+ if springs is not None:
679
+ extra = (extra + e_mat * springs).tocsr()
680
+ stiff = GridElasticity(hm, nu, fixed, springs=extra, rtol=1e-6, maxiter=3000)
681
+ stiff.set_moduli(cg.full_moduli(moduli))
682
+ else:
683
+ stiff = Stiffness(hm, nu, fixed)
684
+ k = cg.assemble(stiff, moduli)
685
+ if springs is not None:
686
+ k = (k + e_mat * springs).tocsr()
687
+ solver = Solver(hm, rtol=1e-6, maxiter=3000, rebuild_after_iters=10_000)
688
+ stiff.cutgrid = cg
689
+ return stiff, solver.solve_many(k, rhs)
690
+ stiff.cutgrid = cg
691
+ return stiff, stiff.solve_many(rhs)
692
+
693
+
694
+ # cells through a member below which it gets a submodel (cut cells)
695
+ _THIN_CELLS = 3.0
696
+
697
+
698
+ def _thin_spots(hm, solid, origin, pitch, vm, body, peak, radius, taken, count=3):
699
+ """Hotspots on members under _THIN_CELLS cells thick reading at least
700
+ 0.8 of the peak, at least 2 * radius from each other and from `taken`."""
701
+ edt = ndimage.distance_transform_edt(np.pad(solid, 1))[1:-1, 1:-1, 1:-1]
702
+ # a cell's member thickness: twice the largest distance to void nearby
703
+ thick = 2.0 * ndimage.maximum_filter(edt, size=5) - 0.5
704
+ thin_el = thick[tuple(hm.elem_ijk.T)] < _THIN_CELLS
705
+ thin = np.zeros(hm.n_nodes, bool)
706
+ thin[hm.enode[thin_el].ravel()] = True
707
+ cand = body & thin & (vm >= 0.8 * peak)
708
+ out = []
709
+ for i in np.argsort(-np.where(cand, vm, -1.0)):
710
+ if not cand[i] or len(out) >= count:
711
+ break
712
+ x = hm.node_xyz[i]
713
+ if all(np.linalg.norm(x - y) >= 2 * radius for y in list(taken) + out):
714
+ out.append(x)
715
+ return out
716
+
717
+
718
+ # Buckling is read on a grid of at most this many cells. The eigensolver
719
+ # factors the stiffness once and solves with it every iteration, so the cost
720
+ # grows fast with size: the 23 g wall bracket took 11 s at 5k cells, 17 s at
721
+ # 21k and 144 s at 71k (5.7 GB). The coarse grid reads low, the safe side:
722
+ # 1.44 / 1.54 / 1.37 at 5k / 11k / 21k cells against 1.81-1.83 at 42-71k.
723
+ # The search verifies every outcome, so this is per outcome: at 20k cells
724
+ # the arm link and turret spent 37-52 s each.
725
+ BUCKLING_CELLS = 12_000
726
+
727
+
728
+ def _buckling(study: Study, mesh, vol: float, pitch: float, contact_band: float | None,
729
+ directions: list) -> dict | None:
730
+ """The part's linear buckling load factor per load case: the multiple of
731
+ the case's loads at which a perfect version of it buckles (nonlinear.
732
+ linear_buckling, incompatible-mode hex8: a column one cell thick reads
733
+ Euler's load within 1%). Real parts buckle below it (imperfections), and
734
+ the goal's safety factor is the margin asked of it. Gravity counts; heat
735
+ does not; the material is read isotropic (a layered process's softer
736
+ across-layer stiffness is not in it). `directions` are the cases' worst force directions from the
737
+ stress check (None for a fixed force). None when nothing holds the part."""
738
+ from .directions import expand
739
+ from .nonlinear import linear_buckling, solid_submesh
740
+
741
+ t0 = time.time()
742
+ mat = study.material
743
+ pb = max(pitch, (vol / BUCKLING_CELLS) ** (1.0 / 3.0))
744
+ solid, origin = voxelize(mesh, pb)
745
+ hm = HexMesh(np.argwhere(solid), pb, origin)
746
+ bc = boundary_conditions(study, hm, check_loads=False, tol_floor=0.5 * (contact_band or 0.0) + 0.5 * pb)
747
+ if not bc.fixed.any() and bc.springs is None:
748
+ return None
749
+ fg = gravity_force(hm, np.ones(hm.n_elem), bc.gravity, bc.fixed) if bc.gravity is not None else None
750
+ spreads = bc.spreads or [None] * len(study.load_cases)
751
+ rhs, layout = expand(bc.loads, spreads, fg)
752
+ forces = []
753
+ for ci, case in enumerate(study.load_cases):
754
+ idx, d = layout[ci], directions[ci]
755
+ if len(idx) == 1:
756
+ f = rhs[idx[0]]
757
+ else:
758
+ f = sum(d[j] * rhs[i] for j, i in enumerate(idx[1:]))
759
+ if idx[0] is not None:
760
+ f = f + rhs[idx[0]]
761
+ forces.append(f)
762
+ springs = mat.E * bc.springs if bc.springs is not None else None
763
+ # pieces no support holds would float: left out (a pin's springs hold)
764
+ hold = bc.fixed.copy()
765
+ if springs is not None:
766
+ hold |= np.asarray(abs(springs).sum(axis=1)).ravel() > 0
767
+ sub, el, _, vecs = solid_submesh(hm, np.ones(hm.n_elem, bool), hold, [bc.fixed.astype(float)] + forces)
768
+ fixed, forces = vecs[0] > 0.5, vecs[1:]
769
+ loaded = [i for i, f in enumerate(forces) if np.any(f)]
770
+ cases = [{"name": c.name, "factor": None} for c in study.load_cases]
771
+ if sub.n_elem and loaded:
772
+ ks = None
773
+ if springs is not None:
774
+ dofs = (3 * hm.node_id(sub.node_ijk)[:, None] + np.arange(3)).ravel()
775
+ ks = springs[dofs][:, dofs].tocsr()
776
+ try:
777
+ # one factorization for every case; three to four digits of the
778
+ # factor are plenty (tol)
779
+ res = linear_buckling(sub, mat.nu, mat.E, fixed, [forces[i] for i in loaded], n_modes=1,
780
+ springs=ks, direct_max_dof=10 ** 6, tol=1e-5)
781
+ except (RuntimeError, ValueError, ArithmeticError) as exc:
782
+ for i in loaded:
783
+ cases[i]["error"] = str(exc)
784
+ res = []
785
+ for i, r in zip(loaded, res):
786
+ lam = float(r.factors[0])
787
+ if np.isfinite(lam):
788
+ mode = r.modes[0].reshape(-1, 3)
789
+ cases[i].update(factor=lam, modeAt=sub.node_xyz[int(np.argmax(np.linalg.norm(mode, axis=1)))].tolist())
790
+ got = [c["factor"] for c in cases if c["factor"] is not None]
791
+ return {"factor": min(got) if got else None, "cases": cases, "pitch": pb,
792
+ "cells": int(hm.n_elem), "seconds": time.time() - t0}
793
+
794
+
795
+ def verify_part(study: Study, mesh, max_cells: int = 60_000, min_pitch: float | None = None,
796
+ contact_band: float | None = None, refine: int = 3, fields: bool = False,
797
+ fractional: bool = False, cut_cells: bool | None = None) -> dict:
798
+ """Analyze a finished part (trimesh) against the study. Returns a JSON-able
799
+ report. `contact_band` (mm) is how far from applied loads and supports
800
+ stress is left out of the peak; it defaults to 1.5 analysis cells, and the
801
+ study runner sets it to the optimization cell size so the excluded band
802
+ does not shrink toward zero as the analysis grid gets finer (a clamped
803
+ edge's stress grows without bound under refinement). `refine` is how many
804
+ hotspots per load case get a 3x finer submodel (see _submodel_peak).
805
+ `fields` adds report["fields"]: the analysis nodes and, per load case,
806
+ nodal von Mises stress and displacement (numpy arrays, not JSON) for
807
+ stress maps (render.render_stress); with a thermal block, also the
808
+ temperature per node at the hot moment. `fractional` analyzes the part's
809
+ surface cells at their filled share of the modulus (the stress formed at
810
+ the full modulus), so the readings move smoothly with the geometry
811
+ rather than in whole-cell steps: for searches and live previews, whose
812
+ neighbouring candidates must compare fairly; the delivered check is
813
+ whole cells. `cut_cells` (default: use_cut_cells, which is on unless
814
+ the study says resolution.cutCells false, LOADPATH_CUT_CELLS=0, or
815
+ it has a pressure load) analyzes the true boundary inside each cell
816
+ (cutcell.py): sub-cell stiffness, stress read on the surface, and
817
+ finer submodels on members under 3 cells thick; member thickness no
818
+ longer rounds to whole cells (GRIP's wrist link struts: whole cells
819
+ 0.35-3.5x of fine tets, cut cells 0.74-1.13x; docs/BENCHMARKS.md)."""
820
+ from .shapes import manifold_from_trimesh
821
+
822
+ t0 = time.time()
823
+ mat = study.material
824
+ part = manifold_from_trimesh(mesh)
825
+ vol = float(part.volume())
826
+ report = {"volume": vol, "mass": vol * mat.density / 1000.0, "material": mat.to_dict()}
827
+
828
+ design = _union(study.design_space + [r.shape for r in study.preserve])
829
+ keep = _union([r.shape for r in study.keep_out])
830
+ pres = _union([r.shape for r in study.preserve])
831
+ envelope = design - keep if keep is not None else design
832
+ env_vol = float(envelope.volume())
833
+ report["designSpaceMass"] = env_vol * mat.density / 1000.0
834
+ report["massReduction"] = 1.0 - vol / env_vol if env_vol > 0 else None
835
+ report["keepOutIntrusion"] = float((part ^ keep).volume()) if keep is not None else 0.0
836
+ if pres is not None:
837
+ # Preserve minus keep-out is what must survive: a boss keeps its bore.
838
+ pres_eff = pres - keep if keep is not None else pres
839
+ report["preserveMissing"] = _preserve_missing(pres_eff, part, mesh)
840
+ else:
841
+ report["preserveMissing"] = 0.0
842
+ report["outsideDesignSpace"] = float((part - design).volume())
843
+ report["boundaryCutArea"] = _boundary_cut_area(study, mesh)
844
+ report.update(_creases(study, mesh))
845
+ report["closedBores"] = _closed_bores(study, mesh)
846
+ # decompose() lists each enclosed cavity too, as a negative volume
847
+ pieces = [b.volume() for b in part.decompose()]
848
+ report["bodies"] = sum(1 for v in pieces if v > 0)
849
+ report["enclosedCavityVolume"] = float(-sum(v for v in pieces if v < 0))
850
+
851
+ if study.print_direction is not None:
852
+ report.update(_overhang(mesh, study.print_direction, study))
853
+ proc = study.process
854
+ build = study.print_direction if study.print_direction is not None else \
855
+ (proc.build if proc is not None and proc.build != "auto" else None)
856
+ if build is not None:
857
+ # sliced as a printer would: bridges, islands, support volume, bed
858
+ from .printcheck import check_print
859
+
860
+ report["print"] = check_print(mesh, build, process=proc.kind if proc is not None else "fdm",
861
+ material=mat, bridge_span=getattr(proc, "bridge_span", None))
862
+
863
+ pitch = (vol / max_cells) ** (1.0 / 3.0)
864
+ if min_pitch:
865
+ pitch = max(pitch, min_pitch)
866
+ if study.shells:
867
+ # A wall voxelizes to a whole number of cells, so at two or three
868
+ # cells through it reads up to a cell thick or thin: bending stress
869
+ # goes as 1/t^2, and a plenum's 2 mm wall read 2.4 mm and 30% under
870
+ # its stress at 0.8 mm cells. Four cells through the thinnest wall or
871
+ # skin, within a cell budget.
872
+ thin = min(t for _, t in study.shells)
873
+ pitch = min(pitch, max(thin / _SHELL_CELLS, (vol / _SHELL_MAX_CELLS) ** (1.0 / 3.0)))
874
+ share = None
875
+ cut = use_cut_cells(study, cut_cells) and not fractional
876
+ cg = None
877
+ if cut:
878
+ from .cutcell import cut_from_mesh
879
+
880
+ cg = cut_from_mesh(mesh, pitch, study.elastic(mat))
881
+ solid, origin = cg.solid_grid()
882
+ share = np.zeros(solid.shape)
883
+ share[tuple(cg.mesh.elem_ijk.T)] = cg.fill
884
+ report["cutCells"] = int(cg.cut.sum())
885
+ elif fractional:
886
+ share, origin = voxelize(mesh, pitch, fractional=True)
887
+ # a sliver under a twentieth of a cell adds nothing but conditioning
888
+ solid = share >= 0.05
889
+ else:
890
+ solid, origin = voxelize(mesh, pitch)
891
+ report["analysisPitch"] = pitch
892
+ report["minMember"], report["p5Member"] = _thickness(solid if share is None else share >= 0.5, pitch)
893
+
894
+ # Face connectivity: cells that meet only at an edge or a corner share
895
+ # two nodes or one, a hinge the stiffness matrix cannot hold, and CG
896
+ # never converges (GRIP's upper arm: 44 such islands of 4-6 cells at a
897
+ # 1.04 mm pitch stalled verification for over an hour).
898
+ lbl, nlab = ndimage.label(solid)
899
+ elem_ijk = np.argwhere(solid)
900
+ hm = HexMesh(elem_ijk, pitch, origin)
901
+ bc = boundary_conditions(study, hm, check_loads=False, tol_floor=0.5 * (contact_band or 0.0) + 0.5 * pitch)
902
+ # Material not connected to a support would float; it is reported as a
903
+ # separate body above and left out of the stress analysis.
904
+ supported_lbls = np.unique(lbl[tuple(elem_ijk[bc.support_nodes[hm.enode].any(axis=1)].T)])
905
+ keep_el = np.isin(lbl[tuple(elem_ijk.T)], supported_lbls)
906
+ if not keep_el.all():
907
+ hm = HexMesh(elem_ijk[keep_el], pitch, origin)
908
+ bc = boundary_conditions(study, hm, check_loads=False, tol_floor=0.5 * (contact_band or 0.0) + 0.5 * pitch)
909
+ if cg is not None:
910
+ cg = cg.restrict(keep_el)
911
+ hm = cg.mesh
912
+ support_nodes, fixed = bc.support_nodes, bc.fixed
913
+ kept_ijk = elem_ijk if keep_el.all() else elem_ijk[keep_el]
914
+ fill = None if share is None else share[tuple(kept_ijk.T)]
915
+
916
+ band = max(1.5 * pitch, contact_band or 0.0)
917
+ report["contactBand"] = band
918
+ edges = _user_edges(study, band)
919
+ on_edge = edges(hm.node_xyz) if edges is not None else None
920
+ fg = gravity_force(hm, np.ones(hm.n_elem) if fill is None else fill, bc.gravity, fixed) if bc.gravity is not None else None
921
+ # Heat (the study's thermal block): the part's own temperature, solved on
922
+ # the part as delivered; each cell's modulus derated by it, the
923
+ # expansion its neighbours and the supports stop as a load in every
924
+ # case, and the stress formed from the strain less the free expansion.
925
+ # Strength falls with the modulus (the same retention curve). The
926
+ # submodels re-solve without heat, so they are skipped.
927
+ moduli, eps, g_node = mat.E, None, None
928
+ if study.thermal is not None:
929
+ from .thermal import StudyHeat, retention
930
+
931
+ heat = StudyHeat(study, hm, study.elastic(mat), fixed, modulus=mat.E)
932
+ heat.temperature()
933
+ moduli = mat.E * heat.g
934
+ if heat.expands:
935
+ eps = heat.eps
936
+ fth = heat.load(np.ones(hm.n_elem), mat.E)
937
+ fg = fth if fg is None else fg + fth
938
+ g_node = retention(heat.curve, heat.t_node)[0] if heat.curve is not None else np.ones(hm.n_nodes)
939
+ refine = 0
940
+ report["thermal"] = {"maxTemperature": float(heat.t_node.max()),
941
+ "minRetention": float(heat.g.min()),
942
+ "expansion": heat.alpha if heat.expands else 0.0,
943
+ "stressFree": heat.t_ref,
944
+ "heatIn": float(heat.bc.power)}
945
+ cyc = heat.cycle_result
946
+ if cyc is not None:
947
+ report["thermal"].update(
948
+ maxTemperature=float(cyc.node_max.max()), minTemperature=float(cyc.node_min.min()),
949
+ swing=float((cyc.node_max - cyc.node_min).max()), hotAt=cyc.t_hot, coldAt=cyc.t_cold,
950
+ meanHot=float(cyc.mean.max()), meanCold=float(cyc.mean.min()), periods=cyc.periods,
951
+ converged=cyc.converged, period=heat.cycle.period, on=heat.cycle.on)
952
+ # A force of unknown direction is solved along each axis and judged in
953
+ # the direction that stresses the part most (directions.py).
954
+ from .directions import expand, worst_stress_direction
955
+ spreads = bc.spreads or [None] * len(study.load_cases)
956
+ rhs, layout = expand(bc.loads, spreads, fg)
957
+ from .simp import _use_mg
958
+
959
+ def solve(moduli, rhs):
960
+ if cg is not None:
961
+ return _solve_cut(cg, study.elastic(mat), fixed, bc.springs, mat.E, moduli, rhs)
962
+ if fill is not None:
963
+ # a part-filled cell is that share as stiff; its stress stays
964
+ # the material's (nodal_stress at the full modulus)
965
+ moduli = np.broadcast_to(moduli, (hm.n_elem,)) * fill
966
+ if _use_mg(hm.n_elem):
967
+ # From simp.MG_MIN_CELLS up, matrix-free multigrid (as the optimizer
968
+ # uses): no assembled matrix, a fraction of the memory and time. It
969
+ # raises RuntimeError on non-convergence like Solver does. It also
970
+ # carries .mesh and .nu, which is all the stress recovery reads.
971
+ from .mg import GridElasticity
972
+
973
+ stiff = GridElasticity(hm, study.elastic(mat), fixed,
974
+ springs=mat.E * bc.springs if bc.springs is not None else None,
975
+ rtol=1e-6, maxiter=3000)
976
+ stiff.set_moduli(np.broadcast_to(moduli, (hm.n_elem,)).astype(float))
977
+ return stiff, stiff.solve_many(rhs)
978
+ stiff = Stiffness(hm, study.elastic(mat), fixed)
979
+ k = stiff.assemble(np.broadcast_to(moduli, (hm.n_elem,)).astype(float))
980
+ if bc.springs is not None:
981
+ k = (k + mat.E * bc.springs).tocsr()
982
+ # 1e-6 is three-plus significant figures of stress; 1e-7 cost a third
983
+ # more iterations. All cases share one multigrid hierarchy and run on
984
+ # threads.
985
+ solver = Solver(hm, rtol=1e-6, maxiter=3000, rebuild_after_iters=10_000)
986
+ return stiff, solver.solve_many(k, rhs)
987
+
988
+ stiff, displacements = solve(moduli, rhs)
989
+ cold = None
990
+ if study.thermal is not None and heat.cold is not None:
991
+ # The cycle's coldest moment, solved the same way: a part held at a
992
+ # hot stress-free temperature is stressed cold, and its swing is what
993
+ # thermal cycling fatigues (each case keeps its hot worst direction).
994
+ t_hot = heat.t_node
995
+ heat.use(heat.cold)
996
+ mod_c = mat.E * heat.g
997
+ eps_c = heat.eps if heat.expands else None
998
+ g_c = retention(heat.curve, heat.cold)[0] if heat.curve is not None else np.ones(hm.n_nodes)
999
+ fg_c = gravity_force(hm, np.ones(hm.n_elem) if fill is None else fill, bc.gravity, fixed) if bc.gravity is not None else None
1000
+ if heat.expands:
1001
+ fth_c = heat.load(np.ones(hm.n_elem), mat.E)
1002
+ fg_c = fth_c if fg_c is None else fg_c + fth_c
1003
+ rhs_c = expand(bc.loads, spreads, fg_c)[0]
1004
+ stiff_c, disp_c = solve(mod_c, rhs_c)
1005
+ cold = (stiff_c, disp_c, mod_c, eps_c, g_c)
1006
+ from scipy.spatial import cKDTree
1007
+ cases = []
1008
+ worst_sf = np.inf
1009
+ worst_dirs = []
1010
+ env = None # von Mises per node, the worst over the cases
1011
+ for ci, case in enumerate(study.load_cases):
1012
+ load_nodes = bc.case_load_nodes[ci]
1013
+ # Exclude nodes within the contact band of an applied load or support.
1014
+ contact = support_nodes | load_nodes
1015
+ near = cKDTree(hm.node_xyz[contact]).query(hm.node_xyz, k=1, distance_upper_bound=band)[0] < np.inf
1016
+ if on_edge is not None:
1017
+ near |= on_edge
1018
+ body = ~near
1019
+ idx = layout[ci]
1020
+ direction = None
1021
+ if len(idx) == 1:
1022
+ f, u = rhs[idx[0]], displacements[idx[0]]
1023
+ else:
1024
+ sp = spreads[ci]
1025
+ # the fixed part carries the expansion, the uncertain force none;
1026
+ # stress over retention, so the worst direction is the one
1027
+ # nearest failure where hot material is weak
1028
+ s0 = nodal_stress(stiff, displacements[idx[0]], moduli, eps) if idx[0] is not None else None
1029
+ sb = [nodal_stress(stiff, displacements[i], moduli) for i in idx[1:]]
1030
+ if g_node is not None:
1031
+ kn = 1.0 / np.maximum(g_node, 1e-3)[:, None]
1032
+ s0 = s0 * kn if s0 is not None else None
1033
+ sb = [b * kn for b in sb]
1034
+ direction, _ = worst_stress_direction(sp, s0, sb, body)
1035
+ f = sum(direction[j] * rhs[i] for j, i in enumerate(idx[1:]))
1036
+ u = sum(direction[j] * displacements[i] for j, i in enumerate(idx[1:]))
1037
+ if idx[0] is not None:
1038
+ f, u = f + rhs[idx[0]], u + displacements[idx[0]]
1039
+ worst_dirs.append(direction)
1040
+ vm = nodal_von_mises(stiff, u, moduli, eps)
1041
+ env = vm if env is None else np.maximum(env, vm)
1042
+ if bc.case_pressure_nodes[ci].any():
1043
+ vm = _smooth_wetted(hm.node_xyz, vm, bc.case_pressure_nodes[ci], pitch)
1044
+ # hot material is weaker: the peak is where stress / retention is
1045
+ util = vm if g_node is None else vm / np.maximum(g_node, 1e-3)
1046
+ pk = int(np.argmax(np.where(body, util, -1))) if body.any() else int(np.argmax(util))
1047
+ peak = float(vm[pk])
1048
+ at = hm.node_xyz[pk].tolist()
1049
+ keep = 1.0 if g_node is None else float(g_node[pk])
1050
+ global_peak = peak
1051
+ refined = []
1052
+ if refine and body.any():
1053
+ half = max(8.0 * pitch, 6.0)
1054
+ spots = list(_hotspots(hm.node_xyz, vm, body, peak, half, count=refine))
1055
+ if cg is not None:
1056
+ # members under 3 cells thick read up to ~15% low even cut
1057
+ # (two cells across a diagonal strip are too few to bend
1058
+ # in): the highest of them get a submodel too
1059
+ spots += _thin_spots(hm, solid, origin, pitch, vm, body, peak, half, spots, count=refine)
1060
+
1061
+ def sub(c, u=u, case=case, half=half, contact=contact):
1062
+ return _submodel_peak(study, mesh, hm, u, c, half, pitch / 2.5, mat,
1063
+ hm.node_xyz[contact], band, case, edges=edges, cut=cg is not None)
1064
+ if len(spots) > 1:
1065
+ # independent boxes, on threads (the solves release the GIL;
1066
+ # amg_solver sets up one at a time, so the same numbers)
1067
+ from concurrent.futures import ThreadPoolExecutor
1068
+
1069
+ with ThreadPoolExecutor(min(len(spots), 3)) as ex:
1070
+ subs = list(ex.map(sub, spots))
1071
+ else:
1072
+ subs = [sub(c) for c in spots]
1073
+ for r in subs:
1074
+ if r is None:
1075
+ continue
1076
+ refined.append({"at": r[1], "peakStress": r[0]})
1077
+ if r[0] > peak:
1078
+ peak, at = r
1079
+ disp = u.reshape(-1, 3)
1080
+ if fields:
1081
+ if "fields" not in report and study.thermal is not None:
1082
+ # C per node at the hot moment (the cycle's hottest, or the steady state)
1083
+ report["fields"] = {"nodes": hm.node_xyz, "cases": [],
1084
+ "temperature": t_hot if cold is not None else heat.t_node}
1085
+ report.setdefault("fields", {"nodes": hm.node_xyz, "cases": []})["cases"].append(
1086
+ {"name": case.name, "vonMises": vm, "displacement": disp, "wetted": bc.case_pressure_nodes[ci]})
1087
+ wet = bc.case_pressure_nodes[ci]
1088
+ if load_nodes.any():
1089
+ defl = float(np.linalg.norm(disp[load_nodes].mean(axis=0)))
1090
+ elif wet.any():
1091
+ # a pressure's deflection is how far its walls bulge: the most any
1092
+ # wetted node moves (relative to the part, when nothing holds it)
1093
+ dw = _rigid_free(hm.node_xyz, u).reshape(-1, 3) if study.self_equilibrated else disp
1094
+ defl = float(np.linalg.norm(dw[wet], axis=1).max())
1095
+ else:
1096
+ defl = 0.0
1097
+ if bc.pressure_imbalance[ci] > 0.02 and study.self_equilibrated:
1098
+ report.setdefault("warnings", []).append(
1099
+ f"{case.name}: the pressure no longer balances on the delivered part "
1100
+ f"({bc.pressure_imbalance[ci]:.1%} net): a wall the cavity relies on is open (a leak)")
1101
+ # The safety factor carries a discretization allowance: on the
1102
+ # plate-with-hole benchmark (tests) refined voxel peaks land within
1103
+ # -12%..+13% of Peterson's Kt, depending on how the grid falls on the
1104
+ # hole. Under-reading stress is the unsafe direction, so the factor
1105
+ # is computed against peak * STRESS_ALLOWANCE.
1106
+ sf = keep * mat.strength / (peak * STRESS_ALLOWANCE) if peak > 0 else np.inf
1107
+ inter = None
1108
+ proc = study.process
1109
+ if proc is not None and proc.build not in (None, "auto") and proc.interlayer < 1.0:
1110
+ # Layered parts part along their layers: the tension pulling
1111
+ # layers apart (the stress normal to them) against the bond's
1112
+ # strength, a fraction of the in-plane one.
1113
+ from .am import build_vector
1114
+
1115
+ nb = build_vector(proc.build)
1116
+ sig = nodal_stress(stiff, u, moduli, eps)
1117
+ sn = sig[:, :3] @ (nb * nb) + 2.0 * (sig[:, 3] * nb[0] * nb[1] + sig[:, 4] * nb[1] * nb[2]
1118
+ + sig[:, 5] * nb[2] * nb[0])
1119
+ tens = np.where(body, np.maximum(sn, 0.0), 0.0)
1120
+ ti = int(np.argmax(tens if g_node is None else tens / np.maximum(g_node, 1e-3)))
1121
+ inter = float(tens[ti])
1122
+ k_in = 1.0 if g_node is None else float(g_node[ti])
1123
+ sf_inter = k_in * proc.interlayer * mat.strength / (inter * STRESS_ALLOWANCE) if inter > 0 else np.inf
1124
+ sf = min(sf, sf_inter)
1125
+ cyc_row = {}
1126
+ if cold is not None:
1127
+ stiff_c, disp_c, mod_c, eps_c, g_c = cold
1128
+ if len(idx) == 1:
1129
+ u_c = disp_c[idx[0]]
1130
+ else:
1131
+ u_c = sum(direction[j] * disp_c[i] for j, i in enumerate(idx[1:]))
1132
+ if idx[0] is not None:
1133
+ u_c = u_c + disp_c[idx[0]]
1134
+ s_h = nodal_stress(stiff, u, moduli, eps) / np.maximum(g_node, 1e-3)[:, None]
1135
+ s_c = nodal_stress(stiff_c, u_c, mod_c, eps_c) / np.maximum(g_c, 1e-3)[:, None]
1136
+ util_c = von_mises(s_c)
1137
+ pk_c = int(np.argmax(np.where(body, util_c, -1))) if body.any() else int(np.argmax(util_c))
1138
+ sf_c = mat.strength / (util_c[pk_c] * STRESS_ALLOWANCE) if util_c[pk_c] > 0 else np.inf
1139
+ sf = min(sf, sf_c)
1140
+ # Thermal-cycling fatigue: durability's design curves read the
1141
+ # cycle's maximum stress (which carries the mean-stress effect for
1142
+ # R up to ~0.3); a stress that barely moves over the cycle is
1143
+ # creep, not fatigue, so above that the swing counts: the
1144
+ # equivalent is min(max, range / 0.7). Stresses over retention,
1145
+ # so they read against room-temperature strength.
1146
+ smax = np.maximum(von_mises(s_h), util_c)
1147
+ rng = von_mises(s_h - s_c)
1148
+ seq = np.where(body, np.minimum(smax, rng / 0.7), 0.0)
1149
+ pc = int(np.argmax(seq))
1150
+ cyc_row = {"coldSafetyFactor": sf_c, "coldPeakStress": float(util_c[pk_c] * g_c[pk_c]),
1151
+ "cycleStress": float(seq[pc]), "cycleStressRange": float(rng[pc]),
1152
+ "cycleAt": hm.node_xyz[pc].tolist()}
1153
+ worst_sf = min(worst_sf, sf)
1154
+ cases.append({
1155
+ "name": case.name,
1156
+ "peakStress": peak,
1157
+ "peakAt": at,
1158
+ "globalPeakStress": global_peak,
1159
+ "refined": refined,
1160
+ "p99Stress": float(np.percentile(vm[body] if body.any() else vm, 99)),
1161
+ "contactPeakStress": float(vm.max()),
1162
+ "safetyFactor": sf,
1163
+ **({"peakTemperature": float((t_hot if cold is not None else heat.t_node)[pk]),
1164
+ "strengthRetention": keep}
1165
+ if g_node is not None else {}),
1166
+ **cyc_row,
1167
+ "interlayerStress": inter,
1168
+ "deflection": defl,
1169
+ "maxDisplacement": float(np.linalg.norm(disp, axis=1).max()),
1170
+ "compliance": float(f @ u),
1171
+ **({"pressureImbalance": bc.pressure_imbalance[ci]} if wet.any() else {}),
1172
+ # the uncertain force's most stressful direction, which the
1173
+ # numbers above are for (unit vector)
1174
+ **({"worstDirection": [float(v) for v in direction]} if direction is not None else {}),
1175
+ })
1176
+ report["cases"] = cases
1177
+ report["safetyFactor"] = worst_sf
1178
+ goal_sf = study.goal.safety_factor
1179
+ if goal_sf and not study.self_equilibrated:
1180
+ # the safety factor holds against buckling too (study._margin). Read
1181
+ # for every part, however far under its stress safety factor: the
1182
+ # tet confirm can lift a voxel reading 2.5x (the MBB beam, 0.78 to
1183
+ # 1.97), and a part it lifts must not pass unchecked.
1184
+ try:
1185
+ bk = _buckling(study, mesh, vol, pitch, contact_band, worst_dirs)
1186
+ except (RuntimeError, ValueError, MemoryError) as exc:
1187
+ bk = None
1188
+ report.setdefault("warnings", []).append(f"buckling check failed: {exc}")
1189
+ if bk is not None:
1190
+ report["buckling"] = bk
1191
+ if bk["factor"] is not None:
1192
+ report["bucklingFactor"] = bk["factor"]
1193
+ if bk["factor"] < goal_sf:
1194
+ worst = min((c for c in bk["cases"] if c["factor"] is not None), key=lambda c: c["factor"])
1195
+ report.setdefault("warnings", []).append(
1196
+ f"{worst['name']}: buckles at {bk['factor']:.2f}x its loads (linear buckling), under "
1197
+ f"the safety factor {goal_sf:g}; the mode is largest near "
1198
+ f"({', '.join(f'{v:.1f}' for v in worst.get('modeAt', []))}). Thicken or brace the "
1199
+ "slender member carrying compression there.")
1200
+ if env is not None and len(env):
1201
+ # how evenly the material is worked: mean von Mises over the near
1202
+ # peak (99.9th percentile, so one singular node does not set it).
1203
+ # Low (about 0.1, the wall brackets) means most of the part idles
1204
+ # at a few percent of the peak, which the stress-constrained method
1205
+ # trims (study.py, method auto)
1206
+ pk = float(np.quantile(env, 0.999))
1207
+ report["stressUtilization"] = float(env.mean() / pk) if pk > 0 else None
1208
+ report["stressAllowance"] = STRESS_ALLOWANCE
1209
+ from .durability import active, assess
1210
+ if active(study):
1211
+ # fatigue (cases with cycles), creep and relaxation (hours, preload)
1212
+ report["durability"] = assess(study, cases, STRESS_ALLOWANCE)
1213
+ report["analysisCells"] = int(hm.n_elem)
1214
+ report["seconds"] = time.time() - t0
1215
+ return report