ianalin123 commited on
Commit
438e23a
·
1 Parent(s): 56c400c

refactor: remove legacy engine, planner, sim, trainer, viz, and server code

Browse files
engine/__init__.py DELETED
File without changes
engine/fold_engine.py DELETED
@@ -1,249 +0,0 @@
1
- """
2
- Fold execution engine.
3
-
4
- Applies fold operations (valley / mountain) to a Paper object using
5
- Rodrigues' rotation formula, face splitting, and layer tracking.
6
- """
7
-
8
- from __future__ import annotations
9
-
10
- import math
11
- from typing import Callable
12
-
13
- import numpy as np
14
-
15
- from .paper import Paper
16
-
17
-
18
- # ────────────────────────────────────────────────────────────────────
19
- # Rodrigues' rotation
20
- # ────────────────────────────────────────────────────────────────────
21
-
22
- def _rodrigues_rotate(
23
- points: np.ndarray,
24
- axis_point: np.ndarray,
25
- axis_dir: np.ndarray,
26
- angle_rad: float,
27
- ) -> np.ndarray:
28
- """Rotate *points* (N, 3) around an axis defined by a point and direction
29
- using Rodrigues' rotation formula. Returns rotated points (N, 3)."""
30
- k = axis_dir / (np.linalg.norm(axis_dir) + 1e-30)
31
- translated = points - axis_point
32
- cos_a = math.cos(angle_rad)
33
- sin_a = math.sin(angle_rad)
34
- dot_term = np.dot(translated, k).reshape(-1, 1) * k # (N,1)*(3,) broadcast
35
- rotated = (
36
- translated * cos_a
37
- + np.cross(k, translated) * sin_a
38
- + dot_term * (1.0 - cos_a)
39
- )
40
- return rotated + axis_point
41
-
42
-
43
- # ────────────────────────────────────────────────────────────────────
44
- # Single fold
45
- # ────────────────────────────────────────────────────────────────────
46
-
47
- def apply_fold(
48
- paper: Paper,
49
- fold_dict: dict,
50
- ) -> tuple[Paper, str | None]:
51
- """Apply a single fold to *paper* and return ``(new_paper, error_or_None)``.
52
-
53
- *fold_dict* has the form::
54
-
55
- {
56
- "type": "valley" | "mountain",
57
- "line": {"start": [x, y], "end": [x, y]},
58
- "angle": 0-180,
59
- }
60
-
61
- Steps:
62
- 1. Validate inputs.
63
- 2. Split faces along the fold line.
64
- 3. Determine vertices to rotate (one side of fold line).
65
- 4. Rodrigues' rotation of those vertices.
66
- 5. Update edge assignments for new fold-line edges.
67
- 6. Update fold angles.
68
- 7. Update layer tracking.
69
- """
70
-
71
- # ── 0. parse & validate ─────────────────────────────────────────
72
- fold_type = fold_dict.get("type", "valley")
73
- line = fold_dict.get("line", {})
74
- angle_deg = fold_dict.get("angle", 180)
75
-
76
- if fold_type not in ("valley", "mountain"):
77
- return paper, f"Unknown fold type: {fold_type}"
78
-
79
- try:
80
- start_2d = np.array(line["start"], dtype=np.float64)[:2]
81
- end_2d = np.array(line["end"], dtype=np.float64)[:2]
82
- except (KeyError, TypeError, IndexError) as exc:
83
- return paper, f"Invalid fold line: {exc}"
84
-
85
- if np.linalg.norm(end_2d - start_2d) < 1e-12:
86
- return paper, "Fold line has zero length"
87
-
88
- if not (0 < angle_deg <= 180):
89
- return paper, f"Angle must be in (0, 180], got {angle_deg}"
90
-
91
- # ── 1. deep copy so the original is untouched ───────────────────
92
- new_paper = paper.copy()
93
-
94
- # ── 2. split faces along fold line ──────────────────────────────
95
- try:
96
- fold_edge_ids = new_paper.split_faces_along_line(start_2d, end_2d)
97
- except Exception as exc:
98
- return paper, f"Face split failed: {exc}"
99
-
100
- # ── 3. determine vertices to rotate ─────────────────────────────
101
- rotate_ids = new_paper.get_vertices_on_side(start_2d, end_2d, "positive")
102
-
103
- if not rotate_ids:
104
- # Try the other side — maybe the fold line is at the boundary
105
- rotate_ids = new_paper.get_vertices_on_side(start_2d, end_2d, "negative")
106
- if not rotate_ids:
107
- return paper, "No vertices to rotate — fold line may not intersect paper"
108
-
109
- # ── 4. Rodrigues' rotation ──────────────────────────────────────
110
- sign = 1.0 if fold_type == "valley" else -1.0
111
- angle_rad = sign * math.radians(angle_deg)
112
-
113
- axis_point = np.array([start_2d[0], start_2d[1], 0.0])
114
- axis_dir = np.array([end_2d[0] - start_2d[0], end_2d[1] - start_2d[1], 0.0])
115
-
116
- pts = new_paper.vertices[rotate_ids]
117
- rotated = _rodrigues_rotate(pts, axis_point, axis_dir, angle_rad)
118
- new_paper.vertices[rotate_ids] = rotated
119
-
120
- # ── 5. update edge assignments ──────────────────────────────────
121
- assignment = "V" if fold_type == "valley" else "M"
122
- for eidx in fold_edge_ids:
123
- if eidx < len(new_paper.assignments):
124
- new_paper.assignments[eidx] = assignment
125
-
126
- # ── 6. update fold angles ───────────────────────────────────────
127
- for eidx in fold_edge_ids:
128
- if eidx < len(new_paper.fold_angles):
129
- new_paper.fold_angles[eidx] = angle_deg * sign
130
-
131
- # ── 7. update layer tracking ────────────────────────────────────
132
- # For each pair of faces on opposite sides of the fold line, record
133
- # layer ordering. Simple heuristic: faces that were rotated are now
134
- # on top (sign +1) of faces that stayed put.
135
- rotated_set = set(rotate_ids)
136
-
137
- def _face_side(face_verts: list[int]) -> str:
138
- """Classify a face as 'rotated', 'fixed', or 'mixed'."""
139
- r_count = sum(1 for v in face_verts if v in rotated_set)
140
- if r_count == len(face_verts):
141
- return "rotated"
142
- if r_count == 0:
143
- return "fixed"
144
- return "mixed"
145
-
146
- face_sides = [_face_side(f) for f in new_paper.faces]
147
- for i in range(len(new_paper.faces)):
148
- for j in range(i + 1, len(new_paper.faces)):
149
- if face_sides[i] == "rotated" and face_sides[j] == "fixed":
150
- new_paper.face_orders.append((i, j, 1))
151
- elif face_sides[i] == "fixed" and face_sides[j] == "rotated":
152
- new_paper.face_orders.append((j, i, 1))
153
-
154
- new_paper.fold_count += 1
155
-
156
- return new_paper, None
157
-
158
-
159
- # ────────────────────────────────────────────────────────────────────
160
- # Strategy executor (matches mock_env.execute_fold_strategy signature)
161
- # ────────────────────────────────────────────────────────────────────
162
-
163
- def execute_fold_strategy(
164
- strategy_fn: Callable,
165
- paper: Paper,
166
- max_folds: int = 20,
167
- ) -> tuple[Paper, list[dict], str | None]:
168
- """Execute a ``fold_strategy`` function against the real physics engine.
169
-
170
- Signature matches ``mock_env.execute_fold_strategy`` so the trainer
171
- reward functions can swap engines transparently.
172
-
173
- Parameters
174
- ----------
175
- strategy_fn : callable
176
- ``strategy_fn(paper_state_dict) -> list[dict]``
177
- paper : Paper
178
- The initial paper state.
179
- max_folds : int
180
- Maximum number of folds to apply.
181
-
182
- Returns
183
- -------
184
- (final_paper, applied_folds, error_or_None)
185
- """
186
- state_dict = paper.to_dict()
187
- try:
188
- folds = strategy_fn(state_dict)
189
- except Exception as exc:
190
- return paper, [], f"Strategy function raised: {exc}"
191
-
192
- if not isinstance(folds, list):
193
- return paper, [], "Strategy must return a list of fold dicts"
194
-
195
- applied: list[dict] = []
196
- current = paper
197
-
198
- for i, fold in enumerate(folds):
199
- if i >= max_folds:
200
- break
201
- if not isinstance(fold, dict):
202
- return current, applied, f"Fold {i} is not a dict"
203
-
204
- current, error = apply_fold(current, fold)
205
- if error:
206
- return current, applied, f"Fold {i} failed: {error}"
207
- applied.append(fold)
208
-
209
- return current, applied, None
210
-
211
-
212
- def apply_pleat(
213
- paper: Paper,
214
- line1: dict,
215
- line2: dict,
216
- angle: float = 180.0,
217
- ) -> tuple[Paper, str | None]:
218
- """Pleat fold: valley at line1, mountain at line2 (two parallel folds).
219
-
220
- Both line dicts have the form: {"start": [x, y], "end": [x, y]}
221
- Returns (new_paper, error_or_None).
222
- """
223
- paper, err = apply_fold(paper, {"type": "valley", "line": line1, "angle": angle})
224
- if err:
225
- return paper, f"Pleat valley fold failed: {err}"
226
- paper, err = apply_fold(paper, {"type": "mountain", "line": line2, "angle": angle})
227
- if err:
228
- return paper, f"Pleat mountain fold failed: {err}"
229
- return paper, None
230
-
231
-
232
- def apply_crimp(
233
- paper: Paper,
234
- line1: dict,
235
- line2: dict,
236
- angle: float = 180.0,
237
- ) -> tuple[Paper, str | None]:
238
- """Crimp fold: mountain at line1, valley at line2 (reverse of pleat).
239
-
240
- Both line dicts have the form: {"start": [x, y], "end": [x, y]}
241
- Returns (new_paper, error_or_None).
242
- """
243
- paper, err = apply_fold(paper, {"type": "mountain", "line": line1, "angle": angle})
244
- if err:
245
- return paper, f"Crimp mountain fold failed: {err}"
246
- paper, err = apply_fold(paper, {"type": "valley", "line": line2, "angle": angle})
247
- if err:
248
- return paper, f"Crimp valley fold failed: {err}"
249
- return paper, None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
engine/materials.py DELETED
@@ -1,79 +0,0 @@
1
- """
2
- Material definitions for origami simulation.
3
-
4
- Provides dataclass-based material properties and preset materials
5
- for paper, mylar, aluminum, and nitinol.
6
- """
7
-
8
- from dataclasses import dataclass
9
-
10
-
11
- @dataclass
12
- class Material:
13
- name: str
14
- thickness_mm: float # mm
15
- youngs_modulus_gpa: float # GPa
16
- max_strain: float # fraction (e.g. 0.03 = 3%)
17
- poissons_ratio: float = 0.3 # dimensionless
18
-
19
- @property
20
- def thickness_m(self) -> float:
21
- """Thickness in meters."""
22
- return self.thickness_mm / 1000.0
23
-
24
- @property
25
- def youngs_modulus_pa(self) -> float:
26
- """Young's modulus in Pascals."""
27
- return self.youngs_modulus_gpa * 1e9
28
-
29
-
30
- # ── Preset materials ────────────────────────────────────────────────
31
-
32
- MATERIAL_PRESETS: dict[str, Material] = {
33
- "paper": Material(
34
- name="paper",
35
- thickness_mm=0.1,
36
- youngs_modulus_gpa=2.0,
37
- max_strain=0.03,
38
- poissons_ratio=0.3,
39
- ),
40
- "mylar": Material(
41
- name="mylar",
42
- thickness_mm=0.05,
43
- youngs_modulus_gpa=4.0,
44
- max_strain=0.03,
45
- poissons_ratio=0.38,
46
- ),
47
- "aluminum": Material(
48
- name="aluminum",
49
- thickness_mm=0.1,
50
- youngs_modulus_gpa=69.0,
51
- max_strain=0.01,
52
- poissons_ratio=0.33,
53
- ),
54
- "nitinol": Material(
55
- name="nitinol",
56
- thickness_mm=0.1,
57
- youngs_modulus_gpa=75.0,
58
- max_strain=0.08,
59
- poissons_ratio=0.33,
60
- ),
61
- }
62
-
63
-
64
- def get_material(name: str) -> Material:
65
- """Return a copy of a preset material by name.
66
-
67
- Raises KeyError if name is not in MATERIAL_PRESETS.
68
- """
69
- if name not in MATERIAL_PRESETS:
70
- available = ", ".join(sorted(MATERIAL_PRESETS))
71
- raise KeyError(f"Unknown material '{name}'. Available: {available}")
72
- preset = MATERIAL_PRESETS[name]
73
- return Material(
74
- name=preset.name,
75
- thickness_mm=preset.thickness_mm,
76
- youngs_modulus_gpa=preset.youngs_modulus_gpa,
77
- max_strain=preset.max_strain,
78
- poissons_ratio=preset.poissons_ratio,
79
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
engine/metrics.py DELETED
@@ -1,231 +0,0 @@
1
- """
2
- Quality metrics for folded origami.
3
-
4
- Computes bounding box, deployment ratio, fold count, and aggregated
5
- metric dictionaries for the trainer reward functions.
6
- """
7
-
8
- from __future__ import annotations
9
-
10
- import numpy as np
11
-
12
- from .paper import Paper
13
-
14
-
15
- def compute_bounding_box(paper: Paper) -> np.ndarray:
16
- """Axis-aligned bounding-box dimensions (dx, dy, dz).
17
-
18
- Returns shape (3,) array. Minimum z-thickness accounts for
19
- material thickness times estimated layer count.
20
- """
21
- if len(paper.vertices) == 0:
22
- return np.zeros(3)
23
-
24
- ptp = np.ptp(paper.vertices, axis=0)
25
- ptp = np.where(np.abs(ptp) < 1e-12, 0.0, ptp)
26
-
27
- # Minimum z from material thickness * layers
28
- t = paper.material.thickness_mm / 1000.0
29
- ptp[2] = max(ptp[2], t * paper.num_layers)
30
-
31
- return ptp
32
-
33
-
34
- def compute_deployment_ratio(paper: Paper) -> float:
35
- """Ratio of folded XY footprint area to original sheet area.
36
-
37
- A fully flat unfolded sheet has ratio 1.0; a tightly folded sheet
38
- approaches 0.0.
39
- """
40
- if paper.original_area <= 0:
41
- return 1.0
42
-
43
- bb = compute_bounding_box(paper)
44
- folded_area = bb[0] * bb[1]
45
-
46
- ratio = folded_area / paper.original_area
47
- return float(np.clip(ratio, 0.0, 1.0))
48
-
49
-
50
- def compute_fold_count(paper: Paper) -> int:
51
- """Number of mountain (M) and valley (V) edges."""
52
- return sum(1 for a in paper.assignments if a in ("M", "V"))
53
-
54
-
55
- def compute_compactness(paper: Paper) -> float:
56
- """1 - deployment_ratio. Higher is more compact."""
57
- return 1.0 - compute_deployment_ratio(paper)
58
-
59
-
60
- def compute_volume(paper: Paper) -> float:
61
- """Bounding-box volume in cubic meters."""
62
- bb = compute_bounding_box(paper)
63
- return float(bb[0] * bb[1] * bb[2])
64
-
65
-
66
- def compute_metrics(paper: Paper, original_paper: Paper | None = None) -> dict:
67
- """Compute all quality metrics and return as a dict.
68
-
69
- Parameters
70
- ----------
71
- paper : Paper
72
- The current (folded) paper state.
73
- original_paper : Paper or None
74
- The original (unfolded) paper, used for strain comparison.
75
- If None, strain is computed against the current paper's rest lengths.
76
-
77
- Returns
78
- -------
79
- dict with keys:
80
- bounding_box, deployment_ratio, fold_count, compactness,
81
- volume, max_strain, mean_strain, num_vertices, num_faces,
82
- num_layers.
83
- """
84
- from .physics import compute_strain # local import to avoid circular
85
-
86
- bb = compute_bounding_box(paper)
87
- strain = compute_strain(paper)
88
-
89
- return {
90
- "bounding_box": {
91
- "x": float(bb[0]),
92
- "y": float(bb[1]),
93
- "z": float(bb[2]),
94
- },
95
- "deployment_ratio": compute_deployment_ratio(paper),
96
- "fold_count": compute_fold_count(paper),
97
- "compactness": compute_compactness(paper),
98
- "volume": compute_volume(paper),
99
- "max_strain": float(np.max(strain)) if len(strain) > 0 else 0.0,
100
- "mean_strain": float(np.mean(strain)) if len(strain) > 0 else 0.0,
101
- "num_vertices": len(paper.vertices),
102
- "num_faces": len(paper.faces),
103
- "num_layers": paper.num_layers,
104
- }
105
-
106
-
107
- def compute_all_metrics(paper, task: dict, validation: dict) -> dict:
108
- """Compute every metric and return a flat dict.
109
-
110
- Called after physics + validation. Combines validity, compactness,
111
- structural, efficiency, and deployability metrics.
112
-
113
- Parameters
114
- ----------
115
- paper : Paper
116
- Current paper state (after simulate()).
117
- task : dict
118
- Task definition with keys: width, height, target_ratio, target_box, must_deploy.
119
- validation : dict
120
- Output of validate_state(paper).
121
- """
122
- import numpy as np
123
-
124
- bb = paper.bounding_box # (3,) array
125
- original_area = paper.original_area if paper.original_area > 0 else (paper.material.thickness_mm / 1000.0)
126
- t = paper.material.thickness_mm / 1000.0
127
- original_bbox_vol = original_area * t
128
- folded_bbox_vol = float(bb[0] * bb[1] * bb[2]) if bb[2] > 0 else float(bb[0] * bb[1] * t)
129
-
130
- # ── Folded area (XY footprint) ────────────────────────────────
131
- if len(paper.vertices) >= 3:
132
- try:
133
- from scipy.spatial import ConvexHull
134
- hull = ConvexHull(paper.vertices[:, :2])
135
- folded_area = float(hull.volume)
136
- except Exception:
137
- ptp = np.ptp(paper.vertices[:, :2], axis=0)
138
- folded_area = float(ptp[0] * ptp[1])
139
- else:
140
- folded_area = original_area
141
-
142
- deployment_ratio = folded_area / original_area if original_area > 0 else 1.0
143
- compactness = 1.0 - deployment_ratio
144
- volume_compaction = folded_bbox_vol / original_bbox_vol if original_bbox_vol > 0 else 1.0
145
- material_volume = original_area * t
146
- packing_efficiency = material_volume / folded_bbox_vol if folded_bbox_vol > 0 else 0.0
147
-
148
- # ── Target box check ──────────────────────────��──────────────
149
- target_box = task.get("target_box")
150
- fits_target_box = False
151
- if target_box and len(target_box) == 3:
152
- fits_target_box = bool(
153
- bb[0] <= target_box[0] + 1e-6 and
154
- bb[1] <= target_box[1] + 1e-6 and
155
- bb[2] <= target_box[2] + 1e-6
156
- )
157
-
158
- # ── Strain ───────────────────────────────────────────────────
159
- strain = paper.strain_per_vertex
160
- max_strain = float(np.max(strain)) if len(strain) > 0 else 0.0
161
- mean_strain = float(np.mean(strain)) if len(strain) > 0 else 0.0
162
-
163
- # ── Energy ───────────────────────────────────────────────────
164
- energy = paper.energy
165
-
166
- # ── Efficiency ───────────────────────────────────────────────
167
- fold_count = paper.fold_count
168
-
169
- # Crease complexity: entropy of M/V assignment distribution
170
- mv_assignments = [a for a in paper.assignments if a in ("M", "V")]
171
- if mv_assignments:
172
- total = len(mv_assignments)
173
- m_count = mv_assignments.count("M")
174
- v_count = mv_assignments.count("V")
175
- p_m = m_count / total if total > 0 else 0
176
- p_v = v_count / total if total > 0 else 0
177
- crease_complexity = 0.0
178
- if p_m > 0:
179
- crease_complexity -= p_m * np.log2(p_m)
180
- if p_v > 0:
181
- crease_complexity -= p_v * np.log2(p_v)
182
- else:
183
- crease_complexity = 0.0
184
-
185
- folding_efficiency = compactness / max(fold_count, 1)
186
-
187
- # ── Deployability ─────────────────────────────────────────────
188
- must_deploy = task.get("must_deploy", False)
189
- # Simple deployability heuristic: if valid and compactness > 0, assume deployable
190
- is_deployable = bool(validation.get("is_valid", False) and compactness > 0.01) if must_deploy else None
191
- # Deployment force estimate from total energy gradient (rough)
192
- deployment_force_estimate = float(energy.get("fold", 0.0)) / max(paper.original_area, 1e-6)
193
-
194
- return {
195
- # Validity (from validation dict)
196
- "is_valid": validation.get("is_valid", False),
197
- "kawasaki_violations": validation.get("kawasaki_violations", 0),
198
- "kawasaki_total_error": validation.get("kawasaki_total_error", 0.0),
199
- "maekawa_violations": validation.get("maekawa_violations", 0),
200
- "self_intersections": validation.get("self_intersections", 0),
201
- "strain_exceeded": validation.get("strain_exceeded", False),
202
-
203
- # Compactness
204
- "deployment_ratio": float(deployment_ratio),
205
- "compactness": float(compactness),
206
- "volume_compaction": float(volume_compaction),
207
- "packing_efficiency": float(packing_efficiency),
208
- "fits_target_box": fits_target_box,
209
- "bounding_box": bb.tolist(),
210
-
211
- # Structural
212
- "max_strain": max_strain,
213
- "mean_strain": mean_strain,
214
- "total_energy": float(energy.get("total", 0.0)),
215
- "energy_bar": float(energy.get("bar", 0.0)),
216
- "energy_facet": float(energy.get("facet", 0.0)),
217
- "energy_fold": float(energy.get("fold", 0.0)),
218
-
219
- # Efficiency
220
- "fold_count": fold_count,
221
- "folding_efficiency": float(folding_efficiency),
222
- "crease_complexity": float(crease_complexity),
223
-
224
- # Deployability
225
- "is_deployable": is_deployable,
226
- "deployment_force_estimate": float(deployment_force_estimate),
227
-
228
- # Shape similarity placeholders
229
- "chamfer_distance": None,
230
- "hausdorff_distance": None,
231
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
engine/paper.py DELETED
@@ -1,525 +0,0 @@
1
- """
2
- Paper — the core geometric data structure for origami simulation.
3
-
4
- Stores vertices, edges, faces, fold assignments, fold angles, layer ordering,
5
- and material. Supports FOLD-format serialization and the face-splitting
6
- operation needed by the fold engine.
7
- """
8
-
9
- from __future__ import annotations
10
-
11
- import copy
12
- import json
13
- from dataclasses import dataclass, field
14
- from typing import Any
15
-
16
- import numpy as np
17
-
18
- from .materials import Material, get_material
19
-
20
-
21
- # ────────────────────────────────────────────────────────────────────
22
- # Helper: 2-D line-segment intersection
23
- # ────────────────────────────────────────────────────────────────────
24
-
25
- def _seg_seg_intersect_2d(
26
- p1: np.ndarray, p2: np.ndarray,
27
- p3: np.ndarray, p4: np.ndarray,
28
- eps: float = 1e-10,
29
- ) -> np.ndarray | None:
30
- """Return the intersection point of segments (p1-p2) and (p3-p4) in 2-D,
31
- or None if they do not intersect. Points that lie on the segment
32
- endpoints are considered intersections (within tolerance *eps*).
33
-
34
- All inputs are shape (2,).
35
- """
36
- d1 = p2 - p1
37
- d2 = p4 - p3
38
- denom = d1[0] * d2[1] - d1[1] * d2[0]
39
-
40
- if abs(denom) < eps:
41
- return None # parallel / collinear
42
-
43
- dp = p3 - p1
44
- t = (dp[0] * d2[1] - dp[1] * d2[0]) / denom
45
- u = (dp[0] * d1[1] - dp[1] * d1[0]) / denom
46
-
47
- if -eps <= t <= 1.0 + eps and -eps <= u <= 1.0 + eps:
48
- return p1 + np.clip(t, 0.0, 1.0) * d1
49
- return None
50
-
51
-
52
- # ────────────────────────────────────────────────────────────────────
53
- # Paper dataclass
54
- # ────────────────────────────────────────────────────────────────────
55
-
56
- @dataclass
57
- class Paper:
58
- """Origami sheet state.
59
-
60
- Attributes
61
- ----------
62
- vertices : np.ndarray, shape (N, 3)
63
- Vertex positions in 3-D.
64
- edges : np.ndarray, shape (E, 2), dtype int
65
- Each row is (v_start, v_end).
66
- faces : list[list[int]]
67
- Each face is an ordered list of vertex indices (CCW winding).
68
- assignments : list[str]
69
- Per-edge assignment: 'M' (mountain), 'V' (valley), 'B' (boundary),
70
- 'F' (flat / unfolded), 'U' (unassigned).
71
- fold_angles : np.ndarray, shape (E,)
72
- Current fold angle (degrees) per edge.
73
- face_orders : list[tuple[int, int, int]]
74
- Layer ordering triples (f_i, f_j, +1/-1) meaning f_i is above/below f_j.
75
- material : Material
76
- The sheet material.
77
- rest_lengths : np.ndarray, shape (E,)
78
- Original (unfolded) edge lengths — used for strain computation.
79
- original_area : float
80
- Area of the sheet before any folds.
81
- """
82
-
83
- vertices: np.ndarray
84
- edges: np.ndarray
85
- faces: list[list[int]]
86
- assignments: list[str]
87
- fold_angles: np.ndarray
88
- face_orders: list[tuple[int, int, int]] = field(default_factory=list)
89
- material: Material = field(default_factory=lambda: get_material("paper"))
90
- rest_lengths: np.ndarray = field(default_factory=lambda: np.empty(0))
91
- original_area: float = 0.0
92
- rest_positions: np.ndarray = field(default_factory=lambda: np.empty((0, 3)))
93
- strain_per_vertex: np.ndarray = field(default_factory=lambda: np.empty(0))
94
- energy: dict = field(default_factory=lambda: {"total": 0.0, "bar": 0.0, "facet": 0.0, "fold": 0.0})
95
- fold_count: int = 0
96
-
97
- # ── constructors ────────────────────────────────────────────────
98
-
99
- @staticmethod
100
- def create_flat_sheet(
101
- width: float = 1.0,
102
- height: float = 1.0,
103
- material: Material | None = None,
104
- ) -> "Paper":
105
- """Create a flat rectangular sheet with 4 vertices, 5 edges
106
- (including one diagonal), and 2 triangular faces."""
107
- mat = material if material is not None else get_material("paper")
108
-
109
- verts = np.array([
110
- [0.0, 0.0, 0.0],
111
- [width, 0.0, 0.0],
112
- [width, height, 0.0],
113
- [0.0, height, 0.0],
114
- ], dtype=np.float64)
115
-
116
- edges = np.array([
117
- [0, 1], # bottom
118
- [1, 2], # right
119
- [2, 3], # top
120
- [3, 0], # left
121
- [0, 2], # diagonal
122
- ], dtype=np.int64)
123
-
124
- faces: list[list[int]] = [[0, 1, 2], [0, 2, 3]]
125
- assignments = ["B", "B", "B", "B", "F"]
126
- fold_angles = np.zeros(len(edges), dtype=np.float64)
127
- rest_lengths = np.array(
128
- [np.linalg.norm(verts[e[1]] - verts[e[0]]) for e in edges],
129
- dtype=np.float64,
130
- )
131
-
132
- paper = Paper(
133
- vertices=verts,
134
- edges=edges,
135
- faces=faces,
136
- assignments=assignments,
137
- fold_angles=fold_angles,
138
- material=mat,
139
- rest_lengths=rest_lengths,
140
- original_area=width * height,
141
- )
142
- paper.rest_positions = verts.copy()
143
- return paper
144
-
145
- # ── dict / prompt serialization (matches mock_env.PaperState.to_dict) ──
146
-
147
- def to_dict(self) -> dict:
148
- """Return a simplified dict suitable for LLM prompts.
149
-
150
- The format matches ``mock_env.PaperState.to_dict()`` so that the
151
- trainer reward functions work with either engine.
152
- """
153
- bb = self.bounding_box
154
- return {
155
- "width": float(bb[0]),
156
- "height": float(bb[1]),
157
- "material": {
158
- "name": self.material.name,
159
- "thickness_mm": self.material.thickness_mm,
160
- "youngs_modulus_gpa": self.material.youngs_modulus_gpa,
161
- },
162
- "vertices": self.vertices.tolist(),
163
- "edges": self.edges.tolist(),
164
- "assignments": list(self.assignments),
165
- "fold_angles": self.fold_angles.tolist(),
166
- "num_layers_at_center": self.num_layers,
167
- "bounding_box": {
168
- "x": float(bb[0]),
169
- "y": float(bb[1]),
170
- "z": float(bb[2]),
171
- },
172
- }
173
-
174
- def to_observation_dict(self) -> dict:
175
- bb = self.bounding_box
176
- return {
177
- "vertices_coords": self.vertices.tolist(),
178
- "edges_vertices": self.edges.tolist(),
179
- "faces_vertices": self.faces,
180
- "edges_assignment": list(self.assignments),
181
- "edges_foldAngle": self.fold_angles.tolist(),
182
- "num_vertices": len(self.vertices),
183
- "num_edges": len(self.edges),
184
- "num_faces": len(self.faces),
185
- "bounding_box": bb.tolist(),
186
- "num_layers": self.num_layers,
187
- "material": {
188
- "name": self.material.name,
189
- "thickness_mm": self.material.thickness_mm,
190
- "youngs_modulus_gpa": self.material.youngs_modulus_gpa,
191
- "max_strain": self.material.max_strain,
192
- "poisson_ratio": self.material.poissons_ratio,
193
- },
194
- "strain_per_vertex": self.strain_per_vertex.tolist(),
195
- "energy": dict(self.energy),
196
- "fold_count": self.fold_count,
197
- "width": float(self.original_area ** 0.5) if self.original_area > 0 else 1.0,
198
- "height": float(self.original_area ** 0.5) if self.original_area > 0 else 1.0,
199
- }
200
-
201
- # ── FOLD format serialization ───────────────────────────────────
202
-
203
- def to_fold_json(self) -> str:
204
- """Serialize to FOLD JSON format (v1.1 subset)."""
205
- fold = {
206
- "file_spec": 1.1,
207
- "file_creator": "optigami",
208
- "file_classes": ["singleModel"],
209
- "frame_classes": ["foldedForm"],
210
- "vertices_coords": self.vertices.tolist(),
211
- "edges_vertices": self.edges.tolist(),
212
- "edges_assignment": self.assignments,
213
- "edges_foldAngle": self.fold_angles.tolist(),
214
- "faces_vertices": self.faces,
215
- "faceOrders": [list(fo) for fo in self.face_orders],
216
- }
217
- return json.dumps(fold, indent=2)
218
-
219
- @staticmethod
220
- def from_fold_json(data: str | dict, material: Material | None = None) -> "Paper":
221
- """Deserialize from FOLD JSON format."""
222
- if isinstance(data, str):
223
- data = json.loads(data)
224
-
225
- verts = np.array(data["vertices_coords"], dtype=np.float64)
226
- edges = np.array(data["edges_vertices"], dtype=np.int64)
227
- faces = data.get("faces_vertices", [])
228
- assignments = data.get("edges_assignment", ["U"] * len(edges))
229
- fold_angles = np.array(
230
- data.get("edges_foldAngle", [0.0] * len(edges)),
231
- dtype=np.float64,
232
- )
233
- face_orders = [tuple(fo) for fo in data.get("faceOrders", [])]
234
-
235
- rest_lengths = np.array(
236
- [np.linalg.norm(verts[e[1]] - verts[e[0]]) for e in edges],
237
- dtype=np.float64,
238
- )
239
-
240
- mat = material if material is not None else get_material("paper")
241
-
242
- # Approximate original area from convex hull of initial XY footprint
243
- try:
244
- from scipy.spatial import ConvexHull
245
- hull = ConvexHull(verts[:, :2])
246
- area = hull.volume # 2-D ConvexHull.volume is area
247
- except Exception:
248
- # Fallback: bounding-box area from XY coordinates
249
- if len(verts) >= 2:
250
- ptp = np.ptp(verts[:, :2], axis=0)
251
- area = float(ptp[0] * ptp[1])
252
- else:
253
- area = 0.0
254
-
255
- return Paper(
256
- vertices=verts,
257
- edges=edges,
258
- faces=faces,
259
- assignments=assignments,
260
- fold_angles=fold_angles,
261
- face_orders=face_orders,
262
- material=mat,
263
- rest_lengths=rest_lengths,
264
- original_area=area,
265
- )
266
-
267
- # ── computed properties ─────────────────────────────────────────
268
-
269
- @property
270
- def bounding_box(self) -> np.ndarray:
271
- """Axis-aligned bounding-box dimensions (dx, dy, dz)."""
272
- if len(self.vertices) == 0:
273
- return np.zeros(3)
274
- ptp = np.ptp(self.vertices, axis=0)
275
- ptp = np.where(np.abs(ptp) < 1e-12, 0.0, ptp)
276
- # Ensure minimum z height from material thickness * layers
277
- t = self.material.thickness_mm / 1000.0
278
- ptp[2] = max(ptp[2], t * self.num_layers)
279
- return ptp
280
-
281
- @property
282
- def num_layers(self) -> int:
283
- """Estimate layer count from face-order triples.
284
-
285
- Falls back to 1 + number of M/V edges as a simple heuristic when
286
- face_orders is empty.
287
- """
288
- if self.face_orders:
289
- face_ids = set()
290
- for fo in self.face_orders:
291
- face_ids.add(fo[0])
292
- face_ids.add(fo[1])
293
- return max(len(face_ids), 1)
294
- # Heuristic: each fold adds one layer
295
- mv_count = sum(1 for a in self.assignments if a in ("M", "V"))
296
- return 1 + mv_count
297
-
298
- # ── topology helpers ────────────────────────────────────────────
299
-
300
- def _find_or_add_vertex(self, point_3d: np.ndarray, tol: float = 1e-8) -> int:
301
- """Return index of an existing vertex close to *point_3d*, or add a
302
- new vertex and return its index."""
303
- for i, v in enumerate(self.vertices):
304
- if np.linalg.norm(v - point_3d) < tol:
305
- return i
306
- idx = len(self.vertices)
307
- self.vertices = np.vstack([self.vertices, point_3d.reshape(1, 3)])
308
- return idx
309
-
310
- def _find_or_add_edge(self, v1: int, v2: int) -> int:
311
- """Return index of edge (v1,v2) or (v2,v1), or add a new edge and
312
- return its index. New edges get assignment 'F' and fold-angle 0."""
313
- for i, e in enumerate(self.edges):
314
- if (e[0] == v1 and e[1] == v2) or (e[0] == v2 and e[1] == v1):
315
- return i
316
- idx = len(self.edges)
317
- self.edges = np.vstack([self.edges, np.array([[v1, v2]], dtype=np.int64)])
318
- self.assignments.append("F")
319
- self.fold_angles = np.append(self.fold_angles, 0.0)
320
- # Rest length for the new edge
321
- rl = np.linalg.norm(self.vertices[v1] - self.vertices[v2])
322
- self.rest_lengths = np.append(self.rest_lengths, rl)
323
- return idx
324
-
325
- # ── face splitting ──────────────────────────────────────────────
326
-
327
- def split_faces_along_line(
328
- self,
329
- start_2d: np.ndarray | list,
330
- end_2d: np.ndarray | list,
331
- ) -> list[int]:
332
- """Split every face that the 2-D line (start_2d -> end_2d) crosses.
333
-
334
- The line is infinite for intersection purposes (we test each face
335
- edge-segment against the full fold-line extent clipped to the paper).
336
-
337
- Returns a list of edge indices that lie *on* the fold line (i.e. the
338
- newly created edges along the fold path).
339
-
340
- This mutates ``self`` in-place (vertices, edges, faces, assignments,
341
- fold_angles, rest_lengths are updated).
342
- """
343
- start_2d = np.asarray(start_2d, dtype=np.float64)
344
- end_2d = np.asarray(end_2d, dtype=np.float64)
345
-
346
- fold_edge_indices: list[int] = []
347
- new_faces: list[list[int]] = []
348
-
349
- faces_to_process = list(range(len(self.faces)))
350
-
351
- for fi in faces_to_process:
352
- face = self.faces[fi]
353
- n = len(face)
354
-
355
- # Gather intersection points along the face boundary
356
- hits: list[tuple[int, np.ndarray]] = [] # (local_edge_index, point_2d)
357
-
358
- for k in range(n):
359
- v_a = face[k]
360
- v_b = face[(k + 1) % n]
361
- pa = self.vertices[v_a][:2]
362
- pb = self.vertices[v_b][:2]
363
-
364
- pt = _seg_seg_intersect_2d(start_2d, end_2d, pa, pb)
365
- if pt is not None:
366
- hits.append((k, pt))
367
-
368
- # Deduplicate hits that are at the same location (e.g. hitting a vertex)
369
- if len(hits) >= 2:
370
- unique_hits: list[tuple[int, np.ndarray]] = [hits[0]]
371
- for h in hits[1:]:
372
- is_dup = False
373
- for uh in unique_hits:
374
- if np.linalg.norm(h[1] - uh[1]) < 1e-8:
375
- is_dup = True
376
- break
377
- if not is_dup:
378
- unique_hits.append(h)
379
- hits = unique_hits
380
-
381
- if len(hits) < 2:
382
- # Line does not fully cross this face — keep face as-is
383
- new_faces.append(face)
384
- continue
385
-
386
- # We only handle the first two intersection points (one chord across face)
387
- hit_a_edge_idx, hit_a_pt = hits[0]
388
- hit_b_edge_idx, hit_b_pt = hits[1]
389
-
390
- # Create / find 3-D vertices at intersection points (z=0 for flat, interpolated otherwise)
391
- def _interp_z(pt2d: np.ndarray, edge_local: int) -> np.ndarray:
392
- """Interpolate z from the edge endpoints."""
393
- v_a = face[edge_local]
394
- v_b = face[(edge_local + 1) % n]
395
- pa = self.vertices[v_a]
396
- pb = self.vertices[v_b]
397
- seg = pb[:2] - pa[:2]
398
- seg_len = np.linalg.norm(seg)
399
- if seg_len < 1e-12:
400
- return np.array([pt2d[0], pt2d[1], pa[2]])
401
- t = np.linalg.norm(pt2d - pa[:2]) / seg_len
402
- t = np.clip(t, 0.0, 1.0)
403
- z = pa[2] + t * (pb[2] - pa[2])
404
- return np.array([pt2d[0], pt2d[1], z])
405
-
406
- pt_a_3d = _interp_z(hit_a_pt, hit_a_edge_idx)
407
- pt_b_3d = _interp_z(hit_b_pt, hit_b_edge_idx)
408
-
409
- idx_a = self._find_or_add_vertex(pt_a_3d)
410
- idx_b = self._find_or_add_vertex(pt_b_3d)
411
-
412
- if idx_a == idx_b:
413
- new_faces.append(face)
414
- continue
415
-
416
- # Add the fold-line edge between the two intersection points
417
- fold_eidx = self._find_or_add_edge(idx_a, idx_b)
418
- fold_edge_indices.append(fold_eidx)
419
-
420
- # ── Split the face into two sub-faces ──
421
- # Walk around the face vertices, inserting idx_a and idx_b at the
422
- # appropriate positions, then split into two loops.
423
- ordered_verts = list(face)
424
-
425
- # Insert intersection vertices into the vertex ring if not already present
426
- def _insert_after(ring: list[int], after_local: int, vid: int) -> list[int]:
427
- """Insert *vid* after position *after_local* if it is not already
428
- adjacent in the ring at that position."""
429
- pos = after_local + 1
430
- if ring[after_local % len(ring)] == vid:
431
- return ring
432
- if ring[pos % len(ring)] == vid:
433
- return ring
434
- return ring[:pos] + [vid] + ring[pos:]
435
-
436
- # Determine insertion order — always insert the one with the
437
- # larger local-edge index first so that the earlier index stays valid.
438
- if hit_a_edge_idx <= hit_b_edge_idx:
439
- ordered_verts = _insert_after(ordered_verts, hit_b_edge_idx, idx_b)
440
- # Recompute hit_a_edge_idx offset if idx_b was inserted before it
441
- # (it shouldn't be, since hit_b >= hit_a, but guard anyway)
442
- a_pos = hit_a_edge_idx
443
- ordered_verts = _insert_after(ordered_verts, a_pos, idx_a)
444
- else:
445
- ordered_verts = _insert_after(ordered_verts, hit_a_edge_idx, idx_a)
446
- ordered_verts = _insert_after(ordered_verts, hit_b_edge_idx, idx_b)
447
-
448
- # Now split the ring at idx_a and idx_b
449
- try:
450
- pos_a = ordered_verts.index(idx_a)
451
- pos_b = ordered_verts.index(idx_b)
452
- except ValueError:
453
- new_faces.append(face)
454
- continue
455
-
456
- if pos_a > pos_b:
457
- pos_a, pos_b = pos_b, pos_a
458
-
459
- loop1 = ordered_verts[pos_a: pos_b + 1]
460
- loop2 = ordered_verts[pos_b:] + ordered_verts[: pos_a + 1]
461
-
462
- # Only keep faces with >= 3 unique vertices
463
- for loop in (loop1, loop2):
464
- unique = list(dict.fromkeys(loop)) # preserve order, dedupe
465
- if len(unique) >= 3:
466
- new_faces.append(unique)
467
- # Ensure all edges of this new face exist
468
- for k in range(len(unique)):
469
- self._find_or_add_edge(unique[k], unique[(k + 1) % len(unique)])
470
-
471
- self.faces = new_faces
472
- return fold_edge_indices
473
-
474
- # ── vertex side test ────────────────────────────────────────────
475
-
476
- def get_vertices_on_side(
477
- self,
478
- line_start: np.ndarray | list,
479
- line_end: np.ndarray | list,
480
- side: str = "positive",
481
- ) -> list[int]:
482
- """Return indices of vertices on one side of a 2-D line.
483
-
484
- *side* can be ``"positive"`` or ``"negative"``. The positive side is
485
- defined by the left-hand normal of (line_end - line_start).
486
- """
487
- ls = np.asarray(line_start, dtype=np.float64)[:2]
488
- le = np.asarray(line_end, dtype=np.float64)[:2]
489
- d = le - ls
490
- normal = np.array([-d[1], d[0]])
491
-
492
- indices: list[int] = []
493
- for i, v in enumerate(self.vertices):
494
- dot = np.dot(v[:2] - ls, normal)
495
- if side == "positive" and dot > 1e-9:
496
- indices.append(i)
497
- elif side == "negative" and dot < -1e-9:
498
- indices.append(i)
499
- return indices
500
-
501
- # ── deep copy ───────────────────────────────────────────────────
502
-
503
- def copy(self) -> "Paper":
504
- """Return an independent deep copy."""
505
- return Paper(
506
- vertices=self.vertices.copy(),
507
- edges=self.edges.copy(),
508
- faces=copy.deepcopy(self.faces),
509
- assignments=list(self.assignments),
510
- fold_angles=self.fold_angles.copy(),
511
- face_orders=list(self.face_orders),
512
- material=Material(
513
- name=self.material.name,
514
- thickness_mm=self.material.thickness_mm,
515
- youngs_modulus_gpa=self.material.youngs_modulus_gpa,
516
- max_strain=self.material.max_strain,
517
- poissons_ratio=self.material.poissons_ratio,
518
- ),
519
- rest_lengths=self.rest_lengths.copy(),
520
- original_area=self.original_area,
521
- rest_positions=self.rest_positions.copy(),
522
- strain_per_vertex=self.strain_per_vertex.copy(),
523
- energy=dict(self.energy),
524
- fold_count=self.fold_count,
525
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
engine/physics.py DELETED
@@ -1,517 +0,0 @@
1
- """
2
- Bar-and-hinge physics model.
3
-
4
- Three energy components:
5
- E_total = E_bar + E_facet + E_fold
6
-
7
- Stiffness parameters are derived from the material properties.
8
- """
9
-
10
- from __future__ import annotations
11
-
12
- from dataclasses import dataclass
13
-
14
- import numpy as np
15
-
16
- from .paper import Paper
17
-
18
-
19
- # ────────────────────────────────────────────────────────────────────
20
- # Stiffness
21
- # ────────────────────────────────────────────────────────────────────
22
-
23
- @dataclass
24
- class StiffnessParams:
25
- """Stiffness values derived from material properties."""
26
- k_axial: np.ndarray # per-edge axial stiffness (E,)
27
- k_facet: float # facet (panel bending) stiffness
28
- k_fold: float # fold (crease torsion) stiffness
29
-
30
-
31
- def compute_stiffness(paper: Paper) -> StiffnessParams:
32
- """Derive stiffness parameters from the paper's material and geometry.
33
-
34
- k_axial = E * t * w / L0 (per edge, w ≈ average of adjacent edge lengths)
35
- k_facet = E * t^3 / (12 * (1 - nu^2))
36
- k_fold = 0.1 * k_facet (crease torsional stiffness, empirical fraction)
37
- """
38
- mat = paper.material
39
- E = mat.youngs_modulus_pa # Pa
40
- t = mat.thickness_m # m
41
- nu = mat.poissons_ratio
42
-
43
- rest = paper.rest_lengths
44
- # Guard against zero rest lengths
45
- safe_rest = np.where(rest > 1e-15, rest, 1e-15)
46
-
47
- # Approximate edge width as the average rest length (simple heuristic)
48
- w = np.mean(safe_rest) if len(safe_rest) > 0 else 1e-3
49
-
50
- k_axial = E * t * w / safe_rest # (E,)
51
-
52
- k_facet = E * t ** 3 / (12.0 * (1.0 - nu ** 2))
53
-
54
- # Crease torsional stiffness — a fraction of facet stiffness
55
- k_fold = 0.1 * k_facet
56
-
57
- return StiffnessParams(k_axial=k_axial, k_facet=k_facet, k_fold=k_fold)
58
-
59
-
60
- # ────────────────────────────────────────────────────────────────────
61
- # Energy components
62
- # ────────────────────────────────────────────────────────────────────
63
-
64
- def compute_bar_energy(paper: Paper) -> float:
65
- """E_bar = sum (1/2) * k_axial * (L - L0)^2
66
-
67
- Measures stretching / compression of edges relative to rest lengths.
68
- """
69
- if len(paper.edges) == 0:
70
- return 0.0
71
-
72
- verts = paper.vertices
73
- edges = paper.edges
74
- current_lengths = np.array([
75
- np.linalg.norm(verts[e[1]] - verts[e[0]]) for e in edges
76
- ])
77
-
78
- stiff = compute_stiffness(paper)
79
- delta = current_lengths - paper.rest_lengths
80
- energy = 0.5 * np.sum(stiff.k_axial * delta ** 2)
81
- return float(energy)
82
-
83
-
84
- def compute_facet_energy(paper: Paper) -> float:
85
- """E_facet = sum (1/2) * k_facet * l * (theta - pi)^2
86
-
87
- Measures bending of facet panels away from flat (pi).
88
- *l* is the edge length (hinge length) and *theta* is the dihedral angle
89
- across the edge between two adjacent faces. For edges that are not
90
- shared by two faces we skip them.
91
- """
92
- if len(paper.edges) == 0 or len(paper.faces) < 2:
93
- return 0.0
94
-
95
- stiff = compute_stiffness(paper)
96
- verts = paper.vertices
97
- edges = paper.edges
98
-
99
- # Build edge → face adjacency
100
- edge_faces: dict[int, list[int]] = {}
101
- for fi, face in enumerate(paper.faces):
102
- n = len(face)
103
- for k in range(n):
104
- va, vb = face[k], face[(k + 1) % n]
105
- for ei, e in enumerate(edges):
106
- if (e[0] == va and e[1] == vb) or (e[0] == vb and e[1] == va):
107
- edge_faces.setdefault(ei, []).append(fi)
108
- break
109
-
110
- energy = 0.0
111
- for ei, adj_faces in edge_faces.items():
112
- if len(adj_faces) < 2:
113
- continue
114
- # Only consider non-fold edges (flat or boundary interior)
115
- if paper.assignments[ei] in ("M", "V"):
116
- continue
117
-
118
- f1, f2 = adj_faces[0], adj_faces[1]
119
- theta = _dihedral_angle(verts, paper.faces[f1], paper.faces[f2], edges[ei])
120
- l = np.linalg.norm(verts[edges[ei][1]] - verts[edges[ei][0]])
121
- energy += 0.5 * stiff.k_facet * l * (theta - np.pi) ** 2
122
-
123
- return float(energy)
124
-
125
-
126
- def compute_fold_energy(paper: Paper) -> float:
127
- """E_fold = sum (1/2) * k_fold * l * (rho - rho_target)^2
128
-
129
- Measures deviation of fold creases from their target angles.
130
- *rho* is the current dihedral angle across the fold edge and
131
- *rho_target* comes from ``fold_angles``.
132
- """
133
- if len(paper.edges) == 0:
134
- return 0.0
135
-
136
- stiff = compute_stiffness(paper)
137
- verts = paper.vertices
138
- edges = paper.edges
139
-
140
- # Build edge → face adjacency
141
- edge_faces: dict[int, list[int]] = {}
142
- for fi, face in enumerate(paper.faces):
143
- n = len(face)
144
- for k in range(n):
145
- va, vb = face[k], face[(k + 1) % n]
146
- for ei, e in enumerate(edges):
147
- if (e[0] == va and e[1] == vb) or (e[0] == vb and e[1] == va):
148
- edge_faces.setdefault(ei, []).append(fi)
149
- break
150
-
151
- energy = 0.0
152
- for ei in range(len(edges)):
153
- if paper.assignments[ei] not in ("M", "V"):
154
- continue
155
- if ei not in edge_faces or len(edge_faces[ei]) < 2:
156
- continue
157
-
158
- f1, f2 = edge_faces[ei][0], edge_faces[ei][1]
159
- rho = _dihedral_angle(verts, paper.faces[f1], paper.faces[f2], edges[ei])
160
- rho_target = np.radians(paper.fold_angles[ei]) # fold_angles stored in degrees
161
- l = np.linalg.norm(verts[edges[ei][1]] - verts[edges[ei][0]])
162
- energy += 0.5 * stiff.k_fold * l * (rho - rho_target) ** 2
163
-
164
- return float(energy)
165
-
166
-
167
- def compute_total_energy(paper: Paper) -> float:
168
- """E_total = E_bar + E_facet + E_fold."""
169
- return compute_bar_energy(paper) + compute_facet_energy(paper) + compute_fold_energy(paper)
170
-
171
-
172
- # ────────────────────────────────────────────────────────────────────
173
- # Strain
174
- # ────────────────────────────────────────────────────────────────────
175
-
176
- def compute_strain(paper: Paper) -> np.ndarray:
177
- """Per-vertex Cauchy strain: average fractional edge-length deviation.
178
-
179
- Returns shape (N,) array of non-negative strain values.
180
- """
181
- n_verts = len(paper.vertices)
182
- if n_verts == 0:
183
- return np.empty(0)
184
-
185
- verts = paper.vertices
186
- edges = paper.edges
187
- rest = paper.rest_lengths
188
-
189
- # Build vertex → edge adjacency
190
- vert_edges: dict[int, list[int]] = {}
191
- for ei, e in enumerate(edges):
192
- vert_edges.setdefault(int(e[0]), []).append(ei)
193
- vert_edges.setdefault(int(e[1]), []).append(ei)
194
-
195
- strain = np.zeros(n_verts, dtype=np.float64)
196
- for vi in range(n_verts):
197
- adj = vert_edges.get(vi, [])
198
- if not adj:
199
- continue
200
- devs = []
201
- for ei in adj:
202
- v1, v2 = edges[ei]
203
- L = np.linalg.norm(verts[v1] - verts[v2])
204
- L0 = rest[ei]
205
- if L0 > 1e-15:
206
- devs.append(abs(L - L0) / L0)
207
- if devs:
208
- strain[vi] = float(np.mean(devs))
209
-
210
- return strain
211
-
212
-
213
- # ────────────────────────────────────────────────────────────────────
214
- # Dihedral angle helper
215
- # ────────────────────────────────────────────────────────────────────
216
-
217
- def _dihedral_angle(
218
- verts: np.ndarray,
219
- face1: list[int],
220
- face2: list[int],
221
- edge: np.ndarray,
222
- ) -> float:
223
- """Compute the dihedral angle (in radians) between two faces sharing *edge*.
224
-
225
- Returns angle in [0, 2*pi). Returns pi if normals cannot be computed.
226
- """
227
- n1 = _face_normal(verts, face1)
228
- n2 = _face_normal(verts, face2)
229
-
230
- if n1 is None or n2 is None:
231
- return np.pi
232
-
233
- cos_a = np.clip(np.dot(n1, n2), -1.0, 1.0)
234
- angle = np.arccos(cos_a)
235
-
236
- # Determine sign from edge direction
237
- edge_dir = verts[edge[1]] - verts[edge[0]]
238
- edge_dir = edge_dir / (np.linalg.norm(edge_dir) + 1e-30)
239
- cross = np.cross(n1, n2)
240
- if np.dot(cross, edge_dir) < 0:
241
- angle = 2.0 * np.pi - angle
242
-
243
- return float(angle)
244
-
245
-
246
- def _face_normal(verts: np.ndarray, face: list[int]) -> np.ndarray | None:
247
- """Compute outward unit normal of a face, or None if degenerate."""
248
- if len(face) < 3:
249
- return None
250
- v0 = verts[face[0]]
251
- v1 = verts[face[1]]
252
- v2 = verts[face[2]]
253
- normal = np.cross(v1 - v0, v2 - v0)
254
- norm = np.linalg.norm(normal)
255
- if norm < 1e-15:
256
- return None
257
- return normal / norm
258
-
259
-
260
- # ────────────────────────────────────────────────────────────────────
261
- # Topology precomputation
262
- # ────────────────────────────────────────────────────────────────────
263
-
264
- def build_beam_list(paper: Paper) -> list[tuple[int, int, float, float]]:
265
- """Build list of (node_a, node_b, rest_len, k_axial) for every edge.
266
-
267
- Uses normalized stiffness values (arch doc constants) scaled by material
268
- Young's modulus ratio — keeps the Verlet integrator stable at unit scale.
269
- """
270
- # Normalized stiffness constants (arch doc values)
271
- K_AXIAL_BASE = 70.0
272
- # Scale by material: paper (3 GPa) = 1.0 baseline
273
- mat = paper.material
274
- E_ratio = mat.youngs_modulus_gpa / 3.0
275
- k_axial = K_AXIAL_BASE * E_ratio
276
-
277
- beams = []
278
- for ei, (v1, v2) in enumerate(paper.edges):
279
- L0 = paper.rest_lengths[ei]
280
- beams.append((int(v1), int(v2), float(L0), float(k_axial)))
281
- return beams
282
-
283
-
284
- def build_crease_list(paper: Paper) -> list[tuple[int, int, int, int, float, float, str]]:
285
- """Build list of (n1, n2, n3, n4, target_angle_rad, k, type) for each crease hinge.
286
-
287
- Each hinge is defined by 4 nodes: n1-n2 is the hinge edge, n3 and n4 are
288
- the wing-tip nodes of the two adjacent faces.
289
- type is 'fold' (M/V crease) or 'facet' (interior flat edge).
290
- """
291
- verts = paper.vertices
292
-
293
- # Build edge → face adjacency
294
- edge_faces: dict[int, list[int]] = {}
295
- for fi, face in enumerate(paper.faces):
296
- n = len(face)
297
- for k in range(n):
298
- va, vb = face[k], face[(k + 1) % n]
299
- for ei, e in enumerate(paper.edges):
300
- if (e[0] == va and e[1] == vb) or (e[0] == vb and e[1] == va):
301
- edge_faces.setdefault(ei, []).append(fi)
302
- break
303
-
304
- creases = []
305
- for ei, adj in edge_faces.items():
306
- if len(adj) < 2:
307
- continue
308
- f1, f2 = adj[0], adj[1]
309
- face1, face2 = paper.faces[f1], paper.faces[f2]
310
- n1, n2 = int(paper.edges[ei][0]), int(paper.edges[ei][1])
311
-
312
- # Find wing-tip nodes (in each face, the vertex NOT on the shared edge)
313
- wing1 = [v for v in face1 if v != n1 and v != n2]
314
- wing2 = [v for v in face2 if v != n1 and v != n2]
315
- if not wing1 or not wing2:
316
- continue
317
- n3, n4 = int(wing1[0]), int(wing2[0])
318
-
319
- # Normalized stiffness constants (arch doc values), scaled by material
320
- E_ratio = paper.material.youngs_modulus_gpa / 3.0
321
- K_FACET = 0.2 * E_ratio
322
- K_FOLD = 0.7 * E_ratio
323
-
324
- asgn = paper.assignments[ei]
325
- if asgn in ("M", "V"):
326
- target = float(np.radians(paper.fold_angles[ei]))
327
- k = K_FOLD
328
- ctype = "fold"
329
- else:
330
- target = float(np.pi)
331
- k = K_FACET
332
- ctype = "facet"
333
-
334
- creases.append((n1, n2, n3, n4, target, k, ctype))
335
- return creases
336
-
337
-
338
- def _torque_to_forces(
339
- p1: np.ndarray, p2: np.ndarray,
340
- p3: np.ndarray, p4: np.ndarray,
341
- torque: float,
342
- ) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]:
343
- """Convert a dihedral torque into forces on the 4 hinge nodes.
344
-
345
- p1-p2 is the hinge edge. p3 and p4 are wing tips.
346
- Returns (f1, f2, f3, f4) as (3,) arrays.
347
- """
348
- e = p2 - p1
349
- e_len = np.linalg.norm(e)
350
- if e_len < 1e-12:
351
- zero = np.zeros(3)
352
- return zero, zero, zero, zero
353
-
354
- e_hat = e / e_len
355
-
356
- # Perpendicular components of wing vectors relative to hinge
357
- d3 = p3 - p1
358
- d4 = p4 - p1
359
- d3_perp = d3 - np.dot(d3, e_hat) * e_hat
360
- d4_perp = d4 - np.dot(d4, e_hat) * e_hat
361
-
362
- len3 = np.linalg.norm(d3_perp)
363
- len4 = np.linalg.norm(d4_perp)
364
-
365
- if len3 < 1e-12 or len4 < 1e-12:
366
- zero = np.zeros(3)
367
- return zero, zero, zero, zero
368
-
369
- # Force on wing tips proportional to torque / lever arm
370
- f3 = torque / (len3 * e_len) * np.cross(e_hat, d3_perp / len3)
371
- f4 = -torque / (len4 * e_len) * np.cross(e_hat, d4_perp / len4)
372
-
373
- # Reaction forces distributed to hinge nodes
374
- f1 = -(f3 + f4) * 0.5
375
- f2 = -(f3 + f4) * 0.5
376
-
377
- return f1, f2, f3, f4
378
-
379
-
380
- # ────────────────────────────────────────────────────────────────────
381
- # Verlet solver
382
- # ────────────────────────────────────────────────────────────────────
383
-
384
- def simulate(
385
- paper: Paper,
386
- fold_percent: float = 1.0,
387
- n_steps: int = 500,
388
- dt: float = 0.005,
389
- damping: float = 0.15,
390
- ) -> Paper:
391
- """Run bar-and-hinge Verlet integration to relax the mesh.
392
-
393
- Updates paper.vertices, paper.strain_per_vertex, and paper.energy in-place.
394
- Returns the mutated paper for chaining.
395
-
396
- Parameters
397
- ----------
398
- paper : Paper
399
- Paper state after a fold has been applied (vertices already rotated).
400
- fold_percent : float
401
- How far along the fold to drive (0=flat, 1=full target angle).
402
- n_steps : int
403
- Maximum integration steps.
404
- dt : float
405
- Time step. Keep small (0.005) for stability with stiff materials.
406
- damping : float
407
- Velocity damping coefficient (0=undamped, 1=fully damped).
408
- """
409
- if len(paper.vertices) == 0:
410
- return paper
411
-
412
- beams = build_beam_list(paper)
413
- creases = build_crease_list(paper)
414
-
415
- pos = paper.vertices.copy() # (N, 3) current positions
416
- last_pos = pos.copy() # (N, 3) previous positions (Verlet)
417
-
418
- max_force_cap = 1e6 # prevent runaway forces
419
-
420
- for _ in range(n_steps):
421
- forces = np.zeros_like(pos)
422
-
423
- # ── Beam (axial spring) forces ───────────────────────────────
424
- for (a, b, L0, k) in beams:
425
- delta = pos[b] - pos[a]
426
- L = np.linalg.norm(delta)
427
- if L < 1e-12:
428
- continue
429
- strain = (L - L0) / L0
430
- F_mag = k * strain
431
- F_vec = F_mag * (delta / L)
432
- # Clamp to prevent instability
433
- F_vec = np.clip(F_vec, -max_force_cap, max_force_cap)
434
- forces[a] += F_vec
435
- forces[b] -= F_vec
436
-
437
- # ── Crease (dihedral spring) forces ─────────────────────────
438
- for (n1, n2, n3, n4, target, k, ctype) in creases:
439
- actual_target = target * fold_percent if ctype == "fold" else target
440
- try:
441
- theta = _compute_dihedral_rad(pos[n1], pos[n2], pos[n3], pos[n4])
442
- except Exception:
443
- continue
444
- delta_theta = theta - actual_target
445
- edge_len = np.linalg.norm(pos[n2] - pos[n1])
446
- torque = k * edge_len * delta_theta
447
- torque = float(np.clip(torque, -max_force_cap, max_force_cap))
448
-
449
- f1, f2, f3, f4 = _torque_to_forces(
450
- pos[n1], pos[n2], pos[n3], pos[n4], torque
451
- )
452
- forces[n1] += np.clip(f1, -max_force_cap, max_force_cap)
453
- forces[n2] += np.clip(f2, -max_force_cap, max_force_cap)
454
- forces[n3] += np.clip(f3, -max_force_cap, max_force_cap)
455
- forces[n4] += np.clip(f4, -max_force_cap, max_force_cap)
456
-
457
- # ── Verlet integration ───────────────────────────────────────
458
- new_pos = pos + (1.0 - damping) * (pos - last_pos) + forces * (dt * dt)
459
-
460
- # NaN guard
461
- if np.any(np.isnan(new_pos)):
462
- break
463
-
464
- last_pos = pos
465
- pos = new_pos
466
-
467
- # ── Convergence check ────────────────────────────────────────
468
- kinetic = np.sum((pos - last_pos) ** 2)
469
- if kinetic < 1e-12:
470
- break
471
-
472
- # ── Write results back to paper ──────────────────────────────────
473
- paper.vertices = pos
474
- paper.strain_per_vertex = compute_strain(paper)
475
- paper.energy = {
476
- "total": compute_total_energy(paper),
477
- "bar": compute_bar_energy(paper),
478
- "facet": compute_facet_energy(paper),
479
- "fold": compute_fold_energy(paper),
480
- }
481
-
482
- return paper
483
-
484
-
485
- def _compute_dihedral_rad(
486
- p1: np.ndarray, p2: np.ndarray,
487
- p3: np.ndarray, p4: np.ndarray,
488
- ) -> float:
489
- """Dihedral angle in radians between planes (p1,p2,p3) and (p1,p2,p4).
490
-
491
- p1-p2 is the hinge edge. p3 and p4 are the wing tips.
492
- Returns angle in [0, 2*pi).
493
- """
494
- e = p2 - p1
495
- e_norm = np.linalg.norm(e)
496
- if e_norm < 1e-12:
497
- return float(np.pi)
498
- e_hat = e / e_norm
499
-
500
- n1 = np.cross(p3 - p1, e)
501
- n2 = np.cross(e, p4 - p1)
502
- len1 = np.linalg.norm(n1)
503
- len2 = np.linalg.norm(n2)
504
- if len1 < 1e-12 or len2 < 1e-12:
505
- return float(np.pi)
506
-
507
- n1 = n1 / len1
508
- n2 = n2 / len2
509
-
510
- cos_a = float(np.clip(np.dot(n1, n2), -1.0, 1.0))
511
- angle = np.arccos(cos_a)
512
-
513
- cross = np.cross(n1, n2)
514
- if np.dot(cross, e_hat) < 0:
515
- angle = 2.0 * np.pi - angle
516
-
517
- return float(angle)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
engine/validation.py DELETED
@@ -1,278 +0,0 @@
1
- """
2
- Geometric validation for origami crease patterns.
3
-
4
- Implements Kawasaki's theorem, Maekawa's theorem, and triangle-triangle
5
- self-intersection detection.
6
- """
7
-
8
- from __future__ import annotations
9
-
10
- from dataclasses import dataclass
11
-
12
- import numpy as np
13
-
14
- from .paper import Paper
15
-
16
-
17
- # ────────────────────────────────────────────────────────────────────
18
- # Result container
19
- # ────────────────────────────────────────────────────────────────────
20
-
21
- @dataclass
22
- class ValidationResult:
23
- kawasaki_valid: bool
24
- kawasaki_violation: float
25
- maekawa_valid: bool
26
- maekawa_violation: float
27
- intersection_free: bool
28
- self_intersection_count: int
29
- is_valid: bool # all checks pass
30
-
31
-
32
- # ────────────────────────────────────────────────────────────────────
33
- # Kawasaki's theorem
34
- # ────────────────────────────────────────────────────────────────────
35
-
36
- def check_kawasaki(paper: Paper) -> tuple[bool, float]:
37
- """At each interior vertex, the alternating sum of sector angles equals pi.
38
-
39
- Specifically, for a vertex with 2n incident creases, the sum of
40
- odd-indexed sector angles equals the sum of even-indexed sector
41
- angles equals pi.
42
-
43
- Returns (is_valid, total_violation). A violation of < 1e-6 is
44
- considered valid.
45
- """
46
- verts = paper.vertices
47
- edges = paper.edges
48
- n_verts = len(verts)
49
-
50
- # Build adjacency: vertex -> list of neighbor vertices (via edges)
51
- adj: dict[int, list[int]] = {}
52
- for e in edges:
53
- adj.setdefault(int(e[0]), []).append(int(e[1]))
54
- adj.setdefault(int(e[1]), []).append(int(e[0]))
55
-
56
- # Identify boundary vertices (incident to a 'B' edge)
57
- boundary_verts: set[int] = set()
58
- for ei, e in enumerate(edges):
59
- if paper.assignments[ei] == "B":
60
- boundary_verts.add(int(e[0]))
61
- boundary_verts.add(int(e[1]))
62
-
63
- total_violation = 0.0
64
-
65
- for vi in range(n_verts):
66
- if vi in boundary_verts:
67
- continue
68
- neighbors = adj.get(vi, [])
69
- if len(neighbors) < 2:
70
- continue
71
-
72
- # Sort neighbors by angle around vi (in the XY plane for flat-foldability)
73
- center = verts[vi][:2]
74
- angles = []
75
- for ni in neighbors:
76
- d = verts[ni][:2] - center
77
- angles.append((np.arctan2(d[1], d[0]), ni))
78
- angles.sort(key=lambda x: x[0])
79
-
80
- # Sector angles
81
- sector_angles = []
82
- for k in range(len(angles)):
83
- a1 = angles[k][0]
84
- a2 = angles[(k + 1) % len(angles)][0]
85
- diff = a2 - a1
86
- if diff <= 0:
87
- diff += 2.0 * np.pi
88
- sector_angles.append(diff)
89
-
90
- if len(sector_angles) < 2:
91
- continue
92
-
93
- # Kawasaki: alternating sums should both equal pi
94
- even_sum = sum(sector_angles[i] for i in range(0, len(sector_angles), 2))
95
- odd_sum = sum(sector_angles[i] for i in range(1, len(sector_angles), 2))
96
-
97
- violation = abs(even_sum - odd_sum)
98
- total_violation += violation
99
-
100
- is_valid = bool(total_violation < 1e-4)
101
- return is_valid, float(total_violation)
102
-
103
-
104
- # ────────────────────────────────────────────────────────────────────
105
- # Maekawa's theorem
106
- # ────────────────────────────────────────────────────────────────────
107
-
108
- def check_maekawa(paper: Paper) -> tuple[bool, float]:
109
- """At each interior vertex, |M - V| = 2.
110
-
111
- Returns (is_valid, total_violation) where violation is
112
- sum of |abs(M-V) - 2| over all interior vertices.
113
- """
114
- edges = paper.edges
115
- verts = paper.vertices
116
- n_verts = len(verts)
117
-
118
- # Boundary vertices
119
- boundary_verts: set[int] = set()
120
- for ei, e in enumerate(edges):
121
- if paper.assignments[ei] == "B":
122
- boundary_verts.add(int(e[0]))
123
- boundary_verts.add(int(e[1]))
124
-
125
- # Count M and V edges per vertex
126
- m_count = [0] * n_verts
127
- v_count = [0] * n_verts
128
- total_mv_per_vertex = [0] * n_verts
129
-
130
- for ei, e in enumerate(edges):
131
- a = paper.assignments[ei]
132
- if a == "M":
133
- m_count[int(e[0])] += 1
134
- m_count[int(e[1])] += 1
135
- elif a == "V":
136
- v_count[int(e[0])] += 1
137
- v_count[int(e[1])] += 1
138
- if a in ("M", "V"):
139
- total_mv_per_vertex[int(e[0])] += 1
140
- total_mv_per_vertex[int(e[1])] += 1
141
-
142
- total_violation = 0.0
143
- for vi in range(n_verts):
144
- if vi in boundary_verts:
145
- continue
146
- # Only check vertices that actually have creases
147
- if total_mv_per_vertex[vi] == 0:
148
- continue
149
- diff = abs(m_count[vi] - v_count[vi])
150
- violation = abs(diff - 2)
151
- total_violation += violation
152
-
153
- is_valid = total_violation < 0.5 # integer theorem, so < 0.5 means exact
154
- return is_valid, float(total_violation)
155
-
156
-
157
- # ────────────────────────────────────────────────────────────────────
158
- # Self-intersection detection (triangle-triangle)
159
- # ────────────────────────────────────────────────────────────────────
160
-
161
- def check_self_intersection(paper: Paper) -> tuple[bool, int]:
162
- """Check for triangle-triangle intersections among the paper's faces.
163
-
164
- Uses the separating-axis theorem (SAT) for triangle-triangle overlap
165
- in 3-D. Faces that share an edge or vertex are skipped.
166
-
167
- Returns (is_valid, count_of_intersections).
168
- """
169
- verts = paper.vertices
170
- faces = paper.faces
171
- count = 0
172
-
173
- for i in range(len(faces)):
174
- for j in range(i + 1, len(faces)):
175
- # Skip faces that share vertices (adjacent faces)
176
- if set(faces[i]) & set(faces[j]):
177
- continue
178
- if _triangles_intersect(verts, faces[i], faces[j]):
179
- count += 1
180
-
181
- return count == 0, count
182
-
183
-
184
- def _triangles_intersect(
185
- verts: np.ndarray,
186
- face1: list[int],
187
- face2: list[int],
188
- ) -> bool:
189
- """Test whether two triangular faces intersect in 3-D using
190
- the separating-axis theorem (Moller's method simplified).
191
-
192
- For non-triangular faces, only tests the first three vertices.
193
- Returns True if the triangles intersect.
194
- """
195
- if len(face1) < 3 or len(face2) < 3:
196
- return False
197
-
198
- t1 = verts[face1[:3]]
199
- t2 = verts[face2[:3]]
200
-
201
- # 13 potential separating axes:
202
- # - normals of each triangle (2)
203
- # - cross products of edge pairs (3x3 = 9)
204
- # - edges themselves don't need separate tests in 3D SAT
205
-
206
- e1_edges = [t1[1] - t1[0], t1[2] - t1[1], t1[0] - t1[2]]
207
- e2_edges = [t2[1] - t2[0], t2[2] - t2[1], t2[0] - t2[2]]
208
-
209
- n1 = np.cross(e1_edges[0], e1_edges[1])
210
- n2 = np.cross(e2_edges[0], e2_edges[1])
211
-
212
- axes = [n1, n2]
213
- for e1 in e1_edges:
214
- for e2 in e2_edges:
215
- ax = np.cross(e1, e2)
216
- if np.linalg.norm(ax) > 1e-12:
217
- axes.append(ax)
218
-
219
- for axis in axes:
220
- norm = np.linalg.norm(axis)
221
- if norm < 1e-12:
222
- continue
223
- axis = axis / norm
224
-
225
- proj1 = np.dot(t1, axis)
226
- proj2 = np.dot(t2, axis)
227
-
228
- min1, max1 = proj1.min(), proj1.max()
229
- min2, max2 = proj2.min(), proj2.max()
230
-
231
- # Check for separation (with small tolerance for shared-edge adjacency)
232
- if max1 < min2 - 1e-9 or max2 < min1 - 1e-9:
233
- return False # separating axis found
234
-
235
- return True # no separating axis → intersection
236
-
237
-
238
- # ────────────────────────────────────────────────────────────────────
239
- # Combined validation
240
- # ────────────────────────────────────────────────────────────────────
241
-
242
- def validate_paper(paper: Paper) -> ValidationResult:
243
- """Run all validation checks and return a combined result."""
244
- k_valid, k_violation = check_kawasaki(paper)
245
- m_valid, m_violation = check_maekawa(paper)
246
- si_valid, si_count = check_self_intersection(paper)
247
-
248
- return ValidationResult(
249
- kawasaki_valid=k_valid,
250
- kawasaki_violation=k_violation,
251
- maekawa_valid=m_valid,
252
- maekawa_violation=m_violation,
253
- intersection_free=si_valid,
254
- self_intersection_count=si_count,
255
- is_valid=k_valid and m_valid and si_valid,
256
- )
257
-
258
-
259
- def validate_state(paper: Paper) -> dict:
260
- """Run all validation checks and return a flat dict.
261
-
262
- This is the interface used by OrigamiEnvironment. It calls the
263
- existing validation functions and returns a dict with all fields
264
- the environment and metrics system need.
265
- """
266
- result = validate_paper(paper)
267
- strain_exceeded = bool(
268
- len(paper.strain_per_vertex) > 0
269
- and float(paper.strain_per_vertex.max()) > paper.material.max_strain
270
- )
271
- return {
272
- "is_valid": result.is_valid and not strain_exceeded,
273
- "kawasaki_violations": int(not result.kawasaki_valid),
274
- "kawasaki_total_error": float(result.kawasaki_violation),
275
- "maekawa_violations": int(not result.maekawa_valid),
276
- "self_intersections": result.self_intersection_count,
277
- "strain_exceeded": strain_exceeded,
278
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
planner/__init__.py DELETED
File without changes
planner/decomposer.py DELETED
@@ -1,284 +0,0 @@
1
- """
2
- Task decomposer: breaks a parsed instruction into sequential sub-goals
3
- with concrete fold operations on a unit square.
4
- """
5
-
6
- from __future__ import annotations
7
-
8
- import copy
9
- from planner.knowledge import (
10
- ORIGAMI_MODELS,
11
- ORIGAMI_BASES,
12
- FOLD_OPERATIONS,
13
- get_model_steps,
14
- get_base_steps,
15
- )
16
-
17
-
18
- # ---------------------------------------------------------------------------
19
- # Internal helpers
20
- # ---------------------------------------------------------------------------
21
-
22
- def _step_to_fold_operation(step: dict) -> dict:
23
- """
24
- Convert a knowledge-base step dict into the engine's fold operation format:
25
- {"type": ..., "line": {"start": [...], "end": [...]}, "angle": ...}
26
- """
27
- op = {
28
- "type": step["type"],
29
- "line": copy.deepcopy(step["line"]),
30
- "angle": step.get("angle", 180),
31
- }
32
- if "layer_select" in step:
33
- op["layer_select"] = step["layer_select"]
34
- return op
35
-
36
-
37
- def _expected_state_after_fold(fold_type: str, prev_state: dict | None) -> dict:
38
- """
39
- Produce a lightweight expected-state dict describing what the paper
40
- should look like after a fold. This is intentionally approximate --
41
- the real simulation engine computes exact geometry.
42
- """
43
- state = dict(prev_state or {"layers": 1, "shape": "square", "phase": "flat"})
44
- if fold_type in ("valley", "mountain"):
45
- state["layers"] = state.get("layers", 1) * 2
46
- elif fold_type == "petal":
47
- state["shape"] = "narrow_diamond"
48
- elif fold_type == "squash":
49
- state["shape"] = "diamond"
50
- elif fold_type == "reverse_inside":
51
- state["shape"] = "pointed_flap_reversed"
52
- elif fold_type == "inflate":
53
- state["phase"] = "3d"
54
- elif fold_type == "turn_over":
55
- state["flipped"] = not state.get("flipped", False)
56
- elif fold_type == "unfold":
57
- # Layers don't literally halve on every unfold, but this is a hint
58
- state["layers"] = max(1, state.get("layers", 1) // 2)
59
- return state
60
-
61
-
62
- def _validation_for_fold(fold_type: str) -> dict:
63
- """Return a simple validation dict for a step."""
64
- checks: dict = {"flat_foldable": True}
65
- if fold_type in ("valley", "mountain"):
66
- checks["kawasaki_check"] = True
67
- checks["maekawa_check"] = True
68
- if fold_type == "inflate":
69
- checks["is_3d"] = True
70
- checks["flat_foldable"] = False
71
- return checks
72
-
73
-
74
- # ---------------------------------------------------------------------------
75
- # Known-model decomposition
76
- # ---------------------------------------------------------------------------
77
-
78
- def _decompose_known_model(parsed: dict) -> list[dict]:
79
- """Decompose a known model into sub-goal steps."""
80
- model_name: str = parsed["model_name"]
81
- model_info = ORIGAMI_MODELS.get(model_name)
82
- if model_info is None:
83
- return _decompose_free_fold(parsed)
84
-
85
- base_name = model_info.get("base")
86
- steps = get_model_steps(model_name)
87
- sub_goals: list[dict] = []
88
- running_state: dict = {"layers": 1, "shape": "square", "phase": "flat"}
89
-
90
- for i, step in enumerate(steps):
91
- fold_op = _step_to_fold_operation(step)
92
- running_state = _expected_state_after_fold(step["type"], running_state)
93
-
94
- sub_goals.append({
95
- "step_number": i + 1,
96
- "description": step.get("description", f"Step {i + 1}"),
97
- "base_required": base_name if i == 0 else None,
98
- "fold_operations": [fold_op],
99
- "expected_state": dict(running_state),
100
- "validation": _validation_for_fold(step["type"]),
101
- })
102
-
103
- return sub_goals
104
-
105
-
106
- # ---------------------------------------------------------------------------
107
- # Packing / optimization decomposition
108
- # ---------------------------------------------------------------------------
109
-
110
- def _decompose_packing(parsed: dict) -> list[dict]:
111
- """
112
- Decompose an optimize_packing task into sub-goals.
113
- Returns a Miura-ori-style fold plan on a unit square.
114
- """
115
- w = parsed["dimensions"]["width"]
116
- h = parsed["dimensions"]["height"]
117
- material = parsed["material"]
118
- constraints = parsed.get("constraints", {})
119
- max_folds = constraints.get("max_folds", 20)
120
-
121
- sub_goals: list[dict] = []
122
- step_num = 0
123
-
124
- # Horizontal valley/mountain pleats (zigzag in Y)
125
- n_horizontal = min(4, max_folds // 4)
126
- spacing_y = 1.0 / (n_horizontal + 1)
127
- for i in range(n_horizontal):
128
- step_num += 1
129
- y = spacing_y * (i + 1)
130
- fold_type = "valley" if i % 2 == 0 else "mountain"
131
- sub_goals.append({
132
- "step_number": step_num,
133
- "description": f"Horizontal {fold_type} fold at y={y:.3f} (pleat {i + 1}/{n_horizontal})",
134
- "base_required": None,
135
- "fold_operations": [{
136
- "type": fold_type,
137
- "line": {"start": [0.0, y], "end": [1.0, y]},
138
- "angle": 180,
139
- "layer_select": "all",
140
- }],
141
- "expected_state": {"layers": i + 2, "phase": "flat", "pattern": "miura_horizontal"},
142
- "validation": {"flat_foldable": True, "kawasaki_check": True},
143
- })
144
-
145
- # Vertical zigzag valley/mountain pleats (Miura-ori angle offsets)
146
- n_vertical = min(4, (max_folds - n_horizontal) // 2)
147
- spacing_x = 1.0 / (n_vertical + 1)
148
- for i in range(n_vertical):
149
- step_num += 1
150
- x = spacing_x * (i + 1)
151
- fold_type = "valley" if i % 2 == 0 else "mountain"
152
- # Miura-ori: alternate slight angle offset to create parallelogram cells
153
- angle_offset = 0.02 * (1 if i % 2 == 0 else -1)
154
- sub_goals.append({
155
- "step_number": step_num,
156
- "description": f"Vertical {fold_type} fold at x={x:.3f} (Miura-ori column {i + 1}/{n_vertical})",
157
- "base_required": None,
158
- "fold_operations": [{
159
- "type": fold_type,
160
- "line": {"start": [x, 0.0 + angle_offset], "end": [x, 1.0 - angle_offset]},
161
- "angle": 180,
162
- "layer_select": "all",
163
- }],
164
- "expected_state": {
165
- "layers": (n_horizontal + 1) * (i + 2),
166
- "phase": "flat",
167
- "pattern": "miura_complete" if i == n_vertical - 1 else "miura_partial",
168
- },
169
- "validation": {"flat_foldable": True, "kawasaki_check": True, "maekawa_check": True},
170
- })
171
-
172
- # Final collapse
173
- step_num += 1
174
- sub_goals.append({
175
- "step_number": step_num,
176
- "description": "Collapse all creases simultaneously into compact Miura-ori stack",
177
- "base_required": None,
178
- "fold_operations": [{
179
- "type": "valley",
180
- "line": {"start": [0.0, 0.5], "end": [1.0, 0.5]},
181
- "angle": 180,
182
- "layer_select": "all",
183
- }],
184
- "expected_state": {
185
- "layers": (n_horizontal + 1) * (n_vertical + 1),
186
- "phase": "compact",
187
- "pattern": "miura_ori",
188
- },
189
- "validation": {
190
- "flat_foldable": True,
191
- "check_bounding_box": constraints.get("target_box"),
192
- "check_deployable": constraints.get("must_deploy", False),
193
- },
194
- })
195
-
196
- return sub_goals
197
-
198
-
199
- # ---------------------------------------------------------------------------
200
- # Free-fold / unknown model decomposition
201
- # ---------------------------------------------------------------------------
202
-
203
- def _decompose_free_fold(parsed: dict) -> list[dict]:
204
- """
205
- Generic decomposition for an unknown model or free-form folding task.
206
- Returns a minimal plan that an LLM can expand upon.
207
- """
208
- return [
209
- {
210
- "step_number": 1,
211
- "description": "Create reference creases (diagonals and midlines)",
212
- "base_required": None,
213
- "fold_operations": [
214
- {"type": "valley", "line": {"start": [0.0, 0.0], "end": [1.0, 1.0]}, "angle": 180},
215
- {"type": "unfold", "line": {"start": [0.0, 0.0], "end": [1.0, 1.0]}, "angle": 0},
216
- {"type": "valley", "line": {"start": [1.0, 0.0], "end": [0.0, 1.0]}, "angle": 180},
217
- {"type": "unfold", "line": {"start": [1.0, 0.0], "end": [0.0, 1.0]}, "angle": 0},
218
- {"type": "valley", "line": {"start": [0.0, 0.5], "end": [1.0, 0.5]}, "angle": 180},
219
- {"type": "unfold", "line": {"start": [0.0, 0.5], "end": [1.0, 0.5]}, "angle": 0},
220
- ],
221
- "expected_state": {"layers": 1, "shape": "square", "phase": "creased"},
222
- "validation": {"flat_foldable": True},
223
- },
224
- {
225
- "step_number": 2,
226
- "description": "Collapse into a base form using reference creases",
227
- "base_required": "preliminary_base",
228
- "fold_operations": [
229
- {"type": "valley", "line": {"start": [0.0, 0.0], "end": [1.0, 1.0]}, "angle": 180, "layer_select": "all"},
230
- ],
231
- "expected_state": {"layers": 4, "shape": "diamond", "phase": "base"},
232
- "validation": {"flat_foldable": True},
233
- },
234
- {
235
- "step_number": 3,
236
- "description": "Shape the model with additional folds (LLM determines specifics)",
237
- "base_required": None,
238
- "fold_operations": [], # Left empty for LLM to fill
239
- "expected_state": {"phase": "shaped"},
240
- "validation": {"flat_foldable": True},
241
- },
242
- ]
243
-
244
-
245
- # ---------------------------------------------------------------------------
246
- # Fold-pattern decomposition
247
- # ---------------------------------------------------------------------------
248
-
249
- def _decompose_pattern(parsed: dict) -> list[dict]:
250
- """Decompose a tessellation/pattern task."""
251
- # For now, delegate to packing which generates a Miura-ori pattern
252
- return _decompose_packing(parsed)
253
-
254
-
255
- # ---------------------------------------------------------------------------
256
- # Public API
257
- # ---------------------------------------------------------------------------
258
-
259
- def decompose_task(parsed: dict) -> list[dict]:
260
- """
261
- Decompose a parsed instruction into sequential sub-goals.
262
-
263
- Args:
264
- parsed: Output of parse_instruction()
265
-
266
- Returns:
267
- List of sub-goal dicts, each with:
268
- - step_number: int
269
- - description: str
270
- - base_required: str or None
271
- - fold_operations: list[dict] (engine-format fold dicts)
272
- - expected_state: dict
273
- - validation: dict
274
- """
275
- intent = parsed.get("intent", "free_fold")
276
-
277
- if intent == "fold_model" and parsed.get("model_name"):
278
- return _decompose_known_model(parsed)
279
- elif intent == "optimize_packing":
280
- return _decompose_packing(parsed)
281
- elif intent == "fold_pattern":
282
- return _decompose_pattern(parsed)
283
- else:
284
- return _decompose_free_fold(parsed)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
planner/knowledge.py DELETED
@@ -1,753 +0,0 @@
1
- """
2
- Origami knowledge base: bases, models, and fold operation primitives.
3
-
4
- All fold coordinates are defined on a unit square (0,0) to (1,1).
5
- Fold lines use {"start": [x, y], "end": [x, y]} format matching the
6
- engine's FoldAction specification from architecture.md.
7
- """
8
-
9
- # ---------------------------------------------------------------------------
10
- # Primitive fold operations — mapping from named folds to engine-level dicts
11
- # ---------------------------------------------------------------------------
12
-
13
- FOLD_OPERATIONS = {
14
- "valley_fold": {
15
- "type": "valley",
16
- "angle": 180,
17
- "description": "Fold paper toward you along a crease line.",
18
- },
19
- "mountain_fold": {
20
- "type": "mountain",
21
- "angle": 180,
22
- "description": "Fold paper away from you along a crease line.",
23
- },
24
- "squash_fold": {
25
- "type": "squash",
26
- "angle": 180,
27
- "description": "Open a flap and flatten it symmetrically.",
28
- "primitives": [
29
- {"type": "valley", "angle": 90, "sub_op": "open_flap"},
30
- {"type": "valley", "angle": 180, "sub_op": "flatten"},
31
- ],
32
- },
33
- "petal_fold": {
34
- "type": "petal",
35
- "angle": 180,
36
- "description": "Lift a point while collapsing sides inward to create a narrow flap.",
37
- "primitives": [
38
- {"type": "valley", "angle": 180, "sub_op": "fold_left_edge_to_center"},
39
- {"type": "valley", "angle": 180, "sub_op": "fold_right_edge_to_center"},
40
- {"type": "valley", "angle": 180, "sub_op": "lift_bottom_point"},
41
- {"type": "mountain", "angle": 180, "sub_op": "collapse_left"},
42
- {"type": "mountain", "angle": 180, "sub_op": "collapse_right"},
43
- ],
44
- },
45
- "reverse_inside_fold": {
46
- "type": "reverse_inside",
47
- "angle": 180,
48
- "description": "Push a flap tip inward, reversing the spine crease.",
49
- "primitives": [
50
- {"type": "valley", "angle": 180, "sub_op": "new_crease_left"},
51
- {"type": "valley", "angle": 180, "sub_op": "new_crease_right"},
52
- {"type": "mountain", "angle": 180, "sub_op": "reverse_spine"},
53
- ],
54
- },
55
- "reverse_outside_fold": {
56
- "type": "reverse_outside",
57
- "angle": 180,
58
- "description": "Wrap a flap tip around the outside, reversing the spine crease.",
59
- "primitives": [
60
- {"type": "mountain", "angle": 180, "sub_op": "new_crease_left"},
61
- {"type": "mountain", "angle": 180, "sub_op": "new_crease_right"},
62
- {"type": "valley", "angle": 180, "sub_op": "reverse_spine"},
63
- ],
64
- },
65
- "crimp": {
66
- "type": "crimp",
67
- "angle": 180,
68
- "description": "Pair of reverse folds creating a zigzag step.",
69
- "primitives": [
70
- {"type": "valley", "angle": 180, "sub_op": "first_crease"},
71
- {"type": "mountain", "angle": 180, "sub_op": "second_crease"},
72
- ],
73
- },
74
- "pleat": {
75
- "type": "pleat",
76
- "angle": 180,
77
- "description": "Alternating valley and mountain folds creating an accordion.",
78
- "primitives": [
79
- {"type": "valley", "angle": 180, "sub_op": "valley_crease"},
80
- {"type": "mountain", "angle": 180, "sub_op": "mountain_crease"},
81
- ],
82
- },
83
- "rabbit_ear": {
84
- "type": "rabbit_ear",
85
- "angle": 180,
86
- "description": "Three creases meeting at a point, creating a triangular raised flap.",
87
- "primitives": [
88
- {"type": "valley", "angle": 180, "sub_op": "bisector_1"},
89
- {"type": "valley", "angle": 180, "sub_op": "bisector_2"},
90
- {"type": "mountain", "angle": 180, "sub_op": "ridge"},
91
- ],
92
- },
93
- "sink_fold": {
94
- "type": "sink",
95
- "angle": 180,
96
- "description": "Push a point into the interior of the model.",
97
- "primitives": [
98
- {"type": "mountain", "angle": 180, "sub_op": "reverse_creases"},
99
- {"type": "valley", "angle": 180, "sub_op": "reflatten"},
100
- ],
101
- },
102
- "turn_over": {
103
- "type": "turn_over",
104
- "angle": 0,
105
- "description": "Flip the paper over.",
106
- },
107
- "unfold": {
108
- "type": "unfold",
109
- "angle": 0,
110
- "description": "Reverse a previous fold (crease remains).",
111
- },
112
- "inflate": {
113
- "type": "inflate",
114
- "angle": 0,
115
- "description": "Gently open and puff out the model to create 3D form.",
116
- },
117
- }
118
-
119
-
120
- # ---------------------------------------------------------------------------
121
- # Origami bases — fundamental starting configurations
122
- # ---------------------------------------------------------------------------
123
-
124
- ORIGAMI_BASES = {
125
- "preliminary_base": {
126
- "name": "Preliminary Base (Square Base)",
127
- "description": "Multi-layered diamond standing on a corner. Gateway to bird and frog bases.",
128
- "total_steps": 9,
129
- "steps": [
130
- {
131
- "description": "Fold in half diagonally (bottom-left to top-right)",
132
- "type": "valley",
133
- "line": {"start": [0.0, 0.0], "end": [1.0, 1.0]},
134
- "angle": 180,
135
- "layer_select": "all",
136
- },
137
- {
138
- "description": "Unfold",
139
- "type": "unfold",
140
- "line": {"start": [0.0, 0.0], "end": [1.0, 1.0]},
141
- "angle": 0,
142
- "layer_select": "all",
143
- },
144
- {
145
- "description": "Fold in half on other diagonal (bottom-right to top-left)",
146
- "type": "valley",
147
- "line": {"start": [1.0, 0.0], "end": [0.0, 1.0]},
148
- "angle": 180,
149
- "layer_select": "all",
150
- },
151
- {
152
- "description": "Unfold",
153
- "type": "unfold",
154
- "line": {"start": [1.0, 0.0], "end": [0.0, 1.0]},
155
- "angle": 0,
156
- "layer_select": "all",
157
- },
158
- {
159
- "description": "Fold in half horizontally",
160
- "type": "valley",
161
- "line": {"start": [0.0, 0.5], "end": [1.0, 0.5]},
162
- "angle": 180,
163
- "layer_select": "all",
164
- },
165
- {
166
- "description": "Unfold",
167
- "type": "unfold",
168
- "line": {"start": [0.0, 0.5], "end": [1.0, 0.5]},
169
- "angle": 0,
170
- "layer_select": "all",
171
- },
172
- {
173
- "description": "Fold in half vertically",
174
- "type": "valley",
175
- "line": {"start": [0.5, 0.0], "end": [0.5, 1.0]},
176
- "angle": 180,
177
- "layer_select": "all",
178
- },
179
- {
180
- "description": "Unfold",
181
- "type": "unfold",
182
- "line": {"start": [0.5, 0.0], "end": [0.5, 1.0]},
183
- "angle": 0,
184
- "layer_select": "all",
185
- },
186
- {
187
- "description": "Collapse into preliminary base: push sides in using existing creases",
188
- "type": "valley",
189
- "line": {"start": [0.0, 0.0], "end": [1.0, 1.0]},
190
- "angle": 180,
191
- "layer_select": "all",
192
- "simultaneous": [
193
- {"type": "valley", "line": {"start": [1.0, 0.0], "end": [0.0, 1.0]}, "angle": 180},
194
- {"type": "mountain", "line": {"start": [0.0, 0.5], "end": [1.0, 0.5]}, "angle": 180},
195
- {"type": "mountain", "line": {"start": [0.5, 0.0], "end": [0.5, 1.0]}, "angle": 180},
196
- ],
197
- },
198
- ],
199
- },
200
-
201
- "waterbomb_base": {
202
- "name": "Waterbomb Base",
203
- "description": "Flat triangle with multiple layers. Inverse of preliminary base.",
204
- "total_steps": 9,
205
- "steps": [
206
- {
207
- "description": "Fold both diagonals — first diagonal",
208
- "type": "valley",
209
- "line": {"start": [0.0, 0.0], "end": [1.0, 1.0]},
210
- "angle": 180,
211
- "layer_select": "all",
212
- },
213
- {
214
- "description": "Unfold first diagonal",
215
- "type": "unfold",
216
- "line": {"start": [0.0, 0.0], "end": [1.0, 1.0]},
217
- "angle": 0,
218
- "layer_select": "all",
219
- },
220
- {
221
- "description": "Fold second diagonal",
222
- "type": "valley",
223
- "line": {"start": [1.0, 0.0], "end": [0.0, 1.0]},
224
- "angle": 180,
225
- "layer_select": "all",
226
- },
227
- {
228
- "description": "Unfold second diagonal",
229
- "type": "unfold",
230
- "line": {"start": [1.0, 0.0], "end": [0.0, 1.0]},
231
- "angle": 0,
232
- "layer_select": "all",
233
- },
234
- {
235
- "description": "Mountain fold horizontally",
236
- "type": "mountain",
237
- "line": {"start": [0.0, 0.5], "end": [1.0, 0.5]},
238
- "angle": 180,
239
- "layer_select": "all",
240
- },
241
- {
242
- "description": "Unfold horizontal",
243
- "type": "unfold",
244
- "line": {"start": [0.0, 0.5], "end": [1.0, 0.5]},
245
- "angle": 0,
246
- "layer_select": "all",
247
- },
248
- {
249
- "description": "Mountain fold vertically",
250
- "type": "mountain",
251
- "line": {"start": [0.5, 0.0], "end": [0.5, 1.0]},
252
- "angle": 180,
253
- "layer_select": "all",
254
- },
255
- {
256
- "description": "Unfold vertical",
257
- "type": "unfold",
258
- "line": {"start": [0.5, 0.0], "end": [0.5, 1.0]},
259
- "angle": 0,
260
- "layer_select": "all",
261
- },
262
- {
263
- "description": "Collapse into waterbomb base: fold top edge down, push sides in",
264
- "type": "mountain",
265
- "line": {"start": [0.0, 0.5], "end": [1.0, 0.5]},
266
- "angle": 180,
267
- "layer_select": "all",
268
- "simultaneous": [
269
- {"type": "valley", "line": {"start": [0.0, 0.0], "end": [1.0, 1.0]}, "angle": 180},
270
- {"type": "valley", "line": {"start": [1.0, 0.0], "end": [0.0, 1.0]}, "angle": 180},
271
- ],
272
- },
273
- ],
274
- },
275
-
276
- "bird_base": {
277
- "name": "Bird Base (Crane Base)",
278
- "description": "Long diamond with 4 narrow flaps. Built from preliminary base + 2 petal folds.",
279
- "requires_base": "preliminary_base",
280
- "total_steps": 13,
281
- "steps": [
282
- # Steps 1-9 are the preliminary base (included by reference)
283
- # Steps 10-22 from the crane sequence build the bird base
284
- {
285
- "description": "Fold left edge of top layer to center line",
286
- "type": "valley",
287
- "line": {"start": [0.25, 0.5], "end": [0.5, 1.0]},
288
- "angle": 180,
289
- "layer_select": "top",
290
- },
291
- {
292
- "description": "Fold right edge of top layer to center line",
293
- "type": "valley",
294
- "line": {"start": [0.75, 0.5], "end": [0.5, 1.0]},
295
- "angle": 180,
296
- "layer_select": "top",
297
- },
298
- {
299
- "description": "Fold top triangle down over kite flaps (crease only)",
300
- "type": "valley",
301
- "line": {"start": [0.25, 0.75], "end": [0.75, 0.75]},
302
- "angle": 180,
303
- "layer_select": "top",
304
- },
305
- {
306
- "description": "Unfold top triangle",
307
- "type": "unfold",
308
- "line": {"start": [0.25, 0.75], "end": [0.75, 0.75]},
309
- "angle": 0,
310
- "layer_select": "top",
311
- },
312
- {
313
- "description": "Unfold kite folds",
314
- "type": "unfold",
315
- "line": {"start": [0.25, 0.5], "end": [0.5, 1.0]},
316
- "angle": 0,
317
- "layer_select": "top",
318
- },
319
- {
320
- "description": "Petal fold front: lift bottom point up, sides collapse inward",
321
- "type": "petal",
322
- "line": {"start": [0.5, 0.5], "end": [0.5, 1.0]},
323
- "angle": 180,
324
- "layer_select": "top",
325
- },
326
- {
327
- "description": "Turn model over",
328
- "type": "turn_over",
329
- "line": {"start": [0.5, 0.0], "end": [0.5, 1.0]},
330
- "angle": 0,
331
- "layer_select": "all",
332
- },
333
- {
334
- "description": "Fold left edge to center line (back)",
335
- "type": "valley",
336
- "line": {"start": [0.25, 0.5], "end": [0.5, 1.0]},
337
- "angle": 180,
338
- "layer_select": "top",
339
- },
340
- {
341
- "description": "Fold right edge to center line (back)",
342
- "type": "valley",
343
- "line": {"start": [0.75, 0.5], "end": [0.5, 1.0]},
344
- "angle": 180,
345
- "layer_select": "top",
346
- },
347
- {
348
- "description": "Fold top triangle down (crease only, back)",
349
- "type": "valley",
350
- "line": {"start": [0.25, 0.75], "end": [0.75, 0.75]},
351
- "angle": 180,
352
- "layer_select": "top",
353
- },
354
- {
355
- "description": "Unfold top triangle (back)",
356
- "type": "unfold",
357
- "line": {"start": [0.25, 0.75], "end": [0.75, 0.75]},
358
- "angle": 0,
359
- "layer_select": "top",
360
- },
361
- {
362
- "description": "Unfold kite folds (back)",
363
- "type": "unfold",
364
- "line": {"start": [0.25, 0.5], "end": [0.5, 1.0]},
365
- "angle": 0,
366
- "layer_select": "top",
367
- },
368
- {
369
- "description": "Petal fold back: lift bottom point up, sides collapse inward",
370
- "type": "petal",
371
- "line": {"start": [0.5, 0.5], "end": [0.5, 1.0]},
372
- "angle": 180,
373
- "layer_select": "top",
374
- },
375
- ],
376
- },
377
-
378
- "frog_base": {
379
- "name": "Frog Base",
380
- "description": "4 long narrow flaps radiating from center. Built from preliminary base + 4 squash + 4 petal folds.",
381
- "requires_base": "preliminary_base",
382
- "total_steps": 8,
383
- "steps": [
384
- {
385
- "description": "Squash fold front-left flap",
386
- "type": "squash",
387
- "line": {"start": [0.25, 0.5], "end": [0.5, 0.75]},
388
- "angle": 180,
389
- "layer_select": "top",
390
- },
391
- {
392
- "description": "Squash fold front-right flap",
393
- "type": "squash",
394
- "line": {"start": [0.75, 0.5], "end": [0.5, 0.75]},
395
- "angle": 180,
396
- "layer_select": "top",
397
- },
398
- {
399
- "description": "Squash fold back-left flap",
400
- "type": "squash",
401
- "line": {"start": [0.25, 0.5], "end": [0.5, 0.75]},
402
- "angle": 180,
403
- "layer_select": "top",
404
- },
405
- {
406
- "description": "Squash fold back-right flap",
407
- "type": "squash",
408
- "line": {"start": [0.75, 0.5], "end": [0.5, 0.75]},
409
- "angle": 180,
410
- "layer_select": "top",
411
- },
412
- {
413
- "description": "Petal fold first diamond",
414
- "type": "petal",
415
- "line": {"start": [0.5, 0.5], "end": [0.5, 1.0]},
416
- "angle": 180,
417
- "layer_select": "top",
418
- },
419
- {
420
- "description": "Petal fold second diamond",
421
- "type": "petal",
422
- "line": {"start": [0.5, 0.5], "end": [0.5, 1.0]},
423
- "angle": 180,
424
- "layer_select": "top",
425
- },
426
- {
427
- "description": "Petal fold third diamond",
428
- "type": "petal",
429
- "line": {"start": [0.5, 0.5], "end": [0.5, 1.0]},
430
- "angle": 180,
431
- "layer_select": "top",
432
- },
433
- {
434
- "description": "Petal fold fourth diamond",
435
- "type": "petal",
436
- "line": {"start": [0.5, 0.5], "end": [0.5, 1.0]},
437
- "angle": 180,
438
- "layer_select": "top",
439
- },
440
- ],
441
- },
442
-
443
- "fish_base": {
444
- "name": "Fish Base",
445
- "description": "Diamond shape with 4 points. Built from kite folds + rabbit ears.",
446
- "total_steps": 7,
447
- "steps": [
448
- {
449
- "description": "Fold diagonal crease (reference line)",
450
- "type": "valley",
451
- "line": {"start": [0.0, 0.0], "end": [1.0, 1.0]},
452
- "angle": 180,
453
- "layer_select": "all",
454
- },
455
- {
456
- "description": "Unfold diagonal",
457
- "type": "unfold",
458
- "line": {"start": [0.0, 0.0], "end": [1.0, 1.0]},
459
- "angle": 0,
460
- "layer_select": "all",
461
- },
462
- {
463
- "description": "Kite fold: fold bottom-left edge to diagonal",
464
- "type": "valley",
465
- "line": {"start": [0.0, 0.0], "end": [0.5, 0.5]},
466
- "angle": 180,
467
- "layer_select": "all",
468
- },
469
- {
470
- "description": "Kite fold: fold top-left edge to diagonal",
471
- "type": "valley",
472
- "line": {"start": [0.0, 1.0], "end": [0.5, 0.5]},
473
- "angle": 180,
474
- "layer_select": "all",
475
- },
476
- {
477
- "description": "Rabbit ear fold on bottom-right corner",
478
- "type": "rabbit_ear",
479
- "line": {"start": [0.5, 0.0], "end": [1.0, 0.5]},
480
- "angle": 180,
481
- "layer_select": "all",
482
- },
483
- {
484
- "description": "Rabbit ear fold on top-right corner",
485
- "type": "rabbit_ear",
486
- "line": {"start": [0.5, 1.0], "end": [1.0, 0.5]},
487
- "angle": 180,
488
- "layer_select": "all",
489
- },
490
- {
491
- "description": "Fold resulting flaps down flat",
492
- "type": "valley",
493
- "line": {"start": [0.5, 0.5], "end": [1.0, 0.5]},
494
- "angle": 180,
495
- "layer_select": "top",
496
- },
497
- ],
498
- },
499
-
500
- "kite_base": {
501
- "name": "Kite Base",
502
- "description": "Simplest base: a kite shape from two folds to a diagonal.",
503
- "total_steps": 3,
504
- "steps": [
505
- {
506
- "description": "Fold diagonal (reference crease)",
507
- "type": "valley",
508
- "line": {"start": [0.0, 0.0], "end": [1.0, 1.0]},
509
- "angle": 180,
510
- "layer_select": "all",
511
- },
512
- {
513
- "description": "Unfold diagonal",
514
- "type": "unfold",
515
- "line": {"start": [0.0, 0.0], "end": [1.0, 1.0]},
516
- "angle": 0,
517
- "layer_select": "all",
518
- },
519
- {
520
- "description": "Fold bottom-left and top-left edges to lie on diagonal",
521
- "type": "valley",
522
- "line": {"start": [0.0, 0.0], "end": [0.5, 0.5]},
523
- "angle": 180,
524
- "layer_select": "all",
525
- },
526
- ],
527
- },
528
- }
529
-
530
-
531
- # ---------------------------------------------------------------------------
532
- # Complete origami models — full fold sequences from flat square to finished
533
- # ---------------------------------------------------------------------------
534
-
535
- ORIGAMI_MODELS = {
536
- "crane": {
537
- "name": "Paper Crane (Tsuru)",
538
- "difficulty": "intermediate",
539
- "base": "bird_base",
540
- "total_steps": 31,
541
- "description": "The traditional Japanese crane. 31 steps from flat square.",
542
- "steps": [
543
- # Phase 1: Pre-crease (steps 1-8)
544
- {"step": 1, "description": "Fold square in half diagonally (bottom-left to top-right)", "type": "valley", "line": {"start": [0.0, 0.0], "end": [1.0, 1.0]}, "angle": 180, "layer_select": "all"},
545
- {"step": 2, "description": "Unfold", "type": "unfold", "line": {"start": [0.0, 0.0], "end": [1.0, 1.0]}, "angle": 0, "layer_select": "all"},
546
- {"step": 3, "description": "Fold in half on other diagonal", "type": "valley", "line": {"start": [1.0, 0.0], "end": [0.0, 1.0]}, "angle": 180, "layer_select": "all"},
547
- {"step": 4, "description": "Unfold", "type": "unfold", "line": {"start": [1.0, 0.0], "end": [0.0, 1.0]}, "angle": 0, "layer_select": "all"},
548
- {"step": 5, "description": "Fold in half horizontally", "type": "valley", "line": {"start": [0.0, 0.5], "end": [1.0, 0.5]}, "angle": 180, "layer_select": "all"},
549
- {"step": 6, "description": "Unfold", "type": "unfold", "line": {"start": [0.0, 0.5], "end": [1.0, 0.5]}, "angle": 0, "layer_select": "all"},
550
- {"step": 7, "description": "Fold in half vertically", "type": "valley", "line": {"start": [0.5, 0.0], "end": [0.5, 1.0]}, "angle": 180, "layer_select": "all"},
551
- {"step": 8, "description": "Unfold", "type": "unfold", "line": {"start": [0.5, 0.0], "end": [0.5, 1.0]}, "angle": 0, "layer_select": "all"},
552
-
553
- # Phase 2: Collapse into preliminary base (step 9)
554
- {"step": 9, "description": "Collapse into preliminary base: push left and right edges inward, fold top down", "type": "valley", "line": {"start": [0.0, 0.0], "end": [1.0, 1.0]}, "angle": 180, "layer_select": "all"},
555
-
556
- # Phase 3: Front kite folds (steps 10-14)
557
- {"step": 10, "description": "Fold left edge of top layer to center line", "type": "valley", "line": {"start": [0.25, 0.5], "end": [0.5, 1.0]}, "angle": 180, "layer_select": "top"},
558
- {"step": 11, "description": "Fold right edge of top layer to center line", "type": "valley", "line": {"start": [0.75, 0.5], "end": [0.5, 1.0]}, "angle": 180, "layer_select": "top"},
559
- {"step": 12, "description": "Fold top triangle down over kite flaps", "type": "valley", "line": {"start": [0.25, 0.75], "end": [0.75, 0.75]}, "angle": 180, "layer_select": "top"},
560
- {"step": 13, "description": "Unfold step 12", "type": "unfold", "line": {"start": [0.25, 0.75], "end": [0.75, 0.75]}, "angle": 0, "layer_select": "top"},
561
- {"step": 14, "description": "Unfold steps 10-11", "type": "unfold", "line": {"start": [0.25, 0.5], "end": [0.5, 1.0]}, "angle": 0, "layer_select": "top"},
562
-
563
- # Phase 4: Front petal fold (step 15)
564
- {"step": 15, "description": "Petal fold: lift bottom point of top layer upward, sides collapse inward", "type": "petal", "line": {"start": [0.5, 0.5], "end": [0.5, 1.0]}, "angle": 180, "layer_select": "top"},
565
-
566
- # Phase 5: Repeat on back (steps 16-22)
567
- {"step": 16, "description": "Turn model over", "type": "turn_over", "line": {"start": [0.5, 0.0], "end": [0.5, 1.0]}, "angle": 0, "layer_select": "all"},
568
- {"step": 17, "description": "Fold left edge to center line", "type": "valley", "line": {"start": [0.25, 0.5], "end": [0.5, 1.0]}, "angle": 180, "layer_select": "top"},
569
- {"step": 18, "description": "Fold right edge to center line", "type": "valley", "line": {"start": [0.75, 0.5], "end": [0.5, 1.0]}, "angle": 180, "layer_select": "top"},
570
- {"step": 19, "description": "Fold top triangle down", "type": "valley", "line": {"start": [0.25, 0.75], "end": [0.75, 0.75]}, "angle": 180, "layer_select": "top"},
571
- {"step": 20, "description": "Unfold step 19", "type": "unfold", "line": {"start": [0.25, 0.75], "end": [0.75, 0.75]}, "angle": 0, "layer_select": "top"},
572
- {"step": 21, "description": "Unfold steps 17-18", "type": "unfold", "line": {"start": [0.25, 0.5], "end": [0.5, 1.0]}, "angle": 0, "layer_select": "top"},
573
- {"step": 22, "description": "Petal fold back: lift bottom point up, collapse sides in. Bird base complete.", "type": "petal", "line": {"start": [0.5, 0.5], "end": [0.5, 1.0]}, "angle": 180, "layer_select": "top"},
574
-
575
- # Phase 6: Narrow the legs (steps 23-27)
576
- {"step": 23, "description": "Fold left flap (front) edge to center", "type": "valley", "line": {"start": [0.375, 0.5], "end": [0.5, 1.0]}, "angle": 180, "layer_select": "top"},
577
- {"step": 24, "description": "Fold right flap (front) edge to center", "type": "valley", "line": {"start": [0.625, 0.5], "end": [0.5, 1.0]}, "angle": 180, "layer_select": "top"},
578
- {"step": 25, "description": "Turn over", "type": "turn_over", "line": {"start": [0.5, 0.0], "end": [0.5, 1.0]}, "angle": 0, "layer_select": "all"},
579
- {"step": 26, "description": "Fold left flap (back) edge to center", "type": "valley", "line": {"start": [0.375, 0.5], "end": [0.5, 1.0]}, "angle": 180, "layer_select": "top"},
580
- {"step": 27, "description": "Fold right flap (back) edge to center", "type": "valley", "line": {"start": [0.625, 0.5], "end": [0.5, 1.0]}, "angle": 180, "layer_select": "top"},
581
-
582
- # Phase 7: Form neck and tail (steps 28-29)
583
- {"step": 28, "description": "Inside reverse fold left flap upward to form neck", "type": "reverse_inside", "line": {"start": [0.35, 0.6], "end": [0.45, 0.85]}, "angle": 150, "layer_select": "all"},
584
- {"step": 29, "description": "Inside reverse fold right flap upward to form tail", "type": "reverse_inside", "line": {"start": [0.55, 0.6], "end": [0.65, 0.85]}, "angle": 150, "layer_select": "all"},
585
-
586
- # Phase 8: Head and finish (steps 30-31)
587
- {"step": 30, "description": "Inside reverse fold tip of neck downward to form head/beak", "type": "reverse_inside", "line": {"start": [0.38, 0.82], "end": [0.42, 0.9]}, "angle": 150, "layer_select": "all"},
588
- {"step": 31, "description": "Pull wings apart gently and press bottom to inflate body", "type": "inflate", "line": {"start": [0.5, 0.5], "end": [0.5, 0.7]}, "angle": 0, "layer_select": "all"},
589
- ],
590
- },
591
-
592
- "boat": {
593
- "name": "Simple Boat",
594
- "difficulty": "simple",
595
- "base": None,
596
- "total_steps": 9,
597
- "description": "A flat boat/hat from simple valley and mountain folds.",
598
- "steps": [
599
- {"step": 1, "description": "Fold in half horizontally (top to bottom)", "type": "valley", "line": {"start": [0.0, 0.5], "end": [1.0, 0.5]}, "angle": 180, "layer_select": "all"},
600
- {"step": 2, "description": "Fold in half vertically (crease only)", "type": "valley", "line": {"start": [0.5, 0.0], "end": [0.5, 0.5]}, "angle": 180, "layer_select": "all"},
601
- {"step": 3, "description": "Unfold vertical", "type": "unfold", "line": {"start": [0.5, 0.0], "end": [0.5, 0.5]}, "angle": 0, "layer_select": "all"},
602
- {"step": 4, "description": "Fold top-left corner down to center mark", "type": "valley", "line": {"start": [0.15, 0.5], "end": [0.5, 0.35]}, "angle": 180, "layer_select": "top"},
603
- {"step": 5, "description": "Fold top-right corner down to center mark", "type": "valley", "line": {"start": [0.85, 0.5], "end": [0.5, 0.35]}, "angle": 180, "layer_select": "top"},
604
- {"step": 6, "description": "Fold bottom strip up (front layer)", "type": "valley", "line": {"start": [0.0, 0.15], "end": [1.0, 0.15]}, "angle": 180, "layer_select": "top"},
605
- {"step": 7, "description": "Turn over", "type": "turn_over", "line": {"start": [0.5, 0.0], "end": [0.5, 0.5]}, "angle": 0, "layer_select": "all"},
606
- {"step": 8, "description": "Fold bottom strip up (back layer)", "type": "valley", "line": {"start": [0.0, 0.15], "end": [1.0, 0.15]}, "angle": 180, "layer_select": "top"},
607
- {"step": 9, "description": "Open from bottom and flatten into boat shape", "type": "inflate", "line": {"start": [0.5, 0.0], "end": [0.5, 0.5]}, "angle": 0, "layer_select": "all"},
608
- ],
609
- },
610
-
611
- "airplane": {
612
- "name": "Paper Airplane (Dart)",
613
- "difficulty": "simple",
614
- "base": None,
615
- "total_steps": 6,
616
- "description": "Classic dart-style paper airplane using only valley folds.",
617
- "steps": [
618
- {"step": 1, "description": "Fold in half vertically (left to right)", "type": "valley", "line": {"start": [0.5, 0.0], "end": [0.5, 1.0]}, "angle": 180, "layer_select": "all"},
619
- {"step": 2, "description": "Unfold", "type": "unfold", "line": {"start": [0.5, 0.0], "end": [0.5, 1.0]}, "angle": 0, "layer_select": "all"},
620
- {"step": 3, "description": "Fold top-left corner to center line", "type": "valley", "line": {"start": [0.0, 1.0], "end": [0.5, 0.7]}, "angle": 180, "layer_select": "all"},
621
- {"step": 4, "description": "Fold top-right corner to center line", "type": "valley", "line": {"start": [1.0, 1.0], "end": [0.5, 0.7]}, "angle": 180, "layer_select": "all"},
622
- {"step": 5, "description": "Fold left angled edge to center line", "type": "valley", "line": {"start": [0.0, 0.7], "end": [0.5, 0.4]}, "angle": 180, "layer_select": "all"},
623
- {"step": 6, "description": "Fold right angled edge to center line", "type": "valley", "line": {"start": [1.0, 0.7], "end": [0.5, 0.4]}, "angle": 180, "layer_select": "all"},
624
- ],
625
- },
626
-
627
- "box": {
628
- "name": "Masu Box (Open-Top Box)",
629
- "difficulty": "low_intermediate",
630
- "base": None,
631
- "total_steps": 13,
632
- "description": "An open-top box. Uses preliminary base concept with tuck folds.",
633
- "steps": [
634
- {"step": 1, "description": "Fold in half horizontally", "type": "valley", "line": {"start": [0.0, 0.5], "end": [1.0, 0.5]}, "angle": 180, "layer_select": "all"},
635
- {"step": 2, "description": "Unfold", "type": "unfold", "line": {"start": [0.0, 0.5], "end": [1.0, 0.5]}, "angle": 0, "layer_select": "all"},
636
- {"step": 3, "description": "Fold in half vertically", "type": "valley", "line": {"start": [0.5, 0.0], "end": [0.5, 1.0]}, "angle": 180, "layer_select": "all"},
637
- {"step": 4, "description": "Unfold", "type": "unfold", "line": {"start": [0.5, 0.0], "end": [0.5, 1.0]}, "angle": 0, "layer_select": "all"},
638
- {"step": 5, "description": "Fold all four corners to center", "type": "valley", "line": {"start": [0.0, 0.0], "end": [0.5, 0.5]}, "angle": 180, "layer_select": "all"},
639
- {"step": 6, "description": "Fold bottom-right corner to center", "type": "valley", "line": {"start": [1.0, 0.0], "end": [0.5, 0.5]}, "angle": 180, "layer_select": "all"},
640
- {"step": 7, "description": "Fold top-left corner to center", "type": "valley", "line": {"start": [0.0, 1.0], "end": [0.5, 0.5]}, "angle": 180, "layer_select": "all"},
641
- {"step": 8, "description": "Fold top-right corner to center", "type": "valley", "line": {"start": [1.0, 1.0], "end": [0.5, 0.5]}, "angle": 180, "layer_select": "all"},
642
- {"step": 9, "description": "Fold top third down to center", "type": "valley", "line": {"start": [0.0, 0.667], "end": [1.0, 0.667]}, "angle": 180, "layer_select": "all"},
643
- {"step": 10, "description": "Fold bottom third up to center", "type": "valley", "line": {"start": [0.0, 0.333], "end": [1.0, 0.333]}, "angle": 180, "layer_select": "all"},
644
- {"step": 11, "description": "Unfold top and bottom, and unfold left/right corners", "type": "unfold", "line": {"start": [0.0, 0.333], "end": [1.0, 0.333]}, "angle": 0, "layer_select": "all"},
645
- {"step": 12, "description": "Raise left and right walls using existing creases, tuck corners in", "type": "valley", "line": {"start": [0.25, 0.0], "end": [0.25, 1.0]}, "angle": 90, "layer_select": "all"},
646
- {"step": 13, "description": "Fold flaps over into box and lock walls in place", "type": "valley", "line": {"start": [0.0, 0.5], "end": [1.0, 0.5]}, "angle": 90, "layer_select": "top"},
647
- ],
648
- },
649
-
650
- "fortune_teller": {
651
- "name": "Fortune Teller (Cootie Catcher)",
652
- "difficulty": "simple",
653
- "base": None,
654
- "total_steps": 8,
655
- "description": "Classic fortune teller: fold corners to center, flip, repeat.",
656
- "steps": [
657
- {"step": 1, "description": "Fold in half diagonally (crease only)", "type": "valley", "line": {"start": [0.0, 0.0], "end": [1.0, 1.0]}, "angle": 180, "layer_select": "all"},
658
- {"step": 2, "description": "Unfold", "type": "unfold", "line": {"start": [0.0, 0.0], "end": [1.0, 1.0]}, "angle": 0, "layer_select": "all"},
659
- {"step": 3, "description": "Fold bottom-left corner to center", "type": "valley", "line": {"start": [0.0, 0.0], "end": [0.5, 0.5]}, "angle": 180, "layer_select": "all"},
660
- {"step": 4, "description": "Fold bottom-right corner to center", "type": "valley", "line": {"start": [1.0, 0.0], "end": [0.5, 0.5]}, "angle": 180, "layer_select": "all"},
661
- {"step": 5, "description": "Fold top-left corner to center", "type": "valley", "line": {"start": [0.0, 1.0], "end": [0.5, 0.5]}, "angle": 180, "layer_select": "all"},
662
- {"step": 6, "description": "Fold top-right corner to center", "type": "valley", "line": {"start": [1.0, 1.0], "end": [0.5, 0.5]}, "angle": 180, "layer_select": "all"},
663
- {"step": 7, "description": "Turn over", "type": "turn_over", "line": {"start": [0.5, 0.0], "end": [0.5, 1.0]}, "angle": 0, "layer_select": "all"},
664
- {"step": 8, "description": "Fold all four new corners to center again", "type": "valley", "line": {"start": [0.25, 0.25], "end": [0.5, 0.5]}, "angle": 180, "layer_select": "all"},
665
- ],
666
- },
667
-
668
- "waterbomb": {
669
- "name": "Waterbomb (Paper Balloon)",
670
- "difficulty": "simple",
671
- "base": "waterbomb_base",
672
- "total_steps": 12,
673
- "description": "Inflatable paper balloon built on the waterbomb base.",
674
- "steps": [
675
- # Phase 1: Waterbomb base (steps 1-9 same as waterbomb_base)
676
- {"step": 1, "description": "Fold first diagonal", "type": "valley", "line": {"start": [0.0, 0.0], "end": [1.0, 1.0]}, "angle": 180, "layer_select": "all"},
677
- {"step": 2, "description": "Unfold", "type": "unfold", "line": {"start": [0.0, 0.0], "end": [1.0, 1.0]}, "angle": 0, "layer_select": "all"},
678
- {"step": 3, "description": "Fold second diagonal", "type": "valley", "line": {"start": [1.0, 0.0], "end": [0.0, 1.0]}, "angle": 180, "layer_select": "all"},
679
- {"step": 4, "description": "Unfold", "type": "unfold", "line": {"start": [1.0, 0.0], "end": [0.0, 1.0]}, "angle": 0, "layer_select": "all"},
680
- {"step": 5, "description": "Fold in half horizontally", "type": "valley", "line": {"start": [0.0, 0.5], "end": [1.0, 0.5]}, "angle": 180, "layer_select": "all"},
681
- {"step": 6, "description": "Collapse into waterbomb base (triangle)", "type": "valley", "line": {"start": [0.0, 0.0], "end": [1.0, 1.0]}, "angle": 180, "layer_select": "all"},
682
- # Phase 2: Fold flaps to top
683
- {"step": 7, "description": "Fold bottom-left corner of front layer up to top", "type": "valley", "line": {"start": [0.0, 0.0], "end": [0.5, 0.5]}, "angle": 180, "layer_select": "top"},
684
- {"step": 8, "description": "Fold bottom-right corner of front layer up to top", "type": "valley", "line": {"start": [1.0, 0.0], "end": [0.5, 0.5]}, "angle": 180, "layer_select": "top"},
685
- # Phase 3: Tuck flaps
686
- {"step": 9, "description": "Fold left and right points to center", "type": "valley", "line": {"start": [0.25, 0.25], "end": [0.5, 0.5]}, "angle": 180, "layer_select": "top"},
687
- {"step": 10, "description": "Tuck small triangles into pockets", "type": "valley", "line": {"start": [0.35, 0.4], "end": [0.5, 0.5]}, "angle": 180, "layer_select": "top"},
688
- {"step": 11, "description": "Repeat steps 7-10 on back", "type": "valley", "line": {"start": [0.0, 0.0], "end": [0.5, 0.5]}, "angle": 180, "layer_select": "top"},
689
- # Phase 4: Inflate
690
- {"step": 12, "description": "Blow into hole at bottom to inflate into cube/sphere", "type": "inflate", "line": {"start": [0.5, 0.0], "end": [0.5, 0.5]}, "angle": 0, "layer_select": "all"},
691
- ],
692
- },
693
-
694
- "jumping_frog": {
695
- "name": "Jumping Frog",
696
- "difficulty": "low_intermediate",
697
- "base": None,
698
- "total_steps": 15,
699
- "description": "A frog that jumps when you press its back. Uses pleats for the spring.",
700
- "steps": [
701
- {"step": 1, "description": "Fold in half horizontally", "type": "valley", "line": {"start": [0.0, 0.5], "end": [1.0, 0.5]}, "angle": 180, "layer_select": "all"},
702
- {"step": 2, "description": "Fold top-left corner to right edge", "type": "valley", "line": {"start": [0.0, 1.0], "end": [1.0, 0.75]}, "angle": 180, "layer_select": "all"},
703
- {"step": 3, "description": "Unfold", "type": "unfold", "line": {"start": [0.0, 1.0], "end": [1.0, 0.75]}, "angle": 0, "layer_select": "all"},
704
- {"step": 4, "description": "Fold top-right corner to left edge", "type": "valley", "line": {"start": [1.0, 1.0], "end": [0.0, 0.75]}, "angle": 180, "layer_select": "all"},
705
- {"step": 5, "description": "Unfold", "type": "unfold", "line": {"start": [1.0, 1.0], "end": [0.0, 0.75]}, "angle": 0, "layer_select": "all"},
706
- {"step": 6, "description": "Collapse top into waterbomb-like triangle", "type": "valley", "line": {"start": [0.0, 0.75], "end": [1.0, 0.75]}, "angle": 180, "layer_select": "all"},
707
- {"step": 7, "description": "Fold left point of triangle up and outward for front leg", "type": "valley", "line": {"start": [0.25, 0.75], "end": [0.5, 1.0]}, "angle": 180, "layer_select": "top"},
708
- {"step": 8, "description": "Fold right point of triangle up and outward for front leg", "type": "valley", "line": {"start": [0.75, 0.75], "end": [0.5, 1.0]}, "angle": 180, "layer_select": "top"},
709
- {"step": 9, "description": "Fold bottom half up to meet triangle base", "type": "valley", "line": {"start": [0.0, 0.375], "end": [1.0, 0.375]}, "angle": 180, "layer_select": "all"},
710
- {"step": 10, "description": "Fold left side to center", "type": "valley", "line": {"start": [0.25, 0.0], "end": [0.25, 0.75]}, "angle": 180, "layer_select": "all"},
711
- {"step": 11, "description": "Fold right side to center", "type": "valley", "line": {"start": [0.75, 0.0], "end": [0.75, 0.75]}, "angle": 180, "layer_select": "all"},
712
- {"step": 12, "description": "Fold bottom up", "type": "valley", "line": {"start": [0.25, 0.25], "end": [0.75, 0.25]}, "angle": 180, "layer_select": "all"},
713
- {"step": 13, "description": "Pull back legs out to sides", "type": "valley", "line": {"start": [0.375, 0.0], "end": [0.375, 0.375]}, "angle": 180, "layer_select": "top"},
714
- {"step": 14, "description": "Pleat fold for spring: fold bottom half down", "type": "mountain", "line": {"start": [0.25, 0.15], "end": [0.75, 0.15]}, "angle": 180, "layer_select": "all"},
715
- {"step": 15, "description": "Valley fold bottom portion back up for spring action", "type": "valley", "line": {"start": [0.25, 0.08], "end": [0.75, 0.08]}, "angle": 180, "layer_select": "all"},
716
- ],
717
- },
718
- }
719
-
720
-
721
- # ---------------------------------------------------------------------------
722
- # Helpers
723
- # ---------------------------------------------------------------------------
724
-
725
- def get_base_steps(base_name: str) -> list[dict]:
726
- """Return the fold steps for a given base, or empty list if not found."""
727
- base = ORIGAMI_BASES.get(base_name)
728
- if base is None:
729
- return []
730
- return list(base["steps"])
731
-
732
-
733
- def get_model_steps(model_name: str) -> list[dict]:
734
- """Return the full fold steps for a known model, or empty list."""
735
- model = ORIGAMI_MODELS.get(model_name)
736
- if model is None:
737
- return []
738
- return list(model["steps"])
739
-
740
-
741
- def list_known_models() -> list[str]:
742
- """Return names of all known origami models."""
743
- return list(ORIGAMI_MODELS.keys())
744
-
745
-
746
- def list_known_bases() -> list[str]:
747
- """Return names of all known origami bases."""
748
- return list(ORIGAMI_BASES.keys())
749
-
750
-
751
- def get_fold_operation(name: str) -> dict | None:
752
- """Look up a fold operation by name."""
753
- return FOLD_OPERATIONS.get(name)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
planner/parser.py DELETED
@@ -1,291 +0,0 @@
1
- """
2
- Instruction parser: converts human text into a structured origami task.
3
-
4
- Handles variations like:
5
- - "make a paper crane"
6
- - "fold me a crane"
7
- - "i want to make a paper crane"
8
- - "crane please"
9
- - "can you fold a crane?"
10
- - "pack a 1m x 1m mylar sheet as compact as possible"
11
- """
12
-
13
- from __future__ import annotations
14
-
15
- import re
16
- from planner.knowledge import ORIGAMI_MODELS, list_known_models
17
-
18
-
19
- # ---------------------------------------------------------------------------
20
- # Vocabulary for matching
21
- # ---------------------------------------------------------------------------
22
-
23
- # Model aliases → canonical name
24
- _MODEL_ALIASES: dict[str, str] = {
25
- # Crane
26
- "crane": "crane",
27
- "tsuru": "crane",
28
- "bird": "crane",
29
- "orizuru": "crane",
30
- # Boat
31
- "boat": "boat",
32
- "hat": "boat",
33
- "paper boat": "boat",
34
- "paper hat": "boat",
35
- # Airplane
36
- "airplane": "airplane",
37
- "aeroplane": "airplane",
38
- "plane": "airplane",
39
- "paper airplane": "airplane",
40
- "paper plane": "airplane",
41
- "dart": "airplane",
42
- "paper dart": "airplane",
43
- # Box
44
- "box": "box",
45
- "masu": "box",
46
- "masu box": "box",
47
- "open box": "box",
48
- # Fortune teller
49
- "fortune teller": "fortune_teller",
50
- "fortune-teller": "fortune_teller",
51
- "cootie catcher": "fortune_teller",
52
- "cootie-catcher": "fortune_teller",
53
- "chatterbox": "fortune_teller",
54
- # Waterbomb / balloon
55
- "waterbomb": "waterbomb",
56
- "water bomb": "waterbomb",
57
- "balloon": "waterbomb",
58
- "paper balloon": "waterbomb",
59
- # Jumping frog
60
- "jumping frog": "jumping_frog",
61
- "frog": "jumping_frog",
62
- "leap frog": "jumping_frog",
63
- }
64
-
65
- # Sorted longest-first so multi-word aliases match before single-word ones
66
- _ALIAS_KEYS_SORTED = sorted(_MODEL_ALIASES.keys(), key=len, reverse=True)
67
-
68
- _MATERIALS = {
69
- "paper": "paper",
70
- "mylar": "mylar",
71
- "aluminum": "aluminum",
72
- "aluminium": "aluminum",
73
- "metal": "metal",
74
- "nitinol": "nitinol",
75
- "foil": "aluminum",
76
- "cardboard": "cardboard",
77
- "cardstock": "cardstock",
78
- "fabric": "fabric",
79
- "cloth": "fabric",
80
- }
81
-
82
- # Intent keywords
83
- _FOLD_VERBS = {
84
- "make", "fold", "create", "build", "construct", "origami",
85
- "craft", "form", "shape", "assemble",
86
- }
87
- _PACK_VERBS = {
88
- "pack", "compress", "compact", "minimize", "reduce", "stow",
89
- "shrink", "deploy", "collapse",
90
- }
91
- _OPTIMIZE_PHRASES = [
92
- "as compact as possible",
93
- "minimize volume",
94
- "minimize packed volume",
95
- "minimize area",
96
- "solar panel",
97
- "stent",
98
- "deployable",
99
- "maximize compactness",
100
- "flatten",
101
- ]
102
-
103
- # Dimension patterns
104
- _DIM_PATTERNS = [
105
- # "10cm x 10cm", "10 cm x 10 cm"
106
- re.compile(
107
- r"(\d+(?:\.\d+)?)\s*(cm|mm|m|in|inch|inches|ft|feet)\s*[xX\u00d7]\s*(\d+(?:\.\d+)?)\s*(cm|mm|m|in|inch|inches|ft|feet)",
108
- re.IGNORECASE,
109
- ),
110
- # "10cm square", "1 meter square"
111
- re.compile(
112
- r"(\d+(?:\.\d+)?)\s*(cm|mm|m|in|inch|inches|ft|feet|meter|meters|metre|metres)\s+square",
113
- re.IGNORECASE,
114
- ),
115
- ]
116
-
117
- _UNIT_TO_M = {
118
- "m": 1.0,
119
- "meter": 1.0,
120
- "meters": 1.0,
121
- "metre": 1.0,
122
- "metres": 1.0,
123
- "cm": 0.01,
124
- "mm": 0.001,
125
- "in": 0.0254,
126
- "inch": 0.0254,
127
- "inches": 0.0254,
128
- "ft": 0.3048,
129
- "feet": 0.3048,
130
- }
131
-
132
-
133
- # ---------------------------------------------------------------------------
134
- # Parsing helpers
135
- # ---------------------------------------------------------------------------
136
-
137
- def _normalise(text: str) -> str:
138
- """Lower-case and strip extra whitespace."""
139
- return " ".join(text.lower().split())
140
-
141
-
142
- def _detect_model(text: str) -> str | None:
143
- """Return canonical model name if one is mentioned, else None."""
144
- norm = _normalise(text)
145
-
146
- # Strip out constraint phrases that might contain model-name false positives
147
- # e.g. "into a 15cm x 15cm x 5cm box" should not match "box" as a model
148
- cleaned = re.sub(
149
- r"(?:fit\s+)?(?:in(?:to)?|inside)\s+(?:a\s+)?\d.*?box",
150
- "",
151
- norm,
152
- )
153
-
154
- for alias in _ALIAS_KEYS_SORTED:
155
- # Use word-boundary-aware search to avoid partial matches
156
- pattern = r"(?:^|\b)" + re.escape(alias) + r"(?:\b|$)"
157
- if re.search(pattern, cleaned):
158
- return _MODEL_ALIASES[alias]
159
- return None
160
-
161
-
162
- def _detect_material(text: str) -> str:
163
- """Return detected material or default 'paper'."""
164
- norm = _normalise(text)
165
- # Check multi-word materials first (none currently, but future-proof)
166
- for keyword, canonical in sorted(_MATERIALS.items(), key=lambda kv: -len(kv[0])):
167
- if keyword in norm:
168
- return canonical
169
- return "paper"
170
-
171
-
172
- def _detect_dimensions(text: str) -> dict[str, float]:
173
- """Parse explicit dimensions. Returns {"width": m, "height": m} or defaults."""
174
- for pat in _DIM_PATTERNS:
175
- m = pat.search(text)
176
- if m:
177
- groups = m.groups()
178
- if len(groups) == 4:
179
- # WxH pattern
180
- w = float(groups[0]) * _UNIT_TO_M.get(groups[1].lower(), 1.0)
181
- h = float(groups[2]) * _UNIT_TO_M.get(groups[3].lower(), 1.0)
182
- return {"width": round(w, 6), "height": round(h, 6)}
183
- elif len(groups) == 2:
184
- # "N unit square"
185
- side = float(groups[0]) * _UNIT_TO_M.get(groups[1].lower(), 1.0)
186
- return {"width": round(side, 6), "height": round(side, 6)}
187
- # Default: unit square
188
- return {"width": 1.0, "height": 1.0}
189
-
190
-
191
- def _detect_constraints(text: str) -> dict:
192
- """Detect any explicit constraints mentioned in the instruction."""
193
- norm = _normalise(text)
194
- constraints: dict = {}
195
-
196
- # Target bounding box: "fit in a 15cm x 15cm x 5cm box"
197
- box_pat = re.compile(
198
- r"fit\s+(?:in(?:to)?|inside)\s+(?:a\s+)?(\d+(?:\.\d+)?)\s*(cm|mm|m)\s*[xX\u00d7]\s*"
199
- r"(\d+(?:\.\d+)?)\s*(cm|mm|m)\s*[xX\u00d7]\s*(\d+(?:\.\d+)?)\s*(cm|mm|m)",
200
- re.IGNORECASE,
201
- )
202
- bm = box_pat.search(norm)
203
- if bm:
204
- g = bm.groups()
205
- constraints["target_box"] = [
206
- float(g[0]) * _UNIT_TO_M.get(g[1], 1.0),
207
- float(g[2]) * _UNIT_TO_M.get(g[3], 1.0),
208
- float(g[4]) * _UNIT_TO_M.get(g[5], 1.0),
209
- ]
210
-
211
- # Max folds
212
- folds_pat = re.compile(r"(?:max(?:imum)?|at most|no more than)\s+(\d+)\s+fold", re.IGNORECASE)
213
- fm = folds_pat.search(norm)
214
- if fm:
215
- constraints["max_folds"] = int(fm.group(1))
216
-
217
- # Compactness emphasis
218
- for phrase in _OPTIMIZE_PHRASES:
219
- if phrase in norm:
220
- constraints["optimize_compactness"] = True
221
- break
222
-
223
- # Must deploy
224
- if "deploy" in norm or "unfold" in norm and "clean" in norm:
225
- constraints["must_deploy"] = True
226
-
227
- return constraints
228
-
229
-
230
- def _detect_intent(text: str, model_name: str | None, constraints: dict) -> str:
231
- """Determine the high-level intent of the instruction."""
232
- norm = _normalise(text)
233
- words = set(norm.split())
234
-
235
- # If packing / optimization phrases are present, it's an optimization task
236
- if constraints.get("optimize_compactness"):
237
- return "optimize_packing"
238
- if words & _PACK_VERBS:
239
- return "optimize_packing"
240
-
241
- # If a known model is detected, it's a fold_model task
242
- if model_name is not None:
243
- return "fold_model"
244
-
245
- # If fold verbs are present but no model, it's a free fold
246
- if words & _FOLD_VERBS:
247
- return "free_fold"
248
-
249
- # Fallback: if there's a pattern keyword
250
- pattern_words = {"miura", "tessellation", "pattern", "waterbomb tessellation", "pleat"}
251
- if words & pattern_words:
252
- return "fold_pattern"
253
-
254
- return "free_fold"
255
-
256
-
257
- # ---------------------------------------------------------------------------
258
- # Public API
259
- # ---------------------------------------------------------------------------
260
-
261
- def parse_instruction(text: str) -> dict:
262
- """
263
- Parse a human origami instruction into a structured task.
264
-
265
- Args:
266
- text: Natural-language instruction, e.g. "make a paper crane"
267
-
268
- Returns:
269
- {
270
- "intent": "fold_model" | "fold_pattern" | "optimize_packing" | "free_fold",
271
- "model_name": str or None,
272
- "material": str,
273
- "dimensions": {"width": float, "height": float},
274
- "constraints": {...},
275
- "raw_instruction": str,
276
- }
277
- """
278
- model_name = _detect_model(text)
279
- material = _detect_material(text)
280
- dimensions = _detect_dimensions(text)
281
- constraints = _detect_constraints(text)
282
- intent = _detect_intent(text, model_name, constraints)
283
-
284
- return {
285
- "intent": intent,
286
- "model_name": model_name,
287
- "material": material,
288
- "dimensions": dimensions,
289
- "constraints": constraints,
290
- "raw_instruction": text,
291
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
planner/planner.py DELETED
@@ -1,305 +0,0 @@
1
- """
2
- OrigamiPlanner: the main orchestrator that converts human instructions
3
- into structured fold plans with LLM-ready prompts.
4
-
5
- Usage:
6
- from planner.planner import OrigamiPlanner
7
-
8
- planner = OrigamiPlanner()
9
- plan = planner.plan("make a paper crane")
10
- print(plan.summary())
11
- prompt = plan.get_prompt_for_step(0)
12
- """
13
-
14
- from __future__ import annotations
15
-
16
- import json
17
- from dataclasses import dataclass, field
18
-
19
- from planner.parser import parse_instruction
20
- from planner.decomposer import decompose_task
21
- from planner.knowledge import ORIGAMI_MODELS, FOLD_OPERATIONS
22
-
23
-
24
- # ---------------------------------------------------------------------------
25
- # Material defaults (mirrors trainer/prompts.py TASK_CONFIGS)
26
- # ---------------------------------------------------------------------------
27
-
28
- _MATERIAL_DEFAULTS = {
29
- "paper": {"thickness_mm": 0.1, "youngs_modulus_gpa": 2.0, "max_strain_pct": 3},
30
- "mylar": {"thickness_mm": 0.05, "youngs_modulus_gpa": 4.0, "max_strain_pct": 3},
31
- "aluminum": {"thickness_mm": 0.02, "youngs_modulus_gpa": 69.0, "max_strain_pct": 1},
32
- "metal": {"thickness_mm": 0.05, "youngs_modulus_gpa": 200.0,"max_strain_pct": 0.5},
33
- "nitinol": {"thickness_mm": 0.1, "youngs_modulus_gpa": 75.0, "max_strain_pct": 8},
34
- "cardboard": {"thickness_mm": 1.0, "youngs_modulus_gpa": 1.0, "max_strain_pct": 2},
35
- "cardstock": {"thickness_mm": 0.3, "youngs_modulus_gpa": 1.5, "max_strain_pct": 2},
36
- "fabric": {"thickness_mm": 0.2, "youngs_modulus_gpa": 0.1, "max_strain_pct": 15},
37
- }
38
-
39
-
40
- # ---------------------------------------------------------------------------
41
- # FoldPlan dataclass
42
- # ---------------------------------------------------------------------------
43
-
44
- @dataclass
45
- class FoldPlan:
46
- """
47
- A complete, executable fold plan produced by OrigamiPlanner.
48
-
49
- Attributes:
50
- instruction: The original human instruction.
51
- parsed: Structured parse result (intent, model, material, etc.).
52
- steps: Ordered list of sub-goal dicts from the decomposer.
53
- prompts: Pre-built LLM prompts, one per step.
54
- """
55
-
56
- instruction: str
57
- parsed: dict
58
- steps: list[dict]
59
- prompts: list[str]
60
-
61
- # ------------------------------------------------------------------
62
- # Summaries
63
- # ------------------------------------------------------------------
64
-
65
- def summary(self) -> str:
66
- """Human-readable summary of the plan."""
67
- lines: list[str] = []
68
- lines.append(f"Origami Plan: {self.instruction}")
69
- lines.append(f" Intent : {self.parsed['intent']}")
70
- if self.parsed.get("model_name"):
71
- model = ORIGAMI_MODELS.get(self.parsed["model_name"], {})
72
- lines.append(f" Model : {model.get('name', self.parsed['model_name'])}")
73
- lines.append(f" Difficulty: {model.get('difficulty', 'unknown')}")
74
- lines.append(f" Material: {self.parsed['material']}")
75
- dims = self.parsed["dimensions"]
76
- lines.append(f" Sheet : {dims['width']}m x {dims['height']}m")
77
- lines.append(f" Steps : {len(self.steps)}")
78
- lines.append("")
79
- lines.append("Step-by-step:")
80
- for s in self.steps:
81
- n = s["step_number"]
82
- desc = s["description"]
83
- n_ops = len(s.get("fold_operations", []))
84
- lines.append(f" {n:>3}. {desc} ({n_ops} fold op{'s' if n_ops != 1 else ''})")
85
- return "\n".join(lines)
86
-
87
- # ------------------------------------------------------------------
88
- # Prompt access
89
- # ------------------------------------------------------------------
90
-
91
- def get_prompt_for_step(self, step_index: int, current_state: dict | None = None) -> str:
92
- """
93
- Get the LLM prompt for a specific step, optionally enriched with
94
- the current paper state from the simulation engine.
95
-
96
- Args:
97
- step_index: Zero-based index into self.steps.
98
- current_state: Optional live paper_state dict from the engine.
99
-
100
- Returns:
101
- A fully-formatted prompt string ready for the LLM.
102
- """
103
- if step_index < 0 or step_index >= len(self.steps):
104
- raise IndexError(f"step_index {step_index} out of range (0..{len(self.steps) - 1})")
105
-
106
- base_prompt = self.prompts[step_index]
107
-
108
- if current_state is None:
109
- return base_prompt
110
-
111
- # Inject live state into the prompt
112
- state_block = _format_state_block(current_state)
113
- return base_prompt.replace("{{CURRENT_STATE}}", state_block)
114
-
115
- # ------------------------------------------------------------------
116
- # Convenience: all fold operations flattened
117
- # ------------------------------------------------------------------
118
-
119
- def all_fold_operations(self) -> list[dict]:
120
- """Return every fold operation across all steps, in order."""
121
- ops: list[dict] = []
122
- for step in self.steps:
123
- ops.extend(step.get("fold_operations", []))
124
- return ops
125
-
126
- def total_fold_count(self) -> int:
127
- """Total number of fold operations in the plan."""
128
- return sum(len(s.get("fold_operations", [])) for s in self.steps)
129
-
130
-
131
- # ---------------------------------------------------------------------------
132
- # Prompt builder helpers
133
- # ---------------------------------------------------------------------------
134
-
135
- def _format_state_block(state: dict) -> str:
136
- """Format a paper_state dict as a human-readable block for the prompt."""
137
- lines = ["CURRENT STATE:"]
138
- if "bounding_box" in state:
139
- bb = state["bounding_box"]
140
- if isinstance(bb, dict):
141
- lines.append(f" Bounding box: {bb.get('x', '?')}m x {bb.get('y', '?')}m x {bb.get('z', '?')}m")
142
- elif isinstance(bb, (list, tuple)) and len(bb) >= 3:
143
- lines.append(f" Bounding box: {bb[0]}m x {bb[1]}m x {bb[2]}m")
144
- if "num_layers_at_center" in state:
145
- lines.append(f" Layers at center: {state['num_layers_at_center']}")
146
- if "deployment_ratio" in state:
147
- lines.append(f" Deployment ratio: {state['deployment_ratio']:.3f}")
148
- if "fold_angles" in state:
149
- n_folds = sum(1 for a in state["fold_angles"] if a != 0)
150
- lines.append(f" Active folds: {n_folds}")
151
- return "\n".join(lines)
152
-
153
-
154
- def _format_fold_ops_as_code(ops: list[dict]) -> str:
155
- """Format fold operations as Python list literal for inclusion in a prompt."""
156
- if not ops:
157
- return " # (LLM: determine fold operations for this step)\n return []"
158
-
159
- lines = [" return ["]
160
- for op in ops:
161
- clean = {
162
- "type": op["type"],
163
- "line": op.get("line", {"start": [0, 0], "end": [1, 1]}),
164
- "angle": op.get("angle", 180),
165
- }
166
- lines.append(f" {json.dumps(clean)},")
167
- lines.append(" ]")
168
- return "\n".join(lines)
169
-
170
-
171
- # ---------------------------------------------------------------------------
172
- # OrigamiPlanner
173
- # ---------------------------------------------------------------------------
174
-
175
- class OrigamiPlanner:
176
- """
177
- Full pipeline: human instruction -> structured plan -> executable fold operations.
178
-
179
- The planner:
180
- 1. Parses the instruction (parser.py)
181
- 2. Decomposes into sub-goals (decomposer.py)
182
- 3. Builds LLM-ready prompts matching trainer/prompts.py format
183
- """
184
-
185
- def plan(self, instruction: str) -> FoldPlan:
186
- """
187
- Plan an origami task from a human instruction.
188
-
189
- Args:
190
- instruction: e.g. "make a paper crane", "pack a 1m mylar sheet"
191
-
192
- Returns:
193
- A FoldPlan with steps and LLM prompts.
194
- """
195
- parsed = parse_instruction(instruction)
196
- steps = decompose_task(parsed)
197
- prompts = [self._build_prompt(step, i, parsed) for i, step in enumerate(steps)]
198
- return FoldPlan(
199
- instruction=instruction,
200
- parsed=parsed,
201
- steps=steps,
202
- prompts=prompts,
203
- )
204
-
205
- # ------------------------------------------------------------------
206
- # Prompt construction
207
- # ------------------------------------------------------------------
208
-
209
- def _build_prompt(self, step: dict, step_index: int, parsed: dict) -> str:
210
- """
211
- Build an LLM-ready prompt for a single sub-goal step.
212
-
213
- The format matches trainer/prompts.py: task description at top,
214
- material/constraints in the middle, and a fold_strategy() code
215
- block wrapped in triple backticks at the bottom.
216
- """
217
- material = parsed["material"]
218
- mat_info = _MATERIAL_DEFAULTS.get(material, _MATERIAL_DEFAULTS["paper"])
219
- dims = parsed["dimensions"]
220
- constraints = parsed.get("constraints", {})
221
- total_steps = len(parsed.get("_all_steps", [])) or step.get("step_number", 1)
222
-
223
- # ---- Header ----
224
- intent = parsed["intent"]
225
- if intent == "fold_model" and parsed.get("model_name"):
226
- model_info = ORIGAMI_MODELS.get(parsed["model_name"], {})
227
- task_line = (
228
- f"TASK: Step {step['step_number']} of {total_steps} — "
229
- f"{step['description']}\n"
230
- f"MODEL: {model_info.get('name', parsed['model_name'])} "
231
- f"(difficulty: {model_info.get('difficulty', 'unknown')})"
232
- )
233
- elif intent == "optimize_packing":
234
- task_line = (
235
- f"TASK: Step {step['step_number']} — {step['description']}\n"
236
- f"GOAL: Minimize packed volume while maintaining deployability."
237
- )
238
- else:
239
- task_line = f"TASK: Step {step['step_number']} — {step['description']}"
240
-
241
- # ---- Material ----
242
- material_block = (
243
- f"MATERIAL:\n"
244
- f" - Name: {material}\n"
245
- f" - Thickness: {mat_info['thickness_mm']}mm\n"
246
- f" - Max strain: {mat_info['max_strain_pct']}%"
247
- )
248
-
249
- # ---- Constraints ----
250
- constraint_lines = ["CONSTRAINTS:"]
251
- if "max_folds" in constraints:
252
- constraint_lines.append(f" - Maximum {constraints['max_folds']} fold operations")
253
- if "target_box" in constraints:
254
- tb = constraints["target_box"]
255
- constraint_lines.append(
256
- f" - Must pack into bounding box <= "
257
- f"{tb[0]*100:.0f}cm x {tb[1]*100:.0f}cm x {tb[2]*100:.0f}cm"
258
- )
259
- if constraints.get("must_deploy"):
260
- constraint_lines.append(" - Must deploy to >= 80% of original area")
261
- constraint_lines.append(" - No self-intersections allowed")
262
- constraints_block = "\n".join(constraint_lines)
263
-
264
- # ---- State placeholder ----
265
- state_block = (
266
- f"CURRENT STATE:\n"
267
- f" Sheet: {dims['width']}m x {dims['height']}m\n"
268
- f" {{{{CURRENT_STATE}}}}"
269
- )
270
-
271
- # ---- Fold operations hint ----
272
- ops = step.get("fold_operations", [])
273
- ops_code = _format_fold_ops_as_code(ops)
274
-
275
- # ---- Expected result ----
276
- expected = step.get("expected_state", {})
277
- expected_block = ""
278
- if expected:
279
- expected_block = f"\nEXPECTED RESULT: {json.dumps(expected)}"
280
-
281
- # ---- Code block (matches trainer/prompts.py format) ----
282
- code_block = (
283
- f'Write a fold_strategy(paper_state) function that returns a list of fold operations.\n'
284
- f'Each fold: {{"type": "valley"|"mountain", "line": {{"start": [x,y], "end": [x,y]}}, "angle": 0-180}}\n'
285
- f'\n'
286
- f'```python\n'
287
- f'def fold_strategy(paper_state):\n'
288
- f'{ops_code}\n'
289
- f'```'
290
- )
291
-
292
- # ---- Assemble ----
293
- sections = [
294
- task_line,
295
- "",
296
- material_block,
297
- "",
298
- constraints_block,
299
- "",
300
- state_block,
301
- expected_block,
302
- "",
303
- code_block,
304
- ]
305
- return "\n".join(sections)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
server/app.py DELETED
@@ -1,181 +0,0 @@
1
- """
2
- server/app.py — Training WebSocket server for Colab environment.
3
-
4
- Provides /ws/training for live streaming of RL training episodes to browsers.
5
- Mount at a publicly accessible URL in Colab (e.g., via ngrok or Colab's proxy).
6
-
7
- Usage in training:
8
- from server.app import broadcast
9
- broadcast.publish(episode_id, {"type": "episode_update", ...})
10
- """
11
- from __future__ import annotations
12
-
13
- import json
14
- from pathlib import Path
15
-
16
- import numpy as np
17
- import uvicorn
18
- from fastapi import FastAPI, HTTPException, WebSocket
19
- from fastapi.middleware.cors import CORSMiddleware
20
- from fastapi.responses import HTMLResponse, JSONResponse
21
- from fastapi.staticfiles import StaticFiles
22
-
23
-
24
- def _np_default(obj):
25
- if isinstance(obj, np.bool_):
26
- return bool(obj)
27
- if isinstance(obj, np.integer):
28
- return int(obj)
29
- if isinstance(obj, np.floating):
30
- return float(obj)
31
- if isinstance(obj, np.ndarray):
32
- return obj.tolist()
33
- raise TypeError(f"Not serializable: {type(obj)}")
34
-
35
-
36
- class NumpyJSONResponse(JSONResponse):
37
- def render(self, content) -> bytes:
38
- return json.dumps(content, default=_np_default).encode("utf-8")
39
-
40
- from server.training_broadcast import TrainingBroadcastServer
41
-
42
- app = FastAPI(title="Optigami Training Server", version="1.0")
43
-
44
- # Allow cross-origin connections (Colab public URL → browser)
45
- app.add_middleware(
46
- CORSMiddleware,
47
- allow_origins=["*"],
48
- allow_credentials=True,
49
- allow_methods=["*"],
50
- allow_headers=["*"],
51
- )
52
-
53
- # Global broadcast server — import and use from training code
54
- broadcast = TrainingBroadcastServer()
55
-
56
-
57
- @app.on_event("startup")
58
- async def _store_loop() -> None:
59
- """Capture the asyncio event loop so training threads can schedule coroutines."""
60
- import asyncio
61
- broadcast._loop = asyncio.get_running_loop()
62
-
63
-
64
- @app.websocket("/ws/training")
65
- async def training_ws(websocket: WebSocket) -> None:
66
- """Spectator WebSocket endpoint. Viewers connect here to watch training."""
67
- await broadcast.connect_spectator(websocket)
68
-
69
-
70
- @app.get("/health")
71
- def health() -> dict:
72
- return {
73
- "status": "ok",
74
- "spectators": broadcast.spectator_count,
75
- "active_episodes": broadcast.active_episodes,
76
- }
77
-
78
-
79
- # ── Demo endpoints (same as openenv_server/app.py so the React UI works) ──
80
-
81
- @app.get("/targets", response_class=NumpyJSONResponse)
82
- def get_targets():
83
- from server.tasks import available_task_names, get_task_by_name
84
- return NumpyJSONResponse({
85
- name: {
86
- "name": name,
87
- "level": t["difficulty"],
88
- "description": t.get("description", ""),
89
- "n_creases": t.get("max_folds", 3),
90
- "difficulty": t["difficulty"],
91
- "material": t.get("material", "paper"),
92
- }
93
- for name in available_task_names()
94
- if (t := get_task_by_name(name))
95
- })
96
-
97
-
98
- _DEMO_SEQUENCES: dict[str, list[dict]] = {
99
- "half_fold": [{"type": "valley", "line": {"start": [0.0, 0.5], "end": [1.0, 0.5]}, "angle": 180.0}],
100
- "quarter_fold": [{"type": "valley", "line": {"start": [0.0, 0.5], "end": [1.0, 0.5]}, "angle": 180.0},
101
- {"type": "valley", "line": {"start": [0.5, 0.0], "end": [0.5, 1.0]}, "angle": 180.0}],
102
- "letter_fold": [{"type": "valley", "line": {"start": [0.0, 0.333], "end": [1.0, 0.333]}, "angle": 180.0},
103
- {"type": "mountain", "line": {"start": [0.0, 0.667], "end": [1.0, 0.667]}, "angle": 180.0}],
104
- "map_fold": [{"type": "valley", "line": {"start": [0.0, 0.5], "end": [1.0, 0.5]}, "angle": 180.0},
105
- {"type": "mountain", "line": {"start": [0.5, 0.0], "end": [0.5, 1.0]}, "angle": 180.0}],
106
- "solar_panel": [{"type": "valley", "line": {"start": [0.0, 0.25], "end": [1.0, 0.25]}, "angle": 180.0},
107
- {"type": "mountain", "line": {"start": [0.0, 0.5], "end": [1.0, 0.5]}, "angle": 180.0},
108
- {"type": "valley", "line": {"start": [0.0, 0.75], "end": [1.0, 0.75]}, "angle": 180.0}],
109
- }
110
-
111
-
112
- @app.get("/episode/demo", response_class=NumpyJSONResponse)
113
- def demo_episode(target: str = "half_fold"):
114
- from server.origami_environment import OrigamiEnvironment
115
- from server.models import OrigamiAction as NewAction
116
- from server.tasks import get_task_by_name
117
-
118
- folds = _DEMO_SEQUENCES.get(target, _DEMO_SEQUENCES["half_fold"])
119
- env = OrigamiEnvironment()
120
- obs = env.reset(task_name=target)
121
- steps: list[dict] = []
122
-
123
- for i, fold_dict in enumerate(folds):
124
- action = NewAction(
125
- fold_type=fold_dict["type"],
126
- fold_line=fold_dict["line"],
127
- fold_angle=float(fold_dict.get("angle", 180.0)),
128
- )
129
- obs = env.step(action)
130
- steps.append({"step": i + 1, "fold": fold_dict,
131
- "paper_state": obs.paper_state, "metrics": obs.metrics,
132
- "done": obs.done})
133
- if obs.done:
134
- break
135
-
136
- return NumpyJSONResponse({"task_name": target, "task": get_task_by_name(target) or {},
137
- "steps": steps, "final_metrics": obs.metrics if steps else {}})
138
-
139
-
140
- @app.get("/episode/replay/{ep_id}", response_class=NumpyJSONResponse)
141
- def replay_episode(ep_id: str):
142
- """Return a stored training episode in the same format as /episode/demo."""
143
- from server.tasks import get_task_by_name
144
- ep = broadcast._registry.get(ep_id)
145
- if not ep:
146
- raise HTTPException(status_code=404, detail=f"Episode '{ep_id}' not found in registry")
147
- return NumpyJSONResponse({
148
- "task_name": ep.task_name,
149
- "task": get_task_by_name(ep.task_name) or {},
150
- "steps": ep.steps,
151
- "final_metrics": ep.final_metrics or (ep.steps[-1]["metrics"] if ep.steps else {}),
152
- })
153
-
154
-
155
- # ── Static files — viewer first, then React app (LAST, catch-all) ──
156
-
157
- _VIEWER_DIR = Path(__file__).resolve().parent.parent / "viewer"
158
- _BUILD_DIR = Path(__file__).resolve().parent.parent / "build"
159
-
160
- if _VIEWER_DIR.exists():
161
- app.mount("/viewer", StaticFiles(directory=str(_VIEWER_DIR), html=True), name="viewer")
162
-
163
-
164
- if _BUILD_DIR.exists():
165
- app.mount("/", StaticFiles(directory=str(_BUILD_DIR), html=True), name="react")
166
- else:
167
- @app.get("/", include_in_schema=False)
168
- def _no_build() -> HTMLResponse:
169
- return HTMLResponse(
170
- "<p>React build not found. Run <code>npm run build</code> in the frontend directory.</p>"
171
- "<p>Training viewer: <a href='/viewer/training.html'>/viewer/training.html</a></p>"
172
- )
173
-
174
-
175
- def run(host: str = "0.0.0.0", port: int = 9001) -> None:
176
- """Start the training server. Call from Colab notebook."""
177
- uvicorn.run(app, host=host, port=port)
178
-
179
-
180
- if __name__ == "__main__":
181
- run()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
server/models.py DELETED
@@ -1,59 +0,0 @@
1
- """
2
- OpenEnv Pydantic models for the origami RL environment.
3
-
4
- OrigamiAction — one fold per step
5
- OrigamiObservation — everything the LLM and Three.js viewer need
6
- OrigamiState — server-side episode tracking
7
- """
8
- from __future__ import annotations
9
-
10
- from typing import Any, Optional
11
-
12
- from pydantic import Field
13
-
14
- from openenv.core.env_server.types import Action, Observation, State
15
-
16
-
17
- class OrigamiAction(Action):
18
- """One fold operation sent by the client each step."""
19
-
20
- fold_type: str = Field(
21
- default="valley",
22
- description="'valley' | 'mountain' | 'pleat' | 'crimp' | 'stop'",
23
- )
24
- fold_line: dict[str, list[float]] = Field(
25
- default_factory=lambda: {"start": [0.0, 0.5], "end": [1.0, 0.5]},
26
- description="{'start': [x, y], 'end': [x, y]} normalized 0-1",
27
- )
28
- fold_angle: float = Field(
29
- default=180.0,
30
- description="Fold angle in degrees, 0-180",
31
- )
32
- layer_select: str = Field(
33
- default="all",
34
- description="'all' | 'top' | 'bottom'",
35
- )
36
-
37
-
38
- class OrigamiObservation(Observation):
39
- """Everything the LLM and Three.js viewer need.
40
-
41
- paper_state contains FOLD-compatible geometry + physics data.
42
- metrics contains all computed quality metrics.
43
- No render_urls — the browser renders from paper_state directly.
44
- """
45
-
46
- task: dict[str, Any] = Field(default_factory=dict)
47
- paper_state: dict[str, Any] = Field(default_factory=dict)
48
- metrics: dict[str, Any] = Field(default_factory=dict)
49
- fold_history: list[dict[str, Any]] = Field(default_factory=list)
50
- error: Optional[str] = Field(default=None)
51
-
52
-
53
- class OrigamiState(State):
54
- """Server-side episode tracking."""
55
-
56
- task_name: str = Field(default="")
57
- num_folds_applied: int = Field(default=0)
58
- is_valid: bool = Field(default=True)
59
- total_reward: float = Field(default=0.0)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
server/origami_environment.py DELETED
@@ -1,211 +0,0 @@
1
- """
2
- OrigamiEnvironment — OpenEnv environment wrapping the origami physics engine.
3
-
4
- Implements reset() / step() / state following the OpenEnv interface.
5
- Engine (physics, fold, validation, metrics) lives in engine/.
6
- No server-side image rendering — paper_state contains all geometry data.
7
- """
8
- from __future__ import annotations
9
-
10
- import json
11
- import os
12
- import uuid
13
- from typing import Any, Optional
14
-
15
- from openenv.core.env_server.interfaces import Environment
16
-
17
- from engine.paper import Paper
18
- from engine.fold_engine import apply_fold
19
- from engine.physics import simulate
20
- from engine.validation import validate_state
21
- from engine.metrics import compute_all_metrics
22
- from server.models import OrigamiAction, OrigamiObservation, OrigamiState
23
- from server.tasks import get_task_by_name, sample_task
24
-
25
-
26
- def _get_material(name: str):
27
- """Get material by name, falling back to paper."""
28
- try:
29
- from engine.materials import get_material
30
- return get_material(name)
31
- except Exception:
32
- from engine.materials import get_material
33
- return get_material("paper")
34
-
35
-
36
- class OrigamiEnvironment(Environment[OrigamiAction, OrigamiObservation, OrigamiState]):
37
- """Origami folding RL environment.
38
-
39
- Each episode: agent receives paper_state + task, applies folds one at a
40
- time via step(), receives metrics + reward, ends with 'stop' action or
41
- when max_folds is reached.
42
- """
43
-
44
- SUPPORTS_CONCURRENT_SESSIONS = False
45
-
46
- def __init__(self, **kwargs):
47
- super().__init__(**kwargs)
48
- self._paper: Optional[Paper] = None
49
- self._task: Optional[dict] = None
50
- self._fold_history: list[dict] = []
51
- self._metrics: dict = {}
52
- self._validation: dict = {}
53
- self._error: Optional[str] = None
54
- self._episode_id: Optional[str] = None
55
- self._step_count: int = 0
56
- self._total_reward: float = 0.0
57
-
58
- # ── reset ─────────────────────────────────────────────────────────
59
-
60
- def reset(
61
- self,
62
- seed: Optional[int] = None,
63
- episode_id: Optional[str] = None,
64
- **kwargs: Any,
65
- ) -> OrigamiObservation:
66
- self._episode_id = episode_id or str(uuid.uuid4())
67
- self._step_count = 0
68
- self._fold_history = []
69
- self._error = None
70
- self._total_reward = 0.0
71
-
72
- # Select task
73
- task_name = kwargs.get("task_name")
74
- if task_name:
75
- self._task = get_task_by_name(task_name)
76
- if not self._task:
77
- self._task = sample_task(seed=seed)
78
-
79
- # Create flat sheet
80
- mat = _get_material(self._task["material"])
81
- self._paper = Paper.create_flat_sheet(
82
- width=self._task["width"],
83
- height=self._task["height"],
84
- material=mat,
85
- )
86
-
87
- # Initial validation + metrics (no physics needed for flat sheet)
88
- self._validation = validate_state(self._paper)
89
- self._metrics = compute_all_metrics(self._paper, self._task, self._validation)
90
-
91
- return self._make_observation(done=False, reward=None)
92
-
93
- # ── step ──────────────────────────────────────────────────────────
94
-
95
- def step(
96
- self,
97
- action: OrigamiAction,
98
- timeout_s: Optional[float] = None,
99
- **kwargs: Any,
100
- ) -> OrigamiObservation:
101
- if self._paper is None or self._task is None:
102
- return self._make_observation(done=True, reward=-5.0)
103
-
104
- self._step_count += 1
105
- self._error = None
106
-
107
- # ── Stop action ───────────────────────────────────────────────
108
- if action.fold_type == "stop":
109
- return self._finalize_episode()
110
-
111
- # ── Build fold dict ───────────────────────────────────────────
112
- fold_dict = {
113
- "type": action.fold_type,
114
- "line": action.fold_line,
115
- "angle": action.fold_angle,
116
- }
117
-
118
- # ── Apply fold ────────────────────────────────────────────────
119
- new_paper, err = apply_fold(self._paper, fold_dict)
120
- if err:
121
- self._error = err
122
- return self._make_observation(done=True, reward=-5.0)
123
-
124
- self._paper = new_paper
125
- self._fold_history.append({**fold_dict, "step": self._step_count})
126
-
127
- # ── Physics relaxation ────────────────────────────────────────
128
- try:
129
- self._paper = simulate(self._paper, fold_percent=1.0)
130
- except Exception as exc:
131
- self._error = f"Physics failed: {exc}"
132
- # Continue — don't abort episode on physics failure
133
-
134
- # ── Validate ──────────────────────────────────────────────────
135
- self._validation = validate_state(self._paper)
136
-
137
- # ── Metrics ───────────────────────────────────────────────────
138
- self._metrics = compute_all_metrics(self._paper, self._task, self._validation)
139
-
140
- # ── Check termination ─────────────────────────────────────────
141
- max_folds = self._task.get("max_folds", 50)
142
- if self._step_count >= max_folds:
143
- return self._finalize_episode()
144
-
145
- if self._validation.get("self_intersections", 0) > 0:
146
- self._error = "Self-intersection detected"
147
- return self._finalize_episode()
148
-
149
- return self._make_observation(done=False, reward=None)
150
-
151
- # ── state ─────────────────────────────────────────────────────────
152
-
153
- @property
154
- def state(self) -> OrigamiState:
155
- return OrigamiState(
156
- episode_id=self._episode_id,
157
- step_count=self._step_count,
158
- task_name=self._task.get("name", "") if self._task else "",
159
- num_folds_applied=len(self._fold_history),
160
- is_valid=self._metrics.get("is_valid", True),
161
- total_reward=self._total_reward,
162
- )
163
-
164
- # ── internals ─────────────────────────────────────────────────────
165
-
166
- def _finalize_episode(self) -> OrigamiObservation:
167
- reward = self._compute_reward()
168
- self._total_reward = reward
169
- return self._make_observation(done=True, reward=reward)
170
-
171
- def _make_observation(self, done: bool, reward: Optional[float]) -> OrigamiObservation:
172
- return OrigamiObservation(
173
- done=done,
174
- reward=reward,
175
- task=self._task or {},
176
- paper_state=self._paper.to_observation_dict() if self._paper else {},
177
- metrics=self._metrics,
178
- fold_history=self._fold_history,
179
- error=self._error,
180
- )
181
-
182
- def _compute_reward(self) -> float:
183
- m = self._metrics
184
- reward = 0.0
185
-
186
- # Compactness is the main signal
187
- reward += m.get("compactness", 0.0) * 20.0
188
-
189
- # Bonus for fitting in target box
190
- if m.get("fits_target_box", False):
191
- reward += 10.0
192
-
193
- # Bonus for deployability (if task requires it)
194
- if m.get("is_deployable", False):
195
- reward += 5.0
196
-
197
- # Penalties for violations
198
- reward -= m.get("kawasaki_violations", 0) * 2.0
199
- reward -= m.get("maekawa_violations", 0) * 2.0
200
- reward -= m.get("self_intersections", 0) * 5.0
201
-
202
- # Penalty for too many folds (encourage efficiency)
203
- reward -= m.get("fold_count", 0) * 0.5
204
-
205
- # Penalty for exceeding material strain limit
206
- max_strain = m.get("max_strain", 0.0)
207
- strain_limit = self._paper.material.max_strain if self._paper else 0.05
208
- if max_strain > strain_limit:
209
- reward -= 3.0 * (max_strain / strain_limit)
210
-
211
- return float(reward)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
server/tasks.py DELETED
@@ -1,123 +0,0 @@
1
- """
2
- Task pool and curriculum for the origami RL environment.
3
-
4
- 7 tasks across 4 difficulty levels.
5
- """
6
- from __future__ import annotations
7
-
8
- import random
9
- from typing import Optional
10
-
11
-
12
- TASKS: dict[str, dict] = {
13
- "half_fold": {
14
- "name": "half_fold",
15
- "description": "Fold a 1x1 paper sheet in half along the horizontal midline.",
16
- "width": 1.0,
17
- "height": 1.0,
18
- "material": "paper",
19
- "target_ratio": 0.50,
20
- "max_folds": 3,
21
- "target_box": [1.0, 0.5, 0.02],
22
- "must_deploy": False,
23
- "difficulty": 1,
24
- },
25
- "quarter_fold": {
26
- "name": "quarter_fold",
27
- "description": "Fold a 1x1 paper sheet into quarters using two perpendicular folds.",
28
- "width": 1.0,
29
- "height": 1.0,
30
- "material": "paper",
31
- "target_ratio": 0.25,
32
- "max_folds": 5,
33
- "target_box": [0.5, 0.5, 0.04],
34
- "must_deploy": False,
35
- "difficulty": 1,
36
- },
37
- "letter_fold": {
38
- "name": "letter_fold",
39
- "description": "Fold a 1x1 paper into thirds (letter fold) using two parallel folds.",
40
- "width": 1.0,
41
- "height": 1.0,
42
- "material": "paper",
43
- "target_ratio": 0.33,
44
- "max_folds": 5,
45
- "target_box": [1.0, 0.34, 0.03],
46
- "must_deploy": False,
47
- "difficulty": 2,
48
- },
49
- "map_fold": {
50
- "name": "map_fold",
51
- "description": "Fold a 1x1 paper into eighths using a grid fold pattern. Must be re-deployable.",
52
- "width": 1.0,
53
- "height": 1.0,
54
- "material": "paper",
55
- "target_ratio": 0.125,
56
- "max_folds": 8,
57
- "target_box": [0.5, 0.25, 0.08],
58
- "must_deploy": True,
59
- "difficulty": 2,
60
- },
61
- "solar_panel": {
62
- "name": "solar_panel",
63
- "description": "Pack a 1x1 Mylar solar panel into a compact configuration using a Miura-ori style fold. Must deploy.",
64
- "width": 1.0,
65
- "height": 1.0,
66
- "material": "mylar",
67
- "target_ratio": 0.05,
68
- "max_folds": 20,
69
- "target_box": [0.25, 0.25, 0.05],
70
- "must_deploy": True,
71
- "difficulty": 3,
72
- },
73
- "shelter_wall": {
74
- "name": "shelter_wall",
75
- "description": "Fold a 1x1 aluminum sheet into a compact structural panel within strain limits.",
76
- "width": 1.0,
77
- "height": 1.0,
78
- "material": "aluminum",
79
- "target_ratio": 0.10,
80
- "max_folds": 15,
81
- "target_box": [0.5, 0.25, 0.1],
82
- "must_deploy": False,
83
- "difficulty": 3,
84
- },
85
- "stent": {
86
- "name": "stent",
87
- "description": "Fold a 0.5x1.5 nitinol sheet into a compact tube configuration for a medical stent. Superelastic material.",
88
- "width": 0.5,
89
- "height": 1.5,
90
- "material": "nitinol",
91
- "target_ratio": 0.09,
92
- "max_folds": 25,
93
- "target_box": [0.1, 0.1, 0.15],
94
- "must_deploy": True,
95
- "difficulty": 4,
96
- },
97
- }
98
-
99
-
100
- def get_task_by_name(name: str) -> Optional[dict]:
101
- """Return task dict by name, or None if not found."""
102
- return TASKS.get(name)
103
-
104
-
105
- def sample_task(seed: Optional[int] = None, difficulty: Optional[int] = None) -> dict:
106
- """Sample a random task, optionally filtered by difficulty level."""
107
- rng = random.Random(seed)
108
- pool = list(TASKS.values())
109
- if difficulty is not None:
110
- pool = [t for t in pool if t["difficulty"] == difficulty]
111
- if not pool:
112
- pool = list(TASKS.values())
113
- return dict(rng.choice(pool))
114
-
115
-
116
- def get_tasks_by_difficulty(level: int) -> list[dict]:
117
- """Return all tasks at a given difficulty level."""
118
- return [dict(t) for t in TASKS.values() if t["difficulty"] == level]
119
-
120
-
121
- def available_task_names() -> list[str]:
122
- """Return sorted list of all task names."""
123
- return sorted(TASKS.keys())
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
server_legacy.py DELETED
@@ -1,172 +0,0 @@
1
- """
2
- FastAPI server for the origami RL environment.
3
- Serves episode data to the React frontend.
4
-
5
- Usage: uvicorn server:app --reload --port 8000
6
- """
7
-
8
- try:
9
- from fastapi import FastAPI
10
- from fastapi.middleware.cors import CORSMiddleware
11
- from pydantic import BaseModel
12
- except ImportError:
13
- print("Run: pip install fastapi uvicorn pydantic")
14
- raise
15
-
16
- from typing import Optional
17
-
18
-
19
- app = FastAPI(title="OrigamiRL API")
20
-
21
- app.add_middleware(
22
- CORSMiddleware,
23
- allow_origins=["*"], # localhost:3000 for React dev
24
- allow_methods=["*"],
25
- allow_headers=["*"],
26
- )
27
-
28
-
29
- class FoldAction(BaseModel):
30
- from_point: list[float] # [x, y]
31
- to_point: list[float] # [x, y]
32
- assignment: str # 'M' or 'V'
33
- instruction: str = ""
34
-
35
-
36
- class EpisodeStep(BaseModel):
37
- step: int
38
- fold: Optional[FoldAction]
39
- paper_state: dict # FOLD JSON of current crease graph
40
- anchor_points: list[list[float]]
41
- reward: dict
42
- done: bool
43
- info: dict
44
- prompt: str # LLM prompt at this step
45
-
46
-
47
- class EpisodeResult(BaseModel):
48
- target_name: str
49
- target: dict # FOLD JSON of target
50
- steps: list[EpisodeStep]
51
- final_reward: dict
52
-
53
-
54
- @app.get("/")
55
- def health_check():
56
- """Health check — returns status and available target names."""
57
- from env.environment import OrigamiEnvironment
58
- env = OrigamiEnvironment()
59
- return {"status": "ok", "targets": env.available_targets()}
60
-
61
-
62
- @app.get("/targets")
63
- def get_targets():
64
- """Return list of available target names and their metadata."""
65
- from env.environment import OrigamiEnvironment
66
- env = OrigamiEnvironment()
67
- targets = {}
68
- for name in env.available_targets():
69
- t = env._targets[name]
70
- targets[name] = {
71
- "name": name,
72
- "level": t.get("level", 1),
73
- "description": t.get("description", ""),
74
- "n_creases": sum(1 for a in t["edges_assignment"] if a in ("M", "V")),
75
- }
76
- return targets
77
-
78
-
79
- @app.get("/episode/run")
80
- def run_episode(target: str = "half_horizontal", completion: str = ""):
81
- """
82
- Run a code-as-policy episode with a provided completion string.
83
-
84
- If completion is empty, returns the prompt so the caller knows what to send.
85
- Returns full episode result with all steps.
86
- """
87
- from env.environment import OrigamiEnvironment
88
- from env.prompts import parse_fold_list, code_as_policy_prompt
89
- from env.rewards import compute_reward, target_crease_edges
90
-
91
- env = OrigamiEnvironment(mode="step")
92
- obs = env.reset(target_name=target)
93
-
94
- if not completion:
95
- return {"prompt": obs["prompt"], "steps": [], "target": env.target}
96
-
97
- try:
98
- folds = parse_fold_list(completion)
99
- except ValueError as e:
100
- return {"error": str(e), "steps": []}
101
-
102
- steps = []
103
- for i, fold in enumerate(folds):
104
- result = env.paper.add_crease(fold["from"], fold["to"], fold["assignment"])
105
- reward = compute_reward(env.paper, result, env.target)
106
-
107
- paper_state = {
108
- "vertices": {str(k): list(v) for k, v in env.paper.graph.vertices.items()},
109
- "edges": [
110
- {
111
- "id": k,
112
- "v1": list(env.paper.graph.vertices[v[0]]),
113
- "v2": list(env.paper.graph.vertices[v[1]]),
114
- "assignment": v[2],
115
- }
116
- for k, v in env.paper.graph.edges.items()
117
- ],
118
- "anchor_points": [list(p) for p in env.paper.anchor_points()],
119
- }
120
-
121
- # Build per-step prompt reflecting current state
122
- from env.prompts import step_level_prompt
123
- step_prompt = step_level_prompt(
124
- target=env.target,
125
- paper_state=env.paper,
126
- step=i + 1,
127
- max_steps=env.max_steps,
128
- last_reward=reward,
129
- )
130
-
131
- steps.append({
132
- "step": i + 1,
133
- "fold": {
134
- "from_point": fold["from"],
135
- "to_point": fold["to"],
136
- "assignment": fold["assignment"],
137
- "instruction": fold.get("instruction", ""),
138
- },
139
- "paper_state": paper_state,
140
- "anchor_points": [list(p) for p in env.paper.anchor_points()],
141
- "reward": reward,
142
- "done": reward.get("completion", 0) > 0,
143
- "info": env._info(),
144
- "prompt": step_prompt,
145
- })
146
-
147
- if reward.get("completion", 0) > 0:
148
- break
149
-
150
- return {
151
- "target_name": target,
152
- "target": env.target,
153
- "steps": steps,
154
- "final_reward": steps[-1]["reward"] if steps else {},
155
- }
156
-
157
-
158
- @app.get("/episode/demo")
159
- def demo_episode(target: str = "half_horizontal"):
160
- """Return a pre-solved demo episode for each target."""
161
- DEMO_COMPLETIONS = {
162
- "half_horizontal": '<folds>[{"instruction": "Valley fold along horizontal center line", "from": [0, 0.5], "to": [1, 0.5], "assignment": "V"}]</folds>',
163
- "half_vertical": '<folds>[{"instruction": "Mountain fold along vertical center line", "from": [0.5, 0], "to": [0.5, 1], "assignment": "M"}]</folds>',
164
- "diagonal_main": '<folds>[{"instruction": "Valley fold along main diagonal", "from": [0, 0], "to": [1, 1], "assignment": "V"}]</folds>',
165
- "diagonal_anti": '<folds>[{"instruction": "Mountain fold along anti-diagonal", "from": [1, 0], "to": [0, 1], "assignment": "M"}]</folds>',
166
- "thirds_h": '<folds>[{"instruction": "Valley fold at one-third height", "from": [0, 0.333], "to": [1, 0.333], "assignment": "V"}, {"instruction": "Valley fold at two-thirds height", "from": [0, 0.667], "to": [1, 0.667], "assignment": "V"}]</folds>',
167
- "thirds_v": '<folds>[{"instruction": "Mountain fold at one-third width", "from": [0.333, 0], "to": [0.333, 1], "assignment": "M"}, {"instruction": "Mountain fold at two-thirds width", "from": [0.667, 0], "to": [0.667, 1], "assignment": "M"}]</folds>',
168
- "accordion_3h": '<folds>[{"instruction": "Valley fold at quarter height", "from": [0, 0.25], "to": [1, 0.25], "assignment": "V"}, {"instruction": "Mountain fold at half height", "from": [0, 0.5], "to": [1, 0.5], "assignment": "M"}, {"instruction": "Valley fold at three-quarter height", "from": [0, 0.75], "to": [1, 0.75], "assignment": "V"}]</folds>',
169
- "accordion_4h": '<folds>[{"instruction": "Valley fold at 0.2", "from": [0, 0.2], "to": [1, 0.2], "assignment": "V"}, {"instruction": "Mountain fold at 0.4", "from": [0, 0.4], "to": [1, 0.4], "assignment": "M"}, {"instruction": "Valley fold at 0.6", "from": [0, 0.6], "to": [1, 0.6], "assignment": "V"}, {"instruction": "Mountain fold at 0.8", "from": [0, 0.8], "to": [1, 0.8], "assignment": "M"}]</folds>',
170
- }
171
- completion = DEMO_COMPLETIONS.get(target, DEMO_COMPLETIONS["half_horizontal"])
172
- return run_episode(target=target, completion=completion)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
sim/__init__.py DELETED
File without changes
sim/animate.py DELETED
@@ -1,149 +0,0 @@
1
- """
2
- Matplotlib 3D animation of origami folding using OrigamiSimulator.
3
-
4
- Usage:
5
- python -m sim.animate [target_name]
6
-
7
- target_name defaults to 'half_horizontal', resolved against
8
- env/targets/<target_name>.fold relative to this file's parent directory.
9
- """
10
-
11
- from __future__ import annotations
12
-
13
- import json
14
- import sys
15
- from pathlib import Path
16
-
17
- import matplotlib.pyplot as plt
18
- import matplotlib.animation as animation
19
- import numpy as np
20
- from mpl_toolkits.mplot3d.art3d import Poly3DCollection
21
-
22
- from .simulator import OrigamiSimulator
23
-
24
- # ── Design system colours ─────────────────────────────────────────────────────
25
- BG_COLOR = '#0d0d14'
26
- AX_COLOR = '#13131d'
27
- PAPER_FACE = '#fafaf5'
28
- PAPER_EDGE = '#2a2a3a'
29
- MOUNTAIN_CLR = '#f59e0b' # amber
30
- VALLEY_CLR = '#38bdf8' # sky
31
-
32
-
33
- # ── Public API ────────────────────────────────────────────────────────────────
34
-
35
- def animate_fold(fold_file: str,
36
- n_frames: int = 80,
37
- steps_per_frame: int = 40,
38
- target_name: str = 'origami') -> None:
39
- """
40
- Animate folding from 0% → 100% → 0% in a triangle-wave loop.
41
-
42
- Parameters
43
- ----------
44
- fold_file : str
45
- Path to the .fold JSON file.
46
- n_frames : int
47
- Total animation frames (default 80 → ~40 in, 40 out).
48
- steps_per_frame : int
49
- Physics steps executed per frame.
50
- target_name : str
51
- Display name shown in the title.
52
- """
53
- fold_data = json.loads(Path(fold_file).read_text())
54
- sim = OrigamiSimulator(fold_data, subdivisions=2)
55
-
56
- # Triangle-wave fold percents: 0 → 1 → 0
57
- half = n_frames // 2
58
- fold_percents = np.concatenate([
59
- np.linspace(0.0, 1.0, half),
60
- np.linspace(1.0, 0.0, n_frames - half),
61
- ])
62
-
63
- # ── Figure setup ──────────────────────────────────────────────────────────
64
- fig = plt.figure(figsize=(9, 7), facecolor=BG_COLOR)
65
- ax = fig.add_subplot(111, projection='3d')
66
- ax.set_facecolor(AX_COLOR)
67
- ax.xaxis.pane.fill = False
68
- ax.yaxis.pane.fill = False
69
- ax.zaxis.pane.fill = False
70
- ax.grid(False)
71
- ax.set_axis_off()
72
-
73
- def update(frame: int) -> list:
74
- pct = fold_percents[frame]
75
- sim.set_fold_percent(pct)
76
- sim.step(steps_per_frame)
77
-
78
- ax.clear()
79
- ax.set_facecolor(AX_COLOR)
80
- ax.xaxis.pane.fill = False
81
- ax.yaxis.pane.fill = False
82
- ax.zaxis.pane.fill = False
83
- ax.grid(False)
84
- ax.set_axis_off()
85
-
86
- # ── Paper surface ─────────────────────────────────────────────────────
87
- verts = [sim.pos[tri] for tri in sim.triangles]
88
- poly = Poly3DCollection(
89
- verts,
90
- alpha=0.85,
91
- facecolor=PAPER_FACE,
92
- edgecolor=PAPER_EDGE,
93
- linewidth=0.2,
94
- zorder=1,
95
- )
96
- ax.add_collection3d(poly)
97
-
98
- # ── Crease / fold edges ───────────────────────────────────────────────
99
- for i in range(len(sim._crease_a)):
100
- if sim._crease_assign[i] not in ('M', 'V'):
101
- continue
102
- a, b = sim._crease_a[i], sim._crease_b[i]
103
- color = MOUNTAIN_CLR if sim._crease_assign[i] == 'M' else VALLEY_CLR
104
- ax.plot(
105
- [sim.pos[a, 0], sim.pos[b, 0]],
106
- [sim.pos[a, 1], sim.pos[b, 1]],
107
- [sim.pos[a, 2], sim.pos[b, 2]],
108
- color=color,
109
- linewidth=2.5,
110
- zorder=2,
111
- )
112
-
113
- # ── Axis limits & style ───────────────────────────────────────────────
114
- ax.set_xlim(-0.2, 1.2)
115
- ax.set_ylim(-0.2, 1.2)
116
- ax.set_zlim(-0.6, 0.6)
117
- ax.set_box_aspect([1.4, 1.4, 1.0])
118
- ax.set_title(
119
- f'OPTIGAMI — {target_name} fold: {pct * 100:.0f}%',
120
- color='#e0e0f0',
121
- fontsize=13,
122
- pad=10,
123
- )
124
-
125
- return []
126
-
127
- ani = animation.FuncAnimation(
128
- fig,
129
- update,
130
- frames=n_frames,
131
- interval=40, # ms between frames (~25 fps)
132
- blit=False,
133
- )
134
-
135
- plt.tight_layout()
136
- plt.show()
137
-
138
-
139
- def main() -> None:
140
- target = sys.argv[1] if len(sys.argv) > 1 else 'half_horizontal'
141
- fold_file = Path(__file__).parent.parent / 'env' / 'targets' / f'{target}.fold'
142
- if not fold_file.exists():
143
- print(f'Error: fold file not found: {fold_file}', file=sys.stderr)
144
- sys.exit(1)
145
- animate_fold(str(fold_file), target_name=target)
146
-
147
-
148
- if __name__ == '__main__':
149
- main()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
sim/simulator.py DELETED
@@ -1,406 +0,0 @@
1
- """
2
- Origami mass-spring dynamic relaxation simulator.
3
-
4
- Based on: Ghassaei et al., "Fast, Interactive Origami Simulation using GPU
5
- Computation", 7OSME 2018.
6
- """
7
-
8
- from __future__ import annotations
9
-
10
- import numpy as np
11
- from scipy.spatial import Delaunay
12
-
13
- # ── Physics constants ────────────────────────────────────────────────────────
14
-
15
- AXIAL_STIFFNESS = 20.0 # K = AXIAL_STIFFNESS / rest_length
16
- CREASE_STIFFNESS = 0.7 # K = CREASE_STIFFNESS * edge_length (M/V creases)
17
- PANEL_STIFFNESS = 0.7 # K = PANEL_STIFFNESS * edge_length (F / panel edges)
18
- PERCENT_DAMPING = 0.45 # global viscous damping fraction
19
- DT = 0.002 # timestep (seconds)
20
-
21
-
22
- # ── Geometry helpers ─────────────────────────────────────────────────────────
23
-
24
- def _normalize(v: np.ndarray) -> np.ndarray:
25
- n = np.linalg.norm(v)
26
- return v / n if n > 1e-12 else v
27
-
28
-
29
- def _triangulate_faces(faces_vertices: list[list[int]]) -> np.ndarray:
30
- """Fan-triangulate polygonal faces (triangles and quads supported)."""
31
- tris = []
32
- for face in faces_vertices:
33
- if len(face) == 3:
34
- tris.append(face)
35
- elif len(face) == 4:
36
- a, b, c, d = face
37
- tris.append([a, b, c])
38
- tris.append([a, c, d])
39
- else:
40
- # General fan triangulation for n-gons
41
- for k in range(1, len(face) - 1):
42
- tris.append([face[0], face[k], face[k + 1]])
43
- return np.array(tris, dtype=np.int32)
44
-
45
-
46
- def _point_on_segment(p: np.ndarray, p0: np.ndarray, p1: np.ndarray,
47
- tol: float = 1e-6) -> bool:
48
- seg = p1 - p0
49
- seg_len = np.linalg.norm(seg)
50
- if seg_len < 1e-10:
51
- return False
52
- seg_dir = seg / seg_len
53
- t = np.dot(p - p0, seg_dir)
54
- perp = (p - p0) - t * seg_dir
55
- return -tol <= t <= seg_len + tol and np.linalg.norm(perp) < tol
56
-
57
-
58
- # ── Mesh subdivision ──────────────────────────────────────────────────────────
59
-
60
- def _subdivide(pos2d: np.ndarray, triangles: np.ndarray
61
- ) -> tuple[np.ndarray, np.ndarray]:
62
- """Split each triangle into 4 by inserting edge midpoints."""
63
- midpoint_cache: dict[tuple[int, int], int] = {}
64
- new_pos = list(pos2d)
65
- new_tris = []
66
-
67
- def get_mid(i: int, j: int) -> int:
68
- key = (min(i, j), max(i, j))
69
- if key not in midpoint_cache:
70
- mid = (np.array(new_pos[i]) + np.array(new_pos[j])) / 2.0
71
- midpoint_cache[key] = len(new_pos)
72
- new_pos.append(mid)
73
- return midpoint_cache[key]
74
-
75
- for tri in triangles:
76
- a, b, c = tri
77
- ab = get_mid(a, b)
78
- bc = get_mid(b, c)
79
- ca = get_mid(c, a)
80
- new_tris.extend([
81
- [a, ab, ca],
82
- [ab, b, bc],
83
- [ca, bc, c ],
84
- [ab, bc, ca],
85
- ])
86
-
87
- return np.array(new_pos, dtype=np.float64), np.array(new_tris, dtype=np.int32)
88
-
89
-
90
- # ── Main simulator ────────────────────────────────────────────────────────────
91
-
92
- class OrigamiSimulator:
93
- """
94
- Mass-spring dynamic relaxation simulator for origami.
95
-
96
- Parameters
97
- ----------
98
- fold_data : dict
99
- Parsed FOLD JSON with keys: vertices_coords, edges_vertices,
100
- edges_assignment.
101
- subdivisions : int
102
- Number of midpoint subdivision passes (default 2 → 4× mesh density).
103
- """
104
-
105
- def __init__(self, fold_data: dict, subdivisions: int = 2) -> None:
106
- self._fold_percent = 0.0
107
- self._build(fold_data, subdivisions)
108
-
109
- # ── Public API ────────────────────────────────────────────────────────────
110
-
111
- def set_fold_percent(self, percent: float) -> None:
112
- """Update all crease spring target angles (0.0 = flat, 1.0 = fully folded)."""
113
- self._fold_percent = float(percent)
114
- self._crease_target = self._fold_percent * self._crease_full_theta
115
-
116
- def step(self, n_steps: int = 50) -> None:
117
- """Advance the simulation by n_steps Euler integration steps."""
118
- for _ in range(n_steps):
119
- self._euler_step()
120
-
121
- def reset(self) -> None:
122
- """Reset to flat state (z=0, vel=0), preserving current fold percent."""
123
- self.pos = self._flat_pos.copy()
124
- self.vel[:] = 0.0
125
-
126
- @property
127
- def crease_indices(self) -> list[tuple[int, int, str]]:
128
- """Return list of (a, b, assignment) for all crease springs."""
129
- return list(zip(
130
- self._crease_a.tolist(),
131
- self._crease_b.tolist(),
132
- self._crease_assign,
133
- ))
134
-
135
- # ── Build ─────────────────────────────────────────────────────────────────
136
-
137
- def _build(self, fold_data: dict, subdivisions: int) -> None:
138
- coords = fold_data['vertices_coords']
139
- orig_edges = fold_data['edges_vertices']
140
- orig_assign = fold_data['edges_assignment']
141
-
142
- # Original 2-D positions
143
- pts2d = np.array([[x, y] for x, y in coords], dtype=np.float64)
144
-
145
- # Build triangles from faces_vertices when available (preferred: ensures
146
- # crease edges appear as actual mesh edges after subdivision).
147
- # Quads [a,b,c,d] are split into [a,b,c] + [a,c,d].
148
- # Fall back to Delaunay only if faces_vertices is absent.
149
- if 'faces_vertices' in fold_data:
150
- triangles = _triangulate_faces(fold_data['faces_vertices'])
151
- else:
152
- tri = Delaunay(pts2d)
153
- triangles = tri.simplices.astype(np.int32)
154
-
155
- # Build original crease segments for later classification
156
- # Only M and V assignments are actual fold creases; B is boundary.
157
- orig_creases: list[tuple[np.ndarray, np.ndarray, str]] = []
158
- for (u, v), asgn in zip(orig_edges, orig_assign):
159
- if asgn in ('M', 'V'):
160
- orig_creases.append((pts2d[u], pts2d[v], asgn))
161
-
162
- # Midpoint subdivision passes
163
- pos2d = pts2d.copy()
164
- for _ in range(subdivisions):
165
- pos2d, triangles = _subdivide(pos2d, triangles)
166
-
167
- n = len(pos2d)
168
-
169
- # 3-D positions (flat, z=0)
170
- pos3d = np.zeros((n, 3), dtype=np.float64)
171
- pos3d[:, :2] = pos2d
172
-
173
- self.pos = pos3d
174
- self._flat_pos = pos3d.copy()
175
- self.vel = np.zeros((n, 3), dtype=np.float64)
176
- self.triangles = triangles
177
-
178
- self._build_beams(triangles)
179
- self._build_masses(triangles)
180
- self._build_creases(triangles, pos2d, orig_creases)
181
-
182
- def _build_beams(self, triangles: np.ndarray) -> None:
183
- """Collect all unique triangle edges as structural (axial) springs."""
184
- edge_set: set[tuple[int, int]] = set()
185
- for tri in triangles:
186
- a, b, c = tri
187
- for i, j in [(a, b), (b, c), (c, a)]:
188
- edge_set.add((min(i, j), max(i, j)))
189
-
190
- edges = np.array(sorted(edge_set), dtype=np.int32)
191
- i_arr = edges[:, 0]
192
- j_arr = edges[:, 1]
193
-
194
- rest = np.linalg.norm(self.pos[i_arr] - self.pos[j_arr], axis=1)
195
- K = AXIAL_STIFFNESS / np.maximum(rest, 1e-12)
196
-
197
- self._beam_i = i_arr
198
- self._beam_j = j_arr
199
- self._beam_rest = rest
200
- self._beam_K = K
201
-
202
- def _build_masses(self, triangles: np.ndarray) -> None:
203
- """Mass per node = sum of (adjacent triangle area / 3)."""
204
- n = len(self.pos)
205
- mass = np.zeros(n, dtype=np.float64)
206
- for tri in triangles:
207
- a, b, c = tri
208
- pa, pb, pc = self.pos[a], self.pos[b], self.pos[c]
209
- area = 0.5 * np.linalg.norm(np.cross(pb - pa, pc - pa))
210
- mass[a] += area / 3.0
211
- mass[b] += area / 3.0
212
- mass[c] += area / 3.0
213
- # Guard against zero-mass nodes (degenerate triangles)
214
- mass = np.maximum(mass, 1e-12)
215
- self.mass = mass
216
-
217
- def _build_creases(self, triangles: np.ndarray, pos2d: np.ndarray,
218
- orig_creases: list[tuple[np.ndarray, np.ndarray, str]]
219
- ) -> None:
220
- """
221
- Identify interior edges (shared by exactly 2 triangles) and classify
222
- them as M/V fold creases or F panel springs.
223
- """
224
- # Map each canonical edge → list of triangle indices containing it
225
- edge_to_tris: dict[tuple[int, int], list[int]] = {}
226
- tri_edge_map: dict[tuple[int, int], list[tuple[int, int, int]]] = {}
227
-
228
- for t_idx, tri in enumerate(triangles):
229
- a, b, c = tri
230
- for (ei, ej), opposite in [
231
- ((min(a, b), max(a, b)), c),
232
- ((min(b, c), max(b, c)), a),
233
- ((min(c, a), max(c, a)), b),
234
- ]:
235
- edge_to_tris.setdefault((ei, ej), []).append(t_idx)
236
- tri_edge_map.setdefault((ei, ej), []).append((ei, ej, opposite))
237
-
238
- crease_a: list[int] = []
239
- crease_b: list[int] = []
240
- crease_c: list[int] = []
241
- crease_d: list[int] = []
242
- crease_assign: list[str] = []
243
- crease_full_theta: list[float] = []
244
- crease_K: list[float] = []
245
-
246
- for edge_key, t_indices in edge_to_tris.items():
247
- if len(t_indices) != 2:
248
- continue # boundary edge
249
-
250
- ei, ej = edge_key
251
- # Collect opposite nodes for each of the two triangles
252
- # Find the opposite node for tri 0 and tri 1
253
- opp_nodes = [None, None]
254
- for t_pos, t_idx in enumerate(t_indices):
255
- tri = triangles[t_idx]
256
- for node in tri:
257
- if node != ei and node != ej:
258
- opp_nodes[t_pos] = node
259
- break
260
-
261
- c_node = opp_nodes[0]
262
- d_node = opp_nodes[1]
263
- if c_node is None or d_node is None:
264
- continue
265
-
266
- # Classify: check if both endpoints lie on the same original crease segment
267
- pi = pos2d[ei]
268
- pj = pos2d[ej]
269
- asgn = 'F'
270
- for p0, p1, crease_type in orig_creases:
271
- if _point_on_segment(pi, p0, p1) and _point_on_segment(pj, p0, p1):
272
- asgn = crease_type
273
- break
274
-
275
- if asgn == 'M':
276
- full_theta = +np.pi
277
- K = CREASE_STIFFNESS * np.linalg.norm(pos2d[ej] - pos2d[ei])
278
- elif asgn == 'V':
279
- full_theta = -np.pi
280
- K = CREASE_STIFFNESS * np.linalg.norm(pos2d[ej] - pos2d[ei])
281
- else: # 'F' panel
282
- full_theta = 0.0
283
- K = PANEL_STIFFNESS * np.linalg.norm(pos2d[ej] - pos2d[ei])
284
-
285
- crease_a.append(ei)
286
- crease_b.append(ej)
287
- crease_c.append(c_node)
288
- crease_d.append(d_node)
289
- crease_assign.append(asgn)
290
- crease_full_theta.append(full_theta)
291
- crease_K.append(K)
292
-
293
- self._crease_a = np.array(crease_a, dtype=np.int32)
294
- self._crease_b = np.array(crease_b, dtype=np.int32)
295
- self._crease_c = np.array(crease_c, dtype=np.int32)
296
- self._crease_d = np.array(crease_d, dtype=np.int32)
297
- self._crease_assign = crease_assign
298
- self._crease_full_theta = np.array(crease_full_theta, dtype=np.float64)
299
- self._crease_K = np.array(crease_K, dtype=np.float64)
300
- self._crease_target = np.zeros(len(crease_a), dtype=np.float64)
301
-
302
- # ── Physics ───────────────────────────────────────────────────────────────
303
-
304
- def _beam_forces(self) -> np.ndarray:
305
- """Vectorized axial spring forces for all beams."""
306
- n = len(self.pos)
307
- forces = np.zeros((n, 3), dtype=np.float64)
308
-
309
- pi = self.pos[self._beam_i]
310
- pj = self.pos[self._beam_j]
311
- diff = pj - pi
312
- lengths = np.linalg.norm(diff, axis=1, keepdims=True)
313
- lengths = np.maximum(lengths, 1e-12)
314
- unit = diff / lengths
315
-
316
- stretch = lengths[:, 0] - self._beam_rest
317
- F_mag = self._beam_K * stretch # scalar force magnitude
318
-
319
- # Damping along the edge
320
- vi = self.vel[self._beam_i]
321
- vj = self.vel[self._beam_j]
322
- rel_vel = np.sum((vj - vi) * unit, axis=1)
323
- damp_mag = PERCENT_DAMPING * rel_vel
324
- F_total = (F_mag + damp_mag)[:, None] * unit
325
-
326
- np.add.at(forces, self._beam_i, F_total)
327
- np.add.at(forces, self._beam_j, -F_total)
328
- return forces
329
-
330
- def _crease_forces(self) -> np.ndarray:
331
- """Torsional spring forces for all crease/panel edges (Python loop)."""
332
- n = len(self.pos)
333
- forces = np.zeros((n, 3), dtype=np.float64)
334
-
335
- pos = self.pos
336
- for idx in range(len(self._crease_a)):
337
- a = self._crease_a[idx]
338
- b = self._crease_b[idx]
339
- c = self._crease_c[idx]
340
- d = self._crease_d[idx]
341
- K = self._crease_K[idx]
342
- target = self._crease_target[idx]
343
-
344
- pa, pb, pc, pd = pos[a], pos[b], pos[c], pos[d]
345
-
346
- edge_vec = pb - pa
347
- edge_len = np.linalg.norm(edge_vec)
348
- if edge_len < 1e-12:
349
- continue
350
- edge_dir = edge_vec / edge_len
351
-
352
- # Face normals
353
- n1_raw = np.cross(pb - pa, pc - pa)
354
- n2_raw = np.cross(pa - pb, pd - pb)
355
- n1_len = np.linalg.norm(n1_raw)
356
- n2_len = np.linalg.norm(n2_raw)
357
- if n1_len < 1e-12 or n2_len < 1e-12:
358
- continue
359
- n1 = n1_raw / n1_len
360
- n2 = n2_raw / n2_len
361
-
362
- # Dihedral angle via atan2
363
- cross_n = np.cross(n1, n2)
364
- sin_theta = np.dot(cross_n, edge_dir)
365
- cos_theta = np.dot(n1, n2)
366
- theta = np.arctan2(sin_theta, cos_theta)
367
-
368
- delta = theta - target
369
- torque = -K * delta
370
-
371
- # Moment arms (perpendicular distance from c, d to crease line)
372
- vc = pc - pa
373
- vd = pd - pa
374
- vc_perp = vc - np.dot(vc, edge_dir) * edge_dir
375
- vd_perp = vd - np.dot(vd, edge_dir) * edge_dir
376
- h_c = np.linalg.norm(vc_perp)
377
- h_d = np.linalg.norm(vd_perp)
378
- if h_c < 1e-12 or h_d < 1e-12:
379
- continue
380
-
381
- # Forces on opposite nodes
382
- F_c = (torque / h_c) * n1
383
- F_d = -(torque / h_d) * n2
384
-
385
- # Reaction on crease nodes (moment balance)
386
- proj_c = np.dot(pc - pa, edge_dir)
387
- proj_d = np.dot(pd - pa, edge_dir)
388
- coef_c_a = 1.0 - proj_c / edge_len
389
- coef_c_b = proj_c / edge_len
390
- coef_d_a = 1.0 - proj_d / edge_len
391
- coef_d_b = proj_d / edge_len
392
-
393
- forces[c] += F_c
394
- forces[d] += F_d
395
- forces[a] -= coef_c_a * F_c + coef_d_a * F_d
396
- forces[b] -= coef_c_b * F_c + coef_d_b * F_d
397
-
398
- return forces
399
-
400
- def _euler_step(self) -> None:
401
- forces = self._beam_forces() + self._crease_forces()
402
- accel = forces / self.mass[:, None]
403
- vel_new = self.vel + accel * DT
404
- vel_new *= (1.0 - PERCENT_DAMPING * DT)
405
- self.pos += vel_new * DT
406
- self.vel = vel_new
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
trainer/__init__.py DELETED
File without changes
trainer/mock_env.py DELETED
@@ -1,249 +0,0 @@
1
- """
2
- Mock origami environment for trainer development.
3
-
4
- Returns fake PaperState responses so we can iterate on the GRPO loop
5
- without waiting for the real physics engine. The mock applies geometric
6
- transforms (vertex rotations around fold lines) but skips energy/strain
7
- computation — those return plausible dummy values.
8
- """
9
-
10
- import math
11
- import numpy as np
12
- from dataclasses import dataclass, field
13
-
14
-
15
- @dataclass
16
- class Material:
17
- name: str = "paper"
18
- thickness_mm: float = 0.1
19
- youngs_modulus_gpa: float = 2.0
20
- max_strain: float = 0.03 # 3%
21
-
22
-
23
- @dataclass
24
- class PaperState:
25
- vertices: np.ndarray # (N, 3)
26
- edges: np.ndarray # (E, 2)
27
- faces: list[list[int]]
28
- assignments: list[str] # M/V/B per edge
29
- fold_angles: np.ndarray # (E,) degrees
30
-
31
- rest_lengths: np.ndarray # (E,)
32
- strain: np.ndarray # (N,)
33
- energy: float = 0.0
34
-
35
- face_orders: list[tuple] = field(default_factory=list)
36
- num_layers: int = 1
37
-
38
- material: Material = field(default_factory=Material)
39
-
40
- bounding_box: np.ndarray = field(default_factory=lambda: np.array([1.0, 1.0, 0.0]))
41
- deployment_ratio: float = 1.0
42
- is_valid: bool = True
43
- kawasaki_violation: float = 0.0
44
- maekawa_violation: float = 0.0
45
- self_intersections: int = 0
46
-
47
- def to_dict(self) -> dict:
48
- return {
49
- "width": float(self.bounding_box[0]),
50
- "height": float(self.bounding_box[1]),
51
- "material": {
52
- "name": self.material.name,
53
- "thickness_mm": self.material.thickness_mm,
54
- "youngs_modulus_gpa": self.material.youngs_modulus_gpa,
55
- },
56
- "vertices": self.vertices.tolist(),
57
- "edges": self.edges.tolist(),
58
- "assignments": self.assignments,
59
- "fold_angles": self.fold_angles.tolist(),
60
- "num_layers_at_center": self.num_layers,
61
- "bounding_box": {
62
- "x": float(self.bounding_box[0]),
63
- "y": float(self.bounding_box[1]),
64
- "z": float(self.bounding_box[2]),
65
- },
66
- }
67
-
68
-
69
- def create_flat_sheet(width: float = 1.0, height: float = 1.0,
70
- material: Material | None = None) -> PaperState:
71
- """Create a flat rectangular sheet with 4 vertices, 5 edges (incl diagonal), 2 faces."""
72
- verts = np.array([
73
- [0, 0, 0],
74
- [width, 0, 0],
75
- [width, height, 0],
76
- [0, height, 0],
77
- ], dtype=float)
78
-
79
- edges = np.array([
80
- [0, 1], [1, 2], [2, 3], [3, 0], # boundary
81
- [0, 2], # diagonal
82
- ], dtype=int)
83
-
84
- faces = [[0, 1, 2], [0, 2, 3]]
85
- assignments = ["B", "B", "B", "B", "F"] # boundary + flat diagonal
86
- fold_angles = np.zeros(len(edges))
87
- rest_lengths = np.array([np.linalg.norm(verts[e[1]] - verts[e[0]]) for e in edges])
88
- strain = np.zeros(len(verts))
89
-
90
- mat = material or Material()
91
- return PaperState(
92
- vertices=verts, edges=edges, faces=faces,
93
- assignments=assignments, fold_angles=fold_angles,
94
- rest_lengths=rest_lengths, strain=strain,
95
- material=mat,
96
- bounding_box=np.array([width, height, 0.0]),
97
- )
98
-
99
-
100
- def _rotate_points(points: np.ndarray, axis_point: np.ndarray,
101
- axis_dir: np.ndarray, angle_rad: float) -> np.ndarray:
102
- """Rotate points around an arbitrary axis using Rodrigues' formula."""
103
- k = axis_dir / np.linalg.norm(axis_dir)
104
- translated = points - axis_point
105
- cos_a = math.cos(angle_rad)
106
- sin_a = math.sin(angle_rad)
107
- rotated = (translated * cos_a +
108
- np.cross(k, translated) * sin_a +
109
- k * (np.dot(translated, k).reshape(-1, 1)) * (1 - cos_a))
110
- return rotated + axis_point
111
-
112
-
113
- def apply_fold_mock(state: PaperState, fold: dict) -> tuple[PaperState, str | None]:
114
- """
115
- Apply a single fold operation to the paper state (mock version).
116
-
117
- fold = {
118
- "type": "valley" | "mountain",
119
- "line": {"start": [x, y], "end": [x, y]},
120
- "angle": 0-180
121
- }
122
-
123
- Returns (new_state, error_string_or_None).
124
- """
125
- fold_type = fold.get("type", "valley")
126
- line = fold.get("line", {})
127
- angle_deg = fold.get("angle", 180)
128
-
129
- start = np.array(line.get("start", [0, 0]), dtype=float)
130
- end = np.array(line.get("end", [0, 0]), dtype=float)
131
-
132
- if np.allclose(start, end):
133
- return state, "Fold line has zero length"
134
-
135
- if fold_type not in ("valley", "mountain"):
136
- return state, f"Unknown fold type: {fold_type}"
137
-
138
- # angle=0 means "no fold" — return unchanged copy
139
- if angle_deg == 0:
140
- return PaperState(
141
- vertices=state.vertices.copy(), edges=state.edges.copy(),
142
- faces=[f[:] for f in state.faces],
143
- assignments=state.assignments[:], fold_angles=state.fold_angles.copy(),
144
- rest_lengths=state.rest_lengths.copy(), strain=state.strain.copy(),
145
- energy=state.energy, face_orders=state.face_orders[:],
146
- num_layers=state.num_layers, material=state.material,
147
- bounding_box=state.bounding_box.copy(),
148
- deployment_ratio=state.deployment_ratio, is_valid=state.is_valid,
149
- kawasaki_violation=state.kawasaki_violation,
150
- maekawa_violation=state.maekawa_violation,
151
- self_intersections=state.self_intersections,
152
- ), None
153
-
154
- if not (0 < angle_deg <= 180):
155
- return state, f"Angle must be in (0, 180], got {angle_deg}"
156
-
157
- # Fold direction: valley folds up (+z), mountain folds down (-z)
158
- sign = 1.0 if fold_type == "valley" else -1.0
159
- angle_rad = sign * math.radians(angle_deg)
160
-
161
- # Determine which vertices are on the "folding" side of the line
162
- line_dir_2d = end - start
163
- normal_2d = np.array([-line_dir_2d[1], line_dir_2d[0]]) # perpendicular
164
-
165
- new_verts = state.vertices.copy()
166
- for i, v in enumerate(new_verts):
167
- point_2d = v[:2] - start
168
- side = np.dot(point_2d, normal_2d)
169
- if side > 1e-9: # on the positive side → rotate
170
- axis_point = np.array([start[0], start[1], 0.0])
171
- axis_dir = np.array([line_dir_2d[0], line_dir_2d[1], 0.0])
172
- new_verts[i] = _rotate_points(
173
- v.reshape(1, -1), axis_point, axis_dir, angle_rad
174
- ).flatten()
175
-
176
- # Update bounding box (clamp near-zero values from floating point)
177
- bb = np.ptp(new_verts, axis=0) # max - min per axis
178
- bb = np.where(np.abs(bb) < 1e-10, 0.0, bb)
179
- # Add minimum thickness per layer (material thickness)
180
- thickness = state.material.thickness_mm / 1000.0 # convert mm to m
181
- num_layers = state.num_layers + 1
182
- bb[2] = max(bb[2], thickness * num_layers)
183
-
184
- # Mock strain: small random value per vertex
185
- new_strain = np.random.uniform(0, 0.01, len(new_verts))
186
-
187
- # Mock energy
188
- new_energy = state.energy + 0.1 * angle_deg / 180.0
189
-
190
- # Update assignments — add new edge as M or V
191
- new_assignments = state.assignments.copy()
192
-
193
- # Deployment ratio estimate: each full fold (180°) halves the area in one direction.
194
- # Partial folds reduce proportionally. This is a mock approximation —
195
- # the real engine will compute from actual face overlaps.
196
- fold_factor = angle_deg / 180.0 # 1.0 for full fold, 0.5 for 90°, etc.
197
- deploy_ratio = state.deployment_ratio * (1.0 - 0.5 * fold_factor)
198
-
199
- new_state = PaperState(
200
- vertices=new_verts,
201
- edges=state.edges.copy(),
202
- faces=state.faces.copy(),
203
- assignments=new_assignments,
204
- fold_angles=state.fold_angles.copy(),
205
- rest_lengths=state.rest_lengths.copy(),
206
- strain=new_strain,
207
- energy=new_energy,
208
- material=state.material,
209
- bounding_box=bb,
210
- deployment_ratio=deploy_ratio,
211
- num_layers=state.num_layers + 1,
212
- is_valid=True,
213
- kawasaki_violation=0.0,
214
- maekawa_violation=0.0,
215
- self_intersections=0,
216
- )
217
- return new_state, None
218
-
219
-
220
- def execute_fold_strategy(strategy_fn, paper_state: PaperState,
221
- max_folds: int = 20) -> tuple[PaperState, list[dict], str | None]:
222
- """
223
- Execute a fold_strategy function against the mock environment.
224
-
225
- Returns (final_state, applied_folds, error_or_None).
226
- """
227
- state_dict = paper_state.to_dict()
228
- try:
229
- folds = strategy_fn(state_dict)
230
- except Exception as e:
231
- return paper_state, [], f"Strategy function raised: {e}"
232
-
233
- if not isinstance(folds, list):
234
- return paper_state, [], "Strategy must return a list of fold dicts"
235
-
236
- applied = []
237
- current = paper_state
238
- for i, fold in enumerate(folds):
239
- if i >= max_folds:
240
- break
241
- if not isinstance(fold, dict):
242
- return current, applied, f"Fold {i} is not a dict"
243
-
244
- current, error = apply_fold_mock(current, fold)
245
- if error:
246
- return current, applied, f"Fold {i} failed: {error}"
247
- applied.append(fold)
248
-
249
- return current, applied, None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
trainer/prompts.py DELETED
@@ -1,211 +0,0 @@
1
- """
2
- Prompt templates for origami fold strategy generation.
3
-
4
- Inspired by SpatialThinker (arXiv 2511.07403): the model must produce
5
- a structured spatial representation BEFORE generating code.
6
-
7
- Output format (4 stages):
8
- <observe> — Describe the paper geometry and constraints
9
- <plan> — Structured fold plan with coordinates and reasoning
10
- <code> — The fold_strategy() function
11
- <verify> — Predict expected outcome (deployment ratio, fold count)
12
-
13
- Dense rewards check each stage independently, not just code execution.
14
- """
15
-
16
- # ---------------------------------------------------------------------------
17
- # System prompt — defines the structured output format
18
- # ---------------------------------------------------------------------------
19
-
20
- SYSTEM_PROMPT = """\
21
- You are an origami engineer specializing in computational fold design.
22
- You solve folding tasks by reasoning spatially about paper geometry.
23
-
24
- You MUST respond in exactly this 4-stage format:
25
-
26
- <observe>
27
- Describe the paper: dimensions, material, coordinate system.
28
- Identify key geometric features (center, edges, diagonals, symmetry axes).
29
- Note constraints (max strain, max folds, target ratio).
30
- </observe>
31
-
32
- <plan>
33
- {
34
- "strategy": "description of overall approach",
35
- "folds": [
36
- {
37
- "description": "what this fold does",
38
- "type": "valley or mountain",
39
- "line_start": [x, y],
40
- "line_end": [x, y],
41
- "angle": 180,
42
- "reasoning": "why these coordinates"
43
- }
44
- ],
45
- "expected_ratio": 0.5,
46
- "expected_folds": 1
47
- }
48
- </plan>
49
-
50
- <code>
51
- ```python
52
- def fold_strategy(paper_state):
53
- # Implementation matching the plan above
54
- return [...]
55
- ```
56
- </code>
57
-
58
- <verify>
59
- Expected deployment ratio: X.XX
60
- Expected fold count: N
61
- Expected max strain: X.XXXX
62
- Potential issues: ...
63
- </verify>
64
-
65
- Rules:
66
- - Only use native Python (no imports except math, itertools, functools)
67
- - Each fold: {"type": "valley"|"mountain", "line": {"start": [x,y], "end": [x,y]}, "angle": 0-180}
68
- - Fold lines must cross the paper boundary (intersect at least 2 edges)
69
- - Valley = fold toward you (+Z), Mountain = fold away (-Z)
70
- - angle=180 = fully folded, smaller = partial fold
71
- - Each fold changes the geometry — later folds operate on already-folded paper
72
- - Fewer folds is better (efficiency matters)
73
- - Respect material strain limits\
74
- """
75
-
76
-
77
- # ---------------------------------------------------------------------------
78
- # Task templates — each includes spatial context
79
- # ---------------------------------------------------------------------------
80
-
81
- TASK_TEMPLATES = {
82
- "half_fold": {
83
- "name": "half_fold",
84
- "prompt": """\
85
- TASK: Fold a {width}m x {height}m {material} sheet in half to minimize one dimension.
86
-
87
- PAPER GEOMETRY:
88
- Corners: (0,0), ({width},0), ({width},{height}), (0,{height})
89
- Center: ({cx},{cy})
90
- Horizontal midline: y={cy} from (0,{cy}) to ({width},{cy})
91
- Vertical midline: x={cx} from ({cx},0) to ({cx},{height})
92
- Diagonals: (0,0)→({width},{height}) and ({width},0)→(0,{height})
93
-
94
- MATERIAL: {material} (thickness: {thickness_mm}mm, max strain: {max_strain_pct}%)
95
- CONSTRAINTS: Maximum {max_folds} fold operations.
96
- TARGET: Deployment ratio <= 0.5""",
97
- "target_ratio": 0.5,
98
- "max_folds": 3,
99
- },
100
-
101
- "letter_fold": {
102
- "name": "letter_fold",
103
- "prompt": """\
104
- TASK: Fold a {width}m x {height}m {material} sheet into thirds (like a letter).
105
-
106
- PAPER GEOMETRY:
107
- Corners: (0,0), ({width},0), ({width},{height}), (0,{height})
108
- Third lines: y={t1:.4f} and y={t2:.4f}
109
- Center: ({cx},{cy})
110
-
111
- MATERIAL: {material} (thickness: {thickness_mm}mm, max strain: {max_strain_pct}%)
112
- CONSTRAINTS: Maximum {max_folds} fold operations.
113
- TARGET: Deployment ratio <= 0.33""",
114
- "target_ratio": 0.33,
115
- "max_folds": 5,
116
- },
117
-
118
- "solar_panel": {
119
- "name": "solar_panel",
120
- "prompt": """\
121
- TASK: Fold a {width}m x {height}m Mylar sheet to minimize packed volume for a solar panel.
122
- The folded panel must be deployable (unfold cleanly to near-original area).
123
-
124
- PAPER GEOMETRY:
125
- Corners: (0,0), ({width},0), ({width},{height}), (0,{height})
126
- Center: ({cx},{cy})
127
- Area: {area}m²
128
-
129
- MATERIAL: Mylar (thickness: 0.05mm, Young's modulus: 4 GPa, max strain: 3%)
130
- CONSTRAINTS:
131
- - Maximum {max_folds} fold operations
132
- - Must pack into bounding box <= 15cm x 15cm x 5cm
133
- - No self-intersections
134
-
135
- TARGET: Deployment ratio <= 0.05 (95% area reduction)
136
-
137
- HINT: Tessellated patterns (alternating M/V folds in a grid) achieve high
138
- compaction with single-DOF deployment. Consider dividing the sheet into
139
- a regular grid of panels.""",
140
- "target_ratio": 0.05,
141
- "max_folds": 20,
142
- },
143
-
144
- "stent_fold": {
145
- "name": "stent_fold",
146
- "prompt": """\
147
- TASK: Fold a {width}m x {height}m Nitinol sheet into a compact cylinder for a medical stent.
148
-
149
- PAPER GEOMETRY:
150
- Corners: (0,0), ({width},0), ({width},{height}), (0,{height})
151
- Center: ({cx},{cy})
152
-
153
- MATERIAL: Nitinol (thickness: 0.1mm, Young's modulus: 75 GPa, max strain: 8%)
154
- CONSTRAINTS:
155
- - Maximum {max_folds} fold operations
156
- - Compressed diameter: 3mm, Deployed diameter: 10mm
157
-
158
- TARGET: Deployment ratio <= 0.1""",
159
- "target_ratio": 0.1,
160
- "max_folds": 15,
161
- },
162
- }
163
-
164
-
165
- # ---------------------------------------------------------------------------
166
- # Config and builders
167
- # ---------------------------------------------------------------------------
168
-
169
- TASK_CONFIGS = {
170
- "half_fold": {
171
- "width": 1.0, "height": 1.0, "material": "paper",
172
- "thickness_mm": 0.1, "max_strain_pct": 3, "max_folds": 3,
173
- },
174
- "letter_fold": {
175
- "width": 1.0, "height": 1.0, "material": "paper",
176
- "thickness_mm": 0.1, "max_strain_pct": 3, "max_folds": 5,
177
- },
178
- "solar_panel": {
179
- "width": 1.0, "height": 1.0, "material": "mylar",
180
- "thickness_mm": 0.05, "max_strain_pct": 3, "max_folds": 20,
181
- },
182
- "stent_fold": {
183
- "width": 0.1, "height": 0.03, "material": "nitinol",
184
- "thickness_mm": 0.1, "max_strain_pct": 8, "max_folds": 15,
185
- },
186
- }
187
-
188
-
189
- def build_prompt(task_name: str = "half_fold", **overrides) -> str:
190
- """Build a complete user prompt for a given task."""
191
- task = TASK_TEMPLATES[task_name]
192
- config = {**TASK_CONFIGS[task_name], **overrides}
193
-
194
- # Add computed geometry values
195
- w = config["width"]
196
- h = config["height"]
197
- config["cx"] = w / 2
198
- config["cy"] = h / 2
199
- config["area"] = w * h
200
- config["t1"] = h / 3
201
- config["t2"] = 2 * h / 3
202
-
203
- return task["prompt"].format(**config)
204
-
205
-
206
- def get_task_target_ratio(task_name: str) -> float:
207
- return TASK_TEMPLATES[task_name]["target_ratio"]
208
-
209
-
210
- def get_task_max_folds(task_name: str) -> int:
211
- return TASK_TEMPLATES[task_name]["max_folds"]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
trainer/rewards.py DELETED
@@ -1,713 +0,0 @@
1
- """
2
- Reward functions for origami GRPO training.
3
-
4
- SpatialThinker-style dense rewards (arXiv 2511.07403):
5
- 1. format_reward (0.10) — All 4 tags present, valid JSON plan, valid function
6
- 2. spatial_reward (0.20) — Fold coordinates in plan are within bounds, lines valid
7
- 3. execution_reward (0.50) — Physical validity + fold quality (code execution)
8
- 4. consistency_reward(0.20) — Plan matches code, verify matches actual results
9
-
10
- Plus legacy rewards for backwards compatibility:
11
- - code_valid, physically_valid, fold_quality
12
-
13
- Lexicographic gating: if code doesn't parse, downstream rewards are 0.
14
- """
15
-
16
- import ast
17
- import re
18
- import sys
19
- import json
20
- import math
21
- import traceback
22
- from typing import Callable
23
-
24
- # Use real engine if available, fall back to mock
25
- try:
26
- from engine.paper import Paper
27
- from engine.fold_engine import execute_fold_strategy
28
- from engine.materials import Material, get_material
29
- from engine.validation import validate_paper
30
- from engine.metrics import compute_metrics
31
-
32
- def _create_sheet(width, height, material):
33
- return Paper.create_flat_sheet(width, height, material)
34
-
35
- USE_REAL_ENGINE = True
36
- except ImportError:
37
- from trainer.mock_env import (
38
- PaperState as Paper, create_flat_sheet, execute_fold_strategy, Material
39
- )
40
-
41
- def _create_sheet(width, height, material):
42
- return create_flat_sheet(width, height, material)
43
-
44
- def validate_paper(p):
45
- from types import SimpleNamespace
46
- return SimpleNamespace(
47
- is_valid=p.is_valid, kawasaki_valid=True, maekawa_valid=True,
48
- kawasaki_violation=p.kawasaki_violation,
49
- maekawa_violation=p.maekawa_violation,
50
- self_intersection_count=p.self_intersections,
51
- )
52
-
53
- def compute_metrics(p, orig):
54
- return {
55
- "deployment_ratio": p.deployment_ratio,
56
- "fold_count": sum(1 for a in p.assignments if a in ("M", "V")),
57
- "max_strain": float(p.strain.max()) if len(p.strain) > 0 else 0.0,
58
- }
59
-
60
- USE_REAL_ENGINE = False
61
-
62
-
63
- # ---------------------------------------------------------------------------
64
- # Helpers
65
- # ---------------------------------------------------------------------------
66
-
67
- def extract_function(text: str) -> str | None:
68
- """Extract fold_strategy() from <code> blocks or triple-backtick code blocks."""
69
- # Try <code> block first (SpatialThinker format)
70
- code_match = re.search(r'<code>(.*?)</code>', text, re.DOTALL)
71
- if code_match:
72
- code_block = code_match.group(1).strip()
73
- elif text.count("```") >= 2:
74
- first = text.find("```") + 3
75
- second = text.find("```", first)
76
- code_block = text[first:second].strip()
77
- else:
78
- return None
79
-
80
- code_block = code_block.removeprefix("```python\n").removeprefix("```python\r\n")
81
- code_block = code_block.removeprefix("python\n").removeprefix("python\r\n")
82
- code_block = code_block.rstrip("`").strip()
83
-
84
- # Find the def statement
85
- def_idx = code_block.find("def ")
86
- if def_idx == -1:
87
- return None
88
- fx = code_block[def_idx:]
89
- if fx.startswith("def fold_strategy("):
90
- return fx
91
- return None
92
-
93
-
94
- def extract_section(text: str, tag: str) -> str | None:
95
- """Extract content between <tag>...</tag>."""
96
- match = re.search(rf'<{tag}>(.*?)</{tag}>', text, re.DOTALL)
97
- return match.group(1).strip() if match else None
98
-
99
-
100
- def extract_plan_json(text: str) -> dict | None:
101
- """Extract and parse the JSON fold plan from <plan> block."""
102
- plan_text = extract_section(text, "plan")
103
- if not plan_text:
104
- return None
105
- try:
106
- return json.loads(plan_text)
107
- except json.JSONDecodeError:
108
- # Try to find JSON object within the plan text
109
- brace_start = plan_text.find("{")
110
- brace_end = plan_text.rfind("}")
111
- if brace_start >= 0 and brace_end > brace_start:
112
- try:
113
- return json.loads(plan_text[brace_start:brace_end + 1])
114
- except json.JSONDecodeError:
115
- pass
116
- return None
117
-
118
-
119
- def check_imports_stdlib_only(code: str) -> tuple[bool, str]:
120
- """Check that code only imports from Python stdlib."""
121
- try:
122
- tree = ast.parse(code)
123
- except SyntaxError as e:
124
- return False, f"syntax error: {e}"
125
-
126
- ALLOWED_MODULES = {
127
- "math", "itertools", "functools", "collections", "copy",
128
- "operator", "typing", "random", "heapq", "bisect",
129
- }
130
-
131
- for node in ast.walk(tree):
132
- if isinstance(node, ast.Import):
133
- for alias in node.names:
134
- root = alias.name.split(".")[0]
135
- if root not in ALLOWED_MODULES:
136
- return False, f"non-stdlib import: {alias.name}"
137
- elif isinstance(node, ast.ImportFrom):
138
- if node.module:
139
- root = node.module.split(".")[0]
140
- if root not in ALLOWED_MODULES:
141
- return False, f"non-stdlib import: {node.module}"
142
-
143
- return True, "ok"
144
-
145
-
146
- def create_sandboxed_function(code: str) -> Callable:
147
- """
148
- Execute the function code in a restricted namespace.
149
- Returns the fold_strategy function object.
150
- """
151
- allowed_builtins = {
152
- "range", "len", "int", "float", "str", "list", "dict", "tuple",
153
- "set", "bool", "abs", "min", "max", "sum", "sorted", "reversed",
154
- "enumerate", "zip", "map", "filter", "round", "isinstance",
155
- "True", "False", "None", "print",
156
- }
157
- safe_builtins = {k: __builtins__[k] if isinstance(__builtins__, dict)
158
- else getattr(__builtins__, k)
159
- for k in allowed_builtins
160
- if (k in __builtins__ if isinstance(__builtins__, dict)
161
- else hasattr(__builtins__, k))}
162
- safe_builtins["__import__"] = __import__ # needed for stdlib imports
163
-
164
- namespace = {"__builtins__": safe_builtins}
165
- exec(code, namespace)
166
-
167
- if "fold_strategy" not in namespace:
168
- raise ValueError("No fold_strategy function defined")
169
-
170
- return namespace["fold_strategy"]
171
-
172
-
173
- # ---------------------------------------------------------------------------
174
- # State for strategy execution
175
- # ---------------------------------------------------------------------------
176
-
177
- # Current task config (set by train.py before training starts)
178
- if USE_REAL_ENGINE:
179
- _default_material = get_material("paper")
180
- else:
181
- _default_material = Material()
182
-
183
- _current_task = {
184
- "width": 1.0,
185
- "height": 1.0,
186
- "material": _default_material,
187
- "target_ratio": 0.5,
188
- "max_folds": 3,
189
- }
190
-
191
- PRINT_EVERY = 5
192
- _print_counter = 0
193
-
194
-
195
- def set_task_config(width=1.0, height=1.0, material=None,
196
- target_ratio=0.5, max_folds=3):
197
- global _current_task
198
- _current_task = {
199
- "width": width,
200
- "height": height,
201
- "material": material or Material(),
202
- "target_ratio": target_ratio,
203
- "max_folds": max_folds,
204
- }
205
-
206
-
207
- # ---------------------------------------------------------------------------
208
- # Reward 1: code_valid
209
- # ---------------------------------------------------------------------------
210
-
211
- def code_valid(completions, **kwargs) -> list[float]:
212
- """
213
- Does the generated code parse as valid Python and produce a callable?
214
-
215
- +1.0 — valid function that can be created
216
- -0.5 — correct structure but exec/sandbox fails
217
- -2.0 — no function found or syntax error
218
- -20.0 — non-stdlib imports (heavy penalty)
219
- """
220
- scores = []
221
- for completion in completions:
222
- response = completion[0]["content"]
223
- function_code = extract_function(response)
224
-
225
- if function_code is None:
226
- scores.append(-2.0)
227
- continue
228
-
229
- ok, info = check_imports_stdlib_only(function_code)
230
- if not ok:
231
- if "syntax error" in info:
232
- scores.append(-2.0)
233
- else:
234
- scores.append(-20.0) # non-stdlib imports
235
- continue
236
-
237
- try:
238
- create_sandboxed_function(function_code)
239
- scores.append(1.0)
240
- except Exception:
241
- scores.append(-0.5)
242
-
243
- return scores
244
-
245
-
246
- # ---------------------------------------------------------------------------
247
- # Reward 2: physically_valid
248
- # ---------------------------------------------------------------------------
249
-
250
- def physically_valid(completions, **kwargs) -> list[float]:
251
- """
252
- Are the folds physically possible?
253
-
254
- +1.0 — all folds valid, no violations
255
- -2.0 — per Kawasaki/Maekawa violation
256
- -5.0 — any self-intersection
257
- -1.0 — strain exceeds material limit
258
- 0.0 — function broken / can't run
259
- """
260
- scores = []
261
- for completion in completions:
262
- response = completion[0]["content"]
263
- function_code = extract_function(response)
264
-
265
- if function_code is None:
266
- scores.append(0.0)
267
- continue
268
-
269
- ok, info = check_imports_stdlib_only(function_code)
270
- if not ok:
271
- scores.append(0.0)
272
- continue
273
-
274
- try:
275
- strategy_fn = create_sandboxed_function(function_code)
276
- except Exception:
277
- scores.append(0.0)
278
- continue
279
-
280
- try:
281
- paper = _create_sheet(
282
- _current_task["width"],
283
- _current_task["height"],
284
- _current_task["material"],
285
- )
286
- original = paper
287
- final_state, applied, error = execute_fold_strategy(
288
- strategy_fn, paper, _current_task["max_folds"]
289
- )
290
-
291
- if error:
292
- scores.append(0.0)
293
- continue
294
-
295
- if len(applied) == 0:
296
- scores.append(0.0)
297
- continue
298
-
299
- # Score based on validity using engine validation
300
- val = validate_paper(final_state)
301
- metrics = compute_metrics(final_state, original)
302
-
303
- score = 1.0
304
- score -= 2.0 * val.kawasaki_violation
305
- score -= 2.0 * val.maekawa_violation
306
- if val.self_intersection_count > 0:
307
- score -= 5.0
308
- max_strain = metrics.get("max_strain", 0.0)
309
- if max_strain > _current_task["material"].max_strain:
310
- score -= 1.0
311
-
312
- scores.append(score)
313
-
314
- except TimeoutError:
315
- scores.append(-1.0)
316
- except Exception:
317
- scores.append(0.0)
318
-
319
- return scores
320
-
321
-
322
- # ---------------------------------------------------------------------------
323
- # Reward 3: fold_quality
324
- # ---------------------------------------------------------------------------
325
-
326
- def fold_quality(completions, **kwargs) -> list[float]:
327
- """
328
- How good is the folding solution?
329
-
330
- +20.0 * compactness — main reward (1 - deployment_ratio)
331
- +10.0 bonus — if meets target ratio
332
- -0.5 per fold — efficiency penalty
333
- -3.0 * overstrain — material stress penalty
334
- -1.0 — timeout
335
- -3.0 — exception
336
- 0.0 — function broken
337
- """
338
- global _print_counter
339
- scores = []
340
-
341
- for completion in completions:
342
- response = completion[0]["content"]
343
- function_code = extract_function(response)
344
-
345
- should_print = (_print_counter % PRINT_EVERY == 0)
346
- _print_counter += 1
347
-
348
- if should_print:
349
- print(f"\n--- Strategy (sample {_print_counter}) ---")
350
- print(function_code if function_code else "[no function extracted]")
351
-
352
- if function_code is None:
353
- scores.append(0.0)
354
- continue
355
-
356
- ok, info = check_imports_stdlib_only(function_code)
357
- if not ok:
358
- scores.append(0.0)
359
- continue
360
-
361
- try:
362
- strategy_fn = create_sandboxed_function(function_code)
363
- except Exception:
364
- scores.append(0.0)
365
- continue
366
-
367
- try:
368
- paper = _create_sheet(
369
- _current_task["width"],
370
- _current_task["height"],
371
- _current_task["material"],
372
- )
373
- original = paper
374
- final_state, applied, error = execute_fold_strategy(
375
- strategy_fn, paper, _current_task["max_folds"]
376
- )
377
-
378
- if error:
379
- if should_print:
380
- print(f"Error: {error}")
381
- scores.append(0.0)
382
- continue
383
-
384
- num_folds = len(applied)
385
- if num_folds == 0:
386
- scores.append(0.0)
387
- continue
388
-
389
- # Use engine metrics
390
- metrics = compute_metrics(final_state, original)
391
- deploy_ratio = metrics.get("deployment_ratio", 1.0)
392
- max_strain = metrics.get("max_strain", 0.0)
393
-
394
- # Compactness: main reward signal
395
- compactness = 1.0 - deploy_ratio
396
- score = 20.0 * compactness
397
-
398
- # Bonus for meeting target
399
- if deploy_ratio <= _current_task["target_ratio"]:
400
- score += 10.0
401
-
402
- # Fold efficiency penalty
403
- score -= 0.5 * num_folds
404
-
405
- # Strain penalty
406
- mat_limit = _current_task["material"].max_strain
407
- if max_strain > mat_limit:
408
- score -= 3.0 * (max_strain / mat_limit)
409
-
410
- if should_print:
411
- print(f"Folds: {num_folds}, Ratio: {deploy_ratio:.3f}, "
412
- f"Compactness: {compactness:.3f}, Score: {score:.2f}")
413
- bb = metrics.get("bounding_box", {})
414
- print(f"BBox: {bb.get('x',0):.3f} x {bb.get('y',0):.3f} x {bb.get('z',0):.3f}")
415
-
416
- scores.append(score)
417
-
418
- except TimeoutError:
419
- if should_print:
420
- print("Timeout!")
421
- scores.append(-1.0)
422
- except Exception as e:
423
- if should_print:
424
- print(f"Exception: {e}")
425
- scores.append(-3.0)
426
-
427
- return scores
428
-
429
-
430
- # ---------------------------------------------------------------------------
431
- # SpatialThinker Dense Rewards (weight 0.10 + 0.20 + 0.50 + 0.20 = 1.0)
432
- # ---------------------------------------------------------------------------
433
-
434
- REQUIRED_TAGS = ["observe", "plan", "code", "verify"]
435
-
436
-
437
- def format_reward(completions, **kwargs) -> list[float]:
438
- """
439
- SpatialThinker format reward (weight: 0.10).
440
-
441
- Checks that the response has all 4 structured tags, valid JSON in <plan>,
442
- and a parseable function in <code>.
443
-
444
- Score range: [0.0, 1.0]
445
- """
446
- scores = []
447
- for completion in completions:
448
- response = completion[0]["content"]
449
- score = 0.0
450
-
451
- # Check each required tag (0.15 each = 0.60 for all 4)
452
- tags_present = 0
453
- for tag in REQUIRED_TAGS:
454
- if extract_section(response, tag) is not None:
455
- tags_present += 1
456
- score += 0.15 * tags_present
457
-
458
- # Valid JSON in <plan> (0.20)
459
- plan = extract_plan_json(response)
460
- if plan is not None:
461
- score += 0.20
462
- # Plan has required fields (0.05 bonus)
463
- if "folds" in plan and isinstance(plan["folds"], list):
464
- score += 0.05
465
-
466
- # Valid function in <code> (0.15)
467
- fn = extract_function(response)
468
- if fn is not None:
469
- score += 0.15
470
-
471
- scores.append(score)
472
- return scores
473
-
474
-
475
- def spatial_reward(completions, **kwargs) -> list[float]:
476
- """
477
- SpatialThinker spatial plan quality reward (weight: 0.20).
478
-
479
- Checks that fold coordinates in <plan> are geometrically valid:
480
- - Within paper bounds
481
- - Line endpoints form valid fold lines (cross the paper)
482
- - Fold types are valid
483
- - Expected ratio/count are reasonable
484
-
485
- Score range: [0.0, 1.0]
486
- """
487
- w = _current_task["width"]
488
- h = _current_task["height"]
489
-
490
- scores = []
491
- for completion in completions:
492
- response = completion[0]["content"]
493
- plan = extract_plan_json(response)
494
-
495
- if plan is None:
496
- scores.append(0.0)
497
- continue
498
-
499
- score = 0.0
500
- folds = plan.get("folds", [])
501
-
502
- if not folds:
503
- scores.append(0.0)
504
- continue
505
-
506
- # Score each fold in the plan
507
- valid_folds = 0
508
- for fold in folds:
509
- fold_score = 0.0
510
-
511
- # Has required fields
512
- has_type = fold.get("type") in ("valley", "mountain")
513
- has_start = isinstance(fold.get("line_start"), list) and len(fold.get("line_start", [])) == 2
514
- has_end = isinstance(fold.get("line_end"), list) and len(fold.get("line_end", [])) == 2
515
-
516
- if has_type:
517
- fold_score += 0.25
518
- if has_start and has_end:
519
- fold_score += 0.25
520
- # Coordinates within paper bounds (with small tolerance)
521
- sx, sy = fold["line_start"]
522
- ex, ey = fold["line_end"]
523
- tol = 0.01
524
- in_bounds = (
525
- -tol <= sx <= w + tol and -tol <= sy <= h + tol and
526
- -tol <= ex <= w + tol and -tol <= ey <= h + tol
527
- )
528
- if in_bounds:
529
- fold_score += 0.25
530
-
531
- # Start != end (not a degenerate line)
532
- dist = math.sqrt((ex - sx)**2 + (ey - sy)**2)
533
- if dist > 0.01:
534
- fold_score += 0.25
535
-
536
- if fold_score > 0.5:
537
- valid_folds += 1
538
-
539
- # Proportion of valid folds
540
- score = valid_folds / len(folds) if folds else 0.0
541
-
542
- # Bonus: expected_ratio is reasonable (0.0 to 1.0)
543
- expected = plan.get("expected_ratio")
544
- if isinstance(expected, (int, float)) and 0.0 < expected <= 1.0:
545
- score = min(1.0, score + 0.1)
546
-
547
- scores.append(min(1.0, score))
548
- return scores
549
-
550
-
551
- def execution_reward(completions, **kwargs) -> list[float]:
552
- """
553
- SpatialThinker execution/accuracy reward (weight: 0.50).
554
-
555
- Combines code validity, physical validity, and fold quality into
556
- one normalized score. This is the main reward signal.
557
-
558
- Score range: [0.0, 1.0]
559
- """
560
- scores = []
561
- for completion in completions:
562
- response = completion[0]["content"]
563
- function_code = extract_function(response)
564
-
565
- # Gate: no function → 0
566
- if function_code is None:
567
- scores.append(0.0)
568
- continue
569
-
570
- ok, info = check_imports_stdlib_only(function_code)
571
- if not ok:
572
- scores.append(0.0)
573
- continue
574
-
575
- try:
576
- strategy_fn = create_sandboxed_function(function_code)
577
- except Exception:
578
- scores.append(0.0)
579
- continue
580
-
581
- try:
582
- paper = _create_sheet(
583
- _current_task["width"],
584
- _current_task["height"],
585
- _current_task["material"],
586
- )
587
- original = paper
588
- final_state, applied, error = execute_fold_strategy(
589
- strategy_fn, paper, _current_task["max_folds"]
590
- )
591
-
592
- if error or len(applied) == 0:
593
- scores.append(0.0)
594
- continue
595
-
596
- val = validate_paper(final_state)
597
- metrics = compute_metrics(final_state, original)
598
- deploy_ratio = metrics.get("deployment_ratio", 1.0)
599
- max_strain = metrics.get("max_strain", 0.0)
600
-
601
- # Physical validity component (0-0.3)
602
- phys = 0.3
603
- if not val.is_valid:
604
- phys -= 0.1 * val.kawasaki_violation
605
- phys -= 0.1 * val.maekawa_violation
606
- if val.self_intersection_count > 0:
607
- phys -= 0.15
608
- mat_limit = _current_task["material"].max_strain
609
- if max_strain > mat_limit:
610
- phys -= 0.05
611
- phys = max(0.0, phys)
612
-
613
- # Quality component (0-0.5)
614
- compactness = 1.0 - deploy_ratio
615
- quality = 0.5 * compactness
616
-
617
- # Target bonus (0-0.2)
618
- target = 0.0
619
- if deploy_ratio <= _current_task["target_ratio"]:
620
- target = 0.2
621
-
622
- score = phys + quality + target
623
- scores.append(min(1.0, score))
624
-
625
- except Exception:
626
- scores.append(0.0)
627
-
628
- return scores
629
-
630
-
631
- def consistency_reward(completions, **kwargs) -> list[float]:
632
- """
633
- SpatialThinker consistency reward (weight: 0.20).
634
-
635
- Checks that <plan> matches <code> and <verify> matches actual results.
636
- - Plan fold count matches code fold count
637
- - Verify predictions close to actual metrics
638
-
639
- Score range: [0.0, 1.0]
640
- """
641
- scores = []
642
- for completion in completions:
643
- response = completion[0]["content"]
644
- plan = extract_plan_json(response)
645
- verify = extract_section(response, "verify")
646
- function_code = extract_function(response)
647
-
648
- # Need at least plan + code to check consistency
649
- if plan is None or function_code is None:
650
- scores.append(0.0)
651
- continue
652
-
653
- score = 0.0
654
-
655
- # 1. Plan fold count vs code fold count (0.4)
656
- plan_folds = plan.get("folds", [])
657
- plan_count = len(plan_folds)
658
-
659
- try:
660
- strategy_fn = create_sandboxed_function(function_code)
661
- paper = _create_sheet(
662
- _current_task["width"],
663
- _current_task["height"],
664
- _current_task["material"],
665
- )
666
- original = paper
667
- final_state, applied, error = execute_fold_strategy(
668
- strategy_fn, paper, _current_task["max_folds"]
669
- )
670
- if error or len(applied) == 0:
671
- scores.append(0.0)
672
- continue
673
-
674
- actual_count = len(applied)
675
- if plan_count == actual_count:
676
- score += 0.4
677
- elif abs(plan_count - actual_count) <= 1:
678
- score += 0.2
679
-
680
- # 2. Verify predictions vs actual (0.6)
681
- if verify:
682
- metrics = compute_metrics(final_state, original)
683
- actual_ratio = metrics.get("deployment_ratio", 1.0)
684
-
685
- # Extract predicted ratio from verify text
686
- ratio_match = re.search(
687
- r'deployment\s*ratio[:\s]*([\d.]+)', verify, re.IGNORECASE)
688
- if ratio_match:
689
- predicted_ratio = float(ratio_match.group(1))
690
- error_pct = abs(predicted_ratio - actual_ratio)
691
- if error_pct < 0.05:
692
- score += 0.4
693
- elif error_pct < 0.15:
694
- score += 0.2
695
- elif error_pct < 0.3:
696
- score += 0.1
697
-
698
- # Extract predicted fold count
699
- count_match = re.search(
700
- r'fold\s*count[:\s]*(\d+)', verify, re.IGNORECASE)
701
- if count_match:
702
- predicted_count = int(count_match.group(1))
703
- if predicted_count == actual_count:
704
- score += 0.2
705
- elif abs(predicted_count - actual_count) <= 1:
706
- score += 0.1
707
-
708
- except Exception:
709
- scores.append(0.0)
710
- continue
711
-
712
- scores.append(min(1.0, score))
713
- return scores
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
trainer/train.py DELETED
@@ -1,215 +0,0 @@
1
- """
2
- Origami GRPO Training Script
3
-
4
- Usage (Colab with T4/A100):
5
- python trainer/train.py
6
-
7
- Or in a notebook:
8
- %run trainer/train.py
9
-
10
- Requires: unsloth, trl>=0.22.2, transformers>=4.56.2, trackio, datasets
11
- """
12
-
13
- import os
14
- import sys
15
-
16
- # Ensure project root is on path
17
- PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
18
- if PROJECT_ROOT not in sys.path:
19
- sys.path.insert(0, PROJECT_ROOT)
20
-
21
- from trainer.prompts import build_prompt, SYSTEM_PROMPT, get_task_target_ratio, get_task_max_folds
22
- from trainer.rewards import (
23
- code_valid, physically_valid, fold_quality, set_task_config,
24
- format_reward, spatial_reward, execution_reward, consistency_reward,
25
- )
26
-
27
- try:
28
- from engine.materials import get_material
29
- Material = type(get_material("paper")) # get the Material class
30
- except ImportError:
31
- from trainer.mock_env import Material
32
- def get_material(name):
33
- return Material()
34
-
35
- # ============================================================================
36
- # Config
37
- # ============================================================================
38
-
39
- MODEL_NAME = "unsloth/Qwen2.5-7B-Instruct"
40
- MAX_SEQ_LENGTH = 2048
41
- LORA_RANK = 4
42
-
43
- # Start with the simplest task
44
- TASK_NAME = "half_fold"
45
-
46
- # GRPO hyperparameters (from 2048 reference, adapted for origami)
47
- LEARNING_RATE = 2e-4
48
- MAX_STEPS = 600
49
- NUM_GENERATIONS = 2
50
- TEMPERATURE = 1.0
51
- BATCH_SIZE = 1
52
- GRAD_ACCUM = 1
53
- DATASET_SIZE = 1000 # replicated prompt dataset
54
-
55
-
56
- def main():
57
- # ========================================================================
58
- # 1. Load model with Unsloth
59
- # ========================================================================
60
- from unsloth import FastLanguageModel
61
-
62
- print(f"Loading model: {MODEL_NAME}")
63
- model, tokenizer = FastLanguageModel.from_pretrained(
64
- model_name=MODEL_NAME,
65
- load_in_4bit=True,
66
- max_seq_length=MAX_SEQ_LENGTH,
67
- )
68
-
69
- # ========================================================================
70
- # 2. Apply LoRA adapters
71
- # ========================================================================
72
- model = FastLanguageModel.get_peft_model(
73
- model,
74
- r=LORA_RANK,
75
- target_modules=[
76
- "q_proj", "k_proj", "v_proj", "o_proj",
77
- "gate_proj", "up_proj", "down_proj",
78
- ],
79
- lora_alpha=LORA_RANK * 2,
80
- use_gradient_checkpointing="unsloth",
81
- random_state=3407,
82
- )
83
-
84
- # ========================================================================
85
- # 3. Build prompt and dataset
86
- # ========================================================================
87
- user_prompt = build_prompt(TASK_NAME)
88
- target_ratio = get_task_target_ratio(TASK_NAME)
89
- max_folds = get_task_max_folds(TASK_NAME)
90
-
91
- # Configure reward functions with task parameters
92
- set_task_config(
93
- width=1.0,
94
- height=1.0,
95
- material=get_material("paper"),
96
- target_ratio=target_ratio,
97
- max_folds=max_folds,
98
- )
99
-
100
- # Create replicated prompt dataset (same pattern as 2048)
101
- from datasets import Dataset
102
-
103
- dataset = Dataset.from_list([
104
- {
105
- "prompt": [
106
- {"role": "system", "content": SYSTEM_PROMPT},
107
- {"role": "user", "content": user_prompt},
108
- ],
109
- }
110
- ] * DATASET_SIZE)
111
-
112
- # Calculate prompt token length for max_completion_length
113
- prompt_tokens = tokenizer.apply_chat_template(
114
- [
115
- {"role": "system", "content": SYSTEM_PROMPT},
116
- {"role": "user", "content": user_prompt},
117
- ],
118
- add_generation_prompt=True,
119
- tokenize=True,
120
- )
121
- max_prompt_length = len(prompt_tokens) + 1
122
- max_completion_length = MAX_SEQ_LENGTH - max_prompt_length
123
- print(f"Prompt tokens: {max_prompt_length}, Max completion: {max_completion_length}")
124
-
125
- # ========================================================================
126
- # 4. Test inference before training
127
- # ========================================================================
128
- print("\n=== Pre-training inference test ===")
129
- text = tokenizer.apply_chat_template(
130
- [
131
- {"role": "system", "content": SYSTEM_PROMPT},
132
- {"role": "user", "content": user_prompt},
133
- ],
134
- tokenize=False,
135
- add_generation_prompt=True,
136
- )
137
-
138
- from transformers import TextStreamer
139
- _ = model.generate(
140
- **tokenizer(text, return_tensors="pt").to("cuda"),
141
- temperature=TEMPERATURE,
142
- max_new_tokens=min(512, max_completion_length),
143
- streamer=TextStreamer(tokenizer, skip_prompt=True),
144
- )
145
-
146
- # ========================================================================
147
- # 5. Configure GRPO training
148
- # ========================================================================
149
- from trl import GRPOConfig, GRPOTrainer
150
-
151
- training_args = GRPOConfig(
152
- temperature=TEMPERATURE,
153
- learning_rate=LEARNING_RATE,
154
- weight_decay=0.001,
155
- warmup_ratio=0.1,
156
- lr_scheduler_type="linear",
157
- optim="adamw_8bit",
158
- logging_steps=1,
159
- per_device_train_batch_size=BATCH_SIZE,
160
- gradient_accumulation_steps=GRAD_ACCUM,
161
- num_generations=NUM_GENERATIONS,
162
- max_prompt_length=max_prompt_length,
163
- max_completion_length=max_completion_length,
164
- max_steps=MAX_STEPS,
165
- save_steps=100,
166
- report_to="trackio",
167
- output_dir="outputs",
168
- )
169
-
170
- # ========================================================================
171
- # 6. Create trainer and start training
172
- # ========================================================================
173
- # SpatialThinker dense rewards (weighted: 0.10 + 0.20 + 0.50 + 0.20)
174
- # These replace the legacy 3-reward setup with structured spatial reasoning
175
- trainer = GRPOTrainer(
176
- model=model,
177
- processing_class=tokenizer,
178
- reward_funcs=[
179
- format_reward, # 0.10 — 4-stage format compliance
180
- spatial_reward, # 0.20 — fold plan geometric validity
181
- execution_reward, # 0.50 — code execution + physical quality
182
- consistency_reward, # 0.20 — plan↔code↔verify agreement
183
- ],
184
- reward_weights=[0.10, 0.20, 0.50, 0.20],
185
- args=training_args,
186
- train_dataset=dataset,
187
- )
188
-
189
- print(f"\n=== Starting GRPO training: {TASK_NAME} ===")
190
- print(f"Steps: {MAX_STEPS}, Generations: {NUM_GENERATIONS}, LR: {LEARNING_RATE}")
191
- trainer.train()
192
-
193
- # ========================================================================
194
- # 7. Post-training inference
195
- # ========================================================================
196
- print("\n=== Post-training inference ===")
197
- _ = model.generate(
198
- **tokenizer(text, return_tensors="pt").to("cuda"),
199
- temperature=TEMPERATURE,
200
- max_new_tokens=min(1024, max_completion_length),
201
- streamer=TextStreamer(tokenizer, skip_prompt=True),
202
- )
203
-
204
- # ========================================================================
205
- # 8. Save model (optional)
206
- # ========================================================================
207
- save_path = "outputs/origami-fold-lora"
208
- print(f"\nSaving LoRA adapter to {save_path}")
209
- model.save_pretrained(save_path)
210
- tokenizer.save_pretrained(save_path)
211
- print("Done!")
212
-
213
-
214
- if __name__ == "__main__":
215
- main()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
training/__init__.py DELETED
File without changes
training/demo.py DELETED
@@ -1,251 +0,0 @@
1
- """
2
- training/demo.py — Run 8 zero-shot rollouts and stream them to the grid viewer.
3
-
4
- Usage:
5
- cd /path/to/optigami
6
- python -m training.demo
7
-
8
- Then open: http://localhost:9001/viewer/training.html
9
-
10
- Each of the 8 "strategies" is a heuristic that mimics what a pretrained LLM might
11
- produce for different tasks — varying from near-optimal to poor. This exercises
12
- the full broadcast → grid viewer pipeline without requiring an LLM API key.
13
- """
14
- from __future__ import annotations
15
-
16
- import asyncio
17
- import time
18
- import uuid
19
- from typing import Callable
20
-
21
- import uvicorn
22
-
23
- from server.app import app, broadcast
24
- from training.runner import run_batch
25
-
26
-
27
- # ── 8 zero-shot heuristic strategies ──────────────────────────────────────────
28
- # Each is a callable: paper_state (dict) → fold_dict
29
- # These represent the range of strategies a pretrained LLM might generate.
30
-
31
- def strategy_perfect_half(paper_state: dict) -> dict:
32
- """Valley fold exactly at horizontal midline — optimal for half_fold."""
33
- return {"type": "valley", "line": {"start": [0.0, 0.5], "end": [1.0, 0.5]}, "angle": 180.0}
34
-
35
-
36
- def strategy_slight_offset(paper_state: dict) -> dict:
37
- """Valley fold slightly off-center — almost optimal."""
38
- return {"type": "valley", "line": {"start": [0.0, 0.48], "end": [1.0, 0.48]}, "angle": 180.0}
39
-
40
-
41
- def strategy_thirds(paper_state: dict) -> dict:
42
- """Letter fold at one-third — wrong for half_fold, generates interesting geometry."""
43
- fold_count = paper_state.get("fold_count", 0)
44
- positions = [0.333, 0.667]
45
- if fold_count >= len(positions):
46
- return {"type": "stop", "line": {"start": [0.0, 0.5], "end": [1.0, 0.5]}, "angle": 0.0}
47
- return {
48
- "type": "valley" if fold_count == 0 else "mountain",
49
- "line": {"start": [0.0, positions[fold_count]], "end": [1.0, positions[fold_count]]},
50
- "angle": 180.0,
51
- }
52
-
53
-
54
- def strategy_vertical(paper_state: dict) -> dict:
55
- """Vertical fold — gets compactness but in wrong dimension for target_box."""
56
- return {"type": "valley", "line": {"start": [0.5, 0.0], "end": [0.5, 1.0]}, "angle": 180.0}
57
-
58
-
59
- def strategy_mountain(paper_state: dict) -> dict:
60
- """Mountain fold at midline — same geometry, different assignment."""
61
- return {"type": "mountain", "line": {"start": [0.0, 0.5], "end": [1.0, 0.5]}, "angle": 180.0}
62
-
63
-
64
- def strategy_accordion(paper_state: dict) -> dict:
65
- """Accordion 3-fold — overfolds, achieves high compactness but more folds."""
66
- fold_count = paper_state.get("fold_count", 0)
67
- positions = [0.25, 0.5, 0.75]
68
- assignments = ["valley", "mountain", "valley"]
69
- if fold_count >= len(positions):
70
- return {"type": "stop", "line": {"start": [0.0, 0.5], "end": [1.0, 0.5]}, "angle": 0.0}
71
- return {
72
- "type": assignments[fold_count],
73
- "line": {"start": [0.0, positions[fold_count]], "end": [1.0, positions[fold_count]]},
74
- "angle": 180.0,
75
- }
76
-
77
-
78
- def strategy_diagonal(paper_state: dict) -> dict:
79
- """Diagonal fold — achieves compactness but irregular bounding box."""
80
- return {"type": "valley", "line": {"start": [0.0, 0.0], "end": [1.0, 1.0]}, "angle": 180.0}
81
-
82
-
83
- def strategy_quarter(paper_state: dict) -> dict:
84
- """Two perpendicular folds — 4x compactness for quarter_fold task."""
85
- fold_count = paper_state.get("fold_count", 0)
86
- if fold_count == 0:
87
- return {"type": "valley", "line": {"start": [0.0, 0.5], "end": [1.0, 0.5]}, "angle": 180.0}
88
- if fold_count == 1:
89
- return {"type": "valley", "line": {"start": [0.5, 0.0], "end": [0.5, 1.0]}, "angle": 180.0}
90
- return {"type": "stop", "line": {"start": [0.0, 0.5], "end": [1.0, 0.5]}, "angle": 0.0}
91
-
92
-
93
- STRATEGIES: list[tuple[str, Callable]] = [
94
- ("perfect_half", strategy_perfect_half),
95
- ("slight_offset", strategy_slight_offset),
96
- ("thirds_fold", strategy_thirds),
97
- ("vertical_fold", strategy_vertical),
98
- ("mountain_fold", strategy_mountain),
99
- ("accordion_3", strategy_accordion),
100
- ("diagonal", strategy_diagonal),
101
- ("quarter_fold", strategy_quarter),
102
- ]
103
-
104
-
105
- # ── Demo runner ────────────────────────────────────────────────────────────────
106
-
107
- async def run_demo(task_name: str = "half_fold", delay_s: float = 0.5) -> None:
108
- """Wait for server to be ready, then fire 8 episodes."""
109
- # Give uvicorn time to bind and call startup hook (sets broadcast._loop)
110
- await asyncio.sleep(1.5)
111
-
112
- batch_id = 1
113
- names, fns = zip(*STRATEGIES)
114
- ep_ids = [f"ep_{name}" for name in names]
115
-
116
- print(f"\n[demo] Starting batch {batch_id} — task: {task_name}")
117
- print(f"[demo] Open http://localhost:9001/viewer/training.html\n")
118
-
119
- # Signal grid to clear and show G=8
120
- await broadcast.start_batch(batch_id, len(fns))
121
-
122
- await asyncio.sleep(delay_s)
123
-
124
- # Run all 8 episodes in the thread pool; broadcast_fn fires into this loop
125
- results = await asyncio.gather(*[
126
- asyncio.to_thread(
127
- _run_one,
128
- fn,
129
- task_name,
130
- ep_id,
131
- broadcast.publish,
132
- )
133
- for fn, ep_id in zip(fns, ep_ids)
134
- ])
135
-
136
- scores = [r["score"] for r in results]
137
- best_idx = max(range(len(scores)), key=lambda i: scores[i])
138
-
139
- await broadcast.finish_batch(batch_id, scores, best_episode_id=ep_ids[best_idx])
140
-
141
- print("\n[demo] Results:")
142
- for name, result in zip(names, results):
143
- print(f" {name:20s} score={result['score']:+.2f} status={result['status']}")
144
- print(f"\n[demo] Best: {names[best_idx]} (score={scores[best_idx]:+.2f})")
145
- print("\n[demo] Grid viewer running. Press Ctrl+C to stop.\n")
146
-
147
-
148
- def _run_one(
149
- strategy_fn: Callable,
150
- task_name: str,
151
- ep_id: str,
152
- broadcast_fn: Callable,
153
- ) -> dict:
154
- """Thin wrapper: adds a small sleep between steps so the viewer can animate."""
155
- from server.models import OrigamiAction
156
- from server.origami_environment import OrigamiEnvironment
157
-
158
- env = OrigamiEnvironment()
159
- obs = env.reset(task_name=task_name)
160
-
161
- broadcast_fn(ep_id, {
162
- "type": "episode_update",
163
- "episode_id": ep_id,
164
- "task_name": task_name,
165
- "step": 0,
166
- "observation": _obs_dict(obs),
167
- })
168
-
169
- max_steps = env._task.get("max_folds", 10) if env._task else 10
170
- status = "done"
171
-
172
- for step_idx in range(max_steps):
173
- if obs.done:
174
- break
175
-
176
- time.sleep(0.3) # pace so the viewer can animate each step
177
-
178
- fold_dict = strategy_fn(obs.paper_state)
179
-
180
- if fold_dict.get("type") == "stop":
181
- break
182
-
183
- action = OrigamiAction(
184
- fold_type=fold_dict["type"],
185
- fold_line=fold_dict["line"],
186
- fold_angle=float(fold_dict.get("angle", 180.0)),
187
- )
188
- obs = env.step(action)
189
-
190
- broadcast_fn(ep_id, {
191
- "type": "episode_update",
192
- "episode_id": ep_id,
193
- "task_name": task_name,
194
- "step": step_idx + 1,
195
- "observation": _obs_dict(obs),
196
- })
197
-
198
- if obs.done:
199
- break
200
- else:
201
- status = "timeout"
202
-
203
- score = obs.reward if obs.reward is not None else env._total_reward or 0.0
204
-
205
- broadcast_fn(ep_id, {
206
- "type": "episode_done",
207
- "episode_id": ep_id,
208
- "status": status,
209
- "score": float(score),
210
- "final_metrics": obs.metrics,
211
- })
212
-
213
- return {
214
- "episode_id": ep_id,
215
- "score": float(score),
216
- "final_metrics": obs.metrics,
217
- "status": status,
218
- }
219
-
220
-
221
- def _obs_dict(obs) -> dict:
222
- try:
223
- return obs.model_dump()
224
- except AttributeError:
225
- return {
226
- "paper_state": getattr(obs, "paper_state", {}),
227
- "metrics": getattr(obs, "metrics", {}),
228
- "fold_history": getattr(obs, "fold_history", []),
229
- "done": getattr(obs, "done", False),
230
- "reward": getattr(obs, "reward", None),
231
- }
232
-
233
-
234
- # ── Entry point ────────────────────────────────────────────────────────────────
235
-
236
- async def _main() -> None:
237
- config = uvicorn.Config(app, host="0.0.0.0", port=9001, log_level="warning")
238
- server = uvicorn.Server(config)
239
-
240
- # Run demo concurrently with the uvicorn server
241
- await asyncio.gather(
242
- server.serve(),
243
- run_demo(task_name="half_fold"),
244
- )
245
-
246
-
247
- if __name__ == "__main__":
248
- try:
249
- asyncio.run(_main())
250
- except KeyboardInterrupt:
251
- print("\n[demo] Stopped.")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
training/demo_llm.py DELETED
@@ -1,318 +0,0 @@
1
- """
2
- training/demo_llm.py — 8 rollouts using Claude as the zero-shot fold strategist.
3
-
4
- Usage:
5
- cd /path/to/optigami
6
- ANTHROPIC_API_KEY=sk-... python -m training.demo_llm
7
-
8
- Each of the 8 episodes calls Claude (claude-haiku-4-5) once per fold step.
9
- Claude receives the current paper state (metrics + fold history) and decides
10
- the next fold action. Episodes run concurrently; all stream to the grid viewer.
11
- """
12
- from __future__ import annotations
13
-
14
- import asyncio
15
- import json
16
- import os
17
- import re
18
- import time
19
- from typing import Any
20
-
21
- import anthropic
22
- import uvicorn
23
-
24
- from server.app import app, broadcast
25
- from server.models import OrigamiAction
26
- from server.origami_environment import OrigamiEnvironment
27
- from server.tasks import get_task_by_name
28
-
29
-
30
- TASK_NAME = "half_fold"
31
- NUM_EPISODES = 8
32
- MODEL = "claude-haiku-4-5-20251001"
33
-
34
-
35
- # ── System prompt ──────────────────────────────────────────────────────────────
36
-
37
- SYSTEM_PROMPT = """\
38
- You are an origami folding agent controlling a robotic paper-folding system.
39
-
40
- COORDINATE SYSTEM
41
- - Paper starts as a flat sheet; coordinates are normalized to the sheet's original size.
42
- - x=0 is the left edge, x=1 is the right edge.
43
- - y=0 is the bottom edge, y=1 is the top edge.
44
- - Fold line endpoints must be on or outside the paper boundary (0.0–1.0 range).
45
- - A fold line that runs off the edge is fine — it just doesn't affect paper outside the sheet.
46
-
47
- FOLD TYPES
48
- - "valley": folds the paper toward you (creates a V crease when viewed from above).
49
- - "mountain": folds the paper away from you (creates a ^ crease).
50
- - "stop": you are satisfied — no more folds needed.
51
-
52
- PHYSICS
53
- - angle=180 means a fully flat fold (paper halved).
54
- - Smaller angles (e.g. 90) create partial folds.
55
- - Each fold updates compactness, bounding_box, and strain readings.
56
- - Kawasaki/Maekawa violations indicate geometrically invalid crease patterns.
57
-
58
- RESPONSE FORMAT — output ONLY valid JSON, no markdown, no explanation:
59
- {"type": "valley", "line": {"start": [x, y], "end": [x, y]}, "angle": 180}
60
- """
61
-
62
- # Eight distinct approach hints — gives diversity across the parallel episodes.
63
- APPROACH_HINTS = [
64
- "Try a single clean horizontal fold at the exact midline.",
65
- "Try a single clean vertical fold at the exact midline.",
66
- "Use two folds: first horizontal then vertical, to create quarters.",
67
- "Use a diagonal fold from one corner to the opposite.",
68
- "Try folding at y=0.333 and y=0.667 to create thirds.",
69
- "Try a single fold but vary the position slightly off-center to explore.",
70
- "Use a mountain fold instead of valley for the primary crease.",
71
- "Try to reach the target box in as few folds as possible — stop early if done.",
72
- ]
73
-
74
-
75
- # ── LLM strategy factory ───────────────────────────────────────────────────────
76
-
77
- def make_llm_strategy(client: anthropic.Anthropic, task: dict, episode_num: int):
78
- """Return a strategy_fn for one episode.
79
-
80
- Each episode has its own conversation history (multi-turn) and a unique
81
- approach hint so the 8 concurrent episodes explore different strategies.
82
- """
83
- history: list[dict[str, Any]] = []
84
- hint = APPROACH_HINTS[episode_num % len(APPROACH_HINTS)]
85
- prev_compactness: list[float] = [0.0] # mutable cell for delta tracking
86
-
87
- def strategy(paper_state: dict, fold_history: list[dict]) -> dict:
88
- fold_count = paper_state.get("fold_count", 0)
89
- compactness = float(paper_state.get("compactness", 0))
90
- bb = paper_state.get("bounding_box", [1, 1, 0])
91
- fits = paper_state.get("fits_target_box", False)
92
- strain = paper_state.get("max_strain", 0.0)
93
- kaw = paper_state.get("kawasaki_violations", 0)
94
- target_box = task.get("target_box", [1, 0.5, 0.02])
95
- max_folds = task.get("max_folds", 3)
96
-
97
- delta = compactness - prev_compactness[0]
98
- prev_compactness[0] = compactness
99
-
100
- # Summarise what has been done so far
101
- history_lines = ""
102
- if fold_history:
103
- history_lines = "Folds applied so far:\n"
104
- for i, f in enumerate(fold_history, 1):
105
- t = f.get("type", "?")
106
- ln = f.get("line", {})
107
- s = ln.get("start", [0, 0])
108
- e = ln.get("end", [1, 1])
109
- ang = f.get("angle", 180)
110
- history_lines += (
111
- f" {i}. {t} fold "
112
- f"from ({s[0]:.3f},{s[1]:.3f}) to ({e[0]:.3f},{e[1]:.3f}) "
113
- f"angle={ang}\n"
114
- )
115
- else:
116
- history_lines = "No folds applied yet — paper is flat.\n"
117
-
118
- sign = "+" if delta >= 0 else ""
119
- user_msg = (
120
- f"Task: {task['description']}\n"
121
- f"Sheet: {task['width']}×{task['height']} {task['material']}\n"
122
- f"Target bounding box: {target_box} (must fit inside to succeed)\n"
123
- f"Max folds remaining: {max_folds - fold_count}\n"
124
- f"\n"
125
- f"{history_lines}"
126
- f"\n"
127
- f"Current state after fold {fold_count}/{max_folds}:\n"
128
- f" compactness : {compactness:.4f} (Δ {sign}{delta:.4f})\n"
129
- f" bounding_box: [{bb[0]:.4f}, {bb[1]:.4f}, {bb[2]:.5f}]\n"
130
- f" fits_target : {'YES ✓' if fits else 'no'}\n"
131
- f" max_strain : {strain:.5f}\n"
132
- f" kaw_violations: {kaw}\n"
133
- f"\n"
134
- f"Approach hint: {hint}\n"
135
- f"\n"
136
- f"What is your next fold action? "
137
- f"Return \"stop\" if the target is already achieved or no useful fold remains."
138
- )
139
-
140
- history.append({"role": "user", "content": user_msg})
141
-
142
- response = client.messages.create(
143
- model=MODEL,
144
- max_tokens=150,
145
- system=SYSTEM_PROMPT,
146
- messages=history,
147
- )
148
- reply = response.content[0].text.strip()
149
- history.append({"role": "assistant", "content": reply})
150
-
151
- # Handle explicit "stop" text before JSON parse
152
- if reply.lower().startswith("stop") or '"type": "stop"' in reply:
153
- return {"type": "stop", "line": {"start": [0, 0.5], "end": [1, 0.5]}, "angle": 0.0}
154
-
155
- # Extract JSON — handles markdown code fences
156
- match = re.search(r'\{[^{}]+\}', reply, re.DOTALL)
157
- if not match:
158
- # Malformed response — default safe fold then stop next turn
159
- return {"type": "valley", "line": {"start": [0.0, 0.5], "end": [1.0, 0.5]}, "angle": 180.0}
160
-
161
- try:
162
- fold_dict = json.loads(match.group())
163
- except json.JSONDecodeError:
164
- return {"type": "valley", "line": {"start": [0.0, 0.5], "end": [1.0, 0.5]}, "angle": 180.0}
165
-
166
- fold_dict.setdefault("type", "valley")
167
- fold_dict.setdefault("line", {"start": [0.0, 0.5], "end": [1.0, 0.5]})
168
- fold_dict.setdefault("angle", 180.0)
169
- return fold_dict
170
-
171
- return strategy
172
-
173
-
174
- # ── Episode runner ─────────────────────────────────────────────────────────────
175
-
176
- def run_episode_llm(
177
- strategy_fn,
178
- task_name: str,
179
- ep_id: str,
180
- broadcast_fn,
181
- ) -> dict:
182
- env = OrigamiEnvironment()
183
- obs = env.reset(task_name=task_name)
184
- task = env._task or {}
185
-
186
- broadcast_fn(ep_id, {
187
- "type": "episode_update",
188
- "episode_id": ep_id,
189
- "task_name": task_name,
190
- "step": 0,
191
- "observation": _obs_dict(obs),
192
- })
193
-
194
- max_steps = task.get("max_folds", 5)
195
- status = "done"
196
-
197
- for step_idx in range(max_steps):
198
- if obs.done:
199
- break
200
-
201
- # Merge paper_state + metrics for the strategy
202
- ps = dict(obs.paper_state)
203
- ps.update(obs.metrics)
204
- ps["fold_count"] = step_idx
205
-
206
- try:
207
- fold_dict = strategy_fn(ps, list(obs.fold_history))
208
- except Exception as exc:
209
- broadcast_fn(ep_id, {
210
- "type": "episode_done", "episode_id": ep_id,
211
- "status": "error", "score": 0.0,
212
- "final_metrics": obs.metrics, "error": str(exc),
213
- })
214
- return {"episode_id": ep_id, "score": 0.0, "status": "error"}
215
-
216
- if fold_dict.get("type") == "stop":
217
- break
218
-
219
- time.sleep(0.5) # pace for viewer animation
220
-
221
- action = OrigamiAction(
222
- fold_type=fold_dict["type"],
223
- fold_line=fold_dict["line"],
224
- fold_angle=float(fold_dict.get("angle", 180.0)),
225
- )
226
- obs = env.step(action)
227
-
228
- broadcast_fn(ep_id, {
229
- "type": "episode_update",
230
- "episode_id": ep_id,
231
- "task_name": task_name,
232
- "step": step_idx + 1,
233
- "observation": _obs_dict(obs),
234
- })
235
-
236
- if obs.done:
237
- break
238
- else:
239
- status = "timeout"
240
-
241
- score = obs.reward if obs.reward is not None else (env._total_reward or 0.0)
242
- broadcast_fn(ep_id, {
243
- "type": "episode_done",
244
- "episode_id": ep_id,
245
- "status": status,
246
- "score": float(score),
247
- "final_metrics": obs.metrics,
248
- })
249
-
250
- return {"episode_id": ep_id, "score": float(score), "status": status}
251
-
252
-
253
- def _obs_dict(obs) -> dict:
254
- try:
255
- return obs.model_dump()
256
- except AttributeError:
257
- return {
258
- "paper_state": getattr(obs, "paper_state", {}),
259
- "metrics": getattr(obs, "metrics", {}),
260
- "fold_history": getattr(obs, "fold_history", []),
261
- "done": getattr(obs, "done", False),
262
- "reward": getattr(obs, "reward", None),
263
- }
264
-
265
-
266
- # ── Main ───────────────────────────────────────────────────────��──────────────
267
-
268
- async def run_demo() -> None:
269
- api_key = os.environ.get("ANTHROPIC_API_KEY")
270
- if not api_key:
271
- raise RuntimeError("Set ANTHROPIC_API_KEY environment variable")
272
-
273
- client = anthropic.Anthropic(api_key=api_key)
274
- task = get_task_by_name(TASK_NAME)
275
-
276
- await asyncio.sleep(1.5) # wait for server startup
277
-
278
- print(f"\n[llm-demo] Model : {MODEL}")
279
- print(f"[llm-demo] Task : {TASK_NAME} — {task['description']}")
280
- print(f"[llm-demo] Open : http://localhost:9001/viewer/training.html\n")
281
- print(f"[llm-demo] Episodes:")
282
- for i, hint in enumerate(APPROACH_HINTS):
283
- print(f" ep_{i:02d} hint: {hint}")
284
- print()
285
-
286
- await broadcast.start_batch(1, NUM_EPISODES)
287
-
288
- ep_ids = [f"ep_{i:02d}" for i in range(NUM_EPISODES)]
289
- strategies = [make_llm_strategy(client, task, i) for i in range(NUM_EPISODES)]
290
-
291
- results = await asyncio.gather(*[
292
- asyncio.to_thread(run_episode_llm, fn, TASK_NAME, ep_id, broadcast.publish)
293
- for fn, ep_id in zip(strategies, ep_ids)
294
- ])
295
-
296
- scores = [r["score"] for r in results]
297
- best_idx = max(range(len(scores)), key=lambda i: scores[i])
298
-
299
- await broadcast.finish_batch(1, scores, best_episode_id=ep_ids[best_idx])
300
-
301
- print("\n[llm-demo] Results:")
302
- for i, (result, hint) in enumerate(zip(results, APPROACH_HINTS)):
303
- marker = " ← best" if i == best_idx else ""
304
- print(f" ep_{i:02d} score={result['score']:+.2f} status={result['status']} hint: {hint}{marker}")
305
- print(f"\n[llm-demo] Press Ctrl+C to stop.\n")
306
-
307
-
308
- async def _main() -> None:
309
- config = uvicorn.Config(app, host="0.0.0.0", port=9001, log_level="warning")
310
- server = uvicorn.Server(config)
311
- await asyncio.gather(server.serve(), run_demo())
312
-
313
-
314
- if __name__ == "__main__":
315
- try:
316
- asyncio.run(_main())
317
- except KeyboardInterrupt:
318
- print("\n[llm-demo] Stopped.")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
training/runner.py DELETED
@@ -1,191 +0,0 @@
1
- """
2
- TrainingRunner — parallel episode executor for GRPO training.
3
-
4
- Each episode runs in a ThreadPoolExecutor thread.
5
- After every env.step(), observations are pushed to the broadcast server (fire-and-forget).
6
- """
7
- from __future__ import annotations
8
-
9
- import uuid
10
- from concurrent.futures import ThreadPoolExecutor, as_completed
11
- from typing import Any, Callable, Optional
12
-
13
- from server.models import OrigamiAction
14
- from server.origami_environment import OrigamiEnvironment
15
-
16
-
17
- BroadcastFn = Callable[[str, dict], None]
18
-
19
-
20
- def run_episode(
21
- strategy_fn: Callable[[dict], dict],
22
- task_name: str,
23
- ep_id: Optional[str] = None,
24
- broadcast_fn: Optional[BroadcastFn] = None,
25
- max_steps: Optional[int] = None,
26
- ) -> dict:
27
- """Run a single origami episode with a given strategy function.
28
-
29
- Args:
30
- strategy_fn: Callable that receives paper_state dict and returns a fold dict:
31
- {"type": "valley"|"mountain"|"pleat"|"crimp"|"stop",
32
- "line": {"start": [x, y], "end": [x, y]},
33
- "angle": 180.0}
34
- task_name: Name of the task (from server/tasks.py)
35
- ep_id: Episode identifier for broadcast; auto-generated if None
36
- broadcast_fn: Optional callback(ep_id, data) for live streaming
37
- max_steps: Override task's max_folds if provided
38
-
39
- Returns:
40
- dict with keys: episode_id, score, final_metrics, fold_history, status
41
- """
42
- ep_id = ep_id or str(uuid.uuid4())[:8]
43
- env = OrigamiEnvironment()
44
-
45
- obs = env.reset(task_name=task_name)
46
-
47
- if broadcast_fn:
48
- broadcast_fn(ep_id, {
49
- "type": "episode_update",
50
- "episode_id": ep_id,
51
- "task_name": task_name,
52
- "step": 0,
53
- "observation": _obs_to_dict(obs),
54
- })
55
-
56
- step_limit = max_steps or env._task.get("max_folds", 20) if env._task else 20
57
- status = "done"
58
-
59
- for step_idx in range(step_limit):
60
- if obs.done:
61
- break
62
-
63
- # Strategy generates a fold dict
64
- try:
65
- fold_dict = strategy_fn(obs.paper_state)
66
- except Exception as exc:
67
- status = "error"
68
- if broadcast_fn:
69
- broadcast_fn(ep_id, {
70
- "type": "episode_done",
71
- "episode_id": ep_id,
72
- "status": "error",
73
- "score": obs.reward or 0.0,
74
- "final_metrics": obs.metrics,
75
- "error": str(exc),
76
- })
77
- break
78
-
79
- fold_type = fold_dict.get("type", "valley")
80
- fold_line = fold_dict.get("line", {"start": [0, 0.5], "end": [1, 0.5]})
81
- fold_angle = float(fold_dict.get("angle", 180.0))
82
-
83
- action = OrigamiAction(
84
- fold_type=fold_type,
85
- fold_line=fold_line,
86
- fold_angle=fold_angle,
87
- )
88
- obs = env.step(action)
89
-
90
- if broadcast_fn:
91
- broadcast_fn(ep_id, {
92
- "type": "episode_update",
93
- "episode_id": ep_id,
94
- "task_name": task_name,
95
- "step": step_idx + 1,
96
- "observation": _obs_to_dict(obs),
97
- })
98
-
99
- if obs.done:
100
- break
101
- else:
102
- status = "timeout"
103
-
104
- score = obs.reward if obs.reward is not None else (env._total_reward or 0.0)
105
-
106
- if broadcast_fn:
107
- broadcast_fn(ep_id, {
108
- "type": "episode_done",
109
- "episode_id": ep_id,
110
- "status": status,
111
- "score": float(score),
112
- "final_metrics": obs.metrics,
113
- })
114
-
115
- return {
116
- "episode_id": ep_id,
117
- "score": float(score),
118
- "final_metrics": obs.metrics,
119
- "fold_history": obs.fold_history,
120
- "status": status,
121
- }
122
-
123
-
124
- def run_batch(
125
- strategy_fns: list[Callable[[dict], dict]],
126
- task_name: str,
127
- broadcast_fn: Optional[BroadcastFn] = None,
128
- batch_id: Optional[int] = None,
129
- max_workers: int = 8,
130
- ) -> list[dict]:
131
- """Run G episodes in parallel with a ThreadPoolExecutor.
132
-
133
- Args:
134
- strategy_fns: List of G strategy callables (one per completion)
135
- task_name: Task to use for all episodes
136
- broadcast_fn: Optional broadcast callback, called after each step
137
- batch_id: Batch identifier for broadcast
138
- max_workers: Max parallel threads (bounded by G)
139
-
140
- Returns:
141
- List of episode result dicts, in same order as strategy_fns
142
- """
143
- n = len(strategy_fns)
144
- ep_ids = [f"ep_{(batch_id or 0):04d}_{i:02d}" for i in range(n)]
145
- workers = min(max_workers, n)
146
-
147
- results: list[dict] = [{}] * n
148
-
149
- with ThreadPoolExecutor(max_workers=workers) as pool:
150
- futures = {
151
- pool.submit(
152
- run_episode,
153
- fn,
154
- task_name,
155
- ep_ids[i],
156
- broadcast_fn,
157
- ): i
158
- for i, fn in enumerate(strategy_fns)
159
- }
160
-
161
- for future in as_completed(futures):
162
- idx = futures[future]
163
- try:
164
- results[idx] = future.result()
165
- except Exception as exc:
166
- results[idx] = {
167
- "episode_id": ep_ids[idx],
168
- "score": 0.0,
169
- "final_metrics": {},
170
- "fold_history": [],
171
- "status": "error",
172
- "error": str(exc),
173
- }
174
-
175
- return results
176
-
177
-
178
- def _obs_to_dict(obs) -> dict:
179
- """Convert OrigamiObservation to a JSON-serializable dict."""
180
- try:
181
- return obs.model_dump()
182
- except AttributeError:
183
- return {
184
- "task": obs.task if hasattr(obs, "task") else {},
185
- "paper_state": obs.paper_state if hasattr(obs, "paper_state") else {},
186
- "metrics": obs.metrics if hasattr(obs, "metrics") else {},
187
- "fold_history": obs.fold_history if hasattr(obs, "fold_history") else [],
188
- "done": obs.done if hasattr(obs, "done") else False,
189
- "reward": obs.reward if hasattr(obs, "reward") else None,
190
- "error": obs.error if hasattr(obs, "error") else None,
191
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
viz/__init__.py DELETED
File without changes
viz/renderer.py DELETED
@@ -1,315 +0,0 @@
1
- """
2
- Matplotlib-based crease pattern renderer.
3
- Used for quick observability during training and debugging.
4
- """
5
- import json
6
- import numpy as np
7
- import matplotlib.pyplot as plt
8
- import matplotlib.patches as patches
9
- from matplotlib.animation import FuncAnimation
10
- from typing import Optional
11
-
12
-
13
- # Design system colors
14
- _COLOR_MOUNTAIN = "#f59e0b"
15
- _COLOR_VALLEY = "#38bdf8"
16
- _COLOR_PAPER = "#fafaf5"
17
- _COLOR_PAPER_EDGE = "#e2e8f0"
18
- _COLOR_AX_BG = "#1a1a2e"
19
- _COLOR_ANCHOR = "#4a4a6a"
20
- _COLOR_REWARD_BG = "#13131d"
21
- _COLOR_GRID = "#2a2a3a"
22
- _COLOR_VALIDITY = "#22d3ee"
23
- _COLOR_PROGRESS = "#22c55e"
24
- _COLOR_ECONOMY = "#a78bfa"
25
-
26
-
27
- def draw_paper_state(ax, paper_state, target=None, step=None, reward=None):
28
- """
29
- Draw the current crease pattern on a matplotlib axes object.
30
-
31
- Args:
32
- ax: matplotlib axes
33
- paper_state: PaperState instance
34
- target: optional FOLD dict for target crease ghost overlay
35
- step: step number for title (None = "Initial")
36
- reward: unused, kept for signature compatibility
37
- """
38
- ax.set_facecolor(_COLOR_AX_BG)
39
-
40
- # Unit square paper
41
- square = patches.Rectangle(
42
- (0, 0), 1, 1,
43
- facecolor=_COLOR_PAPER,
44
- edgecolor=_COLOR_PAPER_EDGE,
45
- linewidth=1.5,
46
- zorder=1,
47
- )
48
- ax.add_patch(square)
49
-
50
- # Target ghost overlay
51
- if target is not None:
52
- verts = target["vertices_coords"]
53
- edges_v = target["edges_vertices"]
54
- edges_a = target["edges_assignment"]
55
- for (v1, v2), assignment in zip(edges_v, edges_a):
56
- if assignment not in ("M", "V"):
57
- continue
58
- x1, y1 = verts[v1]
59
- x2, y2 = verts[v2]
60
- color = _COLOR_MOUNTAIN if assignment == "M" else _COLOR_VALLEY
61
- ax.plot(
62
- [x1, x2], [y1, y2],
63
- color=color,
64
- alpha=0.2,
65
- linewidth=1,
66
- linestyle="--",
67
- zorder=2,
68
- )
69
-
70
- # Current crease edges
71
- for edge in paper_state.crease_edges():
72
- x1, y1 = edge["v1"]
73
- x2, y2 = edge["v2"]
74
- assignment = edge["assignment"]
75
- color = _COLOR_MOUNTAIN if assignment == "M" else _COLOR_VALLEY
76
- ax.plot(
77
- [x1, x2], [y1, y2],
78
- color=color,
79
- linewidth=2.5,
80
- linestyle="-",
81
- solid_capstyle="round",
82
- zorder=3,
83
- )
84
- # Endpoint dots
85
- ax.plot(
86
- [x1, x2], [y1, y2],
87
- color=color,
88
- marker="o",
89
- markersize=5,
90
- linestyle="none",
91
- zorder=4,
92
- )
93
-
94
- # Anchor points as gray crosses
95
- for x, y in paper_state.anchor_points():
96
- ax.plot(
97
- x, y,
98
- color=_COLOR_ANCHOR,
99
- marker="+",
100
- markersize=3,
101
- linestyle="none",
102
- zorder=5,
103
- )
104
-
105
- # Title
106
- title = f"Step {step}" if step is not None else "Initial"
107
- ax.set_title(title, color="white", fontfamily="monospace", fontsize=10, pad=6)
108
-
109
- # Remove ticks and spines
110
- ax.set_xticks([])
111
- ax.set_yticks([])
112
- for spine in ax.spines.values():
113
- spine.set_visible(False)
114
-
115
- ax.set_xlim(-0.05, 1.05)
116
- ax.set_ylim(-0.05, 1.05)
117
- ax.set_aspect("equal")
118
-
119
-
120
- def draw_reward_bars(ax, reward: dict):
121
- """
122
- Draw a horizontal bar chart of reward components.
123
-
124
- Args:
125
- ax: matplotlib axes
126
- reward: dict with keys kawasaki, maekawa, blb, progress, economy (all 0-1)
127
- """
128
- components = ["kawasaki", "maekawa", "blb", "progress", "economy"]
129
- colors = {
130
- "kawasaki": _COLOR_VALIDITY,
131
- "maekawa": _COLOR_VALIDITY,
132
- "blb": _COLOR_VALIDITY,
133
- "progress": _COLOR_PROGRESS,
134
- "economy": _COLOR_ECONOMY,
135
- }
136
-
137
- values = [float(reward.get(c, 0.0)) for c in components]
138
-
139
- ax.set_facecolor(_COLOR_REWARD_BG)
140
-
141
- bar_colors = [colors[c] for c in components]
142
- bars = ax.barh(
143
- components,
144
- values,
145
- height=0.6,
146
- color=bar_colors,
147
- zorder=2,
148
- )
149
-
150
- # Value labels at end of each bar
151
- for bar, val in zip(bars, values):
152
- ax.text(
153
- min(val + 0.02, 0.98),
154
- bar.get_y() + bar.get_height() / 2,
155
- f"{val:.2f}",
156
- va="center",
157
- ha="left",
158
- color="white",
159
- fontfamily="monospace",
160
- fontsize=8,
161
- zorder=3,
162
- )
163
-
164
- # Y-axis label style
165
- ax.tick_params(axis="y", colors="white", labelsize=8)
166
- for label in ax.get_yticklabels():
167
- label.set_fontfamily("monospace")
168
-
169
- # Subtle x gridlines
170
- for x_pos in [0.25, 0.5, 0.75, 1.0]:
171
- ax.axvline(x_pos, color=_COLOR_GRID, linewidth=0.8, zorder=1)
172
-
173
- ax.set_xlim(0, 1.0)
174
- ax.set_xticks([])
175
- ax.tick_params(axis="x", colors="white")
176
- for spine in ax.spines.values():
177
- spine.set_visible(False)
178
-
179
- ax.set_title("Reward Breakdown", color="white", fontfamily="monospace", fontsize=10, pad=6)
180
-
181
-
182
- def render_episode(fold_history, target, rewards_history, save_path=None):
183
- """
184
- Create a multi-panel figure showing an entire episode.
185
-
186
- Args:
187
- fold_history: list of PaperState snapshots (one per step)
188
- target: FOLD dict of target crease pattern
189
- rewards_history: list of reward dicts (one per step)
190
- save_path: if provided, save PNG here; otherwise plt.show()
191
-
192
- Returns:
193
- matplotlib Figure
194
- """
195
- n_states = len(fold_history)
196
- show_states = min(n_states, 4)
197
-
198
- fig = plt.figure(figsize=(4 * show_states + 4, 5), facecolor="#0d0d14")
199
- gs = fig.add_gridspec(
200
- 1, show_states + 1,
201
- width_ratios=[1] * show_states + [1.2],
202
- wspace=0.3,
203
- )
204
-
205
- # Paper state panels (up to 4)
206
- for i in range(show_states):
207
- # Evenly sample from fold_history if more than 4 steps
208
- idx = int(i * (n_states - 1) / max(show_states - 1, 1)) if show_states > 1 else 0
209
- ax = fig.add_subplot(gs[0, i])
210
- draw_paper_state(
211
- ax,
212
- fold_history[idx],
213
- target=target,
214
- step=idx + 1,
215
- reward=rewards_history[idx] if idx < len(rewards_history) else None,
216
- )
217
-
218
- # Reward curves panel
219
- ax_reward = fig.add_subplot(gs[0, show_states])
220
- ax_reward.set_facecolor(_COLOR_REWARD_BG)
221
-
222
- steps = list(range(1, len(rewards_history) + 1))
223
- curve_specs = [
224
- ("progress", _COLOR_PROGRESS, "progress"),
225
- ("kawasaki", _COLOR_VALIDITY, "kawasaki"),
226
- ("total", "#f8fafc", "total"),
227
- ]
228
-
229
- for key, color, label in curve_specs:
230
- vals = [r.get(key, 0.0) for r in rewards_history]
231
- ax_reward.plot(steps, vals, color=color, linewidth=1.5, label=label)
232
-
233
- ax_reward.set_xlim(1, max(len(rewards_history), 1))
234
- ax_reward.set_title("Reward Curves", color="white", fontfamily="monospace", fontsize=10, pad=6)
235
- ax_reward.tick_params(colors="white", labelsize=8)
236
- ax_reward.legend(
237
- fontsize=7,
238
- facecolor=_COLOR_REWARD_BG,
239
- edgecolor=_COLOR_GRID,
240
- labelcolor="white",
241
- )
242
- for spine in ax_reward.spines.values():
243
- spine.set_color(_COLOR_GRID)
244
-
245
- if save_path:
246
- fig.savefig(save_path, dpi=150, facecolor="#0d0d14", bbox_inches="tight")
247
- else:
248
- plt.show()
249
-
250
- return fig
251
-
252
-
253
- def render_training_curves(log_path: str):
254
- """
255
- Read a JSONL log file and plot training curves.
256
-
257
- Each line must be a JSON object with reward component keys.
258
-
259
- Args:
260
- log_path: path to JSONL training log
261
-
262
- Returns:
263
- matplotlib Figure
264
- """
265
- records = []
266
- with open(log_path) as f:
267
- for line in f:
268
- line = line.strip()
269
- if not line:
270
- continue
271
- records.append(json.loads(line))
272
-
273
- episodes = list(range(1, len(records) + 1))
274
-
275
- keys_to_plot = [
276
- ("total", "#f8fafc", "total reward"),
277
- ("progress", _COLOR_PROGRESS, "progress"),
278
- ("kawasaki", _COLOR_VALIDITY, "kawasaki"),
279
- ("maekawa", _COLOR_VALIDITY, "maekawa"),
280
- ("blb", _COLOR_VALIDITY, "blb"),
281
- ]
282
-
283
- fig, axes = plt.subplots(
284
- 2, 1,
285
- figsize=(10, 6),
286
- facecolor="#0d0d14",
287
- gridspec_kw={"hspace": 0.4},
288
- )
289
-
290
- # Top: total + progress
291
- ax_top = axes[0]
292
- ax_top.set_facecolor(_COLOR_REWARD_BG)
293
- for key, color, label in keys_to_plot[:2]:
294
- vals = [r.get(key, 0.0) for r in records]
295
- ax_top.plot(episodes, vals, color=color, linewidth=1.5, label=label)
296
- ax_top.set_title("Training: Total & Progress", color="white", fontfamily="monospace", fontsize=10)
297
- ax_top.tick_params(colors="white", labelsize=8)
298
- ax_top.legend(fontsize=8, facecolor=_COLOR_REWARD_BG, edgecolor=_COLOR_GRID, labelcolor="white")
299
- for spine in ax_top.spines.values():
300
- spine.set_color(_COLOR_GRID)
301
-
302
- # Bottom: kawasaki, maekawa, blb
303
- ax_bot = axes[1]
304
- ax_bot.set_facecolor(_COLOR_REWARD_BG)
305
- for key, color, label in keys_to_plot[2:]:
306
- vals = [r.get(key, 0.0) for r in records]
307
- ax_bot.plot(episodes, vals, color=color, linewidth=1.5, label=label, alpha=0.85)
308
- ax_bot.set_title("Training: Validity Checks", color="white", fontfamily="monospace", fontsize=10)
309
- ax_bot.set_xlabel("Episode", color="white", fontsize=9)
310
- ax_bot.tick_params(colors="white", labelsize=8)
311
- ax_bot.legend(fontsize=8, facecolor=_COLOR_REWARD_BG, edgecolor=_COLOR_GRID, labelcolor="white")
312
- for spine in ax_bot.spines.values():
313
- spine.set_color(_COLOR_GRID)
314
-
315
- return fig