sissississi Claude Opus 4.6 commited on
Commit
efeed27
·
1 Parent(s): aa44758

Add physics engine (Layer 4) and instruction planner (Layer 1)

Browse files

Engine: real origami geometry with face splitting along fold lines,
Rodrigues' rotation, bar-and-hinge energy model, Kawasaki/Maekawa
validation, triangle self-intersection detection, and deployment metrics.

Planner: instruction parser that handles "make a paper crane" style
inputs, decomposes into sub-goals via origami knowledge base (7 models,
6 bases, 13 fold operations with real coordinates), and generates
LLM-ready prompts for each step.

Rewards updated to use real engine (with mock fallback).

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

engine/__init__.py ADDED
File without changes
engine/__pycache__/__init__.cpython-314.pyc ADDED
Binary file (147 Bytes). View file
 
engine/__pycache__/fold_engine.cpython-314.pyc ADDED
Binary file (8.77 kB). View file
 
engine/__pycache__/materials.cpython-314.pyc ADDED
Binary file (3.49 kB). View file
 
engine/__pycache__/metrics.cpython-314.pyc ADDED
Binary file (5.63 kB). View file
 
engine/__pycache__/paper.cpython-314.pyc ADDED
Binary file (24.4 kB). View file
 
engine/__pycache__/physics.cpython-314.pyc ADDED
Binary file (12.8 kB). View file
 
engine/__pycache__/validation.cpython-314.pyc ADDED
Binary file (11 kB). View file
 
