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/render.py ADDED
@@ -0,0 +1,436 @@
1
+ """
2
+ Headless clay renders of a result, for reports and for judging outcomes by
3
+ eye (or by a vision model). matplotlib Agg only: no GL context needed, so it
4
+ works headless and in CI. Keep-outs draw as translucent red, load
5
+ regions as arrows.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import numpy as np
10
+
11
+ __all__ = ["render_views", "render_stress", "render_interfaces"]
12
+
13
+ _VIEWS = [(24, -58), (24, 32), (78, -90)]
14
+
15
+
16
+ def _decimate(mesh, faces=40_000):
17
+ mesh = _outside(mesh)
18
+ if len(mesh.faces) <= faces:
19
+ return mesh
20
+ try:
21
+ return mesh.simplify_quadric_decimation(face_count=faces)
22
+ except Exception: # noqa: BLE001 - render proxy only
23
+ return mesh
24
+
25
+
26
+ def _outside(mesh):
27
+ """The mesh without its enclosed cavities' surfaces (a sealed plenum's
28
+ inside): matplotlib sorts faces by depth only roughly, and a cavity's
29
+ few large faces were drawn over the outside."""
30
+ try:
31
+ parts = mesh.split(only_watertight=False)
32
+ keep = [p for p in parts if p.volume > 0]
33
+ if 0 < len(keep) < len(parts):
34
+ import trimesh
35
+ return trimesh.util.concatenate(keep)
36
+ except Exception: # noqa: BLE001 - render proxy only
37
+ pass
38
+ return mesh
39
+
40
+
41
+ def render_views(mesh, path, study=None, title=None, views=_VIEWS, size=(5.0, 4.2)):
42
+ import matplotlib
43
+ matplotlib.use("Agg")
44
+ import matplotlib.pyplot as plt
45
+ from mpl_toolkits.mplot3d.art3d import Poly3DCollection
46
+
47
+ m = _decimate(mesh)
48
+ tris = m.triangles
49
+ lo, hi = np.asarray(mesh.bounds)
50
+ if study is not None:
51
+ slo, shi = study.bounds()
52
+ lo, hi = np.minimum(lo, slo), np.maximum(hi, shi)
53
+ span = (hi - lo).max()
54
+ mid = 0.5 * (lo + hi)
55
+ fig = plt.figure(figsize=(size[0] * len(views), size[1]), dpi=110)
56
+ for vi, (elev, azim) in enumerate(views):
57
+ ax = fig.add_subplot(1, len(views), vi + 1, projection="3d")
58
+ e, a = np.radians(elev), np.radians(azim)
59
+ view_dir = np.array([np.cos(e) * np.cos(a), np.cos(e) * np.sin(a), np.sin(e)])
60
+ key = view_dir + np.array([0.3, -0.2, 0.6])
61
+ key /= np.linalg.norm(key)
62
+ shade = np.clip(m.face_normals @ key, 0, 1)
63
+ rim = np.clip(m.face_normals @ view_dir, 0, 1)
64
+ base = np.array([0.80, 0.80, 0.82])
65
+ col = np.clip(base[None] * (0.28 + 0.62 * shade + 0.18 * rim)[:, None], 0, 1)
66
+ ax.add_collection3d(Poly3DCollection(tris, facecolors=col, edgecolor="none", linewidth=0))
67
+ if study is not None:
68
+ for r in study.keep_out:
69
+ try:
70
+ ko = r.shape.to_manifold().to_mesh()
71
+ v = np.asarray(ko.vert_properties)[:, :3]
72
+ f = np.asarray(ko.tri_verts)
73
+ ax.add_collection3d(Poly3DCollection(v[f], facecolors=(0.9, 0.25, 0.2, 0.12),
74
+ edgecolor="none"))
75
+ except Exception: # noqa: BLE001
76
+ pass
77
+ for case in study.load_cases:
78
+ for ld in case.loads:
79
+ if not np.any(ld.force):
80
+ continue
81
+ shape = ld.shape if ld.shape is not None else study.region_shape(ld.region)[0]
82
+ blo, bhi = shape.bounds()
83
+ c = 0.5 * (blo + bhi)
84
+ d = ld.force / np.linalg.norm(ld.force) * span * 0.18
85
+ ax.quiver(*(c - d), *d, color=(0.15, 0.45, 0.95), linewidth=2, arrow_length_ratio=0.3)
86
+ ax.set_xlim(mid[0] - span / 2, mid[0] + span / 2)
87
+ ax.set_ylim(mid[1] - span / 2, mid[1] + span / 2)
88
+ ax.set_zlim(mid[2] - span / 2, mid[2] + span / 2)
89
+ ax.set_box_aspect((1, 1, 1), zoom=1.45)
90
+ ax.view_init(elev, azim)
91
+ ax.set_axis_off()
92
+ if title:
93
+ fig.suptitle(title, fontsize=10)
94
+ fig.tight_layout()
95
+ fig.savefig(path, facecolor="white")
96
+ plt.close(fig)
97
+ return path
98
+
99
+
100
+ def render_stress(mesh, fields, path, strength, title=None, views=_VIEWS[:2], size=(5.0, 4.2),
101
+ exaggerate=None, safety_factor=1.0, label=None):
102
+ """Stress map of a verified part: each vertex coloured by the worst
103
+ nodal von Mises stress over the load cases, as a fraction of `strength`
104
+ (verify_part(..., fields=True)["fields"]), divided by `safety_factor`:
105
+ pass the study's goal and red means "at the allowable". `exaggerate` scales the worst
106
+ case's displacement into the drawn shape; by default the largest
107
+ displacement is drawn as 4% of the part's size, so the bend reads.
108
+ `label` replaces the colour bar's label (say when `strength` is not one)."""
109
+ import matplotlib
110
+ matplotlib.use("Agg")
111
+ import matplotlib.pyplot as plt
112
+ from matplotlib import cm, colors
113
+ from mpl_toolkits.mplot3d.art3d import Poly3DCollection
114
+ from scipy.spatial import cKDTree
115
+
116
+ m = _decimate(mesh)
117
+ nodes = fields["nodes"]
118
+ _, idx = cKDTree(nodes).query(m.vertices, k=1)
119
+ vm = np.max([c["vonMises"][idx] for c in fields["cases"]], axis=0)
120
+ worst = max(fields["cases"], key=lambda c: np.linalg.norm(c["displacement"], axis=1).max())
121
+ disp = worst["displacement"][idx]
122
+ lo, hi = np.asarray(mesh.bounds)
123
+ span = (hi - lo).max()
124
+ dmax = np.linalg.norm(disp, axis=1).max()
125
+ k = exaggerate if exaggerate is not None else (0.04 * span / dmax if dmax > 0 else 0.0)
126
+ verts = m.vertices + k * disp
127
+ frac = np.clip(vm * safety_factor / strength, 0, 1)
128
+ face = frac[m.faces].max(axis=1)
129
+ norm = colors.Normalize(0, 1)
130
+ cmap = matplotlib.colormaps["turbo"]
131
+ mid = 0.5 * (lo + hi)
132
+ fig = plt.figure(figsize=(size[0] * len(views) + 0.8, size[1]), dpi=110)
133
+ for vi, (elev, azim) in enumerate(views):
134
+ ax = fig.add_subplot(1, len(views), vi + 1, projection="3d")
135
+ e, a = np.radians(elev), np.radians(azim)
136
+ view_dir = np.array([np.cos(e) * np.cos(a), np.cos(e) * np.sin(a), np.sin(e)])
137
+ key = view_dir + np.array([0.3, -0.2, 0.6])
138
+ key /= np.linalg.norm(key)
139
+ shade = 0.45 + 0.55 * np.clip(m.face_normals @ key, 0, 1)
140
+ col = cmap(norm(face))[:, :3] * shade[:, None]
141
+ ax.add_collection3d(Poly3DCollection(verts[m.faces], facecolors=np.clip(col, 0, 1),
142
+ edgecolor="none", linewidth=0))
143
+ ax.set_xlim(mid[0] - span / 2, mid[0] + span / 2)
144
+ ax.set_ylim(mid[1] - span / 2, mid[1] + span / 2)
145
+ ax.set_zlim(mid[2] - span / 2, mid[2] + span / 2)
146
+ ax.set_box_aspect((1, 1, 1), zoom=1.45)
147
+ ax.view_init(elev, azim)
148
+ ax.set_axis_off()
149
+ cb = fig.colorbar(cm.ScalarMappable(norm=norm, cmap=cmap), ax=fig.axes, shrink=0.7, pad=0.02)
150
+ cb.set_label(label if label else "von Mises / allowable (worst case)" if safety_factor != 1.0 else "von Mises / strength (worst case)")
151
+ if title:
152
+ fig.suptitle(title + (f" · shape {k:.0f}x exaggerated ({worst['name']})" if k else ""), fontsize=10)
153
+ fig.savefig(path, facecolor="white")
154
+ plt.close(fig)
155
+ return path
156
+
157
+
158
+ def render_interfaces(mesh, study, path, max_panels=16, cols=4, size=3.2, margin=6.0):
159
+ """Close-ups of every place the part meets the outside world: each
160
+ preserved region and each keep-out the part touches, cropped to a box
161
+ round it and shot from two sides. Whole-part renders show the layout and
162
+ hide the finish: GRIP's wrist yoke passed every number with sawn-off
163
+ faces, hard creases at its bosses and material over its bolt seats, all
164
+ of them a few pixels in the overview (QA, 10/28). Look at these before
165
+ calling a part done."""
166
+ import matplotlib
167
+ matplotlib.use("Agg")
168
+ import matplotlib.pyplot as plt
169
+ from mpl_toolkits.mplot3d.art3d import Poly3DCollection
170
+
171
+ tc = mesh.triangles_center
172
+
173
+ def on_part(c, sh):
174
+ # A ring's or a sleeve's box centre is in its empty bore: centre the
175
+ # panel on the part's surface where it meets the region nearest that
176
+ # centre instead, so the panel shows the ring and not the hole.
177
+ near = np.abs(sh.sdf(tc)) < 1.5
178
+ if not near.any():
179
+ return c
180
+ pts = tc[near]
181
+ return pts[np.argmin(np.linalg.norm(pts - c, axis=1))]
182
+
183
+ spots = [] # (label, centre, half-size)
184
+ for r in study.preserve:
185
+ shapes = getattr(r.shape, "shapes", [r.shape]) # a union: each piece is its own interface
186
+ for j, sh in enumerate(shapes):
187
+ lo, hi = sh.bounds()
188
+ c = 0.5 * (lo + hi)
189
+ half = min(0.5 * float(np.max(hi - lo)), 20.0) + margin
190
+ if 0.5 * float(np.max(hi - lo)) > half - margin: # bigger than a panel: find the ring
191
+ c = on_part(c, sh)
192
+ spots.append((r.id + (f"[{j}]" if len(shapes) > 1 else ""), c, half))
193
+ for r in study.keep_out:
194
+ # where the part comes near a keep-out: centre on that contact, not on
195
+ # the keep-out (a driver channel runs 25 mm out past the part)
196
+ near = np.abs(r.shape.sdf(tc)) < 1.5
197
+ if near.any():
198
+ pts = tc[near]
199
+ ext = float(np.max(np.ptp(pts, axis=0)))
200
+ c = pts.mean(axis=0)
201
+ # contact all round a bore averages to its axis: use the contact
202
+ # point nearest that mean
203
+ spots.append((r.id, pts[np.argmin(np.linalg.norm(pts - c, axis=1))], min(0.5 * ext, 20.0) + margin))
204
+ # one panel per place: spots closer than a few mm show the same thing
205
+ merged = []
206
+ for lab, c, h in spots:
207
+ for m in merged:
208
+ if np.linalg.norm(m[1] - c) < 4.0:
209
+ m[0].append(lab)
210
+ m[2] = max(m[2], h)
211
+ break
212
+ else:
213
+ merged.append([[lab], c, h])
214
+ merged = merged[:max_panels]
215
+ if not merged:
216
+ return None
217
+ rows = int(np.ceil(len(merged) / cols))
218
+ fig = plt.figure(figsize=(size * cols, size * rows), dpi=110)
219
+ for k, (labs, c, half) in enumerate(merged):
220
+ # crop exactly (boolean faces are long slivers: selecting whole
221
+ # triangles by centre drew planes across the panel)
222
+ crop = mesh
223
+ for ax_i in range(3):
224
+ for sgn in (1, -1):
225
+ n_ = np.zeros(3)
226
+ n_[ax_i] = sgn
227
+ crop = crop.slice_plane(c - half * n_, n_) # keeps the side n_ points to
228
+ if len(crop.faces) == 0:
229
+ break
230
+ ax = fig.add_subplot(rows, cols, k + 1, projection="3d")
231
+ if len(crop.faces):
232
+ tris = crop.triangles
233
+ nrm = crop.face_normals
234
+ # look from the side the nearby surface faces, tilted so relief
235
+ # (a crease, a step, a crater) shows instead of flattening out
236
+ vd = (nrm * crop.area_faces[:, None]).sum(axis=0)
237
+ vd = vd / np.linalg.norm(vd) if np.linalg.norm(vd) > 1e-6 else np.array([0.5, -0.5, 0.7])
238
+ elev = float(np.degrees(np.arcsin(np.clip(vd[2], -1, 1)))) + 25.0
239
+ azim = float(np.degrees(np.arctan2(vd[1], vd[0]))) + 30.0
240
+ e, a = np.radians(elev), np.radians(azim)
241
+ view = np.array([np.cos(e) * np.cos(a), np.cos(e) * np.sin(a), np.sin(e)])
242
+ key = view + np.array([0.3, -0.2, 0.6])
243
+ key /= np.linalg.norm(key)
244
+ shade = np.clip(nrm @ key, 0, 1)
245
+ col = np.clip(np.array([0.80, 0.80, 0.82])[None] * (0.25 + 0.7 * shade)[:, None], 0, 1)
246
+ ax.add_collection3d(Poly3DCollection(tris, facecolors=col, edgecolor="none"))
247
+ ax.view_init(elev, azim)
248
+ for i, axis in enumerate("xyz"):
249
+ getattr(ax, f"set_{axis}lim")(c[i] - half, c[i] + half)
250
+ ax.set_box_aspect((1, 1, 1), zoom=1.25)
251
+ ax.set_axis_off()
252
+ title = ", ".join(labs[:3]) + (f" +{len(labs) - 3}" if len(labs) > 3 else "")
253
+ ax.set_title(title, fontsize=7)
254
+ fig.tight_layout()
255
+ fig.savefig(path, facecolor="white")
256
+ plt.close(fig)
257
+ return path
258
+
259
+
260
+ def render_heat_exchanger(mesh, spaces, core, path, title=None, size=(5.0, 4.6), cut=None, view=(28, -55)):
261
+ """Four panels: the part, the part cut open, and each fluid's space cut
262
+ open at the same plane so the two interleaved networks show (A blue,
263
+ B orange), then fluid A alone. `cut` is (point, normal), by default
264
+ through the core's centre across z, keeping the side the normal points
265
+ to (point it away from the view); `core` is any shape (its bounds)."""
266
+ import matplotlib
267
+ matplotlib.use("Agg")
268
+ import matplotlib.pyplot as plt
269
+ from mpl_toolkits.mplot3d.art3d import Poly3DCollection
270
+
271
+ lo, hi = np.asarray(mesh.bounds)
272
+ span = (hi - lo).max()
273
+ mid = 0.5 * (lo + hi)
274
+ clo, chi = (np.asarray(b, float) for b in core.bounds())
275
+ if cut is None:
276
+ cut = (0.5 * (clo + chi), np.array([0.0, 0.0, 1.0]))
277
+ c0, cn = np.asarray(cut[0], float), np.asarray(cut[1], float)
278
+ cn = cn / np.linalg.norm(cn)
279
+ grey = np.array([0.80, 0.80, 0.82])
280
+ colors = [np.array([0.20, 0.45, 0.85]), np.array([0.92, 0.50, 0.15])]
281
+ names = list(spaces)
282
+ panels = [("part", [(mesh, grey)]),
283
+ ("part, cut open", [(mesh.slice_plane(c0, cn, cap=True), grey)])]
284
+ # keep the half beyond the cut, so the view looks into it
285
+ cutf = [(spaces[n].slice_plane(c0, cn, cap=True), colors[i % 2]) for i, n in enumerate(names)]
286
+ panels.append((" + ".join(names) + ", cut open", cutf))
287
+ panels.append((names[0], cutf[:1]))
288
+ fig = plt.figure(figsize=(size[0] * len(panels), size[1]), dpi=110)
289
+ elev, azim = view
290
+ e, a = np.radians(elev), np.radians(azim)
291
+ view_dir = np.array([np.cos(e) * np.cos(a), np.cos(e) * np.sin(a), np.sin(e)])
292
+ key = view_dir + np.array([0.3, -0.2, 0.6])
293
+ key /= np.linalg.norm(key)
294
+ for pi, (label, items) in enumerate(panels):
295
+ ax = fig.add_subplot(1, len(panels), pi + 1, projection="3d")
296
+ for m, base in items:
297
+ m = _decimate(m, 90_000)
298
+ shade = np.clip(m.face_normals @ key, 0, 1)
299
+ col = np.clip(base[None] * (0.35 + 0.65 * shade)[:, None], 0, 1)
300
+ ax.add_collection3d(Poly3DCollection(m.triangles, facecolors=col, edgecolor="none", linewidth=0))
301
+ ax.set_xlim(mid[0] - span / 2, mid[0] + span / 2)
302
+ ax.set_ylim(mid[1] - span / 2, mid[1] + span / 2)
303
+ ax.set_zlim(mid[2] - span / 2, mid[2] + span / 2)
304
+ ax.set_box_aspect((1, 1, 1), zoom=1.6)
305
+ ax.view_init(elev, azim)
306
+ ax.set_axis_off()
307
+ ax.set_title(label, fontsize=9)
308
+ if title:
309
+ fig.suptitle(title, fontsize=10)
310
+ fig.tight_layout()
311
+ fig.savefig(path, facecolor="white")
312
+ plt.close(fig)
313
+ return path
314
+
315
+ def render_internal_ceilings(variants, up, path, title=None, size=(5.0, 4.6), view=(30, -55), flat=20.0):
316
+ """One panel per variant (label, {fluid: space mesh}): the fluid spaces
317
+ seen from above, the top of each (where it meets metal above it, a
318
+ ceiling) red where it lies within `flat` degrees of level along `up`.
319
+ Inside a metal part nothing there can be supported."""
320
+ import matplotlib
321
+ matplotlib.use("Agg")
322
+ import matplotlib.pyplot as plt
323
+ from mpl_toolkits.mplot3d.art3d import Poly3DCollection
324
+
325
+ up = np.asarray(up, float) / np.linalg.norm(up)
326
+ meshes = [m for _, sp in variants for m in sp.values()]
327
+ lo = np.min([m.bounds[0] for m in meshes], axis=0)
328
+ hi = np.max([m.bounds[1] for m in meshes], axis=0)
329
+ span, mid = (hi - lo).max(), 0.5 * (lo + hi)
330
+ colors = [np.array([0.20, 0.45, 0.85]), np.array([0.92, 0.65, 0.25])]
331
+ red = np.array([0.85, 0.08, 0.08])
332
+ fig = plt.figure(figsize=(size[0] * len(variants), size[1]), dpi=110)
333
+ elev, azim = view
334
+ e, a = np.radians(elev), np.radians(azim)
335
+ view_dir = np.array([np.cos(e) * np.cos(a), np.cos(e) * np.sin(a), np.sin(e)])
336
+ key = view_dir + np.array([0.3, -0.2, 0.6])
337
+ key /= np.linalg.norm(key)
338
+ for pi, (label, spaces) in enumerate(variants):
339
+ ax = fig.add_subplot(1, len(variants), pi + 1, projection="3d")
340
+ for i, m in enumerate(spaces.values()):
341
+ m = _decimate(m, 120_000)
342
+ n = m.face_normals
343
+ shade = np.clip(n @ key, 0, 1)
344
+ base = np.where((n @ up > np.cos(np.radians(flat)))[:, None], red[None], colors[i % 2][None])
345
+ col = np.clip(base * (0.35 + 0.65 * shade)[:, None], 0, 1)
346
+ ax.add_collection3d(Poly3DCollection(m.triangles, facecolors=col, edgecolor="none", linewidth=0))
347
+ ax.set_xlim(mid[0] - span / 2, mid[0] + span / 2)
348
+ ax.set_ylim(mid[1] - span / 2, mid[1] + span / 2)
349
+ ax.set_zlim(mid[2] - span / 2, mid[2] + span / 2)
350
+ ax.set_box_aspect((1, 1, 1), zoom=1.5)
351
+ ax.view_init(elev, azim)
352
+ ax.set_axis_off()
353
+ ax.set_title(label, fontsize=9)
354
+ if title:
355
+ fig.suptitle(title, fontsize=10)
356
+ fig.tight_layout()
357
+ fig.savefig(path, facecolor="white")
358
+ plt.close(fig)
359
+ return path
360
+
361
+
362
+ def render_lattice(mesh, path, title=None, holes=(), size=(5.0, 4.6), view=(28, -55)):
363
+ """A lattice part: whole, cut open across its length and its height so
364
+ the core and its grading show, and a close-up of the first drain hole
365
+ (`holes` as lattice_infill's printability["drainHoles"])."""
366
+ import matplotlib
367
+ matplotlib.use("Agg")
368
+ import matplotlib.pyplot as plt
369
+ import trimesh
370
+ from mpl_toolkits.mplot3d.art3d import Poly3DCollection
371
+
372
+ lo, hi = np.asarray(mesh.bounds)
373
+ mid = 0.5 * (lo + hi)
374
+ ext = hi - lo
375
+ long_ax = int(np.argmax(ext))
376
+ tall = [a for a in np.argsort(ext) if a != long_ax][0]
377
+ side = 3 - long_ax - tall
378
+ n_side, n_tall = np.zeros(3), np.zeros(3)
379
+ n_side[side] = 1.0
380
+ n_tall[tall] = -1.0
381
+ panels = [("part", mesh, None),
382
+ ("cut along its length", mesh.slice_plane(mid, n_side, cap=True), None),
383
+ ("cut through its height", mesh.slice_plane(mid, n_tall, cap=True), None)]
384
+ if holes:
385
+ h = holes[0]
386
+ c, nrm = np.asarray(h["at"]), np.asarray(h["normal"])
387
+ r = 4.0 * h["diameter"]
388
+ panels.append((f"drain hole {h['diameter']:g} mm, cut in half", mesh.slice_plane(c, _perp(nrm), cap=True),
389
+ (c, r, nrm)))
390
+ fig = plt.figure(figsize=(size[0] * len(panels), size[1]), dpi=110)
391
+ grey = np.array([0.80, 0.80, 0.82])
392
+ for pi, (label, m, zoom) in enumerate(panels):
393
+ ax = fig.add_subplot(1, len(panels), pi + 1, projection="3d")
394
+ if zoom is not None:
395
+ c, r, nrm = zoom
396
+ keep = np.all(np.abs(m.triangles_center - c) < r, axis=1)
397
+ m = m.submesh([np.nonzero(keep)[0]], append=True) if keep.any() else m
398
+ # look at the cut face (it faces away from the kept side), a
399
+ # little from outside the part
400
+ v = -_perp(nrm) + 0.5 * nrm
401
+ v /= np.linalg.norm(v)
402
+ elev, azim = np.degrees(np.arcsin(np.clip(v[2], -1, 1))), np.degrees(np.arctan2(v[1], v[0]))
403
+ cen, span = c - nrm * 0.25 * r, 1.6 * r
404
+ else:
405
+ elev, azim = view
406
+ cen, span = mid, ext.max()
407
+ m = _decimate(m, 400_000)
408
+ # matplotlib paints back to front by face centre: the skin's long
409
+ # flat triangles would sort behind the lattice they cover
410
+ v, f = trimesh.remesh.subdivide_to_size(m.vertices, m.faces, max_edge=span / 60.0, max_iter=12)
411
+ m = trimesh.Trimesh(v, f, process=False)
412
+ e, a = np.radians(elev), np.radians(azim)
413
+ view_dir = np.array([np.cos(e) * np.cos(a), np.cos(e) * np.sin(a), np.sin(e)])
414
+ key = view_dir + np.array([0.3, -0.2, 0.6])
415
+ key /= np.linalg.norm(key)
416
+ shade = np.clip(m.face_normals @ key, 0, 1)
417
+ col = np.clip(grey[None] * (0.30 + 0.70 * shade)[:, None], 0, 1)
418
+ ax.add_collection3d(Poly3DCollection(m.triangles, facecolors=col, edgecolor="none", linewidth=0))
419
+ for k in range(3):
420
+ getattr(ax, "set_" + "xyz"[k] + "lim")(cen[k] - span / 2, cen[k] + span / 2)
421
+ ax.set_box_aspect((1, 1, 1), zoom=1.5)
422
+ ax.view_init(elev, azim)
423
+ ax.set_axis_off()
424
+ ax.set_title(label, fontsize=9)
425
+ if title:
426
+ fig.suptitle(title, fontsize=10)
427
+ fig.tight_layout()
428
+ fig.savefig(path, facecolor="white")
429
+ plt.close(fig)
430
+ return path
431
+
432
+
433
+ def _perp(n):
434
+ """A unit vector across `n`: the plane that cuts a drain hole in half."""
435
+ t = np.cross(n, [1.0, 0.0, 0.0] if abs(n[0]) < 0.9 else [0.0, 1.0, 0.0])
436
+ return t / np.linalg.norm(t)
loadpath/resolved.py ADDED
@@ -0,0 +1,189 @@
1
+ """
2
+ Full-resolution FE of a field graph's lattice part: every strut and sheet
3
+ voxelized at a pitch a few voxels across the thinnest wall and solved as
4
+ it is, no homogenization.
5
+
6
+ homogenized_check reads a lattice from its unit cell's tensors, which is
7
+ quick and right inside the lattice. Where cells are cut open at a bare face
8
+ (no skin), how much they keep depends on how the face cuts them: cells cut
9
+ square keep struts running along the face, cells turned and cut on the bias
10
+ leave dangling stubs. One knockdown cannot serve both, and an optimizer
11
+ comparing the two (lattice_orient) takes the difference for a gain:
12
+ bench/results/lattice-orient-confirm.json. `resolved_check` is the arbiter
13
+ for those comparisons. It costs minutes (0.5-1M voxels for a 48 mm bar of
14
+ 6 mm cells at 0.15 mm), so it confirms a pick rather than drives a search.
15
+
16
+ The peak is read as bench/lattice_aniso_validate.py reads it and the unit-
17
+ cell tables are built: each lattice cell's 99.5th percentile of nodal von
18
+ Mises, the worst cell outside the contact band.
19
+ """
20
+ from __future__ import annotations
21
+
22
+ import time
23
+
24
+ import numpy as np
25
+
26
+ __all__ = ["default_pitch", "resolved_check"]
27
+
28
+ # voxels across the thinnest wall or strut the default pitch aims at
29
+ VOXELS_PER_WALL = 6
30
+ MAX_CELLS = 4_000_000
31
+ # up to this many voxels the matrix is assembled and solved by AMG (about
32
+ # 13 GB at 1M cells), above it the matrix-free grid multigrid
33
+ ASSEMBLED_MAX = 600_000
34
+
35
+
36
+ def _lattice_spec(g, name):
37
+ from .homogenized import lattice_nodes
38
+
39
+ lats = lattice_nodes(g, name)
40
+ return (lats[0], g.nodes[lats[0]]) if len(lats) == 1 else (None, None)
41
+
42
+
43
+ def default_pitch(doc, node=None, base_dir=None):
44
+ """Pitch (mm) VOXELS_PER_WALL voxels across the lattice's constant wall
45
+ (or strut); a fortieth of its cell when the wall is graded."""
46
+ from .fields import FieldGraph, expand_groups
47
+
48
+ full = expand_groups(doc)
49
+ name = node or full.get("output") or list(full["nodes"])[-1]
50
+ g = FieldGraph({**full, "output": name}, base_dir=base_dir)
51
+ _, nd = _lattice_spec(g, name)
52
+ if nd is None:
53
+ raise ValueError("resolved_check needs a pitch for a part with no single lattice node")
54
+ t, cell = nd.spec.get("thickness"), nd.spec.get("cell", 8.0)
55
+ if isinstance(t, (int, float)) and "density" not in nd.spec:
56
+ return float(t) / VOXELS_PER_WALL
57
+ return float(np.min(np.atleast_1d(cell))) / 40.0 if isinstance(cell, (int, float, list)) else 0.15
58
+
59
+
60
+ def resolved_check(doc, study, pitch: float | None = None, node=None, base_dir=None, sources=None,
61
+ contact_band: float | None = None, cell_size=None, max_cells: int = MAX_CELLS,
62
+ rtol: float = 1e-7, keep_mesh: bool = False, solver: str = "auto") -> dict:
63
+ """Voxel FE of the graph's part at `pitch` (default_pitch), its lattice
64
+ resolved. Returns {mass, volume, safetyFactor, cases (name, compliance,
65
+ deflection, peakStress, peakAt, peakFaceDistance, peak995), cells,
66
+ pitch, seconds}; with keep_mesh also hm, bc. `contact_band` (mm, default
67
+ one lattice cell): stress this close to a support or load is left out.
68
+ `cell_size` (mm, scalar or per axis): the bins the peak is read over,
69
+ default the lattice's cell. Refuses grids over `max_cells`. `solver`:
70
+ "mg" (matrix-free grid multigrid), "assembled" (AMG on the assembled
71
+ matrix), "auto" assembled up to ASSEMBLED_MAX voxels."""
72
+ from scipy import ndimage
73
+ from scipy.spatial import cKDTree
74
+
75
+ from .domain import boundary_conditions
76
+ from .fem import CORNERS, HexMesh, elastic_c, hex8_b, von_mises
77
+ from .fields import FieldGraph, expand_groups
78
+ from .mg import GridElasticity
79
+ from .verify import STRESS_ALLOWANCE
80
+
81
+ t0 = time.time()
82
+ full = expand_groups(doc)
83
+ name = node or full.get("output") or list(full["nodes"])[-1]
84
+ g = FieldGraph({**full, "output": name}, base_dir=base_dir, sources=sources)
85
+ lat, nd = _lattice_spec(g, name)
86
+ if pitch is None:
87
+ pitch = default_pitch(doc, node=node, base_dir=base_dir)
88
+ pitch = float(pitch)
89
+ if cell_size is None:
90
+ c = nd.spec.get("cell", 8.0) if nd is not None else 8.0
91
+ c = float(np.min(np.atleast_1d(c))) if isinstance(c, (int, float, list)) else 8.0
92
+ cell_size = np.asarray(nd.spec.get("aspect", (1, 1, 1)), float) * c if nd is not None else c
93
+ if contact_band is None:
94
+ contact_band = float(np.min(np.atleast_1d(cell_size)))
95
+ b = g.bounds(name)
96
+ if b is None:
97
+ raise ValueError(f"{name!r} is unbounded")
98
+ lo, hi = (np.asarray(v, float) for v in b)
99
+ dims = np.ceil((hi - lo) / pitch - 1e-9).astype(int)
100
+ if np.prod(dims.astype(float)) * 0.6 > max_cells:
101
+ raise ValueError(f"a resolved check at {pitch:g} mm is ~{np.prod(dims.astype(float)) * 0.6:.3g} voxels "
102
+ f"(max_cells {max_cells}): coarsen the pitch or check a smaller part")
103
+ origin = lo
104
+ keep = []
105
+ jj, kk = np.meshgrid(np.arange(dims[1]), np.arange(dims[2]), indexing="ij")
106
+ for i in range(dims[0]):
107
+ P = np.stack([np.full(jj.size, origin[0] + (i + 0.5) * pitch), origin[1] + (jj.ravel() + 0.5) * pitch,
108
+ origin[2] + (kk.ravel() + 0.5) * pitch], 1)
109
+ s = np.asarray(g.evaluate(P, name), float) < 0
110
+ keep.append(np.stack([np.full(s.sum(), i), jj.ravel()[s], kk.ravel()[s]], 1))
111
+ ijk = np.concatenate(keep)
112
+ grid = np.zeros(dims, bool)
113
+ grid[tuple(ijk.T)] = True
114
+ lbl, _ = ndimage.label(grid)
115
+ hm = HexMesh(ijk, pitch, origin)
116
+ bc = boundary_conditions(study, hm, check_loads=False, tol_floor=0.5 * pitch)
117
+ # only what is joined to a support carries load
118
+ sup = np.unique(lbl[tuple(ijk[bc.support_nodes[hm.enode].any(axis=1)].T)])
119
+ sel = np.isin(lbl[tuple(ijk.T)], sup)
120
+ if not sel.all():
121
+ ijk = ijk[sel]
122
+ hm = HexMesh(ijk, pitch, origin)
123
+ bc = boundary_conditions(study, hm, check_loads=False, tol_floor=0.5 * pitch)
124
+ mat = study.material
125
+ nu = study.elastic(mat)
126
+ rhs = [bc.loads[ci] for ci in range(len(study.load_cases))]
127
+ if solver == "auto":
128
+ solver = "mg" if hm.n_elem > ASSEMBLED_MAX else "assembled"
129
+ if solver == "assembled":
130
+ # smoothed-aggregation AMG on the assembled matrix: on bias-cut
131
+ # lattices (struts ending in thin stubs at every face) the grid
132
+ # multigrid's CG crawls (a turned BCCz bar took 5x as long as laid)
133
+ from .fem import Solver, Stiffness
134
+
135
+ stiff = Stiffness(hm, nu, bc.fixed)
136
+ k = stiff.assemble(np.full(hm.n_elem, mat.E))
137
+ if bc.springs is not None:
138
+ k = (k + mat.E * bc.springs).tocsr()
139
+ us = Solver(hm, rtol=rtol, maxiter=4000).solve_many(k, rhs)
140
+ else:
141
+ grid_solver = GridElasticity(hm, nu, bc.fixed, springs=mat.E * bc.springs if bc.springs is not None
142
+ else None, rtol=rtol, maxiter=4000)
143
+ grid_solver.set_moduli(np.full(hm.n_elem, mat.E))
144
+ us = grid_solver.solve_many(rhs)
145
+ c0 = elastic_c(mat.E, nu)
146
+ bcs = [hex8_b(a, b_, c, nu) / pitch for (a, b_, c) in 2.0 * CORNERS - 1.0]
147
+ counts = np.bincount(hm.enode.ravel(), minlength=hm.n_nodes)[:, None]
148
+ xyz = hm.node_xyz
149
+ dface = np.minimum(xyz - lo, hi - xyz).min(axis=1)
150
+ cases, worst = [], np.inf
151
+ for ci, case in enumerate(study.load_cases):
152
+ u = us[ci]
153
+ ue = u[hm.edof]
154
+ nodal = np.zeros((hm.n_nodes, 6))
155
+ for k in range(8):
156
+ np.add.at(nodal, hm.enode[:, k], (ue @ bcs[k].T) @ c0.T)
157
+ vm = von_mises(nodal / counts)
158
+ contact = bc.support_nodes | bc.case_load_nodes[ci]
159
+ far = cKDTree(xyz[contact]).query(xyz, k=1, distance_upper_bound=contact_band)[0] == np.inf
160
+ if not far.any():
161
+ far = np.ones(len(xyz), bool)
162
+ key = np.floor((xyz[far] - origin) / np.asarray(cell_size, float)).astype(int)
163
+ _, inv = np.unique(key, axis=0, return_inverse=True)
164
+ inv = inv.ravel()
165
+ v = vm[far]
166
+ order = np.argsort(inv, kind="stable")
167
+ edges = np.searchsorted(inv[order], np.arange(inv.max() + 2))
168
+ groups = [order[edges[j]:edges[j + 1]] for j in range(len(edges) - 1) if edges[j + 1] - edges[j] > 50]
169
+ if not groups:
170
+ groups = [np.arange(len(v))]
171
+ peaks = np.array([np.percentile(v[gr], 99.5) for gr in groups])
172
+ j = int(np.argmax(peaks))
173
+ top = groups[j][np.argmax(v[groups[j]])]
174
+ peak = float(peaks[j])
175
+ sf = mat.strength / (peak * STRESS_ALLOWANCE) if peak > 0 else np.inf
176
+ worst = min(worst, sf)
177
+ ln = bc.case_load_nodes[ci]
178
+ disp = u.reshape(-1, 3)
179
+ cases.append({"name": case.name, "compliance": float(bc.loads[ci] @ u),
180
+ "deflection": float(np.linalg.norm(disp[ln].mean(axis=0))) if ln.any() else 0.0,
181
+ "peakStress": peak, "safetyFactor": sf, "peakAt": xyz[far][top].tolist(),
182
+ "peakFaceDistance": float(dface[far][top]), "peak995": float(np.percentile(v, 99.5))})
183
+ volume = float(hm.n_elem * pitch ** 3)
184
+ rep = {"mass": volume * mat.density / 1000.0, "volume": volume, "safetyFactor": worst, "cases": cases,
185
+ "cells": int(hm.n_elem), "pitch": pitch, "lattice": lat, "stressAllowance": STRESS_ALLOWANCE,
186
+ "seconds": time.time() - t0}
187
+ if keep_mesh:
188
+ rep["hm"], rep["bc"] = hm, bc
189
+ return rep