prasanna287 commited on
Commit
1bc70e7
Β·
1 Parent(s): c46255e

Origami RL environment with Three.js viewer

Browse files
.dockerignore ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ .venv/
2
+ .git/
3
+ __pycache__/
4
+ *.pyc
5
+ .pytest_cache/
6
+ outputs/
7
+ *.egg-info/
8
+ .env
Dockerfile ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM ghcr.io/meta-pytorch/openenv-base:latest
2
+
3
+ WORKDIR /app
4
+
5
+ COPY pyproject.toml ./
6
+ RUN pip install --no-cache-dir \
7
+ "openenv-core[core]>=0.2.1" \
8
+ "numpy>=1.24" \
9
+ "scipy>=1.10" \
10
+ "pydantic>=2.0" \
11
+ "fastapi>=0.115.0" \
12
+ "uvicorn>=0.24.0"
13
+
14
+ COPY . /app
15
+
16
+ EXPOSE 8000
17
+
18
+ CMD ["uvicorn", "server.app:app", "--host", "0.0.0.0", "--port", "8000"]
README.md CHANGED
@@ -1,10 +1,39 @@
1
  ---
2
- title: Origami Env
3
- emoji: ⚑
4
- colorFrom: pink
5
- colorTo: red
6
  sdk: docker
7
  pinned: false
 
 
 
8
  ---
9
 
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: Origami Env Environment Server
3
+ emoji: 🦒
4
+ colorFrom: red
5
+ colorTo: indigo
6
  sdk: docker
7
  pinned: false
8
+ app_port: 8000
9
+ tags:
10
+ - openenv
11
  ---
12
 