engine/fold_engine.py ADDED
@@ -0,0 +1,207 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ return new_paper, None
155
+
156
+
157
+ # ────────────────────────────────────────────────────────────────────
158
+ # Strategy executor (matches mock_env.execute_fold_strategy signature)
159
+ # ────────────────────────────────────────────────────────────────────
160
+
161
+ def execute_fold_strategy(
162
+ strategy_fn: Callable,
163
+ paper: Paper,
164
+ max_folds: int = 20,
165
+ ) -> tuple[Paper, list[dict], str | None]:
166
+ """Execute a ``fold_strategy`` function against the real physics engine.
167
+
168
+ Signature matches ``mock_env.execute_fold_strategy`` so the trainer
169
+ reward functions can swap engines transparently.
170
+
171
+ Parameters
172
+ ----------
173
+ strategy_fn : callable
174
+ ``strategy_fn(paper_state_dict) -> list[dict]``
175
+ paper : Paper
176
+ The initial paper state.
177
+ max_folds : int
178
+ Maximum number of folds to apply.
179
+
180
+ Returns
181
+ -------
182
+ (final_paper, applied_folds, error_or_None)
183
+ """
184
+ state_dict = paper.to_dict()
185
+ try:
186
+ folds = strategy_fn(state_dict)
187
+ except Exception as exc:
188
+ return paper, [], f"Strategy function raised: {exc}"
189
+
190
+ if not isinstance(folds, list):
191
+ return paper, [], "Strategy must return a list of fold dicts"
192
+
193
+ applied: list[dict] = []
194
+ current = paper
195
+
196
+ for i, fold in enumerate(folds):
197
+ if i >= max_folds:
198
+ break
199
+ if not isinstance(fold, dict):
200
+ return current, applied, f"Fold {i} is not a dict"
201
+
202
+ current, error = apply_fold(current, fold)
203
+ if error:
204
+ return current, applied, f"Fold {i} failed: {error}"
205
+ applied.append(fold)
206
+
207
+ return current, applied, None
engine/materials.py ADDED
@@ -0,0 +1,79 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 ADDED
@@ -0,0 +1,104 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ }
engine/paper.py ADDED
@@ -0,0 +1,488 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+
93
+ # ── constructors ────────────────────────────────────────────────
94
+
95
+ @staticmethod
96
+ def create_flat_sheet(
97
+ width: float = 1.0,
98
+ height: float = 1.0,
99
+ material: Material | None = None,
100
+ ) -> "Paper":
101
+ """Create a flat rectangular sheet with 4 vertices, 5 edges
102
+ (including one diagonal), and 2 triangular faces."""
103
+ mat = material if material is not None else get_material("paper")
104
+
105
+ verts = np.array([
106
+ [0.0, 0.0, 0.0],
107
+ [width, 0.0, 0.0],
108
+ [width, height, 0.0],
109
+ [0.0, height, 0.0],
110
+ ], dtype=np.float64)
111
+
112
+ edges = np.array([
113
+ [0, 1], # bottom
114
+ [1, 2], # right
115
+ [2, 3], # top
116
+ [3, 0], # left
117
+ [0, 2], # diagonal
118
+ ], dtype=np.int64)
119
+
120
+ faces: list[list[int]] = [[0, 1, 2], [0, 2, 3]]
121
+ assignments = ["B", "B", "B", "B", "F"]
122
+ fold_angles = np.zeros(len(edges), dtype=np.float64)
123
+ rest_lengths = np.array(
124
+ [np.linalg.norm(verts[e[1]] - verts[e[0]]) for e in edges],
125
+ dtype=np.float64,
126
+ )
127
+
128
+ return Paper(
129
+ vertices=verts,
130
+ edges=edges,
131
+ faces=faces,
132
+ assignments=assignments,
133
+ fold_angles=fold_angles,
134
+ material=mat,
135
+ rest_lengths=rest_lengths,
136
+ original_area=width * height,
137
+ )
138
+
139
+ # ── dict / prompt serialization (matches mock_env.PaperState.to_dict) ──
140
+
141
+ def to_dict(self) -> dict:
142
+ """Return a simplified dict suitable for LLM prompts.
143
+
144
+ The format matches ``mock_env.PaperState.to_dict()`` so that the
145
+ trainer reward functions work with either engine.
146
+ """
147
+ bb = self.bounding_box
148
+ return {
149
+ "width": float(bb[0]),
150
+ "height": float(bb[1]),
151
+ "material": {
152
+ "name": self.material.name,
153
+ "thickness_mm": self.material.thickness_mm,
154
+ "youngs_modulus_gpa": self.material.youngs_modulus_gpa,
155
+ },
156
+ "vertices": self.vertices.tolist(),
157
+ "edges": self.edges.tolist(),
158
+ "assignments": list(self.assignments),
159
+ "fold_angles": self.fold_angles.tolist(),
160
+ "num_layers_at_center": self.num_layers,
161
+ "bounding_box": {
162
+ "x": float(bb[0]),
163
+ "y": float(bb[1]),
164
+ "z": float(bb[2]),
165
+ },
166
+ }
167
+
168
+ # ── FOLD format serialization ───────────────────────────────────
169
+
170
+ def to_fold_json(self) -> str:
171
+ """Serialize to FOLD JSON format (v1.1 subset)."""
172
+ fold = {
173
+ "file_spec": 1.1,
174
+ "file_creator": "optigami",
175
+ "file_classes": ["singleModel"],
176
+ "frame_classes": ["foldedForm"],
177
+ "vertices_coords": self.vertices.tolist(),
178
+ "edges_vertices": self.edges.tolist(),
179
+ "edges_assignment": self.assignments,
180
+ "edges_foldAngle": self.fold_angles.tolist(),
181
+ "faces_vertices": self.faces,
182
+ "faceOrders": [list(fo) for fo in self.face_orders],
183
+ }
184
+ return json.dumps(fold, indent=2)
185
+
186
+ @staticmethod
187
+ def from_fold_json(data: str | dict, material: Material | None = None) -> "Paper":
188
+ """Deserialize from FOLD JSON format."""
189
+ if isinstance(data, str):
190
+ data = json.loads(data)
191
+
192
+ verts = np.array(data["vertices_coords"], dtype=np.float64)
193
+ edges = np.array(data["edges_vertices"], dtype=np.int64)
194
+ faces = data.get("faces_vertices", [])
195
+ assignments = data.get("edges_assignment", ["U"] * len(edges))
196
+ fold_angles = np.array(
197
+ data.get("edges_foldAngle", [0.0] * len(edges)),
198
+ dtype=np.float64,
199
+ )
200
+ face_orders = [tuple(fo) for fo in data.get("faceOrders", [])]
201
+
202
+ rest_lengths = np.array(
203
+ [np.linalg.norm(verts[e[1]] - verts[e[0]]) for e in edges],
204
+ dtype=np.float64,
205
+ )
206
+
207
+ mat = material if material is not None else get_material("paper")
208
+
209
+ # Approximate original area from convex hull of initial XY footprint
210
+ try:
211
+ from scipy.spatial import ConvexHull
212
+ hull = ConvexHull(verts[:, :2])
213
+ area = hull.volume # 2-D ConvexHull.volume is area
214
+ except Exception:
215
+ # Fallback: bounding-box area from XY coordinates
216
+ if len(verts) >= 2:
217
+ ptp = np.ptp(verts[:, :2], axis=0)
218
+ area = float(ptp[0] * ptp[1])
219
+ else:
220
+ area = 0.0
221
+
222
+ return Paper(
223
+ vertices=verts,
224
+ edges=edges,
225
+ faces=faces,
226
+ assignments=assignments,
227
+ fold_angles=fold_angles,
228
+ face_orders=face_orders,
229
+ material=mat,
230
+ rest_lengths=rest_lengths,
231
+ original_area=area,
232
+ )
233
+
234
+ # ── computed properties ─────────────────────────────────────────
235
+
236
+ @property
237
+ def bounding_box(self) -> np.ndarray:
238
+ """Axis-aligned bounding-box dimensions (dx, dy, dz)."""
239
+ if len(self.vertices) == 0:
240
+ return np.zeros(3)
241
+ ptp = np.ptp(self.vertices, axis=0)
242
+ ptp = np.where(np.abs(ptp) < 1e-12, 0.0, ptp)
243
+ # Ensure minimum z height from material thickness * layers
244
+ t = self.material.thickness_mm / 1000.0
245
+ ptp[2] = max(ptp[2], t * self.num_layers)
246
+ return ptp
247
+
248
+ @property
249
+ def num_layers(self) -> int:
250
+ """Estimate layer count from face-order triples.
251
+
252
+ Falls back to 1 + number of M/V edges as a simple heuristic when
253
+ face_orders is empty.
254
+ """
255
+ if self.face_orders:
256
+ face_ids = set()
257
+ for fo in self.face_orders:
258
+ face_ids.add(fo[0])
259
+ face_ids.add(fo[1])
260
+ return max(len(face_ids), 1)
261
+ # Heuristic: each fold adds one layer
262
+ mv_count = sum(1 for a in self.assignments if a in ("M", "V"))
263
+ return 1 + mv_count
264
+
265
+ # ── topology helpers ────────────────────────────────────────────
266
+
267
+ def _find_or_add_vertex(self, point_3d: np.ndarray, tol: float = 1e-8) -> int:
268
+ """Return index of an existing vertex close to *point_3d*, or add a
269
+ new vertex and return its index."""
270
+ for i, v in enumerate(self.vertices):
271
+ if np.linalg.norm(v - point_3d) < tol:
272
+ return i
273
+ idx = len(self.vertices)
274
+ self.vertices = np.vstack([self.vertices, point_3d.reshape(1, 3)])
275
+ return idx
276
+
277
+ def _find_or_add_edge(self, v1: int, v2: int) -> int:
278
+ """Return index of edge (v1,v2) or (v2,v1), or add a new edge and
279
+ return its index. New edges get assignment 'F' and fold-angle 0."""
280
+ for i, e in enumerate(self.edges):
281
+ if (e[0] == v1 and e[1] == v2) or (e[0] == v2 and e[1] == v1):
282
+ return i
283
+ idx = len(self.edges)
284
+ self.edges = np.vstack([self.edges, np.array([[v1, v2]], dtype=np.int64)])
285
+ self.assignments.append("F")
286
+ self.fold_angles = np.append(self.fold_angles, 0.0)
287
+ # Rest length for the new edge
288
+ rl = np.linalg.norm(self.vertices[v1] - self.vertices[v2])
289
+ self.rest_lengths = np.append(self.rest_lengths, rl)
290
+ return idx
291
+
292
+ # ── face splitting ──────────────────────────────────────────────
293
+
294
+ def split_faces_along_line(
295
+ self,
296
+ start_2d: np.ndarray | list,
297
+ end_2d: np.ndarray | list,
298
+ ) -> list[int]:
299
+ """Split every face that the 2-D line (start_2d -> end_2d) crosses.
300
+
301
+ The line is infinite for intersection purposes (we test each face
302
+ edge-segment against the full fold-line extent clipped to the paper).
303
+
304
+ Returns a list of edge indices that lie *on* the fold line (i.e. the
305
+ newly created edges along the fold path).
306
+
307
+ This mutates ``self`` in-place (vertices, edges, faces, assignments,
308
+ fold_angles, rest_lengths are updated).
309
+ """
310
+ start_2d = np.asarray(start_2d, dtype=np.float64)
311
+ end_2d = np.asarray(end_2d, dtype=np.float64)
312
+
313
+ fold_edge_indices: list[int] = []
314
+ new_faces: list[list[int]] = []
315
+
316
+ faces_to_process = list(range(len(self.faces)))
317
+
318
+ for fi in faces_to_process:
319
+ face = self.faces[fi]
320
+ n = len(face)
321
+
322
+ # Gather intersection points along the face boundary
323
+ hits: list[tuple[int, np.ndarray]] = [] # (local_edge_index, point_2d)
324
+
325
+ for k in range(n):
326
+ v_a = face[k]
327
+ v_b = face[(k + 1) % n]
328
+ pa = self.vertices[v_a][:2]
329
+ pb = self.vertices[v_b][:2]
330
+
331
+ pt = _seg_seg_intersect_2d(start_2d, end_2d, pa, pb)
332
+ if pt is not None:
333
+ hits.append((k, pt))
334
+
335
+ # Deduplicate hits that are at the same location (e.g. hitting a vertex)
336
+ if len(hits) >= 2:
337
+ unique_hits: list[tuple[int, np.ndarray]] = [hits[0]]
338
+ for h in hits[1:]:
339
+ is_dup = False
340
+ for uh in unique_hits:
341
+ if np.linalg.norm(h[1] - uh[1]) < 1e-8:
342
+ is_dup = True
343
+ break
344
+ if not is_dup:
345
+ unique_hits.append(h)
346
+ hits = unique_hits
347
+
348
+ if len(hits) < 2:
349
+ # Line does not fully cross this face — keep face as-is
350
+ new_faces.append(face)
351
+ continue
352
+
353
+ # We only handle the first two intersection points (one chord across face)
354
+ hit_a_edge_idx, hit_a_pt = hits[0]
355
+ hit_b_edge_idx, hit_b_pt = hits[1]
356
+
357
+ # Create / find 3-D vertices at intersection points (z=0 for flat, interpolated otherwise)
358
+ def _interp_z(pt2d: np.ndarray, edge_local: int) -> np.ndarray:
359
+ """Interpolate z from the edge endpoints."""
360
+ v_a = face[edge_local]
361
+ v_b = face[(edge_local + 1) % n]
362
+ pa = self.vertices[v_a]
363
+ pb = self.vertices[v_b]
364
+ seg = pb[:2] - pa[:2]
365
+ seg_len = np.linalg.norm(seg)
366
+ if seg_len < 1e-12:
367
+ return np.array([pt2d[0], pt2d[1], pa[2]])
368
+ t = np.linalg.norm(pt2d - pa[:2]) / seg_len
369
+ t = np.clip(t, 0.0, 1.0)
370
+ z = pa[2] + t * (pb[2] - pa[2])
371
+ return np.array([pt2d[0], pt2d[1], z])
372
+
373
+ pt_a_3d = _interp_z(hit_a_pt, hit_a_edge_idx)
374
+ pt_b_3d = _interp_z(hit_b_pt, hit_b_edge_idx)
375
+
376
+ idx_a = self._find_or_add_vertex(pt_a_3d)
377
+ idx_b = self._find_or_add_vertex(pt_b_3d)
378
+
379
+ if idx_a == idx_b:
380
+ new_faces.append(face)
381
+ continue
382
+
383
+ # Add the fold-line edge between the two intersection points
384
+ fold_eidx = self._find_or_add_edge(idx_a, idx_b)
385
+ fold_edge_indices.append(fold_eidx)
386
+
387
+ # ── Split the face into two sub-faces ──
388
+ # Walk around the face vertices, inserting idx_a and idx_b at the
389
+ # appropriate positions, then split into two loops.
390
+ ordered_verts = list(face)
391
+
392
+ # Insert intersection vertices into the vertex ring if not already present
393
+ def _insert_after(ring: list[int], after_local: int, vid: int) -> list[int]:
394
+ """Insert *vid* after position *after_local* if it is not already
395
+ adjacent in the ring at that position."""
396
+ pos = after_local + 1
397
+ if ring[after_local % len(ring)] == vid:
398
+ return ring
399
+ if ring[pos % len(ring)] == vid:
400
+ return ring
401
+ return ring[:pos] + [vid] + ring[pos:]
402
+
403
+ # Determine insertion order — always insert the one with the
404
+ # larger local-edge index first so that the earlier index stays valid.
405
+ if hit_a_edge_idx <= hit_b_edge_idx:
406
+ ordered_verts = _insert_after(ordered_verts, hit_b_edge_idx, idx_b)
407
+ # Recompute hit_a_edge_idx offset if idx_b was inserted before it
408
+ # (it shouldn't be, since hit_b >= hit_a, but guard anyway)
409
+ a_pos = hit_a_edge_idx
410
+ ordered_verts = _insert_after(ordered_verts, a_pos, idx_a)
411
+ else:
412
+ ordered_verts = _insert_after(ordered_verts, hit_a_edge_idx, idx_a)
413
+ ordered_verts = _insert_after(ordered_verts, hit_b_edge_idx, idx_b)
414
+
415
+ # Now split the ring at idx_a and idx_b
416
+ try:
417
+ pos_a = ordered_verts.index(idx_a)
418
+ pos_b = ordered_verts.index(idx_b)
419
+ except ValueError:
420
+ new_faces.append(face)
421
+ continue
422
+
423
+ if pos_a > pos_b:
424
+ pos_a, pos_b = pos_b, pos_a
425
+
426
+ loop1 = ordered_verts[pos_a: pos_b + 1]
427
+ loop2 = ordered_verts[pos_b:] + ordered_verts[: pos_a + 1]
428
+
429
+ # Only keep faces with >= 3 unique vertices
430
+ for loop in (loop1, loop2):
431
+ unique = list(dict.fromkeys(loop)) # preserve order, dedupe
432
+ if len(unique) >= 3:
433
+ new_faces.append(unique)
434
+ # Ensure all edges of this new face exist
435
+ for k in range(len(unique)):
436
+ self._find_or_add_edge(unique[k], unique[(k + 1) % len(unique)])
437
+
438
+ self.faces = new_faces
439
+ return fold_edge_indices
440
+
441
+ # ── vertex side test ────────────────────────────────────────────
442
+
443
+ def get_vertices_on_side(
444
+ self,
445
+ line_start: np.ndarray | list,
446
+ line_end: np.ndarray | list,
447
+ side: str = "positive",
448
+ ) -> list[int]:
449
+ """Return indices of vertices on one side of a 2-D line.
450
+
451
+ *side* can be ``"positive"`` or ``"negative"``. The positive side is
452
+ defined by the left-hand normal of (line_end - line_start).
453
+ """
454
+ ls = np.asarray(line_start, dtype=np.float64)[:2]
455
+ le = np.asarray(line_end, dtype=np.float64)[:2]
456
+ d = le - ls
457
+ normal = np.array([-d[1], d[0]])
458
+
459
+ indices: list[int] = []
460
+ for i, v in enumerate(self.vertices):
461
+ dot = np.dot(v[:2] - ls, normal)
462
+ if side == "positive" and dot > 1e-9:
463
+ indices.append(i)
464
+ elif side == "negative" and dot < -1e-9:
465
+ indices.append(i)
466
+ return indices
467
+
468
+ # ── deep copy ───────────────────────────────────────────────────
469
+
470
+ def copy(self) -> "Paper":
471
+ """Return an independent deep copy."""
472
+ return Paper(
473
+ vertices=self.vertices.copy(),
474
+ edges=self.edges.copy(),
475
+ faces=copy.deepcopy(self.faces),
476
+ assignments=list(self.assignments),
477
+ fold_angles=self.fold_angles.copy(),
478
+ face_orders=list(self.face_orders),
479
+ material=Material(
480
+ name=self.material.name,
481
+ thickness_mm=self.material.thickness_mm,
482
+ youngs_modulus_gpa=self.material.youngs_modulus_gpa,
483
+ max_strain=self.material.max_strain,
484
+ poissons_ratio=self.material.poissons_ratio,
485
+ ),
486
+ rest_lengths=self.rest_lengths.copy(),
487
+ original_area=self.original_area,
488
+ )
engine/physics.py ADDED
@@ -0,0 +1,257 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
engine/validation.py ADDED
@@ -0,0 +1,256 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 = 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
+ )
planner/__init__.py ADDED
File without changes
planner/__pycache__/__init__.cpython-314.pyc ADDED
Binary file (148 Bytes). View file
 
