ianalin123 Claude Sonnet 4.6 commited on
Commit
94ab3fc
Β·
1 Parent(s): cecbed6

feat: Python 3D origami mass-spring simulator (Ghassaei 2018)

Browse files

sim/simulator.py: OrigamiSimulator class
- Triangulated mesh via FOLD faces_vertices or scipy Delaunay
- 2-pass midpoint subdivision (~64 triangles for typical targets)
- Axial springs (vectorized NumPy) + torsional crease springs
- Dihedral angle via atan2(crossΒ·edge, dot) β€” 0 at flat, Β±Ο€ fully folded
- Ghassaei moment-arm force decomp for 4-node crease config
- Euler integration with per-beam velocity damping

sim/animate.py: matplotlib 3D animation
- Triangle-wave fold 0β†’100β†’0% with Poly3DCollection
- Mountain=amber, Valley=sky design system colors

Verified: non-zero z-displacement at 100% fold for all 5 targets tested

Run: python -m sim.animate half_horizontal

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

Files changed (4) hide show
  1. requirements.txt +2 -0
  2. sim/__init__.py +0 -0
  3. sim/animate.py +149 -0
  4. sim/simulator.py +406 -0
requirements.txt CHANGED
@@ -1,5 +1,7 @@
1
  shapely>=2.0.0
2
  numpy>=1.24.0
 
 
3
  pytest>=7.0.0
4
  fastapi>=0.100.0
5
  uvicorn>=0.23.0
 
1
  shapely>=2.0.0
2
  numpy>=1.24.0
3
+ scipy>=1.10.0
4
+ matplotlib>=3.7.0
5
  pytest>=7.0.0
6
  fastapi>=0.100.0
7
  uvicorn>=0.23.0
