File size: 15,756 Bytes
19abe39
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
"""
Origami mass-spring dynamic relaxation simulator.

Based on: Ghassaei et al., "Fast, Interactive Origami Simulation using GPU
Computation", 7OSME 2018.
"""

from __future__ import annotations

import numpy as np
from scipy.spatial import Delaunay

# ── Physics constants ────────────────────────────────────────────────────────

AXIAL_STIFFNESS  = 20.0   # K = AXIAL_STIFFNESS / rest_length
CREASE_STIFFNESS = 0.7    # K = CREASE_STIFFNESS * edge_length  (M/V creases)
PANEL_STIFFNESS  = 0.7    # K = PANEL_STIFFNESS  * edge_length  (F / panel edges)
PERCENT_DAMPING  = 0.45   # global viscous damping fraction
DT               = 0.002  # timestep (seconds)


# ── Geometry helpers ─────────────────────────────────────────────────────────

def _normalize(v: np.ndarray) -> np.ndarray:
    n = np.linalg.norm(v)
    return v / n if n > 1e-12 else v


def _triangulate_faces(faces_vertices: list[list[int]]) -> np.ndarray:
    """Fan-triangulate polygonal faces (triangles and quads supported)."""
    tris = []
    for face in faces_vertices:
        if len(face) == 3:
            tris.append(face)
        elif len(face) == 4:
            a, b, c, d = face
            tris.append([a, b, c])
            tris.append([a, c, d])
        else:
            # General fan triangulation for n-gons
            for k in range(1, len(face) - 1):
                tris.append([face[0], face[k], face[k + 1]])
    return np.array(tris, dtype=np.int32)


def _point_on_segment(p: np.ndarray, p0: np.ndarray, p1: np.ndarray,
                      tol: float = 1e-6) -> bool:
    seg = p1 - p0
    seg_len = np.linalg.norm(seg)
    if seg_len < 1e-10:
        return False
    seg_dir = seg / seg_len
    t = np.dot(p - p0, seg_dir)
    perp = (p - p0) - t * seg_dir
    return -tol <= t <= seg_len + tol and np.linalg.norm(perp) < tol


# ── Mesh subdivision ──────────────────────────────────────────────────────────

def _subdivide(pos2d: np.ndarray, triangles: np.ndarray
               ) -> tuple[np.ndarray, np.ndarray]:
    """Split each triangle into 4 by inserting edge midpoints."""
    midpoint_cache: dict[tuple[int, int], int] = {}
    new_pos = list(pos2d)
    new_tris = []

    def get_mid(i: int, j: int) -> int:
        key = (min(i, j), max(i, j))
        if key not in midpoint_cache:
            mid = (np.array(new_pos[i]) + np.array(new_pos[j])) / 2.0
            midpoint_cache[key] = len(new_pos)
            new_pos.append(mid)
        return midpoint_cache[key]

    for tri in triangles:
        a, b, c = tri
        ab = get_mid(a, b)
        bc = get_mid(b, c)
        ca = get_mid(c, a)
        new_tris.extend([
            [a,  ab, ca],
            [ab, b,  bc],
            [ca, bc, c ],
            [ab, bc, ca],
        ])

    return np.array(new_pos, dtype=np.float64), np.array(new_tris, dtype=np.int32)


# ── Main simulator ────────────────────────────────────────────────────────────