planner/__pycache__/decomposer.cpython-314.pyc ADDED
Binary file (10.5 kB). View file
 
planner/__pycache__/knowledge.cpython-314.pyc ADDED
Binary file (30.1 kB). View file
 
planner/__pycache__/parser.cpython-314.pyc ADDED
Binary file (10.2 kB). View file
 
planner/__pycache__/planner.cpython-314.pyc ADDED
Binary file (14.7 kB). View file
 
planner/decomposer.py ADDED
@@ -0,0 +1,284 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 ADDED
@@ -0,0 +1,753 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 ADDED
@@ -0,0 +1,291 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 ADDED
@@ -0,0 +1,305 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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)
trainer/rewards.py CHANGED
@@ -16,9 +16,43 @@ import math
16
  import traceback
17
  from typing import Callable
18
 
19
- from trainer.mock_env import (
20
- PaperState, create_flat_sheet, execute_fold_strategy, Material
21
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
22
 
23
 
24
  # ---------------------------------------------------------------------------
@@ -102,10 +136,15 @@ def create_sandboxed_function(code: str) -> Callable:
102
  # ---------------------------------------------------------------------------
103
 
104
  # Current task config (set by train.py before training starts)
 
 
 
 
 
105
  _current_task = {
106
  "width": 1.0,
107
  "height": 1.0,
108
- "material": Material(),
109
  "target_ratio": 0.5,
110
  "max_folds": 3,
111
  }
@@ -200,11 +239,12 @@ def physically_valid(completions, **kwargs) -> list[float]:
200
  continue
201
 
202
  try:
203
- paper = create_flat_sheet(
204
  _current_task["width"],
205
  _current_task["height"],
206
  _current_task["material"],
207
  )
 
208
  final_state, applied, error = execute_fold_strategy(
209
  strategy_fn, paper, _current_task["max_folds"]
210
  )
@@ -217,13 +257,16 @@ def physically_valid(completions, **kwargs) -> list[float]:
217
  scores.append(0.0)
218
  continue
219
 
220
- # Score based on validity
 
 
 
221
  score = 1.0
222
- score -= 2.0 * final_state.kawasaki_violation
223
- score -= 2.0 * final_state.maekawa_violation
224
- if final_state.self_intersections > 0:
225
  score -= 5.0
226
- max_strain = float(final_state.strain.max()) if len(final_state.strain) > 0 else 0.0
227
  if max_strain > _current_task["material"].max_strain:
228
  score -= 1.0
229
 
@@ -283,11 +326,12 @@ def fold_quality(completions, **kwargs) -> list[float]:
283
  continue
284
 
285
  try:
286
- paper = create_flat_sheet(
287
  _current_task["width"],
288
  _current_task["height"],
289
  _current_task["material"],
290
  )
 
291
  final_state, applied, error = execute_fold_strategy(
292
  strategy_fn, paper, _current_task["max_folds"]
293
  )
@@ -303,28 +347,32 @@ def fold_quality(completions, **kwargs) -> list[float]:
303
  scores.append(0.0)
304
  continue
305
 
 
 
 
 
 
306
  # Compactness: main reward signal
307
- compactness = 1.0 - final_state.deployment_ratio
308
  score = 20.0 * compactness
309
 
310
  # Bonus for meeting target
311
- if final_state.deployment_ratio <= _current_task["target_ratio"]:
312
  score += 10.0
313
 
314
  # Fold efficiency penalty
315
  score -= 0.5 * num_folds
316
 
317
  # Strain penalty
318
- max_strain = float(final_state.strain.max()) if len(final_state.strain) > 0 else 0.0
319
  mat_limit = _current_task["material"].max_strain
320
  if max_strain > mat_limit:
321
  score -= 3.0 * (max_strain / mat_limit)
322
 
323
  if should_print:
324
- print(f"Folds: {num_folds}, Ratio: {final_state.deployment_ratio:.3f}, "
325
  f"Compactness: {compactness:.3f}, Score: {score:.2f}")
326
- bb = final_state.bounding_box
327
- print(f"BBox: {bb[0]:.3f} x {bb[1]:.3f} x {bb[2]:.3f}")
328
 
329
  scores.append(score)
330
 
 
16
  import traceback
17
  from typing import Callable
18
 
19
+ # Use real engine if available, fall back to mock
20
+ try:
21
+ from engine.paper import Paper
22
+ from engine.fold_engine import execute_fold_strategy
23
+ from engine.materials import Material, get_material
24
+ from engine.validation import validate_paper
25
+ from engine.metrics import compute_metrics
26
+
27
+ def _create_sheet(width, height, material):
28
+ return Paper.create_flat_sheet(width, height, material)
29
+
30
+ USE_REAL_ENGINE = True
31
+ except ImportError:
32
+ from trainer.mock_env import (
33
+ PaperState as Paper, create_flat_sheet, execute_fold_strategy, Material
34
+ )
35
+
36
+ def _create_sheet(width, height, material):
37
+ return create_flat_sheet(width, height, material)
38
+
39
+ def validate_paper(p):
40
+ from types import SimpleNamespace
41
+ return SimpleNamespace(
42
+ is_valid=p.is_valid, kawasaki_valid=True, maekawa_valid=True,
43
+ kawasaki_violation=p.kawasaki_violation,
44
+ maekawa_violation=p.maekawa_violation,
45
+ self_intersection_count=p.self_intersections,
46
+ )
47
+
48
+ def compute_metrics(p, orig):
49
+ return {
50
+ "deployment_ratio": p.deployment_ratio,
51
+ "fold_count": sum(1 for a in p.assignments if a in ("M", "V")),
52
+ "max_strain": float(p.strain.max()) if len(p.strain) > 0 else 0.0,
53
+ }
54
+
55
+ USE_REAL_ENGINE = False
56
 
57
 
58
  # ---------------------------------------------------------------------------
 
136
  # ---------------------------------------------------------------------------
137
 
138
  # Current task config (set by train.py before training starts)
139
+ if USE_REAL_ENGINE:
140
+ _default_material = get_material("paper")
141
+ else:
142
+ _default_material = Material()
143
+
144
  _current_task = {
145
  "width": 1.0,
146
  "height": 1.0,
147
+ "material": _default_material,
148
  "target_ratio": 0.5,
149
  "max_folds": 3,
150
  }
 
239
  continue
240
 
241
  try:
242
+ paper = _create_sheet(
243
  _current_task["width"],
244
  _current_task["height"],
245
  _current_task["material"],
246
  )
247
+ original = paper
248
  final_state, applied, error = execute_fold_strategy(
249
  strategy_fn, paper, _current_task["max_folds"]
250
  )
 
257
  scores.append(0.0)
258
  continue
259
 
260
+ # Score based on validity using engine validation
261
+ val = validate_paper(final_state)
262
+ metrics = compute_metrics(final_state, original)
263
+
264
  score = 1.0
265
+ score -= 2.0 * val.kawasaki_violation
266
+ score -= 2.0 * val.maekawa_violation
267
+ if val.self_intersection_count > 0:
268
  score -= 5.0
269
+ max_strain = metrics.get("max_strain", 0.0)
270
  if max_strain > _current_task["material"].max_strain:
271
  score -= 1.0
272
 
 
326
  continue
327
 
328
  try:
329
+ paper = _create_sheet(
330
  _current_task["width"],
331
  _current_task["height"],
332
  _current_task["material"],
333
  )
334
+ original = paper
335
  final_state, applied, error = execute_fold_strategy(
336
  strategy_fn, paper, _current_task["max_folds"]
337
  )
 
347
  scores.append(0.0)
348
  continue
349
 
350
+ # Use engine metrics
351
+ metrics = compute_metrics(final_state, original)
352
+ deploy_ratio = metrics.get("deployment_ratio", 1.0)
353
+ max_strain = metrics.get("max_strain", 0.0)
354
+
355
  # Compactness: main reward signal
356
+ compactness = 1.0 - deploy_ratio
357
  score = 20.0 * compactness
358
 
359
  # Bonus for meeting target
360
+ if deploy_ratio <= _current_task["target_ratio"]:
361
  score += 10.0
362
 
363
  # Fold efficiency penalty
364
  score -= 0.5 * num_folds
365
 
366
  # Strain penalty
 
367
  mat_limit = _current_task["material"].max_strain
368
  if max_strain > mat_limit:
369
  score -= 3.0 * (max_strain / mat_limit)
370
 
371
  if should_print:
372
+ print(f"Folds: {num_folds}, Ratio: {deploy_ratio:.3f}, "
373
  f"Compactness: {compactness:.3f}, Score: {score:.2f}")
374
+ bb = metrics.get("bounding_box", {})
375
+ print(f"BBox: {bb.get('x',0):.3f} x {bb.get('y',0):.3f} x {bb.get('z',0):.3f}")
376
 
377
  scores.append(score)
378
 
trainer/train.py CHANGED
@@ -20,7 +20,14 @@ if PROJECT_ROOT not in sys.path:
20
 
21
  from trainer.prompts import build_prompt, SYSTEM_PROMPT, get_task_target_ratio, get_task_max_folds
22
  from trainer.rewards import code_valid, physically_valid, fold_quality, set_task_config
23
- from trainer.mock_env import Material
 
 
 
 
 
 
 
24
 
25
  # ============================================================================
26
  # Config
@@ -82,7 +89,7 @@ def main():
82
  set_task_config(
83
  width=1.0,
84
  height=1.0,
85
- material=Material(),
86
  target_ratio=target_ratio,
87
  max_folds=max_folds,
88
  )
 
20
 
21
  from trainer.prompts import build_prompt, SYSTEM_PROMPT, get_task_target_ratio, get_task_max_folds
22
  from trainer.rewards import code_valid, physically_valid, fold_quality, set_task_config
23
+
24
+ try:
25
+ from engine.materials import get_material
26
+ Material = type(get_material("paper")) # get the Material class
27
+ except ImportError:
28
+ from trainer.mock_env import Material
29
+ def get_material(name):
30
+ return Material()
31
 
32
  # ============================================================================
33
  # Config
 
89
  set_task_config(
90
  width=1.0,
91
  height=1.0,
92
+ material=get_material("paper"),
93
  target_ratio=target_ratio,
94
  max_folds=max_folds,
95
  )