optigami / engine /physics.py
sissississi's picture
Add physics engine (Layer 4) and instruction planner (Layer 1)
efeed27
raw
history blame
9.2 kB
"""
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
# ────────────────────────────────────────────────────────────────────
@dataclass
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