class OrigamiSimulator:
    """
    Mass-spring dynamic relaxation simulator for origami.

    Parameters
    ----------
    fold_data : dict
        Parsed FOLD JSON with keys: vertices_coords, edges_vertices,
        edges_assignment.
    subdivisions : int
        Number of midpoint subdivision passes (default 2 β†’ 4Γ— mesh density).
    """

    def __init__(self, fold_data: dict, subdivisions: int = 2) -> None:
        self._fold_percent = 0.0
        self._build(fold_data, subdivisions)

    # ── Public API ────────────────────────────────────────────────────────────

    def set_fold_percent(self, percent: float) -> None:
        """Update all crease spring target angles (0.0 = flat, 1.0 = fully folded)."""
        self._fold_percent = float(percent)
        self._crease_target = self._fold_percent * self._crease_full_theta

    def step(self, n_steps: int = 50) -> None:
        """Advance the simulation by n_steps Euler integration steps."""
        for _ in range(n_steps):
            self._euler_step()

    def reset(self) -> None:
        """Reset to flat state (z=0, vel=0), preserving current fold percent."""
        self.pos = self._flat_pos.copy()
        self.vel[:] = 0.0

    @property
    def crease_indices(self) -> list[tuple[int, int, str]]:
        """Return list of (a, b, assignment) for all crease springs."""
        return list(zip(
            self._crease_a.tolist(),
            self._crease_b.tolist(),
            self._crease_assign,
        ))

    # ── Build ─────────────────────────────────────────────────────────────────

    def _build(self, fold_data: dict, subdivisions: int) -> None:
        coords = fold_data['vertices_coords']
        orig_edges = fold_data['edges_vertices']
        orig_assign = fold_data['edges_assignment']

        # Original 2-D positions
        pts2d = np.array([[x, y] for x, y in coords], dtype=np.float64)

        # Build triangles from faces_vertices when available (preferred: ensures
        # crease edges appear as actual mesh edges after subdivision).
        # Quads [a,b,c,d] are split into [a,b,c] + [a,c,d].
        # Fall back to Delaunay only if faces_vertices is absent.
        if 'faces_vertices' in fold_data:
            triangles = _triangulate_faces(fold_data['faces_vertices'])
        else:
            tri = Delaunay(pts2d)
            triangles = tri.simplices.astype(np.int32)

        # Build original crease segments for later classification
        # Only M and V assignments are actual fold creases; B is boundary.
        orig_creases: list[tuple[np.ndarray, np.ndarray, str]] = []
        for (u, v), asgn in zip(orig_edges, orig_assign):
            if asgn in ('M', 'V'):
                orig_creases.append((pts2d[u], pts2d[v], asgn))

        # Midpoint subdivision passes
        pos2d = pts2d.copy()
        for _ in range(subdivisions):
            pos2d, triangles = _subdivide(pos2d, triangles)

        n = len(pos2d)

        # 3-D positions (flat, z=0)
        pos3d = np.zeros((n, 3), dtype=np.float64)
        pos3d[:, :2] = pos2d

        self.pos        = pos3d
        self._flat_pos  = pos3d.copy()
        self.vel        = np.zeros((n, 3), dtype=np.float64)
        self.triangles  = triangles

        self._build_beams(triangles)
        self._build_masses(triangles)
        self._build_creases(triangles, pos2d, orig_creases)

    def _build_beams(self, triangles: np.ndarray) -> None:
        """Collect all unique triangle edges as structural (axial) springs."""
        edge_set: set[tuple[int, int]] = set()
        for tri in triangles:
            a, b, c = tri
            for i, j in [(a, b), (b, c), (c, a)]:
                edge_set.add((min(i, j), max(i, j)))

        edges = np.array(sorted(edge_set), dtype=np.int32)
        i_arr = edges[:, 0]
        j_arr = edges[:, 1]

        rest = np.linalg.norm(self.pos[i_arr] - self.pos[j_arr], axis=1)
        K    = AXIAL_STIFFNESS / np.maximum(rest, 1e-12)

        self._beam_i    = i_arr
        self._beam_j    = j_arr
        self._beam_rest = rest
        self._beam_K    = K

    def _build_masses(self, triangles: np.ndarray) -> None:
        """Mass per node = sum of (adjacent triangle area / 3)."""
        n = len(self.pos)
        mass = np.zeros(n, dtype=np.float64)
        for tri in triangles:
            a, b, c = tri
            pa, pb, pc = self.pos[a], self.pos[b], self.pos[c]
            area = 0.5 * np.linalg.norm(np.cross(pb - pa, pc - pa))
            mass[a] += area / 3.0
            mass[b] += area / 3.0
            mass[c] += area / 3.0
        # Guard against zero-mass nodes (degenerate triangles)
        mass = np.maximum(mass, 1e-12)
        self.mass = mass

    def _build_creases(self, triangles: np.ndarray, pos2d: np.ndarray,
                       orig_creases: list[tuple[np.ndarray, np.ndarray, str]]
                       ) -> None:
        """
        Identify interior edges (shared by exactly 2 triangles) and classify
        them as M/V fold creases or F panel springs.
        """
        # Map each canonical edge β†’ list of triangle indices containing it
        edge_to_tris: dict[tuple[int, int], list[int]] = {}
        tri_edge_map: dict[tuple[int, int], list[tuple[int, int, int]]] = {}

        for t_idx, tri in enumerate(triangles):
            a, b, c = tri
            for (ei, ej), opposite in [
                ((min(a, b), max(a, b)), c),
                ((min(b, c), max(b, c)), a),
                ((min(c, a), max(c, a)), b),
            ]:
                edge_to_tris.setdefault((ei, ej), []).append(t_idx)
                tri_edge_map.setdefault((ei, ej), []).append((ei, ej, opposite))

        crease_a: list[int] = []
        crease_b: list[int] = []
        crease_c: list[int] = []
        crease_d: list[int] = []
        crease_assign: list[str] = []
        crease_full_theta: list[float] = []
        crease_K: list[float] = []

        for edge_key, t_indices in edge_to_tris.items():
            if len(t_indices) != 2:
                continue  # boundary edge

            ei, ej = edge_key
            # Collect opposite nodes for each of the two triangles
            # Find the opposite node for tri 0 and tri 1
            opp_nodes = [None, None]
            for t_pos, t_idx in enumerate(t_indices):
                tri = triangles[t_idx]
                for node in tri:
                    if node != ei and node != ej:
                        opp_nodes[t_pos] = node
                        break

            c_node = opp_nodes[0]
            d_node = opp_nodes[1]
            if c_node is None or d_node is None:
                continue

            # Classify: check if both endpoints lie on the same original crease segment
            pi = pos2d[ei]
            pj = pos2d[ej]
            asgn = 'F'
            for p0, p1, crease_type in orig_creases:
                if _point_on_segment(pi, p0, p1) and _point_on_segment(pj, p0, p1):
                    asgn = crease_type
                    break

            if asgn == 'M':
                full_theta = +np.pi
                K = CREASE_STIFFNESS * np.linalg.norm(pos2d[ej] - pos2d[ei])
            elif asgn == 'V':
                full_theta = -np.pi
                K = CREASE_STIFFNESS * np.linalg.norm(pos2d[ej] - pos2d[ei])
            else:  # 'F' panel
                full_theta = 0.0
                K = PANEL_STIFFNESS * np.linalg.norm(pos2d[ej] - pos2d[ei])

            crease_a.append(ei)
            crease_b.append(ej)
            crease_c.append(c_node)
            crease_d.append(d_node)
            crease_assign.append(asgn)
            crease_full_theta.append(full_theta)
            crease_K.append(K)

        self._crease_a          = np.array(crease_a, dtype=np.int32)
        self._crease_b          = np.array(crease_b, dtype=np.int32)
        self._crease_c          = np.array(crease_c, dtype=np.int32)
        self._crease_d          = np.array(crease_d, dtype=np.int32)
        self._crease_assign     = crease_assign
        self._crease_full_theta = np.array(crease_full_theta, dtype=np.float64)
        self._crease_K          = np.array(crease_K, dtype=np.float64)
        self._crease_target     = np.zeros(len(crease_a), dtype=np.float64)

    # ── Physics ───────────────────────────────────────────────────────────────

    def _beam_forces(self) -> np.ndarray:
        """Vectorized axial spring forces for all beams."""
        n = len(self.pos)
        forces = np.zeros((n, 3), dtype=np.float64)

        pi = self.pos[self._beam_i]
        pj = self.pos[self._beam_j]
        diff = pj - pi
        lengths = np.linalg.norm(diff, axis=1, keepdims=True)
        lengths = np.maximum(lengths, 1e-12)
        unit = diff / lengths

        stretch = lengths[:, 0] - self._beam_rest
        F_mag = self._beam_K * stretch          # scalar force magnitude

        # Damping along the edge
        vi = self.vel[self._beam_i]
        vj = self.vel[self._beam_j]
        rel_vel = np.sum((vj - vi) * unit, axis=1)
        damp_mag = PERCENT_DAMPING * rel_vel
        F_total = (F_mag + damp_mag)[:, None] * unit

        np.add.at(forces, self._beam_i,  F_total)
        np.add.at(forces, self._beam_j, -F_total)
        return forces

    def _crease_forces(self) -> np.ndarray:
        """Torsional spring forces for all crease/panel edges (Python loop)."""
        n = len(self.pos)
        forces = np.zeros((n, 3), dtype=np.float64)

        pos = self.pos
        for idx in range(len(self._crease_a)):
            a = self._crease_a[idx]
            b = self._crease_b[idx]
            c = self._crease_c[idx]
            d = self._crease_d[idx]
            K = self._crease_K[idx]
            target = self._crease_target[idx]

            pa, pb, pc, pd = pos[a], pos[b], pos[c], pos[d]

            edge_vec = pb - pa
            edge_len = np.linalg.norm(edge_vec)
            if edge_len < 1e-12:
                continue
            edge_dir = edge_vec / edge_len

            # Face normals
            n1_raw = np.cross(pb - pa, pc - pa)
            n2_raw = np.cross(pa - pb, pd - pb)
            n1_len = np.linalg.norm(n1_raw)
            n2_len = np.linalg.norm(n2_raw)
            if n1_len < 1e-12 or n2_len < 1e-12:
                continue
            n1 = n1_raw / n1_len
            n2 = n2_raw / n2_len

            # Dihedral angle via atan2
            cross_n = np.cross(n1, n2)
            sin_theta = np.dot(cross_n, edge_dir)
            cos_theta = np.dot(n1, n2)
            theta = np.arctan2(sin_theta, cos_theta)

            delta  = theta - target
            torque = -K * delta

            # Moment arms (perpendicular distance from c, d to crease line)
            vc = pc - pa
            vd = pd - pa
            vc_perp = vc - np.dot(vc, edge_dir) * edge_dir
            vd_perp = vd - np.dot(vd, edge_dir) * edge_dir
            h_c = np.linalg.norm(vc_perp)
            h_d = np.linalg.norm(vd_perp)
            if h_c < 1e-12 or h_d < 1e-12:
                continue

            # Forces on opposite nodes
            F_c =  (torque / h_c) * n1
            F_d = -(torque / h_d) * n2

            # Reaction on crease nodes (moment balance)
            proj_c = np.dot(pc - pa, edge_dir)
            proj_d = np.dot(pd - pa, edge_dir)
            coef_c_a = 1.0 - proj_c / edge_len
            coef_c_b =       proj_c / edge_len
            coef_d_a = 1.0 - proj_d / edge_len
            coef_d_b =       proj_d / edge_len

            forces[c] += F_c
            forces[d] += F_d
            forces[a] -= coef_c_a * F_c + coef_d_a * F_d
            forces[b] -= coef_c_b * F_c + coef_d_b * F_d

        return forces

    def _euler_step(self) -> None:
        forces = self._beam_forces() + self._crease_forces()
        accel  = forces / self.mass[:, None]
        vel_new = self.vel + accel * DT
        vel_new *= (1.0 - PERCENT_DAMPING * DT)
        self.pos += vel_new * DT
        self.vel  = vel_new