Spaces:
Running
Running
Commit ·
438e23a
1
Parent(s): 56c400c
refactor: remove legacy engine, planner, sim, trainer, viz, and server code
Browse files- engine/__init__.py +0 -0
- engine/fold_engine.py +0 -249
- engine/materials.py +0 -79
- engine/metrics.py +0 -231
- engine/paper.py +0 -525
- engine/physics.py +0 -517
- engine/validation.py +0 -278
- planner/__init__.py +0 -0
- planner/decomposer.py +0 -284
- planner/knowledge.py +0 -753
- planner/parser.py +0 -291
- planner/planner.py +0 -305
- server/app.py +0 -181
- server/models.py +0 -59
- server/origami_environment.py +0 -211
- server/tasks.py +0 -123
- server_legacy.py +0 -172
- sim/__init__.py +0 -0
- sim/animate.py +0 -149
- sim/simulator.py +0 -406
- trainer/__init__.py +0 -0
- trainer/mock_env.py +0 -249
- trainer/prompts.py +0 -211
- trainer/rewards.py +0 -713
- trainer/train.py +0 -215
- training/__init__.py +0 -0
- training/demo.py +0 -251
- training/demo_llm.py +0 -318
- training/runner.py +0 -191
- viz/__init__.py +0 -0
- viz/renderer.py +0 -315
engine/__init__.py
DELETED
|
File without changes
|
engine/fold_engine.py
DELETED
|
@@ -1,249 +0,0 @@
|
|
| 1 |
-
"""
|
| 2 |
-
Fold execution engine.
|
| 3 |
-
|
| 4 |
-
Applies fold operations (valley / mountain) to a Paper object using
|
| 5 |
-
Rodrigues' rotation formula, face splitting, and layer tracking.
|
| 6 |
-
"""
|
| 7 |
-
|
| 8 |
-
from __future__ import annotations
|
| 9 |
-
|
| 10 |
-
import math
|
| 11 |
-
from typing import Callable
|
| 12 |
-
|
| 13 |
-
import numpy as np
|
| 14 |
-
|
| 15 |
-
from .paper import Paper
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
# ────────────────────────────────────────────────────────────────────
|
| 19 |
-
# Rodrigues' rotation
|
| 20 |
-
# ────────────────────────────────────────────────────────────────────
|
| 21 |
-
|
| 22 |
-
def _rodrigues_rotate(
|
| 23 |
-
points: np.ndarray,
|
| 24 |
-
axis_point: np.ndarray,
|
| 25 |
-
axis_dir: np.ndarray,
|
| 26 |
-
angle_rad: float,
|
| 27 |
-
) -> np.ndarray:
|
| 28 |
-
"""Rotate *points* (N, 3) around an axis defined by a point and direction
|
| 29 |
-
using Rodrigues' rotation formula. Returns rotated points (N, 3)."""
|
| 30 |
-
k = axis_dir / (np.linalg.norm(axis_dir) + 1e-30)
|
| 31 |
-
translated = points - axis_point
|
| 32 |
-
cos_a = math.cos(angle_rad)
|
| 33 |
-
sin_a = math.sin(angle_rad)
|
| 34 |
-
dot_term = np.dot(translated, k).reshape(-1, 1) * k # (N,1)*(3,) broadcast
|
| 35 |
-
rotated = (
|
| 36 |
-
translated * cos_a
|
| 37 |
-
+ np.cross(k, translated) * sin_a
|
| 38 |
-
+ dot_term * (1.0 - cos_a)
|
| 39 |
-
)
|
| 40 |
-
return rotated + axis_point
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
# ────────────────────────────────────────────────────────────────────
|
| 44 |
-
# Single fold
|
| 45 |
-
# ────────────────────────────────────────────────────────────────────
|
| 46 |
-
|
| 47 |
-
def apply_fold(
|
| 48 |
-
paper: Paper,
|
| 49 |
-
fold_dict: dict,
|
| 50 |
-
) -> tuple[Paper, str | None]:
|
| 51 |
-
"""Apply a single fold to *paper* and return ``(new_paper, error_or_None)``.
|
| 52 |
-
|
| 53 |
-
*fold_dict* has the form::
|
| 54 |
-
|
| 55 |
-
{
|
| 56 |
-
"type": "valley" | "mountain",
|
| 57 |
-
"line": {"start": [x, y], "end": [x, y]},
|
| 58 |
-
"angle": 0-180,
|
| 59 |
-
}
|
| 60 |
-
|
| 61 |
-
Steps:
|
| 62 |
-
1. Validate inputs.
|
| 63 |
-
2. Split faces along the fold line.
|
| 64 |
-
3. Determine vertices to rotate (one side of fold line).
|
| 65 |
-
4. Rodrigues' rotation of those vertices.
|
| 66 |
-
5. Update edge assignments for new fold-line edges.
|
| 67 |
-
6. Update fold angles.
|
| 68 |
-
7. Update layer tracking.
|
| 69 |
-
"""
|
| 70 |
-
|
| 71 |
-
# ── 0. parse & validate ─────────────────────────────────────────
|
| 72 |
-
fold_type = fold_dict.get("type", "valley")
|
| 73 |
-
line = fold_dict.get("line", {})
|
| 74 |
-
angle_deg = fold_dict.get("angle", 180)
|
| 75 |
-
|
| 76 |
-
if fold_type not in ("valley", "mountain"):
|
| 77 |
-
return paper, f"Unknown fold type: {fold_type}"
|
| 78 |
-
|
| 79 |
-
try:
|
| 80 |
-
start_2d = np.array(line["start"], dtype=np.float64)[:2]
|
| 81 |
-
end_2d = np.array(line["end"], dtype=np.float64)[:2]
|
| 82 |
-
except (KeyError, TypeError, IndexError) as exc:
|
| 83 |
-
return paper, f"Invalid fold line: {exc}"
|
| 84 |
-
|
| 85 |
-
if np.linalg.norm(end_2d - start_2d) < 1e-12:
|
| 86 |
-
return paper, "Fold line has zero length"
|
| 87 |
-
|
| 88 |
-
if not (0 < angle_deg <= 180):
|
| 89 |
-
return paper, f"Angle must be in (0, 180], got {angle_deg}"
|
| 90 |
-
|
| 91 |
-
# ── 1. deep copy so the original is untouched ───────────────────
|
| 92 |
-
new_paper = paper.copy()
|
| 93 |
-
|
| 94 |
-
# ── 2. split faces along fold line ──────────────────────────────
|
| 95 |
-
try:
|
| 96 |
-
fold_edge_ids = new_paper.split_faces_along_line(start_2d, end_2d)
|
| 97 |
-
except Exception as exc:
|
| 98 |
-
return paper, f"Face split failed: {exc}"
|
| 99 |
-
|
| 100 |
-
# ── 3. determine vertices to rotate ─────────────────────────────
|
| 101 |
-
rotate_ids = new_paper.get_vertices_on_side(start_2d, end_2d, "positive")
|
| 102 |
-
|
| 103 |
-
if not rotate_ids:
|
| 104 |
-
# Try the other side — maybe the fold line is at the boundary
|
| 105 |
-
rotate_ids = new_paper.get_vertices_on_side(start_2d, end_2d, "negative")
|
| 106 |
-
if not rotate_ids:
|
| 107 |
-
return paper, "No vertices to rotate — fold line may not intersect paper"
|
| 108 |
-
|
| 109 |
-
# ── 4. Rodrigues' rotation ──────────────────────────────────────
|
| 110 |
-
sign = 1.0 if fold_type == "valley" else -1.0
|
| 111 |
-
angle_rad = sign * math.radians(angle_deg)
|
| 112 |
-
|
| 113 |
-
axis_point = np.array([start_2d[0], start_2d[1], 0.0])
|
| 114 |
-
axis_dir = np.array([end_2d[0] - start_2d[0], end_2d[1] - start_2d[1], 0.0])
|
| 115 |
-
|
| 116 |
-
pts = new_paper.vertices[rotate_ids]
|
| 117 |
-
rotated = _rodrigues_rotate(pts, axis_point, axis_dir, angle_rad)
|
| 118 |
-
new_paper.vertices[rotate_ids] = rotated
|
| 119 |
-
|
| 120 |
-
# ── 5. update edge assignments ──────────────────────────────────
|
| 121 |
-
assignment = "V" if fold_type == "valley" else "M"
|
| 122 |
-
for eidx in fold_edge_ids:
|
| 123 |
-
if eidx < len(new_paper.assignments):
|
| 124 |
-
new_paper.assignments[eidx] = assignment
|
| 125 |
-
|
| 126 |
-
# ── 6. update fold angles ───────────────────────────────────────
|
| 127 |
-
for eidx in fold_edge_ids:
|
| 128 |
-
if eidx < len(new_paper.fold_angles):
|
| 129 |
-
new_paper.fold_angles[eidx] = angle_deg * sign
|
| 130 |
-
|
| 131 |
-
# ── 7. update layer tracking ────────────────────────────────────
|
| 132 |
-
# For each pair of faces on opposite sides of the fold line, record
|
| 133 |
-
# layer ordering. Simple heuristic: faces that were rotated are now
|
| 134 |
-
# on top (sign +1) of faces that stayed put.
|
| 135 |
-
rotated_set = set(rotate_ids)
|
| 136 |
-
|
| 137 |
-
def _face_side(face_verts: list[int]) -> str:
|
| 138 |
-
"""Classify a face as 'rotated', 'fixed', or 'mixed'."""
|
| 139 |
-
r_count = sum(1 for v in face_verts if v in rotated_set)
|
| 140 |
-
if r_count == len(face_verts):
|
| 141 |
-
return "rotated"
|
| 142 |
-
if r_count == 0:
|
| 143 |
-
return "fixed"
|
| 144 |
-
return "mixed"
|
| 145 |
-
|
| 146 |
-
face_sides = [_face_side(f) for f in new_paper.faces]
|
| 147 |
-
for i in range(len(new_paper.faces)):
|
| 148 |
-
for j in range(i + 1, len(new_paper.faces)):
|
| 149 |
-
if face_sides[i] == "rotated" and face_sides[j] == "fixed":
|
| 150 |
-
new_paper.face_orders.append((i, j, 1))
|
| 151 |
-
elif face_sides[i] == "fixed" and face_sides[j] == "rotated":
|
| 152 |
-
new_paper.face_orders.append((j, i, 1))
|
| 153 |
-
|
| 154 |
-
new_paper.fold_count += 1
|
| 155 |
-
|
| 156 |
-
return new_paper, None
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
# ────────────────────────────────────────────────────────────────────
|
| 160 |
-
# Strategy executor (matches mock_env.execute_fold_strategy signature)
|
| 161 |
-
# ────────────────────────────────────────────────────────────────────
|
| 162 |
-
|
| 163 |
-
def execute_fold_strategy(
|
| 164 |
-
strategy_fn: Callable,
|
| 165 |
-
paper: Paper,
|
| 166 |
-
max_folds: int = 20,
|
| 167 |
-
) -> tuple[Paper, list[dict], str | None]:
|
| 168 |
-
"""Execute a ``fold_strategy`` function against the real physics engine.
|
| 169 |
-
|
| 170 |
-
Signature matches ``mock_env.execute_fold_strategy`` so the trainer
|
| 171 |
-
reward functions can swap engines transparently.
|
| 172 |
-
|
| 173 |
-
Parameters
|
| 174 |
-
----------
|
| 175 |
-
strategy_fn : callable
|
| 176 |
-
``strategy_fn(paper_state_dict) -> list[dict]``
|
| 177 |
-
paper : Paper
|
| 178 |
-
The initial paper state.
|
| 179 |
-
max_folds : int
|
| 180 |
-
Maximum number of folds to apply.
|
| 181 |
-
|
| 182 |
-
Returns
|
| 183 |
-
-------
|
| 184 |
-
(final_paper, applied_folds, error_or_None)
|
| 185 |
-
"""
|
| 186 |
-
state_dict = paper.to_dict()
|
| 187 |
-
try:
|
| 188 |
-
folds = strategy_fn(state_dict)
|
| 189 |
-
except Exception as exc:
|
| 190 |
-
return paper, [], f"Strategy function raised: {exc}"
|
| 191 |
-
|
| 192 |
-
if not isinstance(folds, list):
|
| 193 |
-
return paper, [], "Strategy must return a list of fold dicts"
|
| 194 |
-
|
| 195 |
-
applied: list[dict] = []
|
| 196 |
-
current = paper
|
| 197 |
-
|
| 198 |
-
for i, fold in enumerate(folds):
|
| 199 |
-
if i >= max_folds:
|
| 200 |
-
break
|
| 201 |
-
if not isinstance(fold, dict):
|
| 202 |
-
return current, applied, f"Fold {i} is not a dict"
|
| 203 |
-
|
| 204 |
-
current, error = apply_fold(current, fold)
|
| 205 |
-
if error:
|
| 206 |
-
return current, applied, f"Fold {i} failed: {error}"
|
| 207 |
-
applied.append(fold)
|
| 208 |
-
|
| 209 |
-
return current, applied, None
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
def apply_pleat(
|
| 213 |
-
paper: Paper,
|
| 214 |
-
line1: dict,
|
| 215 |
-
line2: dict,
|
| 216 |
-
angle: float = 180.0,
|
| 217 |
-
) -> tuple[Paper, str | None]:
|
| 218 |
-
"""Pleat fold: valley at line1, mountain at line2 (two parallel folds).
|
| 219 |
-
|
| 220 |
-
Both line dicts have the form: {"start": [x, y], "end": [x, y]}
|
| 221 |
-
Returns (new_paper, error_or_None).
|
| 222 |
-
"""
|
| 223 |
-
paper, err = apply_fold(paper, {"type": "valley", "line": line1, "angle": angle})
|
| 224 |
-
if err:
|
| 225 |
-
return paper, f"Pleat valley fold failed: {err}"
|
| 226 |
-
paper, err = apply_fold(paper, {"type": "mountain", "line": line2, "angle": angle})
|
| 227 |
-
if err:
|
| 228 |
-
return paper, f"Pleat mountain fold failed: {err}"
|
| 229 |
-
return paper, None
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
def apply_crimp(
|
| 233 |
-
paper: Paper,
|
| 234 |
-
line1: dict,
|
| 235 |
-
line2: dict,
|
| 236 |
-
angle: float = 180.0,
|
| 237 |
-
) -> tuple[Paper, str | None]:
|
| 238 |
-
"""Crimp fold: mountain at line1, valley at line2 (reverse of pleat).
|
| 239 |
-
|
| 240 |
-
Both line dicts have the form: {"start": [x, y], "end": [x, y]}
|
| 241 |
-
Returns (new_paper, error_or_None).
|
| 242 |
-
"""
|
| 243 |
-
paper, err = apply_fold(paper, {"type": "mountain", "line": line1, "angle": angle})
|
| 244 |
-
if err:
|
| 245 |
-
return paper, f"Crimp mountain fold failed: {err}"
|
| 246 |
-
paper, err = apply_fold(paper, {"type": "valley", "line": line2, "angle": angle})
|
| 247 |
-
if err:
|
| 248 |
-
return paper, f"Crimp valley fold failed: {err}"
|
| 249 |
-
return paper, None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
engine/materials.py
DELETED
|
@@ -1,79 +0,0 @@
|
|
| 1 |
-
"""
|
| 2 |
-
Material definitions for origami simulation.
|
| 3 |
-
|
| 4 |
-
Provides dataclass-based material properties and preset materials
|
| 5 |
-
for paper, mylar, aluminum, and nitinol.
|
| 6 |
-
"""
|
| 7 |
-
|
| 8 |
-
from dataclasses import dataclass
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
@dataclass
|
| 12 |
-
class Material:
|
| 13 |
-
name: str
|
| 14 |
-
thickness_mm: float # mm
|
| 15 |
-
youngs_modulus_gpa: float # GPa
|
| 16 |
-
max_strain: float # fraction (e.g. 0.03 = 3%)
|
| 17 |
-
poissons_ratio: float = 0.3 # dimensionless
|
| 18 |
-
|
| 19 |
-
@property
|
| 20 |
-
def thickness_m(self) -> float:
|
| 21 |
-
"""Thickness in meters."""
|
| 22 |
-
return self.thickness_mm / 1000.0
|
| 23 |
-
|
| 24 |
-
@property
|
| 25 |
-
def youngs_modulus_pa(self) -> float:
|
| 26 |
-
"""Young's modulus in Pascals."""
|
| 27 |
-
return self.youngs_modulus_gpa * 1e9
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
# ── Preset materials ────────────────────────────────────────────────
|
| 31 |
-
|
| 32 |
-
MATERIAL_PRESETS: dict[str, Material] = {
|
| 33 |
-
"paper": Material(
|
| 34 |
-
name="paper",
|
| 35 |
-
thickness_mm=0.1,
|
| 36 |
-
youngs_modulus_gpa=2.0,
|
| 37 |
-
max_strain=0.03,
|
| 38 |
-
poissons_ratio=0.3,
|
| 39 |
-
),
|
| 40 |
-
"mylar": Material(
|
| 41 |
-
name="mylar",
|
| 42 |
-
thickness_mm=0.05,
|
| 43 |
-
youngs_modulus_gpa=4.0,
|
| 44 |
-
max_strain=0.03,
|
| 45 |
-
poissons_ratio=0.38,
|
| 46 |
-
),
|
| 47 |
-
"aluminum": Material(
|
| 48 |
-
name="aluminum",
|
| 49 |
-
thickness_mm=0.1,
|
| 50 |
-
youngs_modulus_gpa=69.0,
|
| 51 |
-
max_strain=0.01,
|
| 52 |
-
poissons_ratio=0.33,
|
| 53 |
-
),
|
| 54 |
-
"nitinol": Material(
|
| 55 |
-
name="nitinol",
|
| 56 |
-
thickness_mm=0.1,
|
| 57 |
-
youngs_modulus_gpa=75.0,
|
| 58 |
-
max_strain=0.08,
|
| 59 |
-
poissons_ratio=0.33,
|
| 60 |
-
),
|
| 61 |
-
}
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
def get_material(name: str) -> Material:
|
| 65 |
-
"""Return a copy of a preset material by name.
|
| 66 |
-
|
| 67 |
-
Raises KeyError if name is not in MATERIAL_PRESETS.
|
| 68 |
-
"""
|
| 69 |
-
if name not in MATERIAL_PRESETS:
|
| 70 |
-
available = ", ".join(sorted(MATERIAL_PRESETS))
|
| 71 |
-
raise KeyError(f"Unknown material '{name}'. Available: {available}")
|
| 72 |
-
preset = MATERIAL_PRESETS[name]
|
| 73 |
-
return Material(
|
| 74 |
-
name=preset.name,
|
| 75 |
-
thickness_mm=preset.thickness_mm,
|
| 76 |
-
youngs_modulus_gpa=preset.youngs_modulus_gpa,
|
| 77 |
-
max_strain=preset.max_strain,
|
| 78 |
-
poissons_ratio=preset.poissons_ratio,
|
| 79 |
-
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
engine/metrics.py
DELETED
|
@@ -1,231 +0,0 @@
|
|
| 1 |
-
"""
|
| 2 |
-
Quality metrics for folded origami.
|
| 3 |
-
|
| 4 |
-
Computes bounding box, deployment ratio, fold count, and aggregated
|
| 5 |
-
metric dictionaries for the trainer reward functions.
|
| 6 |
-
"""
|
| 7 |
-
|
| 8 |
-
from __future__ import annotations
|
| 9 |
-
|
| 10 |
-
import numpy as np
|
| 11 |
-
|
| 12 |
-
from .paper import Paper
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
def compute_bounding_box(paper: Paper) -> np.ndarray:
|
| 16 |
-
"""Axis-aligned bounding-box dimensions (dx, dy, dz).
|
| 17 |
-
|
| 18 |
-
Returns shape (3,) array. Minimum z-thickness accounts for
|
| 19 |
-
material thickness times estimated layer count.
|
| 20 |
-
"""
|
| 21 |
-
if len(paper.vertices) == 0:
|
| 22 |
-
return np.zeros(3)
|
| 23 |
-
|
| 24 |
-
ptp = np.ptp(paper.vertices, axis=0)
|
| 25 |
-
ptp = np.where(np.abs(ptp) < 1e-12, 0.0, ptp)
|
| 26 |
-
|
| 27 |
-
# Minimum z from material thickness * layers
|
| 28 |
-
t = paper.material.thickness_mm / 1000.0
|
| 29 |
-
ptp[2] = max(ptp[2], t * paper.num_layers)
|
| 30 |
-
|
| 31 |
-
return ptp
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
def compute_deployment_ratio(paper: Paper) -> float:
|
| 35 |
-
"""Ratio of folded XY footprint area to original sheet area.
|
| 36 |
-
|
| 37 |
-
A fully flat unfolded sheet has ratio 1.0; a tightly folded sheet
|
| 38 |
-
approaches 0.0.
|
| 39 |
-
"""
|
| 40 |
-
if paper.original_area <= 0:
|
| 41 |
-
return 1.0
|
| 42 |
-
|
| 43 |
-
bb = compute_bounding_box(paper)
|
| 44 |
-
folded_area = bb[0] * bb[1]
|
| 45 |
-
|
| 46 |
-
ratio = folded_area / paper.original_area
|
| 47 |
-
return float(np.clip(ratio, 0.0, 1.0))
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
def compute_fold_count(paper: Paper) -> int:
|
| 51 |
-
"""Number of mountain (M) and valley (V) edges."""
|
| 52 |
-
return sum(1 for a in paper.assignments if a in ("M", "V"))
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
def compute_compactness(paper: Paper) -> float:
|
| 56 |
-
"""1 - deployment_ratio. Higher is more compact."""
|
| 57 |
-
return 1.0 - compute_deployment_ratio(paper)
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
def compute_volume(paper: Paper) -> float:
|
| 61 |
-
"""Bounding-box volume in cubic meters."""
|
| 62 |
-
bb = compute_bounding_box(paper)
|
| 63 |
-
return float(bb[0] * bb[1] * bb[2])
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
def compute_metrics(paper: Paper, original_paper: Paper | None = None) -> dict:
|
| 67 |
-
"""Compute all quality metrics and return as a dict.
|
| 68 |
-
|
| 69 |
-
Parameters
|
| 70 |
-
----------
|
| 71 |
-
paper : Paper
|
| 72 |
-
The current (folded) paper state.
|
| 73 |
-
original_paper : Paper or None
|
| 74 |
-
The original (unfolded) paper, used for strain comparison.
|
| 75 |
-
If None, strain is computed against the current paper's rest lengths.
|
| 76 |
-
|
| 77 |
-
Returns
|
| 78 |
-
-------
|
| 79 |
-
dict with keys:
|
| 80 |
-
bounding_box, deployment_ratio, fold_count, compactness,
|
| 81 |
-
volume, max_strain, mean_strain, num_vertices, num_faces,
|
| 82 |
-
num_layers.
|
| 83 |
-
"""
|
| 84 |
-
from .physics import compute_strain # local import to avoid circular
|
| 85 |
-
|
| 86 |
-
bb = compute_bounding_box(paper)
|
| 87 |
-
strain = compute_strain(paper)
|
| 88 |
-
|
| 89 |
-
return {
|
| 90 |
-
"bounding_box": {
|
| 91 |
-
"x": float(bb[0]),
|
| 92 |
-
"y": float(bb[1]),
|
| 93 |
-
"z": float(bb[2]),
|
| 94 |
-
},
|
| 95 |
-
"deployment_ratio": compute_deployment_ratio(paper),
|
| 96 |
-
"fold_count": compute_fold_count(paper),
|
| 97 |
-
"compactness": compute_compactness(paper),
|
| 98 |
-
"volume": compute_volume(paper),
|
| 99 |
-
"max_strain": float(np.max(strain)) if len(strain) > 0 else 0.0,
|
| 100 |
-
"mean_strain": float(np.mean(strain)) if len(strain) > 0 else 0.0,
|
| 101 |
-
"num_vertices": len(paper.vertices),
|
| 102 |
-
"num_faces": len(paper.faces),
|
| 103 |
-
"num_layers": paper.num_layers,
|
| 104 |
-
}
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
def compute_all_metrics(paper, task: dict, validation: dict) -> dict:
|
| 108 |
-
"""Compute every metric and return a flat dict.
|
| 109 |
-
|
| 110 |
-
Called after physics + validation. Combines validity, compactness,
|
| 111 |
-
structural, efficiency, and deployability metrics.
|
| 112 |
-
|
| 113 |
-
Parameters
|
| 114 |
-
----------
|
| 115 |
-
paper : Paper
|
| 116 |
-
Current paper state (after simulate()).
|
| 117 |
-
task : dict
|
| 118 |
-
Task definition with keys: width, height, target_ratio, target_box, must_deploy.
|
| 119 |
-
validation : dict
|
| 120 |
-
Output of validate_state(paper).
|
| 121 |
-
"""
|
| 122 |
-
import numpy as np
|
| 123 |
-
|
| 124 |
-
bb = paper.bounding_box # (3,) array
|
| 125 |
-
original_area = paper.original_area if paper.original_area > 0 else (paper.material.thickness_mm / 1000.0)
|
| 126 |
-
t = paper.material.thickness_mm / 1000.0
|
| 127 |
-
original_bbox_vol = original_area * t
|
| 128 |
-
folded_bbox_vol = float(bb[0] * bb[1] * bb[2]) if bb[2] > 0 else float(bb[0] * bb[1] * t)
|
| 129 |
-
|
| 130 |
-
# ── Folded area (XY footprint) ────────────────────────────────
|
| 131 |
-
if len(paper.vertices) >= 3:
|
| 132 |
-
try:
|
| 133 |
-
from scipy.spatial import ConvexHull
|
| 134 |
-
hull = ConvexHull(paper.vertices[:, :2])
|
| 135 |
-
folded_area = float(hull.volume)
|
| 136 |
-
except Exception:
|
| 137 |
-
ptp = np.ptp(paper.vertices[:, :2], axis=0)
|
| 138 |
-
folded_area = float(ptp[0] * ptp[1])
|
| 139 |
-
else:
|
| 140 |
-
folded_area = original_area
|
| 141 |
-
|
| 142 |
-
deployment_ratio = folded_area / original_area if original_area > 0 else 1.0
|
| 143 |
-
compactness = 1.0 - deployment_ratio
|
| 144 |
-
volume_compaction = folded_bbox_vol / original_bbox_vol if original_bbox_vol > 0 else 1.0
|
| 145 |
-
material_volume = original_area * t
|
| 146 |
-
packing_efficiency = material_volume / folded_bbox_vol if folded_bbox_vol > 0 else 0.0
|
| 147 |
-
|
| 148 |
-
# ── Target box check ──────────────────────────��──────────────
|
| 149 |
-
target_box = task.get("target_box")
|
| 150 |
-
fits_target_box = False
|
| 151 |
-
if target_box and len(target_box) == 3:
|
| 152 |
-
fits_target_box = bool(
|
| 153 |
-
bb[0] <= target_box[0] + 1e-6 and
|
| 154 |
-
bb[1] <= target_box[1] + 1e-6 and
|
| 155 |
-
bb[2] <= target_box[2] + 1e-6
|
| 156 |
-
)
|
| 157 |
-
|
| 158 |
-
# ── Strain ───────────────────────────────────────────────────
|
| 159 |
-
strain = paper.strain_per_vertex
|
| 160 |
-
max_strain = float(np.max(strain)) if len(strain) > 0 else 0.0
|
| 161 |
-
mean_strain = float(np.mean(strain)) if len(strain) > 0 else 0.0
|
| 162 |
-
|
| 163 |
-
# ── Energy ───────────────────────────────────────────────────
|
| 164 |
-
energy = paper.energy
|
| 165 |
-
|
| 166 |
-
# ── Efficiency ───────────────────────────────────────────────
|
| 167 |
-
fold_count = paper.fold_count
|
| 168 |
-
|
| 169 |
-
# Crease complexity: entropy of M/V assignment distribution
|
| 170 |
-
mv_assignments = [a for a in paper.assignments if a in ("M", "V")]
|
| 171 |
-
if mv_assignments:
|
| 172 |
-
total = len(mv_assignments)
|
| 173 |
-
m_count = mv_assignments.count("M")
|
| 174 |
-
v_count = mv_assignments.count("V")
|
| 175 |
-
p_m = m_count / total if total > 0 else 0
|
| 176 |
-
p_v = v_count / total if total > 0 else 0
|
| 177 |
-
crease_complexity = 0.0
|
| 178 |
-
if p_m > 0:
|
| 179 |
-
crease_complexity -= p_m * np.log2(p_m)
|
| 180 |
-
if p_v > 0:
|
| 181 |
-
crease_complexity -= p_v * np.log2(p_v)
|
| 182 |
-
else:
|
| 183 |
-
crease_complexity = 0.0
|
| 184 |
-
|
| 185 |
-
folding_efficiency = compactness / max(fold_count, 1)
|
| 186 |
-
|
| 187 |
-
# ── Deployability ─────────────────────────────────────────────
|
| 188 |
-
must_deploy = task.get("must_deploy", False)
|
| 189 |
-
# Simple deployability heuristic: if valid and compactness > 0, assume deployable
|
| 190 |
-
is_deployable = bool(validation.get("is_valid", False) and compactness > 0.01) if must_deploy else None
|
| 191 |
-
# Deployment force estimate from total energy gradient (rough)
|
| 192 |
-
deployment_force_estimate = float(energy.get("fold", 0.0)) / max(paper.original_area, 1e-6)
|
| 193 |
-
|
| 194 |
-
return {
|
| 195 |
-
# Validity (from validation dict)
|
| 196 |
-
"is_valid": validation.get("is_valid", False),
|
| 197 |
-
"kawasaki_violations": validation.get("kawasaki_violations", 0),
|
| 198 |
-
"kawasaki_total_error": validation.get("kawasaki_total_error", 0.0),
|
| 199 |
-
"maekawa_violations": validation.get("maekawa_violations", 0),
|
| 200 |
-
"self_intersections": validation.get("self_intersections", 0),
|
| 201 |
-
"strain_exceeded": validation.get("strain_exceeded", False),
|
| 202 |
-
|
| 203 |
-
# Compactness
|
| 204 |
-
"deployment_ratio": float(deployment_ratio),
|
| 205 |
-
"compactness": float(compactness),
|
| 206 |
-
"volume_compaction": float(volume_compaction),
|
| 207 |
-
"packing_efficiency": float(packing_efficiency),
|
| 208 |
-
"fits_target_box": fits_target_box,
|
| 209 |
-
"bounding_box": bb.tolist(),
|
| 210 |
-
|
| 211 |
-
# Structural
|
| 212 |
-
"max_strain": max_strain,
|
| 213 |
-
"mean_strain": mean_strain,
|
| 214 |
-
"total_energy": float(energy.get("total", 0.0)),
|
| 215 |
-
"energy_bar": float(energy.get("bar", 0.0)),
|
| 216 |
-
"energy_facet": float(energy.get("facet", 0.0)),
|
| 217 |
-
"energy_fold": float(energy.get("fold", 0.0)),
|
| 218 |
-
|
| 219 |
-
# Efficiency
|
| 220 |
-
"fold_count": fold_count,
|
| 221 |
-
"folding_efficiency": float(folding_efficiency),
|
| 222 |
-
"crease_complexity": float(crease_complexity),
|
| 223 |
-
|
| 224 |
-
# Deployability
|
| 225 |
-
"is_deployable": is_deployable,
|
| 226 |
-
"deployment_force_estimate": float(deployment_force_estimate),
|
| 227 |
-
|
| 228 |
-
# Shape similarity placeholders
|
| 229 |
-
"chamfer_distance": None,
|
| 230 |
-
"hausdorff_distance": None,
|
| 231 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
engine/paper.py
DELETED
|
@@ -1,525 +0,0 @@
|
|
| 1 |
-
"""
|
| 2 |
-
Paper — the core geometric data structure for origami simulation.
|
| 3 |
-
|
| 4 |
-
Stores vertices, edges, faces, fold assignments, fold angles, layer ordering,
|
| 5 |
-
and material. Supports FOLD-format serialization and the face-splitting
|
| 6 |
-
operation needed by the fold engine.
|
| 7 |
-
"""
|
| 8 |
-
|
| 9 |
-
from __future__ import annotations
|
| 10 |
-
|
| 11 |
-
import copy
|
| 12 |
-
import json
|
| 13 |
-
from dataclasses import dataclass, field
|
| 14 |
-
from typing import Any
|
| 15 |
-
|
| 16 |
-
import numpy as np
|
| 17 |
-
|
| 18 |
-
from .materials import Material, get_material
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
# ────────────────────────────────────────────────────────────────────
|
| 22 |
-
# Helper: 2-D line-segment intersection
|
| 23 |
-
# ────────────────────────────────────────────────────────────────────
|
| 24 |
-
|
| 25 |
-
def _seg_seg_intersect_2d(
|
| 26 |
-
p1: np.ndarray, p2: np.ndarray,
|
| 27 |
-
p3: np.ndarray, p4: np.ndarray,
|
| 28 |
-
eps: float = 1e-10,
|
| 29 |
-
) -> np.ndarray | None:
|
| 30 |
-
"""Return the intersection point of segments (p1-p2) and (p3-p4) in 2-D,
|
| 31 |
-
or None if they do not intersect. Points that lie on the segment
|
| 32 |
-
endpoints are considered intersections (within tolerance *eps*).
|
| 33 |
-
|
| 34 |
-
All inputs are shape (2,).
|
| 35 |
-
"""
|
| 36 |
-
d1 = p2 - p1
|
| 37 |
-
d2 = p4 - p3
|
| 38 |
-
denom = d1[0] * d2[1] - d1[1] * d2[0]
|
| 39 |
-
|
| 40 |
-
if abs(denom) < eps:
|
| 41 |
-
return None # parallel / collinear
|
| 42 |
-
|
| 43 |
-
dp = p3 - p1
|
| 44 |
-
t = (dp[0] * d2[1] - dp[1] * d2[0]) / denom
|
| 45 |
-
u = (dp[0] * d1[1] - dp[1] * d1[0]) / denom
|
| 46 |
-
|
| 47 |
-
if -eps <= t <= 1.0 + eps and -eps <= u <= 1.0 + eps:
|
| 48 |
-
return p1 + np.clip(t, 0.0, 1.0) * d1
|
| 49 |
-
return None
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
# ────────────────────────────────────────────────────────────────────
|
| 53 |
-
# Paper dataclass
|
| 54 |
-
# ────────────────────────────────────────────────────────────────────
|
| 55 |
-
|
| 56 |
-
@dataclass
|
| 57 |
-
class Paper:
|
| 58 |
-
"""Origami sheet state.
|
| 59 |
-
|
| 60 |
-
Attributes
|
| 61 |
-
----------
|
| 62 |
-
vertices : np.ndarray, shape (N, 3)
|
| 63 |
-
Vertex positions in 3-D.
|
| 64 |
-
edges : np.ndarray, shape (E, 2), dtype int
|
| 65 |
-
Each row is (v_start, v_end).
|
| 66 |
-
faces : list[list[int]]
|
| 67 |
-
Each face is an ordered list of vertex indices (CCW winding).
|
| 68 |
-
assignments : list[str]
|
| 69 |
-
Per-edge assignment: 'M' (mountain), 'V' (valley), 'B' (boundary),
|
| 70 |
-
'F' (flat / unfolded), 'U' (unassigned).
|
| 71 |
-
fold_angles : np.ndarray, shape (E,)
|
| 72 |
-
Current fold angle (degrees) per edge.
|
| 73 |
-
face_orders : list[tuple[int, int, int]]
|
| 74 |
-
Layer ordering triples (f_i, f_j, +1/-1) meaning f_i is above/below f_j.
|
| 75 |
-
material : Material
|
| 76 |
-
The sheet material.
|
| 77 |
-
rest_lengths : np.ndarray, shape (E,)
|
| 78 |
-
Original (unfolded) edge lengths — used for strain computation.
|
| 79 |
-
original_area : float
|
| 80 |
-
Area of the sheet before any folds.
|
| 81 |
-
"""
|
| 82 |
-
|
| 83 |
-
vertices: np.ndarray
|
| 84 |
-
edges: np.ndarray
|
| 85 |
-
faces: list[list[int]]
|
| 86 |
-
assignments: list[str]
|
| 87 |
-
fold_angles: np.ndarray
|
| 88 |
-
face_orders: list[tuple[int, int, int]] = field(default_factory=list)
|
| 89 |
-
material: Material = field(default_factory=lambda: get_material("paper"))
|
| 90 |
-
rest_lengths: np.ndarray = field(default_factory=lambda: np.empty(0))
|
| 91 |
-
original_area: float = 0.0
|
| 92 |
-
rest_positions: np.ndarray = field(default_factory=lambda: np.empty((0, 3)))
|
| 93 |
-
strain_per_vertex: np.ndarray = field(default_factory=lambda: np.empty(0))
|
| 94 |
-
energy: dict = field(default_factory=lambda: {"total": 0.0, "bar": 0.0, "facet": 0.0, "fold": 0.0})
|
| 95 |
-
fold_count: int = 0
|
| 96 |
-
|
| 97 |
-
# ── constructors ────────────────────────────────────────────────
|
| 98 |
-
|
| 99 |
-
@staticmethod
|
| 100 |
-
def create_flat_sheet(
|
| 101 |
-
width: float = 1.0,
|
| 102 |
-
height: float = 1.0,
|
| 103 |
-
material: Material | None = None,
|
| 104 |
-
) -> "Paper":
|
| 105 |
-
"""Create a flat rectangular sheet with 4 vertices, 5 edges
|
| 106 |
-
(including one diagonal), and 2 triangular faces."""
|
| 107 |
-
mat = material if material is not None else get_material("paper")
|
| 108 |
-
|
| 109 |
-
verts = np.array([
|
| 110 |
-
[0.0, 0.0, 0.0],
|
| 111 |
-
[width, 0.0, 0.0],
|
| 112 |
-
[width, height, 0.0],
|
| 113 |
-
[0.0, height, 0.0],
|
| 114 |
-
], dtype=np.float64)
|
| 115 |
-
|
| 116 |
-
edges = np.array([
|
| 117 |
-
[0, 1], # bottom
|
| 118 |
-
[1, 2], # right
|
| 119 |
-
[2, 3], # top
|
| 120 |
-
[3, 0], # left
|
| 121 |
-
[0, 2], # diagonal
|
| 122 |
-
], dtype=np.int64)
|
| 123 |
-
|
| 124 |
-
faces: list[list[int]] = [[0, 1, 2], [0, 2, 3]]
|
| 125 |
-
assignments = ["B", "B", "B", "B", "F"]
|
| 126 |
-
fold_angles = np.zeros(len(edges), dtype=np.float64)
|
| 127 |
-
rest_lengths = np.array(
|
| 128 |
-
[np.linalg.norm(verts[e[1]] - verts[e[0]]) for e in edges],
|
| 129 |
-
dtype=np.float64,
|
| 130 |
-
)
|
| 131 |
-
|
| 132 |
-
paper = Paper(
|
| 133 |
-
vertices=verts,
|
| 134 |
-
edges=edges,
|
| 135 |
-
faces=faces,
|
| 136 |
-
assignments=assignments,
|
| 137 |
-
fold_angles=fold_angles,
|
| 138 |
-
material=mat,
|
| 139 |
-
rest_lengths=rest_lengths,
|
| 140 |
-
original_area=width * height,
|
| 141 |
-
)
|
| 142 |
-
paper.rest_positions = verts.copy()
|
| 143 |
-
return paper
|
| 144 |
-
|
| 145 |
-
# ── dict / prompt serialization (matches mock_env.PaperState.to_dict) ──
|
| 146 |
-
|
| 147 |
-
def to_dict(self) -> dict:
|
| 148 |
-
"""Return a simplified dict suitable for LLM prompts.
|
| 149 |
-
|
| 150 |
-
The format matches ``mock_env.PaperState.to_dict()`` so that the
|
| 151 |
-
trainer reward functions work with either engine.
|
| 152 |
-
"""
|
| 153 |
-
bb = self.bounding_box
|
| 154 |
-
return {
|
| 155 |
-
"width": float(bb[0]),
|
| 156 |
-
"height": float(bb[1]),
|
| 157 |
-
"material": {
|
| 158 |
-
"name": self.material.name,
|
| 159 |
-
"thickness_mm": self.material.thickness_mm,
|
| 160 |
-
"youngs_modulus_gpa": self.material.youngs_modulus_gpa,
|
| 161 |
-
},
|
| 162 |
-
"vertices": self.vertices.tolist(),
|
| 163 |
-
"edges": self.edges.tolist(),
|
| 164 |
-
"assignments": list(self.assignments),
|
| 165 |
-
"fold_angles": self.fold_angles.tolist(),
|
| 166 |
-
"num_layers_at_center": self.num_layers,
|
| 167 |
-
"bounding_box": {
|
| 168 |
-
"x": float(bb[0]),
|
| 169 |
-
"y": float(bb[1]),
|
| 170 |
-
"z": float(bb[2]),
|
| 171 |
-
},
|
| 172 |
-
}
|
| 173 |
-
|
| 174 |
-
def to_observation_dict(self) -> dict:
|
| 175 |
-
bb = self.bounding_box
|
| 176 |
-
return {
|
| 177 |
-
"vertices_coords": self.vertices.tolist(),
|
| 178 |
-
"edges_vertices": self.edges.tolist(),
|
| 179 |
-
"faces_vertices": self.faces,
|
| 180 |
-
"edges_assignment": list(self.assignments),
|
| 181 |
-
"edges_foldAngle": self.fold_angles.tolist(),
|
| 182 |
-
"num_vertices": len(self.vertices),
|
| 183 |
-
"num_edges": len(self.edges),
|
| 184 |
-
"num_faces": len(self.faces),
|
| 185 |
-
"bounding_box": bb.tolist(),
|
| 186 |
-
"num_layers": self.num_layers,
|
| 187 |
-
"material": {
|
| 188 |
-
"name": self.material.name,
|
| 189 |
-
"thickness_mm": self.material.thickness_mm,
|
| 190 |
-
"youngs_modulus_gpa": self.material.youngs_modulus_gpa,
|
| 191 |
-
"max_strain": self.material.max_strain,
|
| 192 |
-
"poisson_ratio": self.material.poissons_ratio,
|
| 193 |
-
},
|
| 194 |
-
"strain_per_vertex": self.strain_per_vertex.tolist(),
|
| 195 |
-
"energy": dict(self.energy),
|
| 196 |
-
"fold_count": self.fold_count,
|
| 197 |
-
"width": float(self.original_area ** 0.5) if self.original_area > 0 else 1.0,
|
| 198 |
-
"height": float(self.original_area ** 0.5) if self.original_area > 0 else 1.0,
|
| 199 |
-
}
|
| 200 |
-
|
| 201 |
-
# ── FOLD format serialization ───────────────────────────────────
|
| 202 |
-
|
| 203 |
-
def to_fold_json(self) -> str:
|
| 204 |
-
"""Serialize to FOLD JSON format (v1.1 subset)."""
|
| 205 |
-
fold = {
|
| 206 |
-
"file_spec": 1.1,
|
| 207 |
-
"file_creator": "optigami",
|
| 208 |
-
"file_classes": ["singleModel"],
|
| 209 |
-
"frame_classes": ["foldedForm"],
|
| 210 |
-
"vertices_coords": self.vertices.tolist(),
|
| 211 |
-
"edges_vertices": self.edges.tolist(),
|
| 212 |
-
"edges_assignment": self.assignments,
|
| 213 |
-
"edges_foldAngle": self.fold_angles.tolist(),
|
| 214 |
-
"faces_vertices": self.faces,
|
| 215 |
-
"faceOrders": [list(fo) for fo in self.face_orders],
|
| 216 |
-
}
|
| 217 |
-
return json.dumps(fold, indent=2)
|
| 218 |
-
|
| 219 |
-
@staticmethod
|
| 220 |
-
def from_fold_json(data: str | dict, material: Material | None = None) -> "Paper":
|
| 221 |
-
"""Deserialize from FOLD JSON format."""
|
| 222 |
-
if isinstance(data, str):
|
| 223 |
-
data = json.loads(data)
|
| 224 |
-
|
| 225 |
-
verts = np.array(data["vertices_coords"], dtype=np.float64)
|
| 226 |
-
edges = np.array(data["edges_vertices"], dtype=np.int64)
|
| 227 |
-
faces = data.get("faces_vertices", [])
|
| 228 |
-
assignments = data.get("edges_assignment", ["U"] * len(edges))
|
| 229 |
-
fold_angles = np.array(
|
| 230 |
-
data.get("edges_foldAngle", [0.0] * len(edges)),
|
| 231 |
-
dtype=np.float64,
|
| 232 |
-
)
|
| 233 |
-
face_orders = [tuple(fo) for fo in data.get("faceOrders", [])]
|
| 234 |
-
|
| 235 |
-
rest_lengths = np.array(
|
| 236 |
-
[np.linalg.norm(verts[e[1]] - verts[e[0]]) for e in edges],
|
| 237 |
-
dtype=np.float64,
|
| 238 |
-
)
|
| 239 |
-
|
| 240 |
-
mat = material if material is not None else get_material("paper")
|
| 241 |
-
|
| 242 |
-
# Approximate original area from convex hull of initial XY footprint
|
| 243 |
-
try:
|
| 244 |
-
from scipy.spatial import ConvexHull
|
| 245 |
-
hull = ConvexHull(verts[:, :2])
|
| 246 |
-
area = hull.volume # 2-D ConvexHull.volume is area
|
| 247 |
-
except Exception:
|
| 248 |
-
# Fallback: bounding-box area from XY coordinates
|
| 249 |
-
if len(verts) >= 2:
|
| 250 |
-
ptp = np.ptp(verts[:, :2], axis=0)
|
| 251 |
-
area = float(ptp[0] * ptp[1])
|
| 252 |
-
else:
|
| 253 |
-
area = 0.0
|
| 254 |
-
|
| 255 |
-
return Paper(
|
| 256 |
-
vertices=verts,
|
| 257 |
-
edges=edges,
|
| 258 |
-
faces=faces,
|
| 259 |
-
assignments=assignments,
|
| 260 |
-
fold_angles=fold_angles,
|
| 261 |
-
face_orders=face_orders,
|
| 262 |
-
material=mat,
|
| 263 |
-
rest_lengths=rest_lengths,
|
| 264 |
-
original_area=area,
|
| 265 |
-
)
|
| 266 |
-
|
| 267 |
-
# ── computed properties ─────────────────────────────────────────
|
| 268 |
-
|
| 269 |
-
@property
|
| 270 |
-
def bounding_box(self) -> np.ndarray:
|
| 271 |
-
"""Axis-aligned bounding-box dimensions (dx, dy, dz)."""
|
| 272 |
-
if len(self.vertices) == 0:
|
| 273 |
-
return np.zeros(3)
|
| 274 |
-
ptp = np.ptp(self.vertices, axis=0)
|
| 275 |
-
ptp = np.where(np.abs(ptp) < 1e-12, 0.0, ptp)
|
| 276 |
-
# Ensure minimum z height from material thickness * layers
|
| 277 |
-
t = self.material.thickness_mm / 1000.0
|
| 278 |
-
ptp[2] = max(ptp[2], t * self.num_layers)
|
| 279 |
-
return ptp
|
| 280 |
-
|
| 281 |
-
@property
|
| 282 |
-
def num_layers(self) -> int:
|
| 283 |
-
"""Estimate layer count from face-order triples.
|
| 284 |
-
|
| 285 |
-
Falls back to 1 + number of M/V edges as a simple heuristic when
|
| 286 |
-
face_orders is empty.
|
| 287 |
-
"""
|
| 288 |
-
if self.face_orders:
|
| 289 |
-
face_ids = set()
|
| 290 |
-
for fo in self.face_orders:
|
| 291 |
-
face_ids.add(fo[0])
|
| 292 |
-
face_ids.add(fo[1])
|
| 293 |
-
return max(len(face_ids), 1)
|
| 294 |
-
# Heuristic: each fold adds one layer
|
| 295 |
-
mv_count = sum(1 for a in self.assignments if a in ("M", "V"))
|
| 296 |
-
return 1 + mv_count
|
| 297 |
-
|
| 298 |
-
# ── topology helpers ────────────────────────────────────────────
|
| 299 |
-
|
| 300 |
-
def _find_or_add_vertex(self, point_3d: np.ndarray, tol: float = 1e-8) -> int:
|
| 301 |
-
"""Return index of an existing vertex close to *point_3d*, or add a
|
| 302 |
-
new vertex and return its index."""
|
| 303 |
-
for i, v in enumerate(self.vertices):
|
| 304 |
-
if np.linalg.norm(v - point_3d) < tol:
|
| 305 |
-
return i
|
| 306 |
-
idx = len(self.vertices)
|
| 307 |
-
self.vertices = np.vstack([self.vertices, point_3d.reshape(1, 3)])
|
| 308 |
-
return idx
|
| 309 |
-
|
| 310 |
-
def _find_or_add_edge(self, v1: int, v2: int) -> int:
|
| 311 |
-
"""Return index of edge (v1,v2) or (v2,v1), or add a new edge and
|
| 312 |
-
return its index. New edges get assignment 'F' and fold-angle 0."""
|
| 313 |
-
for i, e in enumerate(self.edges):
|
| 314 |
-
if (e[0] == v1 and e[1] == v2) or (e[0] == v2 and e[1] == v1):
|
| 315 |
-
return i
|
| 316 |
-
idx = len(self.edges)
|
| 317 |
-
self.edges = np.vstack([self.edges, np.array([[v1, v2]], dtype=np.int64)])
|
| 318 |
-
self.assignments.append("F")
|
| 319 |
-
self.fold_angles = np.append(self.fold_angles, 0.0)
|
| 320 |
-
# Rest length for the new edge
|
| 321 |
-
rl = np.linalg.norm(self.vertices[v1] - self.vertices[v2])
|
| 322 |
-
self.rest_lengths = np.append(self.rest_lengths, rl)
|
| 323 |
-
return idx
|
| 324 |
-
|
| 325 |
-
# ── face splitting ──────────────────────────────────────────────
|
| 326 |
-
|
| 327 |
-
def split_faces_along_line(
|
| 328 |
-
self,
|
| 329 |
-
start_2d: np.ndarray | list,
|
| 330 |
-
end_2d: np.ndarray | list,
|
| 331 |
-
) -> list[int]:
|
| 332 |
-
"""Split every face that the 2-D line (start_2d -> end_2d) crosses.
|
| 333 |
-
|
| 334 |
-
The line is infinite for intersection purposes (we test each face
|
| 335 |
-
edge-segment against the full fold-line extent clipped to the paper).
|
| 336 |
-
|
| 337 |
-
Returns a list of edge indices that lie *on* the fold line (i.e. the
|
| 338 |
-
newly created edges along the fold path).
|
| 339 |
-
|
| 340 |
-
This mutates ``self`` in-place (vertices, edges, faces, assignments,
|
| 341 |
-
fold_angles, rest_lengths are updated).
|
| 342 |
-
"""
|
| 343 |
-
start_2d = np.asarray(start_2d, dtype=np.float64)
|
| 344 |
-
end_2d = np.asarray(end_2d, dtype=np.float64)
|
| 345 |
-
|
| 346 |
-
fold_edge_indices: list[int] = []
|
| 347 |
-
new_faces: list[list[int]] = []
|
| 348 |
-
|
| 349 |
-
faces_to_process = list(range(len(self.faces)))
|
| 350 |
-
|
| 351 |
-
for fi in faces_to_process:
|
| 352 |
-
face = self.faces[fi]
|
| 353 |
-
n = len(face)
|
| 354 |
-
|
| 355 |
-
# Gather intersection points along the face boundary
|
| 356 |
-
hits: list[tuple[int, np.ndarray]] = [] # (local_edge_index, point_2d)
|
| 357 |
-
|
| 358 |
-
for k in range(n):
|
| 359 |
-
v_a = face[k]
|
| 360 |
-
v_b = face[(k + 1) % n]
|
| 361 |
-
pa = self.vertices[v_a][:2]
|
| 362 |
-
pb = self.vertices[v_b][:2]
|
| 363 |
-
|
| 364 |
-
pt = _seg_seg_intersect_2d(start_2d, end_2d, pa, pb)
|
| 365 |
-
if pt is not None:
|
| 366 |
-
hits.append((k, pt))
|
| 367 |
-
|
| 368 |
-
# Deduplicate hits that are at the same location (e.g. hitting a vertex)
|
| 369 |
-
if len(hits) >= 2:
|
| 370 |
-
unique_hits: list[tuple[int, np.ndarray]] = [hits[0]]
|
| 371 |
-
for h in hits[1:]:
|
| 372 |
-
is_dup = False
|
| 373 |
-
for uh in unique_hits:
|
| 374 |
-
if np.linalg.norm(h[1] - uh[1]) < 1e-8:
|
| 375 |
-
is_dup = True
|
| 376 |
-
break
|
| 377 |
-
if not is_dup:
|
| 378 |
-
unique_hits.append(h)
|
| 379 |
-
hits = unique_hits
|
| 380 |
-
|
| 381 |
-
if len(hits) < 2:
|
| 382 |
-
# Line does not fully cross this face — keep face as-is
|
| 383 |
-
new_faces.append(face)
|
| 384 |
-
continue
|
| 385 |
-
|
| 386 |
-
# We only handle the first two intersection points (one chord across face)
|
| 387 |
-
hit_a_edge_idx, hit_a_pt = hits[0]
|
| 388 |
-
hit_b_edge_idx, hit_b_pt = hits[1]
|
| 389 |
-
|
| 390 |
-
# Create / find 3-D vertices at intersection points (z=0 for flat, interpolated otherwise)
|
| 391 |
-
def _interp_z(pt2d: np.ndarray, edge_local: int) -> np.ndarray:
|
| 392 |
-
"""Interpolate z from the edge endpoints."""
|
| 393 |
-
v_a = face[edge_local]
|
| 394 |
-
v_b = face[(edge_local + 1) % n]
|
| 395 |
-
pa = self.vertices[v_a]
|
| 396 |
-
pb = self.vertices[v_b]
|
| 397 |
-
seg = pb[:2] - pa[:2]
|
| 398 |
-
seg_len = np.linalg.norm(seg)
|
| 399 |
-
if seg_len < 1e-12:
|
| 400 |
-
return np.array([pt2d[0], pt2d[1], pa[2]])
|
| 401 |
-
t = np.linalg.norm(pt2d - pa[:2]) / seg_len
|
| 402 |
-
t = np.clip(t, 0.0, 1.0)
|
| 403 |
-
z = pa[2] + t * (pb[2] - pa[2])
|
| 404 |
-
return np.array([pt2d[0], pt2d[1], z])
|
| 405 |
-
|
| 406 |
-
pt_a_3d = _interp_z(hit_a_pt, hit_a_edge_idx)
|
| 407 |
-
pt_b_3d = _interp_z(hit_b_pt, hit_b_edge_idx)
|
| 408 |
-
|
| 409 |
-
idx_a = self._find_or_add_vertex(pt_a_3d)
|
| 410 |
-
idx_b = self._find_or_add_vertex(pt_b_3d)
|
| 411 |
-
|
| 412 |
-
if idx_a == idx_b:
|
| 413 |
-
new_faces.append(face)
|
| 414 |
-
continue
|
| 415 |
-
|
| 416 |
-
# Add the fold-line edge between the two intersection points
|
| 417 |
-
fold_eidx = self._find_or_add_edge(idx_a, idx_b)
|
| 418 |
-
fold_edge_indices.append(fold_eidx)
|
| 419 |
-
|
| 420 |
-
# ── Split the face into two sub-faces ──
|
| 421 |
-
# Walk around the face vertices, inserting idx_a and idx_b at the
|
| 422 |
-
# appropriate positions, then split into two loops.
|
| 423 |
-
ordered_verts = list(face)
|
| 424 |
-
|
| 425 |
-
# Insert intersection vertices into the vertex ring if not already present
|
| 426 |
-
def _insert_after(ring: list[int], after_local: int, vid: int) -> list[int]:
|
| 427 |
-
"""Insert *vid* after position *after_local* if it is not already
|
| 428 |
-
adjacent in the ring at that position."""
|
| 429 |
-
pos = after_local + 1
|
| 430 |
-
if ring[after_local % len(ring)] == vid:
|
| 431 |
-
return ring
|
| 432 |
-
if ring[pos % len(ring)] == vid:
|
| 433 |
-
return ring
|
| 434 |
-
return ring[:pos] + [vid] + ring[pos:]
|
| 435 |
-
|
| 436 |
-
# Determine insertion order — always insert the one with the
|
| 437 |
-
# larger local-edge index first so that the earlier index stays valid.
|
| 438 |
-
if hit_a_edge_idx <= hit_b_edge_idx:
|
| 439 |
-
ordered_verts = _insert_after(ordered_verts, hit_b_edge_idx, idx_b)
|
| 440 |
-
# Recompute hit_a_edge_idx offset if idx_b was inserted before it
|
| 441 |
-
# (it shouldn't be, since hit_b >= hit_a, but guard anyway)
|
| 442 |
-
a_pos = hit_a_edge_idx
|
| 443 |
-
ordered_verts = _insert_after(ordered_verts, a_pos, idx_a)
|
| 444 |
-
else:
|
| 445 |
-
ordered_verts = _insert_after(ordered_verts, hit_a_edge_idx, idx_a)
|
| 446 |
-
ordered_verts = _insert_after(ordered_verts, hit_b_edge_idx, idx_b)
|
| 447 |
-
|
| 448 |
-
# Now split the ring at idx_a and idx_b
|
| 449 |
-
try:
|
| 450 |
-
pos_a = ordered_verts.index(idx_a)
|
| 451 |
-
pos_b = ordered_verts.index(idx_b)
|
| 452 |
-
except ValueError:
|
| 453 |
-
new_faces.append(face)
|
| 454 |
-
continue
|
| 455 |
-
|
| 456 |
-
if pos_a > pos_b:
|
| 457 |
-
pos_a, pos_b = pos_b, pos_a
|
| 458 |
-
|
| 459 |
-
loop1 = ordered_verts[pos_a: pos_b + 1]
|
| 460 |
-
loop2 = ordered_verts[pos_b:] + ordered_verts[: pos_a + 1]
|
| 461 |
-
|
| 462 |
-
# Only keep faces with >= 3 unique vertices
|
| 463 |
-
for loop in (loop1, loop2):
|
| 464 |
-
unique = list(dict.fromkeys(loop)) # preserve order, dedupe
|
| 465 |
-
if len(unique) >= 3:
|
| 466 |
-
new_faces.append(unique)
|
| 467 |
-
# Ensure all edges of this new face exist
|
| 468 |
-
for k in range(len(unique)):
|
| 469 |
-
self._find_or_add_edge(unique[k], unique[(k + 1) % len(unique)])
|
| 470 |
-
|
| 471 |
-
self.faces = new_faces
|
| 472 |
-
return fold_edge_indices
|
| 473 |
-
|
| 474 |
-
# ── vertex side test ────────────────────────────────────────────
|
| 475 |
-
|
| 476 |
-
def get_vertices_on_side(
|
| 477 |
-
self,
|
| 478 |
-
line_start: np.ndarray | list,
|
| 479 |
-
line_end: np.ndarray | list,
|
| 480 |
-
side: str = "positive",
|
| 481 |
-
) -> list[int]:
|
| 482 |
-
"""Return indices of vertices on one side of a 2-D line.
|
| 483 |
-
|
| 484 |
-
*side* can be ``"positive"`` or ``"negative"``. The positive side is
|
| 485 |
-
defined by the left-hand normal of (line_end - line_start).
|
| 486 |
-
"""
|
| 487 |
-
ls = np.asarray(line_start, dtype=np.float64)[:2]
|
| 488 |
-
le = np.asarray(line_end, dtype=np.float64)[:2]
|
| 489 |
-
d = le - ls
|
| 490 |
-
normal = np.array([-d[1], d[0]])
|
| 491 |
-
|
| 492 |
-
indices: list[int] = []
|
| 493 |
-
for i, v in enumerate(self.vertices):
|
| 494 |
-
dot = np.dot(v[:2] - ls, normal)
|
| 495 |
-
if side == "positive" and dot > 1e-9:
|
| 496 |
-
indices.append(i)
|
| 497 |
-
elif side == "negative" and dot < -1e-9:
|
| 498 |
-
indices.append(i)
|
| 499 |
-
return indices
|
| 500 |
-
|
| 501 |
-
# ── deep copy ───────────────────────────────────────────────────
|
| 502 |
-
|
| 503 |
-
def copy(self) -> "Paper":
|
| 504 |
-
"""Return an independent deep copy."""
|
| 505 |
-
return Paper(
|
| 506 |
-
vertices=self.vertices.copy(),
|
| 507 |
-
edges=self.edges.copy(),
|
| 508 |
-
faces=copy.deepcopy(self.faces),
|
| 509 |
-
assignments=list(self.assignments),
|
| 510 |
-
fold_angles=self.fold_angles.copy(),
|
| 511 |
-
face_orders=list(self.face_orders),
|
| 512 |
-
material=Material(
|
| 513 |
-
name=self.material.name,
|
| 514 |
-
thickness_mm=self.material.thickness_mm,
|
| 515 |
-
youngs_modulus_gpa=self.material.youngs_modulus_gpa,
|
| 516 |
-
max_strain=self.material.max_strain,
|
| 517 |
-
poissons_ratio=self.material.poissons_ratio,
|
| 518 |
-
),
|
| 519 |
-
rest_lengths=self.rest_lengths.copy(),
|
| 520 |
-
original_area=self.original_area,
|
| 521 |
-
rest_positions=self.rest_positions.copy(),
|
| 522 |
-
strain_per_vertex=self.strain_per_vertex.copy(),
|
| 523 |
-
energy=dict(self.energy),
|
| 524 |
-
fold_count=self.fold_count,
|
| 525 |
-
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
engine/physics.py
DELETED
|
@@ -1,517 +0,0 @@
|
|
| 1 |
-
"""
|
| 2 |
-
Bar-and-hinge physics model.
|
| 3 |
-
|
| 4 |
-
Three energy components:
|
| 5 |
-
E_total = E_bar + E_facet + E_fold
|
| 6 |
-
|
| 7 |
-
Stiffness parameters are derived from the material properties.
|
| 8 |
-
"""
|
| 9 |
-
|
| 10 |
-
from __future__ import annotations
|
| 11 |
-
|
| 12 |
-
from dataclasses import dataclass
|
| 13 |
-
|
| 14 |
-
import numpy as np
|
| 15 |
-
|
| 16 |
-
from .paper import Paper
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
# ────────────────────────────────────────────────────────────────────
|
| 20 |
-
# Stiffness
|
| 21 |
-
# ────────────────────────────────────────────────────────────────────
|
| 22 |
-
|
| 23 |
-
@dataclass
|
| 24 |
-
class StiffnessParams:
|
| 25 |
-
"""Stiffness values derived from material properties."""
|
| 26 |
-
k_axial: np.ndarray # per-edge axial stiffness (E,)
|
| 27 |
-
k_facet: float # facet (panel bending) stiffness
|
| 28 |
-
k_fold: float # fold (crease torsion) stiffness
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
def compute_stiffness(paper: Paper) -> StiffnessParams:
|
| 32 |
-
"""Derive stiffness parameters from the paper's material and geometry.
|
| 33 |
-
|
| 34 |
-
k_axial = E * t * w / L0 (per edge, w ≈ average of adjacent edge lengths)
|
| 35 |
-
k_facet = E * t^3 / (12 * (1 - nu^2))
|
| 36 |
-
k_fold = 0.1 * k_facet (crease torsional stiffness, empirical fraction)
|
| 37 |
-
"""
|
| 38 |
-
mat = paper.material
|
| 39 |
-
E = mat.youngs_modulus_pa # Pa
|
| 40 |
-
t = mat.thickness_m # m
|
| 41 |
-
nu = mat.poissons_ratio
|
| 42 |
-
|
| 43 |
-
rest = paper.rest_lengths
|
| 44 |
-
# Guard against zero rest lengths
|
| 45 |
-
safe_rest = np.where(rest > 1e-15, rest, 1e-15)
|
| 46 |
-
|
| 47 |
-
# Approximate edge width as the average rest length (simple heuristic)
|
| 48 |
-
w = np.mean(safe_rest) if len(safe_rest) > 0 else 1e-3
|
| 49 |
-
|
| 50 |
-
k_axial = E * t * w / safe_rest # (E,)
|
| 51 |
-
|
| 52 |
-
k_facet = E * t ** 3 / (12.0 * (1.0 - nu ** 2))
|
| 53 |
-
|
| 54 |
-
# Crease torsional stiffness — a fraction of facet stiffness
|
| 55 |
-
k_fold = 0.1 * k_facet
|
| 56 |
-
|
| 57 |
-
return StiffnessParams(k_axial=k_axial, k_facet=k_facet, k_fold=k_fold)
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
# ────────────────────────────────────────────────────────────────────
|
| 61 |
-
# Energy components
|
| 62 |
-
# ────────────────────────────────────────────────────────────────────
|
| 63 |
-
|
| 64 |
-
def compute_bar_energy(paper: Paper) -> float:
|
| 65 |
-
"""E_bar = sum (1/2) * k_axial * (L - L0)^2
|
| 66 |
-
|
| 67 |
-
Measures stretching / compression of edges relative to rest lengths.
|
| 68 |
-
"""
|
| 69 |
-
if len(paper.edges) == 0:
|
| 70 |
-
return 0.0
|
| 71 |
-
|
| 72 |
-
verts = paper.vertices
|
| 73 |
-
edges = paper.edges
|
| 74 |
-
current_lengths = np.array([
|
| 75 |
-
np.linalg.norm(verts[e[1]] - verts[e[0]]) for e in edges
|
| 76 |
-
])
|
| 77 |
-
|
| 78 |
-
stiff = compute_stiffness(paper)
|
| 79 |
-
delta = current_lengths - paper.rest_lengths
|
| 80 |
-
energy = 0.5 * np.sum(stiff.k_axial * delta ** 2)
|
| 81 |
-
return float(energy)
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
def compute_facet_energy(paper: Paper) -> float:
|
| 85 |
-
"""E_facet = sum (1/2) * k_facet * l * (theta - pi)^2
|
| 86 |
-
|
| 87 |
-
Measures bending of facet panels away from flat (pi).
|
| 88 |
-
*l* is the edge length (hinge length) and *theta* is the dihedral angle
|
| 89 |
-
across the edge between two adjacent faces. For edges that are not
|
| 90 |
-
shared by two faces we skip them.
|
| 91 |
-
"""
|
| 92 |
-
if len(paper.edges) == 0 or len(paper.faces) < 2:
|
| 93 |
-
return 0.0
|
| 94 |
-
|
| 95 |
-
stiff = compute_stiffness(paper)
|
| 96 |
-
verts = paper.vertices
|
| 97 |
-
edges = paper.edges
|
| 98 |
-
|
| 99 |
-
# Build edge → face adjacency
|
| 100 |
-
edge_faces: dict[int, list[int]] = {}
|
| 101 |
-
for fi, face in enumerate(paper.faces):
|
| 102 |
-
n = len(face)
|
| 103 |
-
for k in range(n):
|
| 104 |
-
va, vb = face[k], face[(k + 1) % n]
|
| 105 |
-
for ei, e in enumerate(edges):
|
| 106 |
-
if (e[0] == va and e[1] == vb) or (e[0] == vb and e[1] == va):
|
| 107 |
-
edge_faces.setdefault(ei, []).append(fi)
|
| 108 |
-
break
|
| 109 |
-
|
| 110 |
-
energy = 0.0
|
| 111 |
-
for ei, adj_faces in edge_faces.items():
|
| 112 |
-
if len(adj_faces) < 2:
|
| 113 |
-
continue
|
| 114 |
-
# Only consider non-fold edges (flat or boundary interior)
|
| 115 |
-
if paper.assignments[ei] in ("M", "V"):
|
| 116 |
-
continue
|
| 117 |
-
|
| 118 |
-
f1, f2 = adj_faces[0], adj_faces[1]
|
| 119 |
-
theta = _dihedral_angle(verts, paper.faces[f1], paper.faces[f2], edges[ei])
|
| 120 |
-
l = np.linalg.norm(verts[edges[ei][1]] - verts[edges[ei][0]])
|
| 121 |
-
energy += 0.5 * stiff.k_facet * l * (theta - np.pi) ** 2
|
| 122 |
-
|
| 123 |
-
return float(energy)
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
def compute_fold_energy(paper: Paper) -> float:
|
| 127 |
-
"""E_fold = sum (1/2) * k_fold * l * (rho - rho_target)^2
|
| 128 |
-
|
| 129 |
-
Measures deviation of fold creases from their target angles.
|
| 130 |
-
*rho* is the current dihedral angle across the fold edge and
|
| 131 |
-
*rho_target* comes from ``fold_angles``.
|
| 132 |
-
"""
|
| 133 |
-
if len(paper.edges) == 0:
|
| 134 |
-
return 0.0
|
| 135 |
-
|
| 136 |
-
stiff = compute_stiffness(paper)
|
| 137 |
-
verts = paper.vertices
|
| 138 |
-
edges = paper.edges
|
| 139 |
-
|
| 140 |
-
# Build edge → face adjacency
|
| 141 |
-
edge_faces: dict[int, list[int]] = {}
|
| 142 |
-
for fi, face in enumerate(paper.faces):
|
| 143 |
-
n = len(face)
|
| 144 |
-
for k in range(n):
|
| 145 |
-
va, vb = face[k], face[(k + 1) % n]
|
| 146 |
-
for ei, e in enumerate(edges):
|
| 147 |
-
if (e[0] == va and e[1] == vb) or (e[0] == vb and e[1] == va):
|
| 148 |
-
edge_faces.setdefault(ei, []).append(fi)
|
| 149 |
-
break
|
| 150 |
-
|
| 151 |
-
energy = 0.0
|
| 152 |
-
for ei in range(len(edges)):
|
| 153 |
-
if paper.assignments[ei] not in ("M", "V"):
|
| 154 |
-
continue
|
| 155 |
-
if ei not in edge_faces or len(edge_faces[ei]) < 2:
|
| 156 |
-
continue
|
| 157 |
-
|
| 158 |
-
f1, f2 = edge_faces[ei][0], edge_faces[ei][1]
|
| 159 |
-
rho = _dihedral_angle(verts, paper.faces[f1], paper.faces[f2], edges[ei])
|
| 160 |
-
rho_target = np.radians(paper.fold_angles[ei]) # fold_angles stored in degrees
|
| 161 |
-
l = np.linalg.norm(verts[edges[ei][1]] - verts[edges[ei][0]])
|
| 162 |
-
energy += 0.5 * stiff.k_fold * l * (rho - rho_target) ** 2
|
| 163 |
-
|
| 164 |
-
return float(energy)
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
def compute_total_energy(paper: Paper) -> float:
|
| 168 |
-
"""E_total = E_bar + E_facet + E_fold."""
|
| 169 |
-
return compute_bar_energy(paper) + compute_facet_energy(paper) + compute_fold_energy(paper)
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
# ────────────────────────────────────────────────────────────────────
|
| 173 |
-
# Strain
|
| 174 |
-
# ────────────────────────────────────────────────────────────────────
|
| 175 |
-
|
| 176 |
-
def compute_strain(paper: Paper) -> np.ndarray:
|
| 177 |
-
"""Per-vertex Cauchy strain: average fractional edge-length deviation.
|
| 178 |
-
|
| 179 |
-
Returns shape (N,) array of non-negative strain values.
|
| 180 |
-
"""
|
| 181 |
-
n_verts = len(paper.vertices)
|
| 182 |
-
if n_verts == 0:
|
| 183 |
-
return np.empty(0)
|
| 184 |
-
|
| 185 |
-
verts = paper.vertices
|
| 186 |
-
edges = paper.edges
|
| 187 |
-
rest = paper.rest_lengths
|
| 188 |
-
|
| 189 |
-
# Build vertex → edge adjacency
|
| 190 |
-
vert_edges: dict[int, list[int]] = {}
|
| 191 |
-
for ei, e in enumerate(edges):
|
| 192 |
-
vert_edges.setdefault(int(e[0]), []).append(ei)
|
| 193 |
-
vert_edges.setdefault(int(e[1]), []).append(ei)
|
| 194 |
-
|
| 195 |
-
strain = np.zeros(n_verts, dtype=np.float64)
|
| 196 |
-
for vi in range(n_verts):
|
| 197 |
-
adj = vert_edges.get(vi, [])
|
| 198 |
-
if not adj:
|
| 199 |
-
continue
|
| 200 |
-
devs = []
|
| 201 |
-
for ei in adj:
|
| 202 |
-
v1, v2 = edges[ei]
|
| 203 |
-
L = np.linalg.norm(verts[v1] - verts[v2])
|
| 204 |
-
L0 = rest[ei]
|
| 205 |
-
if L0 > 1e-15:
|
| 206 |
-
devs.append(abs(L - L0) / L0)
|
| 207 |
-
if devs:
|
| 208 |
-
strain[vi] = float(np.mean(devs))
|
| 209 |
-
|
| 210 |
-
return strain
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
# ────────────────────────────────────────────────────────────────────
|
| 214 |
-
# Dihedral angle helper
|
| 215 |
-
# ────────────────────────────────────────────────────────────────────
|
| 216 |
-
|
| 217 |
-
def _dihedral_angle(
|
| 218 |
-
verts: np.ndarray,
|
| 219 |
-
face1: list[int],
|
| 220 |
-
face2: list[int],
|
| 221 |
-
edge: np.ndarray,
|
| 222 |
-
) -> float:
|
| 223 |
-
"""Compute the dihedral angle (in radians) between two faces sharing *edge*.
|
| 224 |
-
|
| 225 |
-
Returns angle in [0, 2*pi). Returns pi if normals cannot be computed.
|
| 226 |
-
"""
|
| 227 |
-
n1 = _face_normal(verts, face1)
|
| 228 |
-
n2 = _face_normal(verts, face2)
|
| 229 |
-
|
| 230 |
-
if n1 is None or n2 is None:
|
| 231 |
-
return np.pi
|
| 232 |
-
|
| 233 |
-
cos_a = np.clip(np.dot(n1, n2), -1.0, 1.0)
|
| 234 |
-
angle = np.arccos(cos_a)
|
| 235 |
-
|
| 236 |
-
# Determine sign from edge direction
|
| 237 |
-
edge_dir = verts[edge[1]] - verts[edge[0]]
|
| 238 |
-
edge_dir = edge_dir / (np.linalg.norm(edge_dir) + 1e-30)
|
| 239 |
-
cross = np.cross(n1, n2)
|
| 240 |
-
if np.dot(cross, edge_dir) < 0:
|
| 241 |
-
angle = 2.0 * np.pi - angle
|
| 242 |
-
|
| 243 |
-
return float(angle)
|
| 244 |
-
|
| 245 |
-
|
| 246 |
-
def _face_normal(verts: np.ndarray, face: list[int]) -> np.ndarray | None:
|
| 247 |
-
"""Compute outward unit normal of a face, or None if degenerate."""
|
| 248 |
-
if len(face) < 3:
|
| 249 |
-
return None
|
| 250 |
-
v0 = verts[face[0]]
|
| 251 |
-
v1 = verts[face[1]]
|
| 252 |
-
v2 = verts[face[2]]
|
| 253 |
-
normal = np.cross(v1 - v0, v2 - v0)
|
| 254 |
-
norm = np.linalg.norm(normal)
|
| 255 |
-
if norm < 1e-15:
|
| 256 |
-
return None
|
| 257 |
-
return normal / norm
|
| 258 |
-
|
| 259 |
-
|
| 260 |
-
# ────────────────────────────────────────────────────────────────────
|
| 261 |
-
# Topology precomputation
|
| 262 |
-
# ────────────────────────────────────────────────────────────────────
|
| 263 |
-
|
| 264 |
-
def build_beam_list(paper: Paper) -> list[tuple[int, int, float, float]]:
|
| 265 |
-
"""Build list of (node_a, node_b, rest_len, k_axial) for every edge.
|
| 266 |
-
|
| 267 |
-
Uses normalized stiffness values (arch doc constants) scaled by material
|
| 268 |
-
Young's modulus ratio — keeps the Verlet integrator stable at unit scale.
|
| 269 |
-
"""
|
| 270 |
-
# Normalized stiffness constants (arch doc values)
|
| 271 |
-
K_AXIAL_BASE = 70.0
|
| 272 |
-
# Scale by material: paper (3 GPa) = 1.0 baseline
|
| 273 |
-
mat = paper.material
|
| 274 |
-
E_ratio = mat.youngs_modulus_gpa / 3.0
|
| 275 |
-
k_axial = K_AXIAL_BASE * E_ratio
|
| 276 |
-
|
| 277 |
-
beams = []
|
| 278 |
-
for ei, (v1, v2) in enumerate(paper.edges):
|
| 279 |
-
L0 = paper.rest_lengths[ei]
|
| 280 |
-
beams.append((int(v1), int(v2), float(L0), float(k_axial)))
|
| 281 |
-
return beams
|
| 282 |
-
|
| 283 |
-
|
| 284 |
-
def build_crease_list(paper: Paper) -> list[tuple[int, int, int, int, float, float, str]]:
|
| 285 |
-
"""Build list of (n1, n2, n3, n4, target_angle_rad, k, type) for each crease hinge.
|
| 286 |
-
|
| 287 |
-
Each hinge is defined by 4 nodes: n1-n2 is the hinge edge, n3 and n4 are
|
| 288 |
-
the wing-tip nodes of the two adjacent faces.
|
| 289 |
-
type is 'fold' (M/V crease) or 'facet' (interior flat edge).
|
| 290 |
-
"""
|
| 291 |
-
verts = paper.vertices
|
| 292 |
-
|
| 293 |
-
# Build edge → face adjacency
|
| 294 |
-
edge_faces: dict[int, list[int]] = {}
|
| 295 |
-
for fi, face in enumerate(paper.faces):
|
| 296 |
-
n = len(face)
|
| 297 |
-
for k in range(n):
|
| 298 |
-
va, vb = face[k], face[(k + 1) % n]
|
| 299 |
-
for ei, e in enumerate(paper.edges):
|
| 300 |
-
if (e[0] == va and e[1] == vb) or (e[0] == vb and e[1] == va):
|
| 301 |
-
edge_faces.setdefault(ei, []).append(fi)
|
| 302 |
-
break
|
| 303 |
-
|
| 304 |
-
creases = []
|
| 305 |
-
for ei, adj in edge_faces.items():
|
| 306 |
-
if len(adj) < 2:
|
| 307 |
-
continue
|
| 308 |
-
f1, f2 = adj[0], adj[1]
|
| 309 |
-
face1, face2 = paper.faces[f1], paper.faces[f2]
|
| 310 |
-
n1, n2 = int(paper.edges[ei][0]), int(paper.edges[ei][1])
|
| 311 |
-
|
| 312 |
-
# Find wing-tip nodes (in each face, the vertex NOT on the shared edge)
|
| 313 |
-
wing1 = [v for v in face1 if v != n1 and v != n2]
|
| 314 |
-
wing2 = [v for v in face2 if v != n1 and v != n2]
|
| 315 |
-
if not wing1 or not wing2:
|
| 316 |
-
continue
|
| 317 |
-
n3, n4 = int(wing1[0]), int(wing2[0])
|
| 318 |
-
|
| 319 |
-
# Normalized stiffness constants (arch doc values), scaled by material
|
| 320 |
-
E_ratio = paper.material.youngs_modulus_gpa / 3.0
|
| 321 |
-
K_FACET = 0.2 * E_ratio
|
| 322 |
-
K_FOLD = 0.7 * E_ratio
|
| 323 |
-
|
| 324 |
-
asgn = paper.assignments[ei]
|
| 325 |
-
if asgn in ("M", "V"):
|
| 326 |
-
target = float(np.radians(paper.fold_angles[ei]))
|
| 327 |
-
k = K_FOLD
|
| 328 |
-
ctype = "fold"
|
| 329 |
-
else:
|
| 330 |
-
target = float(np.pi)
|
| 331 |
-
k = K_FACET
|
| 332 |
-
ctype = "facet"
|
| 333 |
-
|
| 334 |
-
creases.append((n1, n2, n3, n4, target, k, ctype))
|
| 335 |
-
return creases
|
| 336 |
-
|
| 337 |
-
|
| 338 |
-
def _torque_to_forces(
|
| 339 |
-
p1: np.ndarray, p2: np.ndarray,
|
| 340 |
-
p3: np.ndarray, p4: np.ndarray,
|
| 341 |
-
torque: float,
|
| 342 |
-
) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]:
|
| 343 |
-
"""Convert a dihedral torque into forces on the 4 hinge nodes.
|
| 344 |
-
|
| 345 |
-
p1-p2 is the hinge edge. p3 and p4 are wing tips.
|
| 346 |
-
Returns (f1, f2, f3, f4) as (3,) arrays.
|
| 347 |
-
"""
|
| 348 |
-
e = p2 - p1
|
| 349 |
-
e_len = np.linalg.norm(e)
|
| 350 |
-
if e_len < 1e-12:
|
| 351 |
-
zero = np.zeros(3)
|
| 352 |
-
return zero, zero, zero, zero
|
| 353 |
-
|
| 354 |
-
e_hat = e / e_len
|
| 355 |
-
|
| 356 |
-
# Perpendicular components of wing vectors relative to hinge
|
| 357 |
-
d3 = p3 - p1
|
| 358 |
-
d4 = p4 - p1
|
| 359 |
-
d3_perp = d3 - np.dot(d3, e_hat) * e_hat
|
| 360 |
-
d4_perp = d4 - np.dot(d4, e_hat) * e_hat
|
| 361 |
-
|
| 362 |
-
len3 = np.linalg.norm(d3_perp)
|
| 363 |
-
len4 = np.linalg.norm(d4_perp)
|
| 364 |
-
|
| 365 |
-
if len3 < 1e-12 or len4 < 1e-12:
|
| 366 |
-
zero = np.zeros(3)
|
| 367 |
-
return zero, zero, zero, zero
|
| 368 |
-
|
| 369 |
-
# Force on wing tips proportional to torque / lever arm
|
| 370 |
-
f3 = torque / (len3 * e_len) * np.cross(e_hat, d3_perp / len3)
|
| 371 |
-
f4 = -torque / (len4 * e_len) * np.cross(e_hat, d4_perp / len4)
|
| 372 |
-
|
| 373 |
-
# Reaction forces distributed to hinge nodes
|
| 374 |
-
f1 = -(f3 + f4) * 0.5
|
| 375 |
-
f2 = -(f3 + f4) * 0.5
|
| 376 |
-
|
| 377 |
-
return f1, f2, f3, f4
|
| 378 |
-
|
| 379 |
-
|
| 380 |
-
# ────────────────────────────────────────────────────────────────────
|
| 381 |
-
# Verlet solver
|
| 382 |
-
# ────────────────────────────────────────────────────────────────────
|
| 383 |
-
|
| 384 |
-
def simulate(
|
| 385 |
-
paper: Paper,
|
| 386 |
-
fold_percent: float = 1.0,
|
| 387 |
-
n_steps: int = 500,
|
| 388 |
-
dt: float = 0.005,
|
| 389 |
-
damping: float = 0.15,
|
| 390 |
-
) -> Paper:
|
| 391 |
-
"""Run bar-and-hinge Verlet integration to relax the mesh.
|
| 392 |
-
|
| 393 |
-
Updates paper.vertices, paper.strain_per_vertex, and paper.energy in-place.
|
| 394 |
-
Returns the mutated paper for chaining.
|
| 395 |
-
|
| 396 |
-
Parameters
|
| 397 |
-
----------
|
| 398 |
-
paper : Paper
|
| 399 |
-
Paper state after a fold has been applied (vertices already rotated).
|
| 400 |
-
fold_percent : float
|
| 401 |
-
How far along the fold to drive (0=flat, 1=full target angle).
|
| 402 |
-
n_steps : int
|
| 403 |
-
Maximum integration steps.
|
| 404 |
-
dt : float
|
| 405 |
-
Time step. Keep small (0.005) for stability with stiff materials.
|
| 406 |
-
damping : float
|
| 407 |
-
Velocity damping coefficient (0=undamped, 1=fully damped).
|
| 408 |
-
"""
|
| 409 |
-
if len(paper.vertices) == 0:
|
| 410 |
-
return paper
|
| 411 |
-
|
| 412 |
-
beams = build_beam_list(paper)
|
| 413 |
-
creases = build_crease_list(paper)
|
| 414 |
-
|
| 415 |
-
pos = paper.vertices.copy() # (N, 3) current positions
|
| 416 |
-
last_pos = pos.copy() # (N, 3) previous positions (Verlet)
|
| 417 |
-
|
| 418 |
-
max_force_cap = 1e6 # prevent runaway forces
|
| 419 |
-
|
| 420 |
-
for _ in range(n_steps):
|
| 421 |
-
forces = np.zeros_like(pos)
|
| 422 |
-
|
| 423 |
-
# ── Beam (axial spring) forces ───────────────────────────────
|
| 424 |
-
for (a, b, L0, k) in beams:
|
| 425 |
-
delta = pos[b] - pos[a]
|
| 426 |
-
L = np.linalg.norm(delta)
|
| 427 |
-
if L < 1e-12:
|
| 428 |
-
continue
|
| 429 |
-
strain = (L - L0) / L0
|
| 430 |
-
F_mag = k * strain
|
| 431 |
-
F_vec = F_mag * (delta / L)
|
| 432 |
-
# Clamp to prevent instability
|
| 433 |
-
F_vec = np.clip(F_vec, -max_force_cap, max_force_cap)
|
| 434 |
-
forces[a] += F_vec
|
| 435 |
-
forces[b] -= F_vec
|
| 436 |
-
|
| 437 |
-
# ── Crease (dihedral spring) forces ─────────────────────────
|
| 438 |
-
for (n1, n2, n3, n4, target, k, ctype) in creases:
|
| 439 |
-
actual_target = target * fold_percent if ctype == "fold" else target
|
| 440 |
-
try:
|
| 441 |
-
theta = _compute_dihedral_rad(pos[n1], pos[n2], pos[n3], pos[n4])
|
| 442 |
-
except Exception:
|
| 443 |
-
continue
|
| 444 |
-
delta_theta = theta - actual_target
|
| 445 |
-
edge_len = np.linalg.norm(pos[n2] - pos[n1])
|
| 446 |
-
torque = k * edge_len * delta_theta
|
| 447 |
-
torque = float(np.clip(torque, -max_force_cap, max_force_cap))
|
| 448 |
-
|
| 449 |
-
f1, f2, f3, f4 = _torque_to_forces(
|
| 450 |
-
pos[n1], pos[n2], pos[n3], pos[n4], torque
|
| 451 |
-
)
|
| 452 |
-
forces[n1] += np.clip(f1, -max_force_cap, max_force_cap)
|
| 453 |
-
forces[n2] += np.clip(f2, -max_force_cap, max_force_cap)
|
| 454 |
-
forces[n3] += np.clip(f3, -max_force_cap, max_force_cap)
|
| 455 |
-
forces[n4] += np.clip(f4, -max_force_cap, max_force_cap)
|
| 456 |
-
|
| 457 |
-
# ── Verlet integration ───────────────────────────────────────
|
| 458 |
-
new_pos = pos + (1.0 - damping) * (pos - last_pos) + forces * (dt * dt)
|
| 459 |
-
|
| 460 |
-
# NaN guard
|
| 461 |
-
if np.any(np.isnan(new_pos)):
|
| 462 |
-
break
|
| 463 |
-
|
| 464 |
-
last_pos = pos
|
| 465 |
-
pos = new_pos
|
| 466 |
-
|
| 467 |
-
# ── Convergence check ────────────────────────────────────────
|
| 468 |
-
kinetic = np.sum((pos - last_pos) ** 2)
|
| 469 |
-
if kinetic < 1e-12:
|
| 470 |
-
break
|
| 471 |
-
|
| 472 |
-
# ── Write results back to paper ──────────────────────────────────
|
| 473 |
-
paper.vertices = pos
|
| 474 |
-
paper.strain_per_vertex = compute_strain(paper)
|
| 475 |
-
paper.energy = {
|
| 476 |
-
"total": compute_total_energy(paper),
|
| 477 |
-
"bar": compute_bar_energy(paper),
|
| 478 |
-
"facet": compute_facet_energy(paper),
|
| 479 |
-
"fold": compute_fold_energy(paper),
|
| 480 |
-
}
|
| 481 |
-
|
| 482 |
-
return paper
|
| 483 |
-
|
| 484 |
-
|
| 485 |
-
def _compute_dihedral_rad(
|
| 486 |
-
p1: np.ndarray, p2: np.ndarray,
|
| 487 |
-
p3: np.ndarray, p4: np.ndarray,
|
| 488 |
-
) -> float:
|
| 489 |
-
"""Dihedral angle in radians between planes (p1,p2,p3) and (p1,p2,p4).
|
| 490 |
-
|
| 491 |
-
p1-p2 is the hinge edge. p3 and p4 are the wing tips.
|
| 492 |
-
Returns angle in [0, 2*pi).
|
| 493 |
-
"""
|
| 494 |
-
e = p2 - p1
|
| 495 |
-
e_norm = np.linalg.norm(e)
|
| 496 |
-
if e_norm < 1e-12:
|
| 497 |
-
return float(np.pi)
|
| 498 |
-
e_hat = e / e_norm
|
| 499 |
-
|
| 500 |
-
n1 = np.cross(p3 - p1, e)
|
| 501 |
-
n2 = np.cross(e, p4 - p1)
|
| 502 |
-
len1 = np.linalg.norm(n1)
|
| 503 |
-
len2 = np.linalg.norm(n2)
|
| 504 |
-
if len1 < 1e-12 or len2 < 1e-12:
|
| 505 |
-
return float(np.pi)
|
| 506 |
-
|
| 507 |
-
n1 = n1 / len1
|
| 508 |
-
n2 = n2 / len2
|
| 509 |
-
|
| 510 |
-
cos_a = float(np.clip(np.dot(n1, n2), -1.0, 1.0))
|
| 511 |
-
angle = np.arccos(cos_a)
|
| 512 |
-
|
| 513 |
-
cross = np.cross(n1, n2)
|
| 514 |
-
if np.dot(cross, e_hat) < 0:
|
| 515 |
-
angle = 2.0 * np.pi - angle
|
| 516 |
-
|
| 517 |
-
return float(angle)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
engine/validation.py
DELETED
|
@@ -1,278 +0,0 @@
|
|
| 1 |
-
"""
|
| 2 |
-
Geometric validation for origami crease patterns.
|
| 3 |
-
|
| 4 |
-
Implements Kawasaki's theorem, Maekawa's theorem, and triangle-triangle
|
| 5 |
-
self-intersection detection.
|
| 6 |
-
"""
|
| 7 |
-
|
| 8 |
-
from __future__ import annotations
|
| 9 |
-
|
| 10 |
-
from dataclasses import dataclass
|
| 11 |
-
|
| 12 |
-
import numpy as np
|
| 13 |
-
|
| 14 |
-
from .paper import Paper
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
# ────────────────────────────────────────────────────────────────────
|
| 18 |
-
# Result container
|
| 19 |
-
# ────────────────────────────────────────────────────────────────────
|
| 20 |
-
|
| 21 |
-
@dataclass
|
| 22 |
-
class ValidationResult:
|
| 23 |
-
kawasaki_valid: bool
|
| 24 |
-
kawasaki_violation: float
|
| 25 |
-
maekawa_valid: bool
|
| 26 |
-
maekawa_violation: float
|
| 27 |
-
intersection_free: bool
|
| 28 |
-
self_intersection_count: int
|
| 29 |
-
is_valid: bool # all checks pass
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
# ────────────────────────────────────────────────────────────────────
|
| 33 |
-
# Kawasaki's theorem
|
| 34 |
-
# ────────────────────────────────────────────────────────────────────
|
| 35 |
-
|
| 36 |
-
def check_kawasaki(paper: Paper) -> tuple[bool, float]:
|
| 37 |
-
"""At each interior vertex, the alternating sum of sector angles equals pi.
|
| 38 |
-
|
| 39 |
-
Specifically, for a vertex with 2n incident creases, the sum of
|
| 40 |
-
odd-indexed sector angles equals the sum of even-indexed sector
|
| 41 |
-
angles equals pi.
|
| 42 |
-
|
| 43 |
-
Returns (is_valid, total_violation). A violation of < 1e-6 is
|
| 44 |
-
considered valid.
|
| 45 |
-
"""
|
| 46 |
-
verts = paper.vertices
|
| 47 |
-
edges = paper.edges
|
| 48 |
-
n_verts = len(verts)
|
| 49 |
-
|
| 50 |
-
# Build adjacency: vertex -> list of neighbor vertices (via edges)
|
| 51 |
-
adj: dict[int, list[int]] = {}
|
| 52 |
-
for e in edges:
|
| 53 |
-
adj.setdefault(int(e[0]), []).append(int(e[1]))
|
| 54 |
-
adj.setdefault(int(e[1]), []).append(int(e[0]))
|
| 55 |
-
|
| 56 |
-
# Identify boundary vertices (incident to a 'B' edge)
|
| 57 |
-
boundary_verts: set[int] = set()
|
| 58 |
-
for ei, e in enumerate(edges):
|
| 59 |
-
if paper.assignments[ei] == "B":
|
| 60 |
-
boundary_verts.add(int(e[0]))
|
| 61 |
-
boundary_verts.add(int(e[1]))
|
| 62 |
-
|
| 63 |
-
total_violation = 0.0
|
| 64 |
-
|
| 65 |
-
for vi in range(n_verts):
|
| 66 |
-
if vi in boundary_verts:
|
| 67 |
-
continue
|
| 68 |
-
neighbors = adj.get(vi, [])
|
| 69 |
-
if len(neighbors) < 2:
|
| 70 |
-
continue
|
| 71 |
-
|
| 72 |
-
# Sort neighbors by angle around vi (in the XY plane for flat-foldability)
|
| 73 |
-
center = verts[vi][:2]
|
| 74 |
-
angles = []
|
| 75 |
-
for ni in neighbors:
|
| 76 |
-
d = verts[ni][:2] - center
|
| 77 |
-
angles.append((np.arctan2(d[1], d[0]), ni))
|
| 78 |
-
angles.sort(key=lambda x: x[0])
|
| 79 |
-
|
| 80 |
-
# Sector angles
|
| 81 |
-
sector_angles = []
|
| 82 |
-
for k in range(len(angles)):
|
| 83 |
-
a1 = angles[k][0]
|
| 84 |
-
a2 = angles[(k + 1) % len(angles)][0]
|
| 85 |
-
diff = a2 - a1
|
| 86 |
-
if diff <= 0:
|
| 87 |
-
diff += 2.0 * np.pi
|
| 88 |
-
sector_angles.append(diff)
|
| 89 |
-
|
| 90 |
-
if len(sector_angles) < 2:
|
| 91 |
-
continue
|
| 92 |
-
|
| 93 |
-
# Kawasaki: alternating sums should both equal pi
|
| 94 |
-
even_sum = sum(sector_angles[i] for i in range(0, len(sector_angles), 2))
|
| 95 |
-
odd_sum = sum(sector_angles[i] for i in range(1, len(sector_angles), 2))
|
| 96 |
-
|
| 97 |
-
violation = abs(even_sum - odd_sum)
|
| 98 |
-
total_violation += violation
|
| 99 |
-
|
| 100 |
-
is_valid = bool(total_violation < 1e-4)
|
| 101 |
-
return is_valid, float(total_violation)
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
# ────────────────────────────────────────────────────────────────────
|
| 105 |
-
# Maekawa's theorem
|
| 106 |
-
# ────────────────────────────────────────────────────────────────────
|
| 107 |
-
|
| 108 |
-
def check_maekawa(paper: Paper) -> tuple[bool, float]:
|
| 109 |
-
"""At each interior vertex, |M - V| = 2.
|
| 110 |
-
|
| 111 |
-
Returns (is_valid, total_violation) where violation is
|
| 112 |
-
sum of |abs(M-V) - 2| over all interior vertices.
|
| 113 |
-
"""
|
| 114 |
-
edges = paper.edges
|
| 115 |
-
verts = paper.vertices
|
| 116 |
-
n_verts = len(verts)
|
| 117 |
-
|
| 118 |
-
# Boundary vertices
|
| 119 |
-
boundary_verts: set[int] = set()
|
| 120 |
-
for ei, e in enumerate(edges):
|
| 121 |
-
if paper.assignments[ei] == "B":
|
| 122 |
-
boundary_verts.add(int(e[0]))
|
| 123 |
-
boundary_verts.add(int(e[1]))
|
| 124 |
-
|
| 125 |
-
# Count M and V edges per vertex
|
| 126 |
-
m_count = [0] * n_verts
|
| 127 |
-
v_count = [0] * n_verts
|
| 128 |
-
total_mv_per_vertex = [0] * n_verts
|
| 129 |
-
|
| 130 |
-
for ei, e in enumerate(edges):
|
| 131 |
-
a = paper.assignments[ei]
|
| 132 |
-
if a == "M":
|
| 133 |
-
m_count[int(e[0])] += 1
|
| 134 |
-
m_count[int(e[1])] += 1
|
| 135 |
-
elif a == "V":
|
| 136 |
-
v_count[int(e[0])] += 1
|
| 137 |
-
v_count[int(e[1])] += 1
|
| 138 |
-
if a in ("M", "V"):
|
| 139 |
-
total_mv_per_vertex[int(e[0])] += 1
|
| 140 |
-
total_mv_per_vertex[int(e[1])] += 1
|
| 141 |
-
|
| 142 |
-
total_violation = 0.0
|
| 143 |
-
for vi in range(n_verts):
|
| 144 |
-
if vi in boundary_verts:
|
| 145 |
-
continue
|
| 146 |
-
# Only check vertices that actually have creases
|
| 147 |
-
if total_mv_per_vertex[vi] == 0:
|
| 148 |
-
continue
|
| 149 |
-
diff = abs(m_count[vi] - v_count[vi])
|
| 150 |
-
violation = abs(diff - 2)
|
| 151 |
-
total_violation += violation
|
| 152 |
-
|
| 153 |
-
is_valid = total_violation < 0.5 # integer theorem, so < 0.5 means exact
|
| 154 |
-
return is_valid, float(total_violation)
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
# ────────────────────────────────────────────────────────────────────
|
| 158 |
-
# Self-intersection detection (triangle-triangle)
|
| 159 |
-
# ────────────────────────────────────────────────────────────────────
|
| 160 |
-
|
| 161 |
-
def check_self_intersection(paper: Paper) -> tuple[bool, int]:
|
| 162 |
-
"""Check for triangle-triangle intersections among the paper's faces.
|
| 163 |
-
|
| 164 |
-
Uses the separating-axis theorem (SAT) for triangle-triangle overlap
|
| 165 |
-
in 3-D. Faces that share an edge or vertex are skipped.
|
| 166 |
-
|
| 167 |
-
Returns (is_valid, count_of_intersections).
|
| 168 |
-
"""
|
| 169 |
-
verts = paper.vertices
|
| 170 |
-
faces = paper.faces
|
| 171 |
-
count = 0
|
| 172 |
-
|
| 173 |
-
for i in range(len(faces)):
|
| 174 |
-
for j in range(i + 1, len(faces)):
|
| 175 |
-
# Skip faces that share vertices (adjacent faces)
|
| 176 |
-
if set(faces[i]) & set(faces[j]):
|
| 177 |
-
continue
|
| 178 |
-
if _triangles_intersect(verts, faces[i], faces[j]):
|
| 179 |
-
count += 1
|
| 180 |
-
|
| 181 |
-
return count == 0, count
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
def _triangles_intersect(
|
| 185 |
-
verts: np.ndarray,
|
| 186 |
-
face1: list[int],
|
| 187 |
-
face2: list[int],
|
| 188 |
-
) -> bool:
|
| 189 |
-
"""Test whether two triangular faces intersect in 3-D using
|
| 190 |
-
the separating-axis theorem (Moller's method simplified).
|
| 191 |
-
|
| 192 |
-
For non-triangular faces, only tests the first three vertices.
|
| 193 |
-
Returns True if the triangles intersect.
|
| 194 |
-
"""
|
| 195 |
-
if len(face1) < 3 or len(face2) < 3:
|
| 196 |
-
return False
|
| 197 |
-
|
| 198 |
-
t1 = verts[face1[:3]]
|
| 199 |
-
t2 = verts[face2[:3]]
|
| 200 |
-
|
| 201 |
-
# 13 potential separating axes:
|
| 202 |
-
# - normals of each triangle (2)
|
| 203 |
-
# - cross products of edge pairs (3x3 = 9)
|
| 204 |
-
# - edges themselves don't need separate tests in 3D SAT
|
| 205 |
-
|
| 206 |
-
e1_edges = [t1[1] - t1[0], t1[2] - t1[1], t1[0] - t1[2]]
|
| 207 |
-
e2_edges = [t2[1] - t2[0], t2[2] - t2[1], t2[0] - t2[2]]
|
| 208 |
-
|
| 209 |
-
n1 = np.cross(e1_edges[0], e1_edges[1])
|
| 210 |
-
n2 = np.cross(e2_edges[0], e2_edges[1])
|
| 211 |
-
|
| 212 |
-
axes = [n1, n2]
|
| 213 |
-
for e1 in e1_edges:
|
| 214 |
-
for e2 in e2_edges:
|
| 215 |
-
ax = np.cross(e1, e2)
|
| 216 |
-
if np.linalg.norm(ax) > 1e-12:
|
| 217 |
-
axes.append(ax)
|
| 218 |
-
|
| 219 |
-
for axis in axes:
|
| 220 |
-
norm = np.linalg.norm(axis)
|
| 221 |
-
if norm < 1e-12:
|
| 222 |
-
continue
|
| 223 |
-
axis = axis / norm
|
| 224 |
-
|
| 225 |
-
proj1 = np.dot(t1, axis)
|
| 226 |
-
proj2 = np.dot(t2, axis)
|
| 227 |
-
|
| 228 |
-
min1, max1 = proj1.min(), proj1.max()
|
| 229 |
-
min2, max2 = proj2.min(), proj2.max()
|
| 230 |
-
|
| 231 |
-
# Check for separation (with small tolerance for shared-edge adjacency)
|
| 232 |
-
if max1 < min2 - 1e-9 or max2 < min1 - 1e-9:
|
| 233 |
-
return False # separating axis found
|
| 234 |
-
|
| 235 |
-
return True # no separating axis → intersection
|
| 236 |
-
|
| 237 |
-
|
| 238 |
-
# ────────────────────────────────────────────────────────────────────
|
| 239 |
-
# Combined validation
|
| 240 |
-
# ────────────────────────────────────────────────────────────────────
|
| 241 |
-
|
| 242 |
-
def validate_paper(paper: Paper) -> ValidationResult:
|
| 243 |
-
"""Run all validation checks and return a combined result."""
|
| 244 |
-
k_valid, k_violation = check_kawasaki(paper)
|
| 245 |
-
m_valid, m_violation = check_maekawa(paper)
|
| 246 |
-
si_valid, si_count = check_self_intersection(paper)
|
| 247 |
-
|
| 248 |
-
return ValidationResult(
|
| 249 |
-
kawasaki_valid=k_valid,
|
| 250 |
-
kawasaki_violation=k_violation,
|
| 251 |
-
maekawa_valid=m_valid,
|
| 252 |
-
maekawa_violation=m_violation,
|
| 253 |
-
intersection_free=si_valid,
|
| 254 |
-
self_intersection_count=si_count,
|
| 255 |
-
is_valid=k_valid and m_valid and si_valid,
|
| 256 |
-
)
|
| 257 |
-
|
| 258 |
-
|
| 259 |
-
def validate_state(paper: Paper) -> dict:
|
| 260 |
-
"""Run all validation checks and return a flat dict.
|
| 261 |
-
|
| 262 |
-
This is the interface used by OrigamiEnvironment. It calls the
|
| 263 |
-
existing validation functions and returns a dict with all fields
|
| 264 |
-
the environment and metrics system need.
|
| 265 |
-
"""
|
| 266 |
-
result = validate_paper(paper)
|
| 267 |
-
strain_exceeded = bool(
|
| 268 |
-
len(paper.strain_per_vertex) > 0
|
| 269 |
-
and float(paper.strain_per_vertex.max()) > paper.material.max_strain
|
| 270 |
-
)
|
| 271 |
-
return {
|
| 272 |
-
"is_valid": result.is_valid and not strain_exceeded,
|
| 273 |
-
"kawasaki_violations": int(not result.kawasaki_valid),
|
| 274 |
-
"kawasaki_total_error": float(result.kawasaki_violation),
|
| 275 |
-
"maekawa_violations": int(not result.maekawa_valid),
|
| 276 |
-
"self_intersections": result.self_intersection_count,
|
| 277 |
-
"strain_exceeded": strain_exceeded,
|
| 278 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
planner/__init__.py
DELETED
|
File without changes
|
planner/decomposer.py
DELETED
|
@@ -1,284 +0,0 @@
|
|
| 1 |
-
"""
|
| 2 |
-
Task decomposer: breaks a parsed instruction into sequential sub-goals
|
| 3 |
-
with concrete fold operations on a unit square.
|
| 4 |
-
"""
|
| 5 |
-
|
| 6 |
-
from __future__ import annotations
|
| 7 |
-
|
| 8 |
-
import copy
|
| 9 |
-
from planner.knowledge import (
|
| 10 |
-
ORIGAMI_MODELS,
|
| 11 |
-
ORIGAMI_BASES,
|
| 12 |
-
FOLD_OPERATIONS,
|
| 13 |
-
get_model_steps,
|
| 14 |
-
get_base_steps,
|
| 15 |
-
)
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
# ---------------------------------------------------------------------------
|
| 19 |
-
# Internal helpers
|
| 20 |
-
# ---------------------------------------------------------------------------
|
| 21 |
-
|
| 22 |
-
def _step_to_fold_operation(step: dict) -> dict:
|
| 23 |
-
"""
|
| 24 |
-
Convert a knowledge-base step dict into the engine's fold operation format:
|
| 25 |
-
{"type": ..., "line": {"start": [...], "end": [...]}, "angle": ...}
|
| 26 |
-
"""
|
| 27 |
-
op = {
|
| 28 |
-
"type": step["type"],
|
| 29 |
-
"line": copy.deepcopy(step["line"]),
|
| 30 |
-
"angle": step.get("angle", 180),
|
| 31 |
-
}
|
| 32 |
-
if "layer_select" in step:
|
| 33 |
-
op["layer_select"] = step["layer_select"]
|
| 34 |
-
return op
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
def _expected_state_after_fold(fold_type: str, prev_state: dict | None) -> dict:
|
| 38 |
-
"""
|
| 39 |
-
Produce a lightweight expected-state dict describing what the paper
|
| 40 |
-
should look like after a fold. This is intentionally approximate --
|
| 41 |
-
the real simulation engine computes exact geometry.
|
| 42 |
-
"""
|
| 43 |
-
state = dict(prev_state or {"layers": 1, "shape": "square", "phase": "flat"})
|
| 44 |
-
if fold_type in ("valley", "mountain"):
|
| 45 |
-
state["layers"] = state.get("layers", 1) * 2
|
| 46 |
-
elif fold_type == "petal":
|
| 47 |
-
state["shape"] = "narrow_diamond"
|
| 48 |
-
elif fold_type == "squash":
|
| 49 |
-
state["shape"] = "diamond"
|
| 50 |
-
elif fold_type == "reverse_inside":
|
| 51 |
-
state["shape"] = "pointed_flap_reversed"
|
| 52 |
-
elif fold_type == "inflate":
|
| 53 |
-
state["phase"] = "3d"
|
| 54 |
-
elif fold_type == "turn_over":
|
| 55 |
-
state["flipped"] = not state.get("flipped", False)
|
| 56 |
-
elif fold_type == "unfold":
|
| 57 |
-
# Layers don't literally halve on every unfold, but this is a hint
|
| 58 |
-
state["layers"] = max(1, state.get("layers", 1) // 2)
|
| 59 |
-
return state
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
def _validation_for_fold(fold_type: str) -> dict:
|
| 63 |
-
"""Return a simple validation dict for a step."""
|
| 64 |
-
checks: dict = {"flat_foldable": True}
|
| 65 |
-
if fold_type in ("valley", "mountain"):
|
| 66 |
-
checks["kawasaki_check"] = True
|
| 67 |
-
checks["maekawa_check"] = True
|
| 68 |
-
if fold_type == "inflate":
|
| 69 |
-
checks["is_3d"] = True
|
| 70 |
-
checks["flat_foldable"] = False
|
| 71 |
-
return checks
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
# ---------------------------------------------------------------------------
|
| 75 |
-
# Known-model decomposition
|
| 76 |
-
# ---------------------------------------------------------------------------
|
| 77 |
-
|
| 78 |
-
def _decompose_known_model(parsed: dict) -> list[dict]:
|
| 79 |
-
"""Decompose a known model into sub-goal steps."""
|
| 80 |
-
model_name: str = parsed["model_name"]
|
| 81 |
-
model_info = ORIGAMI_MODELS.get(model_name)
|
| 82 |
-
if model_info is None:
|
| 83 |
-
return _decompose_free_fold(parsed)
|
| 84 |
-
|
| 85 |
-
base_name = model_info.get("base")
|
| 86 |
-
steps = get_model_steps(model_name)
|
| 87 |
-
sub_goals: list[dict] = []
|
| 88 |
-
running_state: dict = {"layers": 1, "shape": "square", "phase": "flat"}
|
| 89 |
-
|
| 90 |
-
for i, step in enumerate(steps):
|
| 91 |
-
fold_op = _step_to_fold_operation(step)
|
| 92 |
-
running_state = _expected_state_after_fold(step["type"], running_state)
|
| 93 |
-
|
| 94 |
-
sub_goals.append({
|
| 95 |
-
"step_number": i + 1,
|
| 96 |
-
"description": step.get("description", f"Step {i + 1}"),
|
| 97 |
-
"base_required": base_name if i == 0 else None,
|
| 98 |
-
"fold_operations": [fold_op],
|
| 99 |
-
"expected_state": dict(running_state),
|
| 100 |
-
"validation": _validation_for_fold(step["type"]),
|
| 101 |
-
})
|
| 102 |
-
|
| 103 |
-
return sub_goals
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
# ---------------------------------------------------------------------------
|
| 107 |
-
# Packing / optimization decomposition
|
| 108 |
-
# ---------------------------------------------------------------------------
|
| 109 |
-
|
| 110 |
-
def _decompose_packing(parsed: dict) -> list[dict]:
|
| 111 |
-
"""
|
| 112 |
-
Decompose an optimize_packing task into sub-goals.
|
| 113 |
-
Returns a Miura-ori-style fold plan on a unit square.
|
| 114 |
-
"""
|
| 115 |
-
w = parsed["dimensions"]["width"]
|
| 116 |
-
h = parsed["dimensions"]["height"]
|
| 117 |
-
material = parsed["material"]
|
| 118 |
-
constraints = parsed.get("constraints", {})
|
| 119 |
-
max_folds = constraints.get("max_folds", 20)
|
| 120 |
-
|
| 121 |
-
sub_goals: list[dict] = []
|
| 122 |
-
step_num = 0
|
| 123 |
-
|
| 124 |
-
# Horizontal valley/mountain pleats (zigzag in Y)
|
| 125 |
-
n_horizontal = min(4, max_folds // 4)
|
| 126 |
-
spacing_y = 1.0 / (n_horizontal + 1)
|
| 127 |
-
for i in range(n_horizontal):
|
| 128 |
-
step_num += 1
|
| 129 |
-
y = spacing_y * (i + 1)
|
| 130 |
-
fold_type = "valley" if i % 2 == 0 else "mountain"
|
| 131 |
-
sub_goals.append({
|
| 132 |
-
"step_number": step_num,
|
| 133 |
-
"description": f"Horizontal {fold_type} fold at y={y:.3f} (pleat {i + 1}/{n_horizontal})",
|
| 134 |
-
"base_required": None,
|
| 135 |
-
"fold_operations": [{
|
| 136 |
-
"type": fold_type,
|
| 137 |
-
"line": {"start": [0.0, y], "end": [1.0, y]},
|
| 138 |
-
"angle": 180,
|
| 139 |
-
"layer_select": "all",
|
| 140 |
-
}],
|
| 141 |
-
"expected_state": {"layers": i + 2, "phase": "flat", "pattern": "miura_horizontal"},
|
| 142 |
-
"validation": {"flat_foldable": True, "kawasaki_check": True},
|
| 143 |
-
})
|
| 144 |
-
|
| 145 |
-
# Vertical zigzag valley/mountain pleats (Miura-ori angle offsets)
|
| 146 |
-
n_vertical = min(4, (max_folds - n_horizontal) // 2)
|
| 147 |
-
spacing_x = 1.0 / (n_vertical + 1)
|
| 148 |
-
for i in range(n_vertical):
|
| 149 |
-
step_num += 1
|
| 150 |
-
x = spacing_x * (i + 1)
|
| 151 |
-
fold_type = "valley" if i % 2 == 0 else "mountain"
|
| 152 |
-
# Miura-ori: alternate slight angle offset to create parallelogram cells
|
| 153 |
-
angle_offset = 0.02 * (1 if i % 2 == 0 else -1)
|
| 154 |
-
sub_goals.append({
|
| 155 |
-
"step_number": step_num,
|
| 156 |
-
"description": f"Vertical {fold_type} fold at x={x:.3f} (Miura-ori column {i + 1}/{n_vertical})",
|
| 157 |
-
"base_required": None,
|
| 158 |
-
"fold_operations": [{
|
| 159 |
-
"type": fold_type,
|
| 160 |
-
"line": {"start": [x, 0.0 + angle_offset], "end": [x, 1.0 - angle_offset]},
|
| 161 |
-
"angle": 180,
|
| 162 |
-
"layer_select": "all",
|
| 163 |
-
}],
|
| 164 |
-
"expected_state": {
|
| 165 |
-
"layers": (n_horizontal + 1) * (i + 2),
|
| 166 |
-
"phase": "flat",
|
| 167 |
-
"pattern": "miura_complete" if i == n_vertical - 1 else "miura_partial",
|
| 168 |
-
},
|
| 169 |
-
"validation": {"flat_foldable": True, "kawasaki_check": True, "maekawa_check": True},
|
| 170 |
-
})
|
| 171 |
-
|
| 172 |
-
# Final collapse
|
| 173 |
-
step_num += 1
|
| 174 |
-
sub_goals.append({
|
| 175 |
-
"step_number": step_num,
|
| 176 |
-
"description": "Collapse all creases simultaneously into compact Miura-ori stack",
|
| 177 |
-
"base_required": None,
|
| 178 |
-
"fold_operations": [{
|
| 179 |
-
"type": "valley",
|
| 180 |
-
"line": {"start": [0.0, 0.5], "end": [1.0, 0.5]},
|
| 181 |
-
"angle": 180,
|
| 182 |
-
"layer_select": "all",
|
| 183 |
-
}],
|
| 184 |
-
"expected_state": {
|
| 185 |
-
"layers": (n_horizontal + 1) * (n_vertical + 1),
|
| 186 |
-
"phase": "compact",
|
| 187 |
-
"pattern": "miura_ori",
|
| 188 |
-
},
|
| 189 |
-
"validation": {
|
| 190 |
-
"flat_foldable": True,
|
| 191 |
-
"check_bounding_box": constraints.get("target_box"),
|
| 192 |
-
"check_deployable": constraints.get("must_deploy", False),
|
| 193 |
-
},
|
| 194 |
-
})
|
| 195 |
-
|
| 196 |
-
return sub_goals
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
# ---------------------------------------------------------------------------
|
| 200 |
-
# Free-fold / unknown model decomposition
|
| 201 |
-
# ---------------------------------------------------------------------------
|
| 202 |
-
|
| 203 |
-
def _decompose_free_fold(parsed: dict) -> list[dict]:
|
| 204 |
-
"""
|
| 205 |
-
Generic decomposition for an unknown model or free-form folding task.
|
| 206 |
-
Returns a minimal plan that an LLM can expand upon.
|
| 207 |
-
"""
|
| 208 |
-
return [
|
| 209 |
-
{
|
| 210 |
-
"step_number": 1,
|
| 211 |
-
"description": "Create reference creases (diagonals and midlines)",
|
| 212 |
-
"base_required": None,
|
| 213 |
-
"fold_operations": [
|
| 214 |
-
{"type": "valley", "line": {"start": [0.0, 0.0], "end": [1.0, 1.0]}, "angle": 180},
|
| 215 |
-
{"type": "unfold", "line": {"start": [0.0, 0.0], "end": [1.0, 1.0]}, "angle": 0},
|
| 216 |
-
{"type": "valley", "line": {"start": [1.0, 0.0], "end": [0.0, 1.0]}, "angle": 180},
|
| 217 |
-
{"type": "unfold", "line": {"start": [1.0, 0.0], "end": [0.0, 1.0]}, "angle": 0},
|
| 218 |
-
{"type": "valley", "line": {"start": [0.0, 0.5], "end": [1.0, 0.5]}, "angle": 180},
|
| 219 |
-
{"type": "unfold", "line": {"start": [0.0, 0.5], "end": [1.0, 0.5]}, "angle": 0},
|
| 220 |
-
],
|
| 221 |
-
"expected_state": {"layers": 1, "shape": "square", "phase": "creased"},
|
| 222 |
-
"validation": {"flat_foldable": True},
|
| 223 |
-
},
|
| 224 |
-
{
|
| 225 |
-
"step_number": 2,
|
| 226 |
-
"description": "Collapse into a base form using reference creases",
|
| 227 |
-
"base_required": "preliminary_base",
|
| 228 |
-
"fold_operations": [
|
| 229 |
-
{"type": "valley", "line": {"start": [0.0, 0.0], "end": [1.0, 1.0]}, "angle": 180, "layer_select": "all"},
|
| 230 |
-
],
|
| 231 |
-
"expected_state": {"layers": 4, "shape": "diamond", "phase": "base"},
|
| 232 |
-
"validation": {"flat_foldable": True},
|
| 233 |
-
},
|
| 234 |
-
{
|
| 235 |
-
"step_number": 3,
|
| 236 |
-
"description": "Shape the model with additional folds (LLM determines specifics)",
|
| 237 |
-
"base_required": None,
|
| 238 |
-
"fold_operations": [], # Left empty for LLM to fill
|
| 239 |
-
"expected_state": {"phase": "shaped"},
|
| 240 |
-
"validation": {"flat_foldable": True},
|
| 241 |
-
},
|
| 242 |
-
]
|
| 243 |
-
|
| 244 |
-
|
| 245 |
-
# ---------------------------------------------------------------------------
|
| 246 |
-
# Fold-pattern decomposition
|
| 247 |
-
# ---------------------------------------------------------------------------
|
| 248 |
-
|
| 249 |
-
def _decompose_pattern(parsed: dict) -> list[dict]:
|
| 250 |
-
"""Decompose a tessellation/pattern task."""
|
| 251 |
-
# For now, delegate to packing which generates a Miura-ori pattern
|
| 252 |
-
return _decompose_packing(parsed)
|
| 253 |
-
|
| 254 |
-
|
| 255 |
-
# ---------------------------------------------------------------------------
|
| 256 |
-
# Public API
|
| 257 |
-
# ---------------------------------------------------------------------------
|
| 258 |
-
|
| 259 |
-
def decompose_task(parsed: dict) -> list[dict]:
|
| 260 |
-
"""
|
| 261 |
-
Decompose a parsed instruction into sequential sub-goals.
|
| 262 |
-
|
| 263 |
-
Args:
|
| 264 |
-
parsed: Output of parse_instruction()
|
| 265 |
-
|
| 266 |
-
Returns:
|
| 267 |
-
List of sub-goal dicts, each with:
|
| 268 |
-
- step_number: int
|
| 269 |
-
- description: str
|
| 270 |
-
- base_required: str or None
|
| 271 |
-
- fold_operations: list[dict] (engine-format fold dicts)
|
| 272 |
-
- expected_state: dict
|
| 273 |
-
- validation: dict
|
| 274 |
-
"""
|
| 275 |
-
intent = parsed.get("intent", "free_fold")
|
| 276 |
-
|
| 277 |
-
if intent == "fold_model" and parsed.get("model_name"):
|
| 278 |
-
return _decompose_known_model(parsed)
|
| 279 |
-
elif intent == "optimize_packing":
|
| 280 |
-
return _decompose_packing(parsed)
|
| 281 |
-
elif intent == "fold_pattern":
|
| 282 |
-
return _decompose_pattern(parsed)
|
| 283 |
-
else:
|
| 284 |
-
return _decompose_free_fold(parsed)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
planner/knowledge.py
DELETED
|
@@ -1,753 +0,0 @@
|
|
| 1 |
-
"""
|
| 2 |
-
Origami knowledge base: bases, models, and fold operation primitives.
|
| 3 |
-
|
| 4 |
-
All fold coordinates are defined on a unit square (0,0) to (1,1).
|
| 5 |
-
Fold lines use {"start": [x, y], "end": [x, y]} format matching the
|
| 6 |
-
engine's FoldAction specification from architecture.md.
|
| 7 |
-
"""
|
| 8 |
-
|
| 9 |
-
# ---------------------------------------------------------------------------
|
| 10 |
-
# Primitive fold operations — mapping from named folds to engine-level dicts
|
| 11 |
-
# ---------------------------------------------------------------------------
|
| 12 |
-
|
| 13 |
-
FOLD_OPERATIONS = {
|
| 14 |
-
"valley_fold": {
|
| 15 |
-
"type": "valley",
|
| 16 |
-
"angle": 180,
|
| 17 |
-
"description": "Fold paper toward you along a crease line.",
|
| 18 |
-
},
|
| 19 |
-
"mountain_fold": {
|
| 20 |
-
"type": "mountain",
|
| 21 |
-
"angle": 180,
|
| 22 |
-
"description": "Fold paper away from you along a crease line.",
|
| 23 |
-
},
|
| 24 |
-
"squash_fold": {
|
| 25 |
-
"type": "squash",
|
| 26 |
-
"angle": 180,
|
| 27 |
-
"description": "Open a flap and flatten it symmetrically.",
|
| 28 |
-
"primitives": [
|
| 29 |
-
{"type": "valley", "angle": 90, "sub_op": "open_flap"},
|
| 30 |
-
{"type": "valley", "angle": 180, "sub_op": "flatten"},
|
| 31 |
-
],
|
| 32 |
-
},
|
| 33 |
-
"petal_fold": {
|
| 34 |
-
"type": "petal",
|
| 35 |
-
"angle": 180,
|
| 36 |
-
"description": "Lift a point while collapsing sides inward to create a narrow flap.",
|
| 37 |
-
"primitives": [
|
| 38 |
-
{"type": "valley", "angle": 180, "sub_op": "fold_left_edge_to_center"},
|
| 39 |
-
{"type": "valley", "angle": 180, "sub_op": "fold_right_edge_to_center"},
|
| 40 |
-
{"type": "valley", "angle": 180, "sub_op": "lift_bottom_point"},
|
| 41 |
-
{"type": "mountain", "angle": 180, "sub_op": "collapse_left"},
|
| 42 |
-
{"type": "mountain", "angle": 180, "sub_op": "collapse_right"},
|
| 43 |
-
],
|
| 44 |
-
},
|
| 45 |
-
"reverse_inside_fold": {
|
| 46 |
-
"type": "reverse_inside",
|
| 47 |
-
"angle": 180,
|
| 48 |
-
"description": "Push a flap tip inward, reversing the spine crease.",
|
| 49 |
-
"primitives": [
|
| 50 |
-
{"type": "valley", "angle": 180, "sub_op": "new_crease_left"},
|
| 51 |
-
{"type": "valley", "angle": 180, "sub_op": "new_crease_right"},
|
| 52 |
-
{"type": "mountain", "angle": 180, "sub_op": "reverse_spine"},
|
| 53 |
-
],
|
| 54 |
-
},
|
| 55 |
-
"reverse_outside_fold": {
|
| 56 |
-
"type": "reverse_outside",
|
| 57 |
-
"angle": 180,
|
| 58 |
-
"description": "Wrap a flap tip around the outside, reversing the spine crease.",
|
| 59 |
-
"primitives": [
|
| 60 |
-
{"type": "mountain", "angle": 180, "sub_op": "new_crease_left"},
|
| 61 |
-
{"type": "mountain", "angle": 180, "sub_op": "new_crease_right"},
|
| 62 |
-
{"type": "valley", "angle": 180, "sub_op": "reverse_spine"},
|
| 63 |
-
],
|
| 64 |
-
},
|
| 65 |
-
"crimp": {
|
| 66 |
-
"type": "crimp",
|
| 67 |
-
"angle": 180,
|
| 68 |
-
"description": "Pair of reverse folds creating a zigzag step.",
|
| 69 |
-
"primitives": [
|
| 70 |
-
{"type": "valley", "angle": 180, "sub_op": "first_crease"},
|
| 71 |
-
{"type": "mountain", "angle": 180, "sub_op": "second_crease"},
|
| 72 |
-
],
|
| 73 |
-
},
|
| 74 |
-
"pleat": {
|
| 75 |
-
"type": "pleat",
|
| 76 |
-
"angle": 180,
|
| 77 |
-
"description": "Alternating valley and mountain folds creating an accordion.",
|
| 78 |
-
"primitives": [
|
| 79 |
-
{"type": "valley", "angle": 180, "sub_op": "valley_crease"},
|
| 80 |
-
{"type": "mountain", "angle": 180, "sub_op": "mountain_crease"},
|
| 81 |
-
],
|
| 82 |
-
},
|
| 83 |
-
"rabbit_ear": {
|
| 84 |
-
"type": "rabbit_ear",
|
| 85 |
-
"angle": 180,
|
| 86 |
-
"description": "Three creases meeting at a point, creating a triangular raised flap.",
|
| 87 |
-
"primitives": [
|
| 88 |
-
{"type": "valley", "angle": 180, "sub_op": "bisector_1"},
|
| 89 |
-
{"type": "valley", "angle": 180, "sub_op": "bisector_2"},
|
| 90 |
-
{"type": "mountain", "angle": 180, "sub_op": "ridge"},
|
| 91 |
-
],
|
| 92 |
-
},
|
| 93 |
-
"sink_fold": {
|
| 94 |
-
"type": "sink",
|
| 95 |
-
"angle": 180,
|
| 96 |
-
"description": "Push a point into the interior of the model.",
|
| 97 |
-
"primitives": [
|
| 98 |
-
{"type": "mountain", "angle": 180, "sub_op": "reverse_creases"},
|
| 99 |
-
{"type": "valley", "angle": 180, "sub_op": "reflatten"},
|
| 100 |
-
],
|
| 101 |
-
},
|
| 102 |
-
"turn_over": {
|
| 103 |
-
"type": "turn_over",
|
| 104 |
-
"angle": 0,
|
| 105 |
-
"description": "Flip the paper over.",
|
| 106 |
-
},
|
| 107 |
-
"unfold": {
|
| 108 |
-
"type": "unfold",
|
| 109 |
-
"angle": 0,
|
| 110 |
-
"description": "Reverse a previous fold (crease remains).",
|
| 111 |
-
},
|
| 112 |
-
"inflate": {
|
| 113 |
-
"type": "inflate",
|
| 114 |
-
"angle": 0,
|
| 115 |
-
"description": "Gently open and puff out the model to create 3D form.",
|
| 116 |
-
},
|
| 117 |
-
}
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
# ---------------------------------------------------------------------------
|
| 121 |
-
# Origami bases — fundamental starting configurations
|
| 122 |
-
# ---------------------------------------------------------------------------
|
| 123 |
-
|
| 124 |
-
ORIGAMI_BASES = {
|
| 125 |
-
"preliminary_base": {
|
| 126 |
-
"name": "Preliminary Base (Square Base)",
|
| 127 |
-
"description": "Multi-layered diamond standing on a corner. Gateway to bird and frog bases.",
|
| 128 |
-
"total_steps": 9,
|
| 129 |
-
"steps": [
|
| 130 |
-
{
|
| 131 |
-
"description": "Fold in half diagonally (bottom-left to top-right)",
|
| 132 |
-
"type": "valley",
|
| 133 |
-
"line": {"start": [0.0, 0.0], "end": [1.0, 1.0]},
|
| 134 |
-
"angle": 180,
|
| 135 |
-
"layer_select": "all",
|
| 136 |
-
},
|
| 137 |
-
{
|
| 138 |
-
"description": "Unfold",
|
| 139 |
-
"type": "unfold",
|
| 140 |
-
"line": {"start": [0.0, 0.0], "end": [1.0, 1.0]},
|
| 141 |
-
"angle": 0,
|
| 142 |
-
"layer_select": "all",
|
| 143 |
-
},
|
| 144 |
-
{
|
| 145 |
-
"description": "Fold in half on other diagonal (bottom-right to top-left)",
|
| 146 |
-
"type": "valley",
|
| 147 |
-
"line": {"start": [1.0, 0.0], "end": [0.0, 1.0]},
|
| 148 |
-
"angle": 180,
|
| 149 |
-
"layer_select": "all",
|
| 150 |
-
},
|
| 151 |
-
{
|
| 152 |
-
"description": "Unfold",
|
| 153 |
-
"type": "unfold",
|
| 154 |
-
"line": {"start": [1.0, 0.0], "end": [0.0, 1.0]},
|
| 155 |
-
"angle": 0,
|
| 156 |
-
"layer_select": "all",
|
| 157 |
-
},
|
| 158 |
-
{
|
| 159 |
-
"description": "Fold in half horizontally",
|
| 160 |
-
"type": "valley",
|
| 161 |
-
"line": {"start": [0.0, 0.5], "end": [1.0, 0.5]},
|
| 162 |
-
"angle": 180,
|
| 163 |
-
"layer_select": "all",
|
| 164 |
-
},
|
| 165 |
-
{
|
| 166 |
-
"description": "Unfold",
|
| 167 |
-
"type": "unfold",
|
| 168 |
-
"line": {"start": [0.0, 0.5], "end": [1.0, 0.5]},
|
| 169 |
-
"angle": 0,
|
| 170 |
-
"layer_select": "all",
|
| 171 |
-
},
|
| 172 |
-
{
|
| 173 |
-
"description": "Fold in half vertically",
|
| 174 |
-
"type": "valley",
|
| 175 |
-
"line": {"start": [0.5, 0.0], "end": [0.5, 1.0]},
|
| 176 |
-
"angle": 180,
|
| 177 |
-
"layer_select": "all",
|
| 178 |
-
},
|
| 179 |
-
{
|
| 180 |
-
"description": "Unfold",
|
| 181 |
-
"type": "unfold",
|
| 182 |
-
"line": {"start": [0.5, 0.0], "end": [0.5, 1.0]},
|
| 183 |
-
"angle": 0,
|
| 184 |
-
"layer_select": "all",
|
| 185 |
-
},
|
| 186 |
-
{
|
| 187 |
-
"description": "Collapse into preliminary base: push sides in using existing creases",
|
| 188 |
-
"type": "valley",
|
| 189 |
-
"line": {"start": [0.0, 0.0], "end": [1.0, 1.0]},
|
| 190 |
-
"angle": 180,
|
| 191 |
-
"layer_select": "all",
|
| 192 |
-
"simultaneous": [
|
| 193 |
-
{"type": "valley", "line": {"start": [1.0, 0.0], "end": [0.0, 1.0]}, "angle": 180},
|
| 194 |
-
{"type": "mountain", "line": {"start": [0.0, 0.5], "end": [1.0, 0.5]}, "angle": 180},
|
| 195 |
-
{"type": "mountain", "line": {"start": [0.5, 0.0], "end": [0.5, 1.0]}, "angle": 180},
|
| 196 |
-
],
|
| 197 |
-
},
|
| 198 |
-
],
|
| 199 |
-
},
|
| 200 |
-
|
| 201 |
-
"waterbomb_base": {
|
| 202 |
-
"name": "Waterbomb Base",
|
| 203 |
-
"description": "Flat triangle with multiple layers. Inverse of preliminary base.",
|
| 204 |
-
"total_steps": 9,
|
| 205 |
-
"steps": [
|
| 206 |
-
{
|
| 207 |
-
"description": "Fold both diagonals — first diagonal",
|
| 208 |
-
"type": "valley",
|
| 209 |
-
"line": {"start": [0.0, 0.0], "end": [1.0, 1.0]},
|
| 210 |
-
"angle": 180,
|
| 211 |
-
"layer_select": "all",
|
| 212 |
-
},
|
| 213 |
-
{
|
| 214 |
-
"description": "Unfold first diagonal",
|
| 215 |
-
"type": "unfold",
|
| 216 |
-
"line": {"start": [0.0, 0.0], "end": [1.0, 1.0]},
|
| 217 |
-
"angle": 0,
|
| 218 |
-
"layer_select": "all",
|
| 219 |
-
},
|
| 220 |
-
{
|
| 221 |
-
"description": "Fold second diagonal",
|
| 222 |
-
"type": "valley",
|
| 223 |
-
"line": {"start": [1.0, 0.0], "end": [0.0, 1.0]},
|
| 224 |
-
"angle": 180,
|
| 225 |
-
"layer_select": "all",
|
| 226 |
-
},
|
| 227 |
-
{
|
| 228 |
-
"description": "Unfold second diagonal",
|
| 229 |
-
"type": "unfold",
|
| 230 |
-
"line": {"start": [1.0, 0.0], "end": [0.0, 1.0]},
|
| 231 |
-
"angle": 0,
|
| 232 |
-
"layer_select": "all",
|
| 233 |
-
},
|
| 234 |
-
{
|
| 235 |
-
"description": "Mountain fold horizontally",
|
| 236 |
-
"type": "mountain",
|
| 237 |
-
"line": {"start": [0.0, 0.5], "end": [1.0, 0.5]},
|
| 238 |
-
"angle": 180,
|
| 239 |
-
"layer_select": "all",
|
| 240 |
-
},
|
| 241 |
-
{
|
| 242 |
-
"description": "Unfold horizontal",
|
| 243 |
-
"type": "unfold",
|
| 244 |
-
"line": {"start": [0.0, 0.5], "end": [1.0, 0.5]},
|
| 245 |
-
"angle": 0,
|
| 246 |
-
"layer_select": "all",
|
| 247 |
-
},
|
| 248 |
-
{
|
| 249 |
-
"description": "Mountain fold vertically",
|
| 250 |
-
"type": "mountain",
|
| 251 |
-
"line": {"start": [0.5, 0.0], "end": [0.5, 1.0]},
|
| 252 |
-
"angle": 180,
|
| 253 |
-
"layer_select": "all",
|
| 254 |
-
},
|
| 255 |
-
{
|
| 256 |
-
"description": "Unfold vertical",
|
| 257 |
-
"type": "unfold",
|
| 258 |
-
"line": {"start": [0.5, 0.0], "end": [0.5, 1.0]},
|
| 259 |
-
"angle": 0,
|
| 260 |
-
"layer_select": "all",
|
| 261 |
-
},
|
| 262 |
-
{
|
| 263 |
-
"description": "Collapse into waterbomb base: fold top edge down, push sides in",
|
| 264 |
-
"type": "mountain",
|
| 265 |
-
"line": {"start": [0.0, 0.5], "end": [1.0, 0.5]},
|
| 266 |
-
"angle": 180,
|
| 267 |
-
"layer_select": "all",
|
| 268 |
-
"simultaneous": [
|
| 269 |
-
{"type": "valley", "line": {"start": [0.0, 0.0], "end": [1.0, 1.0]}, "angle": 180},
|
| 270 |
-
{"type": "valley", "line": {"start": [1.0, 0.0], "end": [0.0, 1.0]}, "angle": 180},
|
| 271 |
-
],
|
| 272 |
-
},
|
| 273 |
-
],
|
| 274 |
-
},
|
| 275 |
-
|
| 276 |
-
"bird_base": {
|
| 277 |
-
"name": "Bird Base (Crane Base)",
|
| 278 |
-
"description": "Long diamond with 4 narrow flaps. Built from preliminary base + 2 petal folds.",
|
| 279 |
-
"requires_base": "preliminary_base",
|
| 280 |
-
"total_steps": 13,
|
| 281 |
-
"steps": [
|
| 282 |
-
# Steps 1-9 are the preliminary base (included by reference)
|
| 283 |
-
# Steps 10-22 from the crane sequence build the bird base
|
| 284 |
-
{
|
| 285 |
-
"description": "Fold left edge of top layer to center line",
|
| 286 |
-
"type": "valley",
|
| 287 |
-
"line": {"start": [0.25, 0.5], "end": [0.5, 1.0]},
|
| 288 |
-
"angle": 180,
|
| 289 |
-
"layer_select": "top",
|
| 290 |
-
},
|
| 291 |
-
{
|
| 292 |
-
"description": "Fold right edge of top layer to center line",
|
| 293 |
-
"type": "valley",
|
| 294 |
-
"line": {"start": [0.75, 0.5], "end": [0.5, 1.0]},
|
| 295 |
-
"angle": 180,
|
| 296 |
-
"layer_select": "top",
|
| 297 |
-
},
|
| 298 |
-
{
|
| 299 |
-
"description": "Fold top triangle down over kite flaps (crease only)",
|
| 300 |
-
"type": "valley",
|
| 301 |
-
"line": {"start": [0.25, 0.75], "end": [0.75, 0.75]},
|
| 302 |
-
"angle": 180,
|
| 303 |
-
"layer_select": "top",
|
| 304 |
-
},
|
| 305 |
-
{
|
| 306 |
-
"description": "Unfold top triangle",
|
| 307 |
-
"type": "unfold",
|
| 308 |
-
"line": {"start": [0.25, 0.75], "end": [0.75, 0.75]},
|
| 309 |
-
"angle": 0,
|
| 310 |
-
"layer_select": "top",
|
| 311 |
-
},
|
| 312 |
-
{
|
| 313 |
-
"description": "Unfold kite folds",
|
| 314 |
-
"type": "unfold",
|
| 315 |
-
"line": {"start": [0.25, 0.5], "end": [0.5, 1.0]},
|
| 316 |
-
"angle": 0,
|
| 317 |
-
"layer_select": "top",
|
| 318 |
-
},
|
| 319 |
-
{
|
| 320 |
-
"description": "Petal fold front: lift bottom point up, sides collapse inward",
|
| 321 |
-
"type": "petal",
|
| 322 |
-
"line": {"start": [0.5, 0.5], "end": [0.5, 1.0]},
|
| 323 |
-
"angle": 180,
|
| 324 |
-
"layer_select": "top",
|
| 325 |
-
},
|
| 326 |
-
{
|
| 327 |
-
"description": "Turn model over",
|
| 328 |
-
"type": "turn_over",
|
| 329 |
-
"line": {"start": [0.5, 0.0], "end": [0.5, 1.0]},
|
| 330 |
-
"angle": 0,
|
| 331 |
-
"layer_select": "all",
|
| 332 |
-
},
|
| 333 |
-
{
|
| 334 |
-
"description": "Fold left edge to center line (back)",
|
| 335 |
-
"type": "valley",
|
| 336 |
-
"line": {"start": [0.25, 0.5], "end": [0.5, 1.0]},
|
| 337 |
-
"angle": 180,
|
| 338 |
-
"layer_select": "top",
|
| 339 |
-
},
|
| 340 |
-
{
|
| 341 |
-
"description": "Fold right edge to center line (back)",
|
| 342 |
-
"type": "valley",
|
| 343 |
-
"line": {"start": [0.75, 0.5], "end": [0.5, 1.0]},
|
| 344 |
-
"angle": 180,
|
| 345 |
-
"layer_select": "top",
|
| 346 |
-
},
|
| 347 |
-
{
|
| 348 |
-
"description": "Fold top triangle down (crease only, back)",
|
| 349 |
-
"type": "valley",
|
| 350 |
-
"line": {"start": [0.25, 0.75], "end": [0.75, 0.75]},
|
| 351 |
-
"angle": 180,
|
| 352 |
-
"layer_select": "top",
|
| 353 |
-
},
|
| 354 |
-
{
|
| 355 |
-
"description": "Unfold top triangle (back)",
|
| 356 |
-
"type": "unfold",
|
| 357 |
-
"line": {"start": [0.25, 0.75], "end": [0.75, 0.75]},
|
| 358 |
-
"angle": 0,
|
| 359 |
-
"layer_select": "top",
|
| 360 |
-
},
|
| 361 |
-
{
|
| 362 |
-
"description": "Unfold kite folds (back)",
|
| 363 |
-
"type": "unfold",
|
| 364 |
-
"line": {"start": [0.25, 0.5], "end": [0.5, 1.0]},
|
| 365 |
-
"angle": 0,
|
| 366 |
-
"layer_select": "top",
|
| 367 |
-
},
|
| 368 |
-
{
|
| 369 |
-
"description": "Petal fold back: lift bottom point up, sides collapse inward",
|
| 370 |
-
"type": "petal",
|
| 371 |
-
"line": {"start": [0.5, 0.5], "end": [0.5, 1.0]},
|
| 372 |
-
"angle": 180,
|
| 373 |
-
"layer_select": "top",
|
| 374 |
-
},
|
| 375 |
-
],
|
| 376 |
-
},
|
| 377 |
-
|
| 378 |
-
"frog_base": {
|
| 379 |
-
"name": "Frog Base",
|
| 380 |
-
"description": "4 long narrow flaps radiating from center. Built from preliminary base + 4 squash + 4 petal folds.",
|
| 381 |
-
"requires_base": "preliminary_base",
|
| 382 |
-
"total_steps": 8,
|
| 383 |
-
"steps": [
|
| 384 |
-
{
|
| 385 |
-
"description": "Squash fold front-left flap",
|
| 386 |
-
"type": "squash",
|
| 387 |
-
"line": {"start": [0.25, 0.5], "end": [0.5, 0.75]},
|
| 388 |
-
"angle": 180,
|
| 389 |
-
"layer_select": "top",
|
| 390 |
-
},
|
| 391 |
-
{
|
| 392 |
-
"description": "Squash fold front-right flap",
|
| 393 |
-
"type": "squash",
|
| 394 |
-
"line": {"start": [0.75, 0.5], "end": [0.5, 0.75]},
|
| 395 |
-
"angle": 180,
|
| 396 |
-
"layer_select": "top",
|
| 397 |
-
},
|
| 398 |
-
{
|
| 399 |
-
"description": "Squash fold back-left flap",
|
| 400 |
-
"type": "squash",
|
| 401 |
-
"line": {"start": [0.25, 0.5], "end": [0.5, 0.75]},
|
| 402 |
-
"angle": 180,
|
| 403 |
-
"layer_select": "top",
|
| 404 |
-
},
|
| 405 |
-
{
|
| 406 |
-
"description": "Squash fold back-right flap",
|
| 407 |
-
"type": "squash",
|
| 408 |
-
"line": {"start": [0.75, 0.5], "end": [0.5, 0.75]},
|
| 409 |
-
"angle": 180,
|
| 410 |
-
"layer_select": "top",
|
| 411 |
-
},
|
| 412 |
-
{
|
| 413 |
-
"description": "Petal fold first diamond",
|
| 414 |
-
"type": "petal",
|
| 415 |
-
"line": {"start": [0.5, 0.5], "end": [0.5, 1.0]},
|
| 416 |
-
"angle": 180,
|
| 417 |
-
"layer_select": "top",
|
| 418 |
-
},
|
| 419 |
-
{
|
| 420 |
-
"description": "Petal fold second diamond",
|
| 421 |
-
"type": "petal",
|
| 422 |
-
"line": {"start": [0.5, 0.5], "end": [0.5, 1.0]},
|
| 423 |
-
"angle": 180,
|
| 424 |
-
"layer_select": "top",
|
| 425 |
-
},
|
| 426 |
-
{
|
| 427 |
-
"description": "Petal fold third diamond",
|
| 428 |
-
"type": "petal",
|
| 429 |
-
"line": {"start": [0.5, 0.5], "end": [0.5, 1.0]},
|
| 430 |
-
"angle": 180,
|
| 431 |
-
"layer_select": "top",
|
| 432 |
-
},
|
| 433 |
-
{
|
| 434 |
-
"description": "Petal fold fourth diamond",
|
| 435 |
-
"type": "petal",
|
| 436 |
-
"line": {"start": [0.5, 0.5], "end": [0.5, 1.0]},
|
| 437 |
-
"angle": 180,
|
| 438 |
-
"layer_select": "top",
|
| 439 |
-
},
|
| 440 |
-
],
|
| 441 |
-
},
|
| 442 |
-
|
| 443 |
-
"fish_base": {
|
| 444 |
-
"name": "Fish Base",
|
| 445 |
-
"description": "Diamond shape with 4 points. Built from kite folds + rabbit ears.",
|
| 446 |
-
"total_steps": 7,
|
| 447 |
-
"steps": [
|
| 448 |
-
{
|
| 449 |
-
"description": "Fold diagonal crease (reference line)",
|
| 450 |
-
"type": "valley",
|
| 451 |
-
"line": {"start": [0.0, 0.0], "end": [1.0, 1.0]},
|
| 452 |
-
"angle": 180,
|
| 453 |
-
"layer_select": "all",
|
| 454 |
-
},
|
| 455 |
-
{
|
| 456 |
-
"description": "Unfold diagonal",
|
| 457 |
-
"type": "unfold",
|
| 458 |
-
"line": {"start": [0.0, 0.0], "end": [1.0, 1.0]},
|
| 459 |
-
"angle": 0,
|
| 460 |
-
"layer_select": "all",
|
| 461 |
-
},
|
| 462 |
-
{
|
| 463 |
-
"description": "Kite fold: fold bottom-left edge to diagonal",
|
| 464 |
-
"type": "valley",
|
| 465 |
-
"line": {"start": [0.0, 0.0], "end": [0.5, 0.5]},
|
| 466 |
-
"angle": 180,
|
| 467 |
-
"layer_select": "all",
|
| 468 |
-
},
|
| 469 |
-
{
|
| 470 |
-
"description": "Kite fold: fold top-left edge to diagonal",
|
| 471 |
-
"type": "valley",
|
| 472 |
-
"line": {"start": [0.0, 1.0], "end": [0.5, 0.5]},
|
| 473 |
-
"angle": 180,
|
| 474 |
-
"layer_select": "all",
|
| 475 |
-
},
|
| 476 |
-
{
|
| 477 |
-
"description": "Rabbit ear fold on bottom-right corner",
|
| 478 |
-
"type": "rabbit_ear",
|
| 479 |
-
"line": {"start": [0.5, 0.0], "end": [1.0, 0.5]},
|
| 480 |
-
"angle": 180,
|
| 481 |
-
"layer_select": "all",
|
| 482 |
-
},
|
| 483 |
-
{
|
| 484 |
-
"description": "Rabbit ear fold on top-right corner",
|
| 485 |
-
"type": "rabbit_ear",
|
| 486 |
-
"line": {"start": [0.5, 1.0], "end": [1.0, 0.5]},
|
| 487 |
-
"angle": 180,
|
| 488 |
-
"layer_select": "all",
|
| 489 |
-
},
|
| 490 |
-
{
|
| 491 |
-
"description": "Fold resulting flaps down flat",
|
| 492 |
-
"type": "valley",
|
| 493 |
-
"line": {"start": [0.5, 0.5], "end": [1.0, 0.5]},
|
| 494 |
-
"angle": 180,
|
| 495 |
-
"layer_select": "top",
|
| 496 |
-
},
|
| 497 |
-
],
|
| 498 |
-
},
|
| 499 |
-
|
| 500 |
-
"kite_base": {
|
| 501 |
-
"name": "Kite Base",
|
| 502 |
-
"description": "Simplest base: a kite shape from two folds to a diagonal.",
|
| 503 |
-
"total_steps": 3,
|
| 504 |
-
"steps": [
|
| 505 |
-
{
|
| 506 |
-
"description": "Fold diagonal (reference crease)",
|
| 507 |
-
"type": "valley",
|
| 508 |
-
"line": {"start": [0.0, 0.0], "end": [1.0, 1.0]},
|
| 509 |
-
"angle": 180,
|
| 510 |
-
"layer_select": "all",
|
| 511 |
-
},
|
| 512 |
-
{
|
| 513 |
-
"description": "Unfold diagonal",
|
| 514 |
-
"type": "unfold",
|
| 515 |
-
"line": {"start": [0.0, 0.0], "end": [1.0, 1.0]},
|
| 516 |
-
"angle": 0,
|
| 517 |
-
"layer_select": "all",
|
| 518 |
-
},
|
| 519 |
-
{
|
| 520 |
-
"description": "Fold bottom-left and top-left edges to lie on diagonal",
|
| 521 |
-
"type": "valley",
|
| 522 |
-
"line": {"start": [0.0, 0.0], "end": [0.5, 0.5]},
|
| 523 |
-
"angle": 180,
|
| 524 |
-
"layer_select": "all",
|
| 525 |
-
},
|
| 526 |
-
],
|
| 527 |
-
},
|
| 528 |
-
}
|
| 529 |
-
|
| 530 |
-
|
| 531 |
-
# ---------------------------------------------------------------------------
|
| 532 |
-
# Complete origami models — full fold sequences from flat square to finished
|
| 533 |
-
# ---------------------------------------------------------------------------
|
| 534 |
-
|
| 535 |
-
ORIGAMI_MODELS = {
|
| 536 |
-
"crane": {
|
| 537 |
-
"name": "Paper Crane (Tsuru)",
|
| 538 |
-
"difficulty": "intermediate",
|
| 539 |
-
"base": "bird_base",
|
| 540 |
-
"total_steps": 31,
|
| 541 |
-
"description": "The traditional Japanese crane. 31 steps from flat square.",
|
| 542 |
-
"steps": [
|
| 543 |
-
# Phase 1: Pre-crease (steps 1-8)
|
| 544 |
-
{"step": 1, "description": "Fold square in half diagonally (bottom-left to top-right)", "type": "valley", "line": {"start": [0.0, 0.0], "end": [1.0, 1.0]}, "angle": 180, "layer_select": "all"},
|
| 545 |
-
{"step": 2, "description": "Unfold", "type": "unfold", "line": {"start": [0.0, 0.0], "end": [1.0, 1.0]}, "angle": 0, "layer_select": "all"},
|
| 546 |
-
{"step": 3, "description": "Fold in half on other diagonal", "type": "valley", "line": {"start": [1.0, 0.0], "end": [0.0, 1.0]}, "angle": 180, "layer_select": "all"},
|
| 547 |
-
{"step": 4, "description": "Unfold", "type": "unfold", "line": {"start": [1.0, 0.0], "end": [0.0, 1.0]}, "angle": 0, "layer_select": "all"},
|
| 548 |
-
{"step": 5, "description": "Fold in half horizontally", "type": "valley", "line": {"start": [0.0, 0.5], "end": [1.0, 0.5]}, "angle": 180, "layer_select": "all"},
|
| 549 |
-
{"step": 6, "description": "Unfold", "type": "unfold", "line": {"start": [0.0, 0.5], "end": [1.0, 0.5]}, "angle": 0, "layer_select": "all"},
|
| 550 |
-
{"step": 7, "description": "Fold in half vertically", "type": "valley", "line": {"start": [0.5, 0.0], "end": [0.5, 1.0]}, "angle": 180, "layer_select": "all"},
|
| 551 |
-
{"step": 8, "description": "Unfold", "type": "unfold", "line": {"start": [0.5, 0.0], "end": [0.5, 1.0]}, "angle": 0, "layer_select": "all"},
|
| 552 |
-
|
| 553 |
-
# Phase 2: Collapse into preliminary base (step 9)
|
| 554 |
-
{"step": 9, "description": "Collapse into preliminary base: push left and right edges inward, fold top down", "type": "valley", "line": {"start": [0.0, 0.0], "end": [1.0, 1.0]}, "angle": 180, "layer_select": "all"},
|
| 555 |
-
|
| 556 |
-
# Phase 3: Front kite folds (steps 10-14)
|
| 557 |
-
{"step": 10, "description": "Fold left edge of top layer to center line", "type": "valley", "line": {"start": [0.25, 0.5], "end": [0.5, 1.0]}, "angle": 180, "layer_select": "top"},
|
| 558 |
-
{"step": 11, "description": "Fold right edge of top layer to center line", "type": "valley", "line": {"start": [0.75, 0.5], "end": [0.5, 1.0]}, "angle": 180, "layer_select": "top"},
|
| 559 |
-
{"step": 12, "description": "Fold top triangle down over kite flaps", "type": "valley", "line": {"start": [0.25, 0.75], "end": [0.75, 0.75]}, "angle": 180, "layer_select": "top"},
|
| 560 |
-
{"step": 13, "description": "Unfold step 12", "type": "unfold", "line": {"start": [0.25, 0.75], "end": [0.75, 0.75]}, "angle": 0, "layer_select": "top"},
|
| 561 |
-
{"step": 14, "description": "Unfold steps 10-11", "type": "unfold", "line": {"start": [0.25, 0.5], "end": [0.5, 1.0]}, "angle": 0, "layer_select": "top"},
|
| 562 |
-
|
| 563 |
-
# Phase 4: Front petal fold (step 15)
|
| 564 |
-
{"step": 15, "description": "Petal fold: lift bottom point of top layer upward, sides collapse inward", "type": "petal", "line": {"start": [0.5, 0.5], "end": [0.5, 1.0]}, "angle": 180, "layer_select": "top"},
|
| 565 |
-
|
| 566 |
-
# Phase 5: Repeat on back (steps 16-22)
|
| 567 |
-
{"step": 16, "description": "Turn model over", "type": "turn_over", "line": {"start": [0.5, 0.0], "end": [0.5, 1.0]}, "angle": 0, "layer_select": "all"},
|
| 568 |
-
{"step": 17, "description": "Fold left edge to center line", "type": "valley", "line": {"start": [0.25, 0.5], "end": [0.5, 1.0]}, "angle": 180, "layer_select": "top"},
|
| 569 |
-
{"step": 18, "description": "Fold right edge to center line", "type": "valley", "line": {"start": [0.75, 0.5], "end": [0.5, 1.0]}, "angle": 180, "layer_select": "top"},
|
| 570 |
-
{"step": 19, "description": "Fold top triangle down", "type": "valley", "line": {"start": [0.25, 0.75], "end": [0.75, 0.75]}, "angle": 180, "layer_select": "top"},
|
| 571 |
-
{"step": 20, "description": "Unfold step 19", "type": "unfold", "line": {"start": [0.25, 0.75], "end": [0.75, 0.75]}, "angle": 0, "layer_select": "top"},
|
| 572 |
-
{"step": 21, "description": "Unfold steps 17-18", "type": "unfold", "line": {"start": [0.25, 0.5], "end": [0.5, 1.0]}, "angle": 0, "layer_select": "top"},
|
| 573 |
-
{"step": 22, "description": "Petal fold back: lift bottom point up, collapse sides in. Bird base complete.", "type": "petal", "line": {"start": [0.5, 0.5], "end": [0.5, 1.0]}, "angle": 180, "layer_select": "top"},
|
| 574 |
-
|
| 575 |
-
# Phase 6: Narrow the legs (steps 23-27)
|
| 576 |
-
{"step": 23, "description": "Fold left flap (front) edge to center", "type": "valley", "line": {"start": [0.375, 0.5], "end": [0.5, 1.0]}, "angle": 180, "layer_select": "top"},
|
| 577 |
-
{"step": 24, "description": "Fold right flap (front) edge to center", "type": "valley", "line": {"start": [0.625, 0.5], "end": [0.5, 1.0]}, "angle": 180, "layer_select": "top"},
|
| 578 |
-
{"step": 25, "description": "Turn over", "type": "turn_over", "line": {"start": [0.5, 0.0], "end": [0.5, 1.0]}, "angle": 0, "layer_select": "all"},
|
| 579 |
-
{"step": 26, "description": "Fold left flap (back) edge to center", "type": "valley", "line": {"start": [0.375, 0.5], "end": [0.5, 1.0]}, "angle": 180, "layer_select": "top"},
|
| 580 |
-
{"step": 27, "description": "Fold right flap (back) edge to center", "type": "valley", "line": {"start": [0.625, 0.5], "end": [0.5, 1.0]}, "angle": 180, "layer_select": "top"},
|
| 581 |
-
|
| 582 |
-
# Phase 7: Form neck and tail (steps 28-29)
|
| 583 |
-
{"step": 28, "description": "Inside reverse fold left flap upward to form neck", "type": "reverse_inside", "line": {"start": [0.35, 0.6], "end": [0.45, 0.85]}, "angle": 150, "layer_select": "all"},
|
| 584 |
-
{"step": 29, "description": "Inside reverse fold right flap upward to form tail", "type": "reverse_inside", "line": {"start": [0.55, 0.6], "end": [0.65, 0.85]}, "angle": 150, "layer_select": "all"},
|
| 585 |
-
|
| 586 |
-
# Phase 8: Head and finish (steps 30-31)
|
| 587 |
-
{"step": 30, "description": "Inside reverse fold tip of neck downward to form head/beak", "type": "reverse_inside", "line": {"start": [0.38, 0.82], "end": [0.42, 0.9]}, "angle": 150, "layer_select": "all"},
|
| 588 |
-
{"step": 31, "description": "Pull wings apart gently and press bottom to inflate body", "type": "inflate", "line": {"start": [0.5, 0.5], "end": [0.5, 0.7]}, "angle": 0, "layer_select": "all"},
|
| 589 |
-
],
|
| 590 |
-
},
|
| 591 |
-
|
| 592 |
-
"boat": {
|
| 593 |
-
"name": "Simple Boat",
|
| 594 |
-
"difficulty": "simple",
|
| 595 |
-
"base": None,
|
| 596 |
-
"total_steps": 9,
|
| 597 |
-
"description": "A flat boat/hat from simple valley and mountain folds.",
|
| 598 |
-
"steps": [
|
| 599 |
-
{"step": 1, "description": "Fold in half horizontally (top to bottom)", "type": "valley", "line": {"start": [0.0, 0.5], "end": [1.0, 0.5]}, "angle": 180, "layer_select": "all"},
|
| 600 |
-
{"step": 2, "description": "Fold in half vertically (crease only)", "type": "valley", "line": {"start": [0.5, 0.0], "end": [0.5, 0.5]}, "angle": 180, "layer_select": "all"},
|
| 601 |
-
{"step": 3, "description": "Unfold vertical", "type": "unfold", "line": {"start": [0.5, 0.0], "end": [0.5, 0.5]}, "angle": 0, "layer_select": "all"},
|
| 602 |
-
{"step": 4, "description": "Fold top-left corner down to center mark", "type": "valley", "line": {"start": [0.15, 0.5], "end": [0.5, 0.35]}, "angle": 180, "layer_select": "top"},
|
| 603 |
-
{"step": 5, "description": "Fold top-right corner down to center mark", "type": "valley", "line": {"start": [0.85, 0.5], "end": [0.5, 0.35]}, "angle": 180, "layer_select": "top"},
|
| 604 |
-
{"step": 6, "description": "Fold bottom strip up (front layer)", "type": "valley", "line": {"start": [0.0, 0.15], "end": [1.0, 0.15]}, "angle": 180, "layer_select": "top"},
|
| 605 |
-
{"step": 7, "description": "Turn over", "type": "turn_over", "line": {"start": [0.5, 0.0], "end": [0.5, 0.5]}, "angle": 0, "layer_select": "all"},
|
| 606 |
-
{"step": 8, "description": "Fold bottom strip up (back layer)", "type": "valley", "line": {"start": [0.0, 0.15], "end": [1.0, 0.15]}, "angle": 180, "layer_select": "top"},
|
| 607 |
-
{"step": 9, "description": "Open from bottom and flatten into boat shape", "type": "inflate", "line": {"start": [0.5, 0.0], "end": [0.5, 0.5]}, "angle": 0, "layer_select": "all"},
|
| 608 |
-
],
|
| 609 |
-
},
|
| 610 |
-
|
| 611 |
-
"airplane": {
|
| 612 |
-
"name": "Paper Airplane (Dart)",
|
| 613 |
-
"difficulty": "simple",
|
| 614 |
-
"base": None,
|
| 615 |
-
"total_steps": 6,
|
| 616 |
-
"description": "Classic dart-style paper airplane using only valley folds.",
|
| 617 |
-
"steps": [
|
| 618 |
-
{"step": 1, "description": "Fold in half vertically (left to right)", "type": "valley", "line": {"start": [0.5, 0.0], "end": [0.5, 1.0]}, "angle": 180, "layer_select": "all"},
|
| 619 |
-
{"step": 2, "description": "Unfold", "type": "unfold", "line": {"start": [0.5, 0.0], "end": [0.5, 1.0]}, "angle": 0, "layer_select": "all"},
|
| 620 |
-
{"step": 3, "description": "Fold top-left corner to center line", "type": "valley", "line": {"start": [0.0, 1.0], "end": [0.5, 0.7]}, "angle": 180, "layer_select": "all"},
|
| 621 |
-
{"step": 4, "description": "Fold top-right corner to center line", "type": "valley", "line": {"start": [1.0, 1.0], "end": [0.5, 0.7]}, "angle": 180, "layer_select": "all"},
|
| 622 |
-
{"step": 5, "description": "Fold left angled edge to center line", "type": "valley", "line": {"start": [0.0, 0.7], "end": [0.5, 0.4]}, "angle": 180, "layer_select": "all"},
|
| 623 |
-
{"step": 6, "description": "Fold right angled edge to center line", "type": "valley", "line": {"start": [1.0, 0.7], "end": [0.5, 0.4]}, "angle": 180, "layer_select": "all"},
|
| 624 |
-
],
|
| 625 |
-
},
|
| 626 |
-
|
| 627 |
-
"box": {
|
| 628 |
-
"name": "Masu Box (Open-Top Box)",
|
| 629 |
-
"difficulty": "low_intermediate",
|
| 630 |
-
"base": None,
|
| 631 |
-
"total_steps": 13,
|
| 632 |
-
"description": "An open-top box. Uses preliminary base concept with tuck folds.",
|
| 633 |
-
"steps": [
|
| 634 |
-
{"step": 1, "description": "Fold in half horizontally", "type": "valley", "line": {"start": [0.0, 0.5], "end": [1.0, 0.5]}, "angle": 180, "layer_select": "all"},
|
| 635 |
-
{"step": 2, "description": "Unfold", "type": "unfold", "line": {"start": [0.0, 0.5], "end": [1.0, 0.5]}, "angle": 0, "layer_select": "all"},
|
| 636 |
-
{"step": 3, "description": "Fold in half vertically", "type": "valley", "line": {"start": [0.5, 0.0], "end": [0.5, 1.0]}, "angle": 180, "layer_select": "all"},
|
| 637 |
-
{"step": 4, "description": "Unfold", "type": "unfold", "line": {"start": [0.5, 0.0], "end": [0.5, 1.0]}, "angle": 0, "layer_select": "all"},
|
| 638 |
-
{"step": 5, "description": "Fold all four corners to center", "type": "valley", "line": {"start": [0.0, 0.0], "end": [0.5, 0.5]}, "angle": 180, "layer_select": "all"},
|
| 639 |
-
{"step": 6, "description": "Fold bottom-right corner to center", "type": "valley", "line": {"start": [1.0, 0.0], "end": [0.5, 0.5]}, "angle": 180, "layer_select": "all"},
|
| 640 |
-
{"step": 7, "description": "Fold top-left corner to center", "type": "valley", "line": {"start": [0.0, 1.0], "end": [0.5, 0.5]}, "angle": 180, "layer_select": "all"},
|
| 641 |
-
{"step": 8, "description": "Fold top-right corner to center", "type": "valley", "line": {"start": [1.0, 1.0], "end": [0.5, 0.5]}, "angle": 180, "layer_select": "all"},
|
| 642 |
-
{"step": 9, "description": "Fold top third down to center", "type": "valley", "line": {"start": [0.0, 0.667], "end": [1.0, 0.667]}, "angle": 180, "layer_select": "all"},
|
| 643 |
-
{"step": 10, "description": "Fold bottom third up to center", "type": "valley", "line": {"start": [0.0, 0.333], "end": [1.0, 0.333]}, "angle": 180, "layer_select": "all"},
|
| 644 |
-
{"step": 11, "description": "Unfold top and bottom, and unfold left/right corners", "type": "unfold", "line": {"start": [0.0, 0.333], "end": [1.0, 0.333]}, "angle": 0, "layer_select": "all"},
|
| 645 |
-
{"step": 12, "description": "Raise left and right walls using existing creases, tuck corners in", "type": "valley", "line": {"start": [0.25, 0.0], "end": [0.25, 1.0]}, "angle": 90, "layer_select": "all"},
|
| 646 |
-
{"step": 13, "description": "Fold flaps over into box and lock walls in place", "type": "valley", "line": {"start": [0.0, 0.5], "end": [1.0, 0.5]}, "angle": 90, "layer_select": "top"},
|
| 647 |
-
],
|
| 648 |
-
},
|
| 649 |
-
|
| 650 |
-
"fortune_teller": {
|
| 651 |
-
"name": "Fortune Teller (Cootie Catcher)",
|
| 652 |
-
"difficulty": "simple",
|
| 653 |
-
"base": None,
|
| 654 |
-
"total_steps": 8,
|
| 655 |
-
"description": "Classic fortune teller: fold corners to center, flip, repeat.",
|
| 656 |
-
"steps": [
|
| 657 |
-
{"step": 1, "description": "Fold in half diagonally (crease only)", "type": "valley", "line": {"start": [0.0, 0.0], "end": [1.0, 1.0]}, "angle": 180, "layer_select": "all"},
|
| 658 |
-
{"step": 2, "description": "Unfold", "type": "unfold", "line": {"start": [0.0, 0.0], "end": [1.0, 1.0]}, "angle": 0, "layer_select": "all"},
|
| 659 |
-
{"step": 3, "description": "Fold bottom-left corner to center", "type": "valley", "line": {"start": [0.0, 0.0], "end": [0.5, 0.5]}, "angle": 180, "layer_select": "all"},
|
| 660 |
-
{"step": 4, "description": "Fold bottom-right corner to center", "type": "valley", "line": {"start": [1.0, 0.0], "end": [0.5, 0.5]}, "angle": 180, "layer_select": "all"},
|
| 661 |
-
{"step": 5, "description": "Fold top-left corner to center", "type": "valley", "line": {"start": [0.0, 1.0], "end": [0.5, 0.5]}, "angle": 180, "layer_select": "all"},
|
| 662 |
-
{"step": 6, "description": "Fold top-right corner to center", "type": "valley", "line": {"start": [1.0, 1.0], "end": [0.5, 0.5]}, "angle": 180, "layer_select": "all"},
|
| 663 |
-
{"step": 7, "description": "Turn over", "type": "turn_over", "line": {"start": [0.5, 0.0], "end": [0.5, 1.0]}, "angle": 0, "layer_select": "all"},
|
| 664 |
-
{"step": 8, "description": "Fold all four new corners to center again", "type": "valley", "line": {"start": [0.25, 0.25], "end": [0.5, 0.5]}, "angle": 180, "layer_select": "all"},
|
| 665 |
-
],
|
| 666 |
-
},
|
| 667 |
-
|
| 668 |
-
"waterbomb": {
|
| 669 |
-
"name": "Waterbomb (Paper Balloon)",
|
| 670 |
-
"difficulty": "simple",
|
| 671 |
-
"base": "waterbomb_base",
|
| 672 |
-
"total_steps": 12,
|
| 673 |
-
"description": "Inflatable paper balloon built on the waterbomb base.",
|
| 674 |
-
"steps": [
|
| 675 |
-
# Phase 1: Waterbomb base (steps 1-9 same as waterbomb_base)
|
| 676 |
-
{"step": 1, "description": "Fold first diagonal", "type": "valley", "line": {"start": [0.0, 0.0], "end": [1.0, 1.0]}, "angle": 180, "layer_select": "all"},
|
| 677 |
-
{"step": 2, "description": "Unfold", "type": "unfold", "line": {"start": [0.0, 0.0], "end": [1.0, 1.0]}, "angle": 0, "layer_select": "all"},
|
| 678 |
-
{"step": 3, "description": "Fold second diagonal", "type": "valley", "line": {"start": [1.0, 0.0], "end": [0.0, 1.0]}, "angle": 180, "layer_select": "all"},
|
| 679 |
-
{"step": 4, "description": "Unfold", "type": "unfold", "line": {"start": [1.0, 0.0], "end": [0.0, 1.0]}, "angle": 0, "layer_select": "all"},
|
| 680 |
-
{"step": 5, "description": "Fold in half horizontally", "type": "valley", "line": {"start": [0.0, 0.5], "end": [1.0, 0.5]}, "angle": 180, "layer_select": "all"},
|
| 681 |
-
{"step": 6, "description": "Collapse into waterbomb base (triangle)", "type": "valley", "line": {"start": [0.0, 0.0], "end": [1.0, 1.0]}, "angle": 180, "layer_select": "all"},
|
| 682 |
-
# Phase 2: Fold flaps to top
|
| 683 |
-
{"step": 7, "description": "Fold bottom-left corner of front layer up to top", "type": "valley", "line": {"start": [0.0, 0.0], "end": [0.5, 0.5]}, "angle": 180, "layer_select": "top"},
|
| 684 |
-
{"step": 8, "description": "Fold bottom-right corner of front layer up to top", "type": "valley", "line": {"start": [1.0, 0.0], "end": [0.5, 0.5]}, "angle": 180, "layer_select": "top"},
|
| 685 |
-
# Phase 3: Tuck flaps
|
| 686 |
-
{"step": 9, "description": "Fold left and right points to center", "type": "valley", "line": {"start": [0.25, 0.25], "end": [0.5, 0.5]}, "angle": 180, "layer_select": "top"},
|
| 687 |
-
{"step": 10, "description": "Tuck small triangles into pockets", "type": "valley", "line": {"start": [0.35, 0.4], "end": [0.5, 0.5]}, "angle": 180, "layer_select": "top"},
|
| 688 |
-
{"step": 11, "description": "Repeat steps 7-10 on back", "type": "valley", "line": {"start": [0.0, 0.0], "end": [0.5, 0.5]}, "angle": 180, "layer_select": "top"},
|
| 689 |
-
# Phase 4: Inflate
|
| 690 |
-
{"step": 12, "description": "Blow into hole at bottom to inflate into cube/sphere", "type": "inflate", "line": {"start": [0.5, 0.0], "end": [0.5, 0.5]}, "angle": 0, "layer_select": "all"},
|
| 691 |
-
],
|
| 692 |
-
},
|
| 693 |
-
|
| 694 |
-
"jumping_frog": {
|
| 695 |
-
"name": "Jumping Frog",
|
| 696 |
-
"difficulty": "low_intermediate",
|
| 697 |
-
"base": None,
|
| 698 |
-
"total_steps": 15,
|
| 699 |
-
"description": "A frog that jumps when you press its back. Uses pleats for the spring.",
|
| 700 |
-
"steps": [
|
| 701 |
-
{"step": 1, "description": "Fold in half horizontally", "type": "valley", "line": {"start": [0.0, 0.5], "end": [1.0, 0.5]}, "angle": 180, "layer_select": "all"},
|
| 702 |
-
{"step": 2, "description": "Fold top-left corner to right edge", "type": "valley", "line": {"start": [0.0, 1.0], "end": [1.0, 0.75]}, "angle": 180, "layer_select": "all"},
|
| 703 |
-
{"step": 3, "description": "Unfold", "type": "unfold", "line": {"start": [0.0, 1.0], "end": [1.0, 0.75]}, "angle": 0, "layer_select": "all"},
|
| 704 |
-
{"step": 4, "description": "Fold top-right corner to left edge", "type": "valley", "line": {"start": [1.0, 1.0], "end": [0.0, 0.75]}, "angle": 180, "layer_select": "all"},
|
| 705 |
-
{"step": 5, "description": "Unfold", "type": "unfold", "line": {"start": [1.0, 1.0], "end": [0.0, 0.75]}, "angle": 0, "layer_select": "all"},
|
| 706 |
-
{"step": 6, "description": "Collapse top into waterbomb-like triangle", "type": "valley", "line": {"start": [0.0, 0.75], "end": [1.0, 0.75]}, "angle": 180, "layer_select": "all"},
|
| 707 |
-
{"step": 7, "description": "Fold left point of triangle up and outward for front leg", "type": "valley", "line": {"start": [0.25, 0.75], "end": [0.5, 1.0]}, "angle": 180, "layer_select": "top"},
|
| 708 |
-
{"step": 8, "description": "Fold right point of triangle up and outward for front leg", "type": "valley", "line": {"start": [0.75, 0.75], "end": [0.5, 1.0]}, "angle": 180, "layer_select": "top"},
|
| 709 |
-
{"step": 9, "description": "Fold bottom half up to meet triangle base", "type": "valley", "line": {"start": [0.0, 0.375], "end": [1.0, 0.375]}, "angle": 180, "layer_select": "all"},
|
| 710 |
-
{"step": 10, "description": "Fold left side to center", "type": "valley", "line": {"start": [0.25, 0.0], "end": [0.25, 0.75]}, "angle": 180, "layer_select": "all"},
|
| 711 |
-
{"step": 11, "description": "Fold right side to center", "type": "valley", "line": {"start": [0.75, 0.0], "end": [0.75, 0.75]}, "angle": 180, "layer_select": "all"},
|
| 712 |
-
{"step": 12, "description": "Fold bottom up", "type": "valley", "line": {"start": [0.25, 0.25], "end": [0.75, 0.25]}, "angle": 180, "layer_select": "all"},
|
| 713 |
-
{"step": 13, "description": "Pull back legs out to sides", "type": "valley", "line": {"start": [0.375, 0.0], "end": [0.375, 0.375]}, "angle": 180, "layer_select": "top"},
|
| 714 |
-
{"step": 14, "description": "Pleat fold for spring: fold bottom half down", "type": "mountain", "line": {"start": [0.25, 0.15], "end": [0.75, 0.15]}, "angle": 180, "layer_select": "all"},
|
| 715 |
-
{"step": 15, "description": "Valley fold bottom portion back up for spring action", "type": "valley", "line": {"start": [0.25, 0.08], "end": [0.75, 0.08]}, "angle": 180, "layer_select": "all"},
|
| 716 |
-
],
|
| 717 |
-
},
|
| 718 |
-
}
|
| 719 |
-
|
| 720 |
-
|
| 721 |
-
# ---------------------------------------------------------------------------
|
| 722 |
-
# Helpers
|
| 723 |
-
# ---------------------------------------------------------------------------
|
| 724 |
-
|
| 725 |
-
def get_base_steps(base_name: str) -> list[dict]:
|
| 726 |
-
"""Return the fold steps for a given base, or empty list if not found."""
|
| 727 |
-
base = ORIGAMI_BASES.get(base_name)
|
| 728 |
-
if base is None:
|
| 729 |
-
return []
|
| 730 |
-
return list(base["steps"])
|
| 731 |
-
|
| 732 |
-
|
| 733 |
-
def get_model_steps(model_name: str) -> list[dict]:
|
| 734 |
-
"""Return the full fold steps for a known model, or empty list."""
|
| 735 |
-
model = ORIGAMI_MODELS.get(model_name)
|
| 736 |
-
if model is None:
|
| 737 |
-
return []
|
| 738 |
-
return list(model["steps"])
|
| 739 |
-
|
| 740 |
-
|
| 741 |
-
def list_known_models() -> list[str]:
|
| 742 |
-
"""Return names of all known origami models."""
|
| 743 |
-
return list(ORIGAMI_MODELS.keys())
|
| 744 |
-
|
| 745 |
-
|
| 746 |
-
def list_known_bases() -> list[str]:
|
| 747 |
-
"""Return names of all known origami bases."""
|
| 748 |
-
return list(ORIGAMI_BASES.keys())
|
| 749 |
-
|
| 750 |
-
|
| 751 |
-
def get_fold_operation(name: str) -> dict | None:
|
| 752 |
-
"""Look up a fold operation by name."""
|
| 753 |
-
return FOLD_OPERATIONS.get(name)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
planner/parser.py
DELETED
|
@@ -1,291 +0,0 @@
|
|
| 1 |
-
"""
|
| 2 |
-
Instruction parser: converts human text into a structured origami task.
|
| 3 |
-
|
| 4 |
-
Handles variations like:
|
| 5 |
-
- "make a paper crane"
|
| 6 |
-
- "fold me a crane"
|
| 7 |
-
- "i want to make a paper crane"
|
| 8 |
-
- "crane please"
|
| 9 |
-
- "can you fold a crane?"
|
| 10 |
-
- "pack a 1m x 1m mylar sheet as compact as possible"
|
| 11 |
-
"""
|
| 12 |
-
|
| 13 |
-
from __future__ import annotations
|
| 14 |
-
|
| 15 |
-
import re
|
| 16 |
-
from planner.knowledge import ORIGAMI_MODELS, list_known_models
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
# ---------------------------------------------------------------------------
|
| 20 |
-
# Vocabulary for matching
|
| 21 |
-
# ---------------------------------------------------------------------------
|
| 22 |
-
|
| 23 |
-
# Model aliases → canonical name
|
| 24 |
-
_MODEL_ALIASES: dict[str, str] = {
|
| 25 |
-
# Crane
|
| 26 |
-
"crane": "crane",
|
| 27 |
-
"tsuru": "crane",
|
| 28 |
-
"bird": "crane",
|
| 29 |
-
"orizuru": "crane",
|
| 30 |
-
# Boat
|
| 31 |
-
"boat": "boat",
|
| 32 |
-
"hat": "boat",
|
| 33 |
-
"paper boat": "boat",
|
| 34 |
-
"paper hat": "boat",
|
| 35 |
-
# Airplane
|
| 36 |
-
"airplane": "airplane",
|
| 37 |
-
"aeroplane": "airplane",
|
| 38 |
-
"plane": "airplane",
|
| 39 |
-
"paper airplane": "airplane",
|
| 40 |
-
"paper plane": "airplane",
|
| 41 |
-
"dart": "airplane",
|
| 42 |
-
"paper dart": "airplane",
|
| 43 |
-
# Box
|
| 44 |
-
"box": "box",
|
| 45 |
-
"masu": "box",
|
| 46 |
-
"masu box": "box",
|
| 47 |
-
"open box": "box",
|
| 48 |
-
# Fortune teller
|
| 49 |
-
"fortune teller": "fortune_teller",
|
| 50 |
-
"fortune-teller": "fortune_teller",
|
| 51 |
-
"cootie catcher": "fortune_teller",
|
| 52 |
-
"cootie-catcher": "fortune_teller",
|
| 53 |
-
"chatterbox": "fortune_teller",
|
| 54 |
-
# Waterbomb / balloon
|
| 55 |
-
"waterbomb": "waterbomb",
|
| 56 |
-
"water bomb": "waterbomb",
|
| 57 |
-
"balloon": "waterbomb",
|
| 58 |
-
"paper balloon": "waterbomb",
|
| 59 |
-
# Jumping frog
|
| 60 |
-
"jumping frog": "jumping_frog",
|
| 61 |
-
"frog": "jumping_frog",
|
| 62 |
-
"leap frog": "jumping_frog",
|
| 63 |
-
}
|
| 64 |
-
|
| 65 |
-
# Sorted longest-first so multi-word aliases match before single-word ones
|
| 66 |
-
_ALIAS_KEYS_SORTED = sorted(_MODEL_ALIASES.keys(), key=len, reverse=True)
|
| 67 |
-
|
| 68 |
-
_MATERIALS = {
|
| 69 |
-
"paper": "paper",
|
| 70 |
-
"mylar": "mylar",
|
| 71 |
-
"aluminum": "aluminum",
|
| 72 |
-
"aluminium": "aluminum",
|
| 73 |
-
"metal": "metal",
|
| 74 |
-
"nitinol": "nitinol",
|
| 75 |
-
"foil": "aluminum",
|
| 76 |
-
"cardboard": "cardboard",
|
| 77 |
-
"cardstock": "cardstock",
|
| 78 |
-
"fabric": "fabric",
|
| 79 |
-
"cloth": "fabric",
|
| 80 |
-
}
|
| 81 |
-
|
| 82 |
-
# Intent keywords
|
| 83 |
-
_FOLD_VERBS = {
|
| 84 |
-
"make", "fold", "create", "build", "construct", "origami",
|
| 85 |
-
"craft", "form", "shape", "assemble",
|
| 86 |
-
}
|
| 87 |
-
_PACK_VERBS = {
|
| 88 |
-
"pack", "compress", "compact", "minimize", "reduce", "stow",
|
| 89 |
-
"shrink", "deploy", "collapse",
|
| 90 |
-
}
|
| 91 |
-
_OPTIMIZE_PHRASES = [
|
| 92 |
-
"as compact as possible",
|
| 93 |
-
"minimize volume",
|
| 94 |
-
"minimize packed volume",
|
| 95 |
-
"minimize area",
|
| 96 |
-
"solar panel",
|
| 97 |
-
"stent",
|
| 98 |
-
"deployable",
|
| 99 |
-
"maximize compactness",
|
| 100 |
-
"flatten",
|
| 101 |
-
]
|
| 102 |
-
|
| 103 |
-
# Dimension patterns
|
| 104 |
-
_DIM_PATTERNS = [
|
| 105 |
-
# "10cm x 10cm", "10 cm x 10 cm"
|
| 106 |
-
re.compile(
|
| 107 |
-
r"(\d+(?:\.\d+)?)\s*(cm|mm|m|in|inch|inches|ft|feet)\s*[xX\u00d7]\s*(\d+(?:\.\d+)?)\s*(cm|mm|m|in|inch|inches|ft|feet)",
|
| 108 |
-
re.IGNORECASE,
|
| 109 |
-
),
|
| 110 |
-
# "10cm square", "1 meter square"
|
| 111 |
-
re.compile(
|
| 112 |
-
r"(\d+(?:\.\d+)?)\s*(cm|mm|m|in|inch|inches|ft|feet|meter|meters|metre|metres)\s+square",
|
| 113 |
-
re.IGNORECASE,
|
| 114 |
-
),
|
| 115 |
-
]
|
| 116 |
-
|
| 117 |
-
_UNIT_TO_M = {
|
| 118 |
-
"m": 1.0,
|
| 119 |
-
"meter": 1.0,
|
| 120 |
-
"meters": 1.0,
|
| 121 |
-
"metre": 1.0,
|
| 122 |
-
"metres": 1.0,
|
| 123 |
-
"cm": 0.01,
|
| 124 |
-
"mm": 0.001,
|
| 125 |
-
"in": 0.0254,
|
| 126 |
-
"inch": 0.0254,
|
| 127 |
-
"inches": 0.0254,
|
| 128 |
-
"ft": 0.3048,
|
| 129 |
-
"feet": 0.3048,
|
| 130 |
-
}
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
# ---------------------------------------------------------------------------
|
| 134 |
-
# Parsing helpers
|
| 135 |
-
# ---------------------------------------------------------------------------
|
| 136 |
-
|
| 137 |
-
def _normalise(text: str) -> str:
|
| 138 |
-
"""Lower-case and strip extra whitespace."""
|
| 139 |
-
return " ".join(text.lower().split())
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
def _detect_model(text: str) -> str | None:
|
| 143 |
-
"""Return canonical model name if one is mentioned, else None."""
|
| 144 |
-
norm = _normalise(text)
|
| 145 |
-
|
| 146 |
-
# Strip out constraint phrases that might contain model-name false positives
|
| 147 |
-
# e.g. "into a 15cm x 15cm x 5cm box" should not match "box" as a model
|
| 148 |
-
cleaned = re.sub(
|
| 149 |
-
r"(?:fit\s+)?(?:in(?:to)?|inside)\s+(?:a\s+)?\d.*?box",
|
| 150 |
-
"",
|
| 151 |
-
norm,
|
| 152 |
-
)
|
| 153 |
-
|
| 154 |
-
for alias in _ALIAS_KEYS_SORTED:
|
| 155 |
-
# Use word-boundary-aware search to avoid partial matches
|
| 156 |
-
pattern = r"(?:^|\b)" + re.escape(alias) + r"(?:\b|$)"
|
| 157 |
-
if re.search(pattern, cleaned):
|
| 158 |
-
return _MODEL_ALIASES[alias]
|
| 159 |
-
return None
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
def _detect_material(text: str) -> str:
|
| 163 |
-
"""Return detected material or default 'paper'."""
|
| 164 |
-
norm = _normalise(text)
|
| 165 |
-
# Check multi-word materials first (none currently, but future-proof)
|
| 166 |
-
for keyword, canonical in sorted(_MATERIALS.items(), key=lambda kv: -len(kv[0])):
|
| 167 |
-
if keyword in norm:
|
| 168 |
-
return canonical
|
| 169 |
-
return "paper"
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
def _detect_dimensions(text: str) -> dict[str, float]:
|
| 173 |
-
"""Parse explicit dimensions. Returns {"width": m, "height": m} or defaults."""
|
| 174 |
-
for pat in _DIM_PATTERNS:
|
| 175 |
-
m = pat.search(text)
|
| 176 |
-
if m:
|
| 177 |
-
groups = m.groups()
|
| 178 |
-
if len(groups) == 4:
|
| 179 |
-
# WxH pattern
|
| 180 |
-
w = float(groups[0]) * _UNIT_TO_M.get(groups[1].lower(), 1.0)
|
| 181 |
-
h = float(groups[2]) * _UNIT_TO_M.get(groups[3].lower(), 1.0)
|
| 182 |
-
return {"width": round(w, 6), "height": round(h, 6)}
|
| 183 |
-
elif len(groups) == 2:
|
| 184 |
-
# "N unit square"
|
| 185 |
-
side = float(groups[0]) * _UNIT_TO_M.get(groups[1].lower(), 1.0)
|
| 186 |
-
return {"width": round(side, 6), "height": round(side, 6)}
|
| 187 |
-
# Default: unit square
|
| 188 |
-
return {"width": 1.0, "height": 1.0}
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
def _detect_constraints(text: str) -> dict:
|
| 192 |
-
"""Detect any explicit constraints mentioned in the instruction."""
|
| 193 |
-
norm = _normalise(text)
|
| 194 |
-
constraints: dict = {}
|
| 195 |
-
|
| 196 |
-
# Target bounding box: "fit in a 15cm x 15cm x 5cm box"
|
| 197 |
-
box_pat = re.compile(
|
| 198 |
-
r"fit\s+(?:in(?:to)?|inside)\s+(?:a\s+)?(\d+(?:\.\d+)?)\s*(cm|mm|m)\s*[xX\u00d7]\s*"
|
| 199 |
-
r"(\d+(?:\.\d+)?)\s*(cm|mm|m)\s*[xX\u00d7]\s*(\d+(?:\.\d+)?)\s*(cm|mm|m)",
|
| 200 |
-
re.IGNORECASE,
|
| 201 |
-
)
|
| 202 |
-
bm = box_pat.search(norm)
|
| 203 |
-
if bm:
|
| 204 |
-
g = bm.groups()
|
| 205 |
-
constraints["target_box"] = [
|
| 206 |
-
float(g[0]) * _UNIT_TO_M.get(g[1], 1.0),
|
| 207 |
-
float(g[2]) * _UNIT_TO_M.get(g[3], 1.0),
|
| 208 |
-
float(g[4]) * _UNIT_TO_M.get(g[5], 1.0),
|
| 209 |
-
]
|
| 210 |
-
|
| 211 |
-
# Max folds
|
| 212 |
-
folds_pat = re.compile(r"(?:max(?:imum)?|at most|no more than)\s+(\d+)\s+fold", re.IGNORECASE)
|
| 213 |
-
fm = folds_pat.search(norm)
|
| 214 |
-
if fm:
|
| 215 |
-
constraints["max_folds"] = int(fm.group(1))
|
| 216 |
-
|
| 217 |
-
# Compactness emphasis
|
| 218 |
-
for phrase in _OPTIMIZE_PHRASES:
|
| 219 |
-
if phrase in norm:
|
| 220 |
-
constraints["optimize_compactness"] = True
|
| 221 |
-
break
|
| 222 |
-
|
| 223 |
-
# Must deploy
|
| 224 |
-
if "deploy" in norm or "unfold" in norm and "clean" in norm:
|
| 225 |
-
constraints["must_deploy"] = True
|
| 226 |
-
|
| 227 |
-
return constraints
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
def _detect_intent(text: str, model_name: str | None, constraints: dict) -> str:
|
| 231 |
-
"""Determine the high-level intent of the instruction."""
|
| 232 |
-
norm = _normalise(text)
|
| 233 |
-
words = set(norm.split())
|
| 234 |
-
|
| 235 |
-
# If packing / optimization phrases are present, it's an optimization task
|
| 236 |
-
if constraints.get("optimize_compactness"):
|
| 237 |
-
return "optimize_packing"
|
| 238 |
-
if words & _PACK_VERBS:
|
| 239 |
-
return "optimize_packing"
|
| 240 |
-
|
| 241 |
-
# If a known model is detected, it's a fold_model task
|
| 242 |
-
if model_name is not None:
|
| 243 |
-
return "fold_model"
|
| 244 |
-
|
| 245 |
-
# If fold verbs are present but no model, it's a free fold
|
| 246 |
-
if words & _FOLD_VERBS:
|
| 247 |
-
return "free_fold"
|
| 248 |
-
|
| 249 |
-
# Fallback: if there's a pattern keyword
|
| 250 |
-
pattern_words = {"miura", "tessellation", "pattern", "waterbomb tessellation", "pleat"}
|
| 251 |
-
if words & pattern_words:
|
| 252 |
-
return "fold_pattern"
|
| 253 |
-
|
| 254 |
-
return "free_fold"
|
| 255 |
-
|
| 256 |
-
|
| 257 |
-
# ---------------------------------------------------------------------------
|
| 258 |
-
# Public API
|
| 259 |
-
# ---------------------------------------------------------------------------
|
| 260 |
-
|
| 261 |
-
def parse_instruction(text: str) -> dict:
|
| 262 |
-
"""
|
| 263 |
-
Parse a human origami instruction into a structured task.
|
| 264 |
-
|
| 265 |
-
Args:
|
| 266 |
-
text: Natural-language instruction, e.g. "make a paper crane"
|
| 267 |
-
|
| 268 |
-
Returns:
|
| 269 |
-
{
|
| 270 |
-
"intent": "fold_model" | "fold_pattern" | "optimize_packing" | "free_fold",
|
| 271 |
-
"model_name": str or None,
|
| 272 |
-
"material": str,
|
| 273 |
-
"dimensions": {"width": float, "height": float},
|
| 274 |
-
"constraints": {...},
|
| 275 |
-
"raw_instruction": str,
|
| 276 |
-
}
|
| 277 |
-
"""
|
| 278 |
-
model_name = _detect_model(text)
|
| 279 |
-
material = _detect_material(text)
|
| 280 |
-
dimensions = _detect_dimensions(text)
|
| 281 |
-
constraints = _detect_constraints(text)
|
| 282 |
-
intent = _detect_intent(text, model_name, constraints)
|
| 283 |
-
|
| 284 |
-
return {
|
| 285 |
-
"intent": intent,
|
| 286 |
-
"model_name": model_name,
|
| 287 |
-
"material": material,
|
| 288 |
-
"dimensions": dimensions,
|
| 289 |
-
"constraints": constraints,
|
| 290 |
-
"raw_instruction": text,
|
| 291 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
planner/planner.py
DELETED
|
@@ -1,305 +0,0 @@
|
|
| 1 |
-
"""
|
| 2 |
-
OrigamiPlanner: the main orchestrator that converts human instructions
|
| 3 |
-
into structured fold plans with LLM-ready prompts.
|
| 4 |
-
|
| 5 |
-
Usage:
|
| 6 |
-
from planner.planner import OrigamiPlanner
|
| 7 |
-
|
| 8 |
-
planner = OrigamiPlanner()
|
| 9 |
-
plan = planner.plan("make a paper crane")
|
| 10 |
-
print(plan.summary())
|
| 11 |
-
prompt = plan.get_prompt_for_step(0)
|
| 12 |
-
"""
|
| 13 |
-
|
| 14 |
-
from __future__ import annotations
|
| 15 |
-
|
| 16 |
-
import json
|
| 17 |
-
from dataclasses import dataclass, field
|
| 18 |
-
|
| 19 |
-
from planner.parser import parse_instruction
|
| 20 |
-
from planner.decomposer import decompose_task
|
| 21 |
-
from planner.knowledge import ORIGAMI_MODELS, FOLD_OPERATIONS
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
# ---------------------------------------------------------------------------
|
| 25 |
-
# Material defaults (mirrors trainer/prompts.py TASK_CONFIGS)
|
| 26 |
-
# ---------------------------------------------------------------------------
|
| 27 |
-
|
| 28 |
-
_MATERIAL_DEFAULTS = {
|
| 29 |
-
"paper": {"thickness_mm": 0.1, "youngs_modulus_gpa": 2.0, "max_strain_pct": 3},
|
| 30 |
-
"mylar": {"thickness_mm": 0.05, "youngs_modulus_gpa": 4.0, "max_strain_pct": 3},
|
| 31 |
-
"aluminum": {"thickness_mm": 0.02, "youngs_modulus_gpa": 69.0, "max_strain_pct": 1},
|
| 32 |
-
"metal": {"thickness_mm": 0.05, "youngs_modulus_gpa": 200.0,"max_strain_pct": 0.5},
|
| 33 |
-
"nitinol": {"thickness_mm": 0.1, "youngs_modulus_gpa": 75.0, "max_strain_pct": 8},
|
| 34 |
-
"cardboard": {"thickness_mm": 1.0, "youngs_modulus_gpa": 1.0, "max_strain_pct": 2},
|
| 35 |
-
"cardstock": {"thickness_mm": 0.3, "youngs_modulus_gpa": 1.5, "max_strain_pct": 2},
|
| 36 |
-
"fabric": {"thickness_mm": 0.2, "youngs_modulus_gpa": 0.1, "max_strain_pct": 15},
|
| 37 |
-
}
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
# ---------------------------------------------------------------------------
|
| 41 |
-
# FoldPlan dataclass
|
| 42 |
-
# ---------------------------------------------------------------------------
|
| 43 |
-
|
| 44 |
-
@dataclass
|
| 45 |
-
class FoldPlan:
|
| 46 |
-
"""
|
| 47 |
-
A complete, executable fold plan produced by OrigamiPlanner.
|
| 48 |
-
|
| 49 |
-
Attributes:
|
| 50 |
-
instruction: The original human instruction.
|
| 51 |
-
parsed: Structured parse result (intent, model, material, etc.).
|
| 52 |
-
steps: Ordered list of sub-goal dicts from the decomposer.
|
| 53 |
-
prompts: Pre-built LLM prompts, one per step.
|
| 54 |
-
"""
|
| 55 |
-
|
| 56 |
-
instruction: str
|
| 57 |
-
parsed: dict
|
| 58 |
-
steps: list[dict]
|
| 59 |
-
prompts: list[str]
|
| 60 |
-
|
| 61 |
-
# ------------------------------------------------------------------
|
| 62 |
-
# Summaries
|
| 63 |
-
# ------------------------------------------------------------------
|
| 64 |
-
|
| 65 |
-
def summary(self) -> str:
|
| 66 |
-
"""Human-readable summary of the plan."""
|
| 67 |
-
lines: list[str] = []
|
| 68 |
-
lines.append(f"Origami Plan: {self.instruction}")
|
| 69 |
-
lines.append(f" Intent : {self.parsed['intent']}")
|
| 70 |
-
if self.parsed.get("model_name"):
|
| 71 |
-
model = ORIGAMI_MODELS.get(self.parsed["model_name"], {})
|
| 72 |
-
lines.append(f" Model : {model.get('name', self.parsed['model_name'])}")
|
| 73 |
-
lines.append(f" Difficulty: {model.get('difficulty', 'unknown')}")
|
| 74 |
-
lines.append(f" Material: {self.parsed['material']}")
|
| 75 |
-
dims = self.parsed["dimensions"]
|
| 76 |
-
lines.append(f" Sheet : {dims['width']}m x {dims['height']}m")
|
| 77 |
-
lines.append(f" Steps : {len(self.steps)}")
|
| 78 |
-
lines.append("")
|
| 79 |
-
lines.append("Step-by-step:")
|
| 80 |
-
for s in self.steps:
|
| 81 |
-
n = s["step_number"]
|
| 82 |
-
desc = s["description"]
|
| 83 |
-
n_ops = len(s.get("fold_operations", []))
|
| 84 |
-
lines.append(f" {n:>3}. {desc} ({n_ops} fold op{'s' if n_ops != 1 else ''})")
|
| 85 |
-
return "\n".join(lines)
|
| 86 |
-
|
| 87 |
-
# ------------------------------------------------------------------
|
| 88 |
-
# Prompt access
|
| 89 |
-
# ------------------------------------------------------------------
|
| 90 |
-
|
| 91 |
-
def get_prompt_for_step(self, step_index: int, current_state: dict | None = None) -> str:
|
| 92 |
-
"""
|
| 93 |
-
Get the LLM prompt for a specific step, optionally enriched with
|
| 94 |
-
the current paper state from the simulation engine.
|
| 95 |
-
|
| 96 |
-
Args:
|
| 97 |
-
step_index: Zero-based index into self.steps.
|
| 98 |
-
current_state: Optional live paper_state dict from the engine.
|
| 99 |
-
|
| 100 |
-
Returns:
|
| 101 |
-
A fully-formatted prompt string ready for the LLM.
|
| 102 |
-
"""
|
| 103 |
-
if step_index < 0 or step_index >= len(self.steps):
|
| 104 |
-
raise IndexError(f"step_index {step_index} out of range (0..{len(self.steps) - 1})")
|
| 105 |
-
|
| 106 |
-
base_prompt = self.prompts[step_index]
|
| 107 |
-
|
| 108 |
-
if current_state is None:
|
| 109 |
-
return base_prompt
|
| 110 |
-
|
| 111 |
-
# Inject live state into the prompt
|
| 112 |
-
state_block = _format_state_block(current_state)
|
| 113 |
-
return base_prompt.replace("{{CURRENT_STATE}}", state_block)
|
| 114 |
-
|
| 115 |
-
# ------------------------------------------------------------------
|
| 116 |
-
# Convenience: all fold operations flattened
|
| 117 |
-
# ------------------------------------------------------------------
|
| 118 |
-
|
| 119 |
-
def all_fold_operations(self) -> list[dict]:
|
| 120 |
-
"""Return every fold operation across all steps, in order."""
|
| 121 |
-
ops: list[dict] = []
|
| 122 |
-
for step in self.steps:
|
| 123 |
-
ops.extend(step.get("fold_operations", []))
|
| 124 |
-
return ops
|
| 125 |
-
|
| 126 |
-
def total_fold_count(self) -> int:
|
| 127 |
-
"""Total number of fold operations in the plan."""
|
| 128 |
-
return sum(len(s.get("fold_operations", [])) for s in self.steps)
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
# ---------------------------------------------------------------------------
|
| 132 |
-
# Prompt builder helpers
|
| 133 |
-
# ---------------------------------------------------------------------------
|
| 134 |
-
|
| 135 |
-
def _format_state_block(state: dict) -> str:
|
| 136 |
-
"""Format a paper_state dict as a human-readable block for the prompt."""
|
| 137 |
-
lines = ["CURRENT STATE:"]
|
| 138 |
-
if "bounding_box" in state:
|
| 139 |
-
bb = state["bounding_box"]
|
| 140 |
-
if isinstance(bb, dict):
|
| 141 |
-
lines.append(f" Bounding box: {bb.get('x', '?')}m x {bb.get('y', '?')}m x {bb.get('z', '?')}m")
|
| 142 |
-
elif isinstance(bb, (list, tuple)) and len(bb) >= 3:
|
| 143 |
-
lines.append(f" Bounding box: {bb[0]}m x {bb[1]}m x {bb[2]}m")
|
| 144 |
-
if "num_layers_at_center" in state:
|
| 145 |
-
lines.append(f" Layers at center: {state['num_layers_at_center']}")
|
| 146 |
-
if "deployment_ratio" in state:
|
| 147 |
-
lines.append(f" Deployment ratio: {state['deployment_ratio']:.3f}")
|
| 148 |
-
if "fold_angles" in state:
|
| 149 |
-
n_folds = sum(1 for a in state["fold_angles"] if a != 0)
|
| 150 |
-
lines.append(f" Active folds: {n_folds}")
|
| 151 |
-
return "\n".join(lines)
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
def _format_fold_ops_as_code(ops: list[dict]) -> str:
|
| 155 |
-
"""Format fold operations as Python list literal for inclusion in a prompt."""
|
| 156 |
-
if not ops:
|
| 157 |
-
return " # (LLM: determine fold operations for this step)\n return []"
|
| 158 |
-
|
| 159 |
-
lines = [" return ["]
|
| 160 |
-
for op in ops:
|
| 161 |
-
clean = {
|
| 162 |
-
"type": op["type"],
|
| 163 |
-
"line": op.get("line", {"start": [0, 0], "end": [1, 1]}),
|
| 164 |
-
"angle": op.get("angle", 180),
|
| 165 |
-
}
|
| 166 |
-
lines.append(f" {json.dumps(clean)},")
|
| 167 |
-
lines.append(" ]")
|
| 168 |
-
return "\n".join(lines)
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
# ---------------------------------------------------------------------------
|
| 172 |
-
# OrigamiPlanner
|
| 173 |
-
# ---------------------------------------------------------------------------
|
| 174 |
-
|
| 175 |
-
class OrigamiPlanner:
|
| 176 |
-
"""
|
| 177 |
-
Full pipeline: human instruction -> structured plan -> executable fold operations.
|
| 178 |
-
|
| 179 |
-
The planner:
|
| 180 |
-
1. Parses the instruction (parser.py)
|
| 181 |
-
2. Decomposes into sub-goals (decomposer.py)
|
| 182 |
-
3. Builds LLM-ready prompts matching trainer/prompts.py format
|
| 183 |
-
"""
|
| 184 |
-
|
| 185 |
-
def plan(self, instruction: str) -> FoldPlan:
|
| 186 |
-
"""
|
| 187 |
-
Plan an origami task from a human instruction.
|
| 188 |
-
|
| 189 |
-
Args:
|
| 190 |
-
instruction: e.g. "make a paper crane", "pack a 1m mylar sheet"
|
| 191 |
-
|
| 192 |
-
Returns:
|
| 193 |
-
A FoldPlan with steps and LLM prompts.
|
| 194 |
-
"""
|
| 195 |
-
parsed = parse_instruction(instruction)
|
| 196 |
-
steps = decompose_task(parsed)
|
| 197 |
-
prompts = [self._build_prompt(step, i, parsed) for i, step in enumerate(steps)]
|
| 198 |
-
return FoldPlan(
|
| 199 |
-
instruction=instruction,
|
| 200 |
-
parsed=parsed,
|
| 201 |
-
steps=steps,
|
| 202 |
-
prompts=prompts,
|
| 203 |
-
)
|
| 204 |
-
|
| 205 |
-
# ------------------------------------------------------------------
|
| 206 |
-
# Prompt construction
|
| 207 |
-
# ------------------------------------------------------------------
|
| 208 |
-
|
| 209 |
-
def _build_prompt(self, step: dict, step_index: int, parsed: dict) -> str:
|
| 210 |
-
"""
|
| 211 |
-
Build an LLM-ready prompt for a single sub-goal step.
|
| 212 |
-
|
| 213 |
-
The format matches trainer/prompts.py: task description at top,
|
| 214 |
-
material/constraints in the middle, and a fold_strategy() code
|
| 215 |
-
block wrapped in triple backticks at the bottom.
|
| 216 |
-
"""
|
| 217 |
-
material = parsed["material"]
|
| 218 |
-
mat_info = _MATERIAL_DEFAULTS.get(material, _MATERIAL_DEFAULTS["paper"])
|
| 219 |
-
dims = parsed["dimensions"]
|
| 220 |
-
constraints = parsed.get("constraints", {})
|
| 221 |
-
total_steps = len(parsed.get("_all_steps", [])) or step.get("step_number", 1)
|
| 222 |
-
|
| 223 |
-
# ---- Header ----
|
| 224 |
-
intent = parsed["intent"]
|
| 225 |
-
if intent == "fold_model" and parsed.get("model_name"):
|
| 226 |
-
model_info = ORIGAMI_MODELS.get(parsed["model_name"], {})
|
| 227 |
-
task_line = (
|
| 228 |
-
f"TASK: Step {step['step_number']} of {total_steps} — "
|
| 229 |
-
f"{step['description']}\n"
|
| 230 |
-
f"MODEL: {model_info.get('name', parsed['model_name'])} "
|
| 231 |
-
f"(difficulty: {model_info.get('difficulty', 'unknown')})"
|
| 232 |
-
)
|
| 233 |
-
elif intent == "optimize_packing":
|
| 234 |
-
task_line = (
|
| 235 |
-
f"TASK: Step {step['step_number']} — {step['description']}\n"
|
| 236 |
-
f"GOAL: Minimize packed volume while maintaining deployability."
|
| 237 |
-
)
|
| 238 |
-
else:
|
| 239 |
-
task_line = f"TASK: Step {step['step_number']} — {step['description']}"
|
| 240 |
-
|
| 241 |
-
# ---- Material ----
|
| 242 |
-
material_block = (
|
| 243 |
-
f"MATERIAL:\n"
|
| 244 |
-
f" - Name: {material}\n"
|
| 245 |
-
f" - Thickness: {mat_info['thickness_mm']}mm\n"
|
| 246 |
-
f" - Max strain: {mat_info['max_strain_pct']}%"
|
| 247 |
-
)
|
| 248 |
-
|
| 249 |
-
# ---- Constraints ----
|
| 250 |
-
constraint_lines = ["CONSTRAINTS:"]
|
| 251 |
-
if "max_folds" in constraints:
|
| 252 |
-
constraint_lines.append(f" - Maximum {constraints['max_folds']} fold operations")
|
| 253 |
-
if "target_box" in constraints:
|
| 254 |
-
tb = constraints["target_box"]
|
| 255 |
-
constraint_lines.append(
|
| 256 |
-
f" - Must pack into bounding box <= "
|
| 257 |
-
f"{tb[0]*100:.0f}cm x {tb[1]*100:.0f}cm x {tb[2]*100:.0f}cm"
|
| 258 |
-
)
|
| 259 |
-
if constraints.get("must_deploy"):
|
| 260 |
-
constraint_lines.append(" - Must deploy to >= 80% of original area")
|
| 261 |
-
constraint_lines.append(" - No self-intersections allowed")
|
| 262 |
-
constraints_block = "\n".join(constraint_lines)
|
| 263 |
-
|
| 264 |
-
# ---- State placeholder ----
|
| 265 |
-
state_block = (
|
| 266 |
-
f"CURRENT STATE:\n"
|
| 267 |
-
f" Sheet: {dims['width']}m x {dims['height']}m\n"
|
| 268 |
-
f" {{{{CURRENT_STATE}}}}"
|
| 269 |
-
)
|
| 270 |
-
|
| 271 |
-
# ---- Fold operations hint ----
|
| 272 |
-
ops = step.get("fold_operations", [])
|
| 273 |
-
ops_code = _format_fold_ops_as_code(ops)
|
| 274 |
-
|
| 275 |
-
# ---- Expected result ----
|
| 276 |
-
expected = step.get("expected_state", {})
|
| 277 |
-
expected_block = ""
|
| 278 |
-
if expected:
|
| 279 |
-
expected_block = f"\nEXPECTED RESULT: {json.dumps(expected)}"
|
| 280 |
-
|
| 281 |
-
# ---- Code block (matches trainer/prompts.py format) ----
|
| 282 |
-
code_block = (
|
| 283 |
-
f'Write a fold_strategy(paper_state) function that returns a list of fold operations.\n'
|
| 284 |
-
f'Each fold: {{"type": "valley"|"mountain", "line": {{"start": [x,y], "end": [x,y]}}, "angle": 0-180}}\n'
|
| 285 |
-
f'\n'
|
| 286 |
-
f'```python\n'
|
| 287 |
-
f'def fold_strategy(paper_state):\n'
|
| 288 |
-
f'{ops_code}\n'
|
| 289 |
-
f'```'
|
| 290 |
-
)
|
| 291 |
-
|
| 292 |
-
# ---- Assemble ----
|
| 293 |
-
sections = [
|
| 294 |
-
task_line,
|
| 295 |
-
"",
|
| 296 |
-
material_block,
|
| 297 |
-
"",
|
| 298 |
-
constraints_block,
|
| 299 |
-
"",
|
| 300 |
-
state_block,
|
| 301 |
-
expected_block,
|
| 302 |
-
"",
|
| 303 |
-
code_block,
|
| 304 |
-
]
|
| 305 |
-
return "\n".join(sections)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
server/app.py
DELETED
|
@@ -1,181 +0,0 @@
|
|
| 1 |
-
"""
|
| 2 |
-
server/app.py — Training WebSocket server for Colab environment.
|
| 3 |
-
|
| 4 |
-
Provides /ws/training for live streaming of RL training episodes to browsers.
|
| 5 |
-
Mount at a publicly accessible URL in Colab (e.g., via ngrok or Colab's proxy).
|
| 6 |
-
|
| 7 |
-
Usage in training:
|
| 8 |
-
from server.app import broadcast
|
| 9 |
-
broadcast.publish(episode_id, {"type": "episode_update", ...})
|
| 10 |
-
"""
|
| 11 |
-
from __future__ import annotations
|
| 12 |
-
|
| 13 |
-
import json
|
| 14 |
-
from pathlib import Path
|
| 15 |
-
|
| 16 |
-
import numpy as np
|
| 17 |
-
import uvicorn
|
| 18 |
-
from fastapi import FastAPI, HTTPException, WebSocket
|
| 19 |
-
from fastapi.middleware.cors import CORSMiddleware
|
| 20 |
-
from fastapi.responses import HTMLResponse, JSONResponse
|
| 21 |
-
from fastapi.staticfiles import StaticFiles
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
def _np_default(obj):
|
| 25 |
-
if isinstance(obj, np.bool_):
|
| 26 |
-
return bool(obj)
|
| 27 |
-
if isinstance(obj, np.integer):
|
| 28 |
-
return int(obj)
|
| 29 |
-
if isinstance(obj, np.floating):
|
| 30 |
-
return float(obj)
|
| 31 |
-
if isinstance(obj, np.ndarray):
|
| 32 |
-
return obj.tolist()
|
| 33 |
-
raise TypeError(f"Not serializable: {type(obj)}")
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
class NumpyJSONResponse(JSONResponse):
|
| 37 |
-
def render(self, content) -> bytes:
|
| 38 |
-
return json.dumps(content, default=_np_default).encode("utf-8")
|
| 39 |
-
|
| 40 |
-
from server.training_broadcast import TrainingBroadcastServer
|
| 41 |
-
|
| 42 |
-
app = FastAPI(title="Optigami Training Server", version="1.0")
|
| 43 |
-
|
| 44 |
-
# Allow cross-origin connections (Colab public URL → browser)
|
| 45 |
-
app.add_middleware(
|
| 46 |
-
CORSMiddleware,
|
| 47 |
-
allow_origins=["*"],
|
| 48 |
-
allow_credentials=True,
|
| 49 |
-
allow_methods=["*"],
|
| 50 |
-
allow_headers=["*"],
|
| 51 |
-
)
|
| 52 |
-
|
| 53 |
-
# Global broadcast server — import and use from training code
|
| 54 |
-
broadcast = TrainingBroadcastServer()
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
@app.on_event("startup")
|
| 58 |
-
async def _store_loop() -> None:
|
| 59 |
-
"""Capture the asyncio event loop so training threads can schedule coroutines."""
|
| 60 |
-
import asyncio
|
| 61 |
-
broadcast._loop = asyncio.get_running_loop()
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
@app.websocket("/ws/training")
|
| 65 |
-
async def training_ws(websocket: WebSocket) -> None:
|
| 66 |
-
"""Spectator WebSocket endpoint. Viewers connect here to watch training."""
|
| 67 |
-
await broadcast.connect_spectator(websocket)
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
@app.get("/health")
|
| 71 |
-
def health() -> dict:
|
| 72 |
-
return {
|
| 73 |
-
"status": "ok",
|
| 74 |
-
"spectators": broadcast.spectator_count,
|
| 75 |
-
"active_episodes": broadcast.active_episodes,
|
| 76 |
-
}
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
# ── Demo endpoints (same as openenv_server/app.py so the React UI works) ──
|
| 80 |
-
|
| 81 |
-
@app.get("/targets", response_class=NumpyJSONResponse)
|
| 82 |
-
def get_targets():
|
| 83 |
-
from server.tasks import available_task_names, get_task_by_name
|
| 84 |
-
return NumpyJSONResponse({
|
| 85 |
-
name: {
|
| 86 |
-
"name": name,
|
| 87 |
-
"level": t["difficulty"],
|
| 88 |
-
"description": t.get("description", ""),
|
| 89 |
-
"n_creases": t.get("max_folds", 3),
|
| 90 |
-
"difficulty": t["difficulty"],
|
| 91 |
-
"material": t.get("material", "paper"),
|
| 92 |
-
}
|
| 93 |
-
for name in available_task_names()
|
| 94 |
-
if (t := get_task_by_name(name))
|
| 95 |
-
})
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
_DEMO_SEQUENCES: dict[str, list[dict]] = {
|
| 99 |
-
"half_fold": [{"type": "valley", "line": {"start": [0.0, 0.5], "end": [1.0, 0.5]}, "angle": 180.0}],
|
| 100 |
-
"quarter_fold": [{"type": "valley", "line": {"start": [0.0, 0.5], "end": [1.0, 0.5]}, "angle": 180.0},
|
| 101 |
-
{"type": "valley", "line": {"start": [0.5, 0.0], "end": [0.5, 1.0]}, "angle": 180.0}],
|
| 102 |
-
"letter_fold": [{"type": "valley", "line": {"start": [0.0, 0.333], "end": [1.0, 0.333]}, "angle": 180.0},
|
| 103 |
-
{"type": "mountain", "line": {"start": [0.0, 0.667], "end": [1.0, 0.667]}, "angle": 180.0}],
|
| 104 |
-
"map_fold": [{"type": "valley", "line": {"start": [0.0, 0.5], "end": [1.0, 0.5]}, "angle": 180.0},
|
| 105 |
-
{"type": "mountain", "line": {"start": [0.5, 0.0], "end": [0.5, 1.0]}, "angle": 180.0}],
|
| 106 |
-
"solar_panel": [{"type": "valley", "line": {"start": [0.0, 0.25], "end": [1.0, 0.25]}, "angle": 180.0},
|
| 107 |
-
{"type": "mountain", "line": {"start": [0.0, 0.5], "end": [1.0, 0.5]}, "angle": 180.0},
|
| 108 |
-
{"type": "valley", "line": {"start": [0.0, 0.75], "end": [1.0, 0.75]}, "angle": 180.0}],
|
| 109 |
-
}
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
@app.get("/episode/demo", response_class=NumpyJSONResponse)
|
| 113 |
-
def demo_episode(target: str = "half_fold"):
|
| 114 |
-
from server.origami_environment import OrigamiEnvironment
|
| 115 |
-
from server.models import OrigamiAction as NewAction
|
| 116 |
-
from server.tasks import get_task_by_name
|
| 117 |
-
|
| 118 |
-
folds = _DEMO_SEQUENCES.get(target, _DEMO_SEQUENCES["half_fold"])
|
| 119 |
-
env = OrigamiEnvironment()
|
| 120 |
-
obs = env.reset(task_name=target)
|
| 121 |
-
steps: list[dict] = []
|
| 122 |
-
|
| 123 |
-
for i, fold_dict in enumerate(folds):
|
| 124 |
-
action = NewAction(
|
| 125 |
-
fold_type=fold_dict["type"],
|
| 126 |
-
fold_line=fold_dict["line"],
|
| 127 |
-
fold_angle=float(fold_dict.get("angle", 180.0)),
|
| 128 |
-
)
|
| 129 |
-
obs = env.step(action)
|
| 130 |
-
steps.append({"step": i + 1, "fold": fold_dict,
|
| 131 |
-
"paper_state": obs.paper_state, "metrics": obs.metrics,
|
| 132 |
-
"done": obs.done})
|
| 133 |
-
if obs.done:
|
| 134 |
-
break
|
| 135 |
-
|
| 136 |
-
return NumpyJSONResponse({"task_name": target, "task": get_task_by_name(target) or {},
|
| 137 |
-
"steps": steps, "final_metrics": obs.metrics if steps else {}})
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
@app.get("/episode/replay/{ep_id}", response_class=NumpyJSONResponse)
|
| 141 |
-
def replay_episode(ep_id: str):
|
| 142 |
-
"""Return a stored training episode in the same format as /episode/demo."""
|
| 143 |
-
from server.tasks import get_task_by_name
|
| 144 |
-
ep = broadcast._registry.get(ep_id)
|
| 145 |
-
if not ep:
|
| 146 |
-
raise HTTPException(status_code=404, detail=f"Episode '{ep_id}' not found in registry")
|
| 147 |
-
return NumpyJSONResponse({
|
| 148 |
-
"task_name": ep.task_name,
|
| 149 |
-
"task": get_task_by_name(ep.task_name) or {},
|
| 150 |
-
"steps": ep.steps,
|
| 151 |
-
"final_metrics": ep.final_metrics or (ep.steps[-1]["metrics"] if ep.steps else {}),
|
| 152 |
-
})
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
# ── Static files — viewer first, then React app (LAST, catch-all) ──
|
| 156 |
-
|
| 157 |
-
_VIEWER_DIR = Path(__file__).resolve().parent.parent / "viewer"
|
| 158 |
-
_BUILD_DIR = Path(__file__).resolve().parent.parent / "build"
|
| 159 |
-
|
| 160 |
-
if _VIEWER_DIR.exists():
|
| 161 |
-
app.mount("/viewer", StaticFiles(directory=str(_VIEWER_DIR), html=True), name="viewer")
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
if _BUILD_DIR.exists():
|
| 165 |
-
app.mount("/", StaticFiles(directory=str(_BUILD_DIR), html=True), name="react")
|
| 166 |
-
else:
|
| 167 |
-
@app.get("/", include_in_schema=False)
|
| 168 |
-
def _no_build() -> HTMLResponse:
|
| 169 |
-
return HTMLResponse(
|
| 170 |
-
"<p>React build not found. Run <code>npm run build</code> in the frontend directory.</p>"
|
| 171 |
-
"<p>Training viewer: <a href='/viewer/training.html'>/viewer/training.html</a></p>"
|
| 172 |
-
)
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
def run(host: str = "0.0.0.0", port: int = 9001) -> None:
|
| 176 |
-
"""Start the training server. Call from Colab notebook."""
|
| 177 |
-
uvicorn.run(app, host=host, port=port)
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
if __name__ == "__main__":
|
| 181 |
-
run()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
server/models.py
DELETED
|
@@ -1,59 +0,0 @@
|
|
| 1 |
-
"""
|
| 2 |
-
OpenEnv Pydantic models for the origami RL environment.
|
| 3 |
-
|
| 4 |
-
OrigamiAction — one fold per step
|
| 5 |
-
OrigamiObservation — everything the LLM and Three.js viewer need
|
| 6 |
-
OrigamiState — server-side episode tracking
|
| 7 |
-
"""
|
| 8 |
-
from __future__ import annotations
|
| 9 |
-
|
| 10 |
-
from typing import Any, Optional
|
| 11 |
-
|
| 12 |
-
from pydantic import Field
|
| 13 |
-
|
| 14 |
-
from openenv.core.env_server.types import Action, Observation, State
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
class OrigamiAction(Action):
|
| 18 |
-
"""One fold operation sent by the client each step."""
|
| 19 |
-
|
| 20 |
-
fold_type: str = Field(
|
| 21 |
-
default="valley",
|
| 22 |
-
description="'valley' | 'mountain' | 'pleat' | 'crimp' | 'stop'",
|
| 23 |
-
)
|
| 24 |
-
fold_line: dict[str, list[float]] = Field(
|
| 25 |
-
default_factory=lambda: {"start": [0.0, 0.5], "end": [1.0, 0.5]},
|
| 26 |
-
description="{'start': [x, y], 'end': [x, y]} normalized 0-1",
|
| 27 |
-
)
|
| 28 |
-
fold_angle: float = Field(
|
| 29 |
-
default=180.0,
|
| 30 |
-
description="Fold angle in degrees, 0-180",
|
| 31 |
-
)
|
| 32 |
-
layer_select: str = Field(
|
| 33 |
-
default="all",
|
| 34 |
-
description="'all' | 'top' | 'bottom'",
|
| 35 |
-
)
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
class OrigamiObservation(Observation):
|
| 39 |
-
"""Everything the LLM and Three.js viewer need.
|
| 40 |
-
|
| 41 |
-
paper_state contains FOLD-compatible geometry + physics data.
|
| 42 |
-
metrics contains all computed quality metrics.
|
| 43 |
-
No render_urls — the browser renders from paper_state directly.
|
| 44 |
-
"""
|
| 45 |
-
|
| 46 |
-
task: dict[str, Any] = Field(default_factory=dict)
|
| 47 |
-
paper_state: dict[str, Any] = Field(default_factory=dict)
|
| 48 |
-
metrics: dict[str, Any] = Field(default_factory=dict)
|
| 49 |
-
fold_history: list[dict[str, Any]] = Field(default_factory=list)
|
| 50 |
-
error: Optional[str] = Field(default=None)
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
class OrigamiState(State):
|
| 54 |
-
"""Server-side episode tracking."""
|
| 55 |
-
|
| 56 |
-
task_name: str = Field(default="")
|
| 57 |
-
num_folds_applied: int = Field(default=0)
|
| 58 |
-
is_valid: bool = Field(default=True)
|
| 59 |
-
total_reward: float = Field(default=0.0)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
server/origami_environment.py
DELETED
|
@@ -1,211 +0,0 @@
|
|
| 1 |
-
"""
|
| 2 |
-
OrigamiEnvironment — OpenEnv environment wrapping the origami physics engine.
|
| 3 |
-
|
| 4 |
-
Implements reset() / step() / state following the OpenEnv interface.
|
| 5 |
-
Engine (physics, fold, validation, metrics) lives in engine/.
|
| 6 |
-
No server-side image rendering — paper_state contains all geometry data.
|
| 7 |
-
"""
|
| 8 |
-
from __future__ import annotations
|
| 9 |
-
|
| 10 |
-
import json
|
| 11 |
-
import os
|
| 12 |
-
import uuid
|
| 13 |
-
from typing import Any, Optional
|
| 14 |
-
|
| 15 |
-
from openenv.core.env_server.interfaces import Environment
|
| 16 |
-
|
| 17 |
-
from engine.paper import Paper
|
| 18 |
-
from engine.fold_engine import apply_fold
|
| 19 |
-
from engine.physics import simulate
|
| 20 |
-
from engine.validation import validate_state
|
| 21 |
-
from engine.metrics import compute_all_metrics
|
| 22 |
-
from server.models import OrigamiAction, OrigamiObservation, OrigamiState
|
| 23 |
-
from server.tasks import get_task_by_name, sample_task
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
def _get_material(name: str):
|
| 27 |
-
"""Get material by name, falling back to paper."""
|
| 28 |
-
try:
|
| 29 |
-
from engine.materials import get_material
|
| 30 |
-
return get_material(name)
|
| 31 |
-
except Exception:
|
| 32 |
-
from engine.materials import get_material
|
| 33 |
-
return get_material("paper")
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
class OrigamiEnvironment(Environment[OrigamiAction, OrigamiObservation, OrigamiState]):
|
| 37 |
-
"""Origami folding RL environment.
|
| 38 |
-
|
| 39 |
-
Each episode: agent receives paper_state + task, applies folds one at a
|
| 40 |
-
time via step(), receives metrics + reward, ends with 'stop' action or
|
| 41 |
-
when max_folds is reached.
|
| 42 |
-
"""
|
| 43 |
-
|
| 44 |
-
SUPPORTS_CONCURRENT_SESSIONS = False
|
| 45 |
-
|
| 46 |
-
def __init__(self, **kwargs):
|
| 47 |
-
super().__init__(**kwargs)
|
| 48 |
-
self._paper: Optional[Paper] = None
|
| 49 |
-
self._task: Optional[dict] = None
|
| 50 |
-
self._fold_history: list[dict] = []
|
| 51 |
-
self._metrics: dict = {}
|
| 52 |
-
self._validation: dict = {}
|
| 53 |
-
self._error: Optional[str] = None
|
| 54 |
-
self._episode_id: Optional[str] = None
|
| 55 |
-
self._step_count: int = 0
|
| 56 |
-
self._total_reward: float = 0.0
|
| 57 |
-
|
| 58 |
-
# ── reset ─────────────────────────────────────────────────────────
|
| 59 |
-
|
| 60 |
-
def reset(
|
| 61 |
-
self,
|
| 62 |
-
seed: Optional[int] = None,
|
| 63 |
-
episode_id: Optional[str] = None,
|
| 64 |
-
**kwargs: Any,
|
| 65 |
-
) -> OrigamiObservation:
|
| 66 |
-
self._episode_id = episode_id or str(uuid.uuid4())
|
| 67 |
-
self._step_count = 0
|
| 68 |
-
self._fold_history = []
|
| 69 |
-
self._error = None
|
| 70 |
-
self._total_reward = 0.0
|
| 71 |
-
|
| 72 |
-
# Select task
|
| 73 |
-
task_name = kwargs.get("task_name")
|
| 74 |
-
if task_name:
|
| 75 |
-
self._task = get_task_by_name(task_name)
|
| 76 |
-
if not self._task:
|
| 77 |
-
self._task = sample_task(seed=seed)
|
| 78 |
-
|
| 79 |
-
# Create flat sheet
|
| 80 |
-
mat = _get_material(self._task["material"])
|
| 81 |
-
self._paper = Paper.create_flat_sheet(
|
| 82 |
-
width=self._task["width"],
|
| 83 |
-
height=self._task["height"],
|
| 84 |
-
material=mat,
|
| 85 |
-
)
|
| 86 |
-
|
| 87 |
-
# Initial validation + metrics (no physics needed for flat sheet)
|
| 88 |
-
self._validation = validate_state(self._paper)
|
| 89 |
-
self._metrics = compute_all_metrics(self._paper, self._task, self._validation)
|
| 90 |
-
|
| 91 |
-
return self._make_observation(done=False, reward=None)
|
| 92 |
-
|
| 93 |
-
# ── step ──────────────────────────────────────────────────────────
|
| 94 |
-
|
| 95 |
-
def step(
|
| 96 |
-
self,
|
| 97 |
-
action: OrigamiAction,
|
| 98 |
-
timeout_s: Optional[float] = None,
|
| 99 |
-
**kwargs: Any,
|
| 100 |
-
) -> OrigamiObservation:
|
| 101 |
-
if self._paper is None or self._task is None:
|
| 102 |
-
return self._make_observation(done=True, reward=-5.0)
|
| 103 |
-
|
| 104 |
-
self._step_count += 1
|
| 105 |
-
self._error = None
|
| 106 |
-
|
| 107 |
-
# ── Stop action ───────────────────────────────────────────────
|
| 108 |
-
if action.fold_type == "stop":
|
| 109 |
-
return self._finalize_episode()
|
| 110 |
-
|
| 111 |
-
# ── Build fold dict ───────────────────────────────────────────
|
| 112 |
-
fold_dict = {
|
| 113 |
-
"type": action.fold_type,
|
| 114 |
-
"line": action.fold_line,
|
| 115 |
-
"angle": action.fold_angle,
|
| 116 |
-
}
|
| 117 |
-
|
| 118 |
-
# ── Apply fold ────────────────────────────────────────────────
|
| 119 |
-
new_paper, err = apply_fold(self._paper, fold_dict)
|
| 120 |
-
if err:
|
| 121 |
-
self._error = err
|
| 122 |
-
return self._make_observation(done=True, reward=-5.0)
|
| 123 |
-
|
| 124 |
-
self._paper = new_paper
|
| 125 |
-
self._fold_history.append({**fold_dict, "step": self._step_count})
|
| 126 |
-
|
| 127 |
-
# ── Physics relaxation ────────────────────────────────────────
|
| 128 |
-
try:
|
| 129 |
-
self._paper = simulate(self._paper, fold_percent=1.0)
|
| 130 |
-
except Exception as exc:
|
| 131 |
-
self._error = f"Physics failed: {exc}"
|
| 132 |
-
# Continue — don't abort episode on physics failure
|
| 133 |
-
|
| 134 |
-
# ── Validate ──────────────────────────────────────────────────
|
| 135 |
-
self._validation = validate_state(self._paper)
|
| 136 |
-
|
| 137 |
-
# ── Metrics ───────────────────────────────────────────────────
|
| 138 |
-
self._metrics = compute_all_metrics(self._paper, self._task, self._validation)
|
| 139 |
-
|
| 140 |
-
# ── Check termination ─────────────────────────────────────────
|
| 141 |
-
max_folds = self._task.get("max_folds", 50)
|
| 142 |
-
if self._step_count >= max_folds:
|
| 143 |
-
return self._finalize_episode()
|
| 144 |
-
|
| 145 |
-
if self._validation.get("self_intersections", 0) > 0:
|
| 146 |
-
self._error = "Self-intersection detected"
|
| 147 |
-
return self._finalize_episode()
|
| 148 |
-
|
| 149 |
-
return self._make_observation(done=False, reward=None)
|
| 150 |
-
|
| 151 |
-
# ── state ─────────────────────────────────────────────────────────
|
| 152 |
-
|
| 153 |
-
@property
|
| 154 |
-
def state(self) -> OrigamiState:
|
| 155 |
-
return OrigamiState(
|
| 156 |
-
episode_id=self._episode_id,
|
| 157 |
-
step_count=self._step_count,
|
| 158 |
-
task_name=self._task.get("name", "") if self._task else "",
|
| 159 |
-
num_folds_applied=len(self._fold_history),
|
| 160 |
-
is_valid=self._metrics.get("is_valid", True),
|
| 161 |
-
total_reward=self._total_reward,
|
| 162 |
-
)
|
| 163 |
-
|
| 164 |
-
# ── internals ─────────────────────────────────────────────────────
|
| 165 |
-
|
| 166 |
-
def _finalize_episode(self) -> OrigamiObservation:
|
| 167 |
-
reward = self._compute_reward()
|
| 168 |
-
self._total_reward = reward
|
| 169 |
-
return self._make_observation(done=True, reward=reward)
|
| 170 |
-
|
| 171 |
-
def _make_observation(self, done: bool, reward: Optional[float]) -> OrigamiObservation:
|
| 172 |
-
return OrigamiObservation(
|
| 173 |
-
done=done,
|
| 174 |
-
reward=reward,
|
| 175 |
-
task=self._task or {},
|
| 176 |
-
paper_state=self._paper.to_observation_dict() if self._paper else {},
|
| 177 |
-
metrics=self._metrics,
|
| 178 |
-
fold_history=self._fold_history,
|
| 179 |
-
error=self._error,
|
| 180 |
-
)
|
| 181 |
-
|
| 182 |
-
def _compute_reward(self) -> float:
|
| 183 |
-
m = self._metrics
|
| 184 |
-
reward = 0.0
|
| 185 |
-
|
| 186 |
-
# Compactness is the main signal
|
| 187 |
-
reward += m.get("compactness", 0.0) * 20.0
|
| 188 |
-
|
| 189 |
-
# Bonus for fitting in target box
|
| 190 |
-
if m.get("fits_target_box", False):
|
| 191 |
-
reward += 10.0
|
| 192 |
-
|
| 193 |
-
# Bonus for deployability (if task requires it)
|
| 194 |
-
if m.get("is_deployable", False):
|
| 195 |
-
reward += 5.0
|
| 196 |
-
|
| 197 |
-
# Penalties for violations
|
| 198 |
-
reward -= m.get("kawasaki_violations", 0) * 2.0
|
| 199 |
-
reward -= m.get("maekawa_violations", 0) * 2.0
|
| 200 |
-
reward -= m.get("self_intersections", 0) * 5.0
|
| 201 |
-
|
| 202 |
-
# Penalty for too many folds (encourage efficiency)
|
| 203 |
-
reward -= m.get("fold_count", 0) * 0.5
|
| 204 |
-
|
| 205 |
-
# Penalty for exceeding material strain limit
|
| 206 |
-
max_strain = m.get("max_strain", 0.0)
|
| 207 |
-
strain_limit = self._paper.material.max_strain if self._paper else 0.05
|
| 208 |
-
if max_strain > strain_limit:
|
| 209 |
-
reward -= 3.0 * (max_strain / strain_limit)
|
| 210 |
-
|
| 211 |
-
return float(reward)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
server/tasks.py
DELETED
|
@@ -1,123 +0,0 @@
|
|
| 1 |
-
"""
|
| 2 |
-
Task pool and curriculum for the origami RL environment.
|
| 3 |
-
|
| 4 |
-
7 tasks across 4 difficulty levels.
|
| 5 |
-
"""
|
| 6 |
-
from __future__ import annotations
|
| 7 |
-
|
| 8 |
-
import random
|
| 9 |
-
from typing import Optional
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
TASKS: dict[str, dict] = {
|
| 13 |
-
"half_fold": {
|
| 14 |
-
"name": "half_fold",
|
| 15 |
-
"description": "Fold a 1x1 paper sheet in half along the horizontal midline.",
|
| 16 |
-
"width": 1.0,
|
| 17 |
-
"height": 1.0,
|
| 18 |
-
"material": "paper",
|
| 19 |
-
"target_ratio": 0.50,
|
| 20 |
-
"max_folds": 3,
|
| 21 |
-
"target_box": [1.0, 0.5, 0.02],
|
| 22 |
-
"must_deploy": False,
|
| 23 |
-
"difficulty": 1,
|
| 24 |
-
},
|
| 25 |
-
"quarter_fold": {
|
| 26 |
-
"name": "quarter_fold",
|
| 27 |
-
"description": "Fold a 1x1 paper sheet into quarters using two perpendicular folds.",
|
| 28 |
-
"width": 1.0,
|
| 29 |
-
"height": 1.0,
|
| 30 |
-
"material": "paper",
|
| 31 |
-
"target_ratio": 0.25,
|
| 32 |
-
"max_folds": 5,
|
| 33 |
-
"target_box": [0.5, 0.5, 0.04],
|
| 34 |
-
"must_deploy": False,
|
| 35 |
-
"difficulty": 1,
|
| 36 |
-
},
|
| 37 |
-
"letter_fold": {
|
| 38 |
-
"name": "letter_fold",
|
| 39 |
-
"description": "Fold a 1x1 paper into thirds (letter fold) using two parallel folds.",
|
| 40 |
-
"width": 1.0,
|
| 41 |
-
"height": 1.0,
|
| 42 |
-
"material": "paper",
|
| 43 |
-
"target_ratio": 0.33,
|
| 44 |
-
"max_folds": 5,
|
| 45 |
-
"target_box": [1.0, 0.34, 0.03],
|
| 46 |
-
"must_deploy": False,
|
| 47 |
-
"difficulty": 2,
|
| 48 |
-
},
|
| 49 |
-
"map_fold": {
|
| 50 |
-
"name": "map_fold",
|
| 51 |
-
"description": "Fold a 1x1 paper into eighths using a grid fold pattern. Must be re-deployable.",
|
| 52 |
-
"width": 1.0,
|
| 53 |
-
"height": 1.0,
|
| 54 |
-
"material": "paper",
|
| 55 |
-
"target_ratio": 0.125,
|
| 56 |
-
"max_folds": 8,
|
| 57 |
-
"target_box": [0.5, 0.25, 0.08],
|
| 58 |
-
"must_deploy": True,
|
| 59 |
-
"difficulty": 2,
|
| 60 |
-
},
|
| 61 |
-
"solar_panel": {
|
| 62 |
-
"name": "solar_panel",
|
| 63 |
-
"description": "Pack a 1x1 Mylar solar panel into a compact configuration using a Miura-ori style fold. Must deploy.",
|
| 64 |
-
"width": 1.0,
|
| 65 |
-
"height": 1.0,
|
| 66 |
-
"material": "mylar",
|
| 67 |
-
"target_ratio": 0.05,
|
| 68 |
-
"max_folds": 20,
|
| 69 |
-
"target_box": [0.25, 0.25, 0.05],
|
| 70 |
-
"must_deploy": True,
|
| 71 |
-
"difficulty": 3,
|
| 72 |
-
},
|
| 73 |
-
"shelter_wall": {
|
| 74 |
-
"name": "shelter_wall",
|
| 75 |
-
"description": "Fold a 1x1 aluminum sheet into a compact structural panel within strain limits.",
|
| 76 |
-
"width": 1.0,
|
| 77 |
-
"height": 1.0,
|
| 78 |
-
"material": "aluminum",
|
| 79 |
-
"target_ratio": 0.10,
|
| 80 |
-
"max_folds": 15,
|
| 81 |
-
"target_box": [0.5, 0.25, 0.1],
|
| 82 |
-
"must_deploy": False,
|
| 83 |
-
"difficulty": 3,
|
| 84 |
-
},
|
| 85 |
-
"stent": {
|
| 86 |
-
"name": "stent",
|
| 87 |
-
"description": "Fold a 0.5x1.5 nitinol sheet into a compact tube configuration for a medical stent. Superelastic material.",
|
| 88 |
-
"width": 0.5,
|
| 89 |
-
"height": 1.5,
|
| 90 |
-
"material": "nitinol",
|
| 91 |
-
"target_ratio": 0.09,
|
| 92 |
-
"max_folds": 25,
|
| 93 |
-
"target_box": [0.1, 0.1, 0.15],
|
| 94 |
-
"must_deploy": True,
|
| 95 |
-
"difficulty": 4,
|
| 96 |
-
},
|
| 97 |
-
}
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
def get_task_by_name(name: str) -> Optional[dict]:
|
| 101 |
-
"""Return task dict by name, or None if not found."""
|
| 102 |
-
return TASKS.get(name)
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
def sample_task(seed: Optional[int] = None, difficulty: Optional[int] = None) -> dict:
|
| 106 |
-
"""Sample a random task, optionally filtered by difficulty level."""
|
| 107 |
-
rng = random.Random(seed)
|
| 108 |
-
pool = list(TASKS.values())
|
| 109 |
-
if difficulty is not None:
|
| 110 |
-
pool = [t for t in pool if t["difficulty"] == difficulty]
|
| 111 |
-
if not pool:
|
| 112 |
-
pool = list(TASKS.values())
|
| 113 |
-
return dict(rng.choice(pool))
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
def get_tasks_by_difficulty(level: int) -> list[dict]:
|
| 117 |
-
"""Return all tasks at a given difficulty level."""
|
| 118 |
-
return [dict(t) for t in TASKS.values() if t["difficulty"] == level]
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
def available_task_names() -> list[str]:
|
| 122 |
-
"""Return sorted list of all task names."""
|
| 123 |
-
return sorted(TASKS.keys())
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
server_legacy.py
DELETED
|
@@ -1,172 +0,0 @@
|
|
| 1 |
-
"""
|
| 2 |
-
FastAPI server for the origami RL environment.
|
| 3 |
-
Serves episode data to the React frontend.
|
| 4 |
-
|
| 5 |
-
Usage: uvicorn server:app --reload --port 8000
|
| 6 |
-
"""
|
| 7 |
-
|
| 8 |
-
try:
|
| 9 |
-
from fastapi import FastAPI
|
| 10 |
-
from fastapi.middleware.cors import CORSMiddleware
|
| 11 |
-
from pydantic import BaseModel
|
| 12 |
-
except ImportError:
|
| 13 |
-
print("Run: pip install fastapi uvicorn pydantic")
|
| 14 |
-
raise
|
| 15 |
-
|
| 16 |
-
from typing import Optional
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
app = FastAPI(title="OrigamiRL API")
|
| 20 |
-
|
| 21 |
-
app.add_middleware(
|
| 22 |
-
CORSMiddleware,
|
| 23 |
-
allow_origins=["*"], # localhost:3000 for React dev
|
| 24 |
-
allow_methods=["*"],
|
| 25 |
-
allow_headers=["*"],
|
| 26 |
-
)
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
class FoldAction(BaseModel):
|
| 30 |
-
from_point: list[float] # [x, y]
|
| 31 |
-
to_point: list[float] # [x, y]
|
| 32 |
-
assignment: str # 'M' or 'V'
|
| 33 |
-
instruction: str = ""
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
class EpisodeStep(BaseModel):
|
| 37 |
-
step: int
|
| 38 |
-
fold: Optional[FoldAction]
|
| 39 |
-
paper_state: dict # FOLD JSON of current crease graph
|
| 40 |
-
anchor_points: list[list[float]]
|
| 41 |
-
reward: dict
|
| 42 |
-
done: bool
|
| 43 |
-
info: dict
|
| 44 |
-
prompt: str # LLM prompt at this step
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
class EpisodeResult(BaseModel):
|
| 48 |
-
target_name: str
|
| 49 |
-
target: dict # FOLD JSON of target
|
| 50 |
-
steps: list[EpisodeStep]
|
| 51 |
-
final_reward: dict
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
@app.get("/")
|
| 55 |
-
def health_check():
|
| 56 |
-
"""Health check — returns status and available target names."""
|
| 57 |
-
from env.environment import OrigamiEnvironment
|
| 58 |
-
env = OrigamiEnvironment()
|
| 59 |
-
return {"status": "ok", "targets": env.available_targets()}
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
@app.get("/targets")
|
| 63 |
-
def get_targets():
|
| 64 |
-
"""Return list of available target names and their metadata."""
|
| 65 |
-
from env.environment import OrigamiEnvironment
|
| 66 |
-
env = OrigamiEnvironment()
|
| 67 |
-
targets = {}
|
| 68 |
-
for name in env.available_targets():
|
| 69 |
-
t = env._targets[name]
|
| 70 |
-
targets[name] = {
|
| 71 |
-
"name": name,
|
| 72 |
-
"level": t.get("level", 1),
|
| 73 |
-
"description": t.get("description", ""),
|
| 74 |
-
"n_creases": sum(1 for a in t["edges_assignment"] if a in ("M", "V")),
|
| 75 |
-
}
|
| 76 |
-
return targets
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
@app.get("/episode/run")
|
| 80 |
-
def run_episode(target: str = "half_horizontal", completion: str = ""):
|
| 81 |
-
"""
|
| 82 |
-
Run a code-as-policy episode with a provided completion string.
|
| 83 |
-
|
| 84 |
-
If completion is empty, returns the prompt so the caller knows what to send.
|
| 85 |
-
Returns full episode result with all steps.
|
| 86 |
-
"""
|
| 87 |
-
from env.environment import OrigamiEnvironment
|
| 88 |
-
from env.prompts import parse_fold_list, code_as_policy_prompt
|
| 89 |
-
from env.rewards import compute_reward, target_crease_edges
|
| 90 |
-
|
| 91 |
-
env = OrigamiEnvironment(mode="step")
|
| 92 |
-
obs = env.reset(target_name=target)
|
| 93 |
-
|
| 94 |
-
if not completion:
|
| 95 |
-
return {"prompt": obs["prompt"], "steps": [], "target": env.target}
|
| 96 |
-
|
| 97 |
-
try:
|
| 98 |
-
folds = parse_fold_list(completion)
|
| 99 |
-
except ValueError as e:
|
| 100 |
-
return {"error": str(e), "steps": []}
|
| 101 |
-
|
| 102 |
-
steps = []
|
| 103 |
-
for i, fold in enumerate(folds):
|
| 104 |
-
result = env.paper.add_crease(fold["from"], fold["to"], fold["assignment"])
|
| 105 |
-
reward = compute_reward(env.paper, result, env.target)
|
| 106 |
-
|
| 107 |
-
paper_state = {
|
| 108 |
-
"vertices": {str(k): list(v) for k, v in env.paper.graph.vertices.items()},
|
| 109 |
-
"edges": [
|
| 110 |
-
{
|
| 111 |
-
"id": k,
|
| 112 |
-
"v1": list(env.paper.graph.vertices[v[0]]),
|
| 113 |
-
"v2": list(env.paper.graph.vertices[v[1]]),
|
| 114 |
-
"assignment": v[2],
|
| 115 |
-
}
|
| 116 |
-
for k, v in env.paper.graph.edges.items()
|
| 117 |
-
],
|
| 118 |
-
"anchor_points": [list(p) for p in env.paper.anchor_points()],
|
| 119 |
-
}
|
| 120 |
-
|
| 121 |
-
# Build per-step prompt reflecting current state
|
| 122 |
-
from env.prompts import step_level_prompt
|
| 123 |
-
step_prompt = step_level_prompt(
|
| 124 |
-
target=env.target,
|
| 125 |
-
paper_state=env.paper,
|
| 126 |
-
step=i + 1,
|
| 127 |
-
max_steps=env.max_steps,
|
| 128 |
-
last_reward=reward,
|
| 129 |
-
)
|
| 130 |
-
|
| 131 |
-
steps.append({
|
| 132 |
-
"step": i + 1,
|
| 133 |
-
"fold": {
|
| 134 |
-
"from_point": fold["from"],
|
| 135 |
-
"to_point": fold["to"],
|
| 136 |
-
"assignment": fold["assignment"],
|
| 137 |
-
"instruction": fold.get("instruction", ""),
|
| 138 |
-
},
|
| 139 |
-
"paper_state": paper_state,
|
| 140 |
-
"anchor_points": [list(p) for p in env.paper.anchor_points()],
|
| 141 |
-
"reward": reward,
|
| 142 |
-
"done": reward.get("completion", 0) > 0,
|
| 143 |
-
"info": env._info(),
|
| 144 |
-
"prompt": step_prompt,
|
| 145 |
-
})
|
| 146 |
-
|
| 147 |
-
if reward.get("completion", 0) > 0:
|
| 148 |
-
break
|
| 149 |
-
|
| 150 |
-
return {
|
| 151 |
-
"target_name": target,
|
| 152 |
-
"target": env.target,
|
| 153 |
-
"steps": steps,
|
| 154 |
-
"final_reward": steps[-1]["reward"] if steps else {},
|
| 155 |
-
}
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
@app.get("/episode/demo")
|
| 159 |
-
def demo_episode(target: str = "half_horizontal"):
|
| 160 |
-
"""Return a pre-solved demo episode for each target."""
|
| 161 |
-
DEMO_COMPLETIONS = {
|
| 162 |
-
"half_horizontal": '<folds>[{"instruction": "Valley fold along horizontal center line", "from": [0, 0.5], "to": [1, 0.5], "assignment": "V"}]</folds>',
|
| 163 |
-
"half_vertical": '<folds>[{"instruction": "Mountain fold along vertical center line", "from": [0.5, 0], "to": [0.5, 1], "assignment": "M"}]</folds>',
|
| 164 |
-
"diagonal_main": '<folds>[{"instruction": "Valley fold along main diagonal", "from": [0, 0], "to": [1, 1], "assignment": "V"}]</folds>',
|
| 165 |
-
"diagonal_anti": '<folds>[{"instruction": "Mountain fold along anti-diagonal", "from": [1, 0], "to": [0, 1], "assignment": "M"}]</folds>',
|
| 166 |
-
"thirds_h": '<folds>[{"instruction": "Valley fold at one-third height", "from": [0, 0.333], "to": [1, 0.333], "assignment": "V"}, {"instruction": "Valley fold at two-thirds height", "from": [0, 0.667], "to": [1, 0.667], "assignment": "V"}]</folds>',
|
| 167 |
-
"thirds_v": '<folds>[{"instruction": "Mountain fold at one-third width", "from": [0.333, 0], "to": [0.333, 1], "assignment": "M"}, {"instruction": "Mountain fold at two-thirds width", "from": [0.667, 0], "to": [0.667, 1], "assignment": "M"}]</folds>',
|
| 168 |
-
"accordion_3h": '<folds>[{"instruction": "Valley fold at quarter height", "from": [0, 0.25], "to": [1, 0.25], "assignment": "V"}, {"instruction": "Mountain fold at half height", "from": [0, 0.5], "to": [1, 0.5], "assignment": "M"}, {"instruction": "Valley fold at three-quarter height", "from": [0, 0.75], "to": [1, 0.75], "assignment": "V"}]</folds>',
|
| 169 |
-
"accordion_4h": '<folds>[{"instruction": "Valley fold at 0.2", "from": [0, 0.2], "to": [1, 0.2], "assignment": "V"}, {"instruction": "Mountain fold at 0.4", "from": [0, 0.4], "to": [1, 0.4], "assignment": "M"}, {"instruction": "Valley fold at 0.6", "from": [0, 0.6], "to": [1, 0.6], "assignment": "V"}, {"instruction": "Mountain fold at 0.8", "from": [0, 0.8], "to": [1, 0.8], "assignment": "M"}]</folds>',
|
| 170 |
-
}
|
| 171 |
-
completion = DEMO_COMPLETIONS.get(target, DEMO_COMPLETIONS["half_horizontal"])
|
| 172 |
-
return run_episode(target=target, completion=completion)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
sim/__init__.py
DELETED
|
File without changes
|
sim/animate.py
DELETED
|
@@ -1,149 +0,0 @@
|
|
| 1 |
-
"""
|
| 2 |
-
Matplotlib 3D animation of origami folding using OrigamiSimulator.
|
| 3 |
-
|
| 4 |
-
Usage:
|
| 5 |
-
python -m sim.animate [target_name]
|
| 6 |
-
|
| 7 |
-
target_name defaults to 'half_horizontal', resolved against
|
| 8 |
-
env/targets/<target_name>.fold relative to this file's parent directory.
|
| 9 |
-
"""
|
| 10 |
-
|
| 11 |
-
from __future__ import annotations
|
| 12 |
-
|
| 13 |
-
import json
|
| 14 |
-
import sys
|
| 15 |
-
from pathlib import Path
|
| 16 |
-
|
| 17 |
-
import matplotlib.pyplot as plt
|
| 18 |
-
import matplotlib.animation as animation
|
| 19 |
-
import numpy as np
|
| 20 |
-
from mpl_toolkits.mplot3d.art3d import Poly3DCollection
|
| 21 |
-
|
| 22 |
-
from .simulator import OrigamiSimulator
|
| 23 |
-
|
| 24 |
-
# ── Design system colours ─────────────────────────────────────────────────────
|
| 25 |
-
BG_COLOR = '#0d0d14'
|
| 26 |
-
AX_COLOR = '#13131d'
|
| 27 |
-
PAPER_FACE = '#fafaf5'
|
| 28 |
-
PAPER_EDGE = '#2a2a3a'
|
| 29 |
-
MOUNTAIN_CLR = '#f59e0b' # amber
|
| 30 |
-
VALLEY_CLR = '#38bdf8' # sky
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
# ── Public API ────────────────────────────────────────────────────────────────
|
| 34 |
-
|
| 35 |
-
def animate_fold(fold_file: str,
|
| 36 |
-
n_frames: int = 80,
|
| 37 |
-
steps_per_frame: int = 40,
|
| 38 |
-
target_name: str = 'origami') -> None:
|
| 39 |
-
"""
|
| 40 |
-
Animate folding from 0% → 100% → 0% in a triangle-wave loop.
|
| 41 |
-
|
| 42 |
-
Parameters
|
| 43 |
-
----------
|
| 44 |
-
fold_file : str
|
| 45 |
-
Path to the .fold JSON file.
|
| 46 |
-
n_frames : int
|
| 47 |
-
Total animation frames (default 80 → ~40 in, 40 out).
|
| 48 |
-
steps_per_frame : int
|
| 49 |
-
Physics steps executed per frame.
|
| 50 |
-
target_name : str
|
| 51 |
-
Display name shown in the title.
|
| 52 |
-
"""
|
| 53 |
-
fold_data = json.loads(Path(fold_file).read_text())
|
| 54 |
-
sim = OrigamiSimulator(fold_data, subdivisions=2)
|
| 55 |
-
|
| 56 |
-
# Triangle-wave fold percents: 0 → 1 → 0
|
| 57 |
-
half = n_frames // 2
|
| 58 |
-
fold_percents = np.concatenate([
|
| 59 |
-
np.linspace(0.0, 1.0, half),
|
| 60 |
-
np.linspace(1.0, 0.0, n_frames - half),
|
| 61 |
-
])
|
| 62 |
-
|
| 63 |
-
# ── Figure setup ──────────────────────────────────────────────────────────
|
| 64 |
-
fig = plt.figure(figsize=(9, 7), facecolor=BG_COLOR)
|
| 65 |
-
ax = fig.add_subplot(111, projection='3d')
|
| 66 |
-
ax.set_facecolor(AX_COLOR)
|
| 67 |
-
ax.xaxis.pane.fill = False
|
| 68 |
-
ax.yaxis.pane.fill = False
|
| 69 |
-
ax.zaxis.pane.fill = False
|
| 70 |
-
ax.grid(False)
|
| 71 |
-
ax.set_axis_off()
|
| 72 |
-
|
| 73 |
-
def update(frame: int) -> list:
|
| 74 |
-
pct = fold_percents[frame]
|
| 75 |
-
sim.set_fold_percent(pct)
|
| 76 |
-
sim.step(steps_per_frame)
|
| 77 |
-
|
| 78 |
-
ax.clear()
|
| 79 |
-
ax.set_facecolor(AX_COLOR)
|
| 80 |
-
ax.xaxis.pane.fill = False
|
| 81 |
-
ax.yaxis.pane.fill = False
|
| 82 |
-
ax.zaxis.pane.fill = False
|
| 83 |
-
ax.grid(False)
|
| 84 |
-
ax.set_axis_off()
|
| 85 |
-
|
| 86 |
-
# ── Paper surface ─────────────────────────────────────────────────────
|
| 87 |
-
verts = [sim.pos[tri] for tri in sim.triangles]
|
| 88 |
-
poly = Poly3DCollection(
|
| 89 |
-
verts,
|
| 90 |
-
alpha=0.85,
|
| 91 |
-
facecolor=PAPER_FACE,
|
| 92 |
-
edgecolor=PAPER_EDGE,
|
| 93 |
-
linewidth=0.2,
|
| 94 |
-
zorder=1,
|
| 95 |
-
)
|
| 96 |
-
ax.add_collection3d(poly)
|
| 97 |
-
|
| 98 |
-
# ── Crease / fold edges ───────────────────────────────────────────────
|
| 99 |
-
for i in range(len(sim._crease_a)):
|
| 100 |
-
if sim._crease_assign[i] not in ('M', 'V'):
|
| 101 |
-
continue
|
| 102 |
-
a, b = sim._crease_a[i], sim._crease_b[i]
|
| 103 |
-
color = MOUNTAIN_CLR if sim._crease_assign[i] == 'M' else VALLEY_CLR
|
| 104 |
-
ax.plot(
|
| 105 |
-
[sim.pos[a, 0], sim.pos[b, 0]],
|
| 106 |
-
[sim.pos[a, 1], sim.pos[b, 1]],
|
| 107 |
-
[sim.pos[a, 2], sim.pos[b, 2]],
|
| 108 |
-
color=color,
|
| 109 |
-
linewidth=2.5,
|
| 110 |
-
zorder=2,
|
| 111 |
-
)
|
| 112 |
-
|
| 113 |
-
# ── Axis limits & style ───────────────────────────────────────────────
|
| 114 |
-
ax.set_xlim(-0.2, 1.2)
|
| 115 |
-
ax.set_ylim(-0.2, 1.2)
|
| 116 |
-
ax.set_zlim(-0.6, 0.6)
|
| 117 |
-
ax.set_box_aspect([1.4, 1.4, 1.0])
|
| 118 |
-
ax.set_title(
|
| 119 |
-
f'OPTIGAMI — {target_name} fold: {pct * 100:.0f}%',
|
| 120 |
-
color='#e0e0f0',
|
| 121 |
-
fontsize=13,
|
| 122 |
-
pad=10,
|
| 123 |
-
)
|
| 124 |
-
|
| 125 |
-
return []
|
| 126 |
-
|
| 127 |
-
ani = animation.FuncAnimation(
|
| 128 |
-
fig,
|
| 129 |
-
update,
|
| 130 |
-
frames=n_frames,
|
| 131 |
-
interval=40, # ms between frames (~25 fps)
|
| 132 |
-
blit=False,
|
| 133 |
-
)
|
| 134 |
-
|
| 135 |
-
plt.tight_layout()
|
| 136 |
-
plt.show()
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
def main() -> None:
|
| 140 |
-
target = sys.argv[1] if len(sys.argv) > 1 else 'half_horizontal'
|
| 141 |
-
fold_file = Path(__file__).parent.parent / 'env' / 'targets' / f'{target}.fold'
|
| 142 |
-
if not fold_file.exists():
|
| 143 |
-
print(f'Error: fold file not found: {fold_file}', file=sys.stderr)
|
| 144 |
-
sys.exit(1)
|
| 145 |
-
animate_fold(str(fold_file), target_name=target)
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
if __name__ == '__main__':
|
| 149 |
-
main()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
sim/simulator.py
DELETED
|
@@ -1,406 +0,0 @@
|
|
| 1 |
-
"""
|
| 2 |
-
Origami mass-spring dynamic relaxation simulator.
|
| 3 |
-
|
| 4 |
-
Based on: Ghassaei et al., "Fast, Interactive Origami Simulation using GPU
|
| 5 |
-
Computation", 7OSME 2018.
|
| 6 |
-
"""
|
| 7 |
-
|
| 8 |
-
from __future__ import annotations
|
| 9 |
-
|
| 10 |
-
import numpy as np
|
| 11 |
-
from scipy.spatial import Delaunay
|
| 12 |
-
|
| 13 |
-
# ── Physics constants ────────────────────────────────────────────────────────
|
| 14 |
-
|
| 15 |
-
AXIAL_STIFFNESS = 20.0 # K = AXIAL_STIFFNESS / rest_length
|
| 16 |
-
CREASE_STIFFNESS = 0.7 # K = CREASE_STIFFNESS * edge_length (M/V creases)
|
| 17 |
-
PANEL_STIFFNESS = 0.7 # K = PANEL_STIFFNESS * edge_length (F / panel edges)
|
| 18 |
-
PERCENT_DAMPING = 0.45 # global viscous damping fraction
|
| 19 |
-
DT = 0.002 # timestep (seconds)
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
# ── Geometry helpers ─────────────────────────────────────────────────────────
|
| 23 |
-
|
| 24 |
-
def _normalize(v: np.ndarray) -> np.ndarray:
|
| 25 |
-
n = np.linalg.norm(v)
|
| 26 |
-
return v / n if n > 1e-12 else v
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
def _triangulate_faces(faces_vertices: list[list[int]]) -> np.ndarray:
|
| 30 |
-
"""Fan-triangulate polygonal faces (triangles and quads supported)."""
|
| 31 |
-
tris = []
|
| 32 |
-
for face in faces_vertices:
|
| 33 |
-
if len(face) == 3:
|
| 34 |
-
tris.append(face)
|
| 35 |
-
elif len(face) == 4:
|
| 36 |
-
a, b, c, d = face
|
| 37 |
-
tris.append([a, b, c])
|
| 38 |
-
tris.append([a, c, d])
|
| 39 |
-
else:
|
| 40 |
-
# General fan triangulation for n-gons
|
| 41 |
-
for k in range(1, len(face) - 1):
|
| 42 |
-
tris.append([face[0], face[k], face[k + 1]])
|
| 43 |
-
return np.array(tris, dtype=np.int32)
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
def _point_on_segment(p: np.ndarray, p0: np.ndarray, p1: np.ndarray,
|
| 47 |
-
tol: float = 1e-6) -> bool:
|
| 48 |
-
seg = p1 - p0
|
| 49 |
-
seg_len = np.linalg.norm(seg)
|
| 50 |
-
if seg_len < 1e-10:
|
| 51 |
-
return False
|
| 52 |
-
seg_dir = seg / seg_len
|
| 53 |
-
t = np.dot(p - p0, seg_dir)
|
| 54 |
-
perp = (p - p0) - t * seg_dir
|
| 55 |
-
return -tol <= t <= seg_len + tol and np.linalg.norm(perp) < tol
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
# ── Mesh subdivision ──────────────────────────────────────────────────────────
|
| 59 |
-
|
| 60 |
-
def _subdivide(pos2d: np.ndarray, triangles: np.ndarray
|
| 61 |
-
) -> tuple[np.ndarray, np.ndarray]:
|
| 62 |
-
"""Split each triangle into 4 by inserting edge midpoints."""
|
| 63 |
-
midpoint_cache: dict[tuple[int, int], int] = {}
|
| 64 |
-
new_pos = list(pos2d)
|
| 65 |
-
new_tris = []
|
| 66 |
-
|
| 67 |
-
def get_mid(i: int, j: int) -> int:
|
| 68 |
-
key = (min(i, j), max(i, j))
|
| 69 |
-
if key not in midpoint_cache:
|
| 70 |
-
mid = (np.array(new_pos[i]) + np.array(new_pos[j])) / 2.0
|
| 71 |
-
midpoint_cache[key] = len(new_pos)
|
| 72 |
-
new_pos.append(mid)
|
| 73 |
-
return midpoint_cache[key]
|
| 74 |
-
|
| 75 |
-
for tri in triangles:
|
| 76 |
-
a, b, c = tri
|
| 77 |
-
ab = get_mid(a, b)
|
| 78 |
-
bc = get_mid(b, c)
|
| 79 |
-
ca = get_mid(c, a)
|
| 80 |
-
new_tris.extend([
|
| 81 |
-
[a, ab, ca],
|
| 82 |
-
[ab, b, bc],
|
| 83 |
-
[ca, bc, c ],
|
| 84 |
-
[ab, bc, ca],
|
| 85 |
-
])
|
| 86 |
-
|
| 87 |
-
return np.array(new_pos, dtype=np.float64), np.array(new_tris, dtype=np.int32)
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
# ── Main simulator ────────────────────────────────────────────────────────────
|
| 91 |
-
|
| 92 |
-
class OrigamiSimulator:
|
| 93 |
-
"""
|
| 94 |
-
Mass-spring dynamic relaxation simulator for origami.
|
| 95 |
-
|
| 96 |
-
Parameters
|
| 97 |
-
----------
|
| 98 |
-
fold_data : dict
|
| 99 |
-
Parsed FOLD JSON with keys: vertices_coords, edges_vertices,
|
| 100 |
-
edges_assignment.
|
| 101 |
-
subdivisions : int
|
| 102 |
-
Number of midpoint subdivision passes (default 2 → 4× mesh density).
|
| 103 |
-
"""
|
| 104 |
-
|
| 105 |
-
def __init__(self, fold_data: dict, subdivisions: int = 2) -> None:
|
| 106 |
-
self._fold_percent = 0.0
|
| 107 |
-
self._build(fold_data, subdivisions)
|
| 108 |
-
|
| 109 |
-
# ── Public API ────────────────────────────────────────────────────────────
|
| 110 |
-
|
| 111 |
-
def set_fold_percent(self, percent: float) -> None:
|
| 112 |
-
"""Update all crease spring target angles (0.0 = flat, 1.0 = fully folded)."""
|
| 113 |
-
self._fold_percent = float(percent)
|
| 114 |
-
self._crease_target = self._fold_percent * self._crease_full_theta
|
| 115 |
-
|
| 116 |
-
def step(self, n_steps: int = 50) -> None:
|
| 117 |
-
"""Advance the simulation by n_steps Euler integration steps."""
|
| 118 |
-
for _ in range(n_steps):
|
| 119 |
-
self._euler_step()
|
| 120 |
-
|
| 121 |
-
def reset(self) -> None:
|
| 122 |
-
"""Reset to flat state (z=0, vel=0), preserving current fold percent."""
|
| 123 |
-
self.pos = self._flat_pos.copy()
|
| 124 |
-
self.vel[:] = 0.0
|
| 125 |
-
|
| 126 |
-
@property
|
| 127 |
-
def crease_indices(self) -> list[tuple[int, int, str]]:
|
| 128 |
-
"""Return list of (a, b, assignment) for all crease springs."""
|
| 129 |
-
return list(zip(
|
| 130 |
-
self._crease_a.tolist(),
|
| 131 |
-
self._crease_b.tolist(),
|
| 132 |
-
self._crease_assign,
|
| 133 |
-
))
|
| 134 |
-
|
| 135 |
-
# ── Build ─────────────────────────────────────────────────────────────────
|
| 136 |
-
|
| 137 |
-
def _build(self, fold_data: dict, subdivisions: int) -> None:
|
| 138 |
-
coords = fold_data['vertices_coords']
|
| 139 |
-
orig_edges = fold_data['edges_vertices']
|
| 140 |
-
orig_assign = fold_data['edges_assignment']
|
| 141 |
-
|
| 142 |
-
# Original 2-D positions
|
| 143 |
-
pts2d = np.array([[x, y] for x, y in coords], dtype=np.float64)
|
| 144 |
-
|
| 145 |
-
# Build triangles from faces_vertices when available (preferred: ensures
|
| 146 |
-
# crease edges appear as actual mesh edges after subdivision).
|
| 147 |
-
# Quads [a,b,c,d] are split into [a,b,c] + [a,c,d].
|
| 148 |
-
# Fall back to Delaunay only if faces_vertices is absent.
|
| 149 |
-
if 'faces_vertices' in fold_data:
|
| 150 |
-
triangles = _triangulate_faces(fold_data['faces_vertices'])
|
| 151 |
-
else:
|
| 152 |
-
tri = Delaunay(pts2d)
|
| 153 |
-
triangles = tri.simplices.astype(np.int32)
|
| 154 |
-
|
| 155 |
-
# Build original crease segments for later classification
|
| 156 |
-
# Only M and V assignments are actual fold creases; B is boundary.
|
| 157 |
-
orig_creases: list[tuple[np.ndarray, np.ndarray, str]] = []
|
| 158 |
-
for (u, v), asgn in zip(orig_edges, orig_assign):
|
| 159 |
-
if asgn in ('M', 'V'):
|
| 160 |
-
orig_creases.append((pts2d[u], pts2d[v], asgn))
|
| 161 |
-
|
| 162 |
-
# Midpoint subdivision passes
|
| 163 |
-
pos2d = pts2d.copy()
|
| 164 |
-
for _ in range(subdivisions):
|
| 165 |
-
pos2d, triangles = _subdivide(pos2d, triangles)
|
| 166 |
-
|
| 167 |
-
n = len(pos2d)
|
| 168 |
-
|
| 169 |
-
# 3-D positions (flat, z=0)
|
| 170 |
-
pos3d = np.zeros((n, 3), dtype=np.float64)
|
| 171 |
-
pos3d[:, :2] = pos2d
|
| 172 |
-
|
| 173 |
-
self.pos = pos3d
|
| 174 |
-
self._flat_pos = pos3d.copy()
|
| 175 |
-
self.vel = np.zeros((n, 3), dtype=np.float64)
|
| 176 |
-
self.triangles = triangles
|
| 177 |
-
|
| 178 |
-
self._build_beams(triangles)
|
| 179 |
-
self._build_masses(triangles)
|
| 180 |
-
self._build_creases(triangles, pos2d, orig_creases)
|
| 181 |
-
|
| 182 |
-
def _build_beams(self, triangles: np.ndarray) -> None:
|
| 183 |
-
"""Collect all unique triangle edges as structural (axial) springs."""
|
| 184 |
-
edge_set: set[tuple[int, int]] = set()
|
| 185 |
-
for tri in triangles:
|
| 186 |
-
a, b, c = tri
|
| 187 |
-
for i, j in [(a, b), (b, c), (c, a)]:
|
| 188 |
-
edge_set.add((min(i, j), max(i, j)))
|
| 189 |
-
|
| 190 |
-
edges = np.array(sorted(edge_set), dtype=np.int32)
|
| 191 |
-
i_arr = edges[:, 0]
|
| 192 |
-
j_arr = edges[:, 1]
|
| 193 |
-
|
| 194 |
-
rest = np.linalg.norm(self.pos[i_arr] - self.pos[j_arr], axis=1)
|
| 195 |
-
K = AXIAL_STIFFNESS / np.maximum(rest, 1e-12)
|
| 196 |
-
|
| 197 |
-
self._beam_i = i_arr
|
| 198 |
-
self._beam_j = j_arr
|
| 199 |
-
self._beam_rest = rest
|
| 200 |
-
self._beam_K = K
|
| 201 |
-
|
| 202 |
-
def _build_masses(self, triangles: np.ndarray) -> None:
|
| 203 |
-
"""Mass per node = sum of (adjacent triangle area / 3)."""
|
| 204 |
-
n = len(self.pos)
|
| 205 |
-
mass = np.zeros(n, dtype=np.float64)
|
| 206 |
-
for tri in triangles:
|
| 207 |
-
a, b, c = tri
|
| 208 |
-
pa, pb, pc = self.pos[a], self.pos[b], self.pos[c]
|
| 209 |
-
area = 0.5 * np.linalg.norm(np.cross(pb - pa, pc - pa))
|
| 210 |
-
mass[a] += area / 3.0
|
| 211 |
-
mass[b] += area / 3.0
|
| 212 |
-
mass[c] += area / 3.0
|
| 213 |
-
# Guard against zero-mass nodes (degenerate triangles)
|
| 214 |
-
mass = np.maximum(mass, 1e-12)
|
| 215 |
-
self.mass = mass
|
| 216 |
-
|
| 217 |
-
def _build_creases(self, triangles: np.ndarray, pos2d: np.ndarray,
|
| 218 |
-
orig_creases: list[tuple[np.ndarray, np.ndarray, str]]
|
| 219 |
-
) -> None:
|
| 220 |
-
"""
|
| 221 |
-
Identify interior edges (shared by exactly 2 triangles) and classify
|
| 222 |
-
them as M/V fold creases or F panel springs.
|
| 223 |
-
"""
|
| 224 |
-
# Map each canonical edge → list of triangle indices containing it
|
| 225 |
-
edge_to_tris: dict[tuple[int, int], list[int]] = {}
|
| 226 |
-
tri_edge_map: dict[tuple[int, int], list[tuple[int, int, int]]] = {}
|
| 227 |
-
|
| 228 |
-
for t_idx, tri in enumerate(triangles):
|
| 229 |
-
a, b, c = tri
|
| 230 |
-
for (ei, ej), opposite in [
|
| 231 |
-
((min(a, b), max(a, b)), c),
|
| 232 |
-
((min(b, c), max(b, c)), a),
|
| 233 |
-
((min(c, a), max(c, a)), b),
|
| 234 |
-
]:
|
| 235 |
-
edge_to_tris.setdefault((ei, ej), []).append(t_idx)
|
| 236 |
-
tri_edge_map.setdefault((ei, ej), []).append((ei, ej, opposite))
|
| 237 |
-
|
| 238 |
-
crease_a: list[int] = []
|
| 239 |
-
crease_b: list[int] = []
|
| 240 |
-
crease_c: list[int] = []
|
| 241 |
-
crease_d: list[int] = []
|
| 242 |
-
crease_assign: list[str] = []
|
| 243 |
-
crease_full_theta: list[float] = []
|
| 244 |
-
crease_K: list[float] = []
|
| 245 |
-
|
| 246 |
-
for edge_key, t_indices in edge_to_tris.items():
|
| 247 |
-
if len(t_indices) != 2:
|
| 248 |
-
continue # boundary edge
|
| 249 |
-
|
| 250 |
-
ei, ej = edge_key
|
| 251 |
-
# Collect opposite nodes for each of the two triangles
|
| 252 |
-
# Find the opposite node for tri 0 and tri 1
|
| 253 |
-
opp_nodes = [None, None]
|
| 254 |
-
for t_pos, t_idx in enumerate(t_indices):
|
| 255 |
-
tri = triangles[t_idx]
|
| 256 |
-
for node in tri:
|
| 257 |
-
if node != ei and node != ej:
|
| 258 |
-
opp_nodes[t_pos] = node
|
| 259 |
-
break
|
| 260 |
-
|
| 261 |
-
c_node = opp_nodes[0]
|
| 262 |
-
d_node = opp_nodes[1]
|
| 263 |
-
if c_node is None or d_node is None:
|
| 264 |
-
continue
|
| 265 |
-
|
| 266 |
-
# Classify: check if both endpoints lie on the same original crease segment
|
| 267 |
-
pi = pos2d[ei]
|
| 268 |
-
pj = pos2d[ej]
|
| 269 |
-
asgn = 'F'
|
| 270 |
-
for p0, p1, crease_type in orig_creases:
|
| 271 |
-
if _point_on_segment(pi, p0, p1) and _point_on_segment(pj, p0, p1):
|
| 272 |
-
asgn = crease_type
|
| 273 |
-
break
|
| 274 |
-
|
| 275 |
-
if asgn == 'M':
|
| 276 |
-
full_theta = +np.pi
|
| 277 |
-
K = CREASE_STIFFNESS * np.linalg.norm(pos2d[ej] - pos2d[ei])
|
| 278 |
-
elif asgn == 'V':
|
| 279 |
-
full_theta = -np.pi
|
| 280 |
-
K = CREASE_STIFFNESS * np.linalg.norm(pos2d[ej] - pos2d[ei])
|
| 281 |
-
else: # 'F' panel
|
| 282 |
-
full_theta = 0.0
|
| 283 |
-
K = PANEL_STIFFNESS * np.linalg.norm(pos2d[ej] - pos2d[ei])
|
| 284 |
-
|
| 285 |
-
crease_a.append(ei)
|
| 286 |
-
crease_b.append(ej)
|
| 287 |
-
crease_c.append(c_node)
|
| 288 |
-
crease_d.append(d_node)
|
| 289 |
-
crease_assign.append(asgn)
|
| 290 |
-
crease_full_theta.append(full_theta)
|
| 291 |
-
crease_K.append(K)
|
| 292 |
-
|
| 293 |
-
self._crease_a = np.array(crease_a, dtype=np.int32)
|
| 294 |
-
self._crease_b = np.array(crease_b, dtype=np.int32)
|
| 295 |
-
self._crease_c = np.array(crease_c, dtype=np.int32)
|
| 296 |
-
self._crease_d = np.array(crease_d, dtype=np.int32)
|
| 297 |
-
self._crease_assign = crease_assign
|
| 298 |
-
self._crease_full_theta = np.array(crease_full_theta, dtype=np.float64)
|
| 299 |
-
self._crease_K = np.array(crease_K, dtype=np.float64)
|
| 300 |
-
self._crease_target = np.zeros(len(crease_a), dtype=np.float64)
|
| 301 |
-
|
| 302 |
-
# ── Physics ───────────────────────────────────────────────────────────────
|
| 303 |
-
|
| 304 |
-
def _beam_forces(self) -> np.ndarray:
|
| 305 |
-
"""Vectorized axial spring forces for all beams."""
|
| 306 |
-
n = len(self.pos)
|
| 307 |
-
forces = np.zeros((n, 3), dtype=np.float64)
|
| 308 |
-
|
| 309 |
-
pi = self.pos[self._beam_i]
|
| 310 |
-
pj = self.pos[self._beam_j]
|
| 311 |
-
diff = pj - pi
|
| 312 |
-
lengths = np.linalg.norm(diff, axis=1, keepdims=True)
|
| 313 |
-
lengths = np.maximum(lengths, 1e-12)
|
| 314 |
-
unit = diff / lengths
|
| 315 |
-
|
| 316 |
-
stretch = lengths[:, 0] - self._beam_rest
|
| 317 |
-
F_mag = self._beam_K * stretch # scalar force magnitude
|
| 318 |
-
|
| 319 |
-
# Damping along the edge
|
| 320 |
-
vi = self.vel[self._beam_i]
|
| 321 |
-
vj = self.vel[self._beam_j]
|
| 322 |
-
rel_vel = np.sum((vj - vi) * unit, axis=1)
|
| 323 |
-
damp_mag = PERCENT_DAMPING * rel_vel
|
| 324 |
-
F_total = (F_mag + damp_mag)[:, None] * unit
|
| 325 |
-
|
| 326 |
-
np.add.at(forces, self._beam_i, F_total)
|
| 327 |
-
np.add.at(forces, self._beam_j, -F_total)
|
| 328 |
-
return forces
|
| 329 |
-
|
| 330 |
-
def _crease_forces(self) -> np.ndarray:
|
| 331 |
-
"""Torsional spring forces for all crease/panel edges (Python loop)."""
|
| 332 |
-
n = len(self.pos)
|
| 333 |
-
forces = np.zeros((n, 3), dtype=np.float64)
|
| 334 |
-
|
| 335 |
-
pos = self.pos
|
| 336 |
-
for idx in range(len(self._crease_a)):
|
| 337 |
-
a = self._crease_a[idx]
|
| 338 |
-
b = self._crease_b[idx]
|
| 339 |
-
c = self._crease_c[idx]
|
| 340 |
-
d = self._crease_d[idx]
|
| 341 |
-
K = self._crease_K[idx]
|
| 342 |
-
target = self._crease_target[idx]
|
| 343 |
-
|
| 344 |
-
pa, pb, pc, pd = pos[a], pos[b], pos[c], pos[d]
|
| 345 |
-
|
| 346 |
-
edge_vec = pb - pa
|
| 347 |
-
edge_len = np.linalg.norm(edge_vec)
|
| 348 |
-
if edge_len < 1e-12:
|
| 349 |
-
continue
|
| 350 |
-
edge_dir = edge_vec / edge_len
|
| 351 |
-
|
| 352 |
-
# Face normals
|
| 353 |
-
n1_raw = np.cross(pb - pa, pc - pa)
|
| 354 |
-
n2_raw = np.cross(pa - pb, pd - pb)
|
| 355 |
-
n1_len = np.linalg.norm(n1_raw)
|
| 356 |
-
n2_len = np.linalg.norm(n2_raw)
|
| 357 |
-
if n1_len < 1e-12 or n2_len < 1e-12:
|
| 358 |
-
continue
|
| 359 |
-
n1 = n1_raw / n1_len
|
| 360 |
-
n2 = n2_raw / n2_len
|
| 361 |
-
|
| 362 |
-
# Dihedral angle via atan2
|
| 363 |
-
cross_n = np.cross(n1, n2)
|
| 364 |
-
sin_theta = np.dot(cross_n, edge_dir)
|
| 365 |
-
cos_theta = np.dot(n1, n2)
|
| 366 |
-
theta = np.arctan2(sin_theta, cos_theta)
|
| 367 |
-
|
| 368 |
-
delta = theta - target
|
| 369 |
-
torque = -K * delta
|
| 370 |
-
|
| 371 |
-
# Moment arms (perpendicular distance from c, d to crease line)
|
| 372 |
-
vc = pc - pa
|
| 373 |
-
vd = pd - pa
|
| 374 |
-
vc_perp = vc - np.dot(vc, edge_dir) * edge_dir
|
| 375 |
-
vd_perp = vd - np.dot(vd, edge_dir) * edge_dir
|
| 376 |
-
h_c = np.linalg.norm(vc_perp)
|
| 377 |
-
h_d = np.linalg.norm(vd_perp)
|
| 378 |
-
if h_c < 1e-12 or h_d < 1e-12:
|
| 379 |
-
continue
|
| 380 |
-
|
| 381 |
-
# Forces on opposite nodes
|
| 382 |
-
F_c = (torque / h_c) * n1
|
| 383 |
-
F_d = -(torque / h_d) * n2
|
| 384 |
-
|
| 385 |
-
# Reaction on crease nodes (moment balance)
|
| 386 |
-
proj_c = np.dot(pc - pa, edge_dir)
|
| 387 |
-
proj_d = np.dot(pd - pa, edge_dir)
|
| 388 |
-
coef_c_a = 1.0 - proj_c / edge_len
|
| 389 |
-
coef_c_b = proj_c / edge_len
|
| 390 |
-
coef_d_a = 1.0 - proj_d / edge_len
|
| 391 |
-
coef_d_b = proj_d / edge_len
|
| 392 |
-
|
| 393 |
-
forces[c] += F_c
|
| 394 |
-
forces[d] += F_d
|
| 395 |
-
forces[a] -= coef_c_a * F_c + coef_d_a * F_d
|
| 396 |
-
forces[b] -= coef_c_b * F_c + coef_d_b * F_d
|
| 397 |
-
|
| 398 |
-
return forces
|
| 399 |
-
|
| 400 |
-
def _euler_step(self) -> None:
|
| 401 |
-
forces = self._beam_forces() + self._crease_forces()
|
| 402 |
-
accel = forces / self.mass[:, None]
|
| 403 |
-
vel_new = self.vel + accel * DT
|
| 404 |
-
vel_new *= (1.0 - PERCENT_DAMPING * DT)
|
| 405 |
-
self.pos += vel_new * DT
|
| 406 |
-
self.vel = vel_new
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
trainer/__init__.py
DELETED
|
File without changes
|
trainer/mock_env.py
DELETED
|
@@ -1,249 +0,0 @@
|
|
| 1 |
-
"""
|
| 2 |
-
Mock origami environment for trainer development.
|
| 3 |
-
|
| 4 |
-
Returns fake PaperState responses so we can iterate on the GRPO loop
|
| 5 |
-
without waiting for the real physics engine. The mock applies geometric
|
| 6 |
-
transforms (vertex rotations around fold lines) but skips energy/strain
|
| 7 |
-
computation — those return plausible dummy values.
|
| 8 |
-
"""
|
| 9 |
-
|
| 10 |
-
import math
|
| 11 |
-
import numpy as np
|
| 12 |
-
from dataclasses import dataclass, field
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
@dataclass
|
| 16 |
-
class Material:
|
| 17 |
-
name: str = "paper"
|
| 18 |
-
thickness_mm: float = 0.1
|
| 19 |
-
youngs_modulus_gpa: float = 2.0
|
| 20 |
-
max_strain: float = 0.03 # 3%
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
@dataclass
|
| 24 |
-
class PaperState:
|
| 25 |
-
vertices: np.ndarray # (N, 3)
|
| 26 |
-
edges: np.ndarray # (E, 2)
|
| 27 |
-
faces: list[list[int]]
|
| 28 |
-
assignments: list[str] # M/V/B per edge
|
| 29 |
-
fold_angles: np.ndarray # (E,) degrees
|
| 30 |
-
|
| 31 |
-
rest_lengths: np.ndarray # (E,)
|
| 32 |
-
strain: np.ndarray # (N,)
|
| 33 |
-
energy: float = 0.0
|
| 34 |
-
|
| 35 |
-
face_orders: list[tuple] = field(default_factory=list)
|
| 36 |
-
num_layers: int = 1
|
| 37 |
-
|
| 38 |
-
material: Material = field(default_factory=Material)
|
| 39 |
-
|
| 40 |
-
bounding_box: np.ndarray = field(default_factory=lambda: np.array([1.0, 1.0, 0.0]))
|
| 41 |
-
deployment_ratio: float = 1.0
|
| 42 |
-
is_valid: bool = True
|
| 43 |
-
kawasaki_violation: float = 0.0
|
| 44 |
-
maekawa_violation: float = 0.0
|
| 45 |
-
self_intersections: int = 0
|
| 46 |
-
|
| 47 |
-
def to_dict(self) -> dict:
|
| 48 |
-
return {
|
| 49 |
-
"width": float(self.bounding_box[0]),
|
| 50 |
-
"height": float(self.bounding_box[1]),
|
| 51 |
-
"material": {
|
| 52 |
-
"name": self.material.name,
|
| 53 |
-
"thickness_mm": self.material.thickness_mm,
|
| 54 |
-
"youngs_modulus_gpa": self.material.youngs_modulus_gpa,
|
| 55 |
-
},
|
| 56 |
-
"vertices": self.vertices.tolist(),
|
| 57 |
-
"edges": self.edges.tolist(),
|
| 58 |
-
"assignments": self.assignments,
|
| 59 |
-
"fold_angles": self.fold_angles.tolist(),
|
| 60 |
-
"num_layers_at_center": self.num_layers,
|
| 61 |
-
"bounding_box": {
|
| 62 |
-
"x": float(self.bounding_box[0]),
|
| 63 |
-
"y": float(self.bounding_box[1]),
|
| 64 |
-
"z": float(self.bounding_box[2]),
|
| 65 |
-
},
|
| 66 |
-
}
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
def create_flat_sheet(width: float = 1.0, height: float = 1.0,
|
| 70 |
-
material: Material | None = None) -> PaperState:
|
| 71 |
-
"""Create a flat rectangular sheet with 4 vertices, 5 edges (incl diagonal), 2 faces."""
|
| 72 |
-
verts = np.array([
|
| 73 |
-
[0, 0, 0],
|
| 74 |
-
[width, 0, 0],
|
| 75 |
-
[width, height, 0],
|
| 76 |
-
[0, height, 0],
|
| 77 |
-
], dtype=float)
|
| 78 |
-
|
| 79 |
-
edges = np.array([
|
| 80 |
-
[0, 1], [1, 2], [2, 3], [3, 0], # boundary
|
| 81 |
-
[0, 2], # diagonal
|
| 82 |
-
], dtype=int)
|
| 83 |
-
|
| 84 |
-
faces = [[0, 1, 2], [0, 2, 3]]
|
| 85 |
-
assignments = ["B", "B", "B", "B", "F"] # boundary + flat diagonal
|
| 86 |
-
fold_angles = np.zeros(len(edges))
|
| 87 |
-
rest_lengths = np.array([np.linalg.norm(verts[e[1]] - verts[e[0]]) for e in edges])
|
| 88 |
-
strain = np.zeros(len(verts))
|
| 89 |
-
|
| 90 |
-
mat = material or Material()
|
| 91 |
-
return PaperState(
|
| 92 |
-
vertices=verts, edges=edges, faces=faces,
|
| 93 |
-
assignments=assignments, fold_angles=fold_angles,
|
| 94 |
-
rest_lengths=rest_lengths, strain=strain,
|
| 95 |
-
material=mat,
|
| 96 |
-
bounding_box=np.array([width, height, 0.0]),
|
| 97 |
-
)
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
def _rotate_points(points: np.ndarray, axis_point: np.ndarray,
|
| 101 |
-
axis_dir: np.ndarray, angle_rad: float) -> np.ndarray:
|
| 102 |
-
"""Rotate points around an arbitrary axis using Rodrigues' formula."""
|
| 103 |
-
k = axis_dir / np.linalg.norm(axis_dir)
|
| 104 |
-
translated = points - axis_point
|
| 105 |
-
cos_a = math.cos(angle_rad)
|
| 106 |
-
sin_a = math.sin(angle_rad)
|
| 107 |
-
rotated = (translated * cos_a +
|
| 108 |
-
np.cross(k, translated) * sin_a +
|
| 109 |
-
k * (np.dot(translated, k).reshape(-1, 1)) * (1 - cos_a))
|
| 110 |
-
return rotated + axis_point
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
def apply_fold_mock(state: PaperState, fold: dict) -> tuple[PaperState, str | None]:
|
| 114 |
-
"""
|
| 115 |
-
Apply a single fold operation to the paper state (mock version).
|
| 116 |
-
|
| 117 |
-
fold = {
|
| 118 |
-
"type": "valley" | "mountain",
|
| 119 |
-
"line": {"start": [x, y], "end": [x, y]},
|
| 120 |
-
"angle": 0-180
|
| 121 |
-
}
|
| 122 |
-
|
| 123 |
-
Returns (new_state, error_string_or_None).
|
| 124 |
-
"""
|
| 125 |
-
fold_type = fold.get("type", "valley")
|
| 126 |
-
line = fold.get("line", {})
|
| 127 |
-
angle_deg = fold.get("angle", 180)
|
| 128 |
-
|
| 129 |
-
start = np.array(line.get("start", [0, 0]), dtype=float)
|
| 130 |
-
end = np.array(line.get("end", [0, 0]), dtype=float)
|
| 131 |
-
|
| 132 |
-
if np.allclose(start, end):
|
| 133 |
-
return state, "Fold line has zero length"
|
| 134 |
-
|
| 135 |
-
if fold_type not in ("valley", "mountain"):
|
| 136 |
-
return state, f"Unknown fold type: {fold_type}"
|
| 137 |
-
|
| 138 |
-
# angle=0 means "no fold" — return unchanged copy
|
| 139 |
-
if angle_deg == 0:
|
| 140 |
-
return PaperState(
|
| 141 |
-
vertices=state.vertices.copy(), edges=state.edges.copy(),
|
| 142 |
-
faces=[f[:] for f in state.faces],
|
| 143 |
-
assignments=state.assignments[:], fold_angles=state.fold_angles.copy(),
|
| 144 |
-
rest_lengths=state.rest_lengths.copy(), strain=state.strain.copy(),
|
| 145 |
-
energy=state.energy, face_orders=state.face_orders[:],
|
| 146 |
-
num_layers=state.num_layers, material=state.material,
|
| 147 |
-
bounding_box=state.bounding_box.copy(),
|
| 148 |
-
deployment_ratio=state.deployment_ratio, is_valid=state.is_valid,
|
| 149 |
-
kawasaki_violation=state.kawasaki_violation,
|
| 150 |
-
maekawa_violation=state.maekawa_violation,
|
| 151 |
-
self_intersections=state.self_intersections,
|
| 152 |
-
), None
|
| 153 |
-
|
| 154 |
-
if not (0 < angle_deg <= 180):
|
| 155 |
-
return state, f"Angle must be in (0, 180], got {angle_deg}"
|
| 156 |
-
|
| 157 |
-
# Fold direction: valley folds up (+z), mountain folds down (-z)
|
| 158 |
-
sign = 1.0 if fold_type == "valley" else -1.0
|
| 159 |
-
angle_rad = sign * math.radians(angle_deg)
|
| 160 |
-
|
| 161 |
-
# Determine which vertices are on the "folding" side of the line
|
| 162 |
-
line_dir_2d = end - start
|
| 163 |
-
normal_2d = np.array([-line_dir_2d[1], line_dir_2d[0]]) # perpendicular
|
| 164 |
-
|
| 165 |
-
new_verts = state.vertices.copy()
|
| 166 |
-
for i, v in enumerate(new_verts):
|
| 167 |
-
point_2d = v[:2] - start
|
| 168 |
-
side = np.dot(point_2d, normal_2d)
|
| 169 |
-
if side > 1e-9: # on the positive side → rotate
|
| 170 |
-
axis_point = np.array([start[0], start[1], 0.0])
|
| 171 |
-
axis_dir = np.array([line_dir_2d[0], line_dir_2d[1], 0.0])
|
| 172 |
-
new_verts[i] = _rotate_points(
|
| 173 |
-
v.reshape(1, -1), axis_point, axis_dir, angle_rad
|
| 174 |
-
).flatten()
|
| 175 |
-
|
| 176 |
-
# Update bounding box (clamp near-zero values from floating point)
|
| 177 |
-
bb = np.ptp(new_verts, axis=0) # max - min per axis
|
| 178 |
-
bb = np.where(np.abs(bb) < 1e-10, 0.0, bb)
|
| 179 |
-
# Add minimum thickness per layer (material thickness)
|
| 180 |
-
thickness = state.material.thickness_mm / 1000.0 # convert mm to m
|
| 181 |
-
num_layers = state.num_layers + 1
|
| 182 |
-
bb[2] = max(bb[2], thickness * num_layers)
|
| 183 |
-
|
| 184 |
-
# Mock strain: small random value per vertex
|
| 185 |
-
new_strain = np.random.uniform(0, 0.01, len(new_verts))
|
| 186 |
-
|
| 187 |
-
# Mock energy
|
| 188 |
-
new_energy = state.energy + 0.1 * angle_deg / 180.0
|
| 189 |
-
|
| 190 |
-
# Update assignments — add new edge as M or V
|
| 191 |
-
new_assignments = state.assignments.copy()
|
| 192 |
-
|
| 193 |
-
# Deployment ratio estimate: each full fold (180°) halves the area in one direction.
|
| 194 |
-
# Partial folds reduce proportionally. This is a mock approximation —
|
| 195 |
-
# the real engine will compute from actual face overlaps.
|
| 196 |
-
fold_factor = angle_deg / 180.0 # 1.0 for full fold, 0.5 for 90°, etc.
|
| 197 |
-
deploy_ratio = state.deployment_ratio * (1.0 - 0.5 * fold_factor)
|
| 198 |
-
|
| 199 |
-
new_state = PaperState(
|
| 200 |
-
vertices=new_verts,
|
| 201 |
-
edges=state.edges.copy(),
|
| 202 |
-
faces=state.faces.copy(),
|
| 203 |
-
assignments=new_assignments,
|
| 204 |
-
fold_angles=state.fold_angles.copy(),
|
| 205 |
-
rest_lengths=state.rest_lengths.copy(),
|
| 206 |
-
strain=new_strain,
|
| 207 |
-
energy=new_energy,
|
| 208 |
-
material=state.material,
|
| 209 |
-
bounding_box=bb,
|
| 210 |
-
deployment_ratio=deploy_ratio,
|
| 211 |
-
num_layers=state.num_layers + 1,
|
| 212 |
-
is_valid=True,
|
| 213 |
-
kawasaki_violation=0.0,
|
| 214 |
-
maekawa_violation=0.0,
|
| 215 |
-
self_intersections=0,
|
| 216 |
-
)
|
| 217 |
-
return new_state, None
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
def execute_fold_strategy(strategy_fn, paper_state: PaperState,
|
| 221 |
-
max_folds: int = 20) -> tuple[PaperState, list[dict], str | None]:
|
| 222 |
-
"""
|
| 223 |
-
Execute a fold_strategy function against the mock environment.
|
| 224 |
-
|
| 225 |
-
Returns (final_state, applied_folds, error_or_None).
|
| 226 |
-
"""
|
| 227 |
-
state_dict = paper_state.to_dict()
|
| 228 |
-
try:
|
| 229 |
-
folds = strategy_fn(state_dict)
|
| 230 |
-
except Exception as e:
|
| 231 |
-
return paper_state, [], f"Strategy function raised: {e}"
|
| 232 |
-
|
| 233 |
-
if not isinstance(folds, list):
|
| 234 |
-
return paper_state, [], "Strategy must return a list of fold dicts"
|
| 235 |
-
|
| 236 |
-
applied = []
|
| 237 |
-
current = paper_state
|
| 238 |
-
for i, fold in enumerate(folds):
|
| 239 |
-
if i >= max_folds:
|
| 240 |
-
break
|
| 241 |
-
if not isinstance(fold, dict):
|
| 242 |
-
return current, applied, f"Fold {i} is not a dict"
|
| 243 |
-
|
| 244 |
-
current, error = apply_fold_mock(current, fold)
|
| 245 |
-
if error:
|
| 246 |
-
return current, applied, f"Fold {i} failed: {error}"
|
| 247 |
-
applied.append(fold)
|
| 248 |
-
|
| 249 |
-
return current, applied, None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
trainer/prompts.py
DELETED
|
@@ -1,211 +0,0 @@
|
|
| 1 |
-
"""
|
| 2 |
-
Prompt templates for origami fold strategy generation.
|
| 3 |
-
|
| 4 |
-
Inspired by SpatialThinker (arXiv 2511.07403): the model must produce
|
| 5 |
-
a structured spatial representation BEFORE generating code.
|
| 6 |
-
|
| 7 |
-
Output format (4 stages):
|
| 8 |
-
<observe> — Describe the paper geometry and constraints
|
| 9 |
-
<plan> — Structured fold plan with coordinates and reasoning
|
| 10 |
-
<code> — The fold_strategy() function
|
| 11 |
-
<verify> — Predict expected outcome (deployment ratio, fold count)
|
| 12 |
-
|
| 13 |
-
Dense rewards check each stage independently, not just code execution.
|
| 14 |
-
"""
|
| 15 |
-
|
| 16 |
-
# ---------------------------------------------------------------------------
|
| 17 |
-
# System prompt — defines the structured output format
|
| 18 |
-
# ---------------------------------------------------------------------------
|
| 19 |
-
|
| 20 |
-
SYSTEM_PROMPT = """\
|
| 21 |
-
You are an origami engineer specializing in computational fold design.
|
| 22 |
-
You solve folding tasks by reasoning spatially about paper geometry.
|
| 23 |
-
|
| 24 |
-
You MUST respond in exactly this 4-stage format:
|
| 25 |
-
|
| 26 |
-
<observe>
|
| 27 |
-
Describe the paper: dimensions, material, coordinate system.
|
| 28 |
-
Identify key geometric features (center, edges, diagonals, symmetry axes).
|
| 29 |
-
Note constraints (max strain, max folds, target ratio).
|
| 30 |
-
</observe>
|
| 31 |
-
|
| 32 |
-
<plan>
|
| 33 |
-
{
|
| 34 |
-
"strategy": "description of overall approach",
|
| 35 |
-
"folds": [
|
| 36 |
-
{
|
| 37 |
-
"description": "what this fold does",
|
| 38 |
-
"type": "valley or mountain",
|
| 39 |
-
"line_start": [x, y],
|
| 40 |
-
"line_end": [x, y],
|
| 41 |
-
"angle": 180,
|
| 42 |
-
"reasoning": "why these coordinates"
|
| 43 |
-
}
|
| 44 |
-
],
|
| 45 |
-
"expected_ratio": 0.5,
|
| 46 |
-
"expected_folds": 1
|
| 47 |
-
}
|
| 48 |
-
</plan>
|
| 49 |
-
|
| 50 |
-
<code>
|
| 51 |
-
```python
|
| 52 |
-
def fold_strategy(paper_state):
|
| 53 |
-
# Implementation matching the plan above
|
| 54 |
-
return [...]
|
| 55 |
-
```
|
| 56 |
-
</code>
|
| 57 |
-
|
| 58 |
-
<verify>
|
| 59 |
-
Expected deployment ratio: X.XX
|
| 60 |
-
Expected fold count: N
|
| 61 |
-
Expected max strain: X.XXXX
|
| 62 |
-
Potential issues: ...
|
| 63 |
-
</verify>
|
| 64 |
-
|
| 65 |
-
Rules:
|
| 66 |
-
- Only use native Python (no imports except math, itertools, functools)
|
| 67 |
-
- Each fold: {"type": "valley"|"mountain", "line": {"start": [x,y], "end": [x,y]}, "angle": 0-180}
|
| 68 |
-
- Fold lines must cross the paper boundary (intersect at least 2 edges)
|
| 69 |
-
- Valley = fold toward you (+Z), Mountain = fold away (-Z)
|
| 70 |
-
- angle=180 = fully folded, smaller = partial fold
|
| 71 |
-
- Each fold changes the geometry — later folds operate on already-folded paper
|
| 72 |
-
- Fewer folds is better (efficiency matters)
|
| 73 |
-
- Respect material strain limits\
|
| 74 |
-
"""
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
# ---------------------------------------------------------------------------
|
| 78 |
-
# Task templates — each includes spatial context
|
| 79 |
-
# ---------------------------------------------------------------------------
|
| 80 |
-
|
| 81 |
-
TASK_TEMPLATES = {
|
| 82 |
-
"half_fold": {
|
| 83 |
-
"name": "half_fold",
|
| 84 |
-
"prompt": """\
|
| 85 |
-
TASK: Fold a {width}m x {height}m {material} sheet in half to minimize one dimension.
|
| 86 |
-
|
| 87 |
-
PAPER GEOMETRY:
|
| 88 |
-
Corners: (0,0), ({width},0), ({width},{height}), (0,{height})
|
| 89 |
-
Center: ({cx},{cy})
|
| 90 |
-
Horizontal midline: y={cy} from (0,{cy}) to ({width},{cy})
|
| 91 |
-
Vertical midline: x={cx} from ({cx},0) to ({cx},{height})
|
| 92 |
-
Diagonals: (0,0)→({width},{height}) and ({width},0)→(0,{height})
|
| 93 |
-
|
| 94 |
-
MATERIAL: {material} (thickness: {thickness_mm}mm, max strain: {max_strain_pct}%)
|
| 95 |
-
CONSTRAINTS: Maximum {max_folds} fold operations.
|
| 96 |
-
TARGET: Deployment ratio <= 0.5""",
|
| 97 |
-
"target_ratio": 0.5,
|
| 98 |
-
"max_folds": 3,
|
| 99 |
-
},
|
| 100 |
-
|
| 101 |
-
"letter_fold": {
|
| 102 |
-
"name": "letter_fold",
|
| 103 |
-
"prompt": """\
|
| 104 |
-
TASK: Fold a {width}m x {height}m {material} sheet into thirds (like a letter).
|
| 105 |
-
|
| 106 |
-
PAPER GEOMETRY:
|
| 107 |
-
Corners: (0,0), ({width},0), ({width},{height}), (0,{height})
|
| 108 |
-
Third lines: y={t1:.4f} and y={t2:.4f}
|
| 109 |
-
Center: ({cx},{cy})
|
| 110 |
-
|
| 111 |
-
MATERIAL: {material} (thickness: {thickness_mm}mm, max strain: {max_strain_pct}%)
|
| 112 |
-
CONSTRAINTS: Maximum {max_folds} fold operations.
|
| 113 |
-
TARGET: Deployment ratio <= 0.33""",
|
| 114 |
-
"target_ratio": 0.33,
|
| 115 |
-
"max_folds": 5,
|
| 116 |
-
},
|
| 117 |
-
|
| 118 |
-
"solar_panel": {
|
| 119 |
-
"name": "solar_panel",
|
| 120 |
-
"prompt": """\
|
| 121 |
-
TASK: Fold a {width}m x {height}m Mylar sheet to minimize packed volume for a solar panel.
|
| 122 |
-
The folded panel must be deployable (unfold cleanly to near-original area).
|
| 123 |
-
|
| 124 |
-
PAPER GEOMETRY:
|
| 125 |
-
Corners: (0,0), ({width},0), ({width},{height}), (0,{height})
|
| 126 |
-
Center: ({cx},{cy})
|
| 127 |
-
Area: {area}m²
|
| 128 |
-
|
| 129 |
-
MATERIAL: Mylar (thickness: 0.05mm, Young's modulus: 4 GPa, max strain: 3%)
|
| 130 |
-
CONSTRAINTS:
|
| 131 |
-
- Maximum {max_folds} fold operations
|
| 132 |
-
- Must pack into bounding box <= 15cm x 15cm x 5cm
|
| 133 |
-
- No self-intersections
|
| 134 |
-
|
| 135 |
-
TARGET: Deployment ratio <= 0.05 (95% area reduction)
|
| 136 |
-
|
| 137 |
-
HINT: Tessellated patterns (alternating M/V folds in a grid) achieve high
|
| 138 |
-
compaction with single-DOF deployment. Consider dividing the sheet into
|
| 139 |
-
a regular grid of panels.""",
|
| 140 |
-
"target_ratio": 0.05,
|
| 141 |
-
"max_folds": 20,
|
| 142 |
-
},
|
| 143 |
-
|
| 144 |
-
"stent_fold": {
|
| 145 |
-
"name": "stent_fold",
|
| 146 |
-
"prompt": """\
|
| 147 |
-
TASK: Fold a {width}m x {height}m Nitinol sheet into a compact cylinder for a medical stent.
|
| 148 |
-
|
| 149 |
-
PAPER GEOMETRY:
|
| 150 |
-
Corners: (0,0), ({width},0), ({width},{height}), (0,{height})
|
| 151 |
-
Center: ({cx},{cy})
|
| 152 |
-
|
| 153 |
-
MATERIAL: Nitinol (thickness: 0.1mm, Young's modulus: 75 GPa, max strain: 8%)
|
| 154 |
-
CONSTRAINTS:
|
| 155 |
-
- Maximum {max_folds} fold operations
|
| 156 |
-
- Compressed diameter: 3mm, Deployed diameter: 10mm
|
| 157 |
-
|
| 158 |
-
TARGET: Deployment ratio <= 0.1""",
|
| 159 |
-
"target_ratio": 0.1,
|
| 160 |
-
"max_folds": 15,
|
| 161 |
-
},
|
| 162 |
-
}
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
# ---------------------------------------------------------------------------
|
| 166 |
-
# Config and builders
|
| 167 |
-
# ---------------------------------------------------------------------------
|
| 168 |
-
|
| 169 |
-
TASK_CONFIGS = {
|
| 170 |
-
"half_fold": {
|
| 171 |
-
"width": 1.0, "height": 1.0, "material": "paper",
|
| 172 |
-
"thickness_mm": 0.1, "max_strain_pct": 3, "max_folds": 3,
|
| 173 |
-
},
|
| 174 |
-
"letter_fold": {
|
| 175 |
-
"width": 1.0, "height": 1.0, "material": "paper",
|
| 176 |
-
"thickness_mm": 0.1, "max_strain_pct": 3, "max_folds": 5,
|
| 177 |
-
},
|
| 178 |
-
"solar_panel": {
|
| 179 |
-
"width": 1.0, "height": 1.0, "material": "mylar",
|
| 180 |
-
"thickness_mm": 0.05, "max_strain_pct": 3, "max_folds": 20,
|
| 181 |
-
},
|
| 182 |
-
"stent_fold": {
|
| 183 |
-
"width": 0.1, "height": 0.03, "material": "nitinol",
|
| 184 |
-
"thickness_mm": 0.1, "max_strain_pct": 8, "max_folds": 15,
|
| 185 |
-
},
|
| 186 |
-
}
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
def build_prompt(task_name: str = "half_fold", **overrides) -> str:
|
| 190 |
-
"""Build a complete user prompt for a given task."""
|
| 191 |
-
task = TASK_TEMPLATES[task_name]
|
| 192 |
-
config = {**TASK_CONFIGS[task_name], **overrides}
|
| 193 |
-
|
| 194 |
-
# Add computed geometry values
|
| 195 |
-
w = config["width"]
|
| 196 |
-
h = config["height"]
|
| 197 |
-
config["cx"] = w / 2
|
| 198 |
-
config["cy"] = h / 2
|
| 199 |
-
config["area"] = w * h
|
| 200 |
-
config["t1"] = h / 3
|
| 201 |
-
config["t2"] = 2 * h / 3
|
| 202 |
-
|
| 203 |
-
return task["prompt"].format(**config)
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
def get_task_target_ratio(task_name: str) -> float:
|
| 207 |
-
return TASK_TEMPLATES[task_name]["target_ratio"]
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
def get_task_max_folds(task_name: str) -> int:
|
| 211 |
-
return TASK_TEMPLATES[task_name]["max_folds"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
trainer/rewards.py
DELETED
|
@@ -1,713 +0,0 @@
|
|
| 1 |
-
"""
|
| 2 |
-
Reward functions for origami GRPO training.
|
| 3 |
-
|
| 4 |
-
SpatialThinker-style dense rewards (arXiv 2511.07403):
|
| 5 |
-
1. format_reward (0.10) — All 4 tags present, valid JSON plan, valid function
|
| 6 |
-
2. spatial_reward (0.20) — Fold coordinates in plan are within bounds, lines valid
|
| 7 |
-
3. execution_reward (0.50) — Physical validity + fold quality (code execution)
|
| 8 |
-
4. consistency_reward(0.20) — Plan matches code, verify matches actual results
|
| 9 |
-
|
| 10 |
-
Plus legacy rewards for backwards compatibility:
|
| 11 |
-
- code_valid, physically_valid, fold_quality
|
| 12 |
-
|
| 13 |
-
Lexicographic gating: if code doesn't parse, downstream rewards are 0.
|
| 14 |
-
"""
|
| 15 |
-
|
| 16 |
-
import ast
|
| 17 |
-
import re
|
| 18 |
-
import sys
|
| 19 |
-
import json
|
| 20 |
-
import math
|
| 21 |
-
import traceback
|
| 22 |
-
from typing import Callable
|
| 23 |
-
|
| 24 |
-
# Use real engine if available, fall back to mock
|
| 25 |
-
try:
|
| 26 |
-
from engine.paper import Paper
|
| 27 |
-
from engine.fold_engine import execute_fold_strategy
|
| 28 |
-
from engine.materials import Material, get_material
|
| 29 |
-
from engine.validation import validate_paper
|
| 30 |
-
from engine.metrics import compute_metrics
|
| 31 |
-
|
| 32 |
-
def _create_sheet(width, height, material):
|
| 33 |
-
return Paper.create_flat_sheet(width, height, material)
|
| 34 |
-
|
| 35 |
-
USE_REAL_ENGINE = True
|
| 36 |
-
except ImportError:
|
| 37 |
-
from trainer.mock_env import (
|
| 38 |
-
PaperState as Paper, create_flat_sheet, execute_fold_strategy, Material
|
| 39 |
-
)
|
| 40 |
-
|
| 41 |
-
def _create_sheet(width, height, material):
|
| 42 |
-
return create_flat_sheet(width, height, material)
|
| 43 |
-
|
| 44 |
-
def validate_paper(p):
|
| 45 |
-
from types import SimpleNamespace
|
| 46 |
-
return SimpleNamespace(
|
| 47 |
-
is_valid=p.is_valid, kawasaki_valid=True, maekawa_valid=True,
|
| 48 |
-
kawasaki_violation=p.kawasaki_violation,
|
| 49 |
-
maekawa_violation=p.maekawa_violation,
|
| 50 |
-
self_intersection_count=p.self_intersections,
|
| 51 |
-
)
|
| 52 |
-
|
| 53 |
-
def compute_metrics(p, orig):
|
| 54 |
-
return {
|
| 55 |
-
"deployment_ratio": p.deployment_ratio,
|
| 56 |
-
"fold_count": sum(1 for a in p.assignments if a in ("M", "V")),
|
| 57 |
-
"max_strain": float(p.strain.max()) if len(p.strain) > 0 else 0.0,
|
| 58 |
-
}
|
| 59 |
-
|
| 60 |
-
USE_REAL_ENGINE = False
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
# ---------------------------------------------------------------------------
|
| 64 |
-
# Helpers
|
| 65 |
-
# ---------------------------------------------------------------------------
|
| 66 |
-
|
| 67 |
-
def extract_function(text: str) -> str | None:
|
| 68 |
-
"""Extract fold_strategy() from <code> blocks or triple-backtick code blocks."""
|
| 69 |
-
# Try <code> block first (SpatialThinker format)
|
| 70 |
-
code_match = re.search(r'<code>(.*?)</code>', text, re.DOTALL)
|
| 71 |
-
if code_match:
|
| 72 |
-
code_block = code_match.group(1).strip()
|
| 73 |
-
elif text.count("```") >= 2:
|
| 74 |
-
first = text.find("```") + 3
|
| 75 |
-
second = text.find("```", first)
|
| 76 |
-
code_block = text[first:second].strip()
|
| 77 |
-
else:
|
| 78 |
-
return None
|
| 79 |
-
|
| 80 |
-
code_block = code_block.removeprefix("```python\n").removeprefix("```python\r\n")
|
| 81 |
-
code_block = code_block.removeprefix("python\n").removeprefix("python\r\n")
|
| 82 |
-
code_block = code_block.rstrip("`").strip()
|
| 83 |
-
|
| 84 |
-
# Find the def statement
|
| 85 |
-
def_idx = code_block.find("def ")
|
| 86 |
-
if def_idx == -1:
|
| 87 |
-
return None
|
| 88 |
-
fx = code_block[def_idx:]
|
| 89 |
-
if fx.startswith("def fold_strategy("):
|
| 90 |
-
return fx
|
| 91 |
-
return None
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
def extract_section(text: str, tag: str) -> str | None:
|
| 95 |
-
"""Extract content between <tag>...</tag>."""
|
| 96 |
-
match = re.search(rf'<{tag}>(.*?)</{tag}>', text, re.DOTALL)
|
| 97 |
-
return match.group(1).strip() if match else None
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
def extract_plan_json(text: str) -> dict | None:
|
| 101 |
-
"""Extract and parse the JSON fold plan from <plan> block."""
|
| 102 |
-
plan_text = extract_section(text, "plan")
|
| 103 |
-
if not plan_text:
|
| 104 |
-
return None
|
| 105 |
-
try:
|
| 106 |
-
return json.loads(plan_text)
|
| 107 |
-
except json.JSONDecodeError:
|
| 108 |
-
# Try to find JSON object within the plan text
|
| 109 |
-
brace_start = plan_text.find("{")
|
| 110 |
-
brace_end = plan_text.rfind("}")
|
| 111 |
-
if brace_start >= 0 and brace_end > brace_start:
|
| 112 |
-
try:
|
| 113 |
-
return json.loads(plan_text[brace_start:brace_end + 1])
|
| 114 |
-
except json.JSONDecodeError:
|
| 115 |
-
pass
|
| 116 |
-
return None
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
def check_imports_stdlib_only(code: str) -> tuple[bool, str]:
|
| 120 |
-
"""Check that code only imports from Python stdlib."""
|
| 121 |
-
try:
|
| 122 |
-
tree = ast.parse(code)
|
| 123 |
-
except SyntaxError as e:
|
| 124 |
-
return False, f"syntax error: {e}"
|
| 125 |
-
|
| 126 |
-
ALLOWED_MODULES = {
|
| 127 |
-
"math", "itertools", "functools", "collections", "copy",
|
| 128 |
-
"operator", "typing", "random", "heapq", "bisect",
|
| 129 |
-
}
|
| 130 |
-
|
| 131 |
-
for node in ast.walk(tree):
|
| 132 |
-
if isinstance(node, ast.Import):
|
| 133 |
-
for alias in node.names:
|
| 134 |
-
root = alias.name.split(".")[0]
|
| 135 |
-
if root not in ALLOWED_MODULES:
|
| 136 |
-
return False, f"non-stdlib import: {alias.name}"
|
| 137 |
-
elif isinstance(node, ast.ImportFrom):
|
| 138 |
-
if node.module:
|
| 139 |
-
root = node.module.split(".")[0]
|
| 140 |
-
if root not in ALLOWED_MODULES:
|
| 141 |
-
return False, f"non-stdlib import: {node.module}"
|
| 142 |
-
|
| 143 |
-
return True, "ok"
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
def create_sandboxed_function(code: str) -> Callable:
|
| 147 |
-
"""
|
| 148 |
-
Execute the function code in a restricted namespace.
|
| 149 |
-
Returns the fold_strategy function object.
|
| 150 |
-
"""
|
| 151 |
-
allowed_builtins = {
|
| 152 |
-
"range", "len", "int", "float", "str", "list", "dict", "tuple",
|
| 153 |
-
"set", "bool", "abs", "min", "max", "sum", "sorted", "reversed",
|
| 154 |
-
"enumerate", "zip", "map", "filter", "round", "isinstance",
|
| 155 |
-
"True", "False", "None", "print",
|
| 156 |
-
}
|
| 157 |
-
safe_builtins = {k: __builtins__[k] if isinstance(__builtins__, dict)
|
| 158 |
-
else getattr(__builtins__, k)
|
| 159 |
-
for k in allowed_builtins
|
| 160 |
-
if (k in __builtins__ if isinstance(__builtins__, dict)
|
| 161 |
-
else hasattr(__builtins__, k))}
|
| 162 |
-
safe_builtins["__import__"] = __import__ # needed for stdlib imports
|
| 163 |
-
|
| 164 |
-
namespace = {"__builtins__": safe_builtins}
|
| 165 |
-
exec(code, namespace)
|
| 166 |
-
|
| 167 |
-
if "fold_strategy" not in namespace:
|
| 168 |
-
raise ValueError("No fold_strategy function defined")
|
| 169 |
-
|
| 170 |
-
return namespace["fold_strategy"]
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
# ---------------------------------------------------------------------------
|
| 174 |
-
# State for strategy execution
|
| 175 |
-
# ---------------------------------------------------------------------------
|
| 176 |
-
|
| 177 |
-
# Current task config (set by train.py before training starts)
|
| 178 |
-
if USE_REAL_ENGINE:
|
| 179 |
-
_default_material = get_material("paper")
|
| 180 |
-
else:
|
| 181 |
-
_default_material = Material()
|
| 182 |
-
|
| 183 |
-
_current_task = {
|
| 184 |
-
"width": 1.0,
|
| 185 |
-
"height": 1.0,
|
| 186 |
-
"material": _default_material,
|
| 187 |
-
"target_ratio": 0.5,
|
| 188 |
-
"max_folds": 3,
|
| 189 |
-
}
|
| 190 |
-
|
| 191 |
-
PRINT_EVERY = 5
|
| 192 |
-
_print_counter = 0
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
def set_task_config(width=1.0, height=1.0, material=None,
|
| 196 |
-
target_ratio=0.5, max_folds=3):
|
| 197 |
-
global _current_task
|
| 198 |
-
_current_task = {
|
| 199 |
-
"width": width,
|
| 200 |
-
"height": height,
|
| 201 |
-
"material": material or Material(),
|
| 202 |
-
"target_ratio": target_ratio,
|
| 203 |
-
"max_folds": max_folds,
|
| 204 |
-
}
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
# ---------------------------------------------------------------------------
|
| 208 |
-
# Reward 1: code_valid
|
| 209 |
-
# ---------------------------------------------------------------------------
|
| 210 |
-
|
| 211 |
-
def code_valid(completions, **kwargs) -> list[float]:
|
| 212 |
-
"""
|
| 213 |
-
Does the generated code parse as valid Python and produce a callable?
|
| 214 |
-
|
| 215 |
-
+1.0 — valid function that can be created
|
| 216 |
-
-0.5 — correct structure but exec/sandbox fails
|
| 217 |
-
-2.0 — no function found or syntax error
|
| 218 |
-
-20.0 — non-stdlib imports (heavy penalty)
|
| 219 |
-
"""
|
| 220 |
-
scores = []
|
| 221 |
-
for completion in completions:
|
| 222 |
-
response = completion[0]["content"]
|
| 223 |
-
function_code = extract_function(response)
|
| 224 |
-
|
| 225 |
-
if function_code is None:
|
| 226 |
-
scores.append(-2.0)
|
| 227 |
-
continue
|
| 228 |
-
|
| 229 |
-
ok, info = check_imports_stdlib_only(function_code)
|
| 230 |
-
if not ok:
|
| 231 |
-
if "syntax error" in info:
|
| 232 |
-
scores.append(-2.0)
|
| 233 |
-
else:
|
| 234 |
-
scores.append(-20.0) # non-stdlib imports
|
| 235 |
-
continue
|
| 236 |
-
|
| 237 |
-
try:
|
| 238 |
-
create_sandboxed_function(function_code)
|
| 239 |
-
scores.append(1.0)
|
| 240 |
-
except Exception:
|
| 241 |
-
scores.append(-0.5)
|
| 242 |
-
|
| 243 |
-
return scores
|
| 244 |
-
|
| 245 |
-
|
| 246 |
-
# ---------------------------------------------------------------------------
|
| 247 |
-
# Reward 2: physically_valid
|
| 248 |
-
# ---------------------------------------------------------------------------
|
| 249 |
-
|
| 250 |
-
def physically_valid(completions, **kwargs) -> list[float]:
|
| 251 |
-
"""
|
| 252 |
-
Are the folds physically possible?
|
| 253 |
-
|
| 254 |
-
+1.0 — all folds valid, no violations
|
| 255 |
-
-2.0 — per Kawasaki/Maekawa violation
|
| 256 |
-
-5.0 — any self-intersection
|
| 257 |
-
-1.0 — strain exceeds material limit
|
| 258 |
-
0.0 — function broken / can't run
|
| 259 |
-
"""
|
| 260 |
-
scores = []
|
| 261 |
-
for completion in completions:
|
| 262 |
-
response = completion[0]["content"]
|
| 263 |
-
function_code = extract_function(response)
|
| 264 |
-
|
| 265 |
-
if function_code is None:
|
| 266 |
-
scores.append(0.0)
|
| 267 |
-
continue
|
| 268 |
-
|
| 269 |
-
ok, info = check_imports_stdlib_only(function_code)
|
| 270 |
-
if not ok:
|
| 271 |
-
scores.append(0.0)
|
| 272 |
-
continue
|
| 273 |
-
|
| 274 |
-
try:
|
| 275 |
-
strategy_fn = create_sandboxed_function(function_code)
|
| 276 |
-
except Exception:
|
| 277 |
-
scores.append(0.0)
|
| 278 |
-
continue
|
| 279 |
-
|
| 280 |
-
try:
|
| 281 |
-
paper = _create_sheet(
|
| 282 |
-
_current_task["width"],
|
| 283 |
-
_current_task["height"],
|
| 284 |
-
_current_task["material"],
|
| 285 |
-
)
|
| 286 |
-
original = paper
|
| 287 |
-
final_state, applied, error = execute_fold_strategy(
|
| 288 |
-
strategy_fn, paper, _current_task["max_folds"]
|
| 289 |
-
)
|
| 290 |
-
|
| 291 |
-
if error:
|
| 292 |
-
scores.append(0.0)
|
| 293 |
-
continue
|
| 294 |
-
|
| 295 |
-
if len(applied) == 0:
|
| 296 |
-
scores.append(0.0)
|
| 297 |
-
continue
|
| 298 |
-
|
| 299 |
-
# Score based on validity using engine validation
|
| 300 |
-
val = validate_paper(final_state)
|
| 301 |
-
metrics = compute_metrics(final_state, original)
|
| 302 |
-
|
| 303 |
-
score = 1.0
|
| 304 |
-
score -= 2.0 * val.kawasaki_violation
|
| 305 |
-
score -= 2.0 * val.maekawa_violation
|
| 306 |
-
if val.self_intersection_count > 0:
|
| 307 |
-
score -= 5.0
|
| 308 |
-
max_strain = metrics.get("max_strain", 0.0)
|
| 309 |
-
if max_strain > _current_task["material"].max_strain:
|
| 310 |
-
score -= 1.0
|
| 311 |
-
|
| 312 |
-
scores.append(score)
|
| 313 |
-
|
| 314 |
-
except TimeoutError:
|
| 315 |
-
scores.append(-1.0)
|
| 316 |
-
except Exception:
|
| 317 |
-
scores.append(0.0)
|
| 318 |
-
|
| 319 |
-
return scores
|
| 320 |
-
|
| 321 |
-
|
| 322 |
-
# ---------------------------------------------------------------------------
|
| 323 |
-
# Reward 3: fold_quality
|
| 324 |
-
# ---------------------------------------------------------------------------
|
| 325 |
-
|
| 326 |
-
def fold_quality(completions, **kwargs) -> list[float]:
|
| 327 |
-
"""
|
| 328 |
-
How good is the folding solution?
|
| 329 |
-
|
| 330 |
-
+20.0 * compactness — main reward (1 - deployment_ratio)
|
| 331 |
-
+10.0 bonus — if meets target ratio
|
| 332 |
-
-0.5 per fold — efficiency penalty
|
| 333 |
-
-3.0 * overstrain — material stress penalty
|
| 334 |
-
-1.0 — timeout
|
| 335 |
-
-3.0 — exception
|
| 336 |
-
0.0 — function broken
|
| 337 |
-
"""
|
| 338 |
-
global _print_counter
|
| 339 |
-
scores = []
|
| 340 |
-
|
| 341 |
-
for completion in completions:
|
| 342 |
-
response = completion[0]["content"]
|
| 343 |
-
function_code = extract_function(response)
|
| 344 |
-
|
| 345 |
-
should_print = (_print_counter % PRINT_EVERY == 0)
|
| 346 |
-
_print_counter += 1
|
| 347 |
-
|
| 348 |
-
if should_print:
|
| 349 |
-
print(f"\n--- Strategy (sample {_print_counter}) ---")
|
| 350 |
-
print(function_code if function_code else "[no function extracted]")
|
| 351 |
-
|
| 352 |
-
if function_code is None:
|
| 353 |
-
scores.append(0.0)
|
| 354 |
-
continue
|
| 355 |
-
|
| 356 |
-
ok, info = check_imports_stdlib_only(function_code)
|
| 357 |
-
if not ok:
|
| 358 |
-
scores.append(0.0)
|
| 359 |
-
continue
|
| 360 |
-
|
| 361 |
-
try:
|
| 362 |
-
strategy_fn = create_sandboxed_function(function_code)
|
| 363 |
-
except Exception:
|
| 364 |
-
scores.append(0.0)
|
| 365 |
-
continue
|
| 366 |
-
|
| 367 |
-
try:
|
| 368 |
-
paper = _create_sheet(
|
| 369 |
-
_current_task["width"],
|
| 370 |
-
_current_task["height"],
|
| 371 |
-
_current_task["material"],
|
| 372 |
-
)
|
| 373 |
-
original = paper
|
| 374 |
-
final_state, applied, error = execute_fold_strategy(
|
| 375 |
-
strategy_fn, paper, _current_task["max_folds"]
|
| 376 |
-
)
|
| 377 |
-
|
| 378 |
-
if error:
|
| 379 |
-
if should_print:
|
| 380 |
-
print(f"Error: {error}")
|
| 381 |
-
scores.append(0.0)
|
| 382 |
-
continue
|
| 383 |
-
|
| 384 |
-
num_folds = len(applied)
|
| 385 |
-
if num_folds == 0:
|
| 386 |
-
scores.append(0.0)
|
| 387 |
-
continue
|
| 388 |
-
|
| 389 |
-
# Use engine metrics
|
| 390 |
-
metrics = compute_metrics(final_state, original)
|
| 391 |
-
deploy_ratio = metrics.get("deployment_ratio", 1.0)
|
| 392 |
-
max_strain = metrics.get("max_strain", 0.0)
|
| 393 |
-
|
| 394 |
-
# Compactness: main reward signal
|
| 395 |
-
compactness = 1.0 - deploy_ratio
|
| 396 |
-
score = 20.0 * compactness
|
| 397 |
-
|
| 398 |
-
# Bonus for meeting target
|
| 399 |
-
if deploy_ratio <= _current_task["target_ratio"]:
|
| 400 |
-
score += 10.0
|
| 401 |
-
|
| 402 |
-
# Fold efficiency penalty
|
| 403 |
-
score -= 0.5 * num_folds
|
| 404 |
-
|
| 405 |
-
# Strain penalty
|
| 406 |
-
mat_limit = _current_task["material"].max_strain
|
| 407 |
-
if max_strain > mat_limit:
|
| 408 |
-
score -= 3.0 * (max_strain / mat_limit)
|
| 409 |
-
|
| 410 |
-
if should_print:
|
| 411 |
-
print(f"Folds: {num_folds}, Ratio: {deploy_ratio:.3f}, "
|
| 412 |
-
f"Compactness: {compactness:.3f}, Score: {score:.2f}")
|
| 413 |
-
bb = metrics.get("bounding_box", {})
|
| 414 |
-
print(f"BBox: {bb.get('x',0):.3f} x {bb.get('y',0):.3f} x {bb.get('z',0):.3f}")
|
| 415 |
-
|
| 416 |
-
scores.append(score)
|
| 417 |
-
|
| 418 |
-
except TimeoutError:
|
| 419 |
-
if should_print:
|
| 420 |
-
print("Timeout!")
|
| 421 |
-
scores.append(-1.0)
|
| 422 |
-
except Exception as e:
|
| 423 |
-
if should_print:
|
| 424 |
-
print(f"Exception: {e}")
|
| 425 |
-
scores.append(-3.0)
|
| 426 |
-
|
| 427 |
-
return scores
|
| 428 |
-
|
| 429 |
-
|
| 430 |
-
# ---------------------------------------------------------------------------
|
| 431 |
-
# SpatialThinker Dense Rewards (weight 0.10 + 0.20 + 0.50 + 0.20 = 1.0)
|
| 432 |
-
# ---------------------------------------------------------------------------
|
| 433 |
-
|
| 434 |
-
REQUIRED_TAGS = ["observe", "plan", "code", "verify"]
|
| 435 |
-
|
| 436 |
-
|
| 437 |
-
def format_reward(completions, **kwargs) -> list[float]:
|
| 438 |
-
"""
|
| 439 |
-
SpatialThinker format reward (weight: 0.10).
|
| 440 |
-
|
| 441 |
-
Checks that the response has all 4 structured tags, valid JSON in <plan>,
|
| 442 |
-
and a parseable function in <code>.
|
| 443 |
-
|
| 444 |
-
Score range: [0.0, 1.0]
|
| 445 |
-
"""
|
| 446 |
-
scores = []
|
| 447 |
-
for completion in completions:
|
| 448 |
-
response = completion[0]["content"]
|
| 449 |
-
score = 0.0
|
| 450 |
-
|
| 451 |
-
# Check each required tag (0.15 each = 0.60 for all 4)
|
| 452 |
-
tags_present = 0
|
| 453 |
-
for tag in REQUIRED_TAGS:
|
| 454 |
-
if extract_section(response, tag) is not None:
|
| 455 |
-
tags_present += 1
|
| 456 |
-
score += 0.15 * tags_present
|
| 457 |
-
|
| 458 |
-
# Valid JSON in <plan> (0.20)
|
| 459 |
-
plan = extract_plan_json(response)
|
| 460 |
-
if plan is not None:
|
| 461 |
-
score += 0.20
|
| 462 |
-
# Plan has required fields (0.05 bonus)
|
| 463 |
-
if "folds" in plan and isinstance(plan["folds"], list):
|
| 464 |
-
score += 0.05
|
| 465 |
-
|
| 466 |
-
# Valid function in <code> (0.15)
|
| 467 |
-
fn = extract_function(response)
|
| 468 |
-
if fn is not None:
|
| 469 |
-
score += 0.15
|
| 470 |
-
|
| 471 |
-
scores.append(score)
|
| 472 |
-
return scores
|
| 473 |
-
|
| 474 |
-
|
| 475 |
-
def spatial_reward(completions, **kwargs) -> list[float]:
|
| 476 |
-
"""
|
| 477 |
-
SpatialThinker spatial plan quality reward (weight: 0.20).
|
| 478 |
-
|
| 479 |
-
Checks that fold coordinates in <plan> are geometrically valid:
|
| 480 |
-
- Within paper bounds
|
| 481 |
-
- Line endpoints form valid fold lines (cross the paper)
|
| 482 |
-
- Fold types are valid
|
| 483 |
-
- Expected ratio/count are reasonable
|
| 484 |
-
|
| 485 |
-
Score range: [0.0, 1.0]
|
| 486 |
-
"""
|
| 487 |
-
w = _current_task["width"]
|
| 488 |
-
h = _current_task["height"]
|
| 489 |
-
|
| 490 |
-
scores = []
|
| 491 |
-
for completion in completions:
|
| 492 |
-
response = completion[0]["content"]
|
| 493 |
-
plan = extract_plan_json(response)
|
| 494 |
-
|
| 495 |
-
if plan is None:
|
| 496 |
-
scores.append(0.0)
|
| 497 |
-
continue
|
| 498 |
-
|
| 499 |
-
score = 0.0
|
| 500 |
-
folds = plan.get("folds", [])
|
| 501 |
-
|
| 502 |
-
if not folds:
|
| 503 |
-
scores.append(0.0)
|
| 504 |
-
continue
|
| 505 |
-
|
| 506 |
-
# Score each fold in the plan
|
| 507 |
-
valid_folds = 0
|
| 508 |
-
for fold in folds:
|
| 509 |
-
fold_score = 0.0
|
| 510 |
-
|
| 511 |
-
# Has required fields
|
| 512 |
-
has_type = fold.get("type") in ("valley", "mountain")
|
| 513 |
-
has_start = isinstance(fold.get("line_start"), list) and len(fold.get("line_start", [])) == 2
|
| 514 |
-
has_end = isinstance(fold.get("line_end"), list) and len(fold.get("line_end", [])) == 2
|
| 515 |
-
|
| 516 |
-
if has_type:
|
| 517 |
-
fold_score += 0.25
|
| 518 |
-
if has_start and has_end:
|
| 519 |
-
fold_score += 0.25
|
| 520 |
-
# Coordinates within paper bounds (with small tolerance)
|
| 521 |
-
sx, sy = fold["line_start"]
|
| 522 |
-
ex, ey = fold["line_end"]
|
| 523 |
-
tol = 0.01
|
| 524 |
-
in_bounds = (
|
| 525 |
-
-tol <= sx <= w + tol and -tol <= sy <= h + tol and
|
| 526 |
-
-tol <= ex <= w + tol and -tol <= ey <= h + tol
|
| 527 |
-
)
|
| 528 |
-
if in_bounds:
|
| 529 |
-
fold_score += 0.25
|
| 530 |
-
|
| 531 |
-
# Start != end (not a degenerate line)
|
| 532 |
-
dist = math.sqrt((ex - sx)**2 + (ey - sy)**2)
|
| 533 |
-
if dist > 0.01:
|
| 534 |
-
fold_score += 0.25
|
| 535 |
-
|
| 536 |
-
if fold_score > 0.5:
|
| 537 |
-
valid_folds += 1
|
| 538 |
-
|
| 539 |
-
# Proportion of valid folds
|
| 540 |
-
score = valid_folds / len(folds) if folds else 0.0
|
| 541 |
-
|
| 542 |
-
# Bonus: expected_ratio is reasonable (0.0 to 1.0)
|
| 543 |
-
expected = plan.get("expected_ratio")
|
| 544 |
-
if isinstance(expected, (int, float)) and 0.0 < expected <= 1.0:
|
| 545 |
-
score = min(1.0, score + 0.1)
|
| 546 |
-
|
| 547 |
-
scores.append(min(1.0, score))
|
| 548 |
-
return scores
|
| 549 |
-
|
| 550 |
-
|
| 551 |
-
def execution_reward(completions, **kwargs) -> list[float]:
|
| 552 |
-
"""
|
| 553 |
-
SpatialThinker execution/accuracy reward (weight: 0.50).
|
| 554 |
-
|
| 555 |
-
Combines code validity, physical validity, and fold quality into
|
| 556 |
-
one normalized score. This is the main reward signal.
|
| 557 |
-
|
| 558 |
-
Score range: [0.0, 1.0]
|
| 559 |
-
"""
|
| 560 |
-
scores = []
|
| 561 |
-
for completion in completions:
|
| 562 |
-
response = completion[0]["content"]
|
| 563 |
-
function_code = extract_function(response)
|
| 564 |
-
|
| 565 |
-
# Gate: no function → 0
|
| 566 |
-
if function_code is None:
|
| 567 |
-
scores.append(0.0)
|
| 568 |
-
continue
|
| 569 |
-
|
| 570 |
-
ok, info = check_imports_stdlib_only(function_code)
|
| 571 |
-
if not ok:
|
| 572 |
-
scores.append(0.0)
|
| 573 |
-
continue
|
| 574 |
-
|
| 575 |
-
try:
|
| 576 |
-
strategy_fn = create_sandboxed_function(function_code)
|
| 577 |
-
except Exception:
|
| 578 |
-
scores.append(0.0)
|
| 579 |
-
continue
|
| 580 |
-
|
| 581 |
-
try:
|
| 582 |
-
paper = _create_sheet(
|
| 583 |
-
_current_task["width"],
|
| 584 |
-
_current_task["height"],
|
| 585 |
-
_current_task["material"],
|
| 586 |
-
)
|
| 587 |
-
original = paper
|
| 588 |
-
final_state, applied, error = execute_fold_strategy(
|
| 589 |
-
strategy_fn, paper, _current_task["max_folds"]
|
| 590 |
-
)
|
| 591 |
-
|
| 592 |
-
if error or len(applied) == 0:
|
| 593 |
-
scores.append(0.0)
|
| 594 |
-
continue
|
| 595 |
-
|
| 596 |
-
val = validate_paper(final_state)
|
| 597 |
-
metrics = compute_metrics(final_state, original)
|
| 598 |
-
deploy_ratio = metrics.get("deployment_ratio", 1.0)
|
| 599 |
-
max_strain = metrics.get("max_strain", 0.0)
|
| 600 |
-
|
| 601 |
-
# Physical validity component (0-0.3)
|
| 602 |
-
phys = 0.3
|
| 603 |
-
if not val.is_valid:
|
| 604 |
-
phys -= 0.1 * val.kawasaki_violation
|
| 605 |
-
phys -= 0.1 * val.maekawa_violation
|
| 606 |
-
if val.self_intersection_count > 0:
|
| 607 |
-
phys -= 0.15
|
| 608 |
-
mat_limit = _current_task["material"].max_strain
|
| 609 |
-
if max_strain > mat_limit:
|
| 610 |
-
phys -= 0.05
|
| 611 |
-
phys = max(0.0, phys)
|
| 612 |
-
|
| 613 |
-
# Quality component (0-0.5)
|
| 614 |
-
compactness = 1.0 - deploy_ratio
|
| 615 |
-
quality = 0.5 * compactness
|
| 616 |
-
|
| 617 |
-
# Target bonus (0-0.2)
|
| 618 |
-
target = 0.0
|
| 619 |
-
if deploy_ratio <= _current_task["target_ratio"]:
|
| 620 |
-
target = 0.2
|
| 621 |
-
|
| 622 |
-
score = phys + quality + target
|
| 623 |
-
scores.append(min(1.0, score))
|
| 624 |
-
|
| 625 |
-
except Exception:
|
| 626 |
-
scores.append(0.0)
|
| 627 |
-
|
| 628 |
-
return scores
|
| 629 |
-
|
| 630 |
-
|
| 631 |
-
def consistency_reward(completions, **kwargs) -> list[float]:
|
| 632 |
-
"""
|
| 633 |
-
SpatialThinker consistency reward (weight: 0.20).
|
| 634 |
-
|
| 635 |
-
Checks that <plan> matches <code> and <verify> matches actual results.
|
| 636 |
-
- Plan fold count matches code fold count
|
| 637 |
-
- Verify predictions close to actual metrics
|
| 638 |
-
|
| 639 |
-
Score range: [0.0, 1.0]
|
| 640 |
-
"""
|
| 641 |
-
scores = []
|
| 642 |
-
for completion in completions:
|
| 643 |
-
response = completion[0]["content"]
|
| 644 |
-
plan = extract_plan_json(response)
|
| 645 |
-
verify = extract_section(response, "verify")
|
| 646 |
-
function_code = extract_function(response)
|
| 647 |
-
|
| 648 |
-
# Need at least plan + code to check consistency
|
| 649 |
-
if plan is None or function_code is None:
|
| 650 |
-
scores.append(0.0)
|
| 651 |
-
continue
|
| 652 |
-
|
| 653 |
-
score = 0.0
|
| 654 |
-
|
| 655 |
-
# 1. Plan fold count vs code fold count (0.4)
|
| 656 |
-
plan_folds = plan.get("folds", [])
|
| 657 |
-
plan_count = len(plan_folds)
|
| 658 |
-
|
| 659 |
-
try:
|
| 660 |
-
strategy_fn = create_sandboxed_function(function_code)
|
| 661 |
-
paper = _create_sheet(
|
| 662 |
-
_current_task["width"],
|
| 663 |
-
_current_task["height"],
|
| 664 |
-
_current_task["material"],
|
| 665 |
-
)
|
| 666 |
-
original = paper
|
| 667 |
-
final_state, applied, error = execute_fold_strategy(
|
| 668 |
-
strategy_fn, paper, _current_task["max_folds"]
|
| 669 |
-
)
|
| 670 |
-
if error or len(applied) == 0:
|
| 671 |
-
scores.append(0.0)
|
| 672 |
-
continue
|
| 673 |
-
|
| 674 |
-
actual_count = len(applied)
|
| 675 |
-
if plan_count == actual_count:
|
| 676 |
-
score += 0.4
|
| 677 |
-
elif abs(plan_count - actual_count) <= 1:
|
| 678 |
-
score += 0.2
|
| 679 |
-
|
| 680 |
-
# 2. Verify predictions vs actual (0.6)
|
| 681 |
-
if verify:
|
| 682 |
-
metrics = compute_metrics(final_state, original)
|
| 683 |
-
actual_ratio = metrics.get("deployment_ratio", 1.0)
|
| 684 |
-
|
| 685 |
-
# Extract predicted ratio from verify text
|
| 686 |
-
ratio_match = re.search(
|
| 687 |
-
r'deployment\s*ratio[:\s]*([\d.]+)', verify, re.IGNORECASE)
|
| 688 |
-
if ratio_match:
|
| 689 |
-
predicted_ratio = float(ratio_match.group(1))
|
| 690 |
-
error_pct = abs(predicted_ratio - actual_ratio)
|
| 691 |
-
if error_pct < 0.05:
|
| 692 |
-
score += 0.4
|
| 693 |
-
elif error_pct < 0.15:
|
| 694 |
-
score += 0.2
|
| 695 |
-
elif error_pct < 0.3:
|
| 696 |
-
score += 0.1
|
| 697 |
-
|
| 698 |
-
# Extract predicted fold count
|
| 699 |
-
count_match = re.search(
|
| 700 |
-
r'fold\s*count[:\s]*(\d+)', verify, re.IGNORECASE)
|
| 701 |
-
if count_match:
|
| 702 |
-
predicted_count = int(count_match.group(1))
|
| 703 |
-
if predicted_count == actual_count:
|
| 704 |
-
score += 0.2
|
| 705 |
-
elif abs(predicted_count - actual_count) <= 1:
|
| 706 |
-
score += 0.1
|
| 707 |
-
|
| 708 |
-
except Exception:
|
| 709 |
-
scores.append(0.0)
|
| 710 |
-
continue
|
| 711 |
-
|
| 712 |
-
scores.append(min(1.0, score))
|
| 713 |
-
return scores
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
trainer/train.py
DELETED
|
@@ -1,215 +0,0 @@
|
|
| 1 |
-
"""
|
| 2 |
-
Origami GRPO Training Script
|
| 3 |
-
|
| 4 |
-
Usage (Colab with T4/A100):
|
| 5 |
-
python trainer/train.py
|
| 6 |
-
|
| 7 |
-
Or in a notebook:
|
| 8 |
-
%run trainer/train.py
|
| 9 |
-
|
| 10 |
-
Requires: unsloth, trl>=0.22.2, transformers>=4.56.2, trackio, datasets
|
| 11 |
-
"""
|
| 12 |
-
|
| 13 |
-
import os
|
| 14 |
-
import sys
|
| 15 |
-
|
| 16 |
-
# Ensure project root is on path
|
| 17 |
-
PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
| 18 |
-
if PROJECT_ROOT not in sys.path:
|
| 19 |
-
sys.path.insert(0, PROJECT_ROOT)
|
| 20 |
-
|
| 21 |
-
from trainer.prompts import build_prompt, SYSTEM_PROMPT, get_task_target_ratio, get_task_max_folds
|
| 22 |
-
from trainer.rewards import (
|
| 23 |
-
code_valid, physically_valid, fold_quality, set_task_config,
|
| 24 |
-
format_reward, spatial_reward, execution_reward, consistency_reward,
|
| 25 |
-
)
|
| 26 |
-
|
| 27 |
-
try:
|
| 28 |
-
from engine.materials import get_material
|
| 29 |
-
Material = type(get_material("paper")) # get the Material class
|
| 30 |
-
except ImportError:
|
| 31 |
-
from trainer.mock_env import Material
|
| 32 |
-
def get_material(name):
|
| 33 |
-
return Material()
|
| 34 |
-
|
| 35 |
-
# ============================================================================
|
| 36 |
-
# Config
|
| 37 |
-
# ============================================================================
|
| 38 |
-
|
| 39 |
-
MODEL_NAME = "unsloth/Qwen2.5-7B-Instruct"
|
| 40 |
-
MAX_SEQ_LENGTH = 2048
|
| 41 |
-
LORA_RANK = 4
|
| 42 |
-
|
| 43 |
-
# Start with the simplest task
|
| 44 |
-
TASK_NAME = "half_fold"
|
| 45 |
-
|
| 46 |
-
# GRPO hyperparameters (from 2048 reference, adapted for origami)
|
| 47 |
-
LEARNING_RATE = 2e-4
|
| 48 |
-
MAX_STEPS = 600
|
| 49 |
-
NUM_GENERATIONS = 2
|
| 50 |
-
TEMPERATURE = 1.0
|
| 51 |
-
BATCH_SIZE = 1
|
| 52 |
-
GRAD_ACCUM = 1
|
| 53 |
-
DATASET_SIZE = 1000 # replicated prompt dataset
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
def main():
|
| 57 |
-
# ========================================================================
|
| 58 |
-
# 1. Load model with Unsloth
|
| 59 |
-
# ========================================================================
|
| 60 |
-
from unsloth import FastLanguageModel
|
| 61 |
-
|
| 62 |
-
print(f"Loading model: {MODEL_NAME}")
|
| 63 |
-
model, tokenizer = FastLanguageModel.from_pretrained(
|
| 64 |
-
model_name=MODEL_NAME,
|
| 65 |
-
load_in_4bit=True,
|
| 66 |
-
max_seq_length=MAX_SEQ_LENGTH,
|
| 67 |
-
)
|
| 68 |
-
|
| 69 |
-
# ========================================================================
|
| 70 |
-
# 2. Apply LoRA adapters
|
| 71 |
-
# ========================================================================
|
| 72 |
-
model = FastLanguageModel.get_peft_model(
|
| 73 |
-
model,
|
| 74 |
-
r=LORA_RANK,
|
| 75 |
-
target_modules=[
|
| 76 |
-
"q_proj", "k_proj", "v_proj", "o_proj",
|
| 77 |
-
"gate_proj", "up_proj", "down_proj",
|
| 78 |
-
],
|
| 79 |
-
lora_alpha=LORA_RANK * 2,
|
| 80 |
-
use_gradient_checkpointing="unsloth",
|
| 81 |
-
random_state=3407,
|
| 82 |
-
)
|
| 83 |
-
|
| 84 |
-
# ========================================================================
|
| 85 |
-
# 3. Build prompt and dataset
|
| 86 |
-
# ========================================================================
|
| 87 |
-
user_prompt = build_prompt(TASK_NAME)
|
| 88 |
-
target_ratio = get_task_target_ratio(TASK_NAME)
|
| 89 |
-
max_folds = get_task_max_folds(TASK_NAME)
|
| 90 |
-
|
| 91 |
-
# Configure reward functions with task parameters
|
| 92 |
-
set_task_config(
|
| 93 |
-
width=1.0,
|
| 94 |
-
height=1.0,
|
| 95 |
-
material=get_material("paper"),
|
| 96 |
-
target_ratio=target_ratio,
|
| 97 |
-
max_folds=max_folds,
|
| 98 |
-
)
|
| 99 |
-
|
| 100 |
-
# Create replicated prompt dataset (same pattern as 2048)
|
| 101 |
-
from datasets import Dataset
|
| 102 |
-
|
| 103 |
-
dataset = Dataset.from_list([
|
| 104 |
-
{
|
| 105 |
-
"prompt": [
|
| 106 |
-
{"role": "system", "content": SYSTEM_PROMPT},
|
| 107 |
-
{"role": "user", "content": user_prompt},
|
| 108 |
-
],
|
| 109 |
-
}
|
| 110 |
-
] * DATASET_SIZE)
|
| 111 |
-
|
| 112 |
-
# Calculate prompt token length for max_completion_length
|
| 113 |
-
prompt_tokens = tokenizer.apply_chat_template(
|
| 114 |
-
[
|
| 115 |
-
{"role": "system", "content": SYSTEM_PROMPT},
|
| 116 |
-
{"role": "user", "content": user_prompt},
|
| 117 |
-
],
|
| 118 |
-
add_generation_prompt=True,
|
| 119 |
-
tokenize=True,
|
| 120 |
-
)
|
| 121 |
-
max_prompt_length = len(prompt_tokens) + 1
|
| 122 |
-
max_completion_length = MAX_SEQ_LENGTH - max_prompt_length
|
| 123 |
-
print(f"Prompt tokens: {max_prompt_length}, Max completion: {max_completion_length}")
|
| 124 |
-
|
| 125 |
-
# ========================================================================
|
| 126 |
-
# 4. Test inference before training
|
| 127 |
-
# ========================================================================
|
| 128 |
-
print("\n=== Pre-training inference test ===")
|
| 129 |
-
text = tokenizer.apply_chat_template(
|
| 130 |
-
[
|
| 131 |
-
{"role": "system", "content": SYSTEM_PROMPT},
|
| 132 |
-
{"role": "user", "content": user_prompt},
|
| 133 |
-
],
|
| 134 |
-
tokenize=False,
|
| 135 |
-
add_generation_prompt=True,
|
| 136 |
-
)
|
| 137 |
-
|
| 138 |
-
from transformers import TextStreamer
|
| 139 |
-
_ = model.generate(
|
| 140 |
-
**tokenizer(text, return_tensors="pt").to("cuda"),
|
| 141 |
-
temperature=TEMPERATURE,
|
| 142 |
-
max_new_tokens=min(512, max_completion_length),
|
| 143 |
-
streamer=TextStreamer(tokenizer, skip_prompt=True),
|
| 144 |
-
)
|
| 145 |
-
|
| 146 |
-
# ========================================================================
|
| 147 |
-
# 5. Configure GRPO training
|
| 148 |
-
# ========================================================================
|
| 149 |
-
from trl import GRPOConfig, GRPOTrainer
|
| 150 |
-
|
| 151 |
-
training_args = GRPOConfig(
|
| 152 |
-
temperature=TEMPERATURE,
|
| 153 |
-
learning_rate=LEARNING_RATE,
|
| 154 |
-
weight_decay=0.001,
|
| 155 |
-
warmup_ratio=0.1,
|
| 156 |
-
lr_scheduler_type="linear",
|
| 157 |
-
optim="adamw_8bit",
|
| 158 |
-
logging_steps=1,
|
| 159 |
-
per_device_train_batch_size=BATCH_SIZE,
|
| 160 |
-
gradient_accumulation_steps=GRAD_ACCUM,
|
| 161 |
-
num_generations=NUM_GENERATIONS,
|
| 162 |
-
max_prompt_length=max_prompt_length,
|
| 163 |
-
max_completion_length=max_completion_length,
|
| 164 |
-
max_steps=MAX_STEPS,
|
| 165 |
-
save_steps=100,
|
| 166 |
-
report_to="trackio",
|
| 167 |
-
output_dir="outputs",
|
| 168 |
-
)
|
| 169 |
-
|
| 170 |
-
# ========================================================================
|
| 171 |
-
# 6. Create trainer and start training
|
| 172 |
-
# ========================================================================
|
| 173 |
-
# SpatialThinker dense rewards (weighted: 0.10 + 0.20 + 0.50 + 0.20)
|
| 174 |
-
# These replace the legacy 3-reward setup with structured spatial reasoning
|
| 175 |
-
trainer = GRPOTrainer(
|
| 176 |
-
model=model,
|
| 177 |
-
processing_class=tokenizer,
|
| 178 |
-
reward_funcs=[
|
| 179 |
-
format_reward, # 0.10 — 4-stage format compliance
|
| 180 |
-
spatial_reward, # 0.20 — fold plan geometric validity
|
| 181 |
-
execution_reward, # 0.50 — code execution + physical quality
|
| 182 |
-
consistency_reward, # 0.20 — plan↔code↔verify agreement
|
| 183 |
-
],
|
| 184 |
-
reward_weights=[0.10, 0.20, 0.50, 0.20],
|
| 185 |
-
args=training_args,
|
| 186 |
-
train_dataset=dataset,
|
| 187 |
-
)
|
| 188 |
-
|
| 189 |
-
print(f"\n=== Starting GRPO training: {TASK_NAME} ===")
|
| 190 |
-
print(f"Steps: {MAX_STEPS}, Generations: {NUM_GENERATIONS}, LR: {LEARNING_RATE}")
|
| 191 |
-
trainer.train()
|
| 192 |
-
|
| 193 |
-
# ========================================================================
|
| 194 |
-
# 7. Post-training inference
|
| 195 |
-
# ========================================================================
|
| 196 |
-
print("\n=== Post-training inference ===")
|
| 197 |
-
_ = model.generate(
|
| 198 |
-
**tokenizer(text, return_tensors="pt").to("cuda"),
|
| 199 |
-
temperature=TEMPERATURE,
|
| 200 |
-
max_new_tokens=min(1024, max_completion_length),
|
| 201 |
-
streamer=TextStreamer(tokenizer, skip_prompt=True),
|
| 202 |
-
)
|
| 203 |
-
|
| 204 |
-
# ========================================================================
|
| 205 |
-
# 8. Save model (optional)
|
| 206 |
-
# ========================================================================
|
| 207 |
-
save_path = "outputs/origami-fold-lora"
|
| 208 |
-
print(f"\nSaving LoRA adapter to {save_path}")
|
| 209 |
-
model.save_pretrained(save_path)
|
| 210 |
-
tokenizer.save_pretrained(save_path)
|
| 211 |
-
print("Done!")
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
if __name__ == "__main__":
|
| 215 |
-
main()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
training/__init__.py
DELETED
|
File without changes
|
training/demo.py
DELETED
|
@@ -1,251 +0,0 @@
|
|
| 1 |
-
"""
|
| 2 |
-
training/demo.py — Run 8 zero-shot rollouts and stream them to the grid viewer.
|
| 3 |
-
|
| 4 |
-
Usage:
|
| 5 |
-
cd /path/to/optigami
|
| 6 |
-
python -m training.demo
|
| 7 |
-
|
| 8 |
-
Then open: http://localhost:9001/viewer/training.html
|
| 9 |
-
|
| 10 |
-
Each of the 8 "strategies" is a heuristic that mimics what a pretrained LLM might
|
| 11 |
-
produce for different tasks — varying from near-optimal to poor. This exercises
|
| 12 |
-
the full broadcast → grid viewer pipeline without requiring an LLM API key.
|
| 13 |
-
"""
|
| 14 |
-
from __future__ import annotations
|
| 15 |
-
|
| 16 |
-
import asyncio
|
| 17 |
-
import time
|
| 18 |
-
import uuid
|
| 19 |
-
from typing import Callable
|
| 20 |
-
|
| 21 |
-
import uvicorn
|
| 22 |
-
|
| 23 |
-
from server.app import app, broadcast
|
| 24 |
-
from training.runner import run_batch
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
# ── 8 zero-shot heuristic strategies ──────────────────────────────────────────
|
| 28 |
-
# Each is a callable: paper_state (dict) → fold_dict
|
| 29 |
-
# These represent the range of strategies a pretrained LLM might generate.
|
| 30 |
-
|
| 31 |
-
def strategy_perfect_half(paper_state: dict) -> dict:
|
| 32 |
-
"""Valley fold exactly at horizontal midline — optimal for half_fold."""
|
| 33 |
-
return {"type": "valley", "line": {"start": [0.0, 0.5], "end": [1.0, 0.5]}, "angle": 180.0}
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
def strategy_slight_offset(paper_state: dict) -> dict:
|
| 37 |
-
"""Valley fold slightly off-center — almost optimal."""
|
| 38 |
-
return {"type": "valley", "line": {"start": [0.0, 0.48], "end": [1.0, 0.48]}, "angle": 180.0}
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
def strategy_thirds(paper_state: dict) -> dict:
|
| 42 |
-
"""Letter fold at one-third — wrong for half_fold, generates interesting geometry."""
|
| 43 |
-
fold_count = paper_state.get("fold_count", 0)
|
| 44 |
-
positions = [0.333, 0.667]
|
| 45 |
-
if fold_count >= len(positions):
|
| 46 |
-
return {"type": "stop", "line": {"start": [0.0, 0.5], "end": [1.0, 0.5]}, "angle": 0.0}
|
| 47 |
-
return {
|
| 48 |
-
"type": "valley" if fold_count == 0 else "mountain",
|
| 49 |
-
"line": {"start": [0.0, positions[fold_count]], "end": [1.0, positions[fold_count]]},
|
| 50 |
-
"angle": 180.0,
|
| 51 |
-
}
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
def strategy_vertical(paper_state: dict) -> dict:
|
| 55 |
-
"""Vertical fold — gets compactness but in wrong dimension for target_box."""
|
| 56 |
-
return {"type": "valley", "line": {"start": [0.5, 0.0], "end": [0.5, 1.0]}, "angle": 180.0}
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
def strategy_mountain(paper_state: dict) -> dict:
|
| 60 |
-
"""Mountain fold at midline — same geometry, different assignment."""
|
| 61 |
-
return {"type": "mountain", "line": {"start": [0.0, 0.5], "end": [1.0, 0.5]}, "angle": 180.0}
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
def strategy_accordion(paper_state: dict) -> dict:
|
| 65 |
-
"""Accordion 3-fold — overfolds, achieves high compactness but more folds."""
|
| 66 |
-
fold_count = paper_state.get("fold_count", 0)
|
| 67 |
-
positions = [0.25, 0.5, 0.75]
|
| 68 |
-
assignments = ["valley", "mountain", "valley"]
|
| 69 |
-
if fold_count >= len(positions):
|
| 70 |
-
return {"type": "stop", "line": {"start": [0.0, 0.5], "end": [1.0, 0.5]}, "angle": 0.0}
|
| 71 |
-
return {
|
| 72 |
-
"type": assignments[fold_count],
|
| 73 |
-
"line": {"start": [0.0, positions[fold_count]], "end": [1.0, positions[fold_count]]},
|
| 74 |
-
"angle": 180.0,
|
| 75 |
-
}
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
def strategy_diagonal(paper_state: dict) -> dict:
|
| 79 |
-
"""Diagonal fold — achieves compactness but irregular bounding box."""
|
| 80 |
-
return {"type": "valley", "line": {"start": [0.0, 0.0], "end": [1.0, 1.0]}, "angle": 180.0}
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
def strategy_quarter(paper_state: dict) -> dict:
|
| 84 |
-
"""Two perpendicular folds — 4x compactness for quarter_fold task."""
|
| 85 |
-
fold_count = paper_state.get("fold_count", 0)
|
| 86 |
-
if fold_count == 0:
|
| 87 |
-
return {"type": "valley", "line": {"start": [0.0, 0.5], "end": [1.0, 0.5]}, "angle": 180.0}
|
| 88 |
-
if fold_count == 1:
|
| 89 |
-
return {"type": "valley", "line": {"start": [0.5, 0.0], "end": [0.5, 1.0]}, "angle": 180.0}
|
| 90 |
-
return {"type": "stop", "line": {"start": [0.0, 0.5], "end": [1.0, 0.5]}, "angle": 0.0}
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
STRATEGIES: list[tuple[str, Callable]] = [
|
| 94 |
-
("perfect_half", strategy_perfect_half),
|
| 95 |
-
("slight_offset", strategy_slight_offset),
|
| 96 |
-
("thirds_fold", strategy_thirds),
|
| 97 |
-
("vertical_fold", strategy_vertical),
|
| 98 |
-
("mountain_fold", strategy_mountain),
|
| 99 |
-
("accordion_3", strategy_accordion),
|
| 100 |
-
("diagonal", strategy_diagonal),
|
| 101 |
-
("quarter_fold", strategy_quarter),
|
| 102 |
-
]
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
# ── Demo runner ────────────────────────────────────────────────────────────────
|
| 106 |
-
|
| 107 |
-
async def run_demo(task_name: str = "half_fold", delay_s: float = 0.5) -> None:
|
| 108 |
-
"""Wait for server to be ready, then fire 8 episodes."""
|
| 109 |
-
# Give uvicorn time to bind and call startup hook (sets broadcast._loop)
|
| 110 |
-
await asyncio.sleep(1.5)
|
| 111 |
-
|
| 112 |
-
batch_id = 1
|
| 113 |
-
names, fns = zip(*STRATEGIES)
|
| 114 |
-
ep_ids = [f"ep_{name}" for name in names]
|
| 115 |
-
|
| 116 |
-
print(f"\n[demo] Starting batch {batch_id} — task: {task_name}")
|
| 117 |
-
print(f"[demo] Open http://localhost:9001/viewer/training.html\n")
|
| 118 |
-
|
| 119 |
-
# Signal grid to clear and show G=8
|
| 120 |
-
await broadcast.start_batch(batch_id, len(fns))
|
| 121 |
-
|
| 122 |
-
await asyncio.sleep(delay_s)
|
| 123 |
-
|
| 124 |
-
# Run all 8 episodes in the thread pool; broadcast_fn fires into this loop
|
| 125 |
-
results = await asyncio.gather(*[
|
| 126 |
-
asyncio.to_thread(
|
| 127 |
-
_run_one,
|
| 128 |
-
fn,
|
| 129 |
-
task_name,
|
| 130 |
-
ep_id,
|
| 131 |
-
broadcast.publish,
|
| 132 |
-
)
|
| 133 |
-
for fn, ep_id in zip(fns, ep_ids)
|
| 134 |
-
])
|
| 135 |
-
|
| 136 |
-
scores = [r["score"] for r in results]
|
| 137 |
-
best_idx = max(range(len(scores)), key=lambda i: scores[i])
|
| 138 |
-
|
| 139 |
-
await broadcast.finish_batch(batch_id, scores, best_episode_id=ep_ids[best_idx])
|
| 140 |
-
|
| 141 |
-
print("\n[demo] Results:")
|
| 142 |
-
for name, result in zip(names, results):
|
| 143 |
-
print(f" {name:20s} score={result['score']:+.2f} status={result['status']}")
|
| 144 |
-
print(f"\n[demo] Best: {names[best_idx]} (score={scores[best_idx]:+.2f})")
|
| 145 |
-
print("\n[demo] Grid viewer running. Press Ctrl+C to stop.\n")
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
def _run_one(
|
| 149 |
-
strategy_fn: Callable,
|
| 150 |
-
task_name: str,
|
| 151 |
-
ep_id: str,
|
| 152 |
-
broadcast_fn: Callable,
|
| 153 |
-
) -> dict:
|
| 154 |
-
"""Thin wrapper: adds a small sleep between steps so the viewer can animate."""
|
| 155 |
-
from server.models import OrigamiAction
|
| 156 |
-
from server.origami_environment import OrigamiEnvironment
|
| 157 |
-
|
| 158 |
-
env = OrigamiEnvironment()
|
| 159 |
-
obs = env.reset(task_name=task_name)
|
| 160 |
-
|
| 161 |
-
broadcast_fn(ep_id, {
|
| 162 |
-
"type": "episode_update",
|
| 163 |
-
"episode_id": ep_id,
|
| 164 |
-
"task_name": task_name,
|
| 165 |
-
"step": 0,
|
| 166 |
-
"observation": _obs_dict(obs),
|
| 167 |
-
})
|
| 168 |
-
|
| 169 |
-
max_steps = env._task.get("max_folds", 10) if env._task else 10
|
| 170 |
-
status = "done"
|
| 171 |
-
|
| 172 |
-
for step_idx in range(max_steps):
|
| 173 |
-
if obs.done:
|
| 174 |
-
break
|
| 175 |
-
|
| 176 |
-
time.sleep(0.3) # pace so the viewer can animate each step
|
| 177 |
-
|
| 178 |
-
fold_dict = strategy_fn(obs.paper_state)
|
| 179 |
-
|
| 180 |
-
if fold_dict.get("type") == "stop":
|
| 181 |
-
break
|
| 182 |
-
|
| 183 |
-
action = OrigamiAction(
|
| 184 |
-
fold_type=fold_dict["type"],
|
| 185 |
-
fold_line=fold_dict["line"],
|
| 186 |
-
fold_angle=float(fold_dict.get("angle", 180.0)),
|
| 187 |
-
)
|
| 188 |
-
obs = env.step(action)
|
| 189 |
-
|
| 190 |
-
broadcast_fn(ep_id, {
|
| 191 |
-
"type": "episode_update",
|
| 192 |
-
"episode_id": ep_id,
|
| 193 |
-
"task_name": task_name,
|
| 194 |
-
"step": step_idx + 1,
|
| 195 |
-
"observation": _obs_dict(obs),
|
| 196 |
-
})
|
| 197 |
-
|
| 198 |
-
if obs.done:
|
| 199 |
-
break
|
| 200 |
-
else:
|
| 201 |
-
status = "timeout"
|
| 202 |
-
|
| 203 |
-
score = obs.reward if obs.reward is not None else env._total_reward or 0.0
|
| 204 |
-
|
| 205 |
-
broadcast_fn(ep_id, {
|
| 206 |
-
"type": "episode_done",
|
| 207 |
-
"episode_id": ep_id,
|
| 208 |
-
"status": status,
|
| 209 |
-
"score": float(score),
|
| 210 |
-
"final_metrics": obs.metrics,
|
| 211 |
-
})
|
| 212 |
-
|
| 213 |
-
return {
|
| 214 |
-
"episode_id": ep_id,
|
| 215 |
-
"score": float(score),
|
| 216 |
-
"final_metrics": obs.metrics,
|
| 217 |
-
"status": status,
|
| 218 |
-
}
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
def _obs_dict(obs) -> dict:
|
| 222 |
-
try:
|
| 223 |
-
return obs.model_dump()
|
| 224 |
-
except AttributeError:
|
| 225 |
-
return {
|
| 226 |
-
"paper_state": getattr(obs, "paper_state", {}),
|
| 227 |
-
"metrics": getattr(obs, "metrics", {}),
|
| 228 |
-
"fold_history": getattr(obs, "fold_history", []),
|
| 229 |
-
"done": getattr(obs, "done", False),
|
| 230 |
-
"reward": getattr(obs, "reward", None),
|
| 231 |
-
}
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
# ── Entry point ────────────────────────────────────────────────────────────────
|
| 235 |
-
|
| 236 |
-
async def _main() -> None:
|
| 237 |
-
config = uvicorn.Config(app, host="0.0.0.0", port=9001, log_level="warning")
|
| 238 |
-
server = uvicorn.Server(config)
|
| 239 |
-
|
| 240 |
-
# Run demo concurrently with the uvicorn server
|
| 241 |
-
await asyncio.gather(
|
| 242 |
-
server.serve(),
|
| 243 |
-
run_demo(task_name="half_fold"),
|
| 244 |
-
)
|
| 245 |
-
|
| 246 |
-
|
| 247 |
-
if __name__ == "__main__":
|
| 248 |
-
try:
|
| 249 |
-
asyncio.run(_main())
|
| 250 |
-
except KeyboardInterrupt:
|
| 251 |
-
print("\n[demo] Stopped.")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
training/demo_llm.py
DELETED
|
@@ -1,318 +0,0 @@
|
|
| 1 |
-
"""
|
| 2 |
-
training/demo_llm.py — 8 rollouts using Claude as the zero-shot fold strategist.
|
| 3 |
-
|
| 4 |
-
Usage:
|
| 5 |
-
cd /path/to/optigami
|
| 6 |
-
ANTHROPIC_API_KEY=sk-... python -m training.demo_llm
|
| 7 |
-
|
| 8 |
-
Each of the 8 episodes calls Claude (claude-haiku-4-5) once per fold step.
|
| 9 |
-
Claude receives the current paper state (metrics + fold history) and decides
|
| 10 |
-
the next fold action. Episodes run concurrently; all stream to the grid viewer.
|
| 11 |
-
"""
|
| 12 |
-
from __future__ import annotations
|
| 13 |
-
|
| 14 |
-
import asyncio
|
| 15 |
-
import json
|
| 16 |
-
import os
|
| 17 |
-
import re
|
| 18 |
-
import time
|
| 19 |
-
from typing import Any
|
| 20 |
-
|
| 21 |
-
import anthropic
|
| 22 |
-
import uvicorn
|
| 23 |
-
|
| 24 |
-
from server.app import app, broadcast
|
| 25 |
-
from server.models import OrigamiAction
|
| 26 |
-
from server.origami_environment import OrigamiEnvironment
|
| 27 |
-
from server.tasks import get_task_by_name
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
TASK_NAME = "half_fold"
|
| 31 |
-
NUM_EPISODES = 8
|
| 32 |
-
MODEL = "claude-haiku-4-5-20251001"
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
# ── System prompt ──────────────────────────────────────────────────────────────
|
| 36 |
-
|
| 37 |
-
SYSTEM_PROMPT = """\
|
| 38 |
-
You are an origami folding agent controlling a robotic paper-folding system.
|
| 39 |
-
|
| 40 |
-
COORDINATE SYSTEM
|
| 41 |
-
- Paper starts as a flat sheet; coordinates are normalized to the sheet's original size.
|
| 42 |
-
- x=0 is the left edge, x=1 is the right edge.
|
| 43 |
-
- y=0 is the bottom edge, y=1 is the top edge.
|
| 44 |
-
- Fold line endpoints must be on or outside the paper boundary (0.0–1.0 range).
|
| 45 |
-
- A fold line that runs off the edge is fine — it just doesn't affect paper outside the sheet.
|
| 46 |
-
|
| 47 |
-
FOLD TYPES
|
| 48 |
-
- "valley": folds the paper toward you (creates a V crease when viewed from above).
|
| 49 |
-
- "mountain": folds the paper away from you (creates a ^ crease).
|
| 50 |
-
- "stop": you are satisfied — no more folds needed.
|
| 51 |
-
|
| 52 |
-
PHYSICS
|
| 53 |
-
- angle=180 means a fully flat fold (paper halved).
|
| 54 |
-
- Smaller angles (e.g. 90) create partial folds.
|
| 55 |
-
- Each fold updates compactness, bounding_box, and strain readings.
|
| 56 |
-
- Kawasaki/Maekawa violations indicate geometrically invalid crease patterns.
|
| 57 |
-
|
| 58 |
-
RESPONSE FORMAT — output ONLY valid JSON, no markdown, no explanation:
|
| 59 |
-
{"type": "valley", "line": {"start": [x, y], "end": [x, y]}, "angle": 180}
|
| 60 |
-
"""
|
| 61 |
-
|
| 62 |
-
# Eight distinct approach hints — gives diversity across the parallel episodes.
|
| 63 |
-
APPROACH_HINTS = [
|
| 64 |
-
"Try a single clean horizontal fold at the exact midline.",
|
| 65 |
-
"Try a single clean vertical fold at the exact midline.",
|
| 66 |
-
"Use two folds: first horizontal then vertical, to create quarters.",
|
| 67 |
-
"Use a diagonal fold from one corner to the opposite.",
|
| 68 |
-
"Try folding at y=0.333 and y=0.667 to create thirds.",
|
| 69 |
-
"Try a single fold but vary the position slightly off-center to explore.",
|
| 70 |
-
"Use a mountain fold instead of valley for the primary crease.",
|
| 71 |
-
"Try to reach the target box in as few folds as possible — stop early if done.",
|
| 72 |
-
]
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
# ── LLM strategy factory ───────────────────────────────────────────────────────
|
| 76 |
-
|
| 77 |
-
def make_llm_strategy(client: anthropic.Anthropic, task: dict, episode_num: int):
|
| 78 |
-
"""Return a strategy_fn for one episode.
|
| 79 |
-
|
| 80 |
-
Each episode has its own conversation history (multi-turn) and a unique
|
| 81 |
-
approach hint so the 8 concurrent episodes explore different strategies.
|
| 82 |
-
"""
|
| 83 |
-
history: list[dict[str, Any]] = []
|
| 84 |
-
hint = APPROACH_HINTS[episode_num % len(APPROACH_HINTS)]
|
| 85 |
-
prev_compactness: list[float] = [0.0] # mutable cell for delta tracking
|
| 86 |
-
|
| 87 |
-
def strategy(paper_state: dict, fold_history: list[dict]) -> dict:
|
| 88 |
-
fold_count = paper_state.get("fold_count", 0)
|
| 89 |
-
compactness = float(paper_state.get("compactness", 0))
|
| 90 |
-
bb = paper_state.get("bounding_box", [1, 1, 0])
|
| 91 |
-
fits = paper_state.get("fits_target_box", False)
|
| 92 |
-
strain = paper_state.get("max_strain", 0.0)
|
| 93 |
-
kaw = paper_state.get("kawasaki_violations", 0)
|
| 94 |
-
target_box = task.get("target_box", [1, 0.5, 0.02])
|
| 95 |
-
max_folds = task.get("max_folds", 3)
|
| 96 |
-
|
| 97 |
-
delta = compactness - prev_compactness[0]
|
| 98 |
-
prev_compactness[0] = compactness
|
| 99 |
-
|
| 100 |
-
# Summarise what has been done so far
|
| 101 |
-
history_lines = ""
|
| 102 |
-
if fold_history:
|
| 103 |
-
history_lines = "Folds applied so far:\n"
|
| 104 |
-
for i, f in enumerate(fold_history, 1):
|
| 105 |
-
t = f.get("type", "?")
|
| 106 |
-
ln = f.get("line", {})
|
| 107 |
-
s = ln.get("start", [0, 0])
|
| 108 |
-
e = ln.get("end", [1, 1])
|
| 109 |
-
ang = f.get("angle", 180)
|
| 110 |
-
history_lines += (
|
| 111 |
-
f" {i}. {t} fold "
|
| 112 |
-
f"from ({s[0]:.3f},{s[1]:.3f}) to ({e[0]:.3f},{e[1]:.3f}) "
|
| 113 |
-
f"angle={ang}\n"
|
| 114 |
-
)
|
| 115 |
-
else:
|
| 116 |
-
history_lines = "No folds applied yet — paper is flat.\n"
|
| 117 |
-
|
| 118 |
-
sign = "+" if delta >= 0 else ""
|
| 119 |
-
user_msg = (
|
| 120 |
-
f"Task: {task['description']}\n"
|
| 121 |
-
f"Sheet: {task['width']}×{task['height']} {task['material']}\n"
|
| 122 |
-
f"Target bounding box: {target_box} (must fit inside to succeed)\n"
|
| 123 |
-
f"Max folds remaining: {max_folds - fold_count}\n"
|
| 124 |
-
f"\n"
|
| 125 |
-
f"{history_lines}"
|
| 126 |
-
f"\n"
|
| 127 |
-
f"Current state after fold {fold_count}/{max_folds}:\n"
|
| 128 |
-
f" compactness : {compactness:.4f} (Δ {sign}{delta:.4f})\n"
|
| 129 |
-
f" bounding_box: [{bb[0]:.4f}, {bb[1]:.4f}, {bb[2]:.5f}]\n"
|
| 130 |
-
f" fits_target : {'YES ✓' if fits else 'no'}\n"
|
| 131 |
-
f" max_strain : {strain:.5f}\n"
|
| 132 |
-
f" kaw_violations: {kaw}\n"
|
| 133 |
-
f"\n"
|
| 134 |
-
f"Approach hint: {hint}\n"
|
| 135 |
-
f"\n"
|
| 136 |
-
f"What is your next fold action? "
|
| 137 |
-
f"Return \"stop\" if the target is already achieved or no useful fold remains."
|
| 138 |
-
)
|
| 139 |
-
|
| 140 |
-
history.append({"role": "user", "content": user_msg})
|
| 141 |
-
|
| 142 |
-
response = client.messages.create(
|
| 143 |
-
model=MODEL,
|
| 144 |
-
max_tokens=150,
|
| 145 |
-
system=SYSTEM_PROMPT,
|
| 146 |
-
messages=history,
|
| 147 |
-
)
|
| 148 |
-
reply = response.content[0].text.strip()
|
| 149 |
-
history.append({"role": "assistant", "content": reply})
|
| 150 |
-
|
| 151 |
-
# Handle explicit "stop" text before JSON parse
|
| 152 |
-
if reply.lower().startswith("stop") or '"type": "stop"' in reply:
|
| 153 |
-
return {"type": "stop", "line": {"start": [0, 0.5], "end": [1, 0.5]}, "angle": 0.0}
|
| 154 |
-
|
| 155 |
-
# Extract JSON — handles markdown code fences
|
| 156 |
-
match = re.search(r'\{[^{}]+\}', reply, re.DOTALL)
|
| 157 |
-
if not match:
|
| 158 |
-
# Malformed response — default safe fold then stop next turn
|
| 159 |
-
return {"type": "valley", "line": {"start": [0.0, 0.5], "end": [1.0, 0.5]}, "angle": 180.0}
|
| 160 |
-
|
| 161 |
-
try:
|
| 162 |
-
fold_dict = json.loads(match.group())
|
| 163 |
-
except json.JSONDecodeError:
|
| 164 |
-
return {"type": "valley", "line": {"start": [0.0, 0.5], "end": [1.0, 0.5]}, "angle": 180.0}
|
| 165 |
-
|
| 166 |
-
fold_dict.setdefault("type", "valley")
|
| 167 |
-
fold_dict.setdefault("line", {"start": [0.0, 0.5], "end": [1.0, 0.5]})
|
| 168 |
-
fold_dict.setdefault("angle", 180.0)
|
| 169 |
-
return fold_dict
|
| 170 |
-
|
| 171 |
-
return strategy
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
# ── Episode runner ─────────────────────────────────────────────────────────────
|
| 175 |
-
|
| 176 |
-
def run_episode_llm(
|
| 177 |
-
strategy_fn,
|
| 178 |
-
task_name: str,
|
| 179 |
-
ep_id: str,
|
| 180 |
-
broadcast_fn,
|
| 181 |
-
) -> dict:
|
| 182 |
-
env = OrigamiEnvironment()
|
| 183 |
-
obs = env.reset(task_name=task_name)
|
| 184 |
-
task = env._task or {}
|
| 185 |
-
|
| 186 |
-
broadcast_fn(ep_id, {
|
| 187 |
-
"type": "episode_update",
|
| 188 |
-
"episode_id": ep_id,
|
| 189 |
-
"task_name": task_name,
|
| 190 |
-
"step": 0,
|
| 191 |
-
"observation": _obs_dict(obs),
|
| 192 |
-
})
|
| 193 |
-
|
| 194 |
-
max_steps = task.get("max_folds", 5)
|
| 195 |
-
status = "done"
|
| 196 |
-
|
| 197 |
-
for step_idx in range(max_steps):
|
| 198 |
-
if obs.done:
|
| 199 |
-
break
|
| 200 |
-
|
| 201 |
-
# Merge paper_state + metrics for the strategy
|
| 202 |
-
ps = dict(obs.paper_state)
|
| 203 |
-
ps.update(obs.metrics)
|
| 204 |
-
ps["fold_count"] = step_idx
|
| 205 |
-
|
| 206 |
-
try:
|
| 207 |
-
fold_dict = strategy_fn(ps, list(obs.fold_history))
|
| 208 |
-
except Exception as exc:
|
| 209 |
-
broadcast_fn(ep_id, {
|
| 210 |
-
"type": "episode_done", "episode_id": ep_id,
|
| 211 |
-
"status": "error", "score": 0.0,
|
| 212 |
-
"final_metrics": obs.metrics, "error": str(exc),
|
| 213 |
-
})
|
| 214 |
-
return {"episode_id": ep_id, "score": 0.0, "status": "error"}
|
| 215 |
-
|
| 216 |
-
if fold_dict.get("type") == "stop":
|
| 217 |
-
break
|
| 218 |
-
|
| 219 |
-
time.sleep(0.5) # pace for viewer animation
|
| 220 |
-
|
| 221 |
-
action = OrigamiAction(
|
| 222 |
-
fold_type=fold_dict["type"],
|
| 223 |
-
fold_line=fold_dict["line"],
|
| 224 |
-
fold_angle=float(fold_dict.get("angle", 180.0)),
|
| 225 |
-
)
|
| 226 |
-
obs = env.step(action)
|
| 227 |
-
|
| 228 |
-
broadcast_fn(ep_id, {
|
| 229 |
-
"type": "episode_update",
|
| 230 |
-
"episode_id": ep_id,
|
| 231 |
-
"task_name": task_name,
|
| 232 |
-
"step": step_idx + 1,
|
| 233 |
-
"observation": _obs_dict(obs),
|
| 234 |
-
})
|
| 235 |
-
|
| 236 |
-
if obs.done:
|
| 237 |
-
break
|
| 238 |
-
else:
|
| 239 |
-
status = "timeout"
|
| 240 |
-
|
| 241 |
-
score = obs.reward if obs.reward is not None else (env._total_reward or 0.0)
|
| 242 |
-
broadcast_fn(ep_id, {
|
| 243 |
-
"type": "episode_done",
|
| 244 |
-
"episode_id": ep_id,
|
| 245 |
-
"status": status,
|
| 246 |
-
"score": float(score),
|
| 247 |
-
"final_metrics": obs.metrics,
|
| 248 |
-
})
|
| 249 |
-
|
| 250 |
-
return {"episode_id": ep_id, "score": float(score), "status": status}
|
| 251 |
-
|
| 252 |
-
|
| 253 |
-
def _obs_dict(obs) -> dict:
|
| 254 |
-
try:
|
| 255 |
-
return obs.model_dump()
|
| 256 |
-
except AttributeError:
|
| 257 |
-
return {
|
| 258 |
-
"paper_state": getattr(obs, "paper_state", {}),
|
| 259 |
-
"metrics": getattr(obs, "metrics", {}),
|
| 260 |
-
"fold_history": getattr(obs, "fold_history", []),
|
| 261 |
-
"done": getattr(obs, "done", False),
|
| 262 |
-
"reward": getattr(obs, "reward", None),
|
| 263 |
-
}
|
| 264 |
-
|
| 265 |
-
|
| 266 |
-
# ── Main ───────────────────────────────────────────────────────��──────────────
|
| 267 |
-
|
| 268 |
-
async def run_demo() -> None:
|
| 269 |
-
api_key = os.environ.get("ANTHROPIC_API_KEY")
|
| 270 |
-
if not api_key:
|
| 271 |
-
raise RuntimeError("Set ANTHROPIC_API_KEY environment variable")
|
| 272 |
-
|
| 273 |
-
client = anthropic.Anthropic(api_key=api_key)
|
| 274 |
-
task = get_task_by_name(TASK_NAME)
|
| 275 |
-
|
| 276 |
-
await asyncio.sleep(1.5) # wait for server startup
|
| 277 |
-
|
| 278 |
-
print(f"\n[llm-demo] Model : {MODEL}")
|
| 279 |
-
print(f"[llm-demo] Task : {TASK_NAME} — {task['description']}")
|
| 280 |
-
print(f"[llm-demo] Open : http://localhost:9001/viewer/training.html\n")
|
| 281 |
-
print(f"[llm-demo] Episodes:")
|
| 282 |
-
for i, hint in enumerate(APPROACH_HINTS):
|
| 283 |
-
print(f" ep_{i:02d} hint: {hint}")
|
| 284 |
-
print()
|
| 285 |
-
|
| 286 |
-
await broadcast.start_batch(1, NUM_EPISODES)
|
| 287 |
-
|
| 288 |
-
ep_ids = [f"ep_{i:02d}" for i in range(NUM_EPISODES)]
|
| 289 |
-
strategies = [make_llm_strategy(client, task, i) for i in range(NUM_EPISODES)]
|
| 290 |
-
|
| 291 |
-
results = await asyncio.gather(*[
|
| 292 |
-
asyncio.to_thread(run_episode_llm, fn, TASK_NAME, ep_id, broadcast.publish)
|
| 293 |
-
for fn, ep_id in zip(strategies, ep_ids)
|
| 294 |
-
])
|
| 295 |
-
|
| 296 |
-
scores = [r["score"] for r in results]
|
| 297 |
-
best_idx = max(range(len(scores)), key=lambda i: scores[i])
|
| 298 |
-
|
| 299 |
-
await broadcast.finish_batch(1, scores, best_episode_id=ep_ids[best_idx])
|
| 300 |
-
|
| 301 |
-
print("\n[llm-demo] Results:")
|
| 302 |
-
for i, (result, hint) in enumerate(zip(results, APPROACH_HINTS)):
|
| 303 |
-
marker = " ← best" if i == best_idx else ""
|
| 304 |
-
print(f" ep_{i:02d} score={result['score']:+.2f} status={result['status']} hint: {hint}{marker}")
|
| 305 |
-
print(f"\n[llm-demo] Press Ctrl+C to stop.\n")
|
| 306 |
-
|
| 307 |
-
|
| 308 |
-
async def _main() -> None:
|
| 309 |
-
config = uvicorn.Config(app, host="0.0.0.0", port=9001, log_level="warning")
|
| 310 |
-
server = uvicorn.Server(config)
|
| 311 |
-
await asyncio.gather(server.serve(), run_demo())
|
| 312 |
-
|
| 313 |
-
|
| 314 |
-
if __name__ == "__main__":
|
| 315 |
-
try:
|
| 316 |
-
asyncio.run(_main())
|
| 317 |
-
except KeyboardInterrupt:
|
| 318 |
-
print("\n[llm-demo] Stopped.")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
training/runner.py
DELETED
|
@@ -1,191 +0,0 @@
|
|
| 1 |
-
"""
|
| 2 |
-
TrainingRunner — parallel episode executor for GRPO training.
|
| 3 |
-
|
| 4 |
-
Each episode runs in a ThreadPoolExecutor thread.
|
| 5 |
-
After every env.step(), observations are pushed to the broadcast server (fire-and-forget).
|
| 6 |
-
"""
|
| 7 |
-
from __future__ import annotations
|
| 8 |
-
|
| 9 |
-
import uuid
|
| 10 |
-
from concurrent.futures import ThreadPoolExecutor, as_completed
|
| 11 |
-
from typing import Any, Callable, Optional
|
| 12 |
-
|
| 13 |
-
from server.models import OrigamiAction
|
| 14 |
-
from server.origami_environment import OrigamiEnvironment
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
BroadcastFn = Callable[[str, dict], None]
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
def run_episode(
|
| 21 |
-
strategy_fn: Callable[[dict], dict],
|
| 22 |
-
task_name: str,
|
| 23 |
-
ep_id: Optional[str] = None,
|
| 24 |
-
broadcast_fn: Optional[BroadcastFn] = None,
|
| 25 |
-
max_steps: Optional[int] = None,
|
| 26 |
-
) -> dict:
|
| 27 |
-
"""Run a single origami episode with a given strategy function.
|
| 28 |
-
|
| 29 |
-
Args:
|
| 30 |
-
strategy_fn: Callable that receives paper_state dict and returns a fold dict:
|
| 31 |
-
{"type": "valley"|"mountain"|"pleat"|"crimp"|"stop",
|
| 32 |
-
"line": {"start": [x, y], "end": [x, y]},
|
| 33 |
-
"angle": 180.0}
|
| 34 |
-
task_name: Name of the task (from server/tasks.py)
|
| 35 |
-
ep_id: Episode identifier for broadcast; auto-generated if None
|
| 36 |
-
broadcast_fn: Optional callback(ep_id, data) for live streaming
|
| 37 |
-
max_steps: Override task's max_folds if provided
|
| 38 |
-
|
| 39 |
-
Returns:
|
| 40 |
-
dict with keys: episode_id, score, final_metrics, fold_history, status
|
| 41 |
-
"""
|
| 42 |
-
ep_id = ep_id or str(uuid.uuid4())[:8]
|
| 43 |
-
env = OrigamiEnvironment()
|
| 44 |
-
|
| 45 |
-
obs = env.reset(task_name=task_name)
|
| 46 |
-
|
| 47 |
-
if broadcast_fn:
|
| 48 |
-
broadcast_fn(ep_id, {
|
| 49 |
-
"type": "episode_update",
|
| 50 |
-
"episode_id": ep_id,
|
| 51 |
-
"task_name": task_name,
|
| 52 |
-
"step": 0,
|
| 53 |
-
"observation": _obs_to_dict(obs),
|
| 54 |
-
})
|
| 55 |
-
|
| 56 |
-
step_limit = max_steps or env._task.get("max_folds", 20) if env._task else 20
|
| 57 |
-
status = "done"
|
| 58 |
-
|
| 59 |
-
for step_idx in range(step_limit):
|
| 60 |
-
if obs.done:
|
| 61 |
-
break
|
| 62 |
-
|
| 63 |
-
# Strategy generates a fold dict
|
| 64 |
-
try:
|
| 65 |
-
fold_dict = strategy_fn(obs.paper_state)
|
| 66 |
-
except Exception as exc:
|
| 67 |
-
status = "error"
|
| 68 |
-
if broadcast_fn:
|
| 69 |
-
broadcast_fn(ep_id, {
|
| 70 |
-
"type": "episode_done",
|
| 71 |
-
"episode_id": ep_id,
|
| 72 |
-
"status": "error",
|
| 73 |
-
"score": obs.reward or 0.0,
|
| 74 |
-
"final_metrics": obs.metrics,
|
| 75 |
-
"error": str(exc),
|
| 76 |
-
})
|
| 77 |
-
break
|
| 78 |
-
|
| 79 |
-
fold_type = fold_dict.get("type", "valley")
|
| 80 |
-
fold_line = fold_dict.get("line", {"start": [0, 0.5], "end": [1, 0.5]})
|
| 81 |
-
fold_angle = float(fold_dict.get("angle", 180.0))
|
| 82 |
-
|
| 83 |
-
action = OrigamiAction(
|
| 84 |
-
fold_type=fold_type,
|
| 85 |
-
fold_line=fold_line,
|
| 86 |
-
fold_angle=fold_angle,
|
| 87 |
-
)
|
| 88 |
-
obs = env.step(action)
|
| 89 |
-
|
| 90 |
-
if broadcast_fn:
|
| 91 |
-
broadcast_fn(ep_id, {
|
| 92 |
-
"type": "episode_update",
|
| 93 |
-
"episode_id": ep_id,
|
| 94 |
-
"task_name": task_name,
|
| 95 |
-
"step": step_idx + 1,
|
| 96 |
-
"observation": _obs_to_dict(obs),
|
| 97 |
-
})
|
| 98 |
-
|
| 99 |
-
if obs.done:
|
| 100 |
-
break
|
| 101 |
-
else:
|
| 102 |
-
status = "timeout"
|
| 103 |
-
|
| 104 |
-
score = obs.reward if obs.reward is not None else (env._total_reward or 0.0)
|
| 105 |
-
|
| 106 |
-
if broadcast_fn:
|
| 107 |
-
broadcast_fn(ep_id, {
|
| 108 |
-
"type": "episode_done",
|
| 109 |
-
"episode_id": ep_id,
|
| 110 |
-
"status": status,
|
| 111 |
-
"score": float(score),
|
| 112 |
-
"final_metrics": obs.metrics,
|
| 113 |
-
})
|
| 114 |
-
|
| 115 |
-
return {
|
| 116 |
-
"episode_id": ep_id,
|
| 117 |
-
"score": float(score),
|
| 118 |
-
"final_metrics": obs.metrics,
|
| 119 |
-
"fold_history": obs.fold_history,
|
| 120 |
-
"status": status,
|
| 121 |
-
}
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
def run_batch(
|
| 125 |
-
strategy_fns: list[Callable[[dict], dict]],
|
| 126 |
-
task_name: str,
|
| 127 |
-
broadcast_fn: Optional[BroadcastFn] = None,
|
| 128 |
-
batch_id: Optional[int] = None,
|
| 129 |
-
max_workers: int = 8,
|
| 130 |
-
) -> list[dict]:
|
| 131 |
-
"""Run G episodes in parallel with a ThreadPoolExecutor.
|
| 132 |
-
|
| 133 |
-
Args:
|
| 134 |
-
strategy_fns: List of G strategy callables (one per completion)
|
| 135 |
-
task_name: Task to use for all episodes
|
| 136 |
-
broadcast_fn: Optional broadcast callback, called after each step
|
| 137 |
-
batch_id: Batch identifier for broadcast
|
| 138 |
-
max_workers: Max parallel threads (bounded by G)
|
| 139 |
-
|
| 140 |
-
Returns:
|
| 141 |
-
List of episode result dicts, in same order as strategy_fns
|
| 142 |
-
"""
|
| 143 |
-
n = len(strategy_fns)
|
| 144 |
-
ep_ids = [f"ep_{(batch_id or 0):04d}_{i:02d}" for i in range(n)]
|
| 145 |
-
workers = min(max_workers, n)
|
| 146 |
-
|
| 147 |
-
results: list[dict] = [{}] * n
|
| 148 |
-
|
| 149 |
-
with ThreadPoolExecutor(max_workers=workers) as pool:
|
| 150 |
-
futures = {
|
| 151 |
-
pool.submit(
|
| 152 |
-
run_episode,
|
| 153 |
-
fn,
|
| 154 |
-
task_name,
|
| 155 |
-
ep_ids[i],
|
| 156 |
-
broadcast_fn,
|
| 157 |
-
): i
|
| 158 |
-
for i, fn in enumerate(strategy_fns)
|
| 159 |
-
}
|
| 160 |
-
|
| 161 |
-
for future in as_completed(futures):
|
| 162 |
-
idx = futures[future]
|
| 163 |
-
try:
|
| 164 |
-
results[idx] = future.result()
|
| 165 |
-
except Exception as exc:
|
| 166 |
-
results[idx] = {
|
| 167 |
-
"episode_id": ep_ids[idx],
|
| 168 |
-
"score": 0.0,
|
| 169 |
-
"final_metrics": {},
|
| 170 |
-
"fold_history": [],
|
| 171 |
-
"status": "error",
|
| 172 |
-
"error": str(exc),
|
| 173 |
-
}
|
| 174 |
-
|
| 175 |
-
return results
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
def _obs_to_dict(obs) -> dict:
|
| 179 |
-
"""Convert OrigamiObservation to a JSON-serializable dict."""
|
| 180 |
-
try:
|
| 181 |
-
return obs.model_dump()
|
| 182 |
-
except AttributeError:
|
| 183 |
-
return {
|
| 184 |
-
"task": obs.task if hasattr(obs, "task") else {},
|
| 185 |
-
"paper_state": obs.paper_state if hasattr(obs, "paper_state") else {},
|
| 186 |
-
"metrics": obs.metrics if hasattr(obs, "metrics") else {},
|
| 187 |
-
"fold_history": obs.fold_history if hasattr(obs, "fold_history") else [],
|
| 188 |
-
"done": obs.done if hasattr(obs, "done") else False,
|
| 189 |
-
"reward": obs.reward if hasattr(obs, "reward") else None,
|
| 190 |
-
"error": obs.error if hasattr(obs, "error") else None,
|
| 191 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
viz/__init__.py
DELETED
|
File without changes
|
viz/renderer.py
DELETED
|
@@ -1,315 +0,0 @@
|
|
| 1 |
-
"""
|
| 2 |
-
Matplotlib-based crease pattern renderer.
|
| 3 |
-
Used for quick observability during training and debugging.
|
| 4 |
-
"""
|
| 5 |
-
import json
|
| 6 |
-
import numpy as np
|
| 7 |
-
import matplotlib.pyplot as plt
|
| 8 |
-
import matplotlib.patches as patches
|
| 9 |
-
from matplotlib.animation import FuncAnimation
|
| 10 |
-
from typing import Optional
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
# Design system colors
|
| 14 |
-
_COLOR_MOUNTAIN = "#f59e0b"
|
| 15 |
-
_COLOR_VALLEY = "#38bdf8"
|
| 16 |
-
_COLOR_PAPER = "#fafaf5"
|
| 17 |
-
_COLOR_PAPER_EDGE = "#e2e8f0"
|
| 18 |
-
_COLOR_AX_BG = "#1a1a2e"
|
| 19 |
-
_COLOR_ANCHOR = "#4a4a6a"
|
| 20 |
-
_COLOR_REWARD_BG = "#13131d"
|
| 21 |
-
_COLOR_GRID = "#2a2a3a"
|
| 22 |
-
_COLOR_VALIDITY = "#22d3ee"
|
| 23 |
-
_COLOR_PROGRESS = "#22c55e"
|
| 24 |
-
_COLOR_ECONOMY = "#a78bfa"
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
def draw_paper_state(ax, paper_state, target=None, step=None, reward=None):
|
| 28 |
-
"""
|
| 29 |
-
Draw the current crease pattern on a matplotlib axes object.
|
| 30 |
-
|
| 31 |
-
Args:
|
| 32 |
-
ax: matplotlib axes
|
| 33 |
-
paper_state: PaperState instance
|
| 34 |
-
target: optional FOLD dict for target crease ghost overlay
|
| 35 |
-
step: step number for title (None = "Initial")
|
| 36 |
-
reward: unused, kept for signature compatibility
|
| 37 |
-
"""
|
| 38 |
-
ax.set_facecolor(_COLOR_AX_BG)
|
| 39 |
-
|
| 40 |
-
# Unit square paper
|
| 41 |
-
square = patches.Rectangle(
|
| 42 |
-
(0, 0), 1, 1,
|
| 43 |
-
facecolor=_COLOR_PAPER,
|
| 44 |
-
edgecolor=_COLOR_PAPER_EDGE,
|
| 45 |
-
linewidth=1.5,
|
| 46 |
-
zorder=1,
|
| 47 |
-
)
|
| 48 |
-
ax.add_patch(square)
|
| 49 |
-
|
| 50 |
-
# Target ghost overlay
|
| 51 |
-
if target is not None:
|
| 52 |
-
verts = target["vertices_coords"]
|
| 53 |
-
edges_v = target["edges_vertices"]
|
| 54 |
-
edges_a = target["edges_assignment"]
|
| 55 |
-
for (v1, v2), assignment in zip(edges_v, edges_a):
|
| 56 |
-
if assignment not in ("M", "V"):
|
| 57 |
-
continue
|
| 58 |
-
x1, y1 = verts[v1]
|
| 59 |
-
x2, y2 = verts[v2]
|
| 60 |
-
color = _COLOR_MOUNTAIN if assignment == "M" else _COLOR_VALLEY
|
| 61 |
-
ax.plot(
|
| 62 |
-
[x1, x2], [y1, y2],
|
| 63 |
-
color=color,
|
| 64 |
-
alpha=0.2,
|
| 65 |
-
linewidth=1,
|
| 66 |
-
linestyle="--",
|
| 67 |
-
zorder=2,
|
| 68 |
-
)
|
| 69 |
-
|
| 70 |
-
# Current crease edges
|
| 71 |
-
for edge in paper_state.crease_edges():
|
| 72 |
-
x1, y1 = edge["v1"]
|
| 73 |
-
x2, y2 = edge["v2"]
|
| 74 |
-
assignment = edge["assignment"]
|
| 75 |
-
color = _COLOR_MOUNTAIN if assignment == "M" else _COLOR_VALLEY
|
| 76 |
-
ax.plot(
|
| 77 |
-
[x1, x2], [y1, y2],
|
| 78 |
-
color=color,
|
| 79 |
-
linewidth=2.5,
|
| 80 |
-
linestyle="-",
|
| 81 |
-
solid_capstyle="round",
|
| 82 |
-
zorder=3,
|
| 83 |
-
)
|
| 84 |
-
# Endpoint dots
|
| 85 |
-
ax.plot(
|
| 86 |
-
[x1, x2], [y1, y2],
|
| 87 |
-
color=color,
|
| 88 |
-
marker="o",
|
| 89 |
-
markersize=5,
|
| 90 |
-
linestyle="none",
|
| 91 |
-
zorder=4,
|
| 92 |
-
)
|
| 93 |
-
|
| 94 |
-
# Anchor points as gray crosses
|
| 95 |
-
for x, y in paper_state.anchor_points():
|
| 96 |
-
ax.plot(
|
| 97 |
-
x, y,
|
| 98 |
-
color=_COLOR_ANCHOR,
|
| 99 |
-
marker="+",
|
| 100 |
-
markersize=3,
|
| 101 |
-
linestyle="none",
|
| 102 |
-
zorder=5,
|
| 103 |
-
)
|
| 104 |
-
|
| 105 |
-
# Title
|
| 106 |
-
title = f"Step {step}" if step is not None else "Initial"
|
| 107 |
-
ax.set_title(title, color="white", fontfamily="monospace", fontsize=10, pad=6)
|
| 108 |
-
|
| 109 |
-
# Remove ticks and spines
|
| 110 |
-
ax.set_xticks([])
|
| 111 |
-
ax.set_yticks([])
|
| 112 |
-
for spine in ax.spines.values():
|
| 113 |
-
spine.set_visible(False)
|
| 114 |
-
|
| 115 |
-
ax.set_xlim(-0.05, 1.05)
|
| 116 |
-
ax.set_ylim(-0.05, 1.05)
|
| 117 |
-
ax.set_aspect("equal")
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
def draw_reward_bars(ax, reward: dict):
|
| 121 |
-
"""
|
| 122 |
-
Draw a horizontal bar chart of reward components.
|
| 123 |
-
|
| 124 |
-
Args:
|
| 125 |
-
ax: matplotlib axes
|
| 126 |
-
reward: dict with keys kawasaki, maekawa, blb, progress, economy (all 0-1)
|
| 127 |
-
"""
|
| 128 |
-
components = ["kawasaki", "maekawa", "blb", "progress", "economy"]
|
| 129 |
-
colors = {
|
| 130 |
-
"kawasaki": _COLOR_VALIDITY,
|
| 131 |
-
"maekawa": _COLOR_VALIDITY,
|
| 132 |
-
"blb": _COLOR_VALIDITY,
|
| 133 |
-
"progress": _COLOR_PROGRESS,
|
| 134 |
-
"economy": _COLOR_ECONOMY,
|
| 135 |
-
}
|
| 136 |
-
|
| 137 |
-
values = [float(reward.get(c, 0.0)) for c in components]
|
| 138 |
-
|
| 139 |
-
ax.set_facecolor(_COLOR_REWARD_BG)
|
| 140 |
-
|
| 141 |
-
bar_colors = [colors[c] for c in components]
|
| 142 |
-
bars = ax.barh(
|
| 143 |
-
components,
|
| 144 |
-
values,
|
| 145 |
-
height=0.6,
|
| 146 |
-
color=bar_colors,
|
| 147 |
-
zorder=2,
|
| 148 |
-
)
|
| 149 |
-
|
| 150 |
-
# Value labels at end of each bar
|
| 151 |
-
for bar, val in zip(bars, values):
|
| 152 |
-
ax.text(
|
| 153 |
-
min(val + 0.02, 0.98),
|
| 154 |
-
bar.get_y() + bar.get_height() / 2,
|
| 155 |
-
f"{val:.2f}",
|
| 156 |
-
va="center",
|
| 157 |
-
ha="left",
|
| 158 |
-
color="white",
|
| 159 |
-
fontfamily="monospace",
|
| 160 |
-
fontsize=8,
|
| 161 |
-
zorder=3,
|
| 162 |
-
)
|
| 163 |
-
|
| 164 |
-
# Y-axis label style
|
| 165 |
-
ax.tick_params(axis="y", colors="white", labelsize=8)
|
| 166 |
-
for label in ax.get_yticklabels():
|
| 167 |
-
label.set_fontfamily("monospace")
|
| 168 |
-
|
| 169 |
-
# Subtle x gridlines
|
| 170 |
-
for x_pos in [0.25, 0.5, 0.75, 1.0]:
|
| 171 |
-
ax.axvline(x_pos, color=_COLOR_GRID, linewidth=0.8, zorder=1)
|
| 172 |
-
|
| 173 |
-
ax.set_xlim(0, 1.0)
|
| 174 |
-
ax.set_xticks([])
|
| 175 |
-
ax.tick_params(axis="x", colors="white")
|
| 176 |
-
for spine in ax.spines.values():
|
| 177 |
-
spine.set_visible(False)
|
| 178 |
-
|
| 179 |
-
ax.set_title("Reward Breakdown", color="white", fontfamily="monospace", fontsize=10, pad=6)
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
def render_episode(fold_history, target, rewards_history, save_path=None):
|
| 183 |
-
"""
|
| 184 |
-
Create a multi-panel figure showing an entire episode.
|
| 185 |
-
|
| 186 |
-
Args:
|
| 187 |
-
fold_history: list of PaperState snapshots (one per step)
|
| 188 |
-
target: FOLD dict of target crease pattern
|
| 189 |
-
rewards_history: list of reward dicts (one per step)
|
| 190 |
-
save_path: if provided, save PNG here; otherwise plt.show()
|
| 191 |
-
|
| 192 |
-
Returns:
|
| 193 |
-
matplotlib Figure
|
| 194 |
-
"""
|
| 195 |
-
n_states = len(fold_history)
|
| 196 |
-
show_states = min(n_states, 4)
|
| 197 |
-
|
| 198 |
-
fig = plt.figure(figsize=(4 * show_states + 4, 5), facecolor="#0d0d14")
|
| 199 |
-
gs = fig.add_gridspec(
|
| 200 |
-
1, show_states + 1,
|
| 201 |
-
width_ratios=[1] * show_states + [1.2],
|
| 202 |
-
wspace=0.3,
|
| 203 |
-
)
|
| 204 |
-
|
| 205 |
-
# Paper state panels (up to 4)
|
| 206 |
-
for i in range(show_states):
|
| 207 |
-
# Evenly sample from fold_history if more than 4 steps
|
| 208 |
-
idx = int(i * (n_states - 1) / max(show_states - 1, 1)) if show_states > 1 else 0
|
| 209 |
-
ax = fig.add_subplot(gs[0, i])
|
| 210 |
-
draw_paper_state(
|
| 211 |
-
ax,
|
| 212 |
-
fold_history[idx],
|
| 213 |
-
target=target,
|
| 214 |
-
step=idx + 1,
|
| 215 |
-
reward=rewards_history[idx] if idx < len(rewards_history) else None,
|
| 216 |
-
)
|
| 217 |
-
|
| 218 |
-
# Reward curves panel
|
| 219 |
-
ax_reward = fig.add_subplot(gs[0, show_states])
|
| 220 |
-
ax_reward.set_facecolor(_COLOR_REWARD_BG)
|
| 221 |
-
|
| 222 |
-
steps = list(range(1, len(rewards_history) + 1))
|
| 223 |
-
curve_specs = [
|
| 224 |
-
("progress", _COLOR_PROGRESS, "progress"),
|
| 225 |
-
("kawasaki", _COLOR_VALIDITY, "kawasaki"),
|
| 226 |
-
("total", "#f8fafc", "total"),
|
| 227 |
-
]
|
| 228 |
-
|
| 229 |
-
for key, color, label in curve_specs:
|
| 230 |
-
vals = [r.get(key, 0.0) for r in rewards_history]
|
| 231 |
-
ax_reward.plot(steps, vals, color=color, linewidth=1.5, label=label)
|
| 232 |
-
|
| 233 |
-
ax_reward.set_xlim(1, max(len(rewards_history), 1))
|
| 234 |
-
ax_reward.set_title("Reward Curves", color="white", fontfamily="monospace", fontsize=10, pad=6)
|
| 235 |
-
ax_reward.tick_params(colors="white", labelsize=8)
|
| 236 |
-
ax_reward.legend(
|
| 237 |
-
fontsize=7,
|
| 238 |
-
facecolor=_COLOR_REWARD_BG,
|
| 239 |
-
edgecolor=_COLOR_GRID,
|
| 240 |
-
labelcolor="white",
|
| 241 |
-
)
|
| 242 |
-
for spine in ax_reward.spines.values():
|
| 243 |
-
spine.set_color(_COLOR_GRID)
|
| 244 |
-
|
| 245 |
-
if save_path:
|
| 246 |
-
fig.savefig(save_path, dpi=150, facecolor="#0d0d14", bbox_inches="tight")
|
| 247 |
-
else:
|
| 248 |
-
plt.show()
|
| 249 |
-
|
| 250 |
-
return fig
|
| 251 |
-
|
| 252 |
-
|
| 253 |
-
def render_training_curves(log_path: str):
|
| 254 |
-
"""
|
| 255 |
-
Read a JSONL log file and plot training curves.
|
| 256 |
-
|
| 257 |
-
Each line must be a JSON object with reward component keys.
|
| 258 |
-
|
| 259 |
-
Args:
|
| 260 |
-
log_path: path to JSONL training log
|
| 261 |
-
|
| 262 |
-
Returns:
|
| 263 |
-
matplotlib Figure
|
| 264 |
-
"""
|
| 265 |
-
records = []
|
| 266 |
-
with open(log_path) as f:
|
| 267 |
-
for line in f:
|
| 268 |
-
line = line.strip()
|
| 269 |
-
if not line:
|
| 270 |
-
continue
|
| 271 |
-
records.append(json.loads(line))
|
| 272 |
-
|
| 273 |
-
episodes = list(range(1, len(records) + 1))
|
| 274 |
-
|
| 275 |
-
keys_to_plot = [
|
| 276 |
-
("total", "#f8fafc", "total reward"),
|
| 277 |
-
("progress", _COLOR_PROGRESS, "progress"),
|
| 278 |
-
("kawasaki", _COLOR_VALIDITY, "kawasaki"),
|
| 279 |
-
("maekawa", _COLOR_VALIDITY, "maekawa"),
|
| 280 |
-
("blb", _COLOR_VALIDITY, "blb"),
|
| 281 |
-
]
|
| 282 |
-
|
| 283 |
-
fig, axes = plt.subplots(
|
| 284 |
-
2, 1,
|
| 285 |
-
figsize=(10, 6),
|
| 286 |
-
facecolor="#0d0d14",
|
| 287 |
-
gridspec_kw={"hspace": 0.4},
|
| 288 |
-
)
|
| 289 |
-
|
| 290 |
-
# Top: total + progress
|
| 291 |
-
ax_top = axes[0]
|
| 292 |
-
ax_top.set_facecolor(_COLOR_REWARD_BG)
|
| 293 |
-
for key, color, label in keys_to_plot[:2]:
|
| 294 |
-
vals = [r.get(key, 0.0) for r in records]
|
| 295 |
-
ax_top.plot(episodes, vals, color=color, linewidth=1.5, label=label)
|
| 296 |
-
ax_top.set_title("Training: Total & Progress", color="white", fontfamily="monospace", fontsize=10)
|
| 297 |
-
ax_top.tick_params(colors="white", labelsize=8)
|
| 298 |
-
ax_top.legend(fontsize=8, facecolor=_COLOR_REWARD_BG, edgecolor=_COLOR_GRID, labelcolor="white")
|
| 299 |
-
for spine in ax_top.spines.values():
|
| 300 |
-
spine.set_color(_COLOR_GRID)
|
| 301 |
-
|
| 302 |
-
# Bottom: kawasaki, maekawa, blb
|
| 303 |
-
ax_bot = axes[1]
|
| 304 |
-
ax_bot.set_facecolor(_COLOR_REWARD_BG)
|
| 305 |
-
for key, color, label in keys_to_plot[2:]:
|
| 306 |
-
vals = [r.get(key, 0.0) for r in records]
|
| 307 |
-
ax_bot.plot(episodes, vals, color=color, linewidth=1.5, label=label, alpha=0.85)
|
| 308 |
-
ax_bot.set_title("Training: Validity Checks", color="white", fontfamily="monospace", fontsize=10)
|
| 309 |
-
ax_bot.set_xlabel("Episode", color="white", fontsize=9)
|
| 310 |
-
ax_bot.tick_params(colors="white", labelsize=8)
|
| 311 |
-
ax_bot.legend(fontsize=8, facecolor=_COLOR_REWARD_BG, edgecolor=_COLOR_GRID, labelcolor="white")
|
| 312 |
-
for spine in ax_bot.spines.values():
|
| 313 |
-
spine.set_color(_COLOR_GRID)
|
| 314 |
-
|
| 315 |
-
return fig
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|