Spaces:
Running
Running
| """ | |
| Bar-and-hinge physics model. | |
| Three energy components: | |
| E_total = E_bar + E_facet + E_fold | |
| Stiffness parameters are derived from the material properties. | |
| """ | |
| from __future__ import annotations | |
| from dataclasses import dataclass | |
| import numpy as np | |
| from .paper import Paper | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # Stiffness | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| class StiffnessParams: | |
| """Stiffness values derived from material properties.""" | |
| k_axial: np.ndarray # per-edge axial stiffness (E,) | |
| k_facet: float # facet (panel bending) stiffness | |
| k_fold: float # fold (crease torsion) stiffness | |
| def compute_stiffness(paper: Paper) -> StiffnessParams: | |
| """Derive stiffness parameters from the paper's material and geometry. | |
| k_axial = E * t * w / L0 (per edge, w β average of adjacent edge lengths) | |
| k_facet = E * t^3 / (12 * (1 - nu^2)) | |
| k_fold = 0.1 * k_facet (crease torsional stiffness, empirical fraction) | |
| """ | |
| mat = paper.material | |
| E = mat.youngs_modulus_pa # Pa | |
| t = mat.thickness_m # m | |
| nu = mat.poissons_ratio | |
| rest = paper.rest_lengths | |
| # Guard against zero rest lengths | |
| safe_rest = np.where(rest > 1e-15, rest, 1e-15) | |
| # Approximate edge width as the average rest length (simple heuristic) | |
| w = np.mean(safe_rest) if len(safe_rest) > 0 else 1e-3 | |
| k_axial = E * t * w / safe_rest # (E,) | |
| k_facet = E * t ** 3 / (12.0 * (1.0 - nu ** 2)) | |
| # Crease torsional stiffness β a fraction of facet stiffness | |
| k_fold = 0.1 * k_facet | |
| return StiffnessParams(k_axial=k_axial, k_facet=k_facet, k_fold=k_fold) | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # Energy components | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def compute_bar_energy(paper: Paper) -> float: | |
| """E_bar = sum (1/2) * k_axial * (L - L0)^2 | |
| Measures stretching / compression of edges relative to rest lengths. | |
| """ | |
| if len(paper.edges) == 0: | |
| return 0.0 | |
| verts = paper.vertices | |
| edges = paper.edges | |
| current_lengths = np.array([ | |
| np.linalg.norm(verts[e[1]] - verts[e[0]]) for e in edges | |
| ]) | |
| stiff = compute_stiffness(paper) | |
| delta = current_lengths - paper.rest_lengths | |
| energy = 0.5 * np.sum(stiff.k_axial * delta ** 2) | |
| return float(energy) | |
| def compute_facet_energy(paper: Paper) -> float: | |
| """E_facet = sum (1/2) * k_facet * l * (theta - pi)^2 | |
| Measures bending of facet panels away from flat (pi). | |
| *l* is the edge length (hinge length) and *theta* is the dihedral angle | |
| across the edge between two adjacent faces. For edges that are not | |
| shared by two faces we skip them. | |
| """ | |
| if len(paper.edges) == 0 or len(paper.faces) < 2: | |
| return 0.0 | |
| stiff = compute_stiffness(paper) | |
| verts = paper.vertices | |
| edges = paper.edges | |
| # Build edge β face adjacency | |
| edge_faces: dict[int, list[int]] = {} | |
| for fi, face in enumerate(paper.faces): | |
| n = len(face) | |
| for k in range(n): | |
| va, vb = face[k], face[(k + 1) % n] | |
| for ei, e in enumerate(edges): | |
| if (e[0] == va and e[1] == vb) or (e[0] == vb and e[1] == va): | |
| edge_faces.setdefault(ei, []).append(fi) | |
| break | |
| energy = 0.0 | |
| for ei, adj_faces in edge_faces.items(): | |
| if len(adj_faces) < 2: | |
| continue | |
| # Only consider non-fold edges (flat or boundary interior) | |
| if paper.assignments[ei] in ("M", "V"): | |
| continue | |
| f1, f2 = adj_faces[0], adj_faces[1] | |
| theta = _dihedral_angle(verts, paper.faces[f1], paper.faces[f2], edges[ei]) | |
| l = np.linalg.norm(verts[edges[ei][1]] - verts[edges[ei][0]]) | |
| energy += 0.5 * stiff.k_facet * l * (theta - np.pi) ** 2 | |
| return float(energy) | |
| def compute_fold_energy(paper: Paper) -> float: | |
| """E_fold = sum (1/2) * k_fold * l * (rho - rho_target)^2 | |
| Measures deviation of fold creases from their target angles. | |
| *rho* is the current dihedral angle across the fold edge and | |
| *rho_target* comes from ``fold_angles``. | |
| """ | |
| if len(paper.edges) == 0: | |
| return 0.0 | |
| stiff = compute_stiffness(paper) | |
| verts = paper.vertices | |
| edges = paper.edges | |
| # Build edge β face adjacency | |
| edge_faces: dict[int, list[int]] = {} | |
| for fi, face in enumerate(paper.faces): | |
| n = len(face) | |
| for k in range(n): | |
| va, vb = face[k], face[(k + 1) % n] | |
| for ei, e in enumerate(edges): | |
| if (e[0] == va and e[1] == vb) or (e[0] == vb and e[1] == va): | |
| edge_faces.setdefault(ei, []).append(fi) | |
| break | |
| energy = 0.0 | |
| for ei in range(len(edges)): | |
| if paper.assignments[ei] not in ("M", "V"): | |
| continue | |
| if ei not in edge_faces or len(edge_faces[ei]) < 2: | |
| continue | |
| f1, f2 = edge_faces[ei][0], edge_faces[ei][1] | |
| rho = _dihedral_angle(verts, paper.faces[f1], paper.faces[f2], edges[ei]) | |
| rho_target = np.radians(paper.fold_angles[ei]) # fold_angles stored in degrees | |
| l = np.linalg.norm(verts[edges[ei][1]] - verts[edges[ei][0]]) | |
| energy += 0.5 * stiff.k_fold * l * (rho - rho_target) ** 2 | |
| return float(energy) | |
| def compute_total_energy(paper: Paper) -> float: | |
| """E_total = E_bar + E_facet + E_fold.""" | |
| return compute_bar_energy(paper) + compute_facet_energy(paper) + compute_fold_energy(paper) | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # Strain | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def compute_strain(paper: Paper) -> np.ndarray: | |
| """Per-vertex Cauchy strain: average fractional edge-length deviation. | |
| Returns shape (N,) array of non-negative strain values. | |
| """ | |
| n_verts = len(paper.vertices) | |
| if n_verts == 0: | |
| return np.empty(0) | |
| verts = paper.vertices | |
| edges = paper.edges | |
| rest = paper.rest_lengths | |
| # Build vertex β edge adjacency | |
| vert_edges: dict[int, list[int]] = {} | |
| for ei, e in enumerate(edges): | |
| vert_edges.setdefault(int(e[0]), []).append(ei) | |
| vert_edges.setdefault(int(e[1]), []).append(ei) | |
| strain = np.zeros(n_verts, dtype=np.float64) | |
| for vi in range(n_verts): | |
| adj = vert_edges.get(vi, []) | |
| if not adj: | |
| continue | |
| devs = [] | |
| for ei in adj: | |
| v1, v2 = edges[ei] | |
| L = np.linalg.norm(verts[v1] - verts[v2]) | |
| L0 = rest[ei] | |
| if L0 > 1e-15: | |
| devs.append(abs(L - L0) / L0) | |
| if devs: | |
| strain[vi] = float(np.mean(devs)) | |
| return strain | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # Dihedral angle helper | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def _dihedral_angle( | |
| verts: np.ndarray, | |
| face1: list[int], | |
| face2: list[int], | |
| edge: np.ndarray, | |
| ) -> float: | |
| """Compute the dihedral angle (in radians) between two faces sharing *edge*. | |
| Returns angle in [0, 2*pi). Returns pi if normals cannot be computed. | |
| """ | |
| n1 = _face_normal(verts, face1) | |
| n2 = _face_normal(verts, face2) | |
| if n1 is None or n2 is None: | |
| return np.pi | |
| cos_a = np.clip(np.dot(n1, n2), -1.0, 1.0) | |
| angle = np.arccos(cos_a) | |
| # Determine sign from edge direction | |
| edge_dir = verts[edge[1]] - verts[edge[0]] | |
| edge_dir = edge_dir / (np.linalg.norm(edge_dir) + 1e-30) | |
| cross = np.cross(n1, n2) | |
| if np.dot(cross, edge_dir) < 0: | |
| angle = 2.0 * np.pi - angle | |
| return float(angle) | |
| def _face_normal(verts: np.ndarray, face: list[int]) -> np.ndarray | None: | |
| """Compute outward unit normal of a face, or None if degenerate.""" | |
| if len(face) < 3: | |
| return None | |
| v0 = verts[face[0]] | |
| v1 = verts[face[1]] | |
| v2 = verts[face[2]] | |
| normal = np.cross(v1 - v0, v2 - v0) | |
| norm = np.linalg.norm(normal) | |
| if norm < 1e-15: | |
| return None | |
| return normal / norm | |