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/narrowband.py ADDED
@@ -0,0 +1,418 @@
1
+ """Narrow-band sampling of an implicit field for meshing.
2
+
3
+ A part defined by a field f(P) < 0 only needs f near its surface: deep
4
+ inside or far outside, marching cubes finds nothing. `narrowband_mc` cuts
5
+ the meshing grid into blocks of `block` cells, samples f once per block
6
+ (at its centre) and skips every block whose centre is further from the
7
+ zero level than the field can change across the block (Lipschitz bound
8
+ `L` times the half-diagonal). Only the remaining blocks are sampled at
9
+ full resolution and marched, one block at a time.
10
+
11
+ The surviving blocks share their boundary samples with their neighbours,
12
+ and marching cubes puts a vertex on an edge from that edge's two samples
13
+ alone, so the blocks' meshes join exactly: after merging the shared
14
+ vertices the result is the same mesh dense marching cubes gives on the
15
+ whole grid (tests/test_narrowband.py checks this), at a fraction of the
16
+ field evaluations and with memory set by one batch of blocks, not by the
17
+ grid. That is what lets a field go to a finer pitch than a dense grid
18
+ fits in memory.
19
+
20
+ The skip test is only as safe as `L`. For a distance field (shapes,
21
+ booleans, shells, offsets) L = 1; smooth unions, remaps of a distance and
22
+ plain TPMS level sets in fields.py stay close to it, but a warp-graded
23
+ lattice reaches 30 or more. `lipschitz=None` estimates L as the steepest
24
+ gradient at 20k random points or between neighbouring block centres,
25
+ times `margin`, and never below 1. A bound that is still too low leaves
26
+ a crossing in a skipped leaf, which shows as a hole where it meets a
27
+ sampled one: fields.field_mesh checks the mesh is watertight and, if it
28
+ is not, marches again with every block sampled.
29
+ """
30
+ from __future__ import annotations
31
+
32
+ import math
33
+ import os
34
+ from concurrent.futures import ThreadPoolExecutor
35
+
36
+ import numpy as np
37
+
38
+ __all__ = ["narrowband_mc", "march_blocks", "peval", "watertight", "signed_volume", "shell_volumes", "face_cross"]
39
+
40
+ _BATCH = 2_000_000 # field samples per call
41
+ _CHUNK = 250_000 # field samples per thread task
42
+
43
+
44
+ def _threads():
45
+ env = os.environ.get("LOADPATH_THREADS")
46
+ if env:
47
+ return max(1, int(env))
48
+ try:
49
+ return max(1, len(os.sched_getaffinity(0)))
50
+ except AttributeError:
51
+ return max(1, os.cpu_count() or 1)
52
+
53
+
54
+ _POOL = None
55
+
56
+
57
+ def peval(field, P, chunk=_CHUNK):
58
+ """field(P) as float64, in chunks spread over threads. The fields are
59
+ numpy expressions, which release the GIL, so 4 cores evaluate about
60
+ 3.7x faster, and every chunk is the same computation, so the result is
61
+ bit for bit the serial one. The first chunk runs alone, so a field that
62
+ builds a cached resource on first use (a warp, a mesh's distance grid)
63
+ builds it once. LOADPATH_THREADS=1 turns this off. A field with a
64
+ `batch` attribute (the torch evaluator, which threads itself or runs
65
+ on a GPU) is called serially in chunks of that many points instead."""
66
+ global _POOL
67
+ n = len(P)
68
+ out = np.empty(n)
69
+ nt = _threads()
70
+ chunk = getattr(field, "chunk", None) or chunk
71
+ batch = getattr(field, "batch", None)
72
+ if batch:
73
+ nt, chunk = 1, batch
74
+ if nt == 1 or n <= 2 * chunk:
75
+ for i in range(0, n, chunk):
76
+ out[i:i + chunk] = np.asarray(field(P[i:i + chunk]), float)
77
+ return out
78
+ out[:chunk] = np.asarray(field(P[:chunk]), float)
79
+ if _POOL is None or _POOL._max_workers != nt:
80
+ _POOL = ThreadPoolExecutor(nt, thread_name_prefix="loadpath-field")
81
+
82
+ def one(i):
83
+ out[i:i + chunk] = np.asarray(field(P[i:i + chunk]), float)
84
+
85
+ for _ in _POOL.map(one, range(chunk, n, chunk)):
86
+ pass
87
+ return out
88
+
89
+
90
+ def pblock(fn, P, rows, chunk=_CHUNK):
91
+ """fn(P) for a function giving (rows, len(P)) blocks (a crease tree's
92
+ leaf values), in chunks spread over threads as peval does: the first
93
+ chunk alone, then the rest on the pool."""
94
+ global _POOL
95
+ n = len(P)
96
+ out = np.empty((rows, n))
97
+ nt = _threads()
98
+ if nt == 1 or n <= 2 * chunk:
99
+ for i in range(0, n, chunk):
100
+ out[:, i:i + chunk] = fn(P[i:i + chunk])
101
+ return out
102
+ out[:, :chunk] = fn(P[:chunk])
103
+ if _POOL is None or _POOL._max_workers != nt:
104
+ _POOL = ThreadPoolExecutor(nt, thread_name_prefix="loadpath-field")
105
+
106
+ def one(i):
107
+ out[:, i:i + chunk] = fn(P[i:i + chunk])
108
+
109
+ for _ in _POOL.map(one, range(chunk, n, chunk)):
110
+ pass
111
+ return out
112
+
113
+
114
+ def _grid_points(lo, pitch, i0, n):
115
+ """Points of the sub-grid starting at index i0 (3,) with n (3,) samples."""
116
+ ax = [lo[k] + (i0[k] + np.arange(n[k])) * pitch for k in range(3)]
117
+ X, Y, Z = np.meshgrid(*ax, indexing="ij")
118
+ return np.stack([X.ravel(), Y.ravel(), Z.ravel()], axis=1)
119
+
120
+
121
+ def _eval(field, P):
122
+ # grid samples (kept as float32 for marching cubes): lattice trig in
123
+ # single precision
124
+ from .lattice import single_precision
125
+
126
+ with single_precision():
127
+ return peval(field, P)
128
+
129
+
130
+ def _lipschitz(field, lo, hi, pitch, n=20_000):
131
+ """The steepest gradient seen at n random points (central differences)."""
132
+ P = np.random.default_rng(0).uniform(lo, hi, size=(n, 3))
133
+ h = 0.05 * pitch
134
+ E = np.eye(3) * h
135
+ f = _eval(field, np.concatenate([P + e for e in E] + [P - e for e in E])).reshape(6, n)
136
+ g = (f[:3] - f[3:]) / (2 * h)
137
+ return float(np.sqrt((g * g).sum(axis=0)).max())
138
+
139
+
140
+ _LOCAL = True
141
+ _LOCAL_MARGIN = 3.0 # local slopes come from differences a leaf apart, which smooth over a steep spot
142
+
143
+
144
+ def _local_lipschitz(fl, nl, step, margin, floor):
145
+ """A Lipschitz bound per leaf (blocks, nl**3): the steepest difference
146
+ between its leaf centre and the next one along each axis (inside its
147
+ block), over the spacing, times `margin`, never below `floor`. The global
148
+ bound is the steepest slope anywhere, and a TPMS sheet's distance
149
+ (value over gradient) climbs steeply round the gradient's zeros, deep
150
+ in the channels, far from any wall: there it is 20-30, which keeps
151
+ every leaf of a lattice. Next to a wall the slope is ~1-3, and the
152
+ field varies over a cell (millimetres), not a leaf, so the local slope
153
+ is the one that matters for whether the leaf reaches the surface."""
154
+ F = fl.reshape(-1, nl, nl, nl)
155
+ G = np.zeros_like(F)
156
+ with np.errstate(invalid="ignore"):
157
+ for ax in range(1, 4):
158
+ d = np.abs(np.diff(F, axis=ax))
159
+ d = np.where(np.isnan(d), 0.0, d) # two empty leaves (inf - inf)
160
+ lo = [slice(None)] * 4
161
+ hi = [slice(None)] * 4
162
+ lo[ax] = slice(0, -1)
163
+ hi[ax] = slice(1, None)
164
+ G[tuple(lo)] = np.maximum(G[tuple(lo)], d)
165
+ G[tuple(hi)] = np.maximum(G[tuple(hi)], d)
166
+ return np.maximum(floor, margin * G / step).reshape(fl.shape)
167
+
168
+
169
+ def narrowband_mc(field, lo, dims, pitch, block=32, leaf=4, lipschitz=None, margin=1.5, budget=None,
170
+ progress=None):
171
+ """Marching cubes of field < 0 on the grid lo + i * pitch, i < dims,
172
+ sampling only near the zero level. The grid's outer layer is forced
173
+ outside (+pitch), as fields.field_mesh does, so the mesh is closed.
174
+
175
+ Two levels: blocks of `block` cells are tested at their centres; in
176
+ the blocks that pass, leaves of `leaf` cells are tested the same way,
177
+ and only the samples of the leaves that pass are evaluated. A sample
178
+ of a block that no passing leaf touches gets the sign of its leaf at
179
+ a large magnitude: with the Lipschitz bound holding, every edge the
180
+ surface crosses has both its samples evaluated, so the block's mesh is
181
+ the dense one. Each block is marched on its own and the shared
182
+ vertices merged. Raises ValueError, before the fine pass, when the
183
+ passing leaves need more than `budget` samples. Returns (verts (mm),
184
+ faces, stats)."""
185
+ stats = {}
186
+ verts, faces, nv = [], [], 0
187
+ for i0, n, v, fa in march_blocks(field, lo, dims, pitch, block, leaf, lipschitz, margin, budget, progress,
188
+ stats):
189
+ verts.append(v)
190
+ faces.append(fa + nv)
191
+ nv += len(v)
192
+ if not verts:
193
+ return np.zeros((0, 3)), np.zeros((0, 3), int), stats
194
+ V, Fc = merge_blocks(np.concatenate(verts), np.concatenate(faces), block)
195
+ return np.asarray(lo, float) + V * pitch, Fc, stats
196
+
197
+
198
+ def merge_blocks(V, F, block):
199
+ """Merge the vertices the blocks share. A vertex on a block's face comes
200
+ out of both blocks bit for bit (same edge, same two samples) and every
201
+ other vertex is unique already, so only the ones on a block plane (a
202
+ coordinate that is a multiple of `block`) go through the (slow) row
203
+ unique. Drops the faces the merge collapses."""
204
+ on = (np.mod(V, block) == 0).any(axis=1)
205
+ idx = np.flatnonzero(on)
206
+ remap = np.arange(len(V))
207
+ if len(idx):
208
+ _, first, inv = np.unique(V[idx], axis=0, return_index=True, return_inverse=True)
209
+ remap[idx] = idx[first][inv.reshape(-1)]
210
+ keep_v = remap == np.arange(len(V))
211
+ new = np.cumsum(keep_v) - 1
212
+ F = new[remap[F]]
213
+ V = V[keep_v]
214
+ ok = (F[:, 0] != F[:, 1]) & (F[:, 1] != F[:, 2]) & (F[:, 0] != F[:, 2])
215
+ return V, F[ok]
216
+
217
+
218
+ def march_blocks(field, lo, dims, pitch, block=32, leaf=4, lipschitz=None, margin=1.5, budget=None,
219
+ progress=None, stats=None):
220
+ """The narrow-band blocks of narrowband_mc, marched one at a time, in
221
+ x-major order (all the blocks of one x slab before the next): yields
222
+ (i0, n, verts, faces) per block that the surface crosses, verts in grid
223
+ index units (add lo, times pitch, for mm). Fills `stats` once the
224
+ blocks are planned. The field is evaluated in batches of blocks; memory
225
+ is one batch, whatever the grid."""
226
+ from skimage.measure import marching_cubes
227
+
228
+ lo = np.asarray(lo, float)
229
+ dims = np.asarray(dims, int)
230
+ B, lf = int(block), int(leaf)
231
+ if B % lf:
232
+ raise ValueError("block must be a multiple of leaf")
233
+ ncells = dims - 1
234
+ nb = np.maximum(1, np.ceil(ncells / B).astype(int))
235
+
236
+ def centres(i0, i1):
237
+ """Centres (mm) and half-diagonals of the boxes [i0, i1) in cells."""
238
+ return lo + 0.5 * (i0 + i1) * pitch, 0.5 * np.linalg.norm((i1 - i0) * pitch, axis=-1)
239
+
240
+ bi = np.stack(np.meshgrid(*[np.arange(n) for n in nb], indexing="ij"), -1).reshape(-1, 3)
241
+ b0 = bi * B
242
+ b1 = np.minimum(b0 + B, ncells)
243
+ C, half = centres(b0, b1)
244
+ fc = _eval(field, C)
245
+ if lipschitz is None:
246
+ L = _lipschitz(field, lo, lo + ncells * pitch, pitch)
247
+ F3 = fc.reshape(tuple(nb))
248
+ for k in range(3):
249
+ if nb[k] > 1:
250
+ L = max(L, float(np.max(np.abs(np.diff(F3, axis=k)))) / (B * pitch))
251
+ L = max(1.0, L * margin)
252
+ else:
253
+ L = float(lipschitz)
254
+ on_border = (b0 == 0).any(axis=1) | (b1 == ncells).any(axis=1)
255
+ act = (np.abs(fc) <= L * half + pitch) | (on_border & (fc < 0))
256
+ bi, b0, b1 = bi[act], b0[act], b1[act]
257
+
258
+ # leaves of the passing blocks
259
+ nl = B // lf
260
+ li = np.stack(np.meshgrid(*[np.arange(nl)] * 3, indexing="ij"), -1).reshape(-1, 3)
261
+ l0 = (b0[:, None, :] + li[None] * lf)
262
+ l1 = np.minimum(l0 + lf, b1[:, None, :])
263
+ real = (l0 < b1[:, None, :]).all(axis=2) # leaves past a short block's end are empty
264
+ Cl, hl = centres(l0.reshape(-1, 3), l1.reshape(-1, 3))
265
+ fl = np.full(len(Cl), np.inf)
266
+ rf = real.ravel()
267
+ fl[rf] = _eval(field, Cl[rf])
268
+ fl = fl.reshape(len(b0), -1)
269
+ Ll = L
270
+ if lipschitz is None and _LOCAL:
271
+ Ll = np.minimum(L, _local_lipschitz(fl, nl, lf * pitch, _LOCAL_MARGIN, max(1.0, margin)))
272
+ lon = ((l0 == 0) | (l1 == ncells)).any(axis=2)
273
+ lact = real & ((np.abs(fl) <= Ll * hl.reshape(len(b0), -1) + pitch) | (lon & (fl < 0)))
274
+ keep = lact.any(axis=1)
275
+ b0, b1, fl, lact = b0[keep], b1[keep], fl[keep], lact[keep]
276
+
277
+ def mask(k):
278
+ """The samples block k needs: every sample of a passing leaf."""
279
+ n = b1[k] - b0[k] + 1
280
+ # a leaf's samples run from its first corner to one past its last
281
+ # cell: its cells, grown by one sample on the high side of each axis
282
+ c = np.zeros((nl * lf + 1,) * 3, bool)
283
+ c[:-1, :-1, :-1] = np.repeat(np.repeat(np.repeat(lact[k].reshape(nl, nl, nl), lf, 0), lf, 1), lf, 2)
284
+ m = c.copy()
285
+ m[1:] |= c[:-1]
286
+ m[:, 1:] |= m[:, :-1].copy()
287
+ m[:, :, 1:] |= m[:, :, :-1].copy()
288
+ return m[:n[0], :n[1], :n[2]]
289
+
290
+ fine = None
291
+ if budget is not None:
292
+ fine = sum(int(mask(k).sum()) for k in range(len(b0)))
293
+ if fine > budget:
294
+ raise ValueError(f"the surface needs {fine / 1e6:.1f}M samples at pitch {pitch:g}, over the "
295
+ f"{budget / 1e6:.0f}M budget: use a coarser pitch")
296
+ if stats is None:
297
+ stats = {}
298
+ stats.update({"blocks": int(np.prod(nb)), "activeBlocks": int(len(b0)), "leaves": int(lact.sum()),
299
+ "lipschitz": round(float(L), 3) if np.isfinite(L) else None,
300
+ "samples": int(len(C) + rf.sum()), "denseSamples": int(np.prod(dims))})
301
+
302
+ big = np.float32(1e3 * pitch)
303
+ k = 0
304
+ while k < len(b0):
305
+ # a batch of blocks, one field call
306
+ group, P, cnt = [], [], 0
307
+ while k < len(b0) and (not group or cnt < _BATCH):
308
+ n = b1[k] - b0[k] + 1
309
+ ii = np.argwhere(mask(k))
310
+ P.append(lo + (b0[k] + ii) * pitch)
311
+ group.append((k, n, ii))
312
+ cnt += len(ii)
313
+ k += 1
314
+ F = _eval(field, np.concatenate(P)).astype(np.float32)
315
+ del P
316
+ stats["samples"] += len(F)
317
+ o = 0
318
+ for kk, n, ii in group:
319
+ # unevaluated samples: their leaf's sign, far from zero
320
+ lix = np.minimum(np.indices(tuple(n)).reshape(3, -1).T // lf, nl - 1)
321
+ sgn = np.sign(fl[kk][(lix[:, 0] * nl + lix[:, 1]) * nl + lix[:, 2]])
322
+ f = (sgn * big).astype(np.float32).reshape(tuple(n))
323
+ f[tuple(ii.T)] = F[o:o + len(ii)]
324
+ o += len(ii)
325
+ i0 = b0[kk]
326
+ for ax in range(3):
327
+ if i0[ax] == 0:
328
+ f[(slice(None),) * ax + (0,)] = pitch
329
+ if i0[ax] + n[ax] == dims[ax]:
330
+ f[(slice(None),) * ax + (-1,)] = pitch
331
+ f[f == 0.0] = -1e-4 * pitch
332
+ if f.min() >= 0 or f.max() <= 0:
333
+ continue
334
+ v, fa, _, _ = marching_cubes(f, 0.0)
335
+ yield i0, n, v.astype(np.float64) + i0, fa
336
+ if progress:
337
+ progress({"stage": "field", "done": int(k), "of": int(len(b0))})
338
+
339
+
340
+ # ---- fast bookkeeping on big triangle meshes (trimesh's takes tens of seconds) ----
341
+
342
+ def watertight(F, nv=None):
343
+ """Every edge shared by exactly two faces (trimesh's is_watertight)."""
344
+ F = np.asarray(F, np.int64)
345
+ if not len(F):
346
+ return False
347
+ nv = int(F.max()) + 1 if nv is None else int(nv)
348
+ a = F.ravel()
349
+ b = np.roll(F, -1, axis=1).ravel()
350
+ e = np.sort(np.minimum(a, b) * nv + np.maximum(a, b))
351
+ if len(e) % 2:
352
+ return False
353
+ if not np.array_equal(e[0::2], e[1::2]):
354
+ return False
355
+ return bool(len(e) < 4 or not np.any(e[1:-1:2] == e[2::2]))
356
+
357
+
358
+ def face_cross(V, F):
359
+ """(b - a) x (c - a) for every face (a, b, c): twice its area along its
360
+ normal (compiled, meshops.py; else column by column in numpy)."""
361
+ from .meshops import face_cross as fc
362
+
363
+ return fc(V, F)
364
+
365
+
366
+ def _face_cross_np(V, F):
367
+ # column by column, ~3x numpy's cross on (n, 3) rows
368
+ VT = np.ascontiguousarray(np.asarray(V, float).T)
369
+ a = VT[:, F[:, 0]]
370
+ b = VT[:, F[:, 1]]
371
+ c = VT[:, F[:, 2]]
372
+ b -= a
373
+ c -= a
374
+ out = np.empty((len(F), 3))
375
+ out[:, 0] = b[1] * c[2] - b[2] * c[1]
376
+ out[:, 1] = b[2] * c[0] - b[0] * c[2]
377
+ out[:, 2] = b[0] * c[1] - b[1] * c[0]
378
+ return out
379
+
380
+
381
+ def _triple(V, F):
382
+ """a . (b x c) per face (six times its signed volume from the origin)."""
383
+ from .meshops import triple
384
+
385
+ return triple(V, F)
386
+
387
+
388
+ def _triple_np(V, F):
389
+ VT = np.ascontiguousarray(np.asarray(V, float).T)
390
+ a = VT[:, F[:, 0]]
391
+ b = VT[:, F[:, 1]]
392
+ c = VT[:, F[:, 2]]
393
+ return a[0] * (b[1] * c[2] - b[2] * c[1]) + a[1] * (b[2] * c[0] - b[0] * c[2]) + a[2] * (b[0] * c[1] - b[1] * c[0])
394
+
395
+
396
+ def signed_volume(V, F):
397
+ """Enclosed volume (divergence theorem), positive with outward normals."""
398
+ return float(_triple(V, F).sum() / 6.0)
399
+
400
+
401
+ def shell_volumes(V, F):
402
+ """(label per face, signed volume per connected shell): shells joined
403
+ through shared vertices. A closed body's outside is positive, the wall
404
+ of a void sealed inside it negative."""
405
+ import scipy.sparse as sp
406
+ from scipy.sparse.csgraph import connected_components
407
+
408
+ n = len(V)
409
+ A = sp.coo_matrix((np.ones(2 * len(F), np.int8), (np.r_[F[:, 0], F[:, 1]], np.r_[F[:, 1], F[:, 2]])),
410
+ shape=(n, n))
411
+ _, lab = connected_components(A, directed=False)
412
+ fl = lab[F[:, 0]]
413
+ vol = np.bincount(fl, weights=_triple(V, F) / 6.0)
414
+ # renumber to the shells that have faces
415
+ used = np.unique(fl)
416
+ ren = np.full(lab.max() + 1, -1)
417
+ ren[used] = np.arange(len(used))
418
+ return ren[fl], vol[used]
loadpath/nlkernel.py ADDED
@@ -0,0 +1,241 @@
1
+ """
2
+ Compiled element kernel for the large-deflection solver (nonlinear.py).
3
+
4
+ nonlinear._element builds each hexahedron's internal force and tangent from
5
+ stacks of small numpy operations (3x3 inverses, determinants, 4th-order
6
+ einsums, transposed copies), whose overhead, not their arithmetic, was most
7
+ of a large-deflection solve: about 54 us per element per Newton iteration on
8
+ the 1056-cell tendon finger. Here the same element (total Lagrangian hex8,
9
+ 2x2x2 Gauss, neo-Hookean or St. Venant-Kirchhoff, optionally with the nine
10
+ incompatible modes condensed out) is one compiled loop over elements, run on
11
+ all cores, and the scatter into the CSR tangent is a second compiled loop.
12
+
13
+ Same arithmetic as the numpy element, in a different summation order, so
14
+ forces and tangents agree to ~1e-14 relative, not bit for bit.
15
+ LOADPATH_NLKERNEL=0 runs the numpy element instead.
16
+ """
17
+ from __future__ import annotations
18
+
19
+ import os
20
+
21
+ import numpy as np
22
+
23
+ try:
24
+ import numba as _nb
25
+ except ImportError: # pragma: no cover - numba is a dependency, kept optional
26
+ _nb = None
27
+
28
+ __all__ = ["available", "elements", "scatter"]
29
+
30
+
31
+ def available() -> bool:
32
+ return _nb is not None and os.environ.get("LOADPATH_NLKERNEL", "1") != "0"
33
+
34
+
35
+ if _nb is not None:
36
+ @_nb.njit(cache=True, nogil=True)
37
+ def _gauss(ux, G, h, lam, mu, svk, fx, kx):
38
+ """Accumulate internal force fx (3n) and tangent kx (3n, 3n) of one
39
+ element with generalized displacements ux (n, 3); returns min det F."""
40
+ nn = ux.shape[0]
41
+ w = h ** 3 / 8.0
42
+ wk = w / (h * h)
43
+ jmin = np.inf
44
+ F = np.empty((3, 3))
45
+ S = np.empty((3, 3))
46
+ P = np.empty((3, 3))
47
+ Ci = np.empty((3, 3))
48
+ A = np.empty((3, 3, 3, 3))
49
+ FA = np.empty((3, 3, 3, 3))
50
+ Q = np.empty((3, 3, 3, 3))
51
+ T = np.empty((nn, 3, 3, 3))
52
+ for g in range(G.shape[0]):
53
+ for i in range(3):
54
+ for j in range(3):
55
+ s = 1.0 if i == j else 0.0
56
+ for a in range(nn):
57
+ s += ux[a, i] * G[g, a, j] / h
58
+ F[i, j] = s
59
+ J = (F[0, 0] * (F[1, 1] * F[2, 2] - F[1, 2] * F[2, 1])
60
+ - F[0, 1] * (F[1, 0] * F[2, 2] - F[1, 2] * F[2, 0])
61
+ + F[0, 2] * (F[1, 0] * F[2, 1] - F[1, 1] * F[2, 0]))
62
+ if J < jmin:
63
+ jmin = J
64
+ # C = F^T F
65
+ c00 = F[0, 0] * F[0, 0] + F[1, 0] * F[1, 0] + F[2, 0] * F[2, 0]
66
+ c11 = F[0, 1] * F[0, 1] + F[1, 1] * F[1, 1] + F[2, 1] * F[2, 1]
67
+ c22 = F[0, 2] * F[0, 2] + F[1, 2] * F[1, 2] + F[2, 2] * F[2, 2]
68
+ c01 = F[0, 0] * F[0, 1] + F[1, 0] * F[1, 1] + F[2, 0] * F[2, 1]
69
+ c12 = F[0, 1] * F[0, 2] + F[1, 1] * F[1, 2] + F[2, 1] * F[2, 2]
70
+ c02 = F[0, 0] * F[0, 2] + F[1, 0] * F[1, 2] + F[2, 0] * F[2, 2]
71
+ if svk:
72
+ e00, e11, e22 = 0.5 * (c00 - 1.0), 0.5 * (c11 - 1.0), 0.5 * (c22 - 1.0)
73
+ tr = e00 + e11 + e22
74
+ S[0, 0] = lam * tr + 2 * mu * e00
75
+ S[1, 1] = lam * tr + 2 * mu * e11
76
+ S[2, 2] = lam * tr + 2 * mu * e22
77
+ S[0, 1] = S[1, 0] = mu * c01
78
+ S[1, 2] = S[2, 1] = mu * c12
79
+ S[0, 2] = S[2, 0] = mu * c02
80
+ for i in range(3):
81
+ for j in range(3):
82
+ for k in range(3):
83
+ for l in range(3):
84
+ A[i, j, k, l] = (lam * (i == j) * (k == l)
85
+ + mu * ((i == k) * (j == l) + (i == l) * (j == k)))
86
+ else:
87
+ # C^-1 by cofactors (C symmetric); det C = J^2
88
+ d = c00 * (c11 * c22 - c12 * c12) - c01 * (c01 * c22 - c12 * c02) + c02 * (c01 * c12 - c11 * c02)
89
+ Ci[0, 0] = (c11 * c22 - c12 * c12) / d
90
+ Ci[1, 1] = (c00 * c22 - c02 * c02) / d
91
+ Ci[2, 2] = (c00 * c11 - c01 * c01) / d
92
+ Ci[0, 1] = Ci[1, 0] = (c02 * c12 - c01 * c22) / d
93
+ Ci[1, 2] = Ci[2, 1] = (c01 * c02 - c00 * c12) / d
94
+ Ci[0, 2] = Ci[2, 0] = (c01 * c12 - c02 * c11) / d
95
+ lnJ = np.log(max(J, 1e-12))
96
+ for i in range(3):
97
+ for j in range(3):
98
+ S[i, j] = mu * ((1.0 if i == j else 0.0) - Ci[i, j]) + lam * lnJ * Ci[i, j]
99
+ cc = mu - lam * lnJ
100
+ for i in range(3):
101
+ for j in range(3):
102
+ for k in range(3):
103
+ for l in range(3):
104
+ A[i, j, k, l] = (lam * Ci[i, j] * Ci[k, l]
105
+ + cc * (Ci[i, k] * Ci[j, l] + Ci[i, l] * Ci[j, k]))
106
+ for i in range(3):
107
+ for l in range(3):
108
+ P[i, l] = F[i, 0] * S[0, l] + F[i, 1] * S[1, l] + F[i, 2] * S[2, l]
109
+ for a in range(nn):
110
+ for i in range(3):
111
+ fx[3 * a + i] += w * (P[i, 0] * G[g, a, 0] + P[i, 1] * G[g, a, 1] + P[i, 2] * G[g, a, 2]) / h
112
+ # Q_iljn = F_ik A_klpn F_jp + S_ln delta_ij
113
+ for i in range(3):
114
+ for l in range(3):
115
+ for p in range(3):
116
+ for n in range(3):
117
+ FA[i, l, p, n] = F[i, 0] * A[0, l, p, n] + F[i, 1] * A[1, l, p, n] + F[i, 2] * A[2, l, p, n]
118
+ for i in range(3):
119
+ for l in range(3):
120
+ for j in range(3):
121
+ for n in range(3):
122
+ v = FA[i, l, 0, n] * F[j, 0] + FA[i, l, 1, n] * F[j, 1] + FA[i, l, 2, n] * F[j, 2]
123
+ if i == j:
124
+ v += S[l, n]
125
+ Q[i, l, j, n] = v
126
+ # K_aibj += wk G_al Q_iljn G_bn, upper triangle in (a, b)
127
+ for a in range(nn):
128
+ for i in range(3):
129
+ for j in range(3):
130
+ for n in range(3):
131
+ T[a, i, j, n] = G[g, a, 0] * Q[i, 0, j, n] + G[g, a, 1] * Q[i, 1, j, n] + G[g, a, 2] * Q[i, 2, j, n]
132
+ for a in range(nn):
133
+ for b in range(a, nn):
134
+ gb0, gb1, gb2 = G[g, b, 0], G[g, b, 1], G[g, b, 2]
135
+ for i in range(3):
136
+ for j in range(3):
137
+ kx[3 * a + i, 3 * b + j] += wk * (T[a, i, j, 0] * gb0 + T[a, i, j, 1] * gb1 + T[a, i, j, 2] * gb2)
138
+ n3 = 3 * nn
139
+ for r in range(n3):
140
+ for c in range(r):
141
+ if c // 3 < r // 3:
142
+ kx[r, c] = kx[c, r]
143
+ return jmin
144
+
145
+ @_nb.njit(cache=True, parallel=True)
146
+ def _elements(u, edof, alpha, mod, h, lam, mu, svk, tangent, G, im):
147
+ m = edof.shape[0]
148
+ nn = G.shape[1]
149
+ fe = np.zeros((m, 24))
150
+ ke = np.zeros((m, 24, 24)) if tangent else np.zeros((1, 24, 24))
151
+ jm = np.empty(m)
152
+ anew = np.zeros((m, 3, 3)) if im else np.zeros((1, 3, 3))
153
+ ra = np.zeros((m, 9)) if im else np.zeros((1, 9))
154
+ cpl = np.zeros((m, 9, 24)) if im else np.zeros((1, 9, 24))
155
+ for e in _nb.prange(m):
156
+ ux = np.zeros((nn, 3))
157
+ for a in range(8):
158
+ for i in range(3):
159
+ ux[a, i] = u[edof[e, 3 * a + i]]
160
+ if im:
161
+ for a in range(3):
162
+ for i in range(3):
163
+ ux[8 + a, i] = alpha[e, a, i]
164
+ fx = np.zeros(3 * nn)
165
+ kx = np.zeros((3 * nn, 3 * nn))
166
+ jm[e] = _gauss(ux, G, h, lam, mu, svk, fx, kx)
167
+ me = mod[e]
168
+ if not im:
169
+ for r in range(24):
170
+ fe[e, r] = fx[r] * me
171
+ if tangent:
172
+ for r in range(24):
173
+ for c in range(24):
174
+ ke[e, r, c] = kx[r, c] * me
175
+ continue
176
+ # condense the nine incompatible modes: one local Newton step
177
+ kaa = kx[24:, 24:].copy()
178
+ rhs = np.empty((9, 25))
179
+ for r in range(9):
180
+ rhs[r, 0] = fx[24 + r]
181
+ for c in range(24):
182
+ rhs[r, 1 + c] = kx[c, 24 + r] # K_au = K_ua^T
183
+ sol = np.linalg.solve(kaa, rhs)
184
+ for r in range(24):
185
+ s = fx[r]
186
+ for q in range(9):
187
+ s -= kx[r, 24 + q] * sol[q, 0]
188
+ fe[e, r] = s * me
189
+ if tangent:
190
+ for r in range(24):
191
+ for c in range(24):
192
+ s = kx[r, c]
193
+ for q in range(9):
194
+ s -= kx[r, 24 + q] * sol[q, 1 + c]
195
+ ke[e, r, c] = s * me
196
+ for a in range(3):
197
+ for i in range(3):
198
+ anew[e, a, i] = alpha[e, a, i] - sol[3 * a + i, 0]
199
+ for q in range(9):
200
+ ra[e, q] = fx[24 + q]
201
+ for c in range(24):
202
+ cpl[e, q, c] = sol[q, 1 + c]
203
+ return fe, ke, jm, anew, ra, cpl
204
+
205
+ @_nb.njit(cache=True, nogil=True)
206
+ def _scatter(data, slots, ke, fint, fe, edof, tangent):
207
+ m = edof.shape[0]
208
+ for e in range(m):
209
+ for r in range(24):
210
+ fint[edof[e, r]] += fe[e, r]
211
+ if tangent:
212
+ for a in range(8):
213
+ for b in range(8):
214
+ p = 8 * a + b
215
+ for i in range(3):
216
+ for j in range(3):
217
+ data[slots[p, e, 3 * i + j]] += ke[e, 3 * a + i, 3 * b + j]
218
+
219
+
220
+ def elements(u, edof, alpha, mod, h, nu, kind, tangent, grads):
221
+ """All elements' (fint (m, 24), ke (m, 24, 24), det F per element,
222
+ alpha_new, R_alpha (m, 9), K_aa^-1 K_au (m, 9, 24)), scaled by the
223
+ moduli (fint, ke) as nonlinear._assemble scatters them. grads: (8, n, 3)
224
+ unit-cube gradients, n = 8 (plain) or 11 (incompatible modes)."""
225
+ lam, mu = nu / ((1 + nu) * (1 - 2 * nu)), 1.0 / (2 * (1 + nu))
226
+ im = alpha is not None
227
+ a = np.ascontiguousarray(alpha, float) if im else np.zeros((1, 3, 3))
228
+ return _elements(np.ascontiguousarray(u, float), np.ascontiguousarray(edof), a,
229
+ np.ascontiguousarray(mod, float), float(h), lam, mu, kind == "svk", bool(tangent),
230
+ np.ascontiguousarray(grads, float), im)
231
+
232
+
233
+ def scatter(n_dof, nnz, slots, edof, fe, ke, tangent):
234
+ """Global internal force and the CSR data array of the tangent."""
235
+ fint = np.zeros(n_dof)
236
+ data = np.zeros(nnz) if tangent else np.zeros(1)
237
+ if ke is None or not tangent:
238
+ ke = np.zeros((1, 24, 24))
239
+ _scatter(data, np.ascontiguousarray(slots), np.ascontiguousarray(ke), fint, np.ascontiguousarray(fe),
240
+ np.ascontiguousarray(edof), bool(tangent))
241
+ return fint, (data if tangent else None)