| """Co-rotated strain utilities and the tied linear (PCA) strain basis. |
| |
| 3D analog of the 2D result: after analytic co-rotation, per-tet strain |
| lives in the 6-dim symmetric space; the tied basis compresses it to k |
| components shared across every tet, material, and scenario. |
| |
| vec6 uses sqrt(2) off-diagonal scaling so Euclidean norm == Frobenius |
| norm of the symmetric matrix (keeps PCA variance and constraint energy |
| consistent). |
| """ |
| import numpy as np |
|
|
| _SQRT2 = np.sqrt(2.0) |
|
|
|
|
| def sym_to_vec6(S): |
| """(N,3,3) symmetric -> (N,6): [xx, yy, zz, √2·xy, √2·yz, √2·xz].""" |
| return np.stack([ |
| S[:, 0, 0], S[:, 1, 1], S[:, 2, 2], |
| _SQRT2 * S[:, 0, 1], _SQRT2 * S[:, 1, 2], _SQRT2 * S[:, 0, 2], |
| ], axis=1) |
|
|
|
|
| def vec6_to_sym(v): |
| """(N,6) -> (N,3,3) symmetric.""" |
| S = np.empty((v.shape[0], 3, 3)) |
| S[:, 0, 0], S[:, 1, 1], S[:, 2, 2] = v[:, 0], v[:, 1], v[:, 2] |
| S[:, 0, 1] = S[:, 1, 0] = v[:, 3] / _SQRT2 |
| S[:, 1, 2] = S[:, 2, 1] = v[:, 4] / _SQRT2 |
| S[:, 0, 2] = S[:, 2, 0] = v[:, 5] / _SQRT2 |
| return S |
|
|
|
|
| def corotated_strain(F, R): |
| """e = sym(R^T F) - I as (N,6). The skew part of R^T F is extraction |
| residual, not strain — discard it, exactly as in 2D.""" |
| RtF = np.einsum("nji,njk->nik", R, F) |
| S = 0.5 * (RtF + RtF.transpose(0, 2, 1)) - np.eye(3) |
| return sym_to_vec6(S) |
|
|
|
|
| class TiedStrainBasis: |
| """One linear basis (6,k) shared by every tet — the tied embedding. |
| |
| Uncentered PCA (SVD of the raw sample matrix): rest strain is 0 and |
| the constraint energy is a norm around 0, so no mean removal. |
| """ |
|
|
| def __init__(self, B): |
| self.B = B |
|
|
| @classmethod |
| def fit(cls, samples, k): |
| """samples (N,6) -> basis of top-k right singular vectors.""" |
| _, s, Vt = np.linalg.svd(samples, full_matrices=False) |
| basis = cls(Vt[:k].T.copy()) |
| basis.singular_values = s |
| return basis |
|
|
| @property |
| def k(self): |
| return self.B.shape[1] |
|
|
| def encode(self, e): |
| return e @ self.B |
|
|
| def decode(self, z): |
| return z @ self.B.T |
|
|
| def project(self, e): |
| return self.decode(self.encode(e)) |
|
|
| def coverage(self, samples): |
| """Fraction of strain energy reconstructed: 1 - ||e - BBᵀe||²/||e||².""" |
| num = ((samples - self.project(samples)) ** 2).sum() |
| den = (samples ** 2).sum() |
| return 1.0 - num / max(den, 1e-30) |
|
|