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/impact.py ADDED
@@ -0,0 +1,693 @@
1
+ """
2
+ Crush and impact: what a lattice is for when stiffness per gram is not the goal.
3
+
4
+ A solid part stops a falling or swinging mass like a spring: the force rises
5
+ with the deflection until the part has stored the whole kinetic energy, and
6
+ a stiff part (which is what topology optimization makes) has to push back
7
+ very hard to do that in a fraction of a millimetre. Then it hands the energy
8
+ back as a rebound. A cellular core crushes at a nearly constant stress (the
9
+ plateau) over most of its height, so it absorbs the same energy at a force
10
+ the designer picks, and keeps it (the walls yield or buckle). That is the
11
+ job of a crash box, a hard stop, a bumper or a drop pad.
12
+
13
+ This module predicts that behaviour for TPMS sheet lattices graded by a
14
+ field, and grades one for a given impact:
15
+
16
+ - `collapse_strength`: plastic collapse stress of one periodic unit cell
17
+ under uniaxial compression, from the elastic compensation method
18
+ (Mackenzie & Boyle 1993): repeated linear solves in which every cell wall
19
+ above the yield stress is softened in proportion, so the load spreads
20
+ the way a plastic mechanism spreads it. The lower bound P sigma_y /
21
+ max sigma it gives rises with each pass to near the limit load. The fits
22
+ in CRUSH (sigma_pl / sigma_y = D rho^p) come from it.
23
+ - `crush_curve`: force against crush distance for a part along one axis,
24
+ as a stack of thin layers in series. Each layer's crush force is the sum
25
+ of its cells' plateau stress times their area (cells in parallel), and
26
+ the weakest layer crushes first, through its densification strain
27
+ (Gibson & Ashby: eps_D = 1 - 1.4 rho), before the next one starts. This
28
+ is the standard model for graded foams; it ignores lateral load spreading
29
+ between layers, which `limit_load` checks in 3D.
30
+ - `impact_response`: where on that curve a mass m at speed v stops: crush
31
+ distance, peak force, peak deceleration, and whether the core bottoms out.
32
+ - `grade_for_impact`: the density per layer that crushes every layer at the
33
+ same force (sigma_pl(rho(z)) A(z) = F), the lowest such force that stops
34
+ the mass within the core, which is the flattest, gentlest absorber that
35
+ fits the envelope. A tapered envelope makes a uniform core's force rise
36
+ as the crush front reaches wider layers; grading takes that out.
37
+ - `solid_impact`: the same impact on a solid part from its linear stiffness
38
+ and peak stress (what a topology-optimized part does: elastic, so the
39
+ peak force is sqrt(2 E k), and it rebounds).
40
+ - `limit_load`: the elastic compensation method on a 3D voxel model with
41
+ per-cell modulus and strength (a homogenized graded lattice), for the
42
+ first collapse load without the layer model's assumptions.
43
+
44
+ Assumptions worth knowing. The plateau is taken equal to the plastic
45
+ collapse stress (right for ductile polymers such as PA12, PA11, PETG; PLA
46
+ crushes by fracture and is ragged; TPU buckles elastically and recovers,
47
+ which this does not model). Densification uses Gibson & Ashby's foam
48
+ constant 1.4, not a TPMS measurement. Rate effects are ignored (polymers
49
+ are 10-30% stronger at impact rates, which raises the force a little and
50
+ is on the safe side for stroke, not for force). Treat predictions as
51
+ +-25% until a printed crush coupon calibrates D (see CRUSH).
52
+
53
+ Units: mm, N, MPa; energy in N mm (= mJ), mass in kg, speed in m/s.
54
+ """
55
+ from __future__ import annotations
56
+
57
+ from dataclasses import dataclass
58
+
59
+ import numpy as np
60
+
61
+ from .lattice import SHEETS, relative_density, sheet_modulus, tpms
62
+
63
+ __all__ = ["CRUSH", "CRUSH_DATA", "crush_law", "ELASTOMERS", "HYSTERESIS", "collapse_strength", "plateau_stress", "density_for_plateau", "densification_strain",
64
+ "CrushCurve", "crush_curve", "layer_profile", "impact_response", "grade_for_impact", "solid_impact",
65
+ "limit_load", "foam_stress", "face_load", "grade_3d", "save_density_grid", "impact_report", "sample_core", "DENSIFICATION_ALPHA"]
66
+
67
+ DENSIFICATION_ALPHA = 1.4 # Gibson & Ashby, open-cell foams: eps_D = 1 - 1.4 rho
68
+
69
+ # Plastic collapse under uniaxial compression, sigma_pl / sigma_y = D rho^p,
70
+ # from `collapse_strength` (elastic compensation lower bound, 32^3 voxels
71
+ # per cell, nu 0.39, 15 passes) at rho = 0.11 .. 0.51, fit residuals under
72
+ # 6%. The collapse stress is 1.4-2x the first-yield stress (the walls share
73
+ # load once the hottest ones yield). Exponents near 1.1-1.4 say the sheets
74
+ # carry load mostly by stretching, which is why they beat strut lattices
75
+ # (bending, p ~ 1.5-2) per gram. Per gram, diamond is the strongest.
76
+ # Perfect cells: no buckling, no print defects, quasi-static; a crush
77
+ # coupon is what pins D down for a real machine (docs/IMPACT.md).
78
+ CRUSH = {
79
+ "gyroid": {"D": 0.633, "p": 1.191},
80
+ "diamond": {"D": 0.675, "p": 1.122},
81
+ "schwarz-p": {"D": 0.795, "p": 1.380},
82
+ }
83
+
84
+ # Elastomers (TPU) do not yield: their sheets buckle elastically, the core
85
+ # crushes at the buckling stress and springs back. Plateau sigma_el / E =
86
+ # D rho^p, Gibson & Ashby's elastic-collapse law for elastomeric foams
87
+ # (0.05 rho^2). Our own check, linear buckling of a 2x2x2 gyroid block
88
+ # between glued plates (nonlinear.linear_buckling), gives 0.0043 at rho 0.3
89
+ # with 24 voxels per cell against the law's 0.0045, but is not converged
90
+ # (0.0144 at 16 voxels: thin walls lock), so the law stands until a
91
+ # coupon says otherwise. Same law for every sheet type (no per-type data).
92
+ for _k in ("gyroid", "diamond", "schwarz-p"):
93
+ CRUSH[f"{_k}:elastic"] = {"D": 0.05, "p": 2.0}
94
+ ELASTOMERS = {"tpu"}
95
+ # fraction of the absorbed energy an elastomer core dissipates (the rest
96
+ # comes back as rebound): large-strain compression hysteresis of TPU
97
+ # foams and lattices, roughly 0.2-0.4; rough
98
+ HYSTERESIS = {"tpu": 0.3}
99
+
100
+ # (rho, sigma_pl / sigma_y) behind the fits, for tests and re-fitting
101
+ CRUSH_DATA = {
102
+ "gyroid": [(0.123, 0.0542), (0.206, 0.0927), (0.303, 0.1479), (0.408, 0.2121), (0.509, 0.2987)],
103
+ "diamond": [(0.111, 0.0580), (0.199, 0.1098), (0.289, 0.1656), (0.412, 0.2509), (0.512, 0.3202)],
104
+ "schwarz-p": [(0.113, 0.0397), (0.198, 0.0826), (0.307, 0.1568), (0.399, 0.2267), (0.498, 0.3024)],
105
+ }
106
+
107
+
108
+ # ---- unit cell ---------------------------------------------------------------------
109
+
110
+ def collapse_strength(kind: str, wall_over_cell: float, n: int = 32, nu: float = 0.39, iters: int = 15,
111
+ axis: int = 2) -> dict:
112
+ """Plastic collapse stress (over the solid's yield stress) of a TPMS sheet
113
+ unit cell under uniaxial macro compression along `axis`, lateral faces
114
+ free on average, by the elastic compensation method on an n^3 periodic
115
+ voxel cell.
116
+
117
+ Each pass solves the cell for a unit macro stress, takes the lower bound
118
+ 1 / max(von Mises) (the macro stress at which the most loaded wall
119
+ yields, with the moduli of this pass) and softens every element by
120
+ sigma_ref / sigma_e, sigma_ref the mean wall stress, so overloaded walls
121
+ shed load to idle ones as they would after yielding. Returns rho, the
122
+ first-yield stress (pass 1, the elastic answer), the collapse estimate
123
+ (best lower bound over the passes) and the history."""
124
+ import pyamg
125
+ import scipy.sparse as sp
126
+ from scipy.sparse.linalg import cg
127
+
128
+ from .fem import CORNERS, _b_matrix, elastic_c, hex8_ke, von_mises
129
+
130
+ h = 1.0 / n
131
+ c = (np.arange(n) + 0.5) * h
132
+ g = np.stack(np.meshgrid(c, c, c, indexing="ij"), -1).reshape(-1, 3)
133
+ solid = np.abs(tpms(kind, g, 1.0)) < 0.5 * wall_over_cell
134
+ eijk = np.argwhere(solid.reshape(n, n, n))
135
+ ne = len(eijk)
136
+ corner = (eijk[:, None, :] + CORNERS[None]) % n
137
+ _, inv = np.unique((corner[..., 0] * n + corner[..., 1]) * n + corner[..., 2], return_inverse=True)
138
+ enode = inv.reshape(ne, 8)
139
+ nn = int(enode.max()) + 1
140
+ edof = (3 * enode[:, :, None] + np.arange(3)).reshape(ne, 24)
141
+ ke = hex8_ke(nu) * h
142
+ rows = np.repeat(edof, 24, axis=1).ravel()
143
+ cols = np.tile(edof, (1, 24)).ravel()
144
+ free = np.ones(3 * nn, bool)
145
+ free[:3] = False
146
+ xc = (eijk[:, None, :] + CORNERS[None]) * h
147
+ c0 = elastic_c(1.0, nu)
148
+ bc = _b_matrix(0, 0, 0) / h
149
+ scale = np.ones(ne)
150
+
151
+ def solve(eps_list):
152
+ k = sp.csr_matrix((np.repeat(scale, 576) * np.tile(ke.ravel(), ne), (rows, cols)), shape=(3 * nn, 3 * nn))
153
+ kf = k[free][:, free]
154
+ ml = pyamg.smoothed_aggregation_solver(kf, symmetry="hermitian", max_coarse=400, coarse_solver="splu")
155
+ out = []
156
+ for e in eps_list:
157
+ eps = np.array([[e[0], e[3] / 2, e[5] / 2], [e[3] / 2, e[1], e[4] / 2], [e[5] / 2, e[4] / 2, e[2]]])
158
+ u0 = (xc @ eps.T).reshape(ne, 24)
159
+ f = np.zeros(3 * nn)
160
+ np.add.at(f, edof, -scale[:, None] * (u0 @ ke.T))
161
+ uf, info = cg(kf, f[free], rtol=1e-8, maxiter=3000, M=ml.aspreconditioner())
162
+ if info != 0:
163
+ raise RuntimeError("unit cell solve did not converge")
164
+ u = np.zeros(3 * nn)
165
+ u[free] = uf
166
+ sig = ((u[edof] + u0) @ bc.T) @ c0.T * scale[:, None] # element-center stress
167
+ out.append((sig.sum(axis=0) / n ** 3, sig))
168
+ return out
169
+
170
+ unit = np.eye(6)
171
+ hist = []
172
+ best = 0.0
173
+ first = None
174
+ for _ in range(iters):
175
+ res = solve([unit[0], unit[1], unit[2]])
176
+ cm = np.stack([r[0][:3] for r in res], axis=1) # 3x3 normal block of the macro stiffness
177
+ # macro strains giving unit uniaxial stress along `axis`
178
+ target = np.zeros(3)
179
+ target[axis] = 1.0
180
+ e = np.linalg.solve(cm, target)
181
+ sig = sum(e[i] * res[i][1] for i in range(3))
182
+ vm = von_mises(sig)
183
+ lb = 1.0 / float(vm.max())
184
+ if first is None:
185
+ first = lb
186
+ best = max(best, lb)
187
+ hist.append(lb)
188
+ ref = float(vm.mean())
189
+ scale = scale * np.clip(ref / np.maximum(vm, 1e-12), 0.05, 20.0)
190
+ scale = np.maximum(scale, 1e-6)
191
+ return {"kind": kind, "rho": float(solid.mean()), "firstYield": float(first), "collapse": float(best),
192
+ "history": [float(x) for x in hist]}
193
+
194
+
195
+ # ---- homogenized crush laws ------------------------------------------------------------
196
+
197
+ def _crush_fit(kind):
198
+ fit = CRUSH.get(kind)
199
+ if fit is None:
200
+ raise ValueError(f"lattice type must be one of {sorted(CRUSH)}, got {kind!r}")
201
+ return fit
202
+
203
+
204
+ def _base(kind: str) -> str:
205
+ return kind.split(":")[0]
206
+
207
+
208
+ def crush_law(kind: str, material, calibrated: bool = True) -> tuple:
209
+ """(law key, reference stress) for a lattice of `material`: plastic
210
+ collapse over the yield stress for polymers and metals that yield, and
211
+ elastic buckling over the modulus ("<kind>:elastic") for elastomers,
212
+ which buckle, crush and spring back. A calibration card of that name
213
+ with a crush block (calibration.fit on crush coupons) scales the
214
+ reference by its measured factor."""
215
+ name = getattr(material, "name", "")
216
+ elastic = name in ELASTOMERS or str(getattr(material, "note", "")).startswith("elastomer")
217
+ if not elastic:
218
+ try:
219
+ from .calibration import load_card
220
+
221
+ card = load_card(name) or {}
222
+ elastic = card.get("basedOn") in ELASTOMERS
223
+ except Exception: # noqa: BLE001 - a missing or broken card is no card
224
+ card = {}
225
+ law, ref = (f"{kind}:elastic", float(material.E)) if elastic else (kind, float(material.strength))
226
+ if calibrated:
227
+ from .calibration import crush_factor
228
+
229
+ ref *= crush_factor(name, "elastic" if elastic else "plastic")
230
+ return law, ref
231
+
232
+
233
+ def plateau_stress(kind: str, rho, sigma_y: float):
234
+ """Crush plateau stress (MPa) of a `kind` sheet at relative density rho;
235
+ solid (rho >= 1) is sigma_y. For an elastic law ("gyroid:elastic")
236
+ sigma_y is the solid's modulus (see crush_law)."""
237
+ f = _crush_fit(kind)
238
+ rho = np.asarray(rho, float)
239
+ return np.where(rho >= 1.0, sigma_y, sigma_y * f["D"] * np.clip(rho, 1e-3, 1.0) ** f["p"])
240
+
241
+
242
+ def density_for_plateau(kind: str, stress, sigma_y: float):
243
+ """The relative density whose plateau is `stress` (MPa)."""
244
+ f = _crush_fit(kind)
245
+ return (np.asarray(stress, float) / (sigma_y * f["D"])) ** (1.0 / f["p"])
246
+
247
+
248
+ def densification_strain(rho, alpha: float = DENSIFICATION_ALPHA):
249
+ """Nominal strain at which the crushed walls lock up (Gibson & Ashby)."""
250
+ return np.clip(1.0 - alpha * np.asarray(rho, float), 0.0, 1.0)
251
+
252
+
253
+ # ---- the layer model ----------------------------------------------------------------------
254
+
255
+ @dataclass
256
+ class CrushCurve:
257
+ """Force (N) against crush distance (mm), piecewise linear, plus the
258
+ layers it came from. `stroke` is the distance at which every layer has
259
+ densified (the core bottoms out); `energy(d)` the work absorbed to d."""
260
+ d: np.ndarray
261
+ f: np.ndarray
262
+ stroke: float
263
+ layer_force: np.ndarray
264
+ layer_area: np.ndarray
265
+ layer_rho: np.ndarray
266
+ layer_stroke: np.ndarray
267
+
268
+ def energy(self, d=None):
269
+ e = np.concatenate([[0.0], np.cumsum(0.5 * (self.f[1:] + self.f[:-1]) * np.diff(self.d))])
270
+ return e if d is None else np.interp(d, self.d, e)
271
+
272
+ @property
273
+ def capacity(self):
274
+ return float(self.energy()[-1])
275
+
276
+ def to_dict(self, points=60):
277
+ dd = np.linspace(0.0, float(self.d[-1]), points)
278
+ return {"stroke": round(self.stroke, 3), "capacity": round(self.capacity, 1),
279
+ "distance": [round(float(x), 3) for x in dd],
280
+ "force": [round(float(x), 1) for x in np.interp(dd, self.d, self.f)]}
281
+
282
+
283
+ def layer_profile(inside, rho, pitch: float, axis: int = 2):
284
+ """Per layer along `axis` of a voxel grid: area (mm^2) and area-weighted
285
+ mean relative density of the cells inside the part. inside: bool grid;
286
+ rho: relative density per voxel (same shape, or a scalar)."""
287
+ inside = np.asarray(inside, bool)
288
+ rho = np.broadcast_to(np.asarray(rho, float), inside.shape)
289
+ other = tuple(i for i in range(3) if i != axis)
290
+ n = inside.sum(axis=other)
291
+ area = n * pitch ** 2
292
+ mean = np.where(n > 0, (rho * inside).sum(axis=other) / np.maximum(n, 1), 0.0)
293
+ return area, mean
294
+
295
+
296
+ def crush_curve(kind: str, area, rho, h: float, sigma_y: float, modulus: float,
297
+ alpha: float = DENSIFICATION_ALPHA, layer_force=None) -> CrushCurve:
298
+ """Force-crush curve of a stack of layers of thickness h (mm) with the
299
+ given area and density (one entry per layer; rho may be a scalar). The
300
+ layers act in series: all carry the same force, the elastic part is the
301
+ series compliance, and the layer with the lowest crush force collapses
302
+ first and runs through its densification strain before the force rises
303
+ to the next. `layer_force` (N per layer) overrides sigma_pl(rho) A when
304
+ a layer's cells are not uniform (pass the sum over its cells)."""
305
+ area = np.asarray(area, float)
306
+ rho = np.broadcast_to(np.asarray(rho, float), area.shape).copy()
307
+ keep = area > 0
308
+ area, rho = area[keep], rho[keep]
309
+ fl = plateau_stress(kind, rho, sigma_y) * area if layer_force is None else np.asarray(layer_force, float)[keep]
310
+ stroke = densification_strain(rho, alpha) * h
311
+ k_layer = modulus * sheet_modulus(_base(kind), rho) * area / h
312
+ comp = float(np.sum(1.0 / np.maximum(k_layer, 1e-12))) # series compliance, mm/N
313
+ order = np.argsort(fl, kind="stable")
314
+ d, f = [0.0], [0.0]
315
+ crushed = 0.0
316
+ for i in order:
317
+ # elastic ramp to this layer's force (the uncrushed layers still spring)
318
+ d.append(crushed + comp * fl[i])
319
+ f.append(fl[i])
320
+ crushed += stroke[i]
321
+ d.append(crushed + comp * fl[i])
322
+ f.append(fl[i])
323
+ return CrushCurve(np.array(d), np.array(f), float(crushed + comp * fl.max()), fl, area, rho, stroke)
324
+
325
+
326
+ def impact_response(curve: CrushCurve, energy: float, mass: float | None = None) -> dict:
327
+ """Where a mass carrying `energy` (N mm) stops on the curve: crush
328
+ distance, peak force and (with `mass`, kg) peak deceleration in g. If
329
+ the core cannot take it all before densifying, `bottomedOut` is true and
330
+ `residualEnergy` is what is left for the densified core and whatever
331
+ is behind it (the force then is far above the plateau)."""
332
+ e = curve.energy()
333
+ out = {"energy": round(float(energy), 1), "capacity": round(float(e[-1]), 1)}
334
+ if energy > e[-1]:
335
+ out.update(bottomedOut=True, residualEnergy=round(float(energy - e[-1]), 1),
336
+ crush=round(float(curve.d[-1]), 3), peakForce=None, usedStroke=1.0)
337
+ return out
338
+ # f is piecewise linear in d, so within a segment the energy is
339
+ # quadratic in d: solve it there (a straight line between the corners
340
+ # reads a stiff core that stops on its first ramp far too low)
341
+ k = int(np.clip(np.searchsorted(e, energy, side="right") - 1, 0, len(e) - 2))
342
+ d0, d1 = float(curve.d[k]), float(curve.d[k + 1])
343
+ f0, f1 = float(curve.f[k]), float(curve.f[k + 1])
344
+ rest = max(float(energy - e[k]), 0.0)
345
+ s = (f1 - f0) / max(d1 - d0, 1e-12)
346
+ if abs(s) < 1e-12:
347
+ x = rest / max(f0, 1e-12)
348
+ else:
349
+ x = (-f0 + np.sqrt(max(f0 * f0 + 2 * s * rest, 0.0))) / s
350
+ d = float(min(d0 + max(x, 0.0), d1))
351
+ j = int(np.searchsorted(curve.d, d, side="right"))
352
+ fpk = float(max(curve.f[:j].max(initial=0.0), np.interp(d, curve.d, curve.f)))
353
+ out.update(bottomedOut=False, crush=round(d, 3), peakForce=round(fpk, 1),
354
+ usedStroke=round(d / max(curve.stroke, 1e-9), 3),
355
+ # energy over (peak force x crush): 1 is an ideal square pulse
356
+ efficiency=round(float(energy / max(fpk * d, 1e-12)), 3))
357
+ if mass:
358
+ out["peakG"] = round(fpk / (mass * 9.81), 1)
359
+ return out
360
+
361
+
362
+ def grade_for_impact(kind: str, area, h: float, sigma_y: float, modulus: float, energy: float,
363
+ force_limit: float | None = None, rho_min: float = 0.12, rho_max: float = 0.5,
364
+ reserve: float = 1.25, alpha: float = DENSIFICATION_ALPHA) -> dict:
365
+ """Density per layer for the gentlest stop of `energy` (N mm) in this
366
+ envelope: every layer crushes at the same force F (rho_i from
367
+ sigma_pl(rho_i) A_i = F, clipped to the printable range), and F is the
368
+ lowest for which the core can absorb `reserve` x the energy before it
369
+ densifies. Returns the force, the densities, and the curve, with
370
+ `ok` false if no F up to `force_limit` does it."""
371
+ area = np.asarray(area, float)
372
+ a = area[area > 0]
373
+ lo = float(plateau_stress(kind, rho_min, sigma_y) * a.min())
374
+ hi = float(plateau_stress(kind, rho_max, sigma_y) * a.max())
375
+
376
+ def at(force):
377
+ rho = np.clip(density_for_plateau(kind, force / a, sigma_y), rho_min, rho_max)
378
+ return rho, crush_curve(kind, a, rho, h, sigma_y, modulus, alpha)
379
+
380
+ # the target force with the lowest peak (layers clipped at rho_min crush
381
+ # above it, so the lowest target is not always the gentlest) among those
382
+ # that keep `reserve` x the energy in hand before densifying
383
+ best, peak = None, np.inf
384
+ for force in np.geomspace(lo, hi, 300):
385
+ rho, cv = at(force)
386
+ if cv.capacity < reserve * energy:
387
+ continue
388
+ p = impact_response(cv, energy)["peakForce"]
389
+ if p < peak:
390
+ best, peak = (force, rho, cv), p
391
+ ok = best is not None
392
+ if best is None: # take the most capacity there is
393
+ best = max(((f, *at(f)) for f in np.geomspace(lo, hi, 200)), key=lambda t: t[2].capacity)
394
+ force, rho, cv = best
395
+ resp = impact_response(cv, energy)
396
+ if force_limit is not None and (resp["peakForce"] is None or resp["peakForce"] > force_limit):
397
+ ok = False
398
+ full = np.zeros_like(area)
399
+ full[area > 0] = rho
400
+ return {"ok": ok, "force": float(force), "rho": full, "curve": cv, "response": resp,
401
+ "meanRho": float(np.sum(rho * a) / np.sum(a))}
402
+
403
+
404
+ def solid_impact(stiffness: float, stress_per_newton: float, sigma_y: float, energy: float,
405
+ force_limit: float | None = None, mass: float | None = None) -> dict:
406
+ """The same impact on a solid (elastic) part with stiffness k (N/mm) at the
407
+ impact point and peak von Mises stress s (MPa per N of load): it stops
408
+ the mass at F = sqrt(2 E k) after F/k mm, stores (does not dissipate) the
409
+ energy, and yields if s F > sigma_y. Also the most energy it can take
410
+ under the force limit, F_lim^2 / 2k."""
411
+ fpk = float(np.sqrt(2.0 * energy * stiffness))
412
+ out = {"stiffness": round(float(stiffness), 1), "peakForce": round(fpk, 1),
413
+ "crush": round(fpk / stiffness, 4), "peakStress": round(stress_per_newton * fpk, 1),
414
+ "yields": bool(stress_per_newton * fpk > sigma_y),
415
+ "energyAtYield": round(float((sigma_y / stress_per_newton) ** 2 / (2 * stiffness)), 1),
416
+ "rebound": "elastic: the stored energy comes back"}
417
+ if force_limit is not None:
418
+ out["energyAtForceLimit"] = round(float(force_limit ** 2 / (2 * stiffness)), 2)
419
+ if mass:
420
+ out["peakG"] = round(fpk / (mass * 9.81), 1)
421
+ return out
422
+
423
+
424
+ # ---- 3D check -------------------------------------------------------------------------------
425
+
426
+ def face_load(mesh, axis: int, total: float, top: bool = True) -> np.ndarray:
427
+ """A force `total` (N) along `axis` spread evenly over the mesh's
428
+ highest (or lowest) face normal to that axis, each node taking its
429
+ share of the face cells around it (a uniform pressure, consistently)."""
430
+ from .fem import CORNERS
431
+
432
+ k = mesh.elem_ijk[:, axis]
433
+ edge = k.max() if top else k.min()
434
+ corners = np.flatnonzero(CORNERS[:, axis] == (1 if top else 0))
435
+ elems = np.flatnonzero(k == edge)
436
+ f = np.zeros(mesh.n_dof)
437
+ np.add.at(f, 3 * mesh.enode[np.ix_(elems, corners)].ravel() + axis, total / (4.0 * len(elems)))
438
+ return f
439
+
440
+
441
+ FOAM_ALPHA = 2.12 # Deshpande & Fleck (2000): crushable foam with zero plastic Poisson ratio
442
+
443
+
444
+ def foam_stress(stress, alpha: float = FOAM_ALPHA):
445
+ """Deshpande-Fleck effective stress of a crushable cellular solid:
446
+ sqrt((vm^2 + alpha^2 sm^2) / (1 + (alpha/3)^2)), sm the mean stress.
447
+ Equals the stress in uniaxial compression; unlike von Mises it counts
448
+ pressure, so a laterally confined lattice still crushes (alpha 0 is
449
+ von Mises, a solid)."""
450
+ from .fem import von_mises
451
+
452
+ vm = von_mises(stress)
453
+ sm = stress[:, :3].mean(axis=1)
454
+ return np.sqrt((vm ** 2 + alpha ** 2 * sm ** 2) / (1 + (alpha / 3) ** 2))
455
+
456
+
457
+ def limit_load(mesh, modulus, strength, nu: float, fixed, force, iters: int = 15,
458
+ alpha: float = FOAM_ALPHA) -> dict:
459
+ """Collapse load factor of a voxel model (per-element modulus and
460
+ strength, MPa; for a homogenized lattice the cell's plateau stress) under
461
+ `force` (N per dof), by the elastic compensation method: lower bound
462
+ min over elements of strength / effective stress per pass, elements
463
+ softened by sigma_ref / sigma_e between passes. The effective stress is
464
+ `foam_stress` (alpha 0 for a solid). lambda < 1: it collapses below the
465
+ applied load. Also returns the elastic stiffness along the load
466
+ (|f|^2 / f.u) of the first pass."""
467
+ from scipy.sparse.linalg import cg
468
+
469
+ from .fem import Stiffness, amg_solver
470
+
471
+ mod = np.broadcast_to(np.asarray(modulus, float), (mesh.n_elem,)).copy()
472
+ strength = np.broadcast_to(np.asarray(strength, float), (mesh.n_elem,))
473
+ st = Stiffness(mesh, nu, np.asarray(fixed, bool))
474
+ f = np.where(st.fixed, 0.0, np.asarray(force, float))
475
+ scale = mod.copy()
476
+ hist, best, k0, upper = [], 0.0, None, np.inf
477
+ u = None
478
+ for _ in range(iters):
479
+ k = st.assemble(scale)
480
+ ml = amg_solver(k, symmetry="hermitian", max_coarse=500)
481
+ u, info = cg(k, f, x0=u, rtol=1e-7, maxiter=1500, M=ml.aspreconditioner())
482
+ if k0 is None:
483
+ k0 = float(f @ f / max(f @ u, 1e-30))
484
+ se = foam_stress(st.stress(u, scale), alpha)
485
+ util = se / strength
486
+ lam = 1.0 / float(util.max())
487
+ best = max(best, lam)
488
+ hist.append(lam)
489
+ # Mackenzie & Boyle's upper bound from the same field (kinematic: the
490
+ # strain field is compatible), sum(s sigma / E) / sum(sigma^2 / E)
491
+ ub = float(np.sum(strength * se / scale) / max(np.sum(se ** 2 / scale), 1e-300))
492
+ upper = min(upper, ub)
493
+ ref = float(np.mean(util))
494
+ # softening only, at most 1000x, which keeps the solves well conditioned
495
+ scale = np.clip(scale * np.clip(ref / np.maximum(util, 1e-12), 0.05, 20.0), 1e-3 * mod, mod)
496
+ return {"loadFactor": best, "upperBound": upper, "firstYieldFactor": hist[0], "history": hist,
497
+ "stiffness": k0}
498
+
499
+
500
+ # ---- field graphs -------------------------------------------------------------------------
501
+
502
+ def sample_core(graph, envelope: str, density=None, pitch: float = 0.5, bounds=None):
503
+ """Voxel samples of a field graph's lattice core: (inside, rho, lo, pitch)
504
+ with `inside` the cells whose centres are inside the `envelope` solid
505
+ and rho the `density` field there (a number, a node name, or None for
506
+ solid)."""
507
+ b = graph.bounds(envelope) if bounds is None else bounds
508
+ if b is None:
509
+ raise ValueError(f"node {envelope!r} has no bounds; give the analysis 'bounds'")
510
+ lo, hi = (np.asarray(x, float) for x in b)
511
+ dims = np.maximum(np.ceil((hi - lo) / pitch).astype(int), 1)
512
+ axes = [lo[i] + (np.arange(dims[i]) + 0.5) * pitch for i in range(3)]
513
+ P = np.stack(np.meshgrid(*axes, indexing="ij"), -1).reshape(-1, 3)
514
+ inside = (graph.evaluate(P, envelope) < 0).reshape(tuple(dims))
515
+ if density is None:
516
+ rho = np.ones(inside.shape)
517
+ elif isinstance(density, (int, float)):
518
+ rho = np.full(inside.shape, float(density))
519
+ else:
520
+ rho = np.broadcast_to(np.asarray(graph.evaluate(P, density), float), (len(P),)).reshape(tuple(dims))
521
+ return inside, np.clip(rho, 0.0, 1.0), lo, pitch
522
+
523
+
524
+ def _energy(spec):
525
+ if spec.get("energy") is not None:
526
+ return 1000.0 * float(spec["energy"]) # J -> N mm
527
+ return 1000.0 * 0.5 * float(spec["mass"]) * float(spec["velocity"]) ** 2
528
+
529
+
530
+ def impact_report(graph, spec: dict, material) -> dict:
531
+ """The field graph's `impact` block: crush the `envelope` filled with the
532
+ `lattice` at `density` along `axis`, stop the impact on it, and (with
533
+ `grade`) work out the flattest graded core for the same envelope as a
534
+ ready remap table for the density node."""
535
+ kind = spec.get("lattice", "gyroid")
536
+ axis = "xyz".index(spec.get("axis", "z"))
537
+ pitch = float(spec.get("pitch", 0.5))
538
+ inside, rho, lo, h = sample_core(graph, spec["envelope"], spec.get("density"), pitch)
539
+ law, sy = crush_law(kind, material)
540
+ E = float(material.E)
541
+ other = tuple(i for i in range(3) if i != axis)
542
+ area, mean = layer_profile(inside, rho, h, axis)
543
+ lf = (plateau_stress(law, rho, sy) * inside).sum(axis=other) * h ** 2
544
+ energy = _energy(spec)
545
+ mass = spec.get("mass")
546
+ flim = spec.get("forceLimit")
547
+ cv = crush_curve(law, area, mean, h, sy, E, layer_force=lf)
548
+ resp = impact_response(cv, energy, mass)
549
+ rate = None
550
+ if law.endswith(":elastic") and mass and spec.get("rate", True):
551
+ # an elastomer is stiffer in a fast knock than in the card's test: the
552
+ # crush time (constant-force estimate 2 d / v) sets the factor
553
+ from .viscoelastic import creep_params, event_factor
554
+
555
+ if creep_params(material) is not None:
556
+ v = np.sqrt(2.0 * energy * 1e-3 / float(mass)) # m/s
557
+ fac = 1.0
558
+ for _ in range(4):
559
+ t = 2.0 * float(resp["crush"]) * 1e-3 / v
560
+ fac = float(event_factor(material, t))
561
+ cv = crush_curve(law, area, mean, h, sy * fac, E * fac, layer_force=lf * fac)
562
+ resp = impact_response(cv, energy, mass)
563
+ sy, E = sy * fac, E * fac
564
+ rate = {"eventSeconds": round(t, 4), "stiffening": round(fac, 3)}
565
+ core_mass = float((rho * inside).sum() * h ** 3 * material.density / 1000.0)
566
+ out = {"lattice": kind, "law": "elastic buckling" if law.endswith(":elastic") else "plastic collapse",
567
+ "axis": "xyz"[axis], "energy": round(energy, 1), "coreMass": round(core_mass, 2),
568
+ "meanDensity": round(float((rho * inside).sum() / max(inside.sum(), 1)), 3),
569
+ "response": resp, "curve": cv.to_dict()}
570
+ if rate:
571
+ out["rate"] = rate
572
+ if law.endswith(":elastic"):
573
+ # an elastomer core springs back: only its hysteresis is dissipated
574
+ hy = HYSTERESIS.get(material.name, 0.3)
575
+ out["dissipated"] = hy
576
+ out["reboundEnergy"] = round((1 - hy) * energy, 1)
577
+ if flim is not None:
578
+ out["forceLimit"] = float(flim)
579
+ out["ok"] = bool(not resp["bottomedOut"] and resp["peakForce"] <= flim)
580
+ if spec.get("grade"):
581
+ rho_min = float(spec.get("rhoMin", 0.12))
582
+ if spec.get("cell") is not None:
583
+ # the thinnest printable wall sets the lowest density at this cell
584
+ rho_min = max(rho_min, float(relative_density(kind, float(spec.get("minWall", 0.8)),
585
+ float(spec["cell"]))))
586
+ g = grade_for_impact(law, area, h, sy, E, energy, flim, rho_min, float(spec.get("rhoMax", 0.5)))
587
+ z = lo[axis] + (np.arange(len(area)) + 0.5) * h
588
+ keep = area > 0
589
+ zs, rs = z[keep], g["rho"][keep]
590
+ # thin the table to every ~1 mm (the field is linear in between)
591
+ step = max(1, int(round(1.0 / h)))
592
+ idx = np.unique(np.r_[np.arange(0, len(zs), step), len(zs) - 1])
593
+ gm = float((g["rho"] * area).sum() * h * material.density / 1000.0)
594
+ # the fair comparison: a uniform core of the same mass
595
+ cu = crush_curve(law, area, g["meanRho"], h, sy, E)
596
+ out["uniformSameMass"] = {"density": round(g["meanRho"], 4), "response": impact_response(cu, energy, mass),
597
+ "curve": cu.to_dict()}
598
+ out["graded"] = {"ok": g["ok"], "plateauForce": round(g["force"], 1), "coreMass": round(gm, 2),
599
+ "rhoMin": round(rho_min, 4),
600
+ "response": impact_response(g["curve"], energy, mass), "curve": g["curve"].to_dict(),
601
+ "density": {"op": "remap", "input": {"op": "coordinate", "axis": "xyz"[axis]},
602
+ "from": [round(float(x), 3) for x in zs[idx]],
603
+ "to": [round(float(x), 4) for x in rs[idx]]}}
604
+ return out
605
+
606
+
607
+ def grade_3d(inside, lo, h: float, kind: str, material, force: float, rho0=None, rho_min: float = 0.12,
608
+ rho_max: float = 0.5, axis: int = 2, iters: int = 16, alpha: float = FOAM_ALPHA,
609
+ keep_layers: bool = True, voids: bool = True, void_below: float = 0.5) -> dict:
610
+ """Density per cell, not just per layer, for a core that crushes at
611
+ `force` (N, pressed evenly on the top face along `axis`, foot held).
612
+
613
+ A layer-only grading (grade_for_impact) gives every layer the right
614
+ total strength, but in a taper the load from a narrow layer stays in a
615
+ column and reaches the middle of the wider layers below before their
616
+ rims: the middle crushes early and the rims idle. Here each pass solves
617
+ the homogenized core elastically and, within each layer, shares the
618
+ layer's crush force out in proportion to where the (crushable-foam)
619
+ stress goes, so each layer still crushes at `force` (`keep_layers`).
620
+ Cells that would need less than `void_below` x rho_min become empty
621
+ (`voids`): the idle rim is removed and its material moves to the loaded
622
+ middle, which is why the result is lighter as well. Damped, as the load
623
+ path moves with the densities.
624
+
625
+ inside: bool voxel grid (cell centres at lo + (ijk + 0.5) h); rho0: the
626
+ starting density per voxel (default rho_min). Returns the density grid
627
+ (0 = empty; outside cells carry the nearest inside value so a field
628
+ graph can sample it anywhere), the worst utilization per pass and the
629
+ core mass. In a graph, a grid node of it drives the lattice density and
630
+ an expr `0.1 - density` intersected with the envelope cuts the empty
631
+ cells (see examples/impact/end_stop.py)."""
632
+ from scipy import ndimage
633
+ from scipy.sparse.linalg import cg
634
+
635
+ from .fem import HexMesh, Stiffness, amg_solver
636
+ from .lattice import sheet_modulus
637
+
638
+ kind, sy = crush_law(_base(kind), material)
639
+ E = float(material.E)
640
+ ijk = np.argwhere(inside)
641
+ mesh = HexMesh(ijk, h, lo)
642
+ rho = (np.full(len(ijk), rho_min) if rho0 is None
643
+ else np.clip(np.broadcast_to(rho0, inside.shape)[inside], rho_min, rho_max)).astype(float)
644
+ x = mesh.node_xyz[:, axis]
645
+ fixed = np.zeros(mesh.n_dof, bool)
646
+ foot = np.flatnonzero(x < x.min() + 1e-6)
647
+ fixed[3 * foot + axis] = True
648
+ others = [a for a in range(3) if a != axis]
649
+ fixed[3 * foot[0] + others[0]] = fixed[3 * foot[0] + others[1]] = True # hold it still sideways
650
+ fixed[3 * foot[-1] + others[1]] = True
651
+ f = face_load(mesh, axis, -force)
652
+ st = Stiffness(mesh, float(material.nu), fixed)
653
+ f = np.where(st.fixed, 0.0, f)
654
+ u, hist = None, []
655
+ for _ in range(iters):
656
+ mod = np.where(rho > 0, E * sheet_modulus(_base(kind), np.maximum(rho, 1e-3)), 1e-6 * E)
657
+ k = st.assemble(mod)
658
+ u, _info = cg(k, f, x0=u, rtol=1e-7, maxiter=1500,
659
+ M=amg_solver(k, symmetry="hermitian", max_coarse=500).aspreconditioner())
660
+ se = foam_stress(st.stress(u, mod), alpha)
661
+ util = np.where(rho > 0, se / plateau_stress(kind, np.maximum(rho, 1e-3), sy), 0.0)
662
+ hist.append(float(util.max()))
663
+ if keep_layers:
664
+ # within each layer, share the layer's crush force out in proportion
665
+ # to where the stress goes: scale c per layer so sum sigma_pl A = force
666
+ lay = ijk[:, axis]
667
+ nl = lay.max() + 1
668
+ lo_c, hi_c = np.full(nl, 1e-3), np.full(nl, 1e3)
669
+ for _b in range(40):
670
+ c = np.sqrt(lo_c * hi_c)
671
+ t = np.clip(density_for_plateau(kind, c[lay] * se, sy), rho_min, rho_max)
672
+ if voids:
673
+ t = np.where(density_for_plateau(kind, c[lay] * se, sy) < void_below * rho_min, 0.0, t)
674
+ tot = np.bincount(lay, plateau_stress(kind, t, sy) * h ** 2, nl)
675
+ over = tot > force
676
+ hi_c = np.where(over, c, hi_c)
677
+ lo_c = np.where(over, lo_c, c)
678
+ target = t
679
+ else:
680
+ target = np.clip(density_for_plateau(kind, se, sy), rho_min, rho_max)
681
+ rho = np.where(target == 0.0, 0.0, np.sqrt(np.maximum(rho, rho_min) * np.maximum(target, rho_min)))
682
+ grid = np.zeros(inside.shape)
683
+ grid[inside] = rho
684
+ idx = ndimage.distance_transform_edt(~inside, return_distances=False, return_indices=True)
685
+ return {"rho": grid[tuple(idx)], "inside": inside, "history": hist, "utilization": util,
686
+ "mass": float(rho.sum() * h ** 3 * material.density / 1000.0)}
687
+
688
+
689
+ def save_density_grid(path: str, rho, lo, h: float) -> None:
690
+ """A density grid for a field graph's `grid` node: values at the voxel
691
+ centres lo + (ijk + 0.5) h."""
692
+ np.savez_compressed(path, values=np.asarray(rho, np.float32), origin=np.asarray(lo, float) + 0.5 * h,
693
+ pitch=float(h))