Spaces:
Running
Running
File size: 5,745 Bytes
eb983df | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 | """Origami fold simulator — analytical rotation with cumulative transforms.
BFS from face 0 through the face adjacency graph. Each face accumulates
a rotation transform (R, t) such that: folded_pos = R @ flat_pos + t.
When crossing a fold edge, the fold rotation is composed with the parent
face's transform. Non-fold edges inherit the parent's transform directly.
This correctly handles multiple intersecting folds (e.g. quarter fold)
because each face's transform captures ALL upstream folds.
"""
from dataclasses import dataclass
import numpy as np
from scipy.spatial.transform import Rotation
from .fold_parser import parse_fold
@dataclass
class SimResult:
"""Result of a fold simulation."""
positions: np.ndarray # (N, 3) final vertex positions
converged: bool
steps_taken: int
max_strain: float
total_energy: float
def simulate(
fold_data: dict,
crease_percent: float = 1.0,
max_steps: int = 500,
params: dict | None = None,
) -> SimResult:
"""Simulate a FOLD crease pattern and return final 3D positions.
Uses cumulative rotation transforms per face. BFS from face 0,
composing fold rotations at each crease edge.
Args:
fold_data: FOLD-format dict with vertices, edges, assignments, angles.
crease_percent: 0.0 = flat, 1.0 = fully folded.
max_steps: Unused (kept for API compat).
params: Unused (kept for API compat).
Returns:
SimResult with final positions, strain info.
"""
parsed = parse_fold(fold_data)
flat_pos = parsed["vertices"].copy()
edges = parsed["edges"]
assignments = parsed["assignments"]
fold_angles = parsed["fold_angles"]
faces = parsed["faces"]
positions = flat_pos.copy()
if len(faces) == 0:
return SimResult(
positions=positions, converged=True,
steps_taken=0, max_strain=0.0, total_energy=0.0,
)
# Build face adjacency: edge -> [face_idx, ...]
face_adj = _build_face_adjacency(faces)
# Build crease map: (v_min, v_max) -> fold_angle_rad * crease_percent
crease_map: dict[tuple[int, int], float] = {}
for i, (v1, v2) in enumerate(edges):
key = (min(int(v1), int(v2)), max(int(v1), int(v2)))
if assignments[i] in ("M", "V"):
crease_map[key] = fold_angles[i] * crease_percent
# Per-face cumulative transform: folded = R @ flat + t
n_faces = len(faces)
face_R = [None] * n_faces
face_t = [None] * n_faces
# Face 0 is fixed (identity transform)
face_R[0] = np.eye(3)
face_t[0] = np.zeros(3)
visited = [False] * n_faces
visited[0] = True
placed: set[int] = set()
for vi in faces[0]:
placed.add(int(vi))
queue = [0]
while queue:
fi = queue.pop(0)
face = faces[fi]
for j in range(len(face)):
v1, v2 = int(face[j]), int(face[(j + 1) % len(face)])
edge_key = (min(v1, v2), max(v1, v2))
for fj in face_adj.get(edge_key, []):
if visited[fj]:
continue
visited[fj] = True
queue.append(fj)
angle = crease_map.get(edge_key, 0.0)
if abs(angle) > 1e-10:
# Fold rotation around the edge in folded space
p1 = positions[v1].copy()
axis = positions[v2] - p1
axis_len = np.linalg.norm(axis)
if axis_len > 1e-12:
axis_unit = axis / axis_len
fold_rot = Rotation.from_rotvec(
angle * axis_unit,
).as_matrix()
else:
fold_rot = np.eye(3)
# Compose: R_fj = fold_rot @ R_fi, t_fj adjusted for pivot
face_R[fj] = fold_rot @ face_R[fi]
face_t[fj] = fold_rot @ (face_t[fi] - p1) + p1
else:
# No fold — inherit parent's transform
face_R[fj] = face_R[fi].copy()
face_t[fj] = face_t[fi].copy()
# Place unplaced vertices using this face's transform
for vi in faces[fj]:
vi_int = int(vi)
if vi_int not in placed:
positions[vi_int] = face_R[fj] @ flat_pos[vi_int] + face_t[fj]
placed.add(vi_int)
# Compute strain (deviation from rest edge lengths)
max_strain = _compute_strain(positions, parsed)
return SimResult(
positions=positions,
converged=True,
steps_taken=1,
max_strain=max_strain,
total_energy=0.0,
)
def _build_face_adjacency(
faces: np.ndarray,
) -> dict[tuple[int, int], list[int]]:
"""Map each edge (sorted vertex pair) to list of face indices."""
adj: dict[tuple[int, int], list[int]] = {}
for fi, face in enumerate(faces):
n = len(face)
for j in range(n):
v1, v2 = int(face[j]), int(face[(j + 1) % n])
key = (min(v1, v2), max(v1, v2))
if key not in adj:
adj[key] = []
adj[key].append(fi)
return adj
def _compute_strain(positions: np.ndarray, parsed: dict) -> float:
"""Compute max axial strain across all edges."""
edges = parsed["edges"]
vertices_flat = parsed["vertices"]
max_strain = 0.0
for v1, v2 in edges:
rest = np.linalg.norm(vertices_flat[v2] - vertices_flat[v1])
curr = np.linalg.norm(positions[v2] - positions[v1])
if rest > 1e-12:
strain = abs(curr - rest) / rest
max_strain = max(max_strain, strain)
return max_strain
|