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/contact.py ADDED
@@ -0,0 +1,691 @@
1
+ """
2
+ Contact for the large-deflection solver: the part pressing on rigid objects
3
+ and on itself.
4
+
5
+ A finger closing on a pen, a snap fit riding over its catch, a compliant
6
+ gripper whose jaws meet, a flexure that bottoms out on a stop, a curling
7
+ finger whose segments fold into each other: all of them stop being "a part
8
+ under a load" the moment two surfaces touch. nonlinear.solve_nonlinear takes
9
+ these as `contact=[...]`, next to its tendons, and each one adds a force and
10
+ a load stiffness to every Newton iteration:
11
+
12
+ - `RigidObstacle`: a rigid plane, sphere, cylinder or box the part may not
13
+ enter. It can stay put (a palm, a table, the object being gripped) or move
14
+ with the load factor (an indenter pushed `move` mm into the part, the
15
+ catch a snap fit rides over). Frictionless, penalty on the part's surface
16
+ nodes, with the obstacle's exact signed distance, normal and curvature
17
+ (the tangent is the exact derivative of the contact force).
18
+ - `SelfContact`: the part's own surface faces pressing on each other.
19
+ Every surface node is checked against every surface face nearby that
20
+ is not its own neighbour (two-pass node-to-face, so neither side is
21
+ favoured), penalty along the face's normal.
22
+
23
+ Both take `friction=mu` (Coulomb; default 0, frictionless): a contact
24
+ sticks, held by a tangential penalty as stiff as the normal one, until its
25
+ tangential force reaches mu times its normal force, then slides with mu p
26
+ against its slip (return mapping on the trial force). Friction depends on
27
+ the path, so the state (where each node sticks, or the friction it carried)
28
+ is updated at each converged load step (`_commit`), and a step in which
29
+ contacts carrying much of the load first touch is bisected towards the
30
+ touch (nonlinear.solve_nonlinear), so the answer does not depend on how many
31
+ steps the solve takes. The sliding tangent is unsymmetric (the direct
32
+ solver takes it as it is; CG and the buckling check take a symmetric
33
+ version).
34
+
35
+ Penalty: a node pressed d into a surface pushes back k d with
36
+ k = stiffness * E * pitch (N/mm; E of the cells round it), eased in
37
+ quadratically over the first `smooth` * pitch of penetration so Newton sees
38
+ a continuous tangent as nodes come into contact. The default stiffness 20 leaves a
39
+ penetration of about F / (20 E h) under a node force F, i.e. the surface
40
+ behaves as if a skin 1/20 of a cell thick were slightly soft: well under
41
+ the voxel geometry's own error.
42
+
43
+ Validated in tests/test_contact.py against closed forms: a cantilever
44
+ landing on a rigid support (propped-cantilever reaction), two fingers of one
45
+ part pressed together (both tips meet, the reaction splits as beam theory
46
+ says), a cantilever pressed onto a frictionless floor through large
47
+ deflection (the elastica with the floor reaction, tip sliding along it), and
48
+ an indenter pushed into a cantilever; with friction, a block dragged over a
49
+ floor and over a second block of the same part (drag = mu N once it
50
+ slides) and the cantilever on a rough floor (the elastica with the tip
51
+ force mu R along the beam: R within 0.2%, friction 1%). bench/contact_ccx.py
52
+ runs the same cases in CalculiX (results in bench/results/contact_ccx.json).
53
+
54
+ Units: mm, N, MPa.
55
+ """
56
+ from __future__ import annotations
57
+
58
+ import numpy as np
59
+ import scipy.sparse as sp
60
+
61
+ _TAPER = 0.2
62
+ _TAU = 0.05 # cells: how sharply a node's contact favours the nearest face behind it
63
+ __all__ = ["RigidObstacle", "SelfContact", "boundary_faces", "obstacle_from_spec"]
64
+
65
+ # hex8 corner order (fem.CORNERS: x fastest, then y, then z) and each face's
66
+ # four corners in a cycle, with its outward axis and sign
67
+ _FACES = [((0, 2, 6, 4), 0, -1.0), ((1, 3, 7, 5), 0, 1.0),
68
+ ((0, 1, 5, 4), 1, -1.0), ((2, 3, 7, 6), 1, 1.0),
69
+ ((0, 1, 3, 2), 2, -1.0), ((4, 5, 7, 6), 2, 1.0)]
70
+
71
+
72
+ def _penalty(g, k, s):
73
+ """Contact pressure law p(g) >= 0 (force along the outward normal) and
74
+ dp/dg: 0 for g >= 0 (no force before touching), k g^2 / (2 s) on
75
+ (-s, 0), k (-g - s / 2) below -s."""
76
+ p = np.zeros_like(g)
77
+ dp = np.zeros_like(g)
78
+ lin = g <= -s
79
+ p[lin] = k[lin] * (-g[lin] - 0.5 * s)
80
+ dp[lin] = -k[lin]
81
+ q = (g > -s) & (g < 0)
82
+ p[q] = k[q] * g[q] ** 2 / (2 * s)
83
+ dp[q] = k[q] * g[q] / s
84
+ return p, dp
85
+
86
+
87
+ def _bilinear(xi, eta):
88
+ """Weights of a face's corners (cycle 0-1-2-3) at (xi, eta)."""
89
+ return 0.25 * np.stack([(1 - xi) * (1 - eta), (1 + xi) * (1 - eta), (1 + xi) * (1 + eta), (1 - xi) * (1 + eta)], 1)
90
+
91
+
92
+ def boundary_faces(mesh):
93
+ """The mesh's surface faces: (faces (m, 4) node ids in a cycle, their
94
+ element, outward axis, outward sign)."""
95
+ ijk = mesh.elem_ijk
96
+ lut = {tuple(c): i for i, c in enumerate(ijk.tolist())}
97
+ faces, owner, axes, signs = [], [], [], []
98
+ for corners, ax, sg in _FACES:
99
+ nb = ijk.copy()
100
+ nb[:, ax] += int(sg)
101
+ has = np.fromiter((tuple(c) in lut for c in nb.tolist()), bool, len(nb))
102
+ el = np.flatnonzero(~has)
103
+ faces.append(mesh.enode[el][:, list(corners)])
104
+ owner.append(el)
105
+ axes.append(np.full(len(el), ax))
106
+ signs.append(np.full(len(el), sg))
107
+ return np.concatenate(faces), np.concatenate(owner), np.concatenate(axes), np.concatenate(signs)
108
+
109
+
110
+ def _node_modulus(mesh, modulus):
111
+ """E at each node: the largest of its cells'."""
112
+ mod = np.broadcast_to(np.asarray(modulus, float), (mesh.n_elem,))
113
+ out = np.zeros(mesh.n_nodes)
114
+ np.maximum.at(out, mesh.enode.ravel(), np.repeat(mod, 8))
115
+ return out
116
+
117
+
118
+ def _coulomb(trial, lim, kt, dtrial, dlim):
119
+ """Coulomb's law on a trial tangential force (return mapping).
120
+
121
+ trial (a, 3): the force the tangential penalty kt would push with if the
122
+ contact stuck; lim (a,): mu p, the most friction can hold. dtrial (a, 3,
123
+ m) and dlim (a, m): their derivatives in the m dofs the contact sees.
124
+ Returns the friction force (a, 3), its derivative (a, 3, m) and which
125
+ contacts stick. Sticking: the trial force itself. Sliding: mu p along it.
126
+ dlim None leaves out the sliding force's growth with the normal force:
127
+ the one unsymmetric part of the tangent (the solver's stability check
128
+ takes the tangent without it; with it, the symmetric part of a sliding
129
+ contact's tangent is indefinite and reads as a buckle)."""
130
+ tn = np.linalg.norm(trial, axis=1)
131
+ stick = tn <= lim
132
+ ft = trial.copy()
133
+ dft = dtrial.copy()
134
+ sl = ~stick
135
+ if sl.any():
136
+ t = trial[sl] / tn[sl, None]
137
+ ft[sl] = lim[sl, None] * t
138
+ proj = np.eye(3)[None] - np.einsum("ai,aj->aij", t, t)
139
+ dft[sl] = (lim[sl] / tn[sl])[:, None, None] * np.einsum("aij,ajm->aim", proj, dtrial[sl])
140
+ if dlim is not None:
141
+ dft[sl] += np.einsum("ai,am->aim", t, dlim[sl])
142
+ return ft, dft, stick
143
+
144
+
145
+ def _early(sfac, fresh, p):
146
+ """How early in the step the contacts that touched during it touched:
147
+ (the share of the normal force they carry, the load-weighted point in
148
+ the step where they touched, 0 = its start). The solver cuts a step
149
+ back to the touch when a large share touched early, since friction
150
+ measures a fresh contact's slip from the touch along a straight path
151
+ through the step."""
152
+ tot = p.sum()
153
+ if tot <= 0 or not fresh.any():
154
+ return 0.0, 1.0
155
+ pf = p[fresh]
156
+ share = float(pf.sum() / tot)
157
+ at = float((pf * sfac[fresh]).sum() / max(pf.sum(), 1e-300))
158
+ return share, at
159
+
160
+
161
+ class _Base:
162
+ """Shared bookkeeping: surface nodes, their penalty, per-step results."""
163
+
164
+ def __init__(self, stiffness, smooth, friction=0.0):
165
+ self.stiffness, self.smooth = float(stiffness), float(smooth)
166
+ self.friction = float(friction)
167
+ self._symmetric = False # set by the solver for its stability tangent
168
+ self._early = (0.0, 1.0)
169
+ if self.friction < 0:
170
+ raise ValueError("contact friction: the coefficient must be >= 0")
171
+
172
+ def _setup_common(self, mesh, modulus):
173
+ self.mesh = mesh
174
+ self.faces, self.owner, self.axes, self.signs = boundary_faces(mesh)
175
+ self.snodes = np.unique(self.faces.ravel())
176
+ self.kpen = self.stiffness * _node_modulus(mesh, modulus) * mesh.pitch
177
+ self.s = self.smooth * mesh.pitch
178
+ self.info = {}
179
+
180
+
181
+ class RigidObstacle(_Base):
182
+ """A rigid object the part's surface may not enter.
183
+
184
+ shape: "plane" (point, normal: the outward side is free), "sphere"
185
+ (center, radius), "cylinder" (center, axis, radius: infinitely long),
186
+ or "box" (min, max). `inside: True` turns sphere/cylinder/box into a
187
+ cavity the part must stay inside (a socket, a tube, a mold).
188
+ move: mm the object travels at full load (scaled by the load factor):
189
+ an indenter or a moving jaw. stiffness, smooth: the penalty (module
190
+ docstring).
191
+
192
+ After each step `info` holds the force the object takes from the part
193
+ (N, a 3-vector: on a fixed object, the reaction it must hold), how many
194
+ nodes touch it, and the deepest penetration (mm)."""
195
+
196
+ def __init__(self, shape: str, *, point=None, normal=None, center=None, radius=None, axis=None,
197
+ lo=None, hi=None, inside: bool = False, move=None, stiffness: float = 20.0,
198
+ smooth: float = 0.02, friction: float = 0.0, name: str | None = None):
199
+ super().__init__(stiffness, smooth, friction)
200
+ if shape not in ("plane", "sphere", "cylinder", "box"):
201
+ raise ValueError(f"RigidObstacle: shape must be plane, sphere, cylinder or box, not {shape!r}")
202
+ self.shape, self.inside, self.name = shape, bool(inside), name
203
+ self.move = np.zeros(3) if move is None else np.asarray(move, float)
204
+ if shape == "plane":
205
+ if point is None or normal is None:
206
+ raise ValueError("RigidObstacle plane: needs point and normal")
207
+ self.point = np.asarray(point, float)
208
+ n = np.asarray(normal, float)
209
+ self.normal = n / np.linalg.norm(n)
210
+ elif shape in ("sphere", "cylinder"):
211
+ if center is None or radius is None:
212
+ raise ValueError(f"RigidObstacle {shape}: needs center and radius")
213
+ self.center, self.radius = np.asarray(center, float), float(radius)
214
+ if shape == "cylinder":
215
+ a = np.eye(3)["xyz".index(axis)] if isinstance(axis, str) else np.asarray(
216
+ (0, 1, 0) if axis is None else axis, float)
217
+ self.axis = a / np.linalg.norm(a)
218
+ else:
219
+ if lo is None or hi is None:
220
+ raise ValueError("RigidObstacle box: needs min and max corners")
221
+ self.lo, self.hi = np.asarray(lo, float), np.asarray(hi, float)
222
+
223
+ def sdf(self, x, scale=1.0):
224
+ """Signed distance (positive outside the object, where the part may
225
+ be), its gradient (the outward normal) and Hessian, at points x."""
226
+ off = scale * self.move
227
+ n = len(x)
228
+ H = np.zeros((n, 3, 3))
229
+ if self.shape == "plane":
230
+ g = (x - self.point - off) @ self.normal
231
+ return g, np.broadcast_to(self.normal, (n, 3)).copy(), H
232
+ if self.shape == "box":
233
+ c = 0.5 * (self.lo + self.hi) + off
234
+ h = 0.5 * (self.hi - self.lo)
235
+ q = x - c
236
+ d = np.abs(q) - h
237
+ out = np.maximum(d, 0.0)
238
+ od = np.linalg.norm(out, axis=1)
239
+ outside = od > 0
240
+ g = np.where(outside, od, d.max(axis=1))
241
+ grad = np.zeros((n, 3))
242
+ grad[outside] = np.sign(q[outside]) * out[outside] / od[outside, None]
243
+ ins = ~outside
244
+ if ins.any():
245
+ k = d[ins].argmax(axis=1)
246
+ grad[np.flatnonzero(ins), k] = np.sign(q[ins, k])
247
+ # flat faces; the edges' curvature is left out (a tangent approximation)
248
+ return self._flip(g, grad, H)
249
+ r = x - self.center - off
250
+ if self.shape == "cylinder":
251
+ r = r - np.outer(r @ self.axis, self.axis)
252
+ P = np.eye(3) - np.outer(self.axis, self.axis)
253
+ else:
254
+ P = np.eye(3)
255
+ dist = np.maximum(np.linalg.norm(r, axis=1), 1e-12)
256
+ nrm = r / dist[:, None]
257
+ g = dist - self.radius
258
+ H = (P[None] - np.einsum("ni,nj->nij", nrm, nrm)) / dist[:, None, None]
259
+ return self._flip(g, nrm, H)
260
+
261
+ def _flip(self, g, grad, H):
262
+ if self.inside:
263
+ return -g, -grad, -H
264
+ return g, grad, H
265
+
266
+ def _setup(self, mesh, modulus=1.0):
267
+ self._setup_common(mesh, modulus)
268
+ self._dofs = 3 * self.snodes[:, None] + np.arange(3)[None, :]
269
+ # friction: where each surface node sticks to the object (in the
270
+ # object's own frame, which moves with it), updated at each
271
+ # converged step
272
+ self._anchor = mesh.node_xyz[self.snodes].copy()
273
+ # nodes off the object at the last converged step, and their gap
274
+ # then: one that touches during a step sticks where it touched
275
+ self._fresh = np.ones(len(self.snodes), bool)
276
+ self._gprev = self.sdf(self._anchor, 0.0)[0]
277
+
278
+ def _load(self, u, scale):
279
+ """Force on the part per dof, the load stiffness (-d force / du), and
280
+ the step's report."""
281
+ x = self.mesh.node_xyz[self.snodes] + u[self._dofs]
282
+ g, nrm, H = self.sdf(x, scale)
283
+ k = self.kpen[self.snodes]
284
+ p, dp = _penalty(g, k, self.s)
285
+ act = np.flatnonzero(p > 0)
286
+ self._act = act
287
+ n = u.shape[0]
288
+ f = np.zeros(n)
289
+ if len(act) == 0:
290
+ self.info = {"force": [0.0, 0.0, 0.0], "nodes": 0, "penetration": 0.0}
291
+ return f, sp.csr_matrix((n, n)), self.info
292
+ fa = p[act, None] * nrm[act]
293
+ # f = p(g(x)) n(x): df/dx = p' n n^T + p H
294
+ ke = -(dp[act, None, None] * np.einsum("ni,nj->nij", nrm[act], nrm[act]) + p[act, None, None] * H[act])
295
+ info = {}
296
+ if self.friction > 0:
297
+ ft, dft, stick = self._friction(act, x[act], g[act], scale, nrm[act], H[act], k[act], p[act], dp[act])
298
+ fa = fa + ft
299
+ ke = ke - dft
300
+ info = {"friction": (-ft.sum(axis=0)).tolist(), "sliding": int((~stick).sum())}
301
+ np.add.at(f, self._dofs[act].ravel(), fa.ravel())
302
+ rows = np.repeat(self._dofs[act], 3, axis=1).ravel()
303
+ cols = np.tile(self._dofs[act], (1, 3)).ravel()
304
+ kl = sp.csr_matrix((ke.ravel(), (rows, cols)), shape=(n, n))
305
+ self.info = {"force": (-fa.sum(axis=0)).tolist(), "nodes": int((g[act] < 0).sum()),
306
+ "penetration": float(max(0.0, -g[act].min())), **info}
307
+ return f, kl, self.info
308
+
309
+ def _friction(self, idx, x, g, scale, nrm, H, k, p, dp):
310
+ """Coulomb friction at contacting nodes x (surface nodes idx)
311
+ against their anchors: the tangential slip d_t = P (x - anchor)
312
+ (P = I - n n^T, anchor in the object's frame) pushes back -k d_t
313
+ while it sticks, mu p once that exceeds mu p. A node that was off
314
+ the object at the last converged step is anchored where its path
315
+ since then crossed the surface (by its gaps then and now), not
316
+ where it started the step. Exact tangent including the normal's
317
+ turning (but not the touch point's own motion)."""
318
+ rel = x - scale * self.move
319
+ anc = self._anchor[idx]
320
+ sfac = np.zeros(len(idx))
321
+ fr = self._fresh[idx]
322
+ if fr.any():
323
+ g0 = self._gprev[idx][fr]
324
+ sfac[fr] = np.clip(g0 / np.maximum(g0 - g[fr], 1e-300), 0.0, 1.0)
325
+ self._early = _early(sfac, fr, p)
326
+ d = (1.0 - sfac)[:, None] * (rel - anc)
327
+ dn = np.einsum("ai,ai->a", d, nrm)
328
+ dt = d - dn[:, None] * nrm
329
+ # d(d_t)/dx = (1 - s) P - n (H d)^T - (n . d) H
330
+ P = np.eye(3)[None] - np.einsum("ai,aj->aij", nrm, nrm)
331
+ J = ((1.0 - sfac)[:, None, None] * P - np.einsum("ai,aj->aij", nrm, np.einsum("aij,aj->ai", H, d))
332
+ - dn[:, None, None] * H)
333
+ trial = -k[:, None] * dt
334
+ return _coulomb(trial, self.friction * p, k, -k[:, None, None] * J,
335
+ None if self._symmetric else self.friction * dp[:, None] * nrm)
336
+
337
+ def _commit(self, u, scale):
338
+ """A step converged: nodes that slid carry their anchor along (it
339
+ trails the node by the slip that holds mu p), nodes off the object
340
+ anchor where they are, sticking nodes keep theirs."""
341
+ if self.friction <= 0:
342
+ return
343
+ x = self.mesh.node_xyz[self.snodes] + u[self._dofs]
344
+ g, nrm, H = self.sdf(x, scale)
345
+ k = self.kpen[self.snodes]
346
+ p, dp = _penalty(g, k, self.s)
347
+ off = scale * self.move
348
+ new = x - off
349
+ act = np.flatnonzero(p > 0)
350
+ if len(act):
351
+ ft, _, stick = self._friction(act, x[act], g[act], scale, nrm[act], H[act], k[act], p[act], dp[act])
352
+ # sticking: the anchor stays (or, just touched, sits where the
353
+ # node touched: the stick force over k behind it); sliding: it
354
+ # sits mu p / k behind the node, along the friction
355
+ new[act] = x[act] - off + ft / k[act, None]
356
+ self._anchor = new
357
+ self._fresh = p <= 0
358
+ self._gprev = g
359
+
360
+
361
+ class SelfContact(_Base):
362
+ """The part's surface pressing on itself (fingers of one part meeting, a
363
+ curling finger folding onto its own segments, a flexure closing its
364
+ gap).
365
+
366
+ Each surface node is tested against the surface faces within reach that
367
+ are not its own neighbourhood (within `rings` cells of any of the face's
368
+ nodes through the material, which also leaves out the far side of a
369
+ thin wall and the same surface bending round a tight curl), whose
370
+ outward normals oppose its own, and onto which it projects. A node
371
+ pressed into a face pushes back along the face's normal (penalty as in
372
+ the module docstring), the face's corners taking the reaction by their
373
+ bilinear weights at the projection. Every surface node is a "slave" and
374
+ every surface face a "master", so contact is symmetric between the two
375
+ sides.
376
+
377
+ The tangent holds the face normal and the projection point fixed
378
+ within an iteration (the classic node-to-surface linearization without
379
+ the normal's rotation): exact for flat sliding, a slightly slower Newton
380
+ where faces rotate quickly under contact. `rotation=True` adds the
381
+ faces' turning and the projection point's travel (unsymmetric; the
382
+ tangent error on tilted faces drops from ~25% to ~5%, the rest is the
383
+ share-out between faces), but on the two-finger test it took 13 load
384
+ steps and 10x longer instead of 4, so it is off by default.
385
+
386
+ friction: Coulomb coefficient, incremental: each step adds the
387
+ tangential slip since the last converged step (projected on the face)
388
+ to the friction the node carried then, capped at mu p.
389
+
390
+ `info` after each step: the total normal force (N) carried across
391
+ the contacts, the nodes in contact, the deepest penetration, and with
392
+ friction the summed friction force magnitude and the nodes sliding."""
393
+
394
+ def __init__(self, stiffness: float = 20.0, smooth: float = 0.02, reach: float = 0.6, rings: int = 2,
395
+ friction: float = 0.0, rotation: bool = False):
396
+ super().__init__(stiffness, smooth, friction)
397
+ self.rotation = bool(rotation)
398
+ self.reach, self.rings = float(reach), int(rings)
399
+
400
+ def _setup(self, mesh, modulus=1.0, fixed=None):
401
+ from scipy.spatial import cKDTree # noqa: F401 - checked here so a missing scipy fails early
402
+
403
+ self._setup_common(mesh, modulus)
404
+ # A clamped node never moves: against it the other side is the one
405
+ # that slides, so it is only a master (as a rigid obstacle's
406
+ # surface is), which keeps the exact flat-face linearization there
407
+ self._slaves = self.snodes
408
+ if fixed is not None:
409
+ held = np.asarray(fixed, bool).reshape(-1, 3).all(axis=1)
410
+ self._slaves = self.snodes[~held[self.snodes]]
411
+ # 1-ring of each node: the nodes it shares a cell with
412
+ ne = mesh.n_elem
413
+ inc = sp.csr_matrix((np.ones(ne * 8), (mesh.enode.ravel(), np.repeat(np.arange(ne), 8))),
414
+ shape=(mesh.n_nodes, ne))
415
+ adj = (inc @ inc.T).tocsr()
416
+ adj.data[:] = 1.0
417
+ ring = adj
418
+ for _ in range(self.rings - 1):
419
+ ring = (ring @ adj).tocsr()
420
+ ring.data[:] = 1.0
421
+ ring = ring.tocoo()
422
+ self._ring = np.sort(ring.row.astype(np.int64) * mesh.n_nodes + ring.col)
423
+ # surface-node index of every mesh node, for the node normals
424
+ self._sidx = np.full(mesh.n_nodes, -1)
425
+ self._sidx[self.snodes] = np.arange(len(self.snodes))
426
+ # friction state at the last converged step: the displacement, and
427
+ # the friction force each slave node carried then
428
+ self._u0 = np.zeros(mesh.n_dof)
429
+ self._ft = np.zeros((mesh.n_nodes, 3))
430
+ self._touch = np.zeros(mesh.n_nodes, bool)
431
+ self._last = None
432
+
433
+ def _pairs(self, x):
434
+ """Candidate (slave node, face) pairs whose positions are close."""
435
+ from scipy.spatial import cKDTree
436
+
437
+ fx = x[self.faces] # (m, 4, 3)
438
+ cen = fx.mean(axis=1)
439
+ h = self.mesh.pitch
440
+ tree = cKDTree(cen)
441
+ # a node above a face lies within half a diagonal of its centre
442
+ # (the face can stretch: allow some)
443
+ lists = tree.query_ball_point(x[self._slaves], r=(0.71 + self.reach) * h)
444
+ cnt = np.fromiter((len(a) for a in lists), int, len(lists))
445
+ if cnt.sum() == 0:
446
+ return np.zeros(0, int), np.zeros(0, int), fx
447
+ si = np.repeat(self._slaves, cnt)
448
+ fi = np.fromiter((j for a in lists for j in a), int, int(cnt.sum()))
449
+ # not the face's own neighbourhood
450
+ near = np.zeros(len(si), bool)
451
+ nn = self.mesh.n_nodes
452
+ for c in range(4):
453
+ key = si.astype(np.int64) * nn + self.faces[fi, c]
454
+ pos = np.minimum(np.searchsorted(self._ring, key), len(self._ring) - 1)
455
+ near |= self._ring[pos] == key
456
+ keep = ~near
457
+ return si[keep], fi[keep], fx
458
+
459
+ def _load(self, u, scale):
460
+ mesh = self.mesh
461
+ n = u.shape[0]
462
+ x = mesh.node_xyz + u.reshape(-1, 3)
463
+ si, fi, fx = self._pairs(x)
464
+ f = np.zeros(n)
465
+ empty = {"force": 0.0, "nodes": 0, "penetration": 0.0}
466
+ if len(si) == 0:
467
+ self.info = empty
468
+ return f, sp.csr_matrix((n, n)), self.info
469
+ # deformed face frames: normal from the diagonals, outward
470
+ q = fx[fi]
471
+ d1 = q[:, 2] - q[:, 0]
472
+ d2 = q[:, 3] - q[:, 1]
473
+ nf = np.cross(d1, d2)
474
+ cn = np.linalg.norm(nf, axis=1)
475
+ nf /= cn[:, None]
476
+ # the diagonals' order follows the face cycle; make it outward by
477
+ # the reference outward normal carried by the deformation (sign
478
+ # against the face's own cell centre)
479
+ cell_c = x[mesh.enode[self.owner[fi]]].mean(axis=1)
480
+ cen = q.mean(axis=1)
481
+ flip = np.einsum("ni,ni->n", nf, cen - cell_c) < 0
482
+ nf[flip] *= -1
483
+ # node normals: mean of the outward normals of its own surface faces
484
+ allq = fx
485
+ an = np.cross(allq[:, 2] - allq[:, 0], allq[:, 3] - allq[:, 1])
486
+ acen = allq.mean(axis=1)
487
+ acell = x[mesh.enode[self.owner]].mean(axis=1)
488
+ an *= np.where(np.einsum("ni,ni->n", an, acen - acell) < 0, -1.0, 1.0)[:, None]
489
+ nn = np.zeros((len(self.snodes), 3))
490
+ for c in range(4):
491
+ np.add.at(nn, self._sidx[self.faces[:, c]], an)
492
+ nn /= np.maximum(np.linalg.norm(nn, axis=1), 1e-30)[:, None]
493
+ # facing each other: the force eases in as the node's normal turns
494
+ # from square to the face (cos -1 .. -0.3) to edge-on (0), so a
495
+ # node rounding a corner does not switch in one jump
496
+ facing = np.clip(-np.einsum("ni,ni->n", nn[self._sidx[si]], nf) / 0.3, 0.0, 1.0)
497
+ opposed = facing > 0
498
+ # projection onto the face (parallelogram coordinates from its centre)
499
+ xs = x[si]
500
+ e1 = 0.25 * ((q[:, 1] + q[:, 2]) - (q[:, 0] + q[:, 3])) # centre to the 1-2 edge
501
+ e2 = 0.25 * ((q[:, 2] + q[:, 3]) - (q[:, 0] + q[:, 1]))
502
+ rel = xs - cen
503
+ a11, a12, a22 = np.einsum("ni,ni->n", e1, e1), np.einsum("ni,ni->n", e1, e2), np.einsum("ni,ni->n", e2, e2)
504
+ b1, b2 = np.einsum("ni,ni->n", rel, e1), np.einsum("ni,ni->n", rel, e2)
505
+ det = a11 * a22 - a12 ** 2
506
+ xi = (a22 * b1 - a12 * b2) / det
507
+ eta = (a11 * b2 - a12 * b1) / det
508
+ # the gap from the bilinear surface point under the node: its
509
+ # derivative is then the corner weights along the normal (the
510
+ # normal's own rotation drops out to first order)
511
+ xc, ec = np.clip(xi, -1, 1), np.clip(eta, -1, 1)
512
+ w = _bilinear(xc, ec)
513
+ g = np.einsum("ni,ni->n", xs - np.einsum("nc,nci->ni", w, q), nf)
514
+ # a node may slide off a face's free edge: its force tapers to zero
515
+ # over the last TAPER of a half-face past it, so contact does not
516
+ # switch on and off in one jump there
517
+ reach = np.maximum(np.abs(xi), np.abs(eta))
518
+ ok = opposed & (reach < 1.0 + _TAPER) & (g > -0.5 * mesh.pitch) & (g < 0)
519
+ if not ok.any():
520
+ self.info = empty
521
+ return f, sp.csr_matrix((n, n)), self.info
522
+ ix = np.flatnonzero(ok) # into the candidate pairs, for the tangent's rotation terms
523
+ si, fi, g, nf, xi, eta, reach, facing = (a[ok] for a in (si, fi, g, nf, xi, eta, reach, facing))
524
+ # A node behind several faces (an edge of a flat surface, or a
525
+ # corner it is pushed into) shares its contact among them, weighted
526
+ # towards the nearest surface behind it (the one it has just
527
+ # crossed, i.e. the shortest way back out): omega = exp((g - g_max) /
528
+ # tau), smooth in the positions, so a node rounding a corner turns
529
+ # its push round instead of switching faces in one jump. On a flat
530
+ # surface the faces either side of an edge agree on the gap and
531
+ # share it evenly. Each weight also carries the edge taper and the
532
+ # facing ease-in; the shares sum to at most 1.
533
+ t = np.clip((reach - 1.0) / _TAPER, 0.0, 1.0)
534
+ om = (1 - t * t * (3 - 2 * t)) * facing * facing * (3 - 2 * facing)
535
+ gmax = np.full(mesh.n_nodes, -np.inf)
536
+ np.maximum.at(gmax, si, g)
537
+ om = om * np.exp((g - gmax[si]) / (_TAU * mesh.pitch))
538
+ tot = np.zeros(mesh.n_nodes)
539
+ np.add.at(tot, si, om)
540
+ om = om / np.maximum(tot[si], 1.0)
541
+ k = np.minimum(self.kpen[si], self.kpen[self.faces[fi]].max(axis=1)) * om
542
+ p, dp = _penalty(g, k, self.s)
543
+ act = p > 0
544
+ si, fi, g, nf, xi, eta, p, dp, k, ix = (a[act] for a in (si, fi, g, nf, xi, eta, p, dp, k, ix))
545
+ if len(si) == 0:
546
+ self.info = empty
547
+ return f, sp.csr_matrix((n, n)), self.info
548
+ w = _bilinear(np.clip(xi, -1, 1), np.clip(eta, -1, 1))
549
+ nodes = np.concatenate([si[:, None], self.faces[fi]], axis=1) # (a, 5)
550
+ coef = np.concatenate([np.ones((len(si), 1)), -w], axis=1) # dg/dx per node
551
+ dofs = (3 * nodes[:, :, None] + np.arange(3)[None, None, :]).reshape(len(si), 15)
552
+ v = (coef[:, :, None] * nf[:, None, :]).reshape(len(si), 15) # dg/du
553
+ fpair = p[:, None] * v
554
+ if self.rotation and not self._symmetric:
555
+ dg, dv = self._rotation(q[ix], xs[ix], cn[ix], nf, w, xi, eta, e1[ix], e2[ix], rel[ix],
556
+ a11[ix], a12[ix], a22[ix], det[ix], np.clip(xi, -1, 1), np.clip(eta, -1, 1))
557
+ ke = -(dp[:, None, None] * np.einsum("ai,aj->aij", v, v + dg) + p[:, None, None] * dv)
558
+ else:
559
+ ke = -dp[:, None, None] * np.einsum("ai,aj->aij", v, v)
560
+ fric = {}
561
+ if self.friction > 0:
562
+ self._g = g
563
+ ft, dft, stick = self._friction(u, si, nodes, coef, nf, k, p, dp, v)
564
+ fpair = fpair + (coef[:, :, None] * ft[:, None, :]).reshape(len(si), 15)
565
+ ke = ke - np.einsum("ac,aim->acim", coef, dft).reshape(len(si), 15, 15)
566
+ self._last = (si, ft)
567
+ fric = {"friction": float(np.linalg.norm(ft, axis=1).sum()), "sliding": int(len(np.unique(si[~stick])))}
568
+ np.add.at(f, dofs.ravel(), fpair.ravel())
569
+ rows = np.repeat(dofs, 15, axis=1).ravel()
570
+ cols = np.tile(dofs, (1, 15)).ravel()
571
+ kl = sp.csr_matrix((ke.ravel(), (rows, cols)), shape=(n, n))
572
+ # every pair passes its force from face to node: the sum is what
573
+ # the two sides press on each other with
574
+ self.info = {"force": float(p.sum()), "nodes": int(len(np.unique(si[g < 0]))),
575
+ "penetration": float(max(0.0, -g.min())), **fric}
576
+ return f, kl, self.info
577
+
578
+ @staticmethod
579
+ def _rotation(q, xs, cn, nf, w, xi, eta, e1, e2, rel, a11, a12, a22, det, xc, ec):
580
+ """The node-to-face terms a frozen normal leaves out: how the gap
581
+ and the force's direction change as the face turns (its normal from
582
+ the diagonals) and as the node's projection moves over it. Returns
583
+ the extra gap gradient (a, 15) and d(dg/du)/du (a, 15, 15) over the
584
+ slave node and the face's four corners."""
585
+ a = len(xs)
586
+ d1 = q[:, 2] - q[:, 0]
587
+ d2 = q[:, 3] - q[:, 1]
588
+
589
+ def skew(vv):
590
+ z = np.zeros(len(vv))
591
+ return np.stack([np.stack([z, -vv[:, 2], vv[:, 1]], -1), np.stack([vv[:, 2], z, -vv[:, 0]], -1),
592
+ np.stack([-vv[:, 1], vv[:, 0], z], -1)], 1)
593
+
594
+ P = (np.eye(3)[None] - np.einsum("ai,aj->aij", nf, nf)) / cn[:, None, None]
595
+ # the orientation flip: nf = sgn c / |c|, and sgn P dc = (nf x ...) keeps it right
596
+ sgn = np.sign(np.einsum("ai,ai->a", np.cross(d1, d2), nf))[:, None, None]
597
+ s2, s1 = skew(d2), skew(d1)
598
+ dn = np.zeros((a, 3, 5, 3))
599
+ for c, blk in ((0, s2), (2, -s2), (1, -s1), (3, s1)):
600
+ dn[:, :, c + 1, :] = sgn * np.einsum("aij,ajk->aik", P, blk)
601
+ dn = dn.reshape(a, 3, 15)
602
+ # projection coordinates: A [xi, eta] = b, differentiated
603
+ sv1 = 0.25 * np.array([-1.0, 1.0, 1.0, -1.0])
604
+ sv2 = 0.25 * np.array([-1.0, -1.0, 1.0, 1.0])
605
+ db1 = np.zeros((a, 5, 3))
606
+ db2 = np.zeros((a, 5, 3))
607
+ da11 = np.zeros((a, 5, 3))
608
+ da12 = np.zeros((a, 5, 3))
609
+ da22 = np.zeros((a, 5, 3))
610
+ db1[:, 0], db2[:, 0] = e1, e2
611
+ for c in range(4):
612
+ db1[:, c + 1] = -0.25 * e1 + sv1[c] * rel
613
+ db2[:, c + 1] = -0.25 * e2 + sv2[c] * rel
614
+ da11[:, c + 1] = 2 * sv1[c] * e1
615
+ da22[:, c + 1] = 2 * sv2[c] * e2
616
+ da12[:, c + 1] = sv1[c] * e2 + sv2[c] * e1
617
+ r1 = (db1 - da11 * xi[:, None, None] - da12 * eta[:, None, None]).reshape(a, 15)
618
+ r2 = (db2 - da12 * xi[:, None, None] - da22 * eta[:, None, None]).reshape(a, 15)
619
+ dxi = (a22[:, None] * r1 - a12[:, None] * r2) / det[:, None]
620
+ deta = (a11[:, None] * r2 - a12[:, None] * r1) / det[:, None]
621
+ dxi[np.abs(xi) > 1] = 0.0
622
+ deta[np.abs(eta) > 1] = 0.0
623
+ wx = 0.25 * np.stack([-(1 - ec), (1 - ec), (1 + ec), -(1 + ec)], 1)
624
+ we = 0.25 * np.stack([-(1 - xc), -(1 + xc), (1 + xc), (1 - xc)], 1)
625
+ dw = wx[:, :, None] * dxi[:, None, :] + we[:, :, None] * deta[:, None, :] # (a, 4, 15)
626
+ gapv = xs - np.einsum("ac,aci->ai", w, q)
627
+ dg = np.einsum("ai,aim->am", gapv, dn) - np.einsum("acm,aci,ai->am", dw, q, nf)
628
+ coef = np.concatenate([np.ones((a, 1)), -w], axis=1)
629
+ dv = np.zeros((a, 5, 3, 15))
630
+ dv[:, 1:] -= np.einsum("acm,ai->acim", dw, nf)
631
+ dv += np.einsum("ac,aim->acim", coef, dn)
632
+ return dg, dv.reshape(a, 15, 15)
633
+
634
+ def _friction(self, u, si, nodes, coef, nf, kt, p, dp, v):
635
+ """Coulomb friction per contact pair, incremental from the last
636
+ converged step: the slip since then, P (du_node - sum w du_corner),
637
+ adds -kt P slip to the friction the node carried (its share of it,
638
+ turned into the face's tangent plane), capped at mu p. The tangent
639
+ freezes the normal and the projection point as the normal force
640
+ does."""
641
+ du = (u - self._u0).reshape(-1, 3)
642
+ rel = np.einsum("ac,aci->ai", coef, du[nodes])
643
+ # a node that was not touching at the last converged step only
644
+ # counts the slip after its path crossed the face: the part of
645
+ # the step after the gap then (g - n . slip) closed. One that came
646
+ # in round the face's edge (no gap to close: it slid in sideways)
647
+ # starts sticking where it is now.
648
+ fr = ~self._touch[si]
649
+ sfac = np.zeros(len(si))
650
+ if fr.any():
651
+ gn = np.einsum("ai,ai->a", rel[fr], nf[fr])
652
+ g0 = self._g[fr] - gn
653
+ sfac[fr] = np.where(g0 > 0, np.clip(g0 / np.maximum(g0 - self._g[fr], 1e-300), 0.0, 1.0), 1.0)
654
+ self._early = _early(sfac, fr, p)
655
+ rel = (1.0 - sfac)[:, None] * rel
656
+ P = np.eye(3)[None] - np.einsum("ai,aj->aij", nf, nf)
657
+ ptot = np.zeros(self.mesh.n_nodes)
658
+ np.add.at(ptot, si, p)
659
+ old = self._ft[si] * (p / np.maximum(ptot[si], 1e-300))[:, None]
660
+ old = np.einsum("aij,aj->ai", P, old)
661
+ trial = old - kt[:, None] * np.einsum("aij,aj->ai", P, rel)
662
+ dtrial = -(kt * (1.0 - sfac))[:, None, None, None] * np.einsum("aij,ac->aicj", P, coef)
663
+ dtrial = dtrial.reshape(len(si), 3, 15)
664
+ return _coulomb(trial, self.friction * p, kt, dtrial,
665
+ None if self._symmetric else self.friction * dp[:, None] * v)
666
+
667
+ def _commit(self, u, scale):
668
+ """A step converged: each slave node keeps the friction it carries
669
+ now as the start of the next step's."""
670
+ if self.friction <= 0:
671
+ return
672
+ self._last = None
673
+ self._load(u, scale)
674
+ self._ft[:] = 0.0
675
+ self._touch[:] = False
676
+ if self._last is not None:
677
+ si, ft = self._last
678
+ np.add.at(self._ft, si, ft)
679
+ self._touch[si] = True
680
+ self._u0 = np.asarray(u, float).copy()
681
+
682
+
683
+ def obstacle_from_spec(spec: dict) -> RigidObstacle:
684
+ """A RigidObstacle from its JSON form (the field graph's
685
+ largeDeflection.contact.obstacles items)."""
686
+ kind = spec.get("type")
687
+ kw = {k: spec[k] for k in ("point", "normal", "center", "radius", "axis", "inside", "move", "stiffness",
688
+ "smooth", "friction", "name") if k in spec}
689
+ if kind == "box":
690
+ kw["lo"], kw["hi"] = spec["min"], spec["max"]
691
+ return RigidObstacle(kind, **kw)