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.
- loadpath/README.md +294 -0
- loadpath/SKILL.md +584 -0
- loadpath/__init__.py +17 -0
- loadpath/__main__.py +1202 -0
- loadpath/am.py +316 -0
- loadpath/amgfast.py +326 -0
- loadpath/assembly.py +985 -0
- loadpath/benchmarks/__init__.py +0 -0
- loadpath/benchmarks/beams.py +266 -0
- loadpath/benchmarks/cases/ge-bracket.json +380 -0
- loadpath/benchmarks/evaluate.py +172 -0
- loadpath/benchmarks/mechanisms.py +416 -0
- loadpath/benchmarks/problems.py +140 -0
- loadpath/benchmarks/reference/bridge-3d.top3d.npz +0 -0
- loadpath/benchmarks/reference/cantilever-3d.top3d.npz +0 -0
- loadpath/benchmarks/reference/lbracket-2d.top88-ft2.npz +0 -0
- loadpath/benchmarks/reference/mbb-2d.top88-ft1.npz +0 -0
- loadpath/benchmarks/reference/mbb-2d.top88-ft2.npz +0 -0
- loadpath/benchmarks/reference.py +138 -0
- loadpath/benchmarks/run.py +301 -0
- loadpath/brep.py +1470 -0
- loadpath/bucklingopt.py +300 -0
- loadpath/buildsim.py +1490 -0
- loadpath/cadimport.py +904 -0
- loadpath/calibration.py +885 -0
- loadpath/cavities.py +211 -0
- loadpath/checks/__init__.py +0 -0
- loadpath/checks/dfm.py +354 -0
- loadpath/checks/exchanger.py +7 -0
- loadpath/checks/fea.py +212 -0
- loadpath/checks/fit.py +475 -0
- loadpath/checks/networks.py +260 -0
- loadpath/checks/opening.py +67 -0
- loadpath/checks/sdf_kit.py +7 -0
- loadpath/conjugate.py +267 -0
- loadpath/constrained.py +329 -0
- loadpath/contact.py +691 -0
- loadpath/coopt.py +342 -0
- loadpath/cores.py +163 -0
- loadpath/crease.py +1138 -0
- loadpath/critic.py +212 -0
- loadpath/crosscheck.py +1107 -0
- loadpath/cutcell.py +510 -0
- loadpath/data/lattice_buckling.json +439 -0
- loadpath/data/lattice_tensors.json +27449 -0
- loadpath/data/published_experiments.json +71 -0
- loadpath/decimate.py +177 -0
- loadpath/delivery.py +190 -0
- loadpath/directions.py +262 -0
- loadpath/domain.py +630 -0
- loadpath/drains.py +615 -0
- loadpath/durability.py +413 -0
- loadpath/editor.html +3135 -0
- loadpath/editor.py +610 -0
- loadpath/escape.py +354 -0
- loadpath/evals/__init__.py +0 -0
- loadpath/evals/baselines/coarse.json +1543 -0
- loadpath/evals/baselines/full.json +1497 -0
- loadpath/evals/brief_kinds/__init__.py +1 -0
- loadpath/evals/brief_kinds/heatx.py +387 -0
- loadpath/evals/brief_kinds/lattice.py +571 -0
- loadpath/evals/brief_kinds/multimat.py +593 -0
- loadpath/evals/briefs/assets/bracket.step +1201 -0
- loadpath/evals/briefs/assets/desk_arm.urdf +22 -0
- loadpath/evals/briefs/assets/elbow-gearbox.stl +0 -0
- loadpath/evals/briefs/assets/motor-body.stl +0 -0
- loadpath/evals/briefs/assets/sketch.png +0 -0
- loadpath/evals/briefs/base-adapter-fdm.json +492 -0
- loadpath/evals/briefs/bell-crank-lpbf.json +180 -0
- loadpath/evals/briefs/belt-tensioner-fdm.json +151 -0
- loadpath/evals/briefs/camera-boom-mjf.json +339 -0
- loadpath/evals/briefs/camera-mount-pla.json +203 -0
- loadpath/evals/briefs/coolant-isolator-petg.json +100 -0
- loadpath/evals/briefs/elbow-bearing-bracket-sls.json +300 -0
- loadpath/evals/briefs/elbow-gearbox-forearm-mjf.json +558 -0
- loadpath/evals/briefs/elbow-link-fatigue-fdm.json +251 -0
- loadpath/evals/briefs/elbow-stop-pad-tpu.json +39 -0
- loadpath/evals/briefs/forearm-fdm.json +146 -0
- loadpath/evals/briefs/forearm-servo-link-fdm.json +327 -0
- loadpath/evals/briefs/grip-pad-finger-mm.json +354 -0
- loadpath/evals/briefs/gripper-bracket-creep-fdm.json +340 -0
- loadpath/evals/briefs/gripper-finger-fdm.json +155 -0
- loadpath/evals/briefs/gripper-jaw-fatigue-mjf.json +328 -0
- loadpath/evals/briefs/gripper-palm-fdm.json +715 -0
- loadpath/evals/briefs/hot-stepper-mount-asa-fdm.json +471 -0
- loadpath/evals/briefs/hpu-oil-cooler-lpbf.json +121 -0
- loadpath/evals/briefs/isolated-camera-mount-mm.json +675 -0
- loadpath/evals/briefs/kart-charge-cooler-lpbf.json +93 -0
- loadpath/evals/briefs/lighten-step-bracket.json +220 -0
- loadpath/evals/briefs/nema17-mount-stl.json +429 -0
- loadpath/evals/briefs/pillow-block-sls.json +220 -0
- loadpath/evals/briefs/polyjet-fingertip-mm.json +305 -0
- loadpath/evals/briefs/rod-hook-pla.json +154 -0
- loadpath/evals/briefs/sensor-arm-resin.json +150 -0
- loadpath/evals/briefs/shelf-bracket-pla.json +158 -0
- loadpath/evals/briefs/shoulder-riser-mjf.json +487 -0
- loadpath/evals/briefs/sketch-clevis.json +229 -0
- loadpath/evals/briefs/spring-bracket-pacf.json +294 -0
- loadpath/evals/briefs/tool-flange-ti64.json +280 -0
- loadpath/evals/briefs/tray-arm-lattice-fdm.json +260 -0
- loadpath/evals/briefs/turret-fdm.json +281 -0
- loadpath/evals/briefs/upper-arm-sls.json +179 -0
- loadpath/evals/briefs/urdf-forearm.json +233 -0
- loadpath/evals/briefs/wrist-link-mjf.json +278 -0
- loadpath/evals/briefs/wrist-yoke-mjf.json +245 -0
- loadpath/evals/briefs.py +393 -0
- loadpath/evals/cases/arm-link-cast.json +157 -0
- loadpath/evals/cases/arm-link-driven.json +171 -0
- loadpath/evals/cases/arm-link-sandwich.json +315 -0
- loadpath/evals/cases/arm-link.json +111 -0
- loadpath/evals/cases/bell-crank.json +116 -0
- loadpath/evals/cases/grip-turret.json +479 -0
- loadpath/evals/cases/grip-upper-arm.json +253 -0
- loadpath/evals/cases/grip-wrist-link-mjf.json +929 -0
- loadpath/evals/cases/grip-wrist-yoke.json +831 -0
- loadpath/evals/cases/lpbf-intercooler-core.json +59 -0
- loadpath/evals/cases/mbb-beam.json +61 -0
- loadpath/evals/cases/motor-body.stl +0 -0
- loadpath/evals/cases/motor-mount.json +265 -0
- loadpath/evals/cases/printed-hook.json +77 -0
- loadpath/evals/cases/wall-bracket-fasteners.json +125 -0
- loadpath/evals/cases/wall-bracket.json +126 -0
- loadpath/evals/corpus.py +179 -0
- loadpath/evals/pareto.py +90 -0
- loadpath/evals/run.py +860 -0
- loadpath/examples/arm-link.json +25 -0
- loadpath/examples/bumpered-bracket-compression.json +225 -0
- loadpath/examples/bumpered-bracket-lattice.json +204 -0
- loadpath/examples/bumpered-bracket.json +34 -0
- loadpath/examples/gripper-finger-mm.json +185 -0
- loadpath/examples/heat-exchanger-counterflow.json +21 -0
- loadpath/examples/heat-exchanger-crossflow-tuned.json +48 -0
- loadpath/examples/heat-exchanger-crossflow.json +20 -0
- loadpath/examples/pedal-lattice.json +23 -0
- loadpath/examples/pressure-plenum.json +12 -0
- loadpath/examples/shelf-bracket.json +27 -0
- loadpath/exchanger.py +831 -0
- loadpath/fasteners.py +199 -0
- loadpath/fem.py +668 -0
- loadpath/femops.py +70 -0
- loadpath/fieldjit.py +981 -0
- loadpath/fieldops.py +119 -0
- loadpath/fields.py +3697 -0
- loadpath/fieldtorch.py +464 -0
- loadpath/flow.py +984 -0
- loadpath/flowtopo.py +300 -0
- loadpath/gpu.py +521 -0
- loadpath/heatx.py +1067 -0
- loadpath/heatx_thermal.py +432 -0
- loadpath/homogenized.py +886 -0
- loadpath/ibrep.py +552 -0
- loadpath/impact.py +693 -0
- loadpath/infill.py +376 -0
- loadpath/interface_library.py +340 -0
- loadpath/interlock.py +186 -0
- loadpath/isolator.py +149 -0
- loadpath/knn.py +204 -0
- loadpath/lattice.py +1065 -0
- loadpath/lattice3mf.py +760 -0
- loadpath/lattice_buckling.py +245 -0
- loadpath/lattice_grade.py +325 -0
- loadpath/lattice_orient.py +595 -0
- loadpath/lattice_print.py +159 -0
- loadpath/lattice_tensors.py +558 -0
- loadpath/latticestep.py +1210 -0
- loadpath/manufacture.py +446 -0
- loadpath/material_library.py +489 -0
- loadpath/materials.py +144 -0
- loadpath/mcp_server.py +786 -0
- loadpath/mechanism.py +218 -0
- loadpath/mechanism_nl.py +811 -0
- loadpath/meshops.py +452 -0
- loadpath/mg.py +853 -0
- loadpath/mma.py +214 -0
- loadpath/modal.py +210 -0
- loadpath/multimat.py +1788 -0
- loadpath/narrowband.py +418 -0
- loadpath/nlkernel.py +241 -0
- loadpath/nonlinear.py +1095 -0
- loadpath/orient.py +334 -0
- loadpath/porous.py +994 -0
- loadpath/pressure.py +171 -0
- loadpath/preview.py +335 -0
- loadpath/printcheck.py +263 -0
- loadpath/printsetup.py +207 -0
- loadpath/processcheck.py +376 -0
- loadpath/quick.py +113 -0
- loadpath/recall.py +406 -0
- loadpath/reconstruct.py +797 -0
- loadpath/render.py +436 -0
- loadpath/resolved.py +189 -0
- loadpath/schema.py +1701 -0
- loadpath/sdf_kit.py +843 -0
- loadpath/selfx.py +292 -0
- loadpath/shapes.py +584 -0
- loadpath/sharp.py +717 -0
- loadpath/simp.py +1170 -0
- loadpath/sizing.py +312 -0
- loadpath/slicer.py +321 -0
- loadpath/spec.py +1072 -0
- loadpath/step.py +806 -0
- loadpath/struts.py +639 -0
- loadpath/study.py +1937 -0
- loadpath/tasks.py +822 -0
- loadpath/thermal.py +1691 -0
- loadpath/threemf.py +487 -0
- loadpath/tune.py +437 -0
- loadpath/validation.py +145 -0
- loadpath/verify.py +1215 -0
- loadpath/viewer.html +694 -0
- loadpath/viewer.py +446 -0
- loadpath/viscoelastic.py +106 -0
- loadpath/voxelprint.py +582 -0
- loadpath/voxels.py +402 -0
- loadpath-0.1.1.dist-info/METADATA +319 -0
- loadpath-0.1.1.dist-info/RECORD +220 -0
- loadpath-0.1.1.dist-info/WHEEL +5 -0
- loadpath-0.1.1.dist-info/entry_points.txt +2 -0
- loadpath-0.1.1.dist-info/licenses/LICENSE +21 -0
- 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)
|