sim/__init__.py ADDED
File without changes
sim/animate.py ADDED
@@ -0,0 +1,149 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Matplotlib 3D animation of origami folding using OrigamiSimulator.
3
+
4
+ Usage:
5
+ python -m sim.animate [target_name]
6
+
7
+ target_name defaults to 'half_horizontal', resolved against
8
+ env/targets/<target_name>.fold relative to this file's parent directory.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ import sys
15
+ from pathlib import Path
16
+
17
+ import matplotlib.pyplot as plt
18
+ import matplotlib.animation as animation
19
+ import numpy as np
20
+ from mpl_toolkits.mplot3d.art3d import Poly3DCollection
21
+
22
+ from .simulator import OrigamiSimulator
23
+
24
+ # ── Design system colours ─────────────────────────────────────────────────────
25
+ BG_COLOR = '#0d0d14'
26
+ AX_COLOR = '#13131d'
27
+ PAPER_FACE = '#fafaf5'
28
+ PAPER_EDGE = '#2a2a3a'
29
+ MOUNTAIN_CLR = '#f59e0b' # amber
30
+ VALLEY_CLR = '#38bdf8' # sky
31
+
32
+
33
+ # ── Public API ────────────────────────────────────────────────────────────────
34
+
35
+ def animate_fold(fold_file: str,
36
+ n_frames: int = 80,
37
+ steps_per_frame: int = 40,
38
+ target_name: str = 'origami') -> None:
39
+ """
40
+ Animate folding from 0% β†’ 100% β†’ 0% in a triangle-wave loop.
41
+
42
+ Parameters
43
+ ----------
44
+ fold_file : str
45
+ Path to the .fold JSON file.
46
+ n_frames : int
47
+ Total animation frames (default 80 β†’ ~40 in, 40 out).
48
+ steps_per_frame : int
49
+ Physics steps executed per frame.
50
+ target_name : str
51
+ Display name shown in the title.
52
+ """
53
+ fold_data = json.loads(Path(fold_file).read_text())
54
+ sim = OrigamiSimulator(fold_data, subdivisions=2)
55
+
56
+ # Triangle-wave fold percents: 0 β†’ 1 β†’ 0
57
+ half = n_frames // 2
58
+ fold_percents = np.concatenate([
59
+ np.linspace(0.0, 1.0, half),
60
+ np.linspace(1.0, 0.0, n_frames - half),
61
+ ])
62
+
63
+ # ── Figure setup ──────────────────────────────────────────────────────────
64
+ fig = plt.figure(figsize=(9, 7), facecolor=BG_COLOR)
65
+ ax = fig.add_subplot(111, projection='3d')
66
+ ax.set_facecolor(AX_COLOR)
67
+ ax.xaxis.pane.fill = False
68
+ ax.yaxis.pane.fill = False
69
+ ax.zaxis.pane.fill = False
70
+ ax.grid(False)
71
+ ax.set_axis_off()
72
+
73
+ def update(frame: int) -> list:
74
+ pct = fold_percents[frame]
75
+ sim.set_fold_percent(pct)
76
+ sim.step(steps_per_frame)
77
+
78
+ ax.clear()
79
+ ax.set_facecolor(AX_COLOR)
80
+ ax.xaxis.pane.fill = False
81
+ ax.yaxis.pane.fill = False
82
+ ax.zaxis.pane.fill = False
83
+ ax.grid(False)
84
+ ax.set_axis_off()
85
+
86
+ # ── Paper surface ─────────────────────────────────────────────────────
87
+ verts = [sim.pos[tri] for tri in sim.triangles]
88
+ poly = Poly3DCollection(
89
+ verts,
90
+ alpha=0.85,
91
+ facecolor=PAPER_FACE,
92
+ edgecolor=PAPER_EDGE,
93
+ linewidth=0.2,
94
+ zorder=1,
95
+ )
96
+ ax.add_collection3d(poly)
97
+
98
+ # ── Crease / fold edges ───────────────────────────────────────────────
99
+ for i in range(len(sim._crease_a)):
100
+ if sim._crease_assign[i] not in ('M', 'V'):
101
+ continue
102
+ a, b = sim._crease_a[i], sim._crease_b[i]
103
+ color = MOUNTAIN_CLR if sim._crease_assign[i] == 'M' else VALLEY_CLR
104
+ ax.plot(
105
+ [sim.pos[a, 0], sim.pos[b, 0]],
106
+ [sim.pos[a, 1], sim.pos[b, 1]],
107
+ [sim.pos[a, 2], sim.pos[b, 2]],
108
+ color=color,
109
+ linewidth=2.5,
110
+ zorder=2,
111
+ )
112
+
113
+ # ── Axis limits & style ───────────────────────────────────────────────
114
+ ax.set_xlim(-0.2, 1.2)
115
+ ax.set_ylim(-0.2, 1.2)
116
+ ax.set_zlim(-0.6, 0.6)
117
+ ax.set_box_aspect([1.4, 1.4, 1.0])
118
+ ax.set_title(
119
+ f'OPTIGAMI β€” {target_name} fold: {pct * 100:.0f}%',
120
+ color='#e0e0f0',
121
+ fontsize=13,
122
+ pad=10,
123
+ )
124
+
125
+ return []
126
+
127
+ ani = animation.FuncAnimation(
128
+ fig,
129
+ update,
130
+ frames=n_frames,
131
+ interval=40, # ms between frames (~25 fps)
132
+ blit=False,
133
+ )
134
+
135
+ plt.tight_layout()
136
+ plt.show()
137
+
138
+
139
+ def main() -> None:
140
+ target = sys.argv[1] if len(sys.argv) > 1 else 'half_horizontal'
141
+ fold_file = Path(__file__).parent.parent / 'env' / 'targets' / f'{target}.fold'
142
+ if not fold_file.exists():
143
+ print(f'Error: fold file not found: {fold_file}', file=sys.stderr)
144
+ sys.exit(1)
145
+ animate_fold(str(fold_file), target_name=target)
146
+
147
+
148
+ if __name__ == '__main__':
149
+ main()
sim/simulator.py ADDED
@@ -0,0 +1,406 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Origami mass-spring dynamic relaxation simulator.
3
+
4
+ Based on: Ghassaei et al., "Fast, Interactive Origami Simulation using GPU
5
+ Computation", 7OSME 2018.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import numpy as np
11
+ from scipy.spatial import Delaunay
12
+
13
+ # ── Physics constants ────────────────────────────────────────────────────────
14
+
15
+ AXIAL_STIFFNESS = 20.0 # K = AXIAL_STIFFNESS / rest_length
16
+ CREASE_STIFFNESS = 0.7 # K = CREASE_STIFFNESS * edge_length (M/V creases)
17
+ PANEL_STIFFNESS = 0.7 # K = PANEL_STIFFNESS * edge_length (F / panel edges)
18
+ PERCENT_DAMPING = 0.45 # global viscous damping fraction
19
+ DT = 0.002 # timestep (seconds)
20
+
21
+
22
+ # ── Geometry helpers ─────────────────────────────────────────────────────────
23
+
24
+ def _normalize(v: np.ndarray) -> np.ndarray:
25
+ n = np.linalg.norm(v)
26
+ return v / n if n > 1e-12 else v
27
+
28
+
29
+ def _triangulate_faces(faces_vertices: list[list[int]]) -> np.ndarray:
30
+ """Fan-triangulate polygonal faces (triangles and quads supported)."""
31
+ tris = []
32
+ for face in faces_vertices:
33
+ if len(face) == 3:
34
+ tris.append(face)
35
+ elif len(face) == 4:
36
+ a, b, c, d = face
37
+ tris.append([a, b, c])
38
+ tris.append([a, c, d])
39
+ else:
40
+ # General fan triangulation for n-gons
41
+ for k in range(1, len(face) - 1):
42
+ tris.append([face[0], face[k], face[k + 1]])
43
+ return np.array(tris, dtype=np.int32)
44
+
45
+
46
+ def _point_on_segment(p: np.ndarray, p0: np.ndarray, p1: np.ndarray,
47
+ tol: float = 1e-6) -> bool:
48
+ seg = p1 - p0
49
+ seg_len = np.linalg.norm(seg)
50
+ if seg_len < 1e-10:
51
+ return False
52
+ seg_dir = seg / seg_len
53
+ t = np.dot(p - p0, seg_dir)
54
+ perp = (p - p0) - t * seg_dir
55
+ return -tol <= t <= seg_len + tol and np.linalg.norm(perp) < tol
56
+
57
+
58
+ # ── Mesh subdivision ──────────────────────────────────────────────────────────
59
+
60
+ def _subdivide(pos2d: np.ndarray, triangles: np.ndarray
61
+ ) -> tuple[np.ndarray, np.ndarray]:
62
+ """Split each triangle into 4 by inserting edge midpoints."""
63
+ midpoint_cache: dict[tuple[int, int], int] = {}
64
+ new_pos = list(pos2d)
65
+ new_tris = []
66
+
67
+ def get_mid(i: int, j: int) -> int:
68
+ key = (min(i, j), max(i, j))
69
+ if key not in midpoint_cache:
70
+ mid = (np.array(new_pos[i]) + np.array(new_pos[j])) / 2.0
71
+ midpoint_cache[key] = len(new_pos)
72
+ new_pos.append(mid)
73
+ return midpoint_cache[key]
74
+
75
+ for tri in triangles:
76
+ a, b, c = tri
77
+ ab = get_mid(a, b)
78
+ bc = get_mid(b, c)
79
+ ca = get_mid(c, a)
80
+ new_tris.extend([
81
+ [a, ab, ca],
82
+ [ab, b, bc],
83
+ [ca, bc, c ],
84
+ [ab, bc, ca],
85
+ ])
86
+
87
+ return np.array(new_pos, dtype=np.float64), np.array(new_tris, dtype=np.int32)
88
+
89
+
90
+ # ── Main simulator ────────────────────────────────────────────────────────────
91
+
92
+ class OrigamiSimulator:
93
+ """
94
+ Mass-spring dynamic relaxation simulator for origami.
95
+
96
+ Parameters
97
+ ----------
98
+ fold_data : dict
99
+ Parsed FOLD JSON with keys: vertices_coords, edges_vertices,
100
+ edges_assignment.
101
+ subdivisions : int
102
+ Number of midpoint subdivision passes (default 2 β†’ 4Γ— mesh density).
103
+ """
104
+
105
+ def __init__(self, fold_data: dict, subdivisions: int = 2) -> None:
106
+ self._fold_percent = 0.0
107
+ self._build(fold_data, subdivisions)
108
+
109
+ # ── Public API ────────────────────────────────────────────────────────────
110
+
111
+ def set_fold_percent(self, percent: float) -> None:
112
+ """Update all crease spring target angles (0.0 = flat, 1.0 = fully folded)."""
113
+ self._fold_percent = float(percent)
114
+ self._crease_target = self._fold_percent * self._crease_full_theta
115
+
116
+ def step(self, n_steps: int = 50) -> None:
117
+ """Advance the simulation by n_steps Euler integration steps."""
118
+ for _ in range(n_steps):
119
+ self._euler_step()
120
+
121
+ def reset(self) -> None:
122
+ """Reset to flat state (z=0, vel=0), preserving current fold percent."""
123
+ self.pos = self._flat_pos.copy()
124
+ self.vel[:] = 0.0
125
+
126
+ @property
127
+ def crease_indices(self) -> list[tuple[int, int, str]]:
128
+ """Return list of (a, b, assignment) for all crease springs."""
129
+ return list(zip(
130
+ self._crease_a.tolist(),
131
+ self._crease_b.tolist(),
132
+ self._crease_assign,
133
+ ))
134
+
135
+ # ── Build ─────────────────────────────────────────────────────────────────
136
+
137
+ def _build(self, fold_data: dict, subdivisions: int) -> None:
138
+ coords = fold_data['vertices_coords']
139
+ orig_edges = fold_data['edges_vertices']
140
+ orig_assign = fold_data['edges_assignment']
141
+
142
+ # Original 2-D positions
143
+ pts2d = np.array([[x, y] for x, y in coords], dtype=np.float64)
144
+
145
+ # Build triangles from faces_vertices when available (preferred: ensures
146
+ # crease edges appear as actual mesh edges after subdivision).
147
+ # Quads [a,b,c,d] are split into [a,b,c] + [a,c,d].
148
+ # Fall back to Delaunay only if faces_vertices is absent.
149
+ if 'faces_vertices' in fold_data:
150
+ triangles = _triangulate_faces(fold_data['faces_vertices'])
151
+ else:
152
+ tri = Delaunay(pts2d)
153
+ triangles = tri.simplices.astype(np.int32)
154
+
155
+ # Build original crease segments for later classification
156
+ # Only M and V assignments are actual fold creases; B is boundary.
157
+ orig_creases: list[tuple[np.ndarray, np.ndarray, str]] = []
158
+ for (u, v), asgn in zip(orig_edges, orig_assign):
159
+ if asgn in ('M', 'V'):
160
+ orig_creases.append((pts2d[u], pts2d[v], asgn))
161
+
162
+ # Midpoint subdivision passes
163
+ pos2d = pts2d.copy()
164
+ for _ in range(subdivisions):
165
+ pos2d, triangles = _subdivide(pos2d, triangles)
166
+
167
+ n = len(pos2d)
168
+
169
+ # 3-D positions (flat, z=0)
170
+ pos3d = np.zeros((n, 3), dtype=np.float64)
171
+ pos3d[:, :2] = pos2d
172
+
173
+ self.pos = pos3d
174
+ self._flat_pos = pos3d.copy()
175
+ self.vel = np.zeros((n, 3), dtype=np.float64)
176
+ self.triangles = triangles
177
+
178
+ self._build_beams(triangles)
179
+ self._build_masses(triangles)
180
+ self._build_creases(triangles, pos2d, orig_creases)
181
+
182
+ def _build_beams(self, triangles: np.ndarray) -> None:
183
+ """Collect all unique triangle edges as structural (axial) springs."""
184
+ edge_set: set[tuple[int, int]] = set()
185
+ for tri in triangles:
186
+ a, b, c = tri
187
+ for i, j in [(a, b), (b, c), (c, a)]:
188
+ edge_set.add((min(i, j), max(i, j)))
189
+
190
+ edges = np.array(sorted(edge_set), dtype=np.int32)
191
+ i_arr = edges[:, 0]
192
+ j_arr = edges[:, 1]
193
+
194
+ rest = np.linalg.norm(self.pos[i_arr] - self.pos[j_arr], axis=1)
195
+ K = AXIAL_STIFFNESS / np.maximum(rest, 1e-12)
196
+
197
+ self._beam_i = i_arr
198
+ self._beam_j = j_arr
199
+ self._beam_rest = rest
200
+ self._beam_K = K
201
+
202
+ def _build_masses(self, triangles: np.ndarray) -> None:
203
+ """Mass per node = sum of (adjacent triangle area / 3)."""
204
+ n = len(self.pos)
205
+ mass = np.zeros(n, dtype=np.float64)
206
+ for tri in triangles:
207
+ a, b, c = tri
208
+ pa, pb, pc = self.pos[a], self.pos[b], self.pos[c]
209
+ area = 0.5 * np.linalg.norm(np.cross(pb - pa, pc - pa))
210
+ mass[a] += area / 3.0
211
+ mass[b] += area / 3.0
212
+ mass[c] += area / 3.0
213
+ # Guard against zero-mass nodes (degenerate triangles)
214
+ mass = np.maximum(mass, 1e-12)
215
+ self.mass = mass
216
+
217
+ def _build_creases(self, triangles: np.ndarray, pos2d: np.ndarray,
218
+ orig_creases: list[tuple[np.ndarray, np.ndarray, str]]
219
+ ) -> None:
220
+ """
221
+ Identify interior edges (shared by exactly 2 triangles) and classify
222
+ them as M/V fold creases or F panel springs.
223
+ """
224
+ # Map each canonical edge β†’ list of triangle indices containing it
225
+ edge_to_tris: dict[tuple[int, int], list[int]] = {}
226
+ tri_edge_map: dict[tuple[int, int], list[tuple[int, int, int]]] = {}
227
+
228
+ for t_idx, tri in enumerate(triangles):
229
+ a, b, c = tri
230
+ for (ei, ej), opposite in [
231
+ ((min(a, b), max(a, b)), c),
232
+ ((min(b, c), max(b, c)), a),
233
+ ((min(c, a), max(c, a)), b),
234
+ ]:
235
+ edge_to_tris.setdefault((ei, ej), []).append(t_idx)
236
+ tri_edge_map.setdefault((ei, ej), []).append((ei, ej, opposite))
237
+
238
+ crease_a: list[int] = []
239
+ crease_b: list[int] = []
240
+ crease_c: list[int] = []
241
+ crease_d: list[int] = []
242
+ crease_assign: list[str] = []
243
+ crease_full_theta: list[float] = []
244
+ crease_K: list[float] = []
245
+
246
+ for edge_key, t_indices in edge_to_tris.items():
247
+ if len(t_indices) != 2:
248
+ continue # boundary edge
249
+
250
+ ei, ej = edge_key
251
+ # Collect opposite nodes for each of the two triangles
252
+ # Find the opposite node for tri 0 and tri 1
253
+ opp_nodes = [None, None]
254
+ for t_pos, t_idx in enumerate(t_indices):
255
+ tri = triangles[t_idx]
256
+ for node in tri:
257
+ if node != ei and node != ej:
258
+ opp_nodes[t_pos] = node
259
+ break
260
+
261
+ c_node = opp_nodes[0]
262
+ d_node = opp_nodes[1]
263
+ if c_node is None or d_node is None:
264
+ continue
265
+
266
+ # Classify: check if both endpoints lie on the same original crease segment
267
+ pi = pos2d[ei]
268
+ pj = pos2d[ej]
269
+ asgn = 'F'
270
+ for p0, p1, crease_type in orig_creases:
271
+ if _point_on_segment(pi, p0, p1) and _point_on_segment(pj, p0, p1):
272
+ asgn = crease_type
273
+ break
274
+
275
+ if asgn == 'M':
276
+ full_theta = +np.pi
277
+ K = CREASE_STIFFNESS * np.linalg.norm(pos2d[ej] - pos2d[ei])
278
+ elif asgn == 'V':
279
+ full_theta = -np.pi
280
+ K = CREASE_STIFFNESS * np.linalg.norm(pos2d[ej] - pos2d[ei])
281
+ else: # 'F' panel
282
+ full_theta = 0.0
283
+ K = PANEL_STIFFNESS * np.linalg.norm(pos2d[ej] - pos2d[ei])
284
+
285
+ crease_a.append(ei)
286
+ crease_b.append(ej)
287
+ crease_c.append(c_node)
288
+ crease_d.append(d_node)
289
+ crease_assign.append(asgn)
290
+ crease_full_theta.append(full_theta)
291
+ crease_K.append(K)
292
+
293
+ self._crease_a = np.array(crease_a, dtype=np.int32)
294
+ self._crease_b = np.array(crease_b, dtype=np.int32)
295
+ self._crease_c = np.array(crease_c, dtype=np.int32)
296
+ self._crease_d = np.array(crease_d, dtype=np.int32)
297
+ self._crease_assign = crease_assign
298
+ self._crease_full_theta = np.array(crease_full_theta, dtype=np.float64)
299
+ self._crease_K = np.array(crease_K, dtype=np.float64)
300
+ self._crease_target = np.zeros(len(crease_a), dtype=np.float64)
301
+
302
+ # ── Physics ───────────────────────────────────────────────────────────────
303
+
304
+ def _beam_forces(self) -> np.ndarray:
305
+ """Vectorized axial spring forces for all beams."""
306
+ n = len(self.pos)
307
+ forces = np.zeros((n, 3), dtype=np.float64)
308
+
309
+ pi = self.pos[self._beam_i]
310
+ pj = self.pos[self._beam_j]
311
+ diff = pj - pi
312
+ lengths = np.linalg.norm(diff, axis=1, keepdims=True)
313
+ lengths = np.maximum(lengths, 1e-12)
314
+ unit = diff / lengths
315
+
316
+ stretch = lengths[:, 0] - self._beam_rest
317
+ F_mag = self._beam_K * stretch # scalar force magnitude
318
+
319
+ # Damping along the edge
320
+ vi = self.vel[self._beam_i]
321
+ vj = self.vel[self._beam_j]
322
+ rel_vel = np.sum((vj - vi) * unit, axis=1)
323
+ damp_mag = PERCENT_DAMPING * rel_vel
324
+ F_total = (F_mag + damp_mag)[:, None] * unit
325
+
326
+ np.add.at(forces, self._beam_i, F_total)
327
+ np.add.at(forces, self._beam_j, -F_total)
328
+ return forces
329
+
330
+ def _crease_forces(self) -> np.ndarray:
331
+ """Torsional spring forces for all crease/panel edges (Python loop)."""
332
+ n = len(self.pos)
333
+ forces = np.zeros((n, 3), dtype=np.float64)
334
+
335
+ pos = self.pos
336
+ for idx in range(len(self._crease_a)):
337
+ a = self._crease_a[idx]
338
+ b = self._crease_b[idx]
339
+ c = self._crease_c[idx]
340
+ d = self._crease_d[idx]
341
+ K = self._crease_K[idx]
342
+ target = self._crease_target[idx]
343
+
344
+ pa, pb, pc, pd = pos[a], pos[b], pos[c], pos[d]
345
+
346
+ edge_vec = pb - pa
347
+ edge_len = np.linalg.norm(edge_vec)
348
+ if edge_len < 1e-12:
349
+ continue
350
+ edge_dir = edge_vec / edge_len
351
+
352
+ # Face normals
353
+ n1_raw = np.cross(pb - pa, pc - pa)
354
+ n2_raw = np.cross(pa - pb, pd - pb)
355
+ n1_len = np.linalg.norm(n1_raw)
356
+ n2_len = np.linalg.norm(n2_raw)
357
+ if n1_len < 1e-12 or n2_len < 1e-12:
358
+ continue
359
+ n1 = n1_raw / n1_len
360
+ n2 = n2_raw / n2_len
361
+
362
+ # Dihedral angle via atan2
363
+ cross_n = np.cross(n1, n2)
364
+ sin_theta = np.dot(cross_n, edge_dir)
365
+ cos_theta = np.dot(n1, n2)
366
+ theta = np.arctan2(sin_theta, cos_theta)
367
+
368
+ delta = theta - target
369
+ torque = -K * delta
370
+
371
+ # Moment arms (perpendicular distance from c, d to crease line)
372
+ vc = pc - pa
373
+ vd = pd - pa
374
+ vc_perp = vc - np.dot(vc, edge_dir) * edge_dir
375
+ vd_perp = vd - np.dot(vd, edge_dir) * edge_dir
376
+ h_c = np.linalg.norm(vc_perp)
377
+ h_d = np.linalg.norm(vd_perp)
378
+ if h_c < 1e-12 or h_d < 1e-12:
379
+ continue
380
+
381
+ # Forces on opposite nodes
382
+ F_c = (torque / h_c) * n1
383
+ F_d = -(torque / h_d) * n2
384
+
385
+ # Reaction on crease nodes (moment balance)
386
+ proj_c = np.dot(pc - pa, edge_dir)
387
+ proj_d = np.dot(pd - pa, edge_dir)
388
+ coef_c_a = 1.0 - proj_c / edge_len
389
+ coef_c_b = proj_c / edge_len
390
+ coef_d_a = 1.0 - proj_d / edge_len
391
+ coef_d_b = proj_d / edge_len
392
+
393
+ forces[c] += F_c
394
+ forces[d] += F_d
395
+ forces[a] -= coef_c_a * F_c + coef_d_a * F_d
396
+ forces[b] -= coef_c_b * F_c + coef_d_b * F_d
397
+
398
+ return forces
399
+
400
+ def _euler_step(self) -> None:
401
+ forces = self._beam_forces() + self._crease_forces()
402
+ accel = forces / self.mass[:, None]
403
+ vel_new = self.vel + accel * DT
404
+ vel_new *= (1.0 - PERCENT_DAMPING * DT)
405
+ self.pos += vel_new * DT
406
+ self.vel = vel_new