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/cutcell.py ADDED
@@ -0,0 +1,510 @@
1
+ """
2
+ Cut cells: the voxel analysis with the part's true boundary inside each cell.
3
+
4
+ verify.py keeps a cell when its centre is inside the part and gives it the
5
+ whole cube's stiffness. Member thickness then rounds to whole cells, and a
6
+ member two or three cells thick reads its bending stress tens of percent
7
+ off, either way (benchmarks/beams.py: 0.75-1.28x CalculiX at 2 cells,
8
+ 30-45 degrees to the grid). This module keeps the same grid, nodes and
9
+ solvers and changes two things:
10
+
11
+ - Stiffness: every cell the part touches (fill >= MIN_FILL) is kept, and a
12
+ cell the boundary cuts gets its stiffness integrated over the part of it
13
+ that is solid: an n x n x n split of the cell, each solid sub-cell
14
+ integrated exactly (2x2x2 Gauss), the finite cell method. The element is
15
+ fem.py's incompatible-mode hex8; on a cut cell the bubbles' strain is made
16
+ mean-free over the solid part (Taylor's correction on the material
17
+ domain), so the constant-strain patch test still holds. Full cells keep
18
+ the shared unit-cube matrix, so the assembled path adds the cut cells'
19
+ matrices as explicit elements and the matrix-free multigrid takes them as
20
+ an extra sparse operator (mg.GridElasticity's `springs`, which its
21
+ Galerkin coarsening carries down exactly).
22
+ - Stress: a cut cell's corners can sit a cell outside the part, where its
23
+ field means nothing. Nodes near the boundary read the stress ON the true
24
+ surface instead: a linear least-squares fit (superconvergent patch
25
+ recovery) over the element fields at the solid sample points around the
26
+ node's closest surface point. Bending stress is linear through a member,
27
+ so the fit takes it to the surface whole.
28
+
29
+ The boundary comes from a voxel grid n times finer (`fine`, bool), which
30
+ gives the sub-cells, the fill, and (its boundary faces) the surface points.
31
+
32
+ Accuracy (benchmarks/beams.py, peak / CalculiX): 0.95-1.09 in bending
33
+ and 0.95-1.17 in tension from 2 cells thick up, at any angle, except two
34
+ cells at 45 degrees (0.86): two cells across a diagonal strip are too few
35
+ for a trilinear cell to bend in, which verify's thin-member submodels
36
+ resolve (0.97-1.06 at 2.5x finer). The whole-cell staircase reads
37
+ 0.75-1.95 over the same cases.
38
+ """
39
+ from __future__ import annotations
40
+
41
+ import numpy as np
42
+ import scipy.sparse as sp
43
+ from scipy import ndimage
44
+
45
+ from .fem import CORNERS, HexMesh, Stiffness, _b_matrix, _hex8_parts, elastic_c
46
+
47
+ __all__ = ["CutGrid", "SubcellRule", "MIN_FILL", "SUB"]
48
+
49
+ SUB = 4 # sub-cells per cell edge
50
+ MIN_FILL = 0.02 # a cell with less of the part than this is left out (conditioning)
51
+ _GAUSS = 1.0 / np.sqrt(3.0)
52
+ # how far from the surface (cells) a node reads the surface fit instead of
53
+ # its corner average, and the fit's neighbourhood
54
+ NEAR_SURFACE = 1.0
55
+ FIT_POINTS = 48
56
+ FIT_TAPER = 1.5
57
+
58
+
59
+ def _strain_rows(dn):
60
+ """(n, k, 3) gradients of k scalar fields -> (n, 6, 3k) strain rows."""
61
+ n, k, _ = dn.shape
62
+ b = np.zeros((n, 6, 3 * k))
63
+ dx, dy, dz = dn[..., 0], dn[..., 1], dn[..., 2]
64
+ b[:, 0, 0::3], b[:, 1, 1::3], b[:, 2, 2::3] = dx, dy, dz
65
+ b[:, 3, 0::3], b[:, 3, 1::3] = dy, dx
66
+ b[:, 4, 1::3], b[:, 4, 2::3] = dz, dy
67
+ b[:, 5, 0::3], b[:, 5, 2::3] = dz, dx
68
+ return b
69
+
70
+
71
+ def b_nodes(xi):
72
+ """Trilinear strain rows at natural points xi (n, 3): (n, 6, 24), unit cube."""
73
+ xi = np.asarray(xi, float)
74
+ sg = 2.0 * CORNERS - 1.0
75
+ f = 1.0 + xi[:, None, :] * sg[None]
76
+ dn = np.empty((len(xi), 8, 3))
77
+ dn[..., 0] = 0.25 * sg[None, :, 0] * f[..., 1] * f[..., 2]
78
+ dn[..., 1] = 0.25 * sg[None, :, 1] * f[..., 0] * f[..., 2]
79
+ dn[..., 2] = 0.25 * sg[None, :, 2] * f[..., 0] * f[..., 1]
80
+ return _strain_rows(dn)
81
+
82
+
83
+ def b_bubbles(xi):
84
+ """Strain rows of the nine bubbles (1 - xi_m^2) at natural points: (n, 6, 9)."""
85
+ xi = np.asarray(xi, float)
86
+ dn = np.zeros((len(xi), 3, 3))
87
+ for m in range(3):
88
+ dn[:, m, m] = -4.0 * xi[:, m]
89
+ return _strain_rows(dn)
90
+
91
+
92
+ class SubcellRule:
93
+ """Integrals over each sub-cell of an n^3 split of the unit cube of the
94
+ pieces of the element matrices, so a cut cell's matrices are its
95
+ sub-cell indicator times these."""
96
+
97
+ def __init__(self, nu, n=SUB):
98
+ self.n = n
99
+ c = elastic_c(1.0, nu)
100
+ self.c = c
101
+ ns = n ** 3
102
+ ii = (2 * np.arange(n) + 1) / n - 1.0
103
+ self.centers = np.stack(np.meshgrid(ii, ii, ii, indexing="ij"), -1).reshape(-1, 3)
104
+ g = np.array([[a, b, cc] for a in (-_GAUSS, _GAUSS) for b in (-_GAUSS, _GAUSS) for cc in (-_GAUSS, _GAUSS)])
105
+ pts = (self.centers[:, None, :] + g[None] / n).reshape(-1, 3) # (ns*8, 3)
106
+ bu = b_nodes(pts).reshape(ns, 8, 6, 24)
107
+ ba = b_bubbles(pts).reshape(ns, 8, 6, 9)
108
+ w = 0.125 / ns # Gauss weight (det J = 1/8 on the unit cube)
109
+ cb = np.einsum("ij,sgjk->sgik", c, bu)
110
+ ca = np.einsum("ij,sgjk->sgik", c, ba)
111
+ self.S_uu = w * np.einsum("sgia,sgib->sab", bu, cb)
112
+ self.S_ua = w * np.einsum("sgia,sgib->sab", bu, ca)
113
+ self.S_aa = w * np.einsum("sgia,sgib->sab", ba, ca)
114
+ self.P_u = w * bu.sum(1)
115
+ self.P_a = w * ba.sum(1)
116
+ self.vol = np.full(ns, 1.0 / ns)
117
+
118
+ def matrices(self, ind):
119
+ """(ke (m, 24, 24), r (m, 9, 24), mbar (m, 6, 9)) at unit modulus and
120
+ unit side for cells with sub-cell indicators ind (m, n^3): r maps the
121
+ nodal displacements to the condensed bubble amplitudes, mbar is the
122
+ mean bubble strain taken off on that cell."""
123
+ ind = np.asarray(ind, float)
124
+ c = self.c
125
+ kuu = ind @ self.S_uu.reshape(len(self.vol), -1)
126
+ kuu = kuu.reshape(-1, 24, 24)
127
+ v = ind @ self.vol
128
+ pa = (ind @ self.P_a.reshape(len(self.vol), -1)).reshape(-1, 6, 9)
129
+ pu = (ind @ self.P_u.reshape(len(self.vol), -1)).reshape(-1, 6, 24)
130
+ mbar = pa / v[:, None, None]
131
+ kua = (ind @ self.S_ua.reshape(len(self.vol), -1)).reshape(-1, 24, 9) \
132
+ - np.einsum("eki,kl,elj->eij", pu, c, mbar)
133
+ kaa = ((ind @ self.S_aa.reshape(len(self.vol), -1)).reshape(-1, 9, 9)
134
+ - np.einsum("eki,kl,elj->eij", pa, c, mbar)
135
+ - np.einsum("eki,kl,elj->eij", mbar, c, pa)
136
+ + v[:, None, None] * np.einsum("eki,kl,elj->eij", mbar, c, mbar))
137
+ kaa = 0.5 * (kaa + kaa.transpose(0, 2, 1))
138
+ r = -np.linalg.solve(kaa, kua.transpose(0, 2, 1)) # (m, 9, 24)
139
+ ke = kuu + kua @ r
140
+ return 0.5 * (ke + ke.transpose(0, 2, 1)), r, mbar
141
+
142
+
143
+ class CutGrid:
144
+ """The cut-cell model of a part given as a bool grid `fine` at pitch / n
145
+ whose (0, 0, 0) corner is `origin` (each cell of the analysis grid is an
146
+ n^3 block of it). `cells` picks the analysis cells (fill >= MIN_FILL by
147
+ default). Attributes: mesh (fem.HexMesh), fill (per cell), cut (bool per
148
+ cell), ke/r/mbar for the cut cells."""
149
+
150
+ def __init__(self, fine, n, origin, pitch, nu, min_fill=MIN_FILL):
151
+ fine = np.asarray(fine, bool)
152
+ dims = np.array(fine.shape) // n
153
+ fine = fine[:dims[0] * n, :dims[1] * n, :dims[2] * n]
154
+ self.n, self.pitch, self.nu = n, float(pitch), nu
155
+ self.origin = np.asarray(origin, float)
156
+ blocks = fine.reshape(dims[0], n, dims[1], n, dims[2], n).transpose(0, 2, 4, 1, 3, 5)
157
+ blocks = blocks.reshape(dims[0], dims[1], dims[2], n ** 3)
158
+ fill = blocks.mean(axis=3)
159
+ keep = fill >= min_fill
160
+ ijk = np.argwhere(keep)
161
+ self.mesh = HexMesh(ijk, self.pitch, self.origin)
162
+ self.fill = fill[keep]
163
+ self.ind = blocks[keep]
164
+ self.cut = self.fill < 1.0 - 1e-12
165
+ self.rule = SubcellRule(nu, n)
166
+ cutidx = np.nonzero(self.cut)[0]
167
+ self.cut_index = cutidx
168
+ ke = np.empty((len(cutidx), 24, 24))
169
+ r = np.empty((len(cutidx), 9, 24))
170
+ mb = np.empty((len(cutidx), 6, 9))
171
+ for s in range(0, len(cutidx), 4000):
172
+ sl = slice(s, s + 4000)
173
+ ke[sl], r[sl], mb[sl] = self.rule.matrices(self.ind[cutidx[sl]])
174
+ self.ke_cut, self.r_cut, self.mbar_cut = ke * self.pitch, r, mb
175
+ self.ke_full, self.r_full = _hex8_parts(nu, "im")
176
+ self.ke_full = self.ke_full * self.pitch
177
+ # the true surface, as the fine grid's boundary faces (to within
178
+ # half a fine cell, pitch / 2n), for the surface stress points
179
+ hf = self.pitch / n
180
+ pad = np.pad(fine, 1)
181
+ pts = []
182
+ for ax in range(3):
183
+ d = np.diff(pad.astype(np.int8), axis=ax)
184
+ idx = np.argwhere(d != 0).astype(float)
185
+ # diff index i along ax is the face between fine cells i-1 and i,
186
+ # at i; across it the padded index j is fine cell j-1, centre j-0.5
187
+ idx[:, [a for a in range(3) if a != ax]] -= 0.5
188
+ pts.append(idx)
189
+ self._faces = self.origin + np.concatenate(pts) * hf
190
+ self._fine = fine
191
+ self._fine_h = hf
192
+ self._tree = None
193
+
194
+ def restrict(self, keep):
195
+ """The same model on the elements `keep` (bool per element), e.g.
196
+ without pieces no support holds."""
197
+ keep = np.asarray(keep, bool)
198
+ if keep.all():
199
+ return self
200
+ new = object.__new__(CutGrid)
201
+ new.__dict__.update(self.__dict__)
202
+ new.mesh = HexMesh(self.mesh.elem_ijk[keep], self.pitch, self.origin)
203
+ new.fill, new.ind, new.cut = self.fill[keep], self.ind[keep], self.cut[keep]
204
+ pos = np.full(self.mesh.n_elem, -1, np.int64)
205
+ pos[self.cut_index] = np.arange(len(self.cut_index))
206
+ old = np.nonzero(keep)[0]
207
+ k = pos[old[new.cut]]
208
+ new.cut_index = np.nonzero(new.cut)[0]
209
+ new.ke_cut, new.r_cut, new.mbar_cut = self.ke_cut[k], self.r_cut[k], self.mbar_cut[k]
210
+ return new
211
+
212
+ def solid_grid(self):
213
+ """(bool grid of the kept cells, origin) as verify.voxelize returns."""
214
+ dims = np.array(self._fine.shape) // self.n
215
+ g = np.zeros(dims, bool)
216
+ g[tuple(self.mesh.elem_ijk.T)] = True
217
+ return g, self.origin
218
+
219
+ # --- stiffness ------------------------------------------------------------
220
+
221
+ def full_moduli(self, modulus):
222
+ """Per-element moduli for the shared-matrix path: cut cells 0 (their
223
+ matrices go in separately)."""
224
+ e = np.broadcast_to(np.asarray(modulus, float), (self.mesh.n_elem,)).copy()
225
+ e[self.cut] = 0.0
226
+ return e
227
+
228
+ def cut_matrix(self, stiff, modulus):
229
+ """The cut cells' stiffness as a CSR matrix in stiff's pattern
230
+ (fixed rows and columns cleared, zero on their diagonal)."""
231
+ mod = np.broadcast_to(np.asarray(modulus, float), (self.mesh.n_elem,))[self.cut_index]
232
+ data = np.zeros(stiff.nnz)
233
+ for s in range(0, len(self.cut_index), 20000):
234
+ sl = slice(s, s + 20000)
235
+ stiff.add_elements(data, self.ke_cut[sl] * mod[sl, None, None], self.cut_index[sl])
236
+ return stiff.finish(data, diag=0.0)
237
+
238
+ def assemble(self, stiff: Stiffness, modulus):
239
+ """The assembled K of the cut model (stiff: fem.Stiffness on self.mesh)."""
240
+ return (stiff.assemble(self.full_moduli(modulus)) + self.cut_matrix(stiff, modulus)).tocsr()
241
+
242
+ def cut_operator(self, modulus):
243
+ """The cut cells' stiffness as an unconstrained sparse matrix in mesh
244
+ dof numbering (mg.GridElasticity's `springs`)."""
245
+ m = self.mesh
246
+ mod = np.broadcast_to(np.asarray(modulus, float), (m.n_elem,))[self.cut_index]
247
+ edof = m.edof[self.cut_index]
248
+ rows = np.repeat(edof, 24, axis=1).ravel()
249
+ cols = np.tile(edof, (1, 24)).ravel()
250
+ vals = (self.ke_cut * mod[:, None, None]).ravel()
251
+ return sp.csr_matrix((vals, (rows, cols)), shape=(m.n_dof, m.n_dof))
252
+
253
+ # --- stress ---------------------------------------------------------------
254
+
255
+ def _element_stress(self, elems, xi, u, modulus, thermal=None):
256
+ """Stress (p, 6) of elements elems at natural points xi (p, 3)."""
257
+ m = self.mesh
258
+ c = elastic_c(1.0, self.nu)
259
+ out = np.empty((len(elems), 6))
260
+ pos = np.full(m.n_elem, -1, np.int64)
261
+ pos[self.cut_index] = np.arange(len(self.cut_index))
262
+ mod = np.broadcast_to(np.asarray(modulus, float), (m.n_elem,))
263
+ for s in range(0, len(elems), 50000):
264
+ sl = slice(s, s + 50000)
265
+ e = elems[sl]
266
+ ue = u[m.edof[e]]
267
+ eps = np.einsum("pij,pj->pi", b_nodes(xi[sl]), ue)
268
+ ba = b_bubbles(xi[sl])
269
+ k = pos[e]
270
+ isc = k >= 0
271
+ r = np.broadcast_to(self.r_full, (len(e), 9, 24)).copy()
272
+ r[isc] = self.r_cut[k[isc]]
273
+ if isc.any():
274
+ ba[isc] = ba[isc] - self.mbar_cut[k[isc]]
275
+ alpha = np.einsum("pij,pj->pi", r, ue)
276
+ eps = (eps + np.einsum("pij,pj->pi", ba, alpha)) / self.pitch
277
+ if thermal is not None:
278
+ eps[:, :3] -= np.asarray(thermal, float)[e][:, None]
279
+ out[sl] = (eps @ c.T) * mod[e][:, None]
280
+ return out
281
+
282
+ def _alpha(self, ue):
283
+ """Condensed bubble amplitudes (e, 9) for element displacements (e, 24)."""
284
+ alpha = ue @ self.r_full.T
285
+ if len(self.cut_index):
286
+ alpha[self.cut_index] = np.einsum("pij,pj->pi", self.r_cut, ue[self.cut_index])
287
+ return alpha
288
+
289
+ def _samples(self, near, u, modulus, thermal=None):
290
+ """Stress at the solid sample points of the elements in `near` (bool
291
+ per element): a full cell's eight Gauss points, a cut cell's solid
292
+ sub-cell centres. Returns (xyz, weight, stress)."""
293
+ m = self.mesh
294
+ c = elastic_c(1.0, self.nu)
295
+ mod = np.broadcast_to(np.asarray(modulus, float), (m.n_elem,))
296
+ th = None if thermal is None else np.asarray(thermal, float)
297
+ pos = np.full(m.n_elem, -1, np.int64)
298
+ pos[self.cut_index] = np.arange(len(self.cut_index))
299
+ g = np.array([[a, b, cc] for a in (-_GAUSS, _GAUSS) for b in (-_GAUSS, _GAUSS) for cc in (-_GAUSS, _GAUSS)])
300
+ xyz, w, sig = [], [], []
301
+
302
+ def put(el, xi_pt, weight):
303
+ ue = u[m.edof[el]]
304
+ alpha = ue @ self.r_full.T
305
+ k = pos[el]
306
+ isc = k >= 0
307
+ if isc.any():
308
+ alpha[isc] = np.einsum("pij,pj->pi", self.r_cut[k[isc]], ue[isc])
309
+ bu, ba = b_nodes(xi_pt[None])[0], b_bubbles(xi_pt[None])[0]
310
+ eps = ue @ bu.T + alpha @ ba.T
311
+ if isc.any():
312
+ eps[isc] -= np.einsum("pij,pj->pi", self.mbar_cut[k[isc]], alpha[isc])
313
+ eps /= self.pitch
314
+ if th is not None:
315
+ eps[:, :3] -= th[el][:, None]
316
+ sig.append((eps @ c.T) * mod[el][:, None])
317
+ xyz.append(m.origin + (m.elem_ijk[el] + 0.5 * (xi_pt + 1.0)) * self.pitch)
318
+ w.append(np.full(len(el), weight))
319
+
320
+ full = np.nonzero(near & ~self.cut)[0]
321
+ if len(full):
322
+ for q in g:
323
+ put(full, q, 1.0 / 8)
324
+ cut = np.nonzero(near & self.cut)[0]
325
+ if len(cut):
326
+ ind = self.ind[cut]
327
+ for sidx, q in enumerate(self.rule.centers):
328
+ el = cut[ind[:, sidx]]
329
+ if len(el):
330
+ put(el, q, 1.0 / self.n ** 3)
331
+ if not xyz:
332
+ return np.zeros((0, 3)), np.zeros(0), np.zeros((0, 6))
333
+ return np.concatenate(xyz), np.concatenate(w), np.concatenate(sig)
334
+
335
+ def surface_points(self, xyz, k=8):
336
+ """Points on the true surface nearest each of xyz (the mean of the k
337
+ nearest boundary faces of the fine grid, which smooths its
338
+ staircase) and the signed distance to them (mm, negative inside)."""
339
+ from scipy.spatial import cKDTree
340
+
341
+ if self._tree is None:
342
+ self._tree = cKDTree(self._faces)
343
+ xyz = np.asarray(xyz, float)
344
+ dist, idx = self._tree.query(xyz, k=k)
345
+ p = self._faces[idx].mean(axis=1)
346
+ ijk = np.floor((xyz - self.origin) / self._fine_h).astype(np.int64)
347
+ ok = np.all((ijk >= 0) & (ijk < np.array(self._fine.shape)), axis=1)
348
+ inside = np.zeros(len(xyz), bool)
349
+ inside[ok] = self._fine[tuple(ijk[ok].T)]
350
+ d = np.linalg.norm(xyz - p, axis=1)
351
+ return p, np.where(inside, -d, d)
352
+
353
+ def nodal_stress(self, u, modulus, thermal=None):
354
+ """Stress per node (n_nodes, 6). Nodes within NEAR_SURFACE cells of
355
+ the true surface read the surface fit at their closest surface
356
+ point; the rest the fill-weighted average of their elements' corner
357
+ stresses (verify.nodal_stress's recovery)."""
358
+ from scipy.spatial import cKDTree
359
+
360
+ m = self.mesh
361
+ c = elastic_c(1.0, self.nu)
362
+ mod = np.broadcast_to(np.asarray(modulus, float), (m.n_elem,))
363
+ ue = u[m.edof] # (e, 24)
364
+ alpha = ue @ self.r_full.T # (e, 9) bubble amplitudes
365
+ if len(self.cut_index):
366
+ alpha[self.cut_index] = np.einsum("pij,pj->pi", self.r_cut, ue[self.cut_index])
367
+ acc = np.zeros((m.n_nodes, 6))
368
+ wsum = np.bincount(m.enode.ravel(), weights=np.repeat(self.fill, 8), minlength=m.n_nodes)
369
+ corners = 2.0 * CORNERS - 1.0
370
+ bu, ba = b_nodes(corners), b_bubbles(corners)
371
+ for k in range(8):
372
+ eps = ue @ bu[k].T + alpha @ ba[k].T
373
+ if len(self.cut_index):
374
+ eps[self.cut_index] -= np.einsum("pij,pj->pi", self.mbar_cut, alpha[self.cut_index])
375
+ eps /= self.pitch
376
+ if thermal is not None:
377
+ eps[:, :3] -= np.asarray(thermal, float)[:, None]
378
+ sk = (eps @ c.T) * (mod * self.fill)[:, None]
379
+ for j in range(6):
380
+ acc[:, j] += np.bincount(m.enode[:, k], weights=sk[:, j], minlength=m.n_nodes)
381
+ out = acc / np.maximum(wsum, 1e-30)[:, None]
382
+ # nodes on cut cells, or touching the grid's empty neighbours
383
+ on_cut = np.zeros(m.n_nodes, bool)
384
+ on_cut[m.enode[self.cut].ravel()] = True
385
+ cnt = np.bincount(m.enode.ravel(), minlength=m.n_nodes)
386
+ on_cut |= cnt < 8
387
+ cand = np.nonzero(on_cut)[0]
388
+ if not len(cand):
389
+ return out
390
+ p, d = self.surface_points(m.node_xyz[cand])
391
+ sel = d >= -NEAR_SURFACE * self.pitch # every node outside, and those just inside
392
+ cand, p = cand[sel], p[sel]
393
+ if not len(cand):
394
+ return out
395
+ # samples from elements within two cells of a surface node
396
+ near_node = np.zeros(m.n_nodes, bool)
397
+ near_node[cand] = True
398
+ near_el = near_node[m.enode].any(axis=1)
399
+ for _ in range(2):
400
+ nn = np.zeros(m.n_nodes, bool)
401
+ nn[m.enode[near_el].ravel()] = True
402
+ near_el = nn[m.enode].any(axis=1)
403
+ xyz, w, sig = self._samples(near_el, u, modulus, thermal)
404
+ tree = cKDTree(xyz)
405
+ k = min(FIT_POINTS, len(xyz))
406
+ dist, idx = tree.query(p, k=k)
407
+ out[cand] = _fit_linear(p, xyz, sig, w, dist, idx, self.pitch)
408
+ return out
409
+
410
+
411
+ def _fit_linear(p, xyz, sig, w, dist, idx, h):
412
+ """Weighted least-squares linear field over each point's neighbours,
413
+ evaluated at the point: (p, 6)."""
414
+ out = np.empty((len(p), 6))
415
+ for s in range(0, len(p), 20000):
416
+ sl = slice(s, s + 20000)
417
+ d = (xyz[idx[sl]] - p[sl, None, :]) / h # (q, k, 3)
418
+ # Gaussian-ish taper so the fit is local, and the sample's volume
419
+ ww = w[idx[sl]] * np.exp(-(dist[sl] / (FIT_TAPER * h)) ** 2)
420
+ a = np.concatenate([np.ones(d.shape[:2] + (1,)), d], axis=2) # (q, k, 4)
421
+ aw = a * ww[..., None]
422
+ ata = np.einsum("qki,qkj->qij", aw, a) + 1e-9 * np.eye(4)
423
+ atb = np.einsum("qki,qkc->qic", aw, sig[idx[sl]])
424
+ coef = np.linalg.solve(ata, atb)
425
+ out[sl] = coef[:, 0, :]
426
+ return out
427
+
428
+
429
+ def scan_voxelize(mesh, pitch, origin, dims, chunk=200_000):
430
+ """Solid cells (bool, dims) of a closed mesh on the grid at `origin`,
431
+ cell centres inside by crossing parity along +z, as verify.voxelize
432
+ decides them, but found by rasterizing each triangle onto the cell
433
+ columns it covers instead of casting a ray per column: the sub-cell grid
434
+ has 16x the columns, and per-ray queries took 20 s of a 100 s verify."""
435
+ tri = np.asarray(mesh.triangles, float)
436
+ dims = np.asarray(dims, int)
437
+ jit = pitch * 1e-4
438
+ # column centres, with verify.voxelize's jitter off exact edges
439
+ ox = origin[0] + 0.5 * pitch + 0.37 * jit
440
+ oy = origin[1] + 0.5 * pitch + 0.61 * jit
441
+ i0 = np.clip(np.ceil((tri[:, :, 0].min(1) - ox) / pitch), 0, dims[0]).astype(np.int64)
442
+ i1 = np.clip(np.floor((tri[:, :, 0].max(1) - ox) / pitch) + 1, 0, dims[0]).astype(np.int64)
443
+ j0 = np.clip(np.ceil((tri[:, :, 1].min(1) - oy) / pitch), 0, dims[1]).astype(np.int64)
444
+ j1 = np.clip(np.floor((tri[:, :, 1].max(1) - oy) / pitch) + 1, 0, dims[1]).astype(np.int64)
445
+ ni, nj = np.maximum(i1 - i0, 0), np.maximum(j1 - j0, 0)
446
+ cnt = ni * nj
447
+ cols, zs = [], []
448
+ t_all = np.nonzero(cnt)[0]
449
+ start = 0
450
+ while start < len(t_all):
451
+ # triangles whose candidate columns fit in one chunk
452
+ cs = np.cumsum(cnt[t_all[start:]])
453
+ stop = start + max(1, int(np.searchsorted(cs, chunk, side="right")))
454
+ t = t_all[start:stop]
455
+ start = stop
456
+ c = cnt[t]
457
+ tt = np.repeat(t, c)
458
+ k = np.arange(c.sum()) - np.repeat(np.cumsum(c) - c, c)
459
+ ii = i0[tt] + k // nj[tt]
460
+ jj = j0[tt] + k % nj[tt]
461
+ px, py = ox + ii * pitch, oy + jj * pitch
462
+ a, b, cc = tri[tt, 0], tri[tt, 1], tri[tt, 2]
463
+ # 2D barycentrics of the column in the triangle's xy projection
464
+ d = (b[:, 0] - a[:, 0]) * (cc[:, 1] - a[:, 1]) - (cc[:, 0] - a[:, 0]) * (b[:, 1] - a[:, 1])
465
+ ok = np.abs(d) > 1e-18
466
+ dd = np.where(ok, d, 1.0)
467
+ l1 = ((px - a[:, 0]) * (cc[:, 1] - a[:, 1]) - (cc[:, 0] - a[:, 0]) * (py - a[:, 1])) / dd
468
+ l2 = ((b[:, 0] - a[:, 0]) * (py - a[:, 1]) - (px - a[:, 0]) * (b[:, 1] - a[:, 1])) / dd
469
+ l0 = 1.0 - l1 - l2
470
+ hit = ok & (l0 >= 0) & (l1 >= 0) & (l2 >= 0)
471
+ z = l0 * a[:, 2] + l1 * b[:, 2] + l2 * cc[:, 2]
472
+ cols.append((ii * dims[1] + jj)[hit])
473
+ zs.append(z[hit])
474
+ solid = np.zeros(dims, bool)
475
+ if not cols:
476
+ return solid
477
+ col = np.concatenate(cols)
478
+ z = np.concatenate(zs)
479
+ order = np.lexsort((z, col))
480
+ col, z = col[order], z[order]
481
+ # merge near-duplicate hits in a column (a shared edge counted twice)
482
+ dup = np.zeros(len(z), bool)
483
+ dup[1:] = (col[1:] == col[:-1]) & (np.diff(z) <= 1e-7)
484
+ col, z = col[~dup], z[~dup]
485
+ first = np.r_[True, col[1:] != col[:-1]]
486
+ run = np.cumsum(first) - 1
487
+ starts = np.nonzero(first)[0]
488
+ rank = np.arange(len(col)) - starts[run]
489
+ n_in = np.bincount(run)
490
+ # an odd count drops its last hit (verify.voxelize does the same)
491
+ keep = rank < (n_in[run] // 2) * 2
492
+ col, z, rank = col[keep], z[keep], rank[keep]
493
+ za, zb, cl = z[rank % 2 == 0], z[rank % 2 == 1], col[rank % 2 == 0]
494
+ zc0 = origin[2] + 0.5 * pitch
495
+ ka = np.clip(np.floor((za - zc0) / pitch) + 1, 0, dims[2]).astype(np.int64) # first centre > za
496
+ kb = np.clip(np.ceil((zb - zc0) / pitch), 0, dims[2]).astype(np.int64) # first centre >= zb
497
+ diff = np.zeros((dims[0] * dims[1], dims[2] + 1), np.int32)
498
+ np.add.at(diff, (cl, ka), 1)
499
+ np.add.at(diff, (cl, kb), -1)
500
+ return (np.cumsum(diff, axis=1)[:, :-1] > 0).reshape(dims)
501
+
502
+
503
+ def cut_from_mesh(mesh, pitch, nu, box=None, n=SUB, min_fill=MIN_FILL):
504
+ """CutGrid of a closed trimesh on verify.voxelize's grid at `pitch` (the
505
+ same origin and cells), its sub-cells from voxelizing at pitch / n."""
506
+ lo, hi = (np.asarray(b, float) for b in box) if box is not None else np.asarray(mesh.bounds, float)
507
+ dims = np.maximum(np.ceil((hi - lo) / pitch - 1e-6).astype(int), 1)
508
+ origin = lo - 0.5 * (dims * pitch - (hi - lo))
509
+ fine = scan_voxelize(mesh, pitch / n, origin, dims * n)
510
+ return CutGrid(fine, n, origin, pitch, nu, min_fill=min_fill)