13
+ # Origami Env
14
+
15
+ RL environment for origami folding β€” LLM generates FOLD crease patterns, physics simulates them, reward = shape similarity to target.
16
+
17
+ ## Usage
18
+
19
+ ```python
20
+ from origami_env.client import OrigamiEnv
21
+ from origami_env.models import OrigamiAction
22
+
23
+ with OrigamiEnv(base_url="http://localhost:8000") as env:
24
+ result = env.reset(task_name="triangle")
25
+ result = env.step(OrigamiAction(fold_data={
26
+ "vertices_coords": [[0,0],[1,0],[1,1],[0,1]],
27
+ "edges_vertices": [[0,1],[1,2],[2,3],[3,0],[0,2]],
28
+ "edges_assignment": ["B","B","B","B","V"],
29
+ "edges_foldAngle": [0,0,0,0,180]
30
+ }))
31
+ print(result.observation.shape_similarity)
32
+ ```
33
+
34
+ ## Tasks
35
+
36
+ - **triangle** β€” diagonal valley fold
37
+ - **half_fold** β€” horizontal valley fold at y=0.5
38
+ - **quarter_fold** β€” two perpendicular valley folds
39
+ - **letter_fold** β€” two parallel valley folds at y=1/3 and y=2/3
__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ # origami_env β€” RL environment for origami folding
client.py ADDED
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Origami environment client β€” connects to a running origami_env server."""
2
+
3
+ from typing import Any, Dict
4
+
5
+ from openenv.core.client_types import StepResult
6
+ from openenv.core.env_client import EnvClient
7
+
8
+ from server.models import OrigamiAction, OrigamiObservation, OrigamiState
9
+
10
+
11
+ class OrigamiEnv(EnvClient[OrigamiAction, OrigamiObservation, OrigamiState]):
12
+ """
13
+ Client for the origami RL environment.
14
+
15
+ Example:
16
+ >>> with OrigamiEnv(base_url="http://localhost:8000") as env:
17
+ ... result = env.reset(task_name="triangle")
18
+ ... result = env.step(OrigamiAction(fold_data={...}))
19
+ ... print(result.observation.shape_similarity)
20
+
21
+ >>> # From HuggingFace Spaces
22
+ >>> env = OrigamiEnv.from_env("username/origami_env")
23
+ """
24
+
25
+ def _step_payload(self, action: OrigamiAction) -> Dict[str, Any]:
26
+ return action.model_dump()
27
+
28
+ def _parse_result(self, payload: Dict[str, Any]) -> StepResult[OrigamiObservation]:
29
+ obs_data = payload.get("observation", payload)
30
+ return StepResult(
31
+ observation=OrigamiObservation(**obs_data),
32
+ reward=payload.get("reward"),
33
+ done=payload.get("done", False),
34
+ )
35
+
36
+ def _parse_state(self, payload: Dict[str, Any]) -> OrigamiState:
37
+ return OrigamiState(**payload)
models.py ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ """Re-export models from server.models for OpenEnv client usage."""
2
+
3
+ from server.models import OrigamiAction, OrigamiObservation, OrigamiState
4
+
5
+ __all__ = ["OrigamiAction", "OrigamiObservation", "OrigamiState"]
openenv.yaml ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ spec_version: 1
2
+ name: origami_env
3
+ type: space
4
+ runtime: fastapi
5
+ app: server.app:app
6
+ port: 8000
pyproject.toml ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [build-system]
2
+ requires = ["setuptools>=68.0"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "origami-env"
7
+ version = "0.1.0"
8
+ description = "RL environment for origami folding β€” LLM generates FOLD crease patterns"
9
+ requires-python = ">=3.11"
10
+ dependencies = [
11
+ "openenv-core[core]>=0.2.1",
12
+ "numpy>=1.24",
13
+ "scipy>=1.10",
14
+ "pydantic>=2.0",
15
+ "fastapi>=0.115.0",
16
+ "uvicorn>=0.24.0",
17
+ "requests>=2.31.0",
18
+ ]
19
+
20
+ [project.scripts]
21
+ server = "server.app:main"
22
+
23
+ [project.optional-dependencies]
24
+ training = [
25
+ "trl>=0.7",
26
+ "datasets>=2.14",
27
+ "unsloth",
28
+ "torch",
29
+ "transformers",
30
+ ]
31
+
32
+ [tool.setuptools.packages.find]
33
+ where = ["."]
server/__init__.py ADDED
File without changes
server/app.py ADDED
@@ -0,0 +1,72 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """FastAPI entry point β€” OpenEnv create_app() + custom viewer."""
2
+
3
+ import os
4
+ from pathlib import Path
5
+
6
+ from fastapi.responses import HTMLResponse
7
+ from fastapi.staticfiles import StaticFiles
8
+
9
+ from openenv.core.env_server.http_server import create_app
10
+
11
+ from .environment import OrigamiEnvironment
12
+ from .models import OrigamiAction, OrigamiObservation
13
+
14
+ app = create_app(
15
+ OrigamiEnvironment,
16
+ OrigamiAction,
17
+ OrigamiObservation,
18
+ env_name="origami_env",
19
+ )
20
+
21
+ from .tasks import TASKS
22
+
23
+
24
+ @app.get("/tasks")
25
+ def get_tasks():
26
+ return {
27
+ name: {
28
+ "name": task["name"],
29
+ "description": task["description"],
30
+ "difficulty": task["difficulty"],
31
+ "paper": task["paper"],
32
+ "target_fold": task["target_fold"],
33
+ }
34
+ for name, task in TASKS.items()
35
+ }
36
+
37
+
38
+ @app.get("/tasks/{task_name}")
39
+ def get_task_detail(task_name: str):
40
+ if task_name not in TASKS:
41
+ from fastapi import HTTPException
42
+
43
+ raise HTTPException(status_code=404, detail=f"Task '{task_name}' not found")
44
+ task = TASKS[task_name]
45
+ return {
46
+ "name": task["name"],
47
+ "description": task["description"],
48
+ "difficulty": task["difficulty"],
49
+ "paper": task["paper"],
50
+ "target_fold": task["target_fold"],
51
+ }
52
+
53
+
54
+ _VIEWER_DIR = Path(__file__).resolve().parent.parent / "viewer"
55
+ if _VIEWER_DIR.is_dir():
56
+ app.mount("/", StaticFiles(directory=str(_VIEWER_DIR), html=True), name="renderer")
57
+ else:
58
+
59
+ @app.get("/", response_class=HTMLResponse)
60
+ def no_viewer():
61
+ return HTMLResponse("<h3>Viewer not found</h3><p>API docs at <a href='/docs'>/docs</a></p>")
62
+
63
+
64
+ def main():
65
+ import uvicorn
66
+
67
+ port = int(os.environ.get("PORT", 8000))
68
+ uvicorn.run(app, host="0.0.0.0", port=port)
69
+
70
+
71
+ if __name__ == "__main__":
72
+ main()
server/engine/__init__.py ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ from .simulate import simulate
2
+ from .fold_parser import parse_fold, validate_fold
3
+ from .shape_match import compute_shape_match
server/engine/fold_parser.py ADDED
@@ -0,0 +1,180 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """FOLD JSON parsing and validation.
2
+
3
+ Validates LLM-generated FOLD crease patterns before simulation.
4
+ FOLD spec: https://github.com/edemaine/fold
5
+ """
6
+
7
+ from typing import Any
8
+
9
+ import numpy as np
10
+
11
+
12
+ def validate_fold(fold_data: dict[str, Any]) -> tuple[bool, str]:
13
+ """Validate a FOLD JSON object. Returns (is_valid, error_message)."""
14
+
15
+ # Required fields
16
+ for key in ("vertices_coords", "edges_vertices", "edges_assignment"):
17
+ if key not in fold_data:
18
+ return False, f"Missing required field: {key}"
19
+
20
+ verts = fold_data["vertices_coords"]
21
+ edges = fold_data["edges_vertices"]
22
+ assignments = fold_data["edges_assignment"]
23
+
24
+ # Must have at least 3 vertices (a triangle)
25
+ if len(verts) < 3:
26
+ return False, f"Need at least 3 vertices, got {len(verts)}"
27
+
28
+ # Must have at least 3 edges
29
+ if len(edges) < 3:
30
+ return False, f"Need at least 3 edges, got {len(edges)}"
31
+
32
+ # Edges and assignments must match length
33
+ if len(edges) != len(assignments):
34
+ return False, (
35
+ f"edges_vertices ({len(edges)}) and "
36
+ f"edges_assignment ({len(assignments)}) must match length"
37
+ )
38
+
39
+ # Fold angles must match if present
40
+ if "edges_foldAngle" in fold_data:
41
+ angles = fold_data["edges_foldAngle"]
42
+ if len(angles) != len(edges):
43
+ return False, (
44
+ f"edges_foldAngle ({len(angles)}) must match "
45
+ f"edges_vertices ({len(edges)})"
46
+ )
47
+
48
+ # Validate vertex coordinates (2D or 3D)
49
+ num_verts = len(verts)
50
+ for i, v in enumerate(verts):
51
+ if not isinstance(v, (list, tuple)) or len(v) < 2:
52
+ return False, f"Vertex {i} must be [x, y] or [x, y, z], got {v}"
53
+
54
+ # Validate edge indices
55
+ for i, e in enumerate(edges):
56
+ if not isinstance(e, (list, tuple)) or len(e) != 2:
57
+ return False, f"Edge {i} must be [v1, v2], got {e}"
58
+ v1, v2 = e
59
+ if v1 < 0 or v1 >= num_verts or v2 < 0 or v2 >= num_verts:
60
+ return False, f"Edge {i} references invalid vertex: {e}"
61
+ if v1 == v2:
62
+ return False, f"Edge {i} is degenerate (same vertex): {e}"
63
+
64
+ # Validate assignments
65
+ valid_assignments = {"M", "V", "B", "F", "U", "C"}
66
+ for i, a in enumerate(assignments):
67
+ if a not in valid_assignments:
68
+ return False, f"Edge {i} has invalid assignment '{a}'"
69
+
70
+ # Must have at least one fold crease (M or V)
71
+ has_fold = any(a in ("M", "V") for a in assignments)
72
+ if not has_fold:
73
+ return False, "No fold creases (M or V) found"
74
+
75
+ # Must have boundary edges
76
+ has_boundary = any(a == "B" for a in assignments)
77
+ if not has_boundary:
78
+ return False, "No boundary edges (B) found"
79
+
80
+ return True, ""
81
+
82
+
83
+ def parse_fold(fold_data: dict[str, Any]) -> dict[str, np.ndarray]:
84
+ """Parse validated FOLD JSON into numpy arrays for simulation.
85
+
86
+ Returns dict with:
87
+ vertices: (N, 3) float64 β€” vertex positions (z=0 for 2D input)
88
+ edges: (E, 2) int β€” edge vertex indices
89
+ assignments: list[str] β€” edge type per edge
90
+ fold_angles: (E,) float64 β€” target fold angle per edge (degrees)
91
+ faces: (F, 3) int β€” triangulated face vertex indices
92
+ """
93
+ verts = fold_data["vertices_coords"]
94
+
95
+ # Ensure 3D (add z=0 if 2D)
96
+ vertices = np.zeros((len(verts), 3), dtype=np.float64)
97
+ for i, v in enumerate(verts):
98
+ vertices[i, 0] = v[0]
99
+ vertices[i, 1] = v[1]
100
+ if len(v) > 2:
101
+ vertices[i, 2] = v[2]
102
+
103
+ edges = np.array(fold_data["edges_vertices"], dtype=np.int32)
104
+ assignments = list(fold_data["edges_assignment"])
105
+
106
+ # Fold angles: default based on assignment if not provided
107
+ if "edges_foldAngle" in fold_data:
108
+ fold_angles = np.array(fold_data["edges_foldAngle"], dtype=np.float64)
109
+ else:
110
+ fold_angles = np.zeros(len(edges), dtype=np.float64)
111
+ for i, a in enumerate(assignments):
112
+ if a == "V":
113
+ fold_angles[i] = 180.0
114
+ elif a == "M":
115
+ fold_angles[i] = -180.0
116
+
117
+ # Convert degrees to radians for simulation
118
+ fold_angles_rad = np.radians(fold_angles)
119
+
120
+ # Triangulate faces
121
+ if "faces_vertices" in fold_data:
122
+ raw_faces = fold_data["faces_vertices"]
123
+ faces = _triangulate_faces(raw_faces)
124
+ else:
125
+ faces = _compute_faces(vertices, edges)
126
+
127
+ return {
128
+ "vertices": vertices,
129
+ "edges": edges,
130
+ "assignments": assignments,
131
+ "fold_angles": fold_angles_rad,
132
+ "faces": faces,
133
+ }
134
+
135
+
136
+ def _triangulate_faces(raw_faces: list[list[int]]) -> np.ndarray:
137
+ """Fan-triangulate polygon faces into triangles."""
138
+ triangles = []
139
+ for face in raw_faces:
140
+ if len(face) < 3:
141
+ continue
142
+ for i in range(1, len(face) - 1):
143
+ triangles.append([face[0], face[i], face[i + 1]])
144
+ if not triangles:
145
+ return np.zeros((0, 3), dtype=np.int32)
146
+ return np.array(triangles, dtype=np.int32)
147
+
148
+
149
+ def _compute_faces(vertices: np.ndarray, edges: np.ndarray) -> np.ndarray:
150
+ """Compute triangulated faces from vertices and edges using adjacency.
151
+
152
+ Finds all triangles formed by the edge connectivity.
153
+ """
154
+ from collections import defaultdict
155
+
156
+ n_verts = len(vertices)
157
+ adj = defaultdict(set)
158
+ for v1, v2 in edges:
159
+ adj[v1].add(v2)
160
+ adj[v2].add(v1)
161
+
162
+ triangles = set()
163
+ for v1, v2 in edges:
164
+ common = adj[v1] & adj[v2]
165
+ for v3 in common:
166
+ tri = tuple(sorted([v1, v2, v3]))
167
+ triangles.add(tri)
168
+
169
+ if not triangles:
170
+ # Fallback: create faces using Delaunay on 2D projection
171
+ from scipy.spatial import Delaunay
172
+
173
+ pts_2d = vertices[:, :2]
174
+ try:
175
+ tri = Delaunay(pts_2d)
176
+ return tri.simplices.astype(np.int32)
177
+ except Exception:
178
+ return np.zeros((0, 3), dtype=np.int32)
179
+
180
+ return np.array(list(triangles), dtype=np.int32)
server/engine/shape_match.py ADDED
@@ -0,0 +1,101 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Shape matching for reward computation.
2
+
3
+ Computes similarity between the LLM's folded shape and the target shape.
4
+ Like AlphaFold's RMSD but for origami vertex positions.
5
+ """
6
+
7
+ import numpy as np
8
+ from scipy.spatial.distance import cdist
9
+
10
+
11
+ def compute_shape_match(
12
+ predicted: np.ndarray,
13
+ target: np.ndarray,
14
+ ) -> float:
15
+ """Compute shape similarity between predicted and target positions.
16
+
17
+ Uses chamfer distance normalized by bounding box diagonal.
18
+ Aligns shapes by centering before comparison.
19
+
20
+ Args:
21
+ predicted: (N, 3) predicted vertex positions.
22
+ target: (M, 3) target vertex positions.
23
+
24
+ Returns:
25
+ Similarity score in [0, 1]. 1.0 = perfect match.
26
+ """
27
+ if len(predicted) == 0 or len(target) == 0:
28
+ return 0.0
29
+
30
+ # Center both point clouds
31
+ pred_centered = predicted - predicted.mean(axis=0)
32
+ target_centered = target - target.mean(axis=0)
33
+
34
+ # Try multiple rotations and pick best match
35
+ # (the LLM's pattern might produce a rotated version of the target)
36
+ best_score = 0.0
37
+ for rotation in _get_alignment_rotations():
38
+ rotated = pred_centered @ rotation.T
39
+ score = _chamfer_similarity(rotated, target_centered)
40
+ best_score = max(best_score, score)
41
+
42
+ return best_score
43
+
44
+
45
+ def _chamfer_similarity(a: np.ndarray, b: np.ndarray) -> float:
46
+ """Chamfer distance converted to similarity score.
47
+
48
+ Chamfer = average nearest-neighbor distance (bidirectional).
49
+ Similarity = 1 - (chamfer / diagonal), clamped to [0, 1].
50
+ """
51
+ d = cdist(a, b)
52
+
53
+ # Forward: for each point in a, min distance to b
54
+ forward = d.min(axis=1).mean()
55
+ # Backward: for each point in b, min distance to a
56
+ backward = d.min(axis=0).mean()
57
+ chamfer = (forward + backward) / 2.0
58
+
59
+ # Normalize by bounding box diagonal of target
60
+ all_pts = np.vstack([a, b])
61
+ bbox_diag = np.linalg.norm(all_pts.max(axis=0) - all_pts.min(axis=0))
62
+ if bbox_diag < 1e-12:
63
+ return 1.0 if chamfer < 1e-12 else 0.0
64
+
65
+ similarity = max(0.0, 1.0 - chamfer / bbox_diag)
66
+ return similarity
67
+
68
+
69
+ def _get_alignment_rotations() -> list[np.ndarray]:
70
+ """Generate rotation matrices for alignment search.
71
+
72
+ We check identity + 90Β° rotations around each axis (24 orientations).
73
+ This handles cases where the LLM's fold produces a rotated version.
74
+ """
75
+ I = np.eye(3)
76
+ rotations = [I]
77
+
78
+ # 90Β° rotations around Z axis
79
+ for k in range(1, 4):
80
+ angle = k * np.pi / 2
81
+ c, s = np.cos(angle), np.sin(angle)
82
+ rotations.append(np.array([[c, -s, 0], [s, c, 0], [0, 0, 1]]))
83
+
84
+ # 90Β° rotations around X axis
85
+ for k in range(1, 4):
86
+ angle = k * np.pi / 2
87
+ c, s = np.cos(angle), np.sin(angle)
88
+ rotations.append(np.array([[1, 0, 0], [0, c, -s], [0, s, c]]))
89
+
90
+ # 90Β° rotations around Y axis
91
+ for k in range(1, 4):
92
+ angle = k * np.pi / 2
93
+ c, s = np.cos(angle), np.sin(angle)
94
+ rotations.append(np.array([[c, 0, s], [0, 1, 0], [-s, 0, c]]))
95
+
96
+ # Flip (mirror)
97
+ rotations.append(np.diag([-1, 1, 1]))
98
+ rotations.append(np.diag([1, -1, 1]))
99
+ rotations.append(np.diag([1, 1, -1]))
100
+
101
+ return rotations
server/engine/simulate.py ADDED
@@ -0,0 +1,175 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Origami fold simulator β€” analytical rotation with cumulative transforms.
2
+
3
+ BFS from face 0 through the face adjacency graph. Each face accumulates
4
+ a rotation transform (R, t) such that: folded_pos = R @ flat_pos + t.
5
+ When crossing a fold edge, the fold rotation is composed with the parent
6
+ face's transform. Non-fold edges inherit the parent's transform directly.
7
+
8
+ This correctly handles multiple intersecting folds (e.g. quarter fold)
9
+ because each face's transform captures ALL upstream folds.
10
+ """
11
+
12
+ from dataclasses import dataclass
13
+
14
+ import numpy as np
15
+ from scipy.spatial.transform import Rotation
16
+
17
+ from .fold_parser import parse_fold
18
+
19
+
20
+ @dataclass
21
+ class SimResult:
22
+ """Result of a fold simulation."""
23
+
24
+ positions: np.ndarray # (N, 3) final vertex positions
25
+ converged: bool
26
+ steps_taken: int
27
+ max_strain: float
28
+ total_energy: float
29
+
30
+
31
+ def simulate(
32
+ fold_data: dict,
33
+ crease_percent: float = 1.0,
34
+ max_steps: int = 500,
35
+ params: dict | None = None,
36
+ ) -> SimResult:
37
+ """Simulate a FOLD crease pattern and return final 3D positions.
38
+
39
+ Uses cumulative rotation transforms per face. BFS from face 0,
40
+ composing fold rotations at each crease edge.
41
+
42
+ Args:
43
+ fold_data: FOLD-format dict with vertices, edges, assignments, angles.
44
+ crease_percent: 0.0 = flat, 1.0 = fully folded.
45
+ max_steps: Unused (kept for API compat).
46
+ params: Unused (kept for API compat).
47
+
48
+ Returns:
49
+ SimResult with final positions, strain info.
50
+ """
51
+ parsed = parse_fold(fold_data)
52
+ flat_pos = parsed["vertices"].copy()
53
+ edges = parsed["edges"]
54
+ assignments = parsed["assignments"]
55
+ fold_angles = parsed["fold_angles"]
56
+ faces = parsed["faces"]
57
+ positions = flat_pos.copy()
58
+
59
+ if len(faces) == 0:
60
+ return SimResult(
61
+ positions=positions, converged=True,
62
+ steps_taken=0, max_strain=0.0, total_energy=0.0,
63
+ )
64
+
65
+ # Build face adjacency: edge -> [face_idx, ...]
66
+ face_adj = _build_face_adjacency(faces)
67
+
68
+ # Build crease map: (v_min, v_max) -> fold_angle_rad * crease_percent
69
+ crease_map: dict[tuple[int, int], float] = {}
70
+ for i, (v1, v2) in enumerate(edges):
71
+ key = (min(int(v1), int(v2)), max(int(v1), int(v2)))
72
+ if assignments[i] in ("M", "V"):
73
+ crease_map[key] = fold_angles[i] * crease_percent
74
+
75
+ # Per-face cumulative transform: folded = R @ flat + t
76
+ n_faces = len(faces)
77
+ face_R = [None] * n_faces
78
+ face_t = [None] * n_faces
79
+
80
+ # Face 0 is fixed (identity transform)
81
+ face_R[0] = np.eye(3)
82
+ face_t[0] = np.zeros(3)
83
+
84
+ visited = [False] * n_faces
85
+ visited[0] = True
86
+
87
+ placed: set[int] = set()
88
+ for vi in faces[0]:
89
+ placed.add(int(vi))
90
+
91
+ queue = [0]
92
+ while queue:
93
+ fi = queue.pop(0)
94
+ face = faces[fi]
95
+
96
+ for j in range(len(face)):
97
+ v1, v2 = int(face[j]), int(face[(j + 1) % len(face)])
98
+ edge_key = (min(v1, v2), max(v1, v2))
99
+
100
+ for fj in face_adj.get(edge_key, []):
101
+ if visited[fj]:
102
+ continue
103
+ visited[fj] = True
104
+ queue.append(fj)
105
+
106
+ angle = crease_map.get(edge_key, 0.0)
107
+
108
+ if abs(angle) > 1e-10:
109
+ # Fold rotation around the edge in folded space
110
+ p1 = positions[v1].copy()
111
+ axis = positions[v2] - p1
112
+ axis_len = np.linalg.norm(axis)
113
+ if axis_len > 1e-12:
114
+ axis_unit = axis / axis_len
115
+ fold_rot = Rotation.from_rotvec(
116
+ angle * axis_unit,
117
+ ).as_matrix()
118
+ else:
119
+ fold_rot = np.eye(3)
120
+
121
+ # Compose: R_fj = fold_rot @ R_fi, t_fj adjusted for pivot
122
+ face_R[fj] = fold_rot @ face_R[fi]
123
+ face_t[fj] = fold_rot @ (face_t[fi] - p1) + p1
124
+ else:
125
+ # No fold β€” inherit parent's transform
126
+ face_R[fj] = face_R[fi].copy()
127
+ face_t[fj] = face_t[fi].copy()
128
+
129
+ # Place unplaced vertices using this face's transform
130
+ for vi in faces[fj]:
131
+ vi_int = int(vi)
132
+ if vi_int not in placed:
133
+ positions[vi_int] = face_R[fj] @ flat_pos[vi_int] + face_t[fj]
134
+ placed.add(vi_int)
135
+
136
+ # Compute strain (deviation from rest edge lengths)
137
+ max_strain = _compute_strain(positions, parsed)
138
+
139
+ return SimResult(
140
+ positions=positions,
141
+ converged=True,
142
+ steps_taken=1,
143
+ max_strain=max_strain,
144
+ total_energy=0.0,
145
+ )
146
+
147
+
148
+ def _build_face_adjacency(
149
+ faces: np.ndarray,
150
+ ) -> dict[tuple[int, int], list[int]]:
151
+ """Map each edge (sorted vertex pair) to list of face indices."""
152
+ adj: dict[tuple[int, int], list[int]] = {}
153
+ for fi, face in enumerate(faces):
154
+ n = len(face)
155
+ for j in range(n):
156
+ v1, v2 = int(face[j]), int(face[(j + 1) % n])
157
+ key = (min(v1, v2), max(v1, v2))
158
+ if key not in adj:
159
+ adj[key] = []
160
+ adj[key].append(fi)
161
+ return adj
162
+
163
+
164
+ def _compute_strain(positions: np.ndarray, parsed: dict) -> float:
165
+ """Compute max axial strain across all edges."""
166
+ edges = parsed["edges"]
167
+ vertices_flat = parsed["vertices"]
168
+ max_strain = 0.0
169
+ for v1, v2 in edges:
170
+ rest = np.linalg.norm(vertices_flat[v2] - vertices_flat[v1])
171
+ curr = np.linalg.norm(positions[v2] - positions[v1])
172
+ if rest > 1e-12:
173
+ strain = abs(curr - rest) / rest
174
+ max_strain = max(max_strain, strain)
175
+ return max_strain
server/environment.py ADDED
@@ -0,0 +1,169 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Origami RL Environment β€” OpenEnv Environment subclass.
2
+
3
+ Single-shot episodes: LLM submits a FOLD crease pattern, physics simulates it,
4
+ reward = shape similarity to target. Like AlphaFold for origami.
5
+ """
6
+
7
+ import uuid
8
+ from typing import Any, Optional
9
+
10
+ import numpy as np
11
+ from openenv.core import Environment
12
+
13
+ from .engine.fold_parser import validate_fold
14
+ from .engine.shape_match import compute_shape_match
15
+ from .engine.simulate import SimResult, simulate
16
+ from .models import OrigamiAction, OrigamiObservation, OrigamiState
17
+ from .tasks import get_task
18
+
19
+
20
+ class OrigamiEnvironment(
21
+ Environment[OrigamiAction, OrigamiObservation, OrigamiState]
22
+ ):
23
+ """Origami folding environment.
24
+
25
+ Episode flow:
26
+ 1. reset(task_name="triangle") β†’ returns task description + target info
27
+ 2. step(OrigamiAction(fold_data={...})) β†’ simulates, scores, returns done=True
28
+
29
+ Single action per episode. The action IS the complete crease pattern.
30
+ """
31
+
32
+ SUPPORTS_CONCURRENT_SESSIONS = True
33
+
34
+ def __init__(self, **kwargs: Any):
35
+ super().__init__(**kwargs)
36
+ self._state = OrigamiState()
37
+ self._task: dict = {}
38
+ self._target_positions: np.ndarray = np.zeros((0, 3))
39
+
40
+ def reset(
41
+ self,
42
+ seed: Optional[int] = None,
43
+ episode_id: Optional[str] = None,
44
+ **kwargs: Any,
45
+ ) -> OrigamiObservation:
46
+ """Start a new episode with a target shape task."""
47
+ self._state = OrigamiState(
48
+ episode_id=episode_id or str(uuid.uuid4()),
49
+ step_count=0,
50
+ )
51
+
52
+ # Get task
53
+ task_name = kwargs.get("task_name", "triangle")
54
+ self._task = get_task(task_name)
55
+ self._state.task_name = self._task["name"]
56
+
57
+ # Simulate the target FOLD to get target positions
58
+ target_fold = self._task["target_fold"]
59
+ try:
60
+ target_result = simulate(target_fold, crease_percent=1.0)
61
+ self._target_positions = target_result.positions
62
+ except Exception as e:
63
+ self._target_positions = np.zeros((0, 3))
64
+
65
+ return OrigamiObservation(
66
+ done=False,
67
+ reward=None,
68
+ task={
69
+ "name": self._task["name"],
70
+ "description": self._task["description"],
71
+ "difficulty": self._task["difficulty"],
72
+ "paper": self._task["paper"],
73
+ },
74
+ fold_data={},
75
+ final_positions=[],
76
+ target_positions=self._target_positions.tolist(),
77
+ shape_similarity=0.0,
78
+ max_strain=0.0,
79
+ is_stable=True,
80
+ error=None,
81
+ )
82
+
83
+ def step(
84
+ self,
85
+ action: OrigamiAction,
86
+ timeout_s: Optional[float] = None,
87
+ **kwargs: Any,
88
+ ) -> OrigamiObservation:
89
+ """Evaluate the LLM's crease pattern.
90
+
91
+ 1. Validate FOLD data
92
+ 2. Run physics simulation (creasePercent=1.0)
93
+ 3. Compare final shape to target
94
+ 4. Return observation with reward = similarity Γ— 20
95
+ """
96
+ self._state.step_count += 1
97
+ fold_data = action.fold_data
98
+
99
+ # Validate
100
+ is_valid, error_msg = validate_fold(fold_data)
101
+ if not is_valid:
102
+ self._state.is_stable = False
103
+ return OrigamiObservation(
104
+ done=True,
105
+ reward=-2.0,
106
+ task=self._task_info(),
107
+ fold_data=fold_data,
108
+ final_positions=[],
109
+ target_positions=self._target_positions.tolist(),
110
+ shape_similarity=0.0,
111
+ max_strain=0.0,
112
+ is_stable=False,
113
+ error=f"Invalid FOLD data: {error_msg}",
114
+ )
115
+
116
+ # Simulate
117
+ try:
118
+ result: SimResult = simulate(fold_data, crease_percent=1.0)
119
+ except Exception as e:
120
+ self._state.is_stable = False
121
+ return OrigamiObservation(
122
+ done=True,
123
+ reward=-2.0,
124
+ task=self._task_info(),
125
+ fold_data=fold_data,
126
+ final_positions=[],
127
+ target_positions=self._target_positions.tolist(),
128
+ shape_similarity=0.0,
129
+ max_strain=0.0,
130
+ is_stable=False,
131
+ error=f"Simulation error: {str(e)}",
132
+ )
133
+
134
+ # Shape match
135
+ similarity = compute_shape_match(
136
+ result.positions, self._target_positions
137
+ )
138
+ reward = similarity * 20.0
139
+
140
+ self._state.shape_similarity = similarity
141
+ self._state.is_stable = result.converged
142
+
143
+ return OrigamiObservation(
144
+ done=True,
145
+ reward=reward,
146
+ task=self._task_info(),
147
+ fold_data=fold_data,
148
+ final_positions=result.positions.tolist(),
149
+ target_positions=self._target_positions.tolist(),
150
+ shape_similarity=similarity,
151
+ max_strain=result.max_strain,
152
+ is_stable=result.converged,
153
+ error=None,
154
+ )
155
+
156
+ @property
157
+ def state(self) -> OrigamiState:
158
+ return self._state
159
+
160
+ def _task_info(self) -> dict:
161
+ """Task info dict for observations."""
162
+ if not self._task:
163
+ return {}
164
+ return {
165
+ "name": self._task.get("name", ""),
166
+ "description": self._task.get("description", ""),
167
+ "difficulty": self._task.get("difficulty", 0),
168
+ "paper": self._task.get("paper", {}),
169
+ }
server/models.py ADDED
@@ -0,0 +1,54 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """OpenEnv types for the Origami RL environment.
2
+
3
+ OrigamiAction: LLM submits a FOLD crease pattern.
4
+ OrigamiObservation: Result of simulating that pattern against a target.
5
+ OrigamiState: Internal episode state.
6
+ """
7
+
8
+ from typing import Any, Optional
9
+
10
+ from openenv.core import Action, Observation, State
11
+ from pydantic import Field
12
+
13
+
14
+ class OrigamiAction(Action):
15
+ """LLM submits a FOLD crease pattern as its action.
16
+
17
+ The fold_data dict must contain:
18
+ - vertices_coords: [[x, y], ...] β€” 2D vertex positions on flat paper
19
+ - edges_vertices: [[v1, v2], ...] β€” edge connectivity
20
+ - edges_assignment: ["B"|"M"|"V", ...] β€” boundary/mountain/valley
21
+ - edges_foldAngle: [angle, ...] β€” target fold angles in degrees
22
+ (optional β€” defaults from assignment: M=-180, V=+180, B=0)
23
+ """
24
+
25
+ fold_data: dict[str, Any] = Field(
26
+ ..., description="FOLD-format crease pattern JSON"
27
+ )
28
+
29
+
30
+ class OrigamiObservation(Observation):
31
+ """Result of simulating the LLM's crease pattern.
32
+
33
+ Contains everything the viewer and reward function need:
34
+ - The submitted fold data and simulation results
35
+ - Target shape for overlay comparison
36
+ - Shape similarity score (the reward signal)
37
+ """
38
+
39
+ task: dict[str, Any] = Field(default_factory=dict)
40
+ fold_data: dict[str, Any] = Field(default_factory=dict)
41
+ final_positions: list[list[float]] = Field(default_factory=list)
42
+ target_positions: list[list[float]] = Field(default_factory=list)
43
+ shape_similarity: float = 0.0
44
+ max_strain: float = 0.0
45
+ is_stable: bool = True
46
+ error: Optional[str] = None
47
+
48
+
49
+ class OrigamiState(State):
50
+ """Internal state for an origami episode."""
51
+
52
+ task_name: str = ""
53
+ shape_similarity: float = 0.0
54
+ is_stable: bool = True
server/requirements.txt ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ openenv-core>=0.2.1
2
+ numpy>=1.24
3
+ scipy>=1.10
4
+ pydantic>=2.0
5
+ fastapi>=0.100
6
+ uvicorn>=0.22
server/tasks.py ADDED
@@ -0,0 +1,123 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Task definitions for origami RL training.
2
+
3
+ Each task defines a target shape as a reference FOLD crease pattern.
4
+ The LLM must discover a crease pattern that folds into the same shape.
5
+
6
+ Starting simple (triangle) and progressing to harder folds.
7
+ """
8
+
9
+ TASKS: dict[str, dict] = {
10
+ "triangle": {
11
+ "name": "triangle",
12
+ "description": "Fold the paper in half diagonally to make a triangle",
13
+ "difficulty": 1,
14
+ "paper": {"width": 1.0, "height": 1.0},
15
+ "target_fold": {
16
+ "vertices_coords": [[0, 0], [1, 0], [1, 1], [0, 1]],
17
+ "edges_vertices": [[0, 1], [1, 2], [2, 3], [3, 0], [0, 2]],
18
+ "edges_assignment": ["B", "B", "B", "B", "V"],
19
+ "edges_foldAngle": [0, 0, 0, 0, 180],
20
+ "faces_vertices": [[0, 1, 2], [0, 2, 3]],
21
+ },
22
+ },
23
+ "half_fold": {
24
+ "name": "half_fold",
25
+ "description": "Fold the paper in half horizontally",
26
+ "difficulty": 1,
27
+ "paper": {"width": 1.0, "height": 1.0},
28
+ "target_fold": {
29
+ "vertices_coords": [
30
+ [0, 0], [1, 0], [1, 1], [0, 1], [0, 0.5], [1, 0.5],
31
+ ],
32
+ "edges_vertices": [
33
+ [0, 1], [1, 5], [5, 2], [2, 3], [3, 4], [4, 0],
34
+ [4, 5],
35
+ ],
36
+ "edges_assignment": ["B", "B", "B", "B", "B", "B", "V"],
37
+ "edges_foldAngle": [0, 0, 0, 0, 0, 0, 180],
38
+ "faces_vertices": [[0, 1, 5, 4], [4, 5, 2, 3]],
39
+ },
40
+ },
41
+ "quarter_fold": {
42
+ "name": "quarter_fold",
43
+ "description": "Fold the paper into quarters (two perpendicular folds)",
44
+ "difficulty": 2,
45
+ "paper": {"width": 1.0, "height": 1.0},
46
+ "target_fold": {
47
+ "vertices_coords": [
48
+ [0, 0], [0.5, 0], [1, 0],
49
+ [0, 0.5], [0.5, 0.5], [1, 0.5],
50
+ [0, 1], [0.5, 1], [1, 1],
51
+ ],
52
+ "edges_vertices": [
53
+ # Boundary
54
+ [0, 1], [1, 2], [2, 5], [5, 8], [8, 7], [7, 6], [6, 3], [3, 0],
55
+ # Fold lines
56
+ [1, 4], [4, 7], # vertical fold
57
+ [3, 4], [4, 5], # horizontal fold
58
+ ],
59
+ "edges_assignment": [
60
+ "B", "B", "B", "B", "B", "B", "B", "B",
61
+ "V", "V", "V", "V",
62
+ ],
63
+ "edges_foldAngle": [
64
+ 0, 0, 0, 0, 0, 0, 0, 0,
65
+ 180, 180, 180, 180,
66
+ ],
67
+ "faces_vertices": [
68
+ [0, 1, 4, 3], # bottom-left
69
+ [1, 2, 5, 4], # bottom-right
70
+ [3, 4, 7, 6], # top-left
71
+ [4, 5, 8, 7], # top-right
72
+ ],
73
+ },
74
+ },
75
+ "letter_fold": {
76
+ "name": "letter_fold",
77
+ "description": "Tri-fold the paper like a letter (two parallel folds)",
78
+ "difficulty": 2,
79
+ "paper": {"width": 1.0, "height": 1.0},
80
+ "target_fold": {
81
+ "vertices_coords": [
82
+ [0, 0], [1, 0],
83
+ [0, 1/3], [1, 1/3],
84
+ [0, 2/3], [1, 2/3],
85
+ [0, 1], [1, 1],
86
+ ],
87
+ "edges_vertices": [
88
+ # Boundary
89
+ [0, 1], [1, 3], [3, 5], [5, 7], [7, 6], [6, 4], [4, 2], [2, 0],
90
+ # Fold lines
91
+ [2, 3], # first fold (valley)
92
+ [4, 5], # second fold (mountain)
93
+ ],
94
+ "edges_assignment": [
95
+ "B", "B", "B", "B", "B", "B", "B", "B",
96
+ "V", "M",
97
+ ],
98
+ "edges_foldAngle": [
99
+ 0, 0, 0, 0, 0, 0, 0, 0,
100
+ 180, -180,
101
+ ],
102
+ "faces_vertices": [
103
+ [0, 1, 3, 2], # bottom strip
104
+ [2, 3, 5, 4], # middle strip
105
+ [4, 5, 7, 6], # top strip
106
+ ],
107
+ },
108
+ },
109
+ }
110
+
111
+
112
+ def get_task(name: str | None = None) -> dict:
113
+ """Get a task by name. Defaults to 'triangle'."""
114
+ if name is None:
115
+ name = "triangle"
116
+ if name not in TASKS:
117
+ raise ValueError(f"Unknown task '{name}'. Available: {list(TASKS.keys())}")
118
+ return TASKS[name]
119
+
120
+
121
+ def list_tasks() -> list[str]:
122
+ """List all available task names."""
123
+ return list(TASKS.keys())
tests/test_origami.py ADDED
@@ -0,0 +1,292 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tests for origami RL environment."""
2
+
3
+ import numpy as np
4
+ import pytest
5
+
6
+ from server.engine.fold_parser import parse_fold, validate_fold
7
+ from server.engine.shape_match import compute_shape_match
8
+ from server.engine.simulate import simulate
9
+ from server.environment import OrigamiEnvironment
10
+ from server.models import OrigamiAction
11
+ from server.tasks import TASKS, get_task, list_tasks
12
+ from training.reward import extract_fold_json, shape_match, valid_fold
13
+
14
+ # --- Fixtures ---
15
+
16
+ TRIANGLE_FOLD = {
17
+ "vertices_coords": [[0, 0], [1, 0], [1, 1], [0, 1]],
18
+ "edges_vertices": [[0, 1], [1, 2], [2, 3], [3, 0], [0, 2]],
19
+ "edges_assignment": ["B", "B", "B", "B", "V"],
20
+ "edges_foldAngle": [0, 0, 0, 0, 180],
21
+ }
22
+
23
+ HALF_FOLD = {
24
+ "vertices_coords": [[0, 0], [1, 0], [1, 1], [0, 1], [0, 0.5], [1, 0.5]],
25
+ "edges_vertices": [[0, 1], [1, 5], [5, 4], [4, 0], [4, 3], [3, 2], [2, 5], [4, 5]],
26
+ "edges_assignment": ["B", "B", "B", "B", "B", "B", "B", "V"],
27
+ "edges_foldAngle": [0, 0, 0, 0, 0, 0, 0, 180],
28
+ }
29
+
30
+
31
+ # --- Validation ---
32
+
33
+
34
+ class TestValidation:
35
+ def test_valid_fold_accepted(self):
36
+ valid, err = validate_fold(TRIANGLE_FOLD)
37
+ assert valid, err
38
+
39
+ def test_missing_field_rejected(self):
40
+ valid, _ = validate_fold({"vertices_coords": [[0, 0]]})
41
+ assert not valid
42
+
43
+ def test_no_creases_rejected(self):
44
+ valid, _ = validate_fold(
45
+ {
46
+ "vertices_coords": [[0, 0], [1, 0], [1, 1]],
47
+ "edges_vertices": [[0, 1], [1, 2], [2, 0]],
48
+ "edges_assignment": ["B", "B", "B"],
49
+ }
50
+ )
51
+ assert not valid
52
+
53
+ def test_bad_vertex_index_rejected(self):
54
+ valid, _ = validate_fold(
55
+ {
56
+ "vertices_coords": [[0, 0], [1, 0], [1, 1]],
57
+ "edges_vertices": [[0, 1], [1, 2], [2, 99]],
58
+ "edges_assignment": ["B", "B", "V"],
59
+ }
60
+ )
61
+ assert not valid
62
+
63
+ def test_degenerate_edge_rejected(self):
64
+ valid, _ = validate_fold(
65
+ {
66
+ "vertices_coords": [[0, 0], [1, 0], [1, 1]],
67
+ "edges_vertices": [[0, 1], [1, 1], [1, 2]],
68
+ "edges_assignment": ["B", "V", "B"],
69
+ }
70
+ )
71
+ assert not valid
72
+
73
+
74
+ # --- Parsing ---
75
+
76
+
77
+ class TestParsing:
78
+ def test_parse_creates_3d_vertices(self):
79
+ p = parse_fold(TRIANGLE_FOLD)
80
+ assert p["vertices"].shape == (4, 3)
81
+ assert np.allclose(p["vertices"][:, 2], 0)
82
+
83
+ def test_parse_computes_faces(self):
84
+ p = parse_fold(TRIANGLE_FOLD)
85
+ assert len(p["faces"]) >= 2
86
+
87
+ def test_parse_angles_in_radians(self):
88
+ p = parse_fold(TRIANGLE_FOLD)
89
+ assert abs(p["fold_angles"][4] - np.pi) < 0.01
90
+
91
+
92
+ # --- Physics ---
93
+
94
+
95
+ class TestPhysics:
96
+ def test_flat_stays_flat(self):
97
+ r = simulate(TRIANGLE_FOLD, crease_percent=0.0, max_steps=100)
98
+ assert np.max(np.abs(r.positions[:, 2])) < 0.01
99
+
100
+ def test_fold_creates_z_displacement(self):
101
+ # A partial fold (90Β°) creates z displacement; full 180Β° folds flat
102
+ r = simulate(TRIANGLE_FOLD, crease_percent=0.5, max_steps=2000)
103
+ z_range = r.positions[:, 2].max() - r.positions[:, 2].min()
104
+ assert z_range > 0.1
105
+
106
+ def test_valley_fold_brings_vertices_together(self):
107
+ r = simulate(TRIANGLE_FOLD, crease_percent=1.0, max_steps=2000)
108
+ dist = np.linalg.norm(r.positions[1] - r.positions[3])
109
+ assert dist < 0.1
110
+
111
+ def test_half_fold_works(self):
112
+ # Full fold: top vertices should overlap bottom vertices
113
+ r = simulate(HALF_FOLD, crease_percent=1.0, max_steps=2000)
114
+ # v2=[1,1] should fold onto v1=[1,0], v3=[0,1] onto v0=[0,0]
115
+ dist = np.linalg.norm(r.positions[2] - r.positions[1])
116
+ assert dist < 0.1, f"v2 didn't fold onto v1 (dist={dist})"
117
+
118
+ def test_all_tasks_fold(self):
119
+ for name, task in TASKS.items():
120
+ r = simulate(task["target_fold"], crease_percent=1.0, max_steps=2000)
121
+ assert r.converged, f"Task {name} did not converge"
122
+ assert r.max_strain < 0.01, f"Task {name} has high strain ({r.max_strain})"
123
+ # Partial fold should produce z displacement
124
+ r_half = simulate(task["target_fold"], crease_percent=0.5)
125
+ z_range = r_half.positions[:, 2].max() - r_half.positions[:, 2].min()
126
+ assert z_range > 0.01, f"Task {name} partial fold no z (z_range={z_range})"
127
+
128
+
129
+ # --- Shape Match ---
130
+
131
+
132
+ class TestShapeMatch:
133
+ def test_same_shape_perfect_match(self):
134
+ r = simulate(TRIANGLE_FOLD, crease_percent=1.0, max_steps=2000)
135
+ sim = compute_shape_match(r.positions, r.positions)
136
+ assert sim > 0.99
137
+
138
+ def test_different_shapes_lower_match(self):
139
+ target = simulate(TRIANGLE_FOLD, crease_percent=1.0, max_steps=2000)
140
+ wrong = simulate(HALF_FOLD, crease_percent=1.0, max_steps=2000)
141
+ sim = compute_shape_match(wrong.positions, target.positions)
142
+ assert sim < 0.95
143
+
144
+ def test_flat_vs_folded_lower_match(self):
145
+ target = simulate(TRIANGLE_FOLD, crease_percent=1.0, max_steps=2000)
146
+ flat = np.array([[0, 0, 0], [1, 0, 0], [1, 1, 0], [0, 1, 0]], dtype=float)
147
+ sim = compute_shape_match(flat, target.positions)
148
+ assert sim < 0.95
149
+
150
+
151
+ # --- Environment ---
152
+
153
+
154
+ class TestEnvironment:
155
+ def test_reset(self):
156
+ env = OrigamiEnvironment()
157
+ obs = env.reset(task_name="triangle")
158
+ assert not obs.done
159
+ assert obs.reward is None
160
+ assert len(obs.target_positions) > 0
161
+
162
+ def test_step_correct_fold(self):
163
+ env = OrigamiEnvironment()
164
+ env.reset(task_name="triangle")
165
+ obs = env.step(OrigamiAction(fold_data=TRIANGLE_FOLD))
166
+ assert obs.done
167
+ assert obs.reward == 20.0
168
+ assert obs.shape_similarity == 1.0
169
+
170
+ def test_step_invalid_fold(self):
171
+ env = OrigamiEnvironment()
172
+ env.reset(task_name="triangle")
173
+ obs = env.step(OrigamiAction(fold_data={"bad": True}))
174
+ assert obs.done
175
+ assert obs.reward == -2.0
176
+ assert obs.error is not None
177
+
178
+ def test_state(self):
179
+ env = OrigamiEnvironment()
180
+ env.reset(task_name="triangle")
181
+ assert env.state.task_name == "triangle"
182
+
183
+
184
+ # --- Tasks ---
185
+
186
+
187
+ class TestTasks:
188
+ def test_four_tasks(self):
189
+ assert len(list_tasks()) == 4
190
+
191
+ def test_get_task(self):
192
+ task = get_task("triangle")
193
+ assert task["name"] == "triangle"
194
+
195
+ def test_invalid_task_raises(self):
196
+ with pytest.raises(ValueError):
197
+ get_task("nonexistent")
198
+
199
+
200
+ # --- Rewards ---
201
+
202
+
203
+ class TestRewards:
204
+ def test_extract_json_fenced(self):
205
+ text = '```json\n{"vertices_coords": [[0, 0]]}\n```'
206
+ assert extract_fold_json(text) is not None
207
+
208
+ def test_extract_json_raw(self):
209
+ text = '{"vertices_coords": [[0, 0]]}'
210
+ assert extract_fold_json(text) is not None
211
+
212
+ def test_extract_none_garbage(self):
213
+ assert extract_fold_json("no json here") is None
214
+
215
+ def test_valid_fold_reward(self):
216
+ import json
217
+
218
+ good = [[{"content": json.dumps(TRIANGLE_FOLD)}]]
219
+ bad = [[{"content": "nope"}]]
220
+ scores = valid_fold(good + bad)
221
+ assert scores[0] == 1.0
222
+ assert scores[1] == -2.0
223
+
224
+ def test_shape_match_reward(self):
225
+ import json
226
+
227
+ good = [[{"content": json.dumps(TRIANGLE_FOLD)}]]
228
+ bad = [[{"content": "nope"}]]
229
+ scores = shape_match(good + bad, task_name="triangle")
230
+ assert scores[0] == 20.0
231
+ assert scores[1] == -2.0
232
+
233
+
234
+ # --- API ---
235
+
236
+
237
+ class TestAPI:
238
+ @pytest.fixture
239
+ def client(self):
240
+ from fastapi.testclient import TestClient
241
+
242
+ from server.app import app
243
+
244
+ return TestClient(app)
245
+
246
+ def test_health(self, client):
247
+ r = client.get("/health")
248
+ assert r.status_code == 200
249
+ assert r.json()["status"] == "healthy"
250
+
251
+ def test_tasks_endpoint(self, client):
252
+ r = client.get("/tasks")
253
+ assert r.status_code == 200
254
+ tasks = r.json()
255
+ assert "triangle" in tasks
256
+ assert "half_fold" in tasks
257
+ assert len(tasks) == 4
258
+
259
+ def test_task_detail_endpoint(self, client):
260
+ r = client.get("/tasks/triangle")
261
+ assert r.status_code == 200
262
+ data = r.json()
263
+ assert data["name"] == "triangle"
264
+ assert "target_fold" in data
265
+
266
+ def test_task_not_found(self, client):
267
+ r = client.get("/tasks/nonexistent")
268
+ assert r.status_code == 404
269
+
270
+ def test_websocket_reset_step(self, client):
271
+ with client.websocket_connect("/ws") as ws:
272
+ ws.send_json({"type": "reset", "data": {"task_name": "triangle"}})
273
+ resp = ws.receive_json()
274
+ assert resp["type"] == "observation"
275
+ assert resp["data"]["done"] is False
276
+
277
+ ws.send_json({"type": "step", "data": {"fold_data": TRIANGLE_FOLD}})
278
+ resp = ws.receive_json()
279
+ assert resp["type"] == "observation"
280
+ assert resp["data"]["reward"] == 20.0
281
+ assert resp["data"]["done"] is True
282
+
283
+ def test_websocket_all_tasks(self, client):
284
+ for task_name in ("triangle", "half_fold", "quarter_fold", "letter_fold"):
285
+ r = client.get(f"/tasks/{task_name}")
286
+ fold_data = r.json()["target_fold"]
287
+ with client.websocket_connect("/ws") as ws:
288
+ ws.send_json({"type": "reset", "data": {"task_name": task_name}})
289
+ ws.receive_json()
290
+ ws.send_json({"type": "step", "data": {"fold_data": fold_data}})
291
+ resp = ws.receive_json()
292
+ assert resp["data"]["reward"] == 20.0, f"{task_name} failed"
training/__init__.py ADDED
File without changes
training/reward.py ADDED
@@ -0,0 +1,122 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """GRPO reward functions for origami RL training.
2
+
3
+ Two reward functions (matching the 2048 pattern):
4
+ 1. valid_fold: Does the LLM output parse as valid FOLD JSON?
5
+ 2. shape_match: Simulate and compare to target shape.
6
+ """
7
+
8
+ import json
9
+ import re
10
+ from typing import Any
11
+
12
+ import numpy as np
13
+
14
+ from server.engine.fold_parser import validate_fold
15
+ from server.engine.shape_match import compute_shape_match
16
+ from server.engine.simulate import simulate
17
+ from server.tasks import get_task
18
+
19
+
20
+ def extract_fold_json(response: str) -> dict | None:
21
+ """Extract FOLD JSON from LLM response text.
22
+
23
+ Looks for JSON between ```json ... ``` or raw JSON object.
24
+ """
25
+ # Try fenced code block first
26
+ match = re.search(r"```(?:json)?\s*(\{.*?\})\s*```", response, re.DOTALL)
27
+ if match:
28
+ try:
29
+ return json.loads(match.group(1))
30
+ except json.JSONDecodeError:
31
+ pass
32
+
33
+ # Try raw JSON object
34
+ match = re.search(r"\{[^{}]*\"vertices_coords\"[^{}]*\}", response, re.DOTALL)
35
+ if match:
36
+ try:
37
+ return json.loads(match.group(0))
38
+ except json.JSONDecodeError:
39
+ pass
40
+
41
+ # Try parsing the whole response
42
+ try:
43
+ data = json.loads(response.strip())
44
+ if isinstance(data, dict) and "vertices_coords" in data:
45
+ return data
46
+ except (json.JSONDecodeError, ValueError):
47
+ pass
48
+
49
+ return None
50
+
51
+
52
+ def valid_fold(completions: list, **kwargs: Any) -> list[float]:
53
+ """Reward 1: Does the LLM output parse as valid FOLD JSON?
54
+
55
+ +1.0 valid FOLD JSON with correct structure
56
+ -0.5 parseable JSON but invalid FOLD structure
57
+ -2.0 not parseable as JSON at all
58
+ """
59
+ scores = []
60
+ for completion in completions:
61
+ response = completion[0]["content"]
62
+ fold_data = extract_fold_json(response)
63
+
64
+ if fold_data is None:
65
+ scores.append(-2.0)
66
+ continue
67
+
68
+ is_valid, error = validate_fold(fold_data)
69
+ if is_valid:
70
+ scores.append(1.0)
71
+ else:
72
+ scores.append(-0.5)
73
+
74
+ return scores
75
+
76
+
77
+ def shape_match(
78
+ completions: list,
79
+ task_name: str = "triangle",
80
+ **kwargs: Any,
81
+ ) -> list[float]:
82
+ """Reward 2: Simulate the fold and compare to target shape.
83
+
84
+ Score = similarity Γ— 20.0 (range: 0 to 20)
85
+ -1.0 if simulation fails/diverges
86
+ -2.0 if FOLD data is invalid
87
+
88
+ This is the main reward signal β€” AlphaFold-style shape comparison.
89
+ """
90
+ task = get_task(task_name)
91
+ target_fold = task["target_fold"]
92
+
93
+ # Pre-compute target positions
94
+ try:
95
+ target_result = simulate(target_fold, crease_percent=1.0)
96
+ target_positions = target_result.positions
97
+ except Exception:
98
+ # Target itself fails β€” all scores 0
99
+ return [0.0] * len(completions)
100
+
101
+ scores = []
102
+ for completion in completions:
103
+ response = completion[0]["content"]
104
+ fold_data = extract_fold_json(response)
105
+
106
+ if fold_data is None:
107
+ scores.append(-2.0)
108
+ continue
109
+
110
+ is_valid, error = validate_fold(fold_data)
111
+ if not is_valid:
112
+ scores.append(-1.0)
113
+ continue
114
+
115
+ try:
116
+ result = simulate(fold_data, crease_percent=1.0)
117
+ similarity = compute_shape_match(result.positions, target_positions)
118
+ scores.append(similarity * 20.0)
119
+ except Exception:
120
+ scores.append(-1.0)
121
+
122
+ return scores
training/train_grpo.py ADDED
@@ -0,0 +1,128 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """GRPO training script for origami RL.
2
+
3
+ Follows the 2048 OpenEnv + Unsloth pattern:
4
+ - LLM generates FOLD JSON crease patterns
5
+ - Two reward functions: valid_fold + shape_match
6
+ - GRPOTrainer from TRL handles the RL loop
7
+
8
+ Usage (Colab):
9
+ python -m origami_env.training.train_grpo --task triangle --max_steps 600
10
+ """
11
+
12
+ import argparse
13
+
14
+ PROMPT_TEMPLATE = """You are an origami designer. Generate a FOLD-format crease pattern
15
+ that, when folded, produces the target shape described below.
16
+
17
+ Target: {description}
18
+ Paper size: {width} x {height}
19
+
20
+ Output a JSON object with these exact fields:
21
+ - vertices_coords: [[x, y], ...] β€” 2D positions on the flat paper (0 to {width} for x, 0 to {height} for y)
22
+ - edges_vertices: [[v1, v2], ...] β€” pairs of vertex indices forming edges
23
+ - edges_assignment: ["B"|"M"|"V", ...] β€” B=boundary, M=mountain fold, V=valley fold
24
+ - edges_foldAngle: [angle, ...] β€” fold angles in degrees (M: negative like -180, V: positive like 180, B: 0)
25
+
26
+ Rules:
27
+ - Boundary edges (B) must outline the paper rectangle
28
+ - At least one fold crease (M or V) must exist
29
+ - Mountain fold angles are negative (-180 to 0)
30
+ - Valley fold angles are positive (0 to 180)
31
+ - All vertex indices in edges must be valid (0 to N-1)
32
+
33
+ Output ONLY the JSON object wrapped in ```json ... ``` markers."""
34
+
35
+
36
+ def build_prompt(task: dict) -> str:
37
+ return PROMPT_TEMPLATE.format(
38
+ description=task["description"],
39
+ width=task["paper"]["width"],
40
+ height=task["paper"]["height"],
41
+ )
42
+
43
+
44
+ def main():
45
+ parser = argparse.ArgumentParser(description="GRPO training for origami RL")
46
+ parser.add_argument("--task", default="triangle", help="Task name")
47
+ parser.add_argument("--max_steps", type=int, default=600)
48
+ parser.add_argument("--num_generations", type=int, default=4)
49
+ parser.add_argument("--model", default="unsloth/gpt-oss-20b")
50
+ parser.add_argument("--lr", type=float, default=2e-4)
51
+ args = parser.parse_args()
52
+
53
+ # --- These imports are heavy, only load when actually training ---
54
+ from datasets import Dataset
55
+ from trl import GRPOConfig, GRPOTrainer
56
+ from unsloth import FastLanguageModel
57
+
58
+ from server.tasks import get_task
59
+ from training.reward import shape_match, valid_fold
60
+
61
+ task = get_task(args.task)
62
+ prompt_text = build_prompt(task)
63
+
64
+ # Build dataset (1000 copies of same prompt, like 2048)
65
+ dataset = Dataset.from_list(
66
+ [
67
+ {
68
+ "prompt": [{"role": "user", "content": prompt_text}],
69
+ "answer": 0,
70
+ }
71
+ ]
72
+ * 1000
73
+ )
74
+
75
+ # Load model with LoRA
76
+ model, tokenizer = FastLanguageModel.from_pretrained(
77
+ model_name=args.model,
78
+ load_in_4bit=True,
79
+ max_seq_length=2048,
80
+ )
81
+
82
+ model = FastLanguageModel.get_peft_model(
83
+ model,
84
+ r=8,
85
+ target_modules=[
86
+ "q_proj", "k_proj", "v_proj", "o_proj",
87
+ "gate_proj", "up_proj", "down_proj",
88
+ ],
89
+ lora_alpha=16,
90
+ use_gradient_checkpointing="unsloth",
91
+ )
92
+
93
+ # Wrap shape_match to inject task_name
94
+ def shape_match_reward(completions, **kwargs):
95
+ return shape_match(completions, task_name=args.task, **kwargs)
96
+
97
+ # GRPO config
98
+ training_args = GRPOConfig(
99
+ temperature=1.0,
100
+ learning_rate=args.lr,
101
+ weight_decay=0.001,
102
+ warmup_ratio=0.1,
103
+ lr_scheduler_type="linear",
104
+ optim="adamw_8bit",
105
+ logging_steps=1,
106
+ per_device_train_batch_size=1,
107
+ gradient_accumulation_steps=1,
108
+ num_generations=args.num_generations,
109
+ max_prompt_length=1024,
110
+ max_completion_length=1024,
111
+ max_steps=args.max_steps,
112
+ save_steps=100,
113
+ output_dir="outputs",
114
+ )
115
+
116
+ trainer = GRPOTrainer(
117
+ model=model,
118
+ processing_class=tokenizer,
119
+ reward_funcs=[valid_fold, shape_match_reward],
120
+ args=training_args,
121
+ train_dataset=dataset,
122
+ )
123
+
124
+ trainer.train()
125
+
126
+
127
+ if __name__ == "__main__":
128
+ main()
uv.lock ADDED
The diff for this file is too large to render. See raw diff
 
viewer/index.html ADDED
@@ -0,0 +1,1047 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>Origami RL</title>
7
+ <style>
8
+ @import url('https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,300;0,9..40,400;0,9..40,500;1,9..40,300&family=Instrument+Serif:ital@0;1&display=swap');
9
+
10
+ * { margin: 0; padding: 0; box-sizing: border-box; }
11
+
12
+ :root {
13
+ --bg: #faf9f7;
14
+ --surface: #ffffff;
15
+ --border: #e8e4df;
16
+ --border-hover: #c9c3ba;
17
+ --text: #1a1816;
18
+ --text-secondary: #7a756e;
19
+ --text-tertiary: #a9a49d;
20
+ --mountain: #c0392b;
21
+ --valley: #2471a3;
22
+ --boundary: #1a1816;
23
+ --accent: #c0392b;
24
+ --accent-soft: #f5eeec;
25
+ --success: #27ae60;
26
+ --shadow-sm: 0 1px 3px rgba(26,24,22,0.04);
27
+ --shadow-md: 0 4px 16px rgba(26,24,22,0.06);
28
+ --shadow-lg: 0 8px 32px rgba(26,24,22,0.08);
29
+ --radius: 12px;
30
+ --radius-sm: 8px;
31
+ }
32
+
33
+ body {
34
+ font-family: 'DM Sans', sans-serif;
35
+ background: var(--bg);
36
+ color: var(--text);
37
+ min-height: 100vh;
38
+ -webkit-font-smoothing: antialiased;
39
+ }
40
+
41
+ /* --- HEADER --- */
42
+ header {
43
+ padding: 32px 48px 24px;
44
+ display: flex;
45
+ align-items: baseline;
46
+ gap: 16px;
47
+ }
48
+
49
+ header h1 {
50
+ font-family: 'Instrument Serif', serif;
51
+ font-size: 28px;
52
+ font-weight: 400;
53
+ letter-spacing: -0.02em;
54
+ }
55
+
56
+ header span {
57
+ font-size: 13px;
58
+ color: var(--text-tertiary);
59
+ font-weight: 300;
60
+ }
61
+
62
+ /* --- GRID VIEW --- */
63
+ #grid-view {
64
+ padding: 0 48px 64px;
65
+ }
66
+
67
+ .grid {
68
+ display: grid;
69
+ grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
70
+ gap: 24px;
71
+ max-width: 1280px;
72
+ }
73
+
74
+ .card {
75
+ background: var(--surface);
76
+ border: 1px solid var(--border);
77
+ border-radius: var(--radius);
78
+ overflow: hidden;
79
+ cursor: pointer;
80
+ transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
81
+ box-shadow: var(--shadow-sm);
82
+ }
83
+
84
+ .card:hover {
85
+ border-color: var(--border-hover);
86
+ box-shadow: var(--shadow-md);
87
+ transform: translateY(-2px);
88
+ }
89
+
90
+ .card-canvas {
91
+ width: 100%;
92
+ height: 200px;
93
+ background: var(--bg);
94
+ border-bottom: 1px solid var(--border);
95
+ position: relative;
96
+ }
97
+
98
+ .card-canvas canvas {
99
+ width: 100% !important;
100
+ height: 100% !important;
101
+ }
102
+
103
+ .card-info {
104
+ padding: 16px 20px;
105
+ }
106
+
107
+ .card-name {
108
+ font-family: 'Instrument Serif', serif;
109
+ font-size: 18px;
110
+ margin-bottom: 4px;
111
+ }
112
+
113
+ .card-desc {
114
+ font-size: 12px;
115
+ color: var(--text-secondary);
116
+ margin-bottom: 12px;
117
+ font-weight: 300;
118
+ }
119
+
120
+ .card-score {
121
+ display: flex;
122
+ align-items: center;
123
+ gap: 10px;
124
+ }
125
+
126
+ .score-bar-bg {
127
+ flex: 1;
128
+ height: 4px;
129
+ background: var(--border);
130
+ border-radius: 2px;
131
+ overflow: hidden;
132
+ }
133
+
134
+ .score-bar-fill {
135
+ height: 100%;
136
+ background: var(--accent);
137
+ border-radius: 2px;
138
+ transition: width 0.6s cubic-bezier(0.4, 0, 0.2, 1);
139
+ }
140
+
141
+ .score-label {
142
+ font-size: 12px;
143
+ color: var(--text-tertiary);
144
+ font-weight: 500;
145
+ min-width: 36px;
146
+ text-align: right;
147
+ }
148
+
149
+ /* --- DETAIL VIEW --- */
150
+ #detail-view {
151
+ display: none;
152
+ height: 100vh;
153
+ flex-direction: column;
154
+ }
155
+
156
+ #detail-view.active {
157
+ display: flex;
158
+ }
159
+
160
+ #grid-view.hidden {
161
+ display: none;
162
+ }
163
+
164
+ .detail-header {
165
+ padding: 20px 32px;
166
+ display: flex;
167
+ align-items: center;
168
+ gap: 20px;
169
+ border-bottom: 1px solid var(--border);
170
+ background: var(--surface);
171
+ }
172
+
173
+ .back-btn {
174
+ display: flex;
175
+ align-items: center;
176
+ gap: 6px;
177
+ font-size: 13px;
178
+ color: var(--text-secondary);
179
+ cursor: pointer;
180
+ background: none;
181
+ border: 1px solid var(--border);
182
+ border-radius: var(--radius-sm);
183
+ padding: 6px 14px;
184
+ font-family: inherit;
185
+ transition: all 0.2s;
186
+ }
187
+
188
+ .back-btn:hover {
189
+ border-color: var(--border-hover);
190
+ color: var(--text);
191
+ }
192
+
193
+ .detail-title {
194
+ font-family: 'Instrument Serif', serif;
195
+ font-size: 22px;
196
+ font-weight: 400;
197
+ }
198
+
199
+ .detail-subtitle {
200
+ font-size: 12px;
201
+ color: var(--text-tertiary);
202
+ font-weight: 300;
203
+ margin-left: auto;
204
+ }
205
+
206
+ .detail-body {
207
+ flex: 1;
208
+ display: grid;
209
+ grid-template-columns: 1fr 1.5fr;
210
+ min-height: 0;
211
+ }
212
+
213
+ /* 2D crease pattern panel */
214
+ .panel-2d {
215
+ border-right: 1px solid var(--border);
216
+ background: var(--surface);
217
+ display: flex;
218
+ flex-direction: column;
219
+ }
220
+
221
+ .panel-label {
222
+ font-size: 11px;
223
+ text-transform: uppercase;
224
+ letter-spacing: 0.08em;
225
+ color: var(--text-tertiary);
226
+ padding: 16px 24px 8px;
227
+ font-weight: 500;
228
+ }
229
+
230
+ .crease-canvas {
231
+ flex: 1;
232
+ padding: 24px;
233
+ display: flex;
234
+ align-items: center;
235
+ justify-content: center;
236
+ }
237
+
238
+ .crease-canvas svg {
239
+ max-width: 100%;
240
+ max-height: 100%;
241
+ }
242
+
243
+ .legend {
244
+ padding: 12px 24px 20px;
245
+ display: flex;
246
+ gap: 20px;
247
+ font-size: 11px;
248
+ color: var(--text-secondary);
249
+ }
250
+
251
+ .legend-item {
252
+ display: flex;
253
+ align-items: center;
254
+ gap: 6px;
255
+ }
256
+
257
+ .legend-swatch {
258
+ width: 16px;
259
+ height: 2px;
260
+ border-radius: 1px;
261
+ }
262
+
263
+ /* 3D viewer panel */
264
+ .panel-3d {
265
+ background: var(--bg);
266
+ position: relative;
267
+ }
268
+
269
+ .panel-3d canvas {
270
+ width: 100% !important;
271
+ height: 100% !important;
272
+ }
273
+
274
+ /* Slider */
275
+ .slider-bar {
276
+ padding: 16px 32px;
277
+ background: var(--surface);
278
+ border-top: 1px solid var(--border);
279
+ display: flex;
280
+ align-items: center;
281
+ gap: 20px;
282
+ }
283
+
284
+ .slider-label {
285
+ font-size: 12px;
286
+ color: var(--text-tertiary);
287
+ font-weight: 300;
288
+ min-width: 40px;
289
+ }
290
+
291
+ .slider-track {
292
+ flex: 1;
293
+ position: relative;
294
+ }
295
+
296
+ input[type="range"] {
297
+ -webkit-appearance: none;
298
+ appearance: none;
299
+ width: 100%;
300
+ height: 4px;
301
+ background: var(--border);
302
+ border-radius: 2px;
303
+ outline: none;
304
+ }
305
+
306
+ input[type="range"]::-webkit-slider-thumb {
307
+ -webkit-appearance: none;
308
+ appearance: none;
309
+ width: 18px;
310
+ height: 18px;
311
+ border-radius: 50%;
312
+ background: var(--surface);
313
+ border: 2px solid var(--accent);
314
+ cursor: pointer;
315
+ box-shadow: var(--shadow-sm);
316
+ transition: transform 0.15s;
317
+ }
318
+
319
+ input[type="range"]::-webkit-slider-thumb:hover {
320
+ transform: scale(1.15);
321
+ }
322
+
323
+ .slider-value {
324
+ font-size: 13px;
325
+ font-weight: 500;
326
+ color: var(--text);
327
+ min-width: 40px;
328
+ text-align: right;
329
+ }
330
+
331
+ /* Metrics */
332
+ .metrics-bar {
333
+ padding: 12px 32px;
334
+ background: var(--surface);
335
+ border-top: 1px solid var(--border);
336
+ display: flex;
337
+ gap: 40px;
338
+ }
339
+
340
+ .metric {
341
+ display: flex;
342
+ flex-direction: column;
343
+ gap: 2px;
344
+ }
345
+
346
+ .metric-label {
347
+ font-size: 10px;
348
+ text-transform: uppercase;
349
+ letter-spacing: 0.08em;
350
+ color: var(--text-tertiary);
351
+ }
352
+
353
+ .metric-value {
354
+ font-size: 16px;
355
+ font-weight: 500;
356
+ }
357
+
358
+ .metric-value.high { color: var(--success); }
359
+ .metric-value.mid { color: #e67e22; }
360
+ .metric-value.low { color: var(--accent); }
361
+
362
+ /* --- ANIMATIONS --- */
363
+ @keyframes fadeIn {
364
+ from { opacity: 0; transform: translateY(8px); }
365
+ to { opacity: 1; transform: translateY(0); }
366
+ }
367
+
368
+ .card {
369
+ animation: fadeIn 0.4s cubic-bezier(0.4, 0, 0.2, 1) both;
370
+ }
371
+
372
+ .card:nth-child(1) { animation-delay: 0.05s; }
373
+ .card:nth-child(2) { animation-delay: 0.1s; }
374
+ .card:nth-child(3) { animation-delay: 0.15s; }
375
+ .card:nth-child(4) { animation-delay: 0.2s; }
376
+ </style>
377
+ </head>
378
+ <body>
379
+
380
+ <header>
381
+ <h1>Origami RL</h1>
382
+ <span>fold pattern explorer</span>
383
+ </header>
384
+
385
+ <!-- GRID VIEW -->
386
+ <div id="grid-view">
387
+ <div class="grid" id="grid"></div>
388
+ </div>
389
+
390
+ <!-- DETAIL VIEW -->
391
+ <div id="detail-view">
392
+ <div class="detail-header">
393
+ <button class="back-btn" id="back-btn">← Back</button>
394
+ <div class="detail-title" id="detail-title"></div>
395
+ <div class="detail-subtitle" id="detail-subtitle"></div>
396
+ </div>
397
+ <div class="detail-body">
398
+ <div class="panel-2d">
399
+ <div class="panel-label">Crease Pattern</div>
400
+ <div class="crease-canvas" id="crease-2d"></div>
401
+ <div class="legend">
402
+ <div class="legend-item">
403
+ <div class="legend-swatch" style="background:var(--mountain)"></div>Mountain
404
+ </div>
405
+ <div class="legend-item">
406
+ <div class="legend-swatch" style="background:var(--valley)"></div>Valley
407
+ </div>
408
+ <div class="legend-item">
409
+ <div class="legend-swatch" style="background:var(--boundary)"></div>Boundary
410
+ </div>
411
+ </div>
412
+ </div>
413
+ <div class="panel-3d" id="panel-3d"></div>
414
+ </div>
415
+ <div class="slider-bar">
416
+ <div class="slider-label">flat</div>
417
+ <div class="slider-track">
418
+ <input type="range" id="crease-slider" min="0" max="100" value="100">
419
+ </div>
420
+ <div class="slider-value" id="slider-val">100%</div>
421
+ <div class="slider-label">folded</div>
422
+ </div>
423
+ <div class="metrics-bar">
424
+ <div class="metric">
425
+ <div class="metric-label">Similarity</div>
426
+ <div class="metric-value high" id="m-sim">β€”</div>
427
+ </div>
428
+ <div class="metric">
429
+ <div class="metric-label">Folds</div>
430
+ <div class="metric-value" id="m-folds">β€”</div>
431
+ </div>
432
+ <div class="metric">
433
+ <div class="metric-label">Strain</div>
434
+ <div class="metric-value" id="m-strain">β€”</div>
435
+ </div>
436
+ <div class="metric">
437
+ <div class="metric-label">Vertices</div>
438
+ <div class="metric-value" id="m-verts">β€”</div>
439
+ </div>
440
+ <div class="metric">
441
+ <div class="metric-label">Status</div>
442
+ <div class="metric-value" id="m-status">β€”</div>
443
+ </div>
444
+ </div>
445
+ </div>
446
+
447
+ <script type="importmap">
448
+ {
449
+ "imports": {
450
+ "three": "https://cdn.jsdelivr.net/npm/three@0.163.0/build/three.module.js",
451
+ "three/addons/": "https://cdn.jsdelivr.net/npm/three@0.163.0/examples/jsm/"
452
+ }
453
+ }
454
+ </script>
455
+
456
+ <script type="module">
457
+ import * as THREE from 'three';
458
+ import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
459
+
460
+ // ─── FOLD DATA (hardcoded demo) ───────────────────────────────
461
+ const TASKS = {
462
+ triangle: {
463
+ name: 'Triangle',
464
+ description: 'Diagonal valley fold',
465
+ difficulty: 1,
466
+ similarity: 1.0,
467
+ fold: {
468
+ vertices_coords: [[0,0],[1,0],[1,1],[0,1]],
469
+ edges_vertices: [[0,1],[1,2],[2,3],[3,0],[0,2]],
470
+ edges_assignment: ['B','B','B','B','V'],
471
+ edges_foldAngle: [0,0,0,0,180],
472
+ faces_vertices: [[0,1,2],[0,2,3]],
473
+ },
474
+ strain: 0.0,
475
+ },
476
+ half_fold: {
477
+ name: 'Half Fold',
478
+ description: 'Horizontal valley fold',
479
+ difficulty: 1,
480
+ similarity: 1.0,
481
+ fold: {
482
+ vertices_coords: [[0,0],[1,0],[1,1],[0,1],[0,0.5],[1,0.5]],
483
+ edges_vertices: [[0,1],[1,5],[5,2],[2,3],[3,4],[4,0],[4,5]],
484
+ edges_assignment: ['B','B','B','B','B','B','V'],
485
+ edges_foldAngle: [0,0,0,0,0,0,180],
486
+ faces_vertices: [[0,1,5,4],[4,5,2,3]],
487
+ },
488
+ strain: 0.0,
489
+ },
490
+ quarter_fold: {
491
+ name: 'Quarter Fold',
492
+ description: 'Two perpendicular folds',
493
+ difficulty: 2,
494
+ similarity: 1.0,
495
+ fold: {
496
+ vertices_coords: [[0,0],[0.5,0],[1,0],[0,0.5],[0.5,0.5],[1,0.5],[0,1],[0.5,1],[1,1]],
497
+ edges_vertices: [[0,1],[1,2],[2,5],[5,8],[8,7],[7,6],[6,3],[3,0],[1,4],[4,7],[3,4],[4,5]],
498
+ edges_assignment: ['B','B','B','B','B','B','B','B','V','V','V','V'],
499
+ edges_foldAngle: [0,0,0,0,0,0,0,0,180,180,180,180],
500
+ faces_vertices: [[0,1,4,3],[1,2,5,4],[3,4,7,6],[4,5,8,7]],
501
+ },
502
+ strain: 0.0,
503
+ },
504
+ letter_fold: {
505
+ name: 'Letter Fold',
506
+ description: 'Two parallel folds',
507
+ difficulty: 2,
508
+ similarity: 1.0,
509
+ fold: {
510
+ vertices_coords: [[0,0],[1,0],[0,1/3],[1,1/3],[0,2/3],[1,2/3],[0,1],[1,1]],
511
+ edges_vertices: [[0,1],[1,3],[3,5],[5,7],[7,6],[6,4],[4,2],[2,0],[2,3],[4,5]],
512
+ edges_assignment: ['B','B','B','B','B','B','B','B','V','M'],
513
+ edges_foldAngle: [0,0,0,0,0,0,0,0,180,-180],
514
+ faces_vertices: [[0,1,3,2],[2,3,5,4],[4,5,7,6]],
515
+ },
516
+ strain: 0.0,
517
+ },
518
+ };
519
+
520
+ // ─── CLIENT-SIDE ANALYTICAL FOLD SOLVER ──────────────────────
521
+ // Cumulative transform approach: each face stores (R, t) where
522
+ // folded_pos = R * flat_pos + t. Correctly handles multiple folds.
523
+
524
+ function analyticalFold(fold, creasePercent) {
525
+ const verts = fold.vertices_coords;
526
+ const n = verts.length;
527
+ const flat = new Float32Array(n * 3);
528
+ const pos = new Float32Array(n * 3);
529
+ for (let i = 0; i < n; i++) {
530
+ flat[i*3] = verts[i][0]; flat[i*3+1] = verts[i][1]; flat[i*3+2] = 0;
531
+ pos[i*3] = verts[i][0]; pos[i*3+1] = verts[i][1]; pos[i*3+2] = 0;
532
+ }
533
+
534
+ if (Math.abs(creasePercent) < 1e-10) return pos;
535
+
536
+ const faces = fold.faces_vertices;
537
+ const edges = fold.edges_vertices;
538
+ const assignments = fold.edges_assignment;
539
+ const foldAngles = fold.edges_foldAngle;
540
+ if (!faces || faces.length === 0) return pos;
541
+
542
+ // Build face adjacency
543
+ const faceAdj = {};
544
+ for (let fi = 0; fi < faces.length; fi++) {
545
+ const face = faces[fi];
546
+ for (let j = 0; j < face.length; j++) {
547
+ const v1 = face[j], v2 = face[(j+1) % face.length];
548
+ const key = Math.min(v1,v2) + ',' + Math.max(v1,v2);
549
+ if (!faceAdj[key]) faceAdj[key] = [];
550
+ faceAdj[key].push(fi);
551
+ }
552
+ }
553
+
554
+ // Build crease map
555
+ const creaseMap = {};
556
+ for (let i = 0; i < edges.length; i++) {
557
+ const [v1, v2] = edges[i];
558
+ const key = Math.min(v1,v2) + ',' + Math.max(v1,v2);
559
+ if (assignments[i] === 'M' || assignments[i] === 'V') {
560
+ creaseMap[key] = (foldAngles[i] * Math.PI / 180) * creasePercent;
561
+ }
562
+ }
563
+
564
+ // Per-face cumulative transform: folded = R * flat + t
565
+ // R is a 3x3 matrix stored as [r00,r01,r02, r10,r11,r12, r20,r21,r22]
566
+ const faceR = new Array(faces.length).fill(null);
567
+ const faceT = new Array(faces.length).fill(null);
568
+ // Face 0: identity
569
+ faceR[0] = [1,0,0, 0,1,0, 0,0,1];
570
+ faceT[0] = [0,0,0];
571
+
572
+ const visited = new Array(faces.length).fill(false);
573
+ visited[0] = true;
574
+ const placed = new Set();
575
+ for (const vi of faces[0]) placed.add(vi);
576
+
577
+ const queue = [0];
578
+ while (queue.length > 0) {
579
+ const fi = queue.shift();
580
+ const face = faces[fi];
581
+
582
+ for (let j = 0; j < face.length; j++) {
583
+ const v1 = face[j], v2 = face[(j+1) % face.length];
584
+ const key = Math.min(v1,v2) + ',' + Math.max(v1,v2);
585
+
586
+ for (const fj of (faceAdj[key] || [])) {
587
+ if (visited[fj]) continue;
588
+ visited[fj] = true;
589
+ queue.push(fj);
590
+
591
+ const angle = creaseMap[key] || 0;
592
+ let Rfj, tfj;
593
+
594
+ if (Math.abs(angle) > 1e-10) {
595
+ // Fold rotation around edge (v1->v2) in folded coords
596
+ const p1 = [pos[v1*3], pos[v1*3+1], pos[v1*3+2]];
597
+ const ax = [pos[v2*3]-p1[0], pos[v2*3+1]-p1[1], pos[v2*3+2]-p1[2]];
598
+ const axLen = Math.sqrt(ax[0]*ax[0]+ax[1]*ax[1]+ax[2]*ax[2]);
599
+
600
+ if (axLen < 1e-12) {
601
+ Rfj = faceR[fi].slice(); tfj = faceT[fi].slice();
602
+ } else {
603
+ const u = [ax[0]/axLen, ax[1]/axLen, ax[2]/axLen];
604
+ const foldR = rodriguesMat(u, angle);
605
+ Rfj = matMul(foldR, faceR[fi]);
606
+ // t_fj = foldR * (t_fi - p1) + p1
607
+ const dt = [faceT[fi][0]-p1[0], faceT[fi][1]-p1[1], faceT[fi][2]-p1[2]];
608
+ const rdt = matVec(foldR, dt);
609
+ tfj = [rdt[0]+p1[0], rdt[1]+p1[1], rdt[2]+p1[2]];
610
+ }
611
+ } else {
612
+ Rfj = faceR[fi].slice();
613
+ tfj = faceT[fi].slice();
614
+ }
615
+
616
+ faceR[fj] = Rfj;
617
+ faceT[fj] = tfj;
618
+
619
+ // Place unplaced vertices
620
+ for (const vi of faces[fj]) {
621
+ if (!placed.has(vi)) {
622
+ const fv = [flat[vi*3], flat[vi*3+1], flat[vi*3+2]];
623
+ const rv = matVec(Rfj, fv);
624
+ pos[vi*3] = rv[0] + tfj[0];
625
+ pos[vi*3+1] = rv[1] + tfj[1];
626
+ pos[vi*3+2] = rv[2] + tfj[2];
627
+ placed.add(vi);
628
+ }
629
+ }
630
+ }
631
+ }
632
+ }
633
+
634
+ return pos;
635
+ }
636
+
637
+ // 3x3 rotation matrix from axis-angle (Rodrigues)
638
+ function rodriguesMat(u, angle) {
639
+ const c = Math.cos(angle), s = Math.sin(angle), t = 1 - c;
640
+ return [
641
+ c + u[0]*u[0]*t, u[0]*u[1]*t - u[2]*s, u[0]*u[2]*t + u[1]*s,
642
+ u[1]*u[0]*t + u[2]*s, c + u[1]*u[1]*t, u[1]*u[2]*t - u[0]*s,
643
+ u[2]*u[0]*t - u[1]*s, u[2]*u[1]*t + u[0]*s, c + u[2]*u[2]*t,
644
+ ];
645
+ }
646
+
647
+ // 3x3 matrix multiply (row-major flat arrays)
648
+ function matMul(a, b) {
649
+ return [
650
+ a[0]*b[0]+a[1]*b[3]+a[2]*b[6], a[0]*b[1]+a[1]*b[4]+a[2]*b[7], a[0]*b[2]+a[1]*b[5]+a[2]*b[8],
651
+ a[3]*b[0]+a[4]*b[3]+a[5]*b[6], a[3]*b[1]+a[4]*b[4]+a[5]*b[7], a[3]*b[2]+a[4]*b[5]+a[5]*b[8],
652
+ a[6]*b[0]+a[7]*b[3]+a[8]*b[6], a[6]*b[1]+a[7]*b[4]+a[8]*b[7], a[6]*b[2]+a[7]*b[5]+a[8]*b[8],
653
+ ];
654
+ }
655
+
656
+ // 3x3 matrix * vec3
657
+ function matVec(m, v) {
658
+ return [
659
+ m[0]*v[0]+m[1]*v[1]+m[2]*v[2],
660
+ m[3]*v[0]+m[4]*v[1]+m[5]*v[2],
661
+ m[6]*v[0]+m[7]*v[1]+m[8]*v[2],
662
+ ];
663
+ }
664
+
665
+ // ─── THREE.JS HELPERS ─────────────────────────────────────────
666
+
667
+ const EDGE_COLORS = { M: 0xc0392b, V: 0x2471a3, B: 0x1a1816, F: 0xddd8d0, U: 0xa9a49d };
668
+
669
+ function buildMesh(task, creasePercent = 1) {
670
+ const fold = task.fold;
671
+ const n = fold.vertices_coords.length;
672
+ const group = new THREE.Group();
673
+
674
+ // Compute folded positions analytically
675
+ const positions = analyticalFold(fold, creasePercent);
676
+
677
+ // Faces
678
+ const faces = fold.faces_vertices;
679
+ const indices = [];
680
+ for (const face of faces) {
681
+ for (let i = 1; i < face.length - 1; i++) {
682
+ indices.push(face[0], face[i], face[i+1]);
683
+ }
684
+ }
685
+
686
+ const geo = new THREE.BufferGeometry();
687
+ geo.setAttribute('position', new THREE.BufferAttribute(positions, 3));
688
+ geo.setIndex(indices);
689
+ geo.computeVertexNormals();
690
+
691
+ const mat = new THREE.MeshPhongMaterial({
692
+ color: 0xfaf9f7,
693
+ side: THREE.DoubleSide,
694
+ flatShading: false,
695
+ shininess: 20,
696
+ specular: 0x222222,
697
+ });
698
+ const mesh = new THREE.Mesh(geo, mat);
699
+ group.add(mesh);
700
+
701
+ // Edges
702
+ for (let i = 0; i < fold.edges_vertices.length; i++) {
703
+ const [v1, v2] = fold.edges_vertices[i];
704
+ const assignment = fold.edges_assignment[i];
705
+ const color = EDGE_COLORS[assignment] || 0xaaaaaa;
706
+ const lineGeo = new THREE.BufferGeometry();
707
+ const linePos = new Float32Array([
708
+ positions[v1*3], positions[v1*3+1], positions[v1*3+2],
709
+ positions[v2*3], positions[v2*3+1], positions[v2*3+2],
710
+ ]);
711
+ lineGeo.setAttribute('position', new THREE.BufferAttribute(linePos, 3));
712
+ const lineMat = new THREE.LineBasicMaterial({
713
+ color,
714
+ linewidth: assignment === 'B' ? 2 : 1,
715
+ });
716
+ group.add(new THREE.LineSegments(lineGeo, lineMat));
717
+ }
718
+
719
+ // Center
720
+ const box = new THREE.Box3().setFromObject(group);
721
+ const center = box.getCenter(new THREE.Vector3());
722
+ group.position.sub(center);
723
+
724
+ return group;
725
+ }
726
+
727
+ function setupScene(container, opts = {}) {
728
+ const w = container.clientWidth || 300;
729
+ const h = container.clientHeight || 200;
730
+
731
+ const scene = new THREE.Scene();
732
+ scene.background = opts.bg ? new THREE.Color(opts.bg) : new THREE.Color(0xfaf9f7);
733
+
734
+ const camera = new THREE.PerspectiveCamera(35, w / h, 0.01, 100);
735
+ camera.position.set(0, 0.4, 2.2);
736
+
737
+ const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
738
+ renderer.setSize(w, h);
739
+ renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
740
+ container.appendChild(renderer.domElement);
741
+
742
+ // Lights
743
+ const ambientLight = new THREE.AmbientLight(0xffffff, 0.5);
744
+ scene.add(ambientLight);
745
+ const dirLight1 = new THREE.DirectionalLight(0xffffff, 0.7);
746
+ dirLight1.position.set(1, 2, 3);
747
+ scene.add(dirLight1);
748
+ const dirLight2 = new THREE.DirectionalLight(0xffffff, 0.3);
749
+ dirLight2.position.set(-2, 1, -1);
750
+ scene.add(dirLight2);
751
+
752
+ let controls = null;
753
+ if (opts.orbit) {
754
+ controls = new OrbitControls(camera, renderer.domElement);
755
+ controls.enableDamping = true;
756
+ controls.dampingFactor = 0.08;
757
+ controls.enablePan = false;
758
+ controls.minDistance = 0.8;
759
+ controls.maxDistance = 5;
760
+ controls.autoRotate = opts.autoRotate || false;
761
+ controls.autoRotateSpeed = 1.5;
762
+ }
763
+
764
+ return { scene, camera, renderer, controls };
765
+ }
766
+
767
+ // ─── GRID VIEW ────────────────────────────────────────────────
768
+
769
+ const gridEl = document.getElementById('grid');
770
+ const cardScenes = [];
771
+
772
+ for (const [key, task] of Object.entries(TASKS)) {
773
+ const card = document.createElement('div');
774
+ card.className = 'card';
775
+ card.innerHTML = `
776
+ <div class="card-canvas" id="card-${key}"></div>
777
+ <div class="card-info">
778
+ <div class="card-name">${task.name}</div>
779
+ <div class="card-desc">${task.description} Β· difficulty ${task.difficulty}</div>
780
+ <div class="card-score">
781
+ <div class="score-bar-bg"><div class="score-bar-fill" style="width:${task.similarity * 100}%"></div></div>
782
+ <div class="score-label">${Math.round(task.similarity * 100)}%</div>
783
+ </div>
784
+ </div>
785
+ `;
786
+ card.addEventListener('click', () => showDetail(key));
787
+ gridEl.appendChild(card);
788
+ }
789
+
790
+ // Init mini 3D scenes after DOM paint
791
+ requestAnimationFrame(() => {
792
+ for (const [key, task] of Object.entries(TASKS)) {
793
+ const container = document.getElementById(`card-${key}`);
794
+ if (!container) continue;
795
+ const { scene, camera, renderer } = setupScene(container, { autoRotate: true });
796
+ const mesh = buildMesh(task, 1.0);
797
+ scene.add(mesh);
798
+
799
+ // Slow auto-rotate
800
+ const animate = () => {
801
+ requestAnimationFrame(animate);
802
+ mesh.rotation.y += 0.005;
803
+ renderer.render(scene, camera);
804
+ };
805
+ animate();
806
+ cardScenes.push({ key, scene, camera, renderer, mesh });
807
+ }
808
+ });
809
+
810
+ // ─── DETAIL VIEW ──────────────────────────────────────────────
811
+
812
+ let detailScene = null;
813
+ let detailRenderer = null;
814
+ let detailCamera = null;
815
+ let detailControls = null;
816
+ let detailMeshGroup = null;
817
+ let detailAnimId = null;
818
+ let currentTaskKey = null;
819
+
820
+ function showDetail(key) {
821
+ currentTaskKey = key;
822
+ const task = TASKS[key];
823
+
824
+ document.getElementById('grid-view').classList.add('hidden');
825
+ document.getElementById('detail-view').classList.add('active');
826
+
827
+ document.getElementById('detail-title').textContent = task.name;
828
+ document.getElementById('detail-subtitle').textContent = task.description;
829
+
830
+ // 2D crease pattern
831
+ draw2DCrease(task);
832
+
833
+ // Metrics
834
+ const foldCount = task.fold.edges_assignment.filter(a => a === 'M' || a === 'V').length;
835
+ document.getElementById('m-sim').textContent = Math.round(task.similarity * 100) + '%';
836
+ document.getElementById('m-folds').textContent = foldCount;
837
+ document.getElementById('m-strain').textContent = task.strain.toFixed(4);
838
+ document.getElementById('m-verts').textContent = task.fold.vertices_coords.length;
839
+ document.getElementById('m-status').textContent = task.strain < 0.05 ? 'stable' : 'settling';
840
+
841
+ const simEl = document.getElementById('m-sim');
842
+ simEl.className = 'metric-value ' + (task.similarity > 0.9 ? 'high' : task.similarity > 0.5 ? 'mid' : 'low');
843
+
844
+ // 3D viewer
845
+ const panel = document.getElementById('panel-3d');
846
+ // Clean up old
847
+ if (detailRenderer) {
848
+ panel.removeChild(detailRenderer.domElement);
849
+ detailRenderer.dispose();
850
+ if (detailAnimId) cancelAnimationFrame(detailAnimId);
851
+ }
852
+
853
+ const setup = setupScene(panel, { orbit: true, bg: 0xf5f3f0 });
854
+ detailScene = setup.scene;
855
+ detailCamera = setup.camera;
856
+ detailRenderer = setup.renderer;
857
+ detailControls = setup.controls;
858
+
859
+ // Reset slider
860
+ const slider = document.getElementById('crease-slider');
861
+ slider.value = 100;
862
+ document.getElementById('slider-val').textContent = '100%';
863
+
864
+ // Build mesh
865
+ updateDetailMesh(1.0);
866
+
867
+ // Target ghost overlay
868
+ addTargetGhost(task);
869
+
870
+ // Animate
871
+ const animateDetail = () => {
872
+ detailAnimId = requestAnimationFrame(animateDetail);
873
+ detailControls.update();
874
+ detailRenderer.render(detailScene, detailCamera);
875
+ };
876
+ animateDetail();
877
+
878
+ // Resize
879
+ const resizeObserver = new ResizeObserver(() => {
880
+ const w = panel.clientWidth;
881
+ const h = panel.clientHeight;
882
+ detailCamera.aspect = w / h;
883
+ detailCamera.updateProjectionMatrix();
884
+ detailRenderer.setSize(w, h);
885
+ });
886
+ resizeObserver.observe(panel);
887
+ }
888
+
889
+ function updateDetailMesh(cp) {
890
+ if (!currentTaskKey) return;
891
+ const task = TASKS[currentTaskKey];
892
+
893
+ // Remove old mesh group (keep ghost)
894
+ if (detailMeshGroup) {
895
+ detailScene.remove(detailMeshGroup);
896
+ }
897
+
898
+ detailMeshGroup = buildMesh(task, cp);
899
+ detailScene.add(detailMeshGroup);
900
+ }
901
+
902
+ function addTargetGhost(task) {
903
+ const fold = task.fold;
904
+ const n = fold.vertices_coords.length;
905
+
906
+ // Wireframe ghost of fully folded state
907
+ const positions = analyticalFold(fold, 1.0);
908
+
909
+ const faces = fold.faces_vertices;
910
+ const indices = [];
911
+ for (const face of faces) {
912
+ for (let i = 1; i < face.length - 1; i++) {
913
+ indices.push(face[0], face[i], face[i+1]);
914
+ }
915
+ }
916
+
917
+ const geo = new THREE.BufferGeometry();
918
+ geo.setAttribute('position', new THREE.BufferAttribute(positions, 3));
919
+ geo.setIndex(indices);
920
+
921
+ const ghostMat = new THREE.MeshBasicMaterial({
922
+ color: 0xc0392b,
923
+ wireframe: true,
924
+ transparent: true,
925
+ opacity: 0.15,
926
+ });
927
+
928
+ const ghost = new THREE.Mesh(geo, ghostMat);
929
+
930
+ // Center same as main mesh
931
+ const box = new THREE.Box3().setFromBufferAttribute(geo.getAttribute('position'));
932
+ const center = box.getCenter(new THREE.Vector3());
933
+ ghost.position.sub(center);
934
+
935
+ detailScene.add(ghost);
936
+ }
937
+
938
+ function draw2DCrease(task) {
939
+ const container = document.getElementById('crease-2d');
940
+ const fold = task.fold;
941
+ const verts = fold.vertices_coords;
942
+
943
+ // Find bounds
944
+ let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
945
+ for (const v of verts) {
946
+ minX = Math.min(minX, v[0]); minY = Math.min(minY, v[1]);
947
+ maxX = Math.max(maxX, v[0]); maxY = Math.max(maxY, v[1]);
948
+ }
949
+
950
+ const pad = 0.15;
951
+ const vw = maxX - minX + pad * 2;
952
+ const vh = maxY - minY + pad * 2;
953
+ const size = 280;
954
+ const scale = size / Math.max(vw, vh);
955
+
956
+ const toX = (x) => (x - minX + pad) * scale;
957
+ const toY = (y) => size - (y - minY + pad) * scale; // flip Y
958
+
959
+ const STROKE = { M: '#c0392b', V: '#2471a3', B: '#1a1816', F: '#ddd8d0', U: '#a9a49d' };
960
+
961
+ let svg = `<svg width="${size}" height="${size}" viewBox="0 0 ${size} ${size}" xmlns="http://www.w3.org/2000/svg">`;
962
+
963
+ // Background paper
964
+ const paperPts = [];
965
+ // Find boundary edges and form polygon
966
+ const bVerts = new Set();
967
+ for (let i = 0; i < fold.edges_vertices.length; i++) {
968
+ if (fold.edges_assignment[i] === 'B') {
969
+ bVerts.add(fold.edges_vertices[i][0]);
970
+ bVerts.add(fold.edges_vertices[i][1]);
971
+ }
972
+ }
973
+
974
+ // Draw faces as background
975
+ for (const face of fold.faces_vertices) {
976
+ const pts = face.map(vi => `${toX(verts[vi][0])},${toY(verts[vi][1])}`).join(' ');
977
+ svg += `<polygon points="${pts}" fill="#ffffff" stroke="none"/>`;
978
+ }
979
+
980
+ // Draw edges
981
+ for (let i = 0; i < fold.edges_vertices.length; i++) {
982
+ const [v1, v2] = fold.edges_vertices[i];
983
+ const a = fold.edges_assignment[i];
984
+ const color = STROKE[a] || '#aaa';
985
+ const width = a === 'B' ? 2 : 1.5;
986
+ const dash = a === 'M' ? 'stroke-dasharray="6 3"' : a === 'V' ? 'stroke-dasharray="2 2"' : '';
987
+
988
+ svg += `<line x1="${toX(verts[v1][0])}" y1="${toY(verts[v1][1])}" x2="${toX(verts[v2][0])}" y2="${toY(verts[v2][1])}" stroke="${color}" stroke-width="${width}" stroke-linecap="round" ${dash}/>`;
989
+ }
990
+
991
+ // Draw vertices
992
+ for (let i = 0; i < verts.length; i++) {
993
+ svg += `<circle cx="${toX(verts[i][0])}" cy="${toY(verts[i][1])}" r="3" fill="var(--text)" opacity="0.3"/>`;
994
+ }
995
+
996
+ svg += '</svg>';
997
+ container.innerHTML = svg;
998
+ }
999
+
1000
+ function showGrid() {
1001
+ document.getElementById('grid-view').classList.remove('hidden');
1002
+ document.getElementById('detail-view').classList.remove('active');
1003
+ if (detailAnimId) cancelAnimationFrame(detailAnimId);
1004
+ currentTaskKey = null;
1005
+ }
1006
+
1007
+ // ─── SLIDER ───────────────────────────────────────────────────
1008
+
1009
+ document.getElementById('crease-slider').addEventListener('input', (e) => {
1010
+ const val = parseInt(e.target.value);
1011
+ document.getElementById('slider-val').textContent = val + '%';
1012
+ updateDetailMesh(val / 100);
1013
+ });
1014
+
1015
+ // ─── BACK BUTTON & KEYBOARD ───────────────────────────────────
1016
+
1017
+ document.getElementById('back-btn').addEventListener('click', () => showGrid());
1018
+
1019
+ document.addEventListener('keydown', (e) => {
1020
+ if (e.key === 'Escape' && currentTaskKey) showGrid();
1021
+ });
1022
+
1023
+ // ─── WEBSOCKET (optional live connection) ─────────────────────
1024
+
1025
+ function connectWS() {
1026
+ try {
1027
+ const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
1028
+ const ws = new WebSocket(`${proto}//${location.host}/ws`);
1029
+ ws.onopen = () => console.log('[WS] Connected');
1030
+ ws.onmessage = (e) => {
1031
+ const data = JSON.parse(e.data);
1032
+ if (data.type === 'observation' && data.data?.observation) {
1033
+ const obs = data.data.observation;
1034
+ console.log('[WS] Observation:', obs.shape_similarity);
1035
+ }
1036
+ };
1037
+ ws.onerror = () => console.log('[WS] No server (using demo data)');
1038
+ } catch (e) {
1039
+ // Server not running, use demo data
1040
+ }
1041
+ }
1042
+
1043
+ connectWS();
1044
+ </script>
1045
+
1046
+ </body>
1047
+ </html>