Spaces:
Running
Running
Commit ·
efeed27
1
Parent(s): aa44758
Add physics engine (Layer 4) and instruction planner (Layer 1)
Browse filesEngine: real origami geometry with face splitting along fold lines,
Rodrigues' rotation, bar-and-hinge energy model, Kawasaki/Maekawa
validation, triangle self-intersection detection, and deployment metrics.
Planner: instruction parser that handles "make a paper crane" style
inputs, decomposes into sub-goals via origami knowledge base (7 models,
6 bases, 13 fold operations with real coordinates), and generates
LLM-ready prompts for each step.
Rewards updated to use real engine (with mock fallback).
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- engine/__init__.py +0 -0
- engine/__pycache__/__init__.cpython-314.pyc +0 -0
- engine/__pycache__/fold_engine.cpython-314.pyc +0 -0
- engine/__pycache__/materials.cpython-314.pyc +0 -0
- engine/__pycache__/metrics.cpython-314.pyc +0 -0
- engine/__pycache__/paper.cpython-314.pyc +0 -0
- engine/__pycache__/physics.cpython-314.pyc +0 -0
- engine/__pycache__/validation.cpython-314.pyc +0 -0
- engine/fold_engine.py +207 -0
- engine/materials.py +79 -0
- engine/metrics.py +104 -0
- engine/paper.py +488 -0
- engine/physics.py +257 -0
- engine/validation.py +256 -0
- planner/__init__.py +0 -0
- planner/__pycache__/__init__.cpython-314.pyc +0 -0
- planner/__pycache__/decomposer.cpython-314.pyc +0 -0
- planner/__pycache__/knowledge.cpython-314.pyc +0 -0
- planner/__pycache__/parser.cpython-314.pyc +0 -0
- planner/__pycache__/planner.cpython-314.pyc +0 -0
- planner/decomposer.py +284 -0
- planner/knowledge.py +753 -0
- planner/parser.py +291 -0
- planner/planner.py +305 -0
- trainer/rewards.py +65 -17
- trainer/train.py +9 -2
engine/__init__.py
ADDED
|
File without changes
|
engine/__pycache__/__init__.cpython-314.pyc
ADDED
|
Binary file (147 Bytes). View file
|
|
|
engine/__pycache__/fold_engine.cpython-314.pyc
ADDED
|
Binary file (8.77 kB). View file
|
|
|
engine/__pycache__/materials.cpython-314.pyc
ADDED
|
Binary file (3.49 kB). View file
|
|
|
engine/__pycache__/metrics.cpython-314.pyc
ADDED
|
Binary file (5.63 kB). View file
|
|
|
engine/__pycache__/paper.cpython-314.pyc
ADDED
|
Binary file (24.4 kB). View file
|
|
|
engine/__pycache__/physics.cpython-314.pyc
ADDED
|
Binary file (12.8 kB). View file
|
|
|
engine/__pycache__/validation.cpython-314.pyc
ADDED
|
Binary file (11 kB). View file
|
|
|
engine/fold_engine.py
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Fold execution engine.
|
| 3 |
+
|
| 4 |
+
Applies fold operations (valley / mountain) to a Paper object using
|
| 5 |
+
Rodrigues' rotation formula, face splitting, and layer tracking.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
import math
|
| 11 |
+
from typing import Callable
|
| 12 |
+
|
| 13 |
+
import numpy as np
|
| 14 |
+
|
| 15 |
+
from .paper import Paper
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
# ────────────────────────────────────────────────────────────────────
|
| 19 |
+
# Rodrigues' rotation
|
| 20 |
+
# ────────────────────────────────────────────────────────────────────
|
| 21 |
+
|
| 22 |
+
def _rodrigues_rotate(
|
| 23 |
+
points: np.ndarray,
|
| 24 |
+
axis_point: np.ndarray,
|
| 25 |
+
axis_dir: np.ndarray,
|
| 26 |
+
angle_rad: float,
|
| 27 |
+
) -> np.ndarray:
|
| 28 |
+
"""Rotate *points* (N, 3) around an axis defined by a point and direction
|
| 29 |
+
using Rodrigues' rotation formula. Returns rotated points (N, 3)."""
|
| 30 |
+
k = axis_dir / (np.linalg.norm(axis_dir) + 1e-30)
|
| 31 |
+
translated = points - axis_point
|
| 32 |
+
cos_a = math.cos(angle_rad)
|
| 33 |
+
sin_a = math.sin(angle_rad)
|
| 34 |
+
dot_term = np.dot(translated, k).reshape(-1, 1) * k # (N,1)*(3,) broadcast
|
| 35 |
+
rotated = (
|
| 36 |
+
translated * cos_a
|
| 37 |
+
+ np.cross(k, translated) * sin_a
|
| 38 |
+
+ dot_term * (1.0 - cos_a)
|
| 39 |
+
)
|
| 40 |
+
return rotated + axis_point
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
# ────────────────────────────────────────────────────────────────────
|
| 44 |
+
# Single fold
|
| 45 |
+
# ────────────────────────────────────────────────────────────────────
|
| 46 |
+
|
| 47 |
+
def apply_fold(
|
| 48 |
+
paper: Paper,
|
| 49 |
+
fold_dict: dict,
|
| 50 |
+
) -> tuple[Paper, str | None]:
|
| 51 |
+
"""Apply a single fold to *paper* and return ``(new_paper, error_or_None)``.
|
| 52 |
+
|
| 53 |
+
*fold_dict* has the form::
|
| 54 |
+
|
| 55 |
+
{
|
| 56 |
+
"type": "valley" | "mountain",
|
| 57 |
+
"line": {"start": [x, y], "end": [x, y]},
|
| 58 |
+
"angle": 0-180,
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
Steps:
|
| 62 |
+
1. Validate inputs.
|
| 63 |
+
2. Split faces along the fold line.
|
| 64 |
+
3. Determine vertices to rotate (one side of fold line).
|
| 65 |
+
4. Rodrigues' rotation of those vertices.
|
| 66 |
+
5. Update edge assignments for new fold-line edges.
|
| 67 |
+
6. Update fold angles.
|
| 68 |
+
7. Update layer tracking.
|
| 69 |
+
"""
|
| 70 |
+
|
| 71 |
+
# ── 0. parse & validate ─────────────────────────────────────────
|
| 72 |
+
fold_type = fold_dict.get("type", "valley")
|
| 73 |
+
line = fold_dict.get("line", {})
|
| 74 |
+
angle_deg = fold_dict.get("angle", 180)
|
| 75 |
+
|
| 76 |
+
if fold_type not in ("valley", "mountain"):
|
| 77 |
+
return paper, f"Unknown fold type: {fold_type}"
|
| 78 |
+
|
| 79 |
+
try:
|
| 80 |
+
start_2d = np.array(line["start"], dtype=np.float64)[:2]
|
| 81 |
+
end_2d = np.array(line["end"], dtype=np.float64)[:2]
|
| 82 |
+
except (KeyError, TypeError, IndexError) as exc:
|
| 83 |
+
return paper, f"Invalid fold line: {exc}"
|
| 84 |
+
|
| 85 |
+
if np.linalg.norm(end_2d - start_2d) < 1e-12:
|
| 86 |
+
return paper, "Fold line has zero length"
|
| 87 |
+
|
| 88 |
+
if not (0 < angle_deg <= 180):
|
| 89 |
+
return paper, f"Angle must be in (0, 180], got {angle_deg}"
|
| 90 |
+
|
| 91 |
+
# ── 1. deep copy so the original is untouched ───────────────────
|
| 92 |
+
new_paper = paper.copy()
|
| 93 |
+
|
| 94 |
+
# ── 2. split faces along fold line ──────────────────────────────
|
| 95 |
+
try:
|
| 96 |
+
fold_edge_ids = new_paper.split_faces_along_line(start_2d, end_2d)
|
| 97 |
+
except Exception as exc:
|
| 98 |
+
return paper, f"Face split failed: {exc}"
|
| 99 |
+
|
| 100 |
+
# ── 3. determine vertices to rotate ─────────────────────────────
|
| 101 |
+
rotate_ids = new_paper.get_vertices_on_side(start_2d, end_2d, "positive")
|
| 102 |
+
|
| 103 |
+
if not rotate_ids:
|
| 104 |
+
# Try the other side — maybe the fold line is at the boundary
|
| 105 |
+
rotate_ids = new_paper.get_vertices_on_side(start_2d, end_2d, "negative")
|
| 106 |
+
if not rotate_ids:
|
| 107 |
+
return paper, "No vertices to rotate — fold line may not intersect paper"
|
| 108 |
+
|
| 109 |
+
# ── 4. Rodrigues' rotation ──────────────────────────────────────
|
| 110 |
+
sign = 1.0 if fold_type == "valley" else -1.0
|
| 111 |
+
angle_rad = sign * math.radians(angle_deg)
|
| 112 |
+
|
| 113 |
+
axis_point = np.array([start_2d[0], start_2d[1], 0.0])
|
| 114 |
+
axis_dir = np.array([end_2d[0] - start_2d[0], end_2d[1] - start_2d[1], 0.0])
|
| 115 |
+
|
| 116 |
+
pts = new_paper.vertices[rotate_ids]
|
| 117 |
+
rotated = _rodrigues_rotate(pts, axis_point, axis_dir, angle_rad)
|
| 118 |
+
new_paper.vertices[rotate_ids] = rotated
|
| 119 |
+
|
| 120 |
+
# ── 5. update edge assignments ──────────────────────────────────
|
| 121 |
+
assignment = "V" if fold_type == "valley" else "M"
|
| 122 |
+
for eidx in fold_edge_ids:
|
| 123 |
+
if eidx < len(new_paper.assignments):
|
| 124 |
+
new_paper.assignments[eidx] = assignment
|
| 125 |
+
|
| 126 |
+
# ── 6. update fold angles ───────────────────────────────────────
|
| 127 |
+
for eidx in fold_edge_ids:
|
| 128 |
+
if eidx < len(new_paper.fold_angles):
|
| 129 |
+
new_paper.fold_angles[eidx] = angle_deg * sign
|
| 130 |
+
|
| 131 |
+
# ── 7. update layer tracking ────────────────────────────────────
|
| 132 |
+
# For each pair of faces on opposite sides of the fold line, record
|
| 133 |
+
# layer ordering. Simple heuristic: faces that were rotated are now
|
| 134 |
+
# on top (sign +1) of faces that stayed put.
|
| 135 |
+
rotated_set = set(rotate_ids)
|
| 136 |
+
|
| 137 |
+
def _face_side(face_verts: list[int]) -> str:
|
| 138 |
+
"""Classify a face as 'rotated', 'fixed', or 'mixed'."""
|
| 139 |
+
r_count = sum(1 for v in face_verts if v in rotated_set)
|
| 140 |
+
if r_count == len(face_verts):
|
| 141 |
+
return "rotated"
|
| 142 |
+
if r_count == 0:
|
| 143 |
+
return "fixed"
|
| 144 |
+
return "mixed"
|
| 145 |
+
|
| 146 |
+
face_sides = [_face_side(f) for f in new_paper.faces]
|
| 147 |
+
for i in range(len(new_paper.faces)):
|
| 148 |
+
for j in range(i + 1, len(new_paper.faces)):
|
| 149 |
+
if face_sides[i] == "rotated" and face_sides[j] == "fixed":
|
| 150 |
+
new_paper.face_orders.append((i, j, 1))
|
| 151 |
+
elif face_sides[i] == "fixed" and face_sides[j] == "rotated":
|
| 152 |
+
new_paper.face_orders.append((j, i, 1))
|
| 153 |
+
|
| 154 |
+
return new_paper, None
|
| 155 |
+
|
| 156 |
+
|
| 157 |
+
# ────────────────────────────────────────────────────────────────────
|
| 158 |
+
# Strategy executor (matches mock_env.execute_fold_strategy signature)
|
| 159 |
+
# ────────────────────────────────────────────────────────────────────
|
| 160 |
+
|
| 161 |
+
def execute_fold_strategy(
|
| 162 |
+
strategy_fn: Callable,
|
| 163 |
+
paper: Paper,
|
| 164 |
+
max_folds: int = 20,
|
| 165 |
+
) -> tuple[Paper, list[dict], str | None]:
|
| 166 |
+
"""Execute a ``fold_strategy`` function against the real physics engine.
|
| 167 |
+
|
| 168 |
+
Signature matches ``mock_env.execute_fold_strategy`` so the trainer
|
| 169 |
+
reward functions can swap engines transparently.
|
| 170 |
+
|
| 171 |
+
Parameters
|
| 172 |
+
----------
|
| 173 |
+
strategy_fn : callable
|
| 174 |
+
``strategy_fn(paper_state_dict) -> list[dict]``
|
| 175 |
+
paper : Paper
|
| 176 |
+
The initial paper state.
|
| 177 |
+
max_folds : int
|
| 178 |
+
Maximum number of folds to apply.
|
| 179 |
+
|
| 180 |
+
Returns
|
| 181 |
+
-------
|
| 182 |
+
(final_paper, applied_folds, error_or_None)
|
| 183 |
+
"""
|
| 184 |
+
state_dict = paper.to_dict()
|
| 185 |
+
try:
|
| 186 |
+
folds = strategy_fn(state_dict)
|
| 187 |
+
except Exception as exc:
|
| 188 |
+
return paper, [], f"Strategy function raised: {exc}"
|
| 189 |
+
|
| 190 |
+
if not isinstance(folds, list):
|
| 191 |
+
return paper, [], "Strategy must return a list of fold dicts"
|
| 192 |
+
|
| 193 |
+
applied: list[dict] = []
|
| 194 |
+
current = paper
|
| 195 |
+
|
| 196 |
+
for i, fold in enumerate(folds):
|
| 197 |
+
if i >= max_folds:
|
| 198 |
+
break
|
| 199 |
+
if not isinstance(fold, dict):
|
| 200 |
+
return current, applied, f"Fold {i} is not a dict"
|
| 201 |
+
|
| 202 |
+
current, error = apply_fold(current, fold)
|
| 203 |
+
if error:
|
| 204 |
+
return current, applied, f"Fold {i} failed: {error}"
|
| 205 |
+
applied.append(fold)
|
| 206 |
+
|
| 207 |
+
return current, applied, None
|
engine/materials.py
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Material definitions for origami simulation.
|
| 3 |
+
|
| 4 |
+
Provides dataclass-based material properties and preset materials
|
| 5 |
+
for paper, mylar, aluminum, and nitinol.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from dataclasses import dataclass
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
@dataclass
|
| 12 |
+
class Material:
|
| 13 |
+
name: str
|
| 14 |
+
thickness_mm: float # mm
|
| 15 |
+
youngs_modulus_gpa: float # GPa
|
| 16 |
+
max_strain: float # fraction (e.g. 0.03 = 3%)
|
| 17 |
+
poissons_ratio: float = 0.3 # dimensionless
|
| 18 |
+
|
| 19 |
+
@property
|
| 20 |
+
def thickness_m(self) -> float:
|
| 21 |
+
"""Thickness in meters."""
|
| 22 |
+
return self.thickness_mm / 1000.0
|
| 23 |
+
|
| 24 |
+
@property
|
| 25 |
+
def youngs_modulus_pa(self) -> float:
|
| 26 |
+
"""Young's modulus in Pascals."""
|
| 27 |
+
return self.youngs_modulus_gpa * 1e9
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
# ── Preset materials ────────────────────────────────────────────────
|
| 31 |
+
|
| 32 |
+
MATERIAL_PRESETS: dict[str, Material] = {
|
| 33 |
+
"paper": Material(
|
| 34 |
+
name="paper",
|
| 35 |
+
thickness_mm=0.1,
|
| 36 |
+
youngs_modulus_gpa=2.0,
|
| 37 |
+
max_strain=0.03,
|
| 38 |
+
poissons_ratio=0.3,
|
| 39 |
+
),
|
| 40 |
+
"mylar": Material(
|
| 41 |
+
name="mylar",
|
| 42 |
+
thickness_mm=0.05,
|
| 43 |
+
youngs_modulus_gpa=4.0,
|
| 44 |
+
max_strain=0.03,
|
| 45 |
+
poissons_ratio=0.38,
|
| 46 |
+
),
|
| 47 |
+
"aluminum": Material(
|
| 48 |
+
name="aluminum",
|
| 49 |
+
thickness_mm=0.1,
|
| 50 |
+
youngs_modulus_gpa=69.0,
|
| 51 |
+
max_strain=0.01,
|
| 52 |
+
poissons_ratio=0.33,
|
| 53 |
+
),
|
| 54 |
+
"nitinol": Material(
|
| 55 |
+
name="nitinol",
|
| 56 |
+
thickness_mm=0.1,
|
| 57 |
+
youngs_modulus_gpa=75.0,
|
| 58 |
+
max_strain=0.08,
|
| 59 |
+
poissons_ratio=0.33,
|
| 60 |
+
),
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
def get_material(name: str) -> Material:
|
| 65 |
+
"""Return a copy of a preset material by name.
|
| 66 |
+
|
| 67 |
+
Raises KeyError if name is not in MATERIAL_PRESETS.
|
| 68 |
+
"""
|
| 69 |
+
if name not in MATERIAL_PRESETS:
|
| 70 |
+
available = ", ".join(sorted(MATERIAL_PRESETS))
|
| 71 |
+
raise KeyError(f"Unknown material '{name}'. Available: {available}")
|
| 72 |
+
preset = MATERIAL_PRESETS[name]
|
| 73 |
+
return Material(
|
| 74 |
+
name=preset.name,
|
| 75 |
+
thickness_mm=preset.thickness_mm,
|
| 76 |
+
youngs_modulus_gpa=preset.youngs_modulus_gpa,
|
| 77 |
+
max_strain=preset.max_strain,
|
| 78 |
+
poissons_ratio=preset.poissons_ratio,
|
| 79 |
+
)
|
engine/metrics.py
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Quality metrics for folded origami.
|
| 3 |
+
|
| 4 |
+
Computes bounding box, deployment ratio, fold count, and aggregated
|
| 5 |
+
metric dictionaries for the trainer reward functions.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
import numpy as np
|
| 11 |
+
|
| 12 |
+
from .paper import Paper
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def compute_bounding_box(paper: Paper) -> np.ndarray:
|
| 16 |
+
"""Axis-aligned bounding-box dimensions (dx, dy, dz).
|
| 17 |
+
|
| 18 |
+
Returns shape (3,) array. Minimum z-thickness accounts for
|
| 19 |
+
material thickness times estimated layer count.
|
| 20 |
+
"""
|
| 21 |
+
if len(paper.vertices) == 0:
|
| 22 |
+
return np.zeros(3)
|
| 23 |
+
|
| 24 |
+
ptp = np.ptp(paper.vertices, axis=0)
|
| 25 |
+
ptp = np.where(np.abs(ptp) < 1e-12, 0.0, ptp)
|
| 26 |
+
|
| 27 |
+
# Minimum z from material thickness * layers
|
| 28 |
+
t = paper.material.thickness_mm / 1000.0
|
| 29 |
+
ptp[2] = max(ptp[2], t * paper.num_layers)
|
| 30 |
+
|
| 31 |
+
return ptp
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def compute_deployment_ratio(paper: Paper) -> float:
|
| 35 |
+
"""Ratio of folded XY footprint area to original sheet area.
|
| 36 |
+
|
| 37 |
+
A fully flat unfolded sheet has ratio 1.0; a tightly folded sheet
|
| 38 |
+
approaches 0.0.
|
| 39 |
+
"""
|
| 40 |
+
if paper.original_area <= 0:
|
| 41 |
+
return 1.0
|
| 42 |
+
|
| 43 |
+
bb = compute_bounding_box(paper)
|
| 44 |
+
folded_area = bb[0] * bb[1]
|
| 45 |
+
|
| 46 |
+
ratio = folded_area / paper.original_area
|
| 47 |
+
return float(np.clip(ratio, 0.0, 1.0))
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def compute_fold_count(paper: Paper) -> int:
|
| 51 |
+
"""Number of mountain (M) and valley (V) edges."""
|
| 52 |
+
return sum(1 for a in paper.assignments if a in ("M", "V"))
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def compute_compactness(paper: Paper) -> float:
|
| 56 |
+
"""1 - deployment_ratio. Higher is more compact."""
|
| 57 |
+
return 1.0 - compute_deployment_ratio(paper)
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
def compute_volume(paper: Paper) -> float:
|
| 61 |
+
"""Bounding-box volume in cubic meters."""
|
| 62 |
+
bb = compute_bounding_box(paper)
|
| 63 |
+
return float(bb[0] * bb[1] * bb[2])
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
def compute_metrics(paper: Paper, original_paper: Paper | None = None) -> dict:
|
| 67 |
+
"""Compute all quality metrics and return as a dict.
|
| 68 |
+
|
| 69 |
+
Parameters
|
| 70 |
+
----------
|
| 71 |
+
paper : Paper
|
| 72 |
+
The current (folded) paper state.
|
| 73 |
+
original_paper : Paper or None
|
| 74 |
+
The original (unfolded) paper, used for strain comparison.
|
| 75 |
+
If None, strain is computed against the current paper's rest lengths.
|
| 76 |
+
|
| 77 |
+
Returns
|
| 78 |
+
-------
|
| 79 |
+
dict with keys:
|
| 80 |
+
bounding_box, deployment_ratio, fold_count, compactness,
|
| 81 |
+
volume, max_strain, mean_strain, num_vertices, num_faces,
|
| 82 |
+
num_layers.
|
| 83 |
+
"""
|
| 84 |
+
from .physics import compute_strain # local import to avoid circular
|
| 85 |
+
|
| 86 |
+
bb = compute_bounding_box(paper)
|
| 87 |
+
strain = compute_strain(paper)
|
| 88 |
+
|
| 89 |
+
return {
|
| 90 |
+
"bounding_box": {
|
| 91 |
+
"x": float(bb[0]),
|
| 92 |
+
"y": float(bb[1]),
|
| 93 |
+
"z": float(bb[2]),
|
| 94 |
+
},
|
| 95 |
+
"deployment_ratio": compute_deployment_ratio(paper),
|
| 96 |
+
"fold_count": compute_fold_count(paper),
|
| 97 |
+
"compactness": compute_compactness(paper),
|
| 98 |
+
"volume": compute_volume(paper),
|
| 99 |
+
"max_strain": float(np.max(strain)) if len(strain) > 0 else 0.0,
|
| 100 |
+
"mean_strain": float(np.mean(strain)) if len(strain) > 0 else 0.0,
|
| 101 |
+
"num_vertices": len(paper.vertices),
|
| 102 |
+
"num_faces": len(paper.faces),
|
| 103 |
+
"num_layers": paper.num_layers,
|
| 104 |
+
}
|
engine/paper.py
ADDED
|
@@ -0,0 +1,488 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Paper — the core geometric data structure for origami simulation.
|
| 3 |
+
|
| 4 |
+
Stores vertices, edges, faces, fold assignments, fold angles, layer ordering,
|
| 5 |
+
and material. Supports FOLD-format serialization and the face-splitting
|
| 6 |
+
operation needed by the fold engine.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
|
| 11 |
+
import copy
|
| 12 |
+
import json
|
| 13 |
+
from dataclasses import dataclass, field
|
| 14 |
+
from typing import Any
|
| 15 |
+
|
| 16 |
+
import numpy as np
|
| 17 |
+
|
| 18 |
+
from .materials import Material, get_material
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
# ────────────────────────────────────────────────────────────────────
|
| 22 |
+
# Helper: 2-D line-segment intersection
|
| 23 |
+
# ────────────────────────────────────────────────────────────────────
|
| 24 |
+
|
| 25 |
+
def _seg_seg_intersect_2d(
|
| 26 |
+
p1: np.ndarray, p2: np.ndarray,
|
| 27 |
+
p3: np.ndarray, p4: np.ndarray,
|
| 28 |
+
eps: float = 1e-10,
|
| 29 |
+
) -> np.ndarray | None:
|
| 30 |
+
"""Return the intersection point of segments (p1-p2) and (p3-p4) in 2-D,
|
| 31 |
+
or None if they do not intersect. Points that lie on the segment
|
| 32 |
+
endpoints are considered intersections (within tolerance *eps*).
|
| 33 |
+
|
| 34 |
+
All inputs are shape (2,).
|
| 35 |
+
"""
|
| 36 |
+
d1 = p2 - p1
|
| 37 |
+
d2 = p4 - p3
|
| 38 |
+
denom = d1[0] * d2[1] - d1[1] * d2[0]
|
| 39 |
+
|
| 40 |
+
if abs(denom) < eps:
|
| 41 |
+
return None # parallel / collinear
|
| 42 |
+
|
| 43 |
+
dp = p3 - p1
|
| 44 |
+
t = (dp[0] * d2[1] - dp[1] * d2[0]) / denom
|
| 45 |
+
u = (dp[0] * d1[1] - dp[1] * d1[0]) / denom
|
| 46 |
+
|
| 47 |
+
if -eps <= t <= 1.0 + eps and -eps <= u <= 1.0 + eps:
|
| 48 |
+
return p1 + np.clip(t, 0.0, 1.0) * d1
|
| 49 |
+
return None
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
# ────────────────────────────────────────────────────────────────────
|
| 53 |
+
# Paper dataclass
|
| 54 |
+
# ────────────────────────────────────────────────────────────────────
|
| 55 |
+
|
| 56 |
+
@dataclass
|
| 57 |
+
class Paper:
|
| 58 |
+
"""Origami sheet state.
|
| 59 |
+
|
| 60 |
+
Attributes
|
| 61 |
+
----------
|
| 62 |
+
vertices : np.ndarray, shape (N, 3)
|
| 63 |
+
Vertex positions in 3-D.
|
| 64 |
+
edges : np.ndarray, shape (E, 2), dtype int
|
| 65 |
+
Each row is (v_start, v_end).
|
| 66 |
+
faces : list[list[int]]
|
| 67 |
+
Each face is an ordered list of vertex indices (CCW winding).
|
| 68 |
+
assignments : list[str]
|
| 69 |
+
Per-edge assignment: 'M' (mountain), 'V' (valley), 'B' (boundary),
|
| 70 |
+
'F' (flat / unfolded), 'U' (unassigned).
|
| 71 |
+
fold_angles : np.ndarray, shape (E,)
|
| 72 |
+
Current fold angle (degrees) per edge.
|
| 73 |
+
face_orders : list[tuple[int, int, int]]
|
| 74 |
+
Layer ordering triples (f_i, f_j, +1/-1) meaning f_i is above/below f_j.
|
| 75 |
+
material : Material
|
| 76 |
+
The sheet material.
|
| 77 |
+
rest_lengths : np.ndarray, shape (E,)
|
| 78 |
+
Original (unfolded) edge lengths — used for strain computation.
|
| 79 |
+
original_area : float
|
| 80 |
+
Area of the sheet before any folds.
|
| 81 |
+
"""
|
| 82 |
+
|
| 83 |
+
vertices: np.ndarray
|
| 84 |
+
edges: np.ndarray
|
| 85 |
+
faces: list[list[int]]
|
| 86 |
+
assignments: list[str]
|
| 87 |
+
fold_angles: np.ndarray
|
| 88 |
+
face_orders: list[tuple[int, int, int]] = field(default_factory=list)
|
| 89 |
+
material: Material = field(default_factory=lambda: get_material("paper"))
|
| 90 |
+
rest_lengths: np.ndarray = field(default_factory=lambda: np.empty(0))
|
| 91 |
+
original_area: float = 0.0
|
| 92 |
+
|
| 93 |
+
# ── constructors ────────────────────────────────────────────────
|
| 94 |
+
|
| 95 |
+
@staticmethod
|
| 96 |
+
def create_flat_sheet(
|
| 97 |
+
width: float = 1.0,
|
| 98 |
+
height: float = 1.0,
|
| 99 |
+
material: Material | None = None,
|
| 100 |
+
) -> "Paper":
|
| 101 |
+
"""Create a flat rectangular sheet with 4 vertices, 5 edges
|
| 102 |
+
(including one diagonal), and 2 triangular faces."""
|
| 103 |
+
mat = material if material is not None else get_material("paper")
|
| 104 |
+
|
| 105 |
+
verts = np.array([
|
| 106 |
+
[0.0, 0.0, 0.0],
|
| 107 |
+
[width, 0.0, 0.0],
|
| 108 |
+
[width, height, 0.0],
|
| 109 |
+
[0.0, height, 0.0],
|
| 110 |
+
], dtype=np.float64)
|
| 111 |
+
|
| 112 |
+
edges = np.array([
|
| 113 |
+
[0, 1], # bottom
|
| 114 |
+
[1, 2], # right
|
| 115 |
+
[2, 3], # top
|
| 116 |
+
[3, 0], # left
|
| 117 |
+
[0, 2], # diagonal
|
| 118 |
+
], dtype=np.int64)
|
| 119 |
+
|
| 120 |
+
faces: list[list[int]] = [[0, 1, 2], [0, 2, 3]]
|
| 121 |
+
assignments = ["B", "B", "B", "B", "F"]
|
| 122 |
+
fold_angles = np.zeros(len(edges), dtype=np.float64)
|
| 123 |
+
rest_lengths = np.array(
|
| 124 |
+
[np.linalg.norm(verts[e[1]] - verts[e[0]]) for e in edges],
|
| 125 |
+
dtype=np.float64,
|
| 126 |
+
)
|
| 127 |
+
|
| 128 |
+
return Paper(
|
| 129 |
+
vertices=verts,
|
| 130 |
+
edges=edges,
|
| 131 |
+
faces=faces,
|
| 132 |
+
assignments=assignments,
|
| 133 |
+
fold_angles=fold_angles,
|
| 134 |
+
material=mat,
|
| 135 |
+
rest_lengths=rest_lengths,
|
| 136 |
+
original_area=width * height,
|
| 137 |
+
)
|
| 138 |
+
|
| 139 |
+
# ── dict / prompt serialization (matches mock_env.PaperState.to_dict) ──
|
| 140 |
+
|
| 141 |
+
def to_dict(self) -> dict:
|
| 142 |
+
"""Return a simplified dict suitable for LLM prompts.
|
| 143 |
+
|
| 144 |
+
The format matches ``mock_env.PaperState.to_dict()`` so that the
|
| 145 |
+
trainer reward functions work with either engine.
|
| 146 |
+
"""
|
| 147 |
+
bb = self.bounding_box
|
| 148 |
+
return {
|
| 149 |
+
"width": float(bb[0]),
|
| 150 |
+
"height": float(bb[1]),
|
| 151 |
+
"material": {
|
| 152 |
+
"name": self.material.name,
|
| 153 |
+
"thickness_mm": self.material.thickness_mm,
|
| 154 |
+
"youngs_modulus_gpa": self.material.youngs_modulus_gpa,
|
| 155 |
+
},
|
| 156 |
+
"vertices": self.vertices.tolist(),
|
| 157 |
+
"edges": self.edges.tolist(),
|
| 158 |
+
"assignments": list(self.assignments),
|
| 159 |
+
"fold_angles": self.fold_angles.tolist(),
|
| 160 |
+
"num_layers_at_center": self.num_layers,
|
| 161 |
+
"bounding_box": {
|
| 162 |
+
"x": float(bb[0]),
|
| 163 |
+
"y": float(bb[1]),
|
| 164 |
+
"z": float(bb[2]),
|
| 165 |
+
},
|
| 166 |
+
}
|
| 167 |
+
|
| 168 |
+
# ── FOLD format serialization ───────────────────────────────────
|
| 169 |
+
|
| 170 |
+
def to_fold_json(self) -> str:
|
| 171 |
+
"""Serialize to FOLD JSON format (v1.1 subset)."""
|
| 172 |
+
fold = {
|
| 173 |
+
"file_spec": 1.1,
|
| 174 |
+
"file_creator": "optigami",
|
| 175 |
+
"file_classes": ["singleModel"],
|
| 176 |
+
"frame_classes": ["foldedForm"],
|
| 177 |
+
"vertices_coords": self.vertices.tolist(),
|
| 178 |
+
"edges_vertices": self.edges.tolist(),
|
| 179 |
+
"edges_assignment": self.assignments,
|
| 180 |
+
"edges_foldAngle": self.fold_angles.tolist(),
|
| 181 |
+
"faces_vertices": self.faces,
|
| 182 |
+
"faceOrders": [list(fo) for fo in self.face_orders],
|
| 183 |
+
}
|
| 184 |
+
return json.dumps(fold, indent=2)
|
| 185 |
+
|
| 186 |
+
@staticmethod
|
| 187 |
+
def from_fold_json(data: str | dict, material: Material | None = None) -> "Paper":
|
| 188 |
+
"""Deserialize from FOLD JSON format."""
|
| 189 |
+
if isinstance(data, str):
|
| 190 |
+
data = json.loads(data)
|
| 191 |
+
|
| 192 |
+
verts = np.array(data["vertices_coords"], dtype=np.float64)
|
| 193 |
+
edges = np.array(data["edges_vertices"], dtype=np.int64)
|
| 194 |
+
faces = data.get("faces_vertices", [])
|
| 195 |
+
assignments = data.get("edges_assignment", ["U"] * len(edges))
|
| 196 |
+
fold_angles = np.array(
|
| 197 |
+
data.get("edges_foldAngle", [0.0] * len(edges)),
|
| 198 |
+
dtype=np.float64,
|
| 199 |
+
)
|
| 200 |
+
face_orders = [tuple(fo) for fo in data.get("faceOrders", [])]
|
| 201 |
+
|
| 202 |
+
rest_lengths = np.array(
|
| 203 |
+
[np.linalg.norm(verts[e[1]] - verts[e[0]]) for e in edges],
|
| 204 |
+
dtype=np.float64,
|
| 205 |
+
)
|
| 206 |
+
|
| 207 |
+
mat = material if material is not None else get_material("paper")
|
| 208 |
+
|
| 209 |
+
# Approximate original area from convex hull of initial XY footprint
|
| 210 |
+
try:
|
| 211 |
+
from scipy.spatial import ConvexHull
|
| 212 |
+
hull = ConvexHull(verts[:, :2])
|
| 213 |
+
area = hull.volume # 2-D ConvexHull.volume is area
|
| 214 |
+
except Exception:
|
| 215 |
+
# Fallback: bounding-box area from XY coordinates
|
| 216 |
+
if len(verts) >= 2:
|
| 217 |
+
ptp = np.ptp(verts[:, :2], axis=0)
|
| 218 |
+
area = float(ptp[0] * ptp[1])
|
| 219 |
+
else:
|
| 220 |
+
area = 0.0
|
| 221 |
+
|
| 222 |
+
return Paper(
|
| 223 |
+
vertices=verts,
|
| 224 |
+
edges=edges,
|
| 225 |
+
faces=faces,
|
| 226 |
+
assignments=assignments,
|
| 227 |
+
fold_angles=fold_angles,
|
| 228 |
+
face_orders=face_orders,
|
| 229 |
+
material=mat,
|
| 230 |
+
rest_lengths=rest_lengths,
|
| 231 |
+
original_area=area,
|
| 232 |
+
)
|
| 233 |
+
|
| 234 |
+
# ── computed properties ─────────────────────────────────────────
|
| 235 |
+
|
| 236 |
+
@property
|
| 237 |
+
def bounding_box(self) -> np.ndarray:
|
| 238 |
+
"""Axis-aligned bounding-box dimensions (dx, dy, dz)."""
|
| 239 |
+
if len(self.vertices) == 0:
|
| 240 |
+
return np.zeros(3)
|
| 241 |
+
ptp = np.ptp(self.vertices, axis=0)
|
| 242 |
+
ptp = np.where(np.abs(ptp) < 1e-12, 0.0, ptp)
|
| 243 |
+
# Ensure minimum z height from material thickness * layers
|
| 244 |
+
t = self.material.thickness_mm / 1000.0
|
| 245 |
+
ptp[2] = max(ptp[2], t * self.num_layers)
|
| 246 |
+
return ptp
|
| 247 |
+
|
| 248 |
+
@property
|
| 249 |
+
def num_layers(self) -> int:
|
| 250 |
+
"""Estimate layer count from face-order triples.
|
| 251 |
+
|
| 252 |
+
Falls back to 1 + number of M/V edges as a simple heuristic when
|
| 253 |
+
face_orders is empty.
|
| 254 |
+
"""
|
| 255 |
+
if self.face_orders:
|
| 256 |
+
face_ids = set()
|
| 257 |
+
for fo in self.face_orders:
|
| 258 |
+
face_ids.add(fo[0])
|
| 259 |
+
face_ids.add(fo[1])
|
| 260 |
+
return max(len(face_ids), 1)
|
| 261 |
+
# Heuristic: each fold adds one layer
|
| 262 |
+
mv_count = sum(1 for a in self.assignments if a in ("M", "V"))
|
| 263 |
+
return 1 + mv_count
|
| 264 |
+
|
| 265 |
+
# ── topology helpers ────────────────────────────────────────────
|
| 266 |
+
|
| 267 |
+
def _find_or_add_vertex(self, point_3d: np.ndarray, tol: float = 1e-8) -> int:
|
| 268 |
+
"""Return index of an existing vertex close to *point_3d*, or add a
|
| 269 |
+
new vertex and return its index."""
|
| 270 |
+
for i, v in enumerate(self.vertices):
|
| 271 |
+
if np.linalg.norm(v - point_3d) < tol:
|
| 272 |
+
return i
|
| 273 |
+
idx = len(self.vertices)
|
| 274 |
+
self.vertices = np.vstack([self.vertices, point_3d.reshape(1, 3)])
|
| 275 |
+
return idx
|
| 276 |
+
|
| 277 |
+
def _find_or_add_edge(self, v1: int, v2: int) -> int:
|
| 278 |
+
"""Return index of edge (v1,v2) or (v2,v1), or add a new edge and
|
| 279 |
+
return its index. New edges get assignment 'F' and fold-angle 0."""
|
| 280 |
+
for i, e in enumerate(self.edges):
|
| 281 |
+
if (e[0] == v1 and e[1] == v2) or (e[0] == v2 and e[1] == v1):
|
| 282 |
+
return i
|
| 283 |
+
idx = len(self.edges)
|
| 284 |
+
self.edges = np.vstack([self.edges, np.array([[v1, v2]], dtype=np.int64)])
|
| 285 |
+
self.assignments.append("F")
|
| 286 |
+
self.fold_angles = np.append(self.fold_angles, 0.0)
|
| 287 |
+
# Rest length for the new edge
|
| 288 |
+
rl = np.linalg.norm(self.vertices[v1] - self.vertices[v2])
|
| 289 |
+
self.rest_lengths = np.append(self.rest_lengths, rl)
|
| 290 |
+
return idx
|
| 291 |
+
|
| 292 |
+
# ── face splitting ──────────────────────────────────────────────
|
| 293 |
+
|
| 294 |
+
def split_faces_along_line(
|
| 295 |
+
self,
|
| 296 |
+
start_2d: np.ndarray | list,
|
| 297 |
+
end_2d: np.ndarray | list,
|
| 298 |
+
) -> list[int]:
|
| 299 |
+
"""Split every face that the 2-D line (start_2d -> end_2d) crosses.
|
| 300 |
+
|
| 301 |
+
The line is infinite for intersection purposes (we test each face
|
| 302 |
+
edge-segment against the full fold-line extent clipped to the paper).
|
| 303 |
+
|
| 304 |
+
Returns a list of edge indices that lie *on* the fold line (i.e. the
|
| 305 |
+
newly created edges along the fold path).
|
| 306 |
+
|
| 307 |
+
This mutates ``self`` in-place (vertices, edges, faces, assignments,
|
| 308 |
+
fold_angles, rest_lengths are updated).
|
| 309 |
+
"""
|
| 310 |
+
start_2d = np.asarray(start_2d, dtype=np.float64)
|
| 311 |
+
end_2d = np.asarray(end_2d, dtype=np.float64)
|
| 312 |
+
|
| 313 |
+
fold_edge_indices: list[int] = []
|
| 314 |
+
new_faces: list[list[int]] = []
|
| 315 |
+
|
| 316 |
+
faces_to_process = list(range(len(self.faces)))
|
| 317 |
+
|
| 318 |
+
for fi in faces_to_process:
|
| 319 |
+
face = self.faces[fi]
|
| 320 |
+
n = len(face)
|
| 321 |
+
|
| 322 |
+
# Gather intersection points along the face boundary
|
| 323 |
+
hits: list[tuple[int, np.ndarray]] = [] # (local_edge_index, point_2d)
|
| 324 |
+
|
| 325 |
+
for k in range(n):
|
| 326 |
+
v_a = face[k]
|
| 327 |
+
v_b = face[(k + 1) % n]
|
| 328 |
+
pa = self.vertices[v_a][:2]
|
| 329 |
+
pb = self.vertices[v_b][:2]
|
| 330 |
+
|
| 331 |
+
pt = _seg_seg_intersect_2d(start_2d, end_2d, pa, pb)
|
| 332 |
+
if pt is not None:
|
| 333 |
+
hits.append((k, pt))
|
| 334 |
+
|
| 335 |
+
# Deduplicate hits that are at the same location (e.g. hitting a vertex)
|
| 336 |
+
if len(hits) >= 2:
|
| 337 |
+
unique_hits: list[tuple[int, np.ndarray]] = [hits[0]]
|
| 338 |
+
for h in hits[1:]:
|
| 339 |
+
is_dup = False
|
| 340 |
+
for uh in unique_hits:
|
| 341 |
+
if np.linalg.norm(h[1] - uh[1]) < 1e-8:
|
| 342 |
+
is_dup = True
|
| 343 |
+
break
|
| 344 |
+
if not is_dup:
|
| 345 |
+
unique_hits.append(h)
|
| 346 |
+
hits = unique_hits
|
| 347 |
+
|
| 348 |
+
if len(hits) < 2:
|
| 349 |
+
# Line does not fully cross this face — keep face as-is
|
| 350 |
+
new_faces.append(face)
|
| 351 |
+
continue
|
| 352 |
+
|
| 353 |
+
# We only handle the first two intersection points (one chord across face)
|
| 354 |
+
hit_a_edge_idx, hit_a_pt = hits[0]
|
| 355 |
+
hit_b_edge_idx, hit_b_pt = hits[1]
|
| 356 |
+
|
| 357 |
+
# Create / find 3-D vertices at intersection points (z=0 for flat, interpolated otherwise)
|
| 358 |
+
def _interp_z(pt2d: np.ndarray, edge_local: int) -> np.ndarray:
|
| 359 |
+
"""Interpolate z from the edge endpoints."""
|
| 360 |
+
v_a = face[edge_local]
|
| 361 |
+
v_b = face[(edge_local + 1) % n]
|
| 362 |
+
pa = self.vertices[v_a]
|
| 363 |
+
pb = self.vertices[v_b]
|
| 364 |
+
seg = pb[:2] - pa[:2]
|
| 365 |
+
seg_len = np.linalg.norm(seg)
|
| 366 |
+
if seg_len < 1e-12:
|
| 367 |
+
return np.array([pt2d[0], pt2d[1], pa[2]])
|
| 368 |
+
t = np.linalg.norm(pt2d - pa[:2]) / seg_len
|
| 369 |
+
t = np.clip(t, 0.0, 1.0)
|
| 370 |
+
z = pa[2] + t * (pb[2] - pa[2])
|
| 371 |
+
return np.array([pt2d[0], pt2d[1], z])
|
| 372 |
+
|
| 373 |
+
pt_a_3d = _interp_z(hit_a_pt, hit_a_edge_idx)
|
| 374 |
+
pt_b_3d = _interp_z(hit_b_pt, hit_b_edge_idx)
|
| 375 |
+
|
| 376 |
+
idx_a = self._find_or_add_vertex(pt_a_3d)
|
| 377 |
+
idx_b = self._find_or_add_vertex(pt_b_3d)
|
| 378 |
+
|
| 379 |
+
if idx_a == idx_b:
|
| 380 |
+
new_faces.append(face)
|
| 381 |
+
continue
|
| 382 |
+
|
| 383 |
+
# Add the fold-line edge between the two intersection points
|
| 384 |
+
fold_eidx = self._find_or_add_edge(idx_a, idx_b)
|
| 385 |
+
fold_edge_indices.append(fold_eidx)
|
| 386 |
+
|
| 387 |
+
# ── Split the face into two sub-faces ──
|
| 388 |
+
# Walk around the face vertices, inserting idx_a and idx_b at the
|
| 389 |
+
# appropriate positions, then split into two loops.
|
| 390 |
+
ordered_verts = list(face)
|
| 391 |
+
|
| 392 |
+
# Insert intersection vertices into the vertex ring if not already present
|
| 393 |
+
def _insert_after(ring: list[int], after_local: int, vid: int) -> list[int]:
|
| 394 |
+
"""Insert *vid* after position *after_local* if it is not already
|
| 395 |
+
adjacent in the ring at that position."""
|
| 396 |
+
pos = after_local + 1
|
| 397 |
+
if ring[after_local % len(ring)] == vid:
|
| 398 |
+
return ring
|
| 399 |
+
if ring[pos % len(ring)] == vid:
|
| 400 |
+
return ring
|
| 401 |
+
return ring[:pos] + [vid] + ring[pos:]
|
| 402 |
+
|
| 403 |
+
# Determine insertion order — always insert the one with the
|
| 404 |
+
# larger local-edge index first so that the earlier index stays valid.
|
| 405 |
+
if hit_a_edge_idx <= hit_b_edge_idx:
|
| 406 |
+
ordered_verts = _insert_after(ordered_verts, hit_b_edge_idx, idx_b)
|
| 407 |
+
# Recompute hit_a_edge_idx offset if idx_b was inserted before it
|
| 408 |
+
# (it shouldn't be, since hit_b >= hit_a, but guard anyway)
|
| 409 |
+
a_pos = hit_a_edge_idx
|
| 410 |
+
ordered_verts = _insert_after(ordered_verts, a_pos, idx_a)
|
| 411 |
+
else:
|
| 412 |
+
ordered_verts = _insert_after(ordered_verts, hit_a_edge_idx, idx_a)
|
| 413 |
+
ordered_verts = _insert_after(ordered_verts, hit_b_edge_idx, idx_b)
|
| 414 |
+
|
| 415 |
+
# Now split the ring at idx_a and idx_b
|
| 416 |
+
try:
|
| 417 |
+
pos_a = ordered_verts.index(idx_a)
|
| 418 |
+
pos_b = ordered_verts.index(idx_b)
|
| 419 |
+
except ValueError:
|
| 420 |
+
new_faces.append(face)
|
| 421 |
+
continue
|
| 422 |
+
|
| 423 |
+
if pos_a > pos_b:
|
| 424 |
+
pos_a, pos_b = pos_b, pos_a
|
| 425 |
+
|
| 426 |
+
loop1 = ordered_verts[pos_a: pos_b + 1]
|
| 427 |
+
loop2 = ordered_verts[pos_b:] + ordered_verts[: pos_a + 1]
|
| 428 |
+
|
| 429 |
+
# Only keep faces with >= 3 unique vertices
|
| 430 |
+
for loop in (loop1, loop2):
|
| 431 |
+
unique = list(dict.fromkeys(loop)) # preserve order, dedupe
|
| 432 |
+
if len(unique) >= 3:
|
| 433 |
+
new_faces.append(unique)
|
| 434 |
+
# Ensure all edges of this new face exist
|
| 435 |
+
for k in range(len(unique)):
|
| 436 |
+
self._find_or_add_edge(unique[k], unique[(k + 1) % len(unique)])
|
| 437 |
+
|
| 438 |
+
self.faces = new_faces
|
| 439 |
+
return fold_edge_indices
|
| 440 |
+
|
| 441 |
+
# ── vertex side test ────────────────────────────────────────────
|
| 442 |
+
|
| 443 |
+
def get_vertices_on_side(
|
| 444 |
+
self,
|
| 445 |
+
line_start: np.ndarray | list,
|
| 446 |
+
line_end: np.ndarray | list,
|
| 447 |
+
side: str = "positive",
|
| 448 |
+
) -> list[int]:
|
| 449 |
+
"""Return indices of vertices on one side of a 2-D line.
|
| 450 |
+
|
| 451 |
+
*side* can be ``"positive"`` or ``"negative"``. The positive side is
|
| 452 |
+
defined by the left-hand normal of (line_end - line_start).
|
| 453 |
+
"""
|
| 454 |
+
ls = np.asarray(line_start, dtype=np.float64)[:2]
|
| 455 |
+
le = np.asarray(line_end, dtype=np.float64)[:2]
|
| 456 |
+
d = le - ls
|
| 457 |
+
normal = np.array([-d[1], d[0]])
|
| 458 |
+
|
| 459 |
+
indices: list[int] = []
|
| 460 |
+
for i, v in enumerate(self.vertices):
|
| 461 |
+
dot = np.dot(v[:2] - ls, normal)
|
| 462 |
+
if side == "positive" and dot > 1e-9:
|
| 463 |
+
indices.append(i)
|
| 464 |
+
elif side == "negative" and dot < -1e-9:
|
| 465 |
+
indices.append(i)
|
| 466 |
+
return indices
|
| 467 |
+
|
| 468 |
+
# ── deep copy ───────────────────────────────────────────────────
|
| 469 |
+
|
| 470 |
+
def copy(self) -> "Paper":
|
| 471 |
+
"""Return an independent deep copy."""
|
| 472 |
+
return Paper(
|
| 473 |
+
vertices=self.vertices.copy(),
|
| 474 |
+
edges=self.edges.copy(),
|
| 475 |
+
faces=copy.deepcopy(self.faces),
|
| 476 |
+
assignments=list(self.assignments),
|
| 477 |
+
fold_angles=self.fold_angles.copy(),
|
| 478 |
+
face_orders=list(self.face_orders),
|
| 479 |
+
material=Material(
|
| 480 |
+
name=self.material.name,
|
| 481 |
+
thickness_mm=self.material.thickness_mm,
|
| 482 |
+
youngs_modulus_gpa=self.material.youngs_modulus_gpa,
|
| 483 |
+
max_strain=self.material.max_strain,
|
| 484 |
+
poissons_ratio=self.material.poissons_ratio,
|
| 485 |
+
),
|
| 486 |
+
rest_lengths=self.rest_lengths.copy(),
|
| 487 |
+
original_area=self.original_area,
|
| 488 |
+
)
|
engine/physics.py
ADDED
|
@@ -0,0 +1,257 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Bar-and-hinge physics model.
|
| 3 |
+
|
| 4 |
+
Three energy components:
|
| 5 |
+
E_total = E_bar + E_facet + E_fold
|
| 6 |
+
|
| 7 |
+
Stiffness parameters are derived from the material properties.
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
from __future__ import annotations
|
| 11 |
+
|
| 12 |
+
from dataclasses import dataclass
|
| 13 |
+
|
| 14 |
+
import numpy as np
|
| 15 |
+
|
| 16 |
+
from .paper import Paper
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
# ────────────────────────────────────────────────────────────────────
|
| 20 |
+
# Stiffness
|
| 21 |
+
# ────────────────────────────────────────────────────────────────────
|
| 22 |
+
|
| 23 |
+
@dataclass
|
| 24 |
+
class StiffnessParams:
|
| 25 |
+
"""Stiffness values derived from material properties."""
|
| 26 |
+
k_axial: np.ndarray # per-edge axial stiffness (E,)
|
| 27 |
+
k_facet: float # facet (panel bending) stiffness
|
| 28 |
+
k_fold: float # fold (crease torsion) stiffness
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def compute_stiffness(paper: Paper) -> StiffnessParams:
|
| 32 |
+
"""Derive stiffness parameters from the paper's material and geometry.
|
| 33 |
+
|
| 34 |
+
k_axial = E * t * w / L0 (per edge, w ≈ average of adjacent edge lengths)
|
| 35 |
+
k_facet = E * t^3 / (12 * (1 - nu^2))
|
| 36 |
+
k_fold = 0.1 * k_facet (crease torsional stiffness, empirical fraction)
|
| 37 |
+
"""
|
| 38 |
+
mat = paper.material
|
| 39 |
+
E = mat.youngs_modulus_pa # Pa
|
| 40 |
+
t = mat.thickness_m # m
|
| 41 |
+
nu = mat.poissons_ratio
|
| 42 |
+
|
| 43 |
+
rest = paper.rest_lengths
|
| 44 |
+
# Guard against zero rest lengths
|
| 45 |
+
safe_rest = np.where(rest > 1e-15, rest, 1e-15)
|
| 46 |
+
|
| 47 |
+
# Approximate edge width as the average rest length (simple heuristic)
|
| 48 |
+
w = np.mean(safe_rest) if len(safe_rest) > 0 else 1e-3
|
| 49 |
+
|
| 50 |
+
k_axial = E * t * w / safe_rest # (E,)
|
| 51 |
+
|
| 52 |
+
k_facet = E * t ** 3 / (12.0 * (1.0 - nu ** 2))
|
| 53 |
+
|
| 54 |
+
# Crease torsional stiffness — a fraction of facet stiffness
|
| 55 |
+
k_fold = 0.1 * k_facet
|
| 56 |
+
|
| 57 |
+
return StiffnessParams(k_axial=k_axial, k_facet=k_facet, k_fold=k_fold)
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
# ────────────────────────────────────────────────────────────────────
|
| 61 |
+
# Energy components
|
| 62 |
+
# ────────────────────────────────────────────────────────────────────
|
| 63 |
+
|
| 64 |
+
def compute_bar_energy(paper: Paper) -> float:
|
| 65 |
+
"""E_bar = sum (1/2) * k_axial * (L - L0)^2
|
| 66 |
+
|
| 67 |
+
Measures stretching / compression of edges relative to rest lengths.
|
| 68 |
+
"""
|
| 69 |
+
if len(paper.edges) == 0:
|
| 70 |
+
return 0.0
|
| 71 |
+
|
| 72 |
+
verts = paper.vertices
|
| 73 |
+
edges = paper.edges
|
| 74 |
+
current_lengths = np.array([
|
| 75 |
+
np.linalg.norm(verts[e[1]] - verts[e[0]]) for e in edges
|
| 76 |
+
])
|
| 77 |
+
|
| 78 |
+
stiff = compute_stiffness(paper)
|
| 79 |
+
delta = current_lengths - paper.rest_lengths
|
| 80 |
+
energy = 0.5 * np.sum(stiff.k_axial * delta ** 2)
|
| 81 |
+
return float(energy)
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
def compute_facet_energy(paper: Paper) -> float:
|
| 85 |
+
"""E_facet = sum (1/2) * k_facet * l * (theta - pi)^2
|
| 86 |
+
|
| 87 |
+
Measures bending of facet panels away from flat (pi).
|
| 88 |
+
*l* is the edge length (hinge length) and *theta* is the dihedral angle
|
| 89 |
+
across the edge between two adjacent faces. For edges that are not
|
| 90 |
+
shared by two faces we skip them.
|
| 91 |
+
"""
|
| 92 |
+
if len(paper.edges) == 0 or len(paper.faces) < 2:
|
| 93 |
+
return 0.0
|
| 94 |
+
|
| 95 |
+
stiff = compute_stiffness(paper)
|
| 96 |
+
verts = paper.vertices
|
| 97 |
+
edges = paper.edges
|
| 98 |
+
|
| 99 |
+
# Build edge → face adjacency
|
| 100 |
+
edge_faces: dict[int, list[int]] = {}
|
| 101 |
+
for fi, face in enumerate(paper.faces):
|
| 102 |
+
n = len(face)
|
| 103 |
+
for k in range(n):
|
| 104 |
+
va, vb = face[k], face[(k + 1) % n]
|
| 105 |
+
for ei, e in enumerate(edges):
|
| 106 |
+
if (e[0] == va and e[1] == vb) or (e[0] == vb and e[1] == va):
|
| 107 |
+
edge_faces.setdefault(ei, []).append(fi)
|
| 108 |
+
break
|
| 109 |
+
|
| 110 |
+
energy = 0.0
|
| 111 |
+
for ei, adj_faces in edge_faces.items():
|
| 112 |
+
if len(adj_faces) < 2:
|
| 113 |
+
continue
|
| 114 |
+
# Only consider non-fold edges (flat or boundary interior)
|
| 115 |
+
if paper.assignments[ei] in ("M", "V"):
|
| 116 |
+
continue
|
| 117 |
+
|
| 118 |
+
f1, f2 = adj_faces[0], adj_faces[1]
|
| 119 |
+
theta = _dihedral_angle(verts, paper.faces[f1], paper.faces[f2], edges[ei])
|
| 120 |
+
l = np.linalg.norm(verts[edges[ei][1]] - verts[edges[ei][0]])
|
| 121 |
+
energy += 0.5 * stiff.k_facet * l * (theta - np.pi) ** 2
|
| 122 |
+
|
| 123 |
+
return float(energy)
|
| 124 |
+
|
| 125 |
+
|
| 126 |
+
def compute_fold_energy(paper: Paper) -> float:
|
| 127 |
+
"""E_fold = sum (1/2) * k_fold * l * (rho - rho_target)^2
|
| 128 |
+
|
| 129 |
+
Measures deviation of fold creases from their target angles.
|
| 130 |
+
*rho* is the current dihedral angle across the fold edge and
|
| 131 |
+
*rho_target* comes from ``fold_angles``.
|
| 132 |
+
"""
|
| 133 |
+
if len(paper.edges) == 0:
|
| 134 |
+
return 0.0
|
| 135 |
+
|
| 136 |
+
stiff = compute_stiffness(paper)
|
| 137 |
+
verts = paper.vertices
|
| 138 |
+
edges = paper.edges
|
| 139 |
+
|
| 140 |
+
# Build edge → face adjacency
|
| 141 |
+
edge_faces: dict[int, list[int]] = {}
|
| 142 |
+
for fi, face in enumerate(paper.faces):
|
| 143 |
+
n = len(face)
|
| 144 |
+
for k in range(n):
|
| 145 |
+
va, vb = face[k], face[(k + 1) % n]
|
| 146 |
+
for ei, e in enumerate(edges):
|
| 147 |
+
if (e[0] == va and e[1] == vb) or (e[0] == vb and e[1] == va):
|
| 148 |
+
edge_faces.setdefault(ei, []).append(fi)
|
| 149 |
+
break
|
| 150 |
+
|
| 151 |
+
energy = 0.0
|
| 152 |
+
for ei in range(len(edges)):
|
| 153 |
+
if paper.assignments[ei] not in ("M", "V"):
|
| 154 |
+
continue
|
| 155 |
+
if ei not in edge_faces or len(edge_faces[ei]) < 2:
|
| 156 |
+
continue
|
| 157 |
+
|
| 158 |
+
f1, f2 = edge_faces[ei][0], edge_faces[ei][1]
|
| 159 |
+
rho = _dihedral_angle(verts, paper.faces[f1], paper.faces[f2], edges[ei])
|
| 160 |
+
rho_target = np.radians(paper.fold_angles[ei]) # fold_angles stored in degrees
|
| 161 |
+
l = np.linalg.norm(verts[edges[ei][1]] - verts[edges[ei][0]])
|
| 162 |
+
energy += 0.5 * stiff.k_fold * l * (rho - rho_target) ** 2
|
| 163 |
+
|
| 164 |
+
return float(energy)
|
| 165 |
+
|
| 166 |
+
|
| 167 |
+
def compute_total_energy(paper: Paper) -> float:
|
| 168 |
+
"""E_total = E_bar + E_facet + E_fold."""
|
| 169 |
+
return compute_bar_energy(paper) + compute_facet_energy(paper) + compute_fold_energy(paper)
|
| 170 |
+
|
| 171 |
+
|
| 172 |
+
# ────────────────────────────────────────────────────────────────────
|
| 173 |
+
# Strain
|
| 174 |
+
# ────────────────────────────────────────────────────────────────────
|
| 175 |
+
|
| 176 |
+
def compute_strain(paper: Paper) -> np.ndarray:
|
| 177 |
+
"""Per-vertex Cauchy strain: average fractional edge-length deviation.
|
| 178 |
+
|
| 179 |
+
Returns shape (N,) array of non-negative strain values.
|
| 180 |
+
"""
|
| 181 |
+
n_verts = len(paper.vertices)
|
| 182 |
+
if n_verts == 0:
|
| 183 |
+
return np.empty(0)
|
| 184 |
+
|
| 185 |
+
verts = paper.vertices
|
| 186 |
+
edges = paper.edges
|
| 187 |
+
rest = paper.rest_lengths
|
| 188 |
+
|
| 189 |
+
# Build vertex → edge adjacency
|
| 190 |
+
vert_edges: dict[int, list[int]] = {}
|
| 191 |
+
for ei, e in enumerate(edges):
|
| 192 |
+
vert_edges.setdefault(int(e[0]), []).append(ei)
|
| 193 |
+
vert_edges.setdefault(int(e[1]), []).append(ei)
|
| 194 |
+
|
| 195 |
+
strain = np.zeros(n_verts, dtype=np.float64)
|
| 196 |
+
for vi in range(n_verts):
|
| 197 |
+
adj = vert_edges.get(vi, [])
|
| 198 |
+
if not adj:
|
| 199 |
+
continue
|
| 200 |
+
devs = []
|
| 201 |
+
for ei in adj:
|
| 202 |
+
v1, v2 = edges[ei]
|
| 203 |
+
L = np.linalg.norm(verts[v1] - verts[v2])
|
| 204 |
+
L0 = rest[ei]
|
| 205 |
+
if L0 > 1e-15:
|
| 206 |
+
devs.append(abs(L - L0) / L0)
|
| 207 |
+
if devs:
|
| 208 |
+
strain[vi] = float(np.mean(devs))
|
| 209 |
+
|
| 210 |
+
return strain
|
| 211 |
+
|
| 212 |
+
|
| 213 |
+
# ────────────────────────────────────────────────────────────────────
|
| 214 |
+
# Dihedral angle helper
|
| 215 |
+
# ────────────────────────────────────────────────────────────────────
|
| 216 |
+
|
| 217 |
+
def _dihedral_angle(
|
| 218 |
+
verts: np.ndarray,
|
| 219 |
+
face1: list[int],
|
| 220 |
+
face2: list[int],
|
| 221 |
+
edge: np.ndarray,
|
| 222 |
+
) -> float:
|
| 223 |
+
"""Compute the dihedral angle (in radians) between two faces sharing *edge*.
|
| 224 |
+
|
| 225 |
+
Returns angle in [0, 2*pi). Returns pi if normals cannot be computed.
|
| 226 |
+
"""
|
| 227 |
+
n1 = _face_normal(verts, face1)
|
| 228 |
+
n2 = _face_normal(verts, face2)
|
| 229 |
+
|
| 230 |
+
if n1 is None or n2 is None:
|
| 231 |
+
return np.pi
|
| 232 |
+
|
| 233 |
+
cos_a = np.clip(np.dot(n1, n2), -1.0, 1.0)
|
| 234 |
+
angle = np.arccos(cos_a)
|
| 235 |
+
|
| 236 |
+
# Determine sign from edge direction
|
| 237 |
+
edge_dir = verts[edge[1]] - verts[edge[0]]
|
| 238 |
+
edge_dir = edge_dir / (np.linalg.norm(edge_dir) + 1e-30)
|
| 239 |
+
cross = np.cross(n1, n2)
|
| 240 |
+
if np.dot(cross, edge_dir) < 0:
|
| 241 |
+
angle = 2.0 * np.pi - angle
|
| 242 |
+
|
| 243 |
+
return float(angle)
|
| 244 |
+
|
| 245 |
+
|
| 246 |
+
def _face_normal(verts: np.ndarray, face: list[int]) -> np.ndarray | None:
|
| 247 |
+
"""Compute outward unit normal of a face, or None if degenerate."""
|
| 248 |
+
if len(face) < 3:
|
| 249 |
+
return None
|
| 250 |
+
v0 = verts[face[0]]
|
| 251 |
+
v1 = verts[face[1]]
|
| 252 |
+
v2 = verts[face[2]]
|
| 253 |
+
normal = np.cross(v1 - v0, v2 - v0)
|
| 254 |
+
norm = np.linalg.norm(normal)
|
| 255 |
+
if norm < 1e-15:
|
| 256 |
+
return None
|
| 257 |
+
return normal / norm
|
engine/validation.py
ADDED
|
@@ -0,0 +1,256 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Geometric validation for origami crease patterns.
|
| 3 |
+
|
| 4 |
+
Implements Kawasaki's theorem, Maekawa's theorem, and triangle-triangle
|
| 5 |
+
self-intersection detection.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
from dataclasses import dataclass
|
| 11 |
+
|
| 12 |
+
import numpy as np
|
| 13 |
+
|
| 14 |
+
from .paper import Paper
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
# ────────────────────────────────────────────────────────────────────
|
| 18 |
+
# Result container
|
| 19 |
+
# ────────────────────────────────────────────────────────────────────
|
| 20 |
+
|
| 21 |
+
@dataclass
|
| 22 |
+
class ValidationResult:
|
| 23 |
+
kawasaki_valid: bool
|
| 24 |
+
kawasaki_violation: float
|
| 25 |
+
maekawa_valid: bool
|
| 26 |
+
maekawa_violation: float
|
| 27 |
+
intersection_free: bool
|
| 28 |
+
self_intersection_count: int
|
| 29 |
+
is_valid: bool # all checks pass
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
# ────────────────────────────────────────────────────────────────────
|
| 33 |
+
# Kawasaki's theorem
|
| 34 |
+
# ────────────────────────────────────────────────────────────────────
|
| 35 |
+
|
| 36 |
+
def check_kawasaki(paper: Paper) -> tuple[bool, float]:
|
| 37 |
+
"""At each interior vertex, the alternating sum of sector angles equals pi.
|
| 38 |
+
|
| 39 |
+
Specifically, for a vertex with 2n incident creases, the sum of
|
| 40 |
+
odd-indexed sector angles equals the sum of even-indexed sector
|
| 41 |
+
angles equals pi.
|
| 42 |
+
|
| 43 |
+
Returns (is_valid, total_violation). A violation of < 1e-6 is
|
| 44 |
+
considered valid.
|
| 45 |
+
"""
|
| 46 |
+
verts = paper.vertices
|
| 47 |
+
edges = paper.edges
|
| 48 |
+
n_verts = len(verts)
|
| 49 |
+
|
| 50 |
+
# Build adjacency: vertex -> list of neighbor vertices (via edges)
|
| 51 |
+
adj: dict[int, list[int]] = {}
|
| 52 |
+
for e in edges:
|
| 53 |
+
adj.setdefault(int(e[0]), []).append(int(e[1]))
|
| 54 |
+
adj.setdefault(int(e[1]), []).append(int(e[0]))
|
| 55 |
+
|
| 56 |
+
# Identify boundary vertices (incident to a 'B' edge)
|
| 57 |
+
boundary_verts: set[int] = set()
|
| 58 |
+
for ei, e in enumerate(edges):
|
| 59 |
+
if paper.assignments[ei] == "B":
|
| 60 |
+
boundary_verts.add(int(e[0]))
|
| 61 |
+
boundary_verts.add(int(e[1]))
|
| 62 |
+
|
| 63 |
+
total_violation = 0.0
|
| 64 |
+
|
| 65 |
+
for vi in range(n_verts):
|
| 66 |
+
if vi in boundary_verts:
|
| 67 |
+
continue
|
| 68 |
+
neighbors = adj.get(vi, [])
|
| 69 |
+
if len(neighbors) < 2:
|
| 70 |
+
continue
|
| 71 |
+
|
| 72 |
+
# Sort neighbors by angle around vi (in the XY plane for flat-foldability)
|
| 73 |
+
center = verts[vi][:2]
|
| 74 |
+
angles = []
|
| 75 |
+
for ni in neighbors:
|
| 76 |
+
d = verts[ni][:2] - center
|
| 77 |
+
angles.append((np.arctan2(d[1], d[0]), ni))
|
| 78 |
+
angles.sort(key=lambda x: x[0])
|
| 79 |
+
|
| 80 |
+
# Sector angles
|
| 81 |
+
sector_angles = []
|
| 82 |
+
for k in range(len(angles)):
|
| 83 |
+
a1 = angles[k][0]
|
| 84 |
+
a2 = angles[(k + 1) % len(angles)][0]
|
| 85 |
+
diff = a2 - a1
|
| 86 |
+
if diff <= 0:
|
| 87 |
+
diff += 2.0 * np.pi
|
| 88 |
+
sector_angles.append(diff)
|
| 89 |
+
|
| 90 |
+
if len(sector_angles) < 2:
|
| 91 |
+
continue
|
| 92 |
+
|
| 93 |
+
# Kawasaki: alternating sums should both equal pi
|
| 94 |
+
even_sum = sum(sector_angles[i] for i in range(0, len(sector_angles), 2))
|
| 95 |
+
odd_sum = sum(sector_angles[i] for i in range(1, len(sector_angles), 2))
|
| 96 |
+
|
| 97 |
+
violation = abs(even_sum - odd_sum)
|
| 98 |
+
total_violation += violation
|
| 99 |
+
|
| 100 |
+
is_valid = total_violation < 1e-4
|
| 101 |
+
return is_valid, float(total_violation)
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
# ────────────────────────────────────────────────────────────────────
|
| 105 |
+
# Maekawa's theorem
|
| 106 |
+
# ────────────────────────────────────────────────────────────────────
|
| 107 |
+
|
| 108 |
+
def check_maekawa(paper: Paper) -> tuple[bool, float]:
|
| 109 |
+
"""At each interior vertex, |M - V| = 2.
|
| 110 |
+
|
| 111 |
+
Returns (is_valid, total_violation) where violation is
|
| 112 |
+
sum of |abs(M-V) - 2| over all interior vertices.
|
| 113 |
+
"""
|
| 114 |
+
edges = paper.edges
|
| 115 |
+
verts = paper.vertices
|
| 116 |
+
n_verts = len(verts)
|
| 117 |
+
|
| 118 |
+
# Boundary vertices
|
| 119 |
+
boundary_verts: set[int] = set()
|
| 120 |
+
for ei, e in enumerate(edges):
|
| 121 |
+
if paper.assignments[ei] == "B":
|
| 122 |
+
boundary_verts.add(int(e[0]))
|
| 123 |
+
boundary_verts.add(int(e[1]))
|
| 124 |
+
|
| 125 |
+
# Count M and V edges per vertex
|
| 126 |
+
m_count = [0] * n_verts
|
| 127 |
+
v_count = [0] * n_verts
|
| 128 |
+
total_mv_per_vertex = [0] * n_verts
|
| 129 |
+
|
| 130 |
+
for ei, e in enumerate(edges):
|
| 131 |
+
a = paper.assignments[ei]
|
| 132 |
+
if a == "M":
|
| 133 |
+
m_count[int(e[0])] += 1
|
| 134 |
+
m_count[int(e[1])] += 1
|
| 135 |
+
elif a == "V":
|
| 136 |
+
v_count[int(e[0])] += 1
|
| 137 |
+
v_count[int(e[1])] += 1
|
| 138 |
+
if a in ("M", "V"):
|
| 139 |
+
total_mv_per_vertex[int(e[0])] += 1
|
| 140 |
+
total_mv_per_vertex[int(e[1])] += 1
|
| 141 |
+
|
| 142 |
+
total_violation = 0.0
|
| 143 |
+
for vi in range(n_verts):
|
| 144 |
+
if vi in boundary_verts:
|
| 145 |
+
continue
|
| 146 |
+
# Only check vertices that actually have creases
|
| 147 |
+
if total_mv_per_vertex[vi] == 0:
|
| 148 |
+
continue
|
| 149 |
+
diff = abs(m_count[vi] - v_count[vi])
|
| 150 |
+
violation = abs(diff - 2)
|
| 151 |
+
total_violation += violation
|
| 152 |
+
|
| 153 |
+
is_valid = total_violation < 0.5 # integer theorem, so < 0.5 means exact
|
| 154 |
+
return is_valid, float(total_violation)
|
| 155 |
+
|
| 156 |
+
|
| 157 |
+
# ────────────────────────────────────────────────────────────────────
|
| 158 |
+
# Self-intersection detection (triangle-triangle)
|
| 159 |
+
# ────────────────────────────────────────────────────────────────────
|
| 160 |
+
|
| 161 |
+
def check_self_intersection(paper: Paper) -> tuple[bool, int]:
|
| 162 |
+
"""Check for triangle-triangle intersections among the paper's faces.
|
| 163 |
+
|
| 164 |
+
Uses the separating-axis theorem (SAT) for triangle-triangle overlap
|
| 165 |
+
in 3-D. Faces that share an edge or vertex are skipped.
|
| 166 |
+
|
| 167 |
+
Returns (is_valid, count_of_intersections).
|
| 168 |
+
"""
|
| 169 |
+
verts = paper.vertices
|
| 170 |
+
faces = paper.faces
|
| 171 |
+
count = 0
|
| 172 |
+
|
| 173 |
+
for i in range(len(faces)):
|
| 174 |
+
for j in range(i + 1, len(faces)):
|
| 175 |
+
# Skip faces that share vertices (adjacent faces)
|
| 176 |
+
if set(faces[i]) & set(faces[j]):
|
| 177 |
+
continue
|
| 178 |
+
if _triangles_intersect(verts, faces[i], faces[j]):
|
| 179 |
+
count += 1
|
| 180 |
+
|
| 181 |
+
return count == 0, count
|
| 182 |
+
|
| 183 |
+
|
| 184 |
+
def _triangles_intersect(
|
| 185 |
+
verts: np.ndarray,
|
| 186 |
+
face1: list[int],
|
| 187 |
+
face2: list[int],
|
| 188 |
+
) -> bool:
|
| 189 |
+
"""Test whether two triangular faces intersect in 3-D using
|
| 190 |
+
the separating-axis theorem (Moller's method simplified).
|
| 191 |
+
|
| 192 |
+
For non-triangular faces, only tests the first three vertices.
|
| 193 |
+
Returns True if the triangles intersect.
|
| 194 |
+
"""
|
| 195 |
+
if len(face1) < 3 or len(face2) < 3:
|
| 196 |
+
return False
|
| 197 |
+
|
| 198 |
+
t1 = verts[face1[:3]]
|
| 199 |
+
t2 = verts[face2[:3]]
|
| 200 |
+
|
| 201 |
+
# 13 potential separating axes:
|
| 202 |
+
# - normals of each triangle (2)
|
| 203 |
+
# - cross products of edge pairs (3x3 = 9)
|
| 204 |
+
# - edges themselves don't need separate tests in 3D SAT
|
| 205 |
+
|
| 206 |
+
e1_edges = [t1[1] - t1[0], t1[2] - t1[1], t1[0] - t1[2]]
|
| 207 |
+
e2_edges = [t2[1] - t2[0], t2[2] - t2[1], t2[0] - t2[2]]
|
| 208 |
+
|
| 209 |
+
n1 = np.cross(e1_edges[0], e1_edges[1])
|
| 210 |
+
n2 = np.cross(e2_edges[0], e2_edges[1])
|
| 211 |
+
|
| 212 |
+
axes = [n1, n2]
|
| 213 |
+
for e1 in e1_edges:
|
| 214 |
+
for e2 in e2_edges:
|
| 215 |
+
ax = np.cross(e1, e2)
|
| 216 |
+
if np.linalg.norm(ax) > 1e-12:
|
| 217 |
+
axes.append(ax)
|
| 218 |
+
|
| 219 |
+
for axis in axes:
|
| 220 |
+
norm = np.linalg.norm(axis)
|
| 221 |
+
if norm < 1e-12:
|
| 222 |
+
continue
|
| 223 |
+
axis = axis / norm
|
| 224 |
+
|
| 225 |
+
proj1 = np.dot(t1, axis)
|
| 226 |
+
proj2 = np.dot(t2, axis)
|
| 227 |
+
|
| 228 |
+
min1, max1 = proj1.min(), proj1.max()
|
| 229 |
+
min2, max2 = proj2.min(), proj2.max()
|
| 230 |
+
|
| 231 |
+
# Check for separation (with small tolerance for shared-edge adjacency)
|
| 232 |
+
if max1 < min2 - 1e-9 or max2 < min1 - 1e-9:
|
| 233 |
+
return False # separating axis found
|
| 234 |
+
|
| 235 |
+
return True # no separating axis → intersection
|
| 236 |
+
|
| 237 |
+
|
| 238 |
+
# ────────────────────────────────────────────────────────────────────
|
| 239 |
+
# Combined validation
|
| 240 |
+
# ────────────────────────────────────────────────────────────────────
|
| 241 |
+
|
| 242 |
+
def validate_paper(paper: Paper) -> ValidationResult:
|
| 243 |
+
"""Run all validation checks and return a combined result."""
|
| 244 |
+
k_valid, k_violation = check_kawasaki(paper)
|
| 245 |
+
m_valid, m_violation = check_maekawa(paper)
|
| 246 |
+
si_valid, si_count = check_self_intersection(paper)
|
| 247 |
+
|
| 248 |
+
return ValidationResult(
|
| 249 |
+
kawasaki_valid=k_valid,
|
| 250 |
+
kawasaki_violation=k_violation,
|
| 251 |
+
maekawa_valid=m_valid,
|
| 252 |
+
maekawa_violation=m_violation,
|
| 253 |
+
intersection_free=si_valid,
|
| 254 |
+
self_intersection_count=si_count,
|
| 255 |
+
is_valid=k_valid and m_valid and si_valid,
|
| 256 |
+
)
|
planner/__init__.py
ADDED
|
File without changes
|
planner/__pycache__/__init__.cpython-314.pyc
ADDED
|
Binary file (148 Bytes). View file
|
|
|
planner/__pycache__/decomposer.cpython-314.pyc
ADDED
|
Binary file (10.5 kB). View file
|
|
|
planner/__pycache__/knowledge.cpython-314.pyc
ADDED
|
Binary file (30.1 kB). View file
|
|
|
planner/__pycache__/parser.cpython-314.pyc
ADDED
|
Binary file (10.2 kB). View file
|
|
|
planner/__pycache__/planner.cpython-314.pyc
ADDED
|
Binary file (14.7 kB). View file
|
|
|
planner/decomposer.py
ADDED
|
@@ -0,0 +1,284 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Task decomposer: breaks a parsed instruction into sequential sub-goals
|
| 3 |
+
with concrete fold operations on a unit square.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
from __future__ import annotations
|
| 7 |
+
|
| 8 |
+
import copy
|
| 9 |
+
from planner.knowledge import (
|
| 10 |
+
ORIGAMI_MODELS,
|
| 11 |
+
ORIGAMI_BASES,
|
| 12 |
+
FOLD_OPERATIONS,
|
| 13 |
+
get_model_steps,
|
| 14 |
+
get_base_steps,
|
| 15 |
+
)
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
# ---------------------------------------------------------------------------
|
| 19 |
+
# Internal helpers
|
| 20 |
+
# ---------------------------------------------------------------------------
|
| 21 |
+
|
| 22 |
+
def _step_to_fold_operation(step: dict) -> dict:
|
| 23 |
+
"""
|
| 24 |
+
Convert a knowledge-base step dict into the engine's fold operation format:
|
| 25 |
+
{"type": ..., "line": {"start": [...], "end": [...]}, "angle": ...}
|
| 26 |
+
"""
|
| 27 |
+
op = {
|
| 28 |
+
"type": step["type"],
|
| 29 |
+
"line": copy.deepcopy(step["line"]),
|
| 30 |
+
"angle": step.get("angle", 180),
|
| 31 |
+
}
|
| 32 |
+
if "layer_select" in step:
|
| 33 |
+
op["layer_select"] = step["layer_select"]
|
| 34 |
+
return op
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def _expected_state_after_fold(fold_type: str, prev_state: dict | None) -> dict:
|
| 38 |
+
"""
|
| 39 |
+
Produce a lightweight expected-state dict describing what the paper
|
| 40 |
+
should look like after a fold. This is intentionally approximate --
|
| 41 |
+
the real simulation engine computes exact geometry.
|
| 42 |
+
"""
|
| 43 |
+
state = dict(prev_state or {"layers": 1, "shape": "square", "phase": "flat"})
|
| 44 |
+
if fold_type in ("valley", "mountain"):
|
| 45 |
+
state["layers"] = state.get("layers", 1) * 2
|
| 46 |
+
elif fold_type == "petal":
|
| 47 |
+
state["shape"] = "narrow_diamond"
|
| 48 |
+
elif fold_type == "squash":
|
| 49 |
+
state["shape"] = "diamond"
|
| 50 |
+
elif fold_type == "reverse_inside":
|
| 51 |
+
state["shape"] = "pointed_flap_reversed"
|
| 52 |
+
elif fold_type == "inflate":
|
| 53 |
+
state["phase"] = "3d"
|
| 54 |
+
elif fold_type == "turn_over":
|
| 55 |
+
state["flipped"] = not state.get("flipped", False)
|
| 56 |
+
elif fold_type == "unfold":
|
| 57 |
+
# Layers don't literally halve on every unfold, but this is a hint
|
| 58 |
+
state["layers"] = max(1, state.get("layers", 1) // 2)
|
| 59 |
+
return state
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
def _validation_for_fold(fold_type: str) -> dict:
|
| 63 |
+
"""Return a simple validation dict for a step."""
|
| 64 |
+
checks: dict = {"flat_foldable": True}
|
| 65 |
+
if fold_type in ("valley", "mountain"):
|
| 66 |
+
checks["kawasaki_check"] = True
|
| 67 |
+
checks["maekawa_check"] = True
|
| 68 |
+
if fold_type == "inflate":
|
| 69 |
+
checks["is_3d"] = True
|
| 70 |
+
checks["flat_foldable"] = False
|
| 71 |
+
return checks
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
# ---------------------------------------------------------------------------
|
| 75 |
+
# Known-model decomposition
|
| 76 |
+
# ---------------------------------------------------------------------------
|
| 77 |
+
|
| 78 |
+
def _decompose_known_model(parsed: dict) -> list[dict]:
|
| 79 |
+
"""Decompose a known model into sub-goal steps."""
|
| 80 |
+
model_name: str = parsed["model_name"]
|
| 81 |
+
model_info = ORIGAMI_MODELS.get(model_name)
|
| 82 |
+
if model_info is None:
|
| 83 |
+
return _decompose_free_fold(parsed)
|
| 84 |
+
|
| 85 |
+
base_name = model_info.get("base")
|
| 86 |
+
steps = get_model_steps(model_name)
|
| 87 |
+
sub_goals: list[dict] = []
|
| 88 |
+
running_state: dict = {"layers": 1, "shape": "square", "phase": "flat"}
|
| 89 |
+
|
| 90 |
+
for i, step in enumerate(steps):
|
| 91 |
+
fold_op = _step_to_fold_operation(step)
|
| 92 |
+
running_state = _expected_state_after_fold(step["type"], running_state)
|
| 93 |
+
|
| 94 |
+
sub_goals.append({
|
| 95 |
+
"step_number": i + 1,
|
| 96 |
+
"description": step.get("description", f"Step {i + 1}"),
|
| 97 |
+
"base_required": base_name if i == 0 else None,
|
| 98 |
+
"fold_operations": [fold_op],
|
| 99 |
+
"expected_state": dict(running_state),
|
| 100 |
+
"validation": _validation_for_fold(step["type"]),
|
| 101 |
+
})
|
| 102 |
+
|
| 103 |
+
return sub_goals
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
# ---------------------------------------------------------------------------
|
| 107 |
+
# Packing / optimization decomposition
|
| 108 |
+
# ---------------------------------------------------------------------------
|
| 109 |
+
|
| 110 |
+
def _decompose_packing(parsed: dict) -> list[dict]:
|
| 111 |
+
"""
|
| 112 |
+
Decompose an optimize_packing task into sub-goals.
|
| 113 |
+
Returns a Miura-ori-style fold plan on a unit square.
|
| 114 |
+
"""
|
| 115 |
+
w = parsed["dimensions"]["width"]
|
| 116 |
+
h = parsed["dimensions"]["height"]
|
| 117 |
+
material = parsed["material"]
|
| 118 |
+
constraints = parsed.get("constraints", {})
|
| 119 |
+
max_folds = constraints.get("max_folds", 20)
|
| 120 |
+
|
| 121 |
+
sub_goals: list[dict] = []
|
| 122 |
+
step_num = 0
|
| 123 |
+
|
| 124 |
+
# Horizontal valley/mountain pleats (zigzag in Y)
|
| 125 |
+
n_horizontal = min(4, max_folds // 4)
|
| 126 |
+
spacing_y = 1.0 / (n_horizontal + 1)
|
| 127 |
+
for i in range(n_horizontal):
|
| 128 |
+
step_num += 1
|
| 129 |
+
y = spacing_y * (i + 1)
|
| 130 |
+
fold_type = "valley" if i % 2 == 0 else "mountain"
|
| 131 |
+
sub_goals.append({
|
| 132 |
+
"step_number": step_num,
|
| 133 |
+
"description": f"Horizontal {fold_type} fold at y={y:.3f} (pleat {i + 1}/{n_horizontal})",
|
| 134 |
+
"base_required": None,
|
| 135 |
+
"fold_operations": [{
|
| 136 |
+
"type": fold_type,
|
| 137 |
+
"line": {"start": [0.0, y], "end": [1.0, y]},
|
| 138 |
+
"angle": 180,
|
| 139 |
+
"layer_select": "all",
|
| 140 |
+
}],
|
| 141 |
+
"expected_state": {"layers": i + 2, "phase": "flat", "pattern": "miura_horizontal"},
|
| 142 |
+
"validation": {"flat_foldable": True, "kawasaki_check": True},
|
| 143 |
+
})
|
| 144 |
+
|
| 145 |
+
# Vertical zigzag valley/mountain pleats (Miura-ori angle offsets)
|
| 146 |
+
n_vertical = min(4, (max_folds - n_horizontal) // 2)
|
| 147 |
+
spacing_x = 1.0 / (n_vertical + 1)
|
| 148 |
+
for i in range(n_vertical):
|
| 149 |
+
step_num += 1
|
| 150 |
+
x = spacing_x * (i + 1)
|
| 151 |
+
fold_type = "valley" if i % 2 == 0 else "mountain"
|
| 152 |
+
# Miura-ori: alternate slight angle offset to create parallelogram cells
|
| 153 |
+
angle_offset = 0.02 * (1 if i % 2 == 0 else -1)
|
| 154 |
+
sub_goals.append({
|
| 155 |
+
"step_number": step_num,
|
| 156 |
+
"description": f"Vertical {fold_type} fold at x={x:.3f} (Miura-ori column {i + 1}/{n_vertical})",
|
| 157 |
+
"base_required": None,
|
| 158 |
+
"fold_operations": [{
|
| 159 |
+
"type": fold_type,
|
| 160 |
+
"line": {"start": [x, 0.0 + angle_offset], "end": [x, 1.0 - angle_offset]},
|
| 161 |
+
"angle": 180,
|
| 162 |
+
"layer_select": "all",
|
| 163 |
+
}],
|
| 164 |
+
"expected_state": {
|
| 165 |
+
"layers": (n_horizontal + 1) * (i + 2),
|
| 166 |
+
"phase": "flat",
|
| 167 |
+
"pattern": "miura_complete" if i == n_vertical - 1 else "miura_partial",
|
| 168 |
+
},
|
| 169 |
+
"validation": {"flat_foldable": True, "kawasaki_check": True, "maekawa_check": True},
|
| 170 |
+
})
|
| 171 |
+
|
| 172 |
+
# Final collapse
|
| 173 |
+
step_num += 1
|
| 174 |
+
sub_goals.append({
|
| 175 |
+
"step_number": step_num,
|
| 176 |
+
"description": "Collapse all creases simultaneously into compact Miura-ori stack",
|
| 177 |
+
"base_required": None,
|
| 178 |
+
"fold_operations": [{
|
| 179 |
+
"type": "valley",
|
| 180 |
+
"line": {"start": [0.0, 0.5], "end": [1.0, 0.5]},
|
| 181 |
+
"angle": 180,
|
| 182 |
+
"layer_select": "all",
|
| 183 |
+
}],
|
| 184 |
+
"expected_state": {
|
| 185 |
+
"layers": (n_horizontal + 1) * (n_vertical + 1),
|
| 186 |
+
"phase": "compact",
|
| 187 |
+
"pattern": "miura_ori",
|
| 188 |
+
},
|
| 189 |
+
"validation": {
|
| 190 |
+
"flat_foldable": True,
|
| 191 |
+
"check_bounding_box": constraints.get("target_box"),
|
| 192 |
+
"check_deployable": constraints.get("must_deploy", False),
|
| 193 |
+
},
|
| 194 |
+
})
|
| 195 |
+
|
| 196 |
+
return sub_goals
|
| 197 |
+
|
| 198 |
+
|
| 199 |
+
# ---------------------------------------------------------------------------
|
| 200 |
+
# Free-fold / unknown model decomposition
|
| 201 |
+
# ---------------------------------------------------------------------------
|
| 202 |
+
|
| 203 |
+
def _decompose_free_fold(parsed: dict) -> list[dict]:
|
| 204 |
+
"""
|
| 205 |
+
Generic decomposition for an unknown model or free-form folding task.
|
| 206 |
+
Returns a minimal plan that an LLM can expand upon.
|
| 207 |
+
"""
|
| 208 |
+
return [
|
| 209 |
+
{
|
| 210 |
+
"step_number": 1,
|
| 211 |
+
"description": "Create reference creases (diagonals and midlines)",
|
| 212 |
+
"base_required": None,
|
| 213 |
+
"fold_operations": [
|
| 214 |
+
{"type": "valley", "line": {"start": [0.0, 0.0], "end": [1.0, 1.0]}, "angle": 180},
|
| 215 |
+
{"type": "unfold", "line": {"start": [0.0, 0.0], "end": [1.0, 1.0]}, "angle": 0},
|
| 216 |
+
{"type": "valley", "line": {"start": [1.0, 0.0], "end": [0.0, 1.0]}, "angle": 180},
|
| 217 |
+
{"type": "unfold", "line": {"start": [1.0, 0.0], "end": [0.0, 1.0]}, "angle": 0},
|
| 218 |
+
{"type": "valley", "line": {"start": [0.0, 0.5], "end": [1.0, 0.5]}, "angle": 180},
|
| 219 |
+
{"type": "unfold", "line": {"start": [0.0, 0.5], "end": [1.0, 0.5]}, "angle": 0},
|
| 220 |
+
],
|
| 221 |
+
"expected_state": {"layers": 1, "shape": "square", "phase": "creased"},
|
| 222 |
+
"validation": {"flat_foldable": True},
|
| 223 |
+
},
|
| 224 |
+
{
|
| 225 |
+
"step_number": 2,
|
| 226 |
+
"description": "Collapse into a base form using reference creases",
|
| 227 |
+
"base_required": "preliminary_base",
|
| 228 |
+
"fold_operations": [
|
| 229 |
+
{"type": "valley", "line": {"start": [0.0, 0.0], "end": [1.0, 1.0]}, "angle": 180, "layer_select": "all"},
|
| 230 |
+
],
|
| 231 |
+
"expected_state": {"layers": 4, "shape": "diamond", "phase": "base"},
|
| 232 |
+
"validation": {"flat_foldable": True},
|
| 233 |
+
},
|
| 234 |
+
{
|
| 235 |
+
"step_number": 3,
|
| 236 |
+
"description": "Shape the model with additional folds (LLM determines specifics)",
|
| 237 |
+
"base_required": None,
|
| 238 |
+
"fold_operations": [], # Left empty for LLM to fill
|
| 239 |
+
"expected_state": {"phase": "shaped"},
|
| 240 |
+
"validation": {"flat_foldable": True},
|
| 241 |
+
},
|
| 242 |
+
]
|
| 243 |
+
|
| 244 |
+
|
| 245 |
+
# ---------------------------------------------------------------------------
|
| 246 |
+
# Fold-pattern decomposition
|
| 247 |
+
# ---------------------------------------------------------------------------
|
| 248 |
+
|
| 249 |
+
def _decompose_pattern(parsed: dict) -> list[dict]:
|
| 250 |
+
"""Decompose a tessellation/pattern task."""
|
| 251 |
+
# For now, delegate to packing which generates a Miura-ori pattern
|
| 252 |
+
return _decompose_packing(parsed)
|
| 253 |
+
|
| 254 |
+
|
| 255 |
+
# ---------------------------------------------------------------------------
|
| 256 |
+
# Public API
|
| 257 |
+
# ---------------------------------------------------------------------------
|
| 258 |
+
|
| 259 |
+
def decompose_task(parsed: dict) -> list[dict]:
|
| 260 |
+
"""
|
| 261 |
+
Decompose a parsed instruction into sequential sub-goals.
|
| 262 |
+
|
| 263 |
+
Args:
|
| 264 |
+
parsed: Output of parse_instruction()
|
| 265 |
+
|
| 266 |
+
Returns:
|
| 267 |
+
List of sub-goal dicts, each with:
|
| 268 |
+
- step_number: int
|
| 269 |
+
- description: str
|
| 270 |
+
- base_required: str or None
|
| 271 |
+
- fold_operations: list[dict] (engine-format fold dicts)
|
| 272 |
+
- expected_state: dict
|
| 273 |
+
- validation: dict
|
| 274 |
+
"""
|
| 275 |
+
intent = parsed.get("intent", "free_fold")
|
| 276 |
+
|
| 277 |
+
if intent == "fold_model" and parsed.get("model_name"):
|
| 278 |
+
return _decompose_known_model(parsed)
|
| 279 |
+
elif intent == "optimize_packing":
|
| 280 |
+
return _decompose_packing(parsed)
|
| 281 |
+
elif intent == "fold_pattern":
|
| 282 |
+
return _decompose_pattern(parsed)
|
| 283 |
+
else:
|
| 284 |
+
return _decompose_free_fold(parsed)
|
planner/knowledge.py
ADDED
|
@@ -0,0 +1,753 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Origami knowledge base: bases, models, and fold operation primitives.
|
| 3 |
+
|
| 4 |
+
All fold coordinates are defined on a unit square (0,0) to (1,1).
|
| 5 |
+
Fold lines use {"start": [x, y], "end": [x, y]} format matching the
|
| 6 |
+
engine's FoldAction specification from architecture.md.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
# ---------------------------------------------------------------------------
|
| 10 |
+
# Primitive fold operations — mapping from named folds to engine-level dicts
|
| 11 |
+
# ---------------------------------------------------------------------------
|
| 12 |
+
|
| 13 |
+
FOLD_OPERATIONS = {
|
| 14 |
+
"valley_fold": {
|
| 15 |
+
"type": "valley",
|
| 16 |
+
"angle": 180,
|
| 17 |
+
"description": "Fold paper toward you along a crease line.",
|
| 18 |
+
},
|
| 19 |
+
"mountain_fold": {
|
| 20 |
+
"type": "mountain",
|
| 21 |
+
"angle": 180,
|
| 22 |
+
"description": "Fold paper away from you along a crease line.",
|
| 23 |
+
},
|
| 24 |
+
"squash_fold": {
|
| 25 |
+
"type": "squash",
|
| 26 |
+
"angle": 180,
|
| 27 |
+
"description": "Open a flap and flatten it symmetrically.",
|
| 28 |
+
"primitives": [
|
| 29 |
+
{"type": "valley", "angle": 90, "sub_op": "open_flap"},
|
| 30 |
+
{"type": "valley", "angle": 180, "sub_op": "flatten"},
|
| 31 |
+
],
|
| 32 |
+
},
|
| 33 |
+
"petal_fold": {
|
| 34 |
+
"type": "petal",
|
| 35 |
+
"angle": 180,
|
| 36 |
+
"description": "Lift a point while collapsing sides inward to create a narrow flap.",
|
| 37 |
+
"primitives": [
|
| 38 |
+
{"type": "valley", "angle": 180, "sub_op": "fold_left_edge_to_center"},
|
| 39 |
+
{"type": "valley", "angle": 180, "sub_op": "fold_right_edge_to_center"},
|
| 40 |
+
{"type": "valley", "angle": 180, "sub_op": "lift_bottom_point"},
|
| 41 |
+
{"type": "mountain", "angle": 180, "sub_op": "collapse_left"},
|
| 42 |
+
{"type": "mountain", "angle": 180, "sub_op": "collapse_right"},
|
| 43 |
+
],
|
| 44 |
+
},
|
| 45 |
+
"reverse_inside_fold": {
|
| 46 |
+
"type": "reverse_inside",
|
| 47 |
+
"angle": 180,
|
| 48 |
+
"description": "Push a flap tip inward, reversing the spine crease.",
|
| 49 |
+
"primitives": [
|
| 50 |
+
{"type": "valley", "angle": 180, "sub_op": "new_crease_left"},
|
| 51 |
+
{"type": "valley", "angle": 180, "sub_op": "new_crease_right"},
|
| 52 |
+
{"type": "mountain", "angle": 180, "sub_op": "reverse_spine"},
|
| 53 |
+
],
|
| 54 |
+
},
|
| 55 |
+
"reverse_outside_fold": {
|
| 56 |
+
"type": "reverse_outside",
|
| 57 |
+
"angle": 180,
|
| 58 |
+
"description": "Wrap a flap tip around the outside, reversing the spine crease.",
|
| 59 |
+
"primitives": [
|
| 60 |
+
{"type": "mountain", "angle": 180, "sub_op": "new_crease_left"},
|
| 61 |
+
{"type": "mountain", "angle": 180, "sub_op": "new_crease_right"},
|
| 62 |
+
{"type": "valley", "angle": 180, "sub_op": "reverse_spine"},
|
| 63 |
+
],
|
| 64 |
+
},
|
| 65 |
+
"crimp": {
|
| 66 |
+
"type": "crimp",
|
| 67 |
+
"angle": 180,
|
| 68 |
+
"description": "Pair of reverse folds creating a zigzag step.",
|
| 69 |
+
"primitives": [
|
| 70 |
+
{"type": "valley", "angle": 180, "sub_op": "first_crease"},
|
| 71 |
+
{"type": "mountain", "angle": 180, "sub_op": "second_crease"},
|
| 72 |
+
],
|
| 73 |
+
},
|
| 74 |
+
"pleat": {
|
| 75 |
+
"type": "pleat",
|
| 76 |
+
"angle": 180,
|
| 77 |
+
"description": "Alternating valley and mountain folds creating an accordion.",
|
| 78 |
+
"primitives": [
|
| 79 |
+
{"type": "valley", "angle": 180, "sub_op": "valley_crease"},
|
| 80 |
+
{"type": "mountain", "angle": 180, "sub_op": "mountain_crease"},
|
| 81 |
+
],
|
| 82 |
+
},
|
| 83 |
+
"rabbit_ear": {
|
| 84 |
+
"type": "rabbit_ear",
|
| 85 |
+
"angle": 180,
|
| 86 |
+
"description": "Three creases meeting at a point, creating a triangular raised flap.",
|
| 87 |
+
"primitives": [
|
| 88 |
+
{"type": "valley", "angle": 180, "sub_op": "bisector_1"},
|
| 89 |
+
{"type": "valley", "angle": 180, "sub_op": "bisector_2"},
|
| 90 |
+
{"type": "mountain", "angle": 180, "sub_op": "ridge"},
|
| 91 |
+
],
|
| 92 |
+
},
|
| 93 |
+
"sink_fold": {
|
| 94 |
+
"type": "sink",
|
| 95 |
+
"angle": 180,
|
| 96 |
+
"description": "Push a point into the interior of the model.",
|
| 97 |
+
"primitives": [
|
| 98 |
+
{"type": "mountain", "angle": 180, "sub_op": "reverse_creases"},
|
| 99 |
+
{"type": "valley", "angle": 180, "sub_op": "reflatten"},
|
| 100 |
+
],
|
| 101 |
+
},
|
| 102 |
+
"turn_over": {
|
| 103 |
+
"type": "turn_over",
|
| 104 |
+
"angle": 0,
|
| 105 |
+
"description": "Flip the paper over.",
|
| 106 |
+
},
|
| 107 |
+
"unfold": {
|
| 108 |
+
"type": "unfold",
|
| 109 |
+
"angle": 0,
|
| 110 |
+
"description": "Reverse a previous fold (crease remains).",
|
| 111 |
+
},
|
| 112 |
+
"inflate": {
|
| 113 |
+
"type": "inflate",
|
| 114 |
+
"angle": 0,
|
| 115 |
+
"description": "Gently open and puff out the model to create 3D form.",
|
| 116 |
+
},
|
| 117 |
+
}
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
# ---------------------------------------------------------------------------
|
| 121 |
+
# Origami bases — fundamental starting configurations
|
| 122 |
+
# ---------------------------------------------------------------------------
|
| 123 |
+
|
| 124 |
+
ORIGAMI_BASES = {
|
| 125 |
+
"preliminary_base": {
|
| 126 |
+
"name": "Preliminary Base (Square Base)",
|
| 127 |
+
"description": "Multi-layered diamond standing on a corner. Gateway to bird and frog bases.",
|
| 128 |
+
"total_steps": 9,
|
| 129 |
+
"steps": [
|
| 130 |
+
{
|
| 131 |
+
"description": "Fold in half diagonally (bottom-left to top-right)",
|
| 132 |
+
"type": "valley",
|
| 133 |
+
"line": {"start": [0.0, 0.0], "end": [1.0, 1.0]},
|
| 134 |
+
"angle": 180,
|
| 135 |
+
"layer_select": "all",
|
| 136 |
+
},
|
| 137 |
+
{
|
| 138 |
+
"description": "Unfold",
|
| 139 |
+
"type": "unfold",
|
| 140 |
+
"line": {"start": [0.0, 0.0], "end": [1.0, 1.0]},
|
| 141 |
+
"angle": 0,
|
| 142 |
+
"layer_select": "all",
|
| 143 |
+
},
|
| 144 |
+
{
|
| 145 |
+
"description": "Fold in half on other diagonal (bottom-right to top-left)",
|
| 146 |
+
"type": "valley",
|
| 147 |
+
"line": {"start": [1.0, 0.0], "end": [0.0, 1.0]},
|
| 148 |
+
"angle": 180,
|
| 149 |
+
"layer_select": "all",
|
| 150 |
+
},
|
| 151 |
+
{
|
| 152 |
+
"description": "Unfold",
|
| 153 |
+
"type": "unfold",
|
| 154 |
+
"line": {"start": [1.0, 0.0], "end": [0.0, 1.0]},
|
| 155 |
+
"angle": 0,
|
| 156 |
+
"layer_select": "all",
|
| 157 |
+
},
|
| 158 |
+
{
|
| 159 |
+
"description": "Fold in half horizontally",
|
| 160 |
+
"type": "valley",
|
| 161 |
+
"line": {"start": [0.0, 0.5], "end": [1.0, 0.5]},
|
| 162 |
+
"angle": 180,
|
| 163 |
+
"layer_select": "all",
|
| 164 |
+
},
|
| 165 |
+
{
|
| 166 |
+
"description": "Unfold",
|
| 167 |
+
"type": "unfold",
|
| 168 |
+
"line": {"start": [0.0, 0.5], "end": [1.0, 0.5]},
|
| 169 |
+
"angle": 0,
|
| 170 |
+
"layer_select": "all",
|
| 171 |
+
},
|
| 172 |
+
{
|
| 173 |
+
"description": "Fold in half vertically",
|
| 174 |
+
"type": "valley",
|
| 175 |
+
"line": {"start": [0.5, 0.0], "end": [0.5, 1.0]},
|
| 176 |
+
"angle": 180,
|
| 177 |
+
"layer_select": "all",
|
| 178 |
+
},
|
| 179 |
+
{
|
| 180 |
+
"description": "Unfold",
|
| 181 |
+
"type": "unfold",
|
| 182 |
+
"line": {"start": [0.5, 0.0], "end": [0.5, 1.0]},
|
| 183 |
+
"angle": 0,
|
| 184 |
+
"layer_select": "all",
|
| 185 |
+
},
|
| 186 |
+
{
|
| 187 |
+
"description": "Collapse into preliminary base: push sides in using existing creases",
|
| 188 |
+
"type": "valley",
|
| 189 |
+
"line": {"start": [0.0, 0.0], "end": [1.0, 1.0]},
|
| 190 |
+
"angle": 180,
|
| 191 |
+
"layer_select": "all",
|
| 192 |
+
"simultaneous": [
|
| 193 |
+
{"type": "valley", "line": {"start": [1.0, 0.0], "end": [0.0, 1.0]}, "angle": 180},
|
| 194 |
+
{"type": "mountain", "line": {"start": [0.0, 0.5], "end": [1.0, 0.5]}, "angle": 180},
|
| 195 |
+
{"type": "mountain", "line": {"start": [0.5, 0.0], "end": [0.5, 1.0]}, "angle": 180},
|
| 196 |
+
],
|
| 197 |
+
},
|
| 198 |
+
],
|
| 199 |
+
},
|
| 200 |
+
|
| 201 |
+
"waterbomb_base": {
|
| 202 |
+
"name": "Waterbomb Base",
|
| 203 |
+
"description": "Flat triangle with multiple layers. Inverse of preliminary base.",
|
| 204 |
+
"total_steps": 9,
|
| 205 |
+
"steps": [
|
| 206 |
+
{
|
| 207 |
+
"description": "Fold both diagonals — first diagonal",
|
| 208 |
+
"type": "valley",
|
| 209 |
+
"line": {"start": [0.0, 0.0], "end": [1.0, 1.0]},
|
| 210 |
+
"angle": 180,
|
| 211 |
+
"layer_select": "all",
|
| 212 |
+
},
|
| 213 |
+
{
|
| 214 |
+
"description": "Unfold first diagonal",
|
| 215 |
+
"type": "unfold",
|
| 216 |
+
"line": {"start": [0.0, 0.0], "end": [1.0, 1.0]},
|
| 217 |
+
"angle": 0,
|
| 218 |
+
"layer_select": "all",
|
| 219 |
+
},
|
| 220 |
+
{
|
| 221 |
+
"description": "Fold second diagonal",
|
| 222 |
+
"type": "valley",
|
| 223 |
+
"line": {"start": [1.0, 0.0], "end": [0.0, 1.0]},
|
| 224 |
+
"angle": 180,
|
| 225 |
+
"layer_select": "all",
|
| 226 |
+
},
|
| 227 |
+
{
|
| 228 |
+
"description": "Unfold second diagonal",
|
| 229 |
+
"type": "unfold",
|
| 230 |
+
"line": {"start": [1.0, 0.0], "end": [0.0, 1.0]},
|
| 231 |
+
"angle": 0,
|
| 232 |
+
"layer_select": "all",
|
| 233 |
+
},
|
| 234 |
+
{
|
| 235 |
+
"description": "Mountain fold horizontally",
|
| 236 |
+
"type": "mountain",
|
| 237 |
+
"line": {"start": [0.0, 0.5], "end": [1.0, 0.5]},
|
| 238 |
+
"angle": 180,
|
| 239 |
+
"layer_select": "all",
|
| 240 |
+
},
|
| 241 |
+
{
|
| 242 |
+
"description": "Unfold horizontal",
|
| 243 |
+
"type": "unfold",
|
| 244 |
+
"line": {"start": [0.0, 0.5], "end": [1.0, 0.5]},
|
| 245 |
+
"angle": 0,
|
| 246 |
+
"layer_select": "all",
|
| 247 |
+
},
|
| 248 |
+
{
|
| 249 |
+
"description": "Mountain fold vertically",
|
| 250 |
+
"type": "mountain",
|
| 251 |
+
"line": {"start": [0.5, 0.0], "end": [0.5, 1.0]},
|
| 252 |
+
"angle": 180,
|
| 253 |
+
"layer_select": "all",
|
| 254 |
+
},
|
| 255 |
+
{
|
| 256 |
+
"description": "Unfold vertical",
|
| 257 |
+
"type": "unfold",
|
| 258 |
+
"line": {"start": [0.5, 0.0], "end": [0.5, 1.0]},
|
| 259 |
+
"angle": 0,
|
| 260 |
+
"layer_select": "all",
|
| 261 |
+
},
|
| 262 |
+
{
|
| 263 |
+
"description": "Collapse into waterbomb base: fold top edge down, push sides in",
|
| 264 |
+
"type": "mountain",
|
| 265 |
+
"line": {"start": [0.0, 0.5], "end": [1.0, 0.5]},
|
| 266 |
+
"angle": 180,
|
| 267 |
+
"layer_select": "all",
|
| 268 |
+
"simultaneous": [
|
| 269 |
+
{"type": "valley", "line": {"start": [0.0, 0.0], "end": [1.0, 1.0]}, "angle": 180},
|
| 270 |
+
{"type": "valley", "line": {"start": [1.0, 0.0], "end": [0.0, 1.0]}, "angle": 180},
|
| 271 |
+
],
|
| 272 |
+
},
|
| 273 |
+
],
|
| 274 |
+
},
|
| 275 |
+
|
| 276 |
+
"bird_base": {
|
| 277 |
+
"name": "Bird Base (Crane Base)",
|
| 278 |
+
"description": "Long diamond with 4 narrow flaps. Built from preliminary base + 2 petal folds.",
|
| 279 |
+
"requires_base": "preliminary_base",
|
| 280 |
+
"total_steps": 13,
|
| 281 |
+
"steps": [
|
| 282 |
+
# Steps 1-9 are the preliminary base (included by reference)
|
| 283 |
+
# Steps 10-22 from the crane sequence build the bird base
|
| 284 |
+
{
|
| 285 |
+
"description": "Fold left edge of top layer to center line",
|
| 286 |
+
"type": "valley",
|
| 287 |
+
"line": {"start": [0.25, 0.5], "end": [0.5, 1.0]},
|
| 288 |
+
"angle": 180,
|
| 289 |
+
"layer_select": "top",
|
| 290 |
+
},
|
| 291 |
+
{
|
| 292 |
+
"description": "Fold right edge of top layer to center line",
|
| 293 |
+
"type": "valley",
|
| 294 |
+
"line": {"start": [0.75, 0.5], "end": [0.5, 1.0]},
|
| 295 |
+
"angle": 180,
|
| 296 |
+
"layer_select": "top",
|
| 297 |
+
},
|
| 298 |
+
{
|
| 299 |
+
"description": "Fold top triangle down over kite flaps (crease only)",
|
| 300 |
+
"type": "valley",
|
| 301 |
+
"line": {"start": [0.25, 0.75], "end": [0.75, 0.75]},
|
| 302 |
+
"angle": 180,
|
| 303 |
+
"layer_select": "top",
|
| 304 |
+
},
|
| 305 |
+
{
|
| 306 |
+
"description": "Unfold top triangle",
|
| 307 |
+
"type": "unfold",
|
| 308 |
+
"line": {"start": [0.25, 0.75], "end": [0.75, 0.75]},
|
| 309 |
+
"angle": 0,
|
| 310 |
+
"layer_select": "top",
|
| 311 |
+
},
|
| 312 |
+
{
|
| 313 |
+
"description": "Unfold kite folds",
|
| 314 |
+
"type": "unfold",
|
| 315 |
+
"line": {"start": [0.25, 0.5], "end": [0.5, 1.0]},
|
| 316 |
+
"angle": 0,
|
| 317 |
+
"layer_select": "top",
|
| 318 |
+
},
|
| 319 |
+
{
|
| 320 |
+
"description": "Petal fold front: lift bottom point up, sides collapse inward",
|
| 321 |
+
"type": "petal",
|
| 322 |
+
"line": {"start": [0.5, 0.5], "end": [0.5, 1.0]},
|
| 323 |
+
"angle": 180,
|
| 324 |
+
"layer_select": "top",
|
| 325 |
+
},
|
| 326 |
+
{
|
| 327 |
+
"description": "Turn model over",
|
| 328 |
+
"type": "turn_over",
|
| 329 |
+
"line": {"start": [0.5, 0.0], "end": [0.5, 1.0]},
|
| 330 |
+
"angle": 0,
|
| 331 |
+
"layer_select": "all",
|
| 332 |
+
},
|
| 333 |
+
{
|
| 334 |
+
"description": "Fold left edge to center line (back)",
|
| 335 |
+
"type": "valley",
|
| 336 |
+
"line": {"start": [0.25, 0.5], "end": [0.5, 1.0]},
|
| 337 |
+
"angle": 180,
|
| 338 |
+
"layer_select": "top",
|
| 339 |
+
},
|
| 340 |
+
{
|
| 341 |
+
"description": "Fold right edge to center line (back)",
|
| 342 |
+
"type": "valley",
|
| 343 |
+
"line": {"start": [0.75, 0.5], "end": [0.5, 1.0]},
|
| 344 |
+
"angle": 180,
|
| 345 |
+
"layer_select": "top",
|
| 346 |
+
},
|
| 347 |
+
{
|
| 348 |
+
"description": "Fold top triangle down (crease only, back)",
|
| 349 |
+
"type": "valley",
|
| 350 |
+
"line": {"start": [0.25, 0.75], "end": [0.75, 0.75]},
|
| 351 |
+
"angle": 180,
|
| 352 |
+
"layer_select": "top",
|
| 353 |
+
},
|
| 354 |
+
{
|
| 355 |
+
"description": "Unfold top triangle (back)",
|
| 356 |
+
"type": "unfold",
|
| 357 |
+
"line": {"start": [0.25, 0.75], "end": [0.75, 0.75]},
|
| 358 |
+
"angle": 0,
|
| 359 |
+
"layer_select": "top",
|
| 360 |
+
},
|
| 361 |
+
{
|
| 362 |
+
"description": "Unfold kite folds (back)",
|
| 363 |
+
"type": "unfold",
|
| 364 |
+
"line": {"start": [0.25, 0.5], "end": [0.5, 1.0]},
|
| 365 |
+
"angle": 0,
|
| 366 |
+
"layer_select": "top",
|
| 367 |
+
},
|
| 368 |
+
{
|
| 369 |
+
"description": "Petal fold back: lift bottom point up, sides collapse inward",
|
| 370 |
+
"type": "petal",
|
| 371 |
+
"line": {"start": [0.5, 0.5], "end": [0.5, 1.0]},
|
| 372 |
+
"angle": 180,
|
| 373 |
+
"layer_select": "top",
|
| 374 |
+
},
|
| 375 |
+
],
|
| 376 |
+
},
|
| 377 |
+
|
| 378 |
+
"frog_base": {
|
| 379 |
+
"name": "Frog Base",
|
| 380 |
+
"description": "4 long narrow flaps radiating from center. Built from preliminary base + 4 squash + 4 petal folds.",
|
| 381 |
+
"requires_base": "preliminary_base",
|
| 382 |
+
"total_steps": 8,
|
| 383 |
+
"steps": [
|
| 384 |
+
{
|
| 385 |
+
"description": "Squash fold front-left flap",
|
| 386 |
+
"type": "squash",
|
| 387 |
+
"line": {"start": [0.25, 0.5], "end": [0.5, 0.75]},
|
| 388 |
+
"angle": 180,
|
| 389 |
+
"layer_select": "top",
|
| 390 |
+
},
|
| 391 |
+
{
|
| 392 |
+
"description": "Squash fold front-right flap",
|
| 393 |
+
"type": "squash",
|
| 394 |
+
"line": {"start": [0.75, 0.5], "end": [0.5, 0.75]},
|
| 395 |
+
"angle": 180,
|
| 396 |
+
"layer_select": "top",
|
| 397 |
+
},
|
| 398 |
+
{
|
| 399 |
+
"description": "Squash fold back-left flap",
|
| 400 |
+
"type": "squash",
|
| 401 |
+
"line": {"start": [0.25, 0.5], "end": [0.5, 0.75]},
|
| 402 |
+
"angle": 180,
|
| 403 |
+
"layer_select": "top",
|
| 404 |
+
},
|
| 405 |
+
{
|
| 406 |
+
"description": "Squash fold back-right flap",
|
| 407 |
+
"type": "squash",
|
| 408 |
+
"line": {"start": [0.75, 0.5], "end": [0.5, 0.75]},
|
| 409 |
+
"angle": 180,
|
| 410 |
+
"layer_select": "top",
|
| 411 |
+
},
|
| 412 |
+
{
|
| 413 |
+
"description": "Petal fold first diamond",
|
| 414 |
+
"type": "petal",
|
| 415 |
+
"line": {"start": [0.5, 0.5], "end": [0.5, 1.0]},
|
| 416 |
+
"angle": 180,
|
| 417 |
+
"layer_select": "top",
|
| 418 |
+
},
|
| 419 |
+
{
|
| 420 |
+
"description": "Petal fold second diamond",
|
| 421 |
+
"type": "petal",
|
| 422 |
+
"line": {"start": [0.5, 0.5], "end": [0.5, 1.0]},
|
| 423 |
+
"angle": 180,
|
| 424 |
+
"layer_select": "top",
|
| 425 |
+
},
|
| 426 |
+
{
|
| 427 |
+
"description": "Petal fold third diamond",
|
| 428 |
+
"type": "petal",
|
| 429 |
+
"line": {"start": [0.5, 0.5], "end": [0.5, 1.0]},
|
| 430 |
+
"angle": 180,
|
| 431 |
+
"layer_select": "top",
|
| 432 |
+
},
|
| 433 |
+
{
|
| 434 |
+
"description": "Petal fold fourth diamond",
|
| 435 |
+
"type": "petal",
|
| 436 |
+
"line": {"start": [0.5, 0.5], "end": [0.5, 1.0]},
|
| 437 |
+
"angle": 180,
|
| 438 |
+
"layer_select": "top",
|
| 439 |
+
},
|
| 440 |
+
],
|
| 441 |
+
},
|
| 442 |
+
|
| 443 |
+
"fish_base": {
|
| 444 |
+
"name": "Fish Base",
|
| 445 |
+
"description": "Diamond shape with 4 points. Built from kite folds + rabbit ears.",
|
| 446 |
+
"total_steps": 7,
|
| 447 |
+
"steps": [
|
| 448 |
+
{
|
| 449 |
+
"description": "Fold diagonal crease (reference line)",
|
| 450 |
+
"type": "valley",
|
| 451 |
+
"line": {"start": [0.0, 0.0], "end": [1.0, 1.0]},
|
| 452 |
+
"angle": 180,
|
| 453 |
+
"layer_select": "all",
|
| 454 |
+
},
|
| 455 |
+
{
|
| 456 |
+
"description": "Unfold diagonal",
|
| 457 |
+
"type": "unfold",
|
| 458 |
+
"line": {"start": [0.0, 0.0], "end": [1.0, 1.0]},
|
| 459 |
+
"angle": 0,
|
| 460 |
+
"layer_select": "all",
|
| 461 |
+
},
|
| 462 |
+
{
|
| 463 |
+
"description": "Kite fold: fold bottom-left edge to diagonal",
|
| 464 |
+
"type": "valley",
|
| 465 |
+
"line": {"start": [0.0, 0.0], "end": [0.5, 0.5]},
|
| 466 |
+
"angle": 180,
|
| 467 |
+
"layer_select": "all",
|
| 468 |
+
},
|
| 469 |
+
{
|
| 470 |
+
"description": "Kite fold: fold top-left edge to diagonal",
|
| 471 |
+
"type": "valley",
|
| 472 |
+
"line": {"start": [0.0, 1.0], "end": [0.5, 0.5]},
|
| 473 |
+
"angle": 180,
|
| 474 |
+
"layer_select": "all",
|
| 475 |
+
},
|
| 476 |
+
{
|
| 477 |
+
"description": "Rabbit ear fold on bottom-right corner",
|
| 478 |
+
"type": "rabbit_ear",
|
| 479 |
+
"line": {"start": [0.5, 0.0], "end": [1.0, 0.5]},
|
| 480 |
+
"angle": 180,
|
| 481 |
+
"layer_select": "all",
|
| 482 |
+
},
|
| 483 |
+
{
|
| 484 |
+
"description": "Rabbit ear fold on top-right corner",
|
| 485 |
+
"type": "rabbit_ear",
|
| 486 |
+
"line": {"start": [0.5, 1.0], "end": [1.0, 0.5]},
|
| 487 |
+
"angle": 180,
|
| 488 |
+
"layer_select": "all",
|
| 489 |
+
},
|
| 490 |
+
{
|
| 491 |
+
"description": "Fold resulting flaps down flat",
|
| 492 |
+
"type": "valley",
|
| 493 |
+
"line": {"start": [0.5, 0.5], "end": [1.0, 0.5]},
|
| 494 |
+
"angle": 180,
|
| 495 |
+
"layer_select": "top",
|
| 496 |
+
},
|
| 497 |
+
],
|
| 498 |
+
},
|
| 499 |
+
|
| 500 |
+
"kite_base": {
|
| 501 |
+
"name": "Kite Base",
|
| 502 |
+
"description": "Simplest base: a kite shape from two folds to a diagonal.",
|
| 503 |
+
"total_steps": 3,
|
| 504 |
+
"steps": [
|
| 505 |
+
{
|
| 506 |
+
"description": "Fold diagonal (reference crease)",
|
| 507 |
+
"type": "valley",
|
| 508 |
+
"line": {"start": [0.0, 0.0], "end": [1.0, 1.0]},
|
| 509 |
+
"angle": 180,
|
| 510 |
+
"layer_select": "all",
|
| 511 |
+
},
|
| 512 |
+
{
|
| 513 |
+
"description": "Unfold diagonal",
|
| 514 |
+
"type": "unfold",
|
| 515 |
+
"line": {"start": [0.0, 0.0], "end": [1.0, 1.0]},
|
| 516 |
+
"angle": 0,
|
| 517 |
+
"layer_select": "all",
|
| 518 |
+
},
|
| 519 |
+
{
|
| 520 |
+
"description": "Fold bottom-left and top-left edges to lie on diagonal",
|
| 521 |
+
"type": "valley",
|
| 522 |
+
"line": {"start": [0.0, 0.0], "end": [0.5, 0.5]},
|
| 523 |
+
"angle": 180,
|
| 524 |
+
"layer_select": "all",
|
| 525 |
+
},
|
| 526 |
+
],
|
| 527 |
+
},
|
| 528 |
+
}
|
| 529 |
+
|
| 530 |
+
|
| 531 |
+
# ---------------------------------------------------------------------------
|
| 532 |
+
# Complete origami models — full fold sequences from flat square to finished
|
| 533 |
+
# ---------------------------------------------------------------------------
|
| 534 |
+
|
| 535 |
+
ORIGAMI_MODELS = {
|
| 536 |
+
"crane": {
|
| 537 |
+
"name": "Paper Crane (Tsuru)",
|
| 538 |
+
"difficulty": "intermediate",
|
| 539 |
+
"base": "bird_base",
|
| 540 |
+
"total_steps": 31,
|
| 541 |
+
"description": "The traditional Japanese crane. 31 steps from flat square.",
|
| 542 |
+
"steps": [
|
| 543 |
+
# Phase 1: Pre-crease (steps 1-8)
|
| 544 |
+
{"step": 1, "description": "Fold square in half diagonally (bottom-left to top-right)", "type": "valley", "line": {"start": [0.0, 0.0], "end": [1.0, 1.0]}, "angle": 180, "layer_select": "all"},
|
| 545 |
+
{"step": 2, "description": "Unfold", "type": "unfold", "line": {"start": [0.0, 0.0], "end": [1.0, 1.0]}, "angle": 0, "layer_select": "all"},
|
| 546 |
+
{"step": 3, "description": "Fold in half on other diagonal", "type": "valley", "line": {"start": [1.0, 0.0], "end": [0.0, 1.0]}, "angle": 180, "layer_select": "all"},
|
| 547 |
+
{"step": 4, "description": "Unfold", "type": "unfold", "line": {"start": [1.0, 0.0], "end": [0.0, 1.0]}, "angle": 0, "layer_select": "all"},
|
| 548 |
+
{"step": 5, "description": "Fold in half horizontally", "type": "valley", "line": {"start": [0.0, 0.5], "end": [1.0, 0.5]}, "angle": 180, "layer_select": "all"},
|
| 549 |
+
{"step": 6, "description": "Unfold", "type": "unfold", "line": {"start": [0.0, 0.5], "end": [1.0, 0.5]}, "angle": 0, "layer_select": "all"},
|
| 550 |
+
{"step": 7, "description": "Fold in half vertically", "type": "valley", "line": {"start": [0.5, 0.0], "end": [0.5, 1.0]}, "angle": 180, "layer_select": "all"},
|
| 551 |
+
{"step": 8, "description": "Unfold", "type": "unfold", "line": {"start": [0.5, 0.0], "end": [0.5, 1.0]}, "angle": 0, "layer_select": "all"},
|
| 552 |
+
|
| 553 |
+
# Phase 2: Collapse into preliminary base (step 9)
|
| 554 |
+
{"step": 9, "description": "Collapse into preliminary base: push left and right edges inward, fold top down", "type": "valley", "line": {"start": [0.0, 0.0], "end": [1.0, 1.0]}, "angle": 180, "layer_select": "all"},
|
| 555 |
+
|
| 556 |
+
# Phase 3: Front kite folds (steps 10-14)
|
| 557 |
+
{"step": 10, "description": "Fold left edge of top layer to center line", "type": "valley", "line": {"start": [0.25, 0.5], "end": [0.5, 1.0]}, "angle": 180, "layer_select": "top"},
|
| 558 |
+
{"step": 11, "description": "Fold right edge of top layer to center line", "type": "valley", "line": {"start": [0.75, 0.5], "end": [0.5, 1.0]}, "angle": 180, "layer_select": "top"},
|
| 559 |
+
{"step": 12, "description": "Fold top triangle down over kite flaps", "type": "valley", "line": {"start": [0.25, 0.75], "end": [0.75, 0.75]}, "angle": 180, "layer_select": "top"},
|
| 560 |
+
{"step": 13, "description": "Unfold step 12", "type": "unfold", "line": {"start": [0.25, 0.75], "end": [0.75, 0.75]}, "angle": 0, "layer_select": "top"},
|
| 561 |
+
{"step": 14, "description": "Unfold steps 10-11", "type": "unfold", "line": {"start": [0.25, 0.5], "end": [0.5, 1.0]}, "angle": 0, "layer_select": "top"},
|
| 562 |
+
|
| 563 |
+
# Phase 4: Front petal fold (step 15)
|
| 564 |
+
{"step": 15, "description": "Petal fold: lift bottom point of top layer upward, sides collapse inward", "type": "petal", "line": {"start": [0.5, 0.5], "end": [0.5, 1.0]}, "angle": 180, "layer_select": "top"},
|
| 565 |
+
|
| 566 |
+
# Phase 5: Repeat on back (steps 16-22)
|
| 567 |
+
{"step": 16, "description": "Turn model over", "type": "turn_over", "line": {"start": [0.5, 0.0], "end": [0.5, 1.0]}, "angle": 0, "layer_select": "all"},
|
| 568 |
+
{"step": 17, "description": "Fold left edge to center line", "type": "valley", "line": {"start": [0.25, 0.5], "end": [0.5, 1.0]}, "angle": 180, "layer_select": "top"},
|
| 569 |
+
{"step": 18, "description": "Fold right edge to center line", "type": "valley", "line": {"start": [0.75, 0.5], "end": [0.5, 1.0]}, "angle": 180, "layer_select": "top"},
|
| 570 |
+
{"step": 19, "description": "Fold top triangle down", "type": "valley", "line": {"start": [0.25, 0.75], "end": [0.75, 0.75]}, "angle": 180, "layer_select": "top"},
|
| 571 |
+
{"step": 20, "description": "Unfold step 19", "type": "unfold", "line": {"start": [0.25, 0.75], "end": [0.75, 0.75]}, "angle": 0, "layer_select": "top"},
|
| 572 |
+
{"step": 21, "description": "Unfold steps 17-18", "type": "unfold", "line": {"start": [0.25, 0.5], "end": [0.5, 1.0]}, "angle": 0, "layer_select": "top"},
|
| 573 |
+
{"step": 22, "description": "Petal fold back: lift bottom point up, collapse sides in. Bird base complete.", "type": "petal", "line": {"start": [0.5, 0.5], "end": [0.5, 1.0]}, "angle": 180, "layer_select": "top"},
|
| 574 |
+
|
| 575 |
+
# Phase 6: Narrow the legs (steps 23-27)
|
| 576 |
+
{"step": 23, "description": "Fold left flap (front) edge to center", "type": "valley", "line": {"start": [0.375, 0.5], "end": [0.5, 1.0]}, "angle": 180, "layer_select": "top"},
|
| 577 |
+
{"step": 24, "description": "Fold right flap (front) edge to center", "type": "valley", "line": {"start": [0.625, 0.5], "end": [0.5, 1.0]}, "angle": 180, "layer_select": "top"},
|
| 578 |
+
{"step": 25, "description": "Turn over", "type": "turn_over", "line": {"start": [0.5, 0.0], "end": [0.5, 1.0]}, "angle": 0, "layer_select": "all"},
|
| 579 |
+
{"step": 26, "description": "Fold left flap (back) edge to center", "type": "valley", "line": {"start": [0.375, 0.5], "end": [0.5, 1.0]}, "angle": 180, "layer_select": "top"},
|
| 580 |
+
{"step": 27, "description": "Fold right flap (back) edge to center", "type": "valley", "line": {"start": [0.625, 0.5], "end": [0.5, 1.0]}, "angle": 180, "layer_select": "top"},
|
| 581 |
+
|
| 582 |
+
# Phase 7: Form neck and tail (steps 28-29)
|
| 583 |
+
{"step": 28, "description": "Inside reverse fold left flap upward to form neck", "type": "reverse_inside", "line": {"start": [0.35, 0.6], "end": [0.45, 0.85]}, "angle": 150, "layer_select": "all"},
|
| 584 |
+
{"step": 29, "description": "Inside reverse fold right flap upward to form tail", "type": "reverse_inside", "line": {"start": [0.55, 0.6], "end": [0.65, 0.85]}, "angle": 150, "layer_select": "all"},
|
| 585 |
+
|
| 586 |
+
# Phase 8: Head and finish (steps 30-31)
|
| 587 |
+
{"step": 30, "description": "Inside reverse fold tip of neck downward to form head/beak", "type": "reverse_inside", "line": {"start": [0.38, 0.82], "end": [0.42, 0.9]}, "angle": 150, "layer_select": "all"},
|
| 588 |
+
{"step": 31, "description": "Pull wings apart gently and press bottom to inflate body", "type": "inflate", "line": {"start": [0.5, 0.5], "end": [0.5, 0.7]}, "angle": 0, "layer_select": "all"},
|
| 589 |
+
],
|
| 590 |
+
},
|
| 591 |
+
|
| 592 |
+
"boat": {
|
| 593 |
+
"name": "Simple Boat",
|
| 594 |
+
"difficulty": "simple",
|
| 595 |
+
"base": None,
|
| 596 |
+
"total_steps": 9,
|
| 597 |
+
"description": "A flat boat/hat from simple valley and mountain folds.",
|
| 598 |
+
"steps": [
|
| 599 |
+
{"step": 1, "description": "Fold in half horizontally (top to bottom)", "type": "valley", "line": {"start": [0.0, 0.5], "end": [1.0, 0.5]}, "angle": 180, "layer_select": "all"},
|
| 600 |
+
{"step": 2, "description": "Fold in half vertically (crease only)", "type": "valley", "line": {"start": [0.5, 0.0], "end": [0.5, 0.5]}, "angle": 180, "layer_select": "all"},
|
| 601 |
+
{"step": 3, "description": "Unfold vertical", "type": "unfold", "line": {"start": [0.5, 0.0], "end": [0.5, 0.5]}, "angle": 0, "layer_select": "all"},
|
| 602 |
+
{"step": 4, "description": "Fold top-left corner down to center mark", "type": "valley", "line": {"start": [0.15, 0.5], "end": [0.5, 0.35]}, "angle": 180, "layer_select": "top"},
|
| 603 |
+
{"step": 5, "description": "Fold top-right corner down to center mark", "type": "valley", "line": {"start": [0.85, 0.5], "end": [0.5, 0.35]}, "angle": 180, "layer_select": "top"},
|
| 604 |
+
{"step": 6, "description": "Fold bottom strip up (front layer)", "type": "valley", "line": {"start": [0.0, 0.15], "end": [1.0, 0.15]}, "angle": 180, "layer_select": "top"},
|
| 605 |
+
{"step": 7, "description": "Turn over", "type": "turn_over", "line": {"start": [0.5, 0.0], "end": [0.5, 0.5]}, "angle": 0, "layer_select": "all"},
|
| 606 |
+
{"step": 8, "description": "Fold bottom strip up (back layer)", "type": "valley", "line": {"start": [0.0, 0.15], "end": [1.0, 0.15]}, "angle": 180, "layer_select": "top"},
|
| 607 |
+
{"step": 9, "description": "Open from bottom and flatten into boat shape", "type": "inflate", "line": {"start": [0.5, 0.0], "end": [0.5, 0.5]}, "angle": 0, "layer_select": "all"},
|
| 608 |
+
],
|
| 609 |
+
},
|
| 610 |
+
|
| 611 |
+
"airplane": {
|
| 612 |
+
"name": "Paper Airplane (Dart)",
|
| 613 |
+
"difficulty": "simple",
|
| 614 |
+
"base": None,
|
| 615 |
+
"total_steps": 6,
|
| 616 |
+
"description": "Classic dart-style paper airplane using only valley folds.",
|
| 617 |
+
"steps": [
|
| 618 |
+
{"step": 1, "description": "Fold in half vertically (left to right)", "type": "valley", "line": {"start": [0.5, 0.0], "end": [0.5, 1.0]}, "angle": 180, "layer_select": "all"},
|
| 619 |
+
{"step": 2, "description": "Unfold", "type": "unfold", "line": {"start": [0.5, 0.0], "end": [0.5, 1.0]}, "angle": 0, "layer_select": "all"},
|
| 620 |
+
{"step": 3, "description": "Fold top-left corner to center line", "type": "valley", "line": {"start": [0.0, 1.0], "end": [0.5, 0.7]}, "angle": 180, "layer_select": "all"},
|
| 621 |
+
{"step": 4, "description": "Fold top-right corner to center line", "type": "valley", "line": {"start": [1.0, 1.0], "end": [0.5, 0.7]}, "angle": 180, "layer_select": "all"},
|
| 622 |
+
{"step": 5, "description": "Fold left angled edge to center line", "type": "valley", "line": {"start": [0.0, 0.7], "end": [0.5, 0.4]}, "angle": 180, "layer_select": "all"},
|
| 623 |
+
{"step": 6, "description": "Fold right angled edge to center line", "type": "valley", "line": {"start": [1.0, 0.7], "end": [0.5, 0.4]}, "angle": 180, "layer_select": "all"},
|
| 624 |
+
],
|
| 625 |
+
},
|
| 626 |
+
|
| 627 |
+
"box": {
|
| 628 |
+
"name": "Masu Box (Open-Top Box)",
|
| 629 |
+
"difficulty": "low_intermediate",
|
| 630 |
+
"base": None,
|
| 631 |
+
"total_steps": 13,
|
| 632 |
+
"description": "An open-top box. Uses preliminary base concept with tuck folds.",
|
| 633 |
+
"steps": [
|
| 634 |
+
{"step": 1, "description": "Fold in half horizontally", "type": "valley", "line": {"start": [0.0, 0.5], "end": [1.0, 0.5]}, "angle": 180, "layer_select": "all"},
|
| 635 |
+
{"step": 2, "description": "Unfold", "type": "unfold", "line": {"start": [0.0, 0.5], "end": [1.0, 0.5]}, "angle": 0, "layer_select": "all"},
|
| 636 |
+
{"step": 3, "description": "Fold in half vertically", "type": "valley", "line": {"start": [0.5, 0.0], "end": [0.5, 1.0]}, "angle": 180, "layer_select": "all"},
|
| 637 |
+
{"step": 4, "description": "Unfold", "type": "unfold", "line": {"start": [0.5, 0.0], "end": [0.5, 1.0]}, "angle": 0, "layer_select": "all"},
|
| 638 |
+
{"step": 5, "description": "Fold all four corners to center", "type": "valley", "line": {"start": [0.0, 0.0], "end": [0.5, 0.5]}, "angle": 180, "layer_select": "all"},
|
| 639 |
+
{"step": 6, "description": "Fold bottom-right corner to center", "type": "valley", "line": {"start": [1.0, 0.0], "end": [0.5, 0.5]}, "angle": 180, "layer_select": "all"},
|
| 640 |
+
{"step": 7, "description": "Fold top-left corner to center", "type": "valley", "line": {"start": [0.0, 1.0], "end": [0.5, 0.5]}, "angle": 180, "layer_select": "all"},
|
| 641 |
+
{"step": 8, "description": "Fold top-right corner to center", "type": "valley", "line": {"start": [1.0, 1.0], "end": [0.5, 0.5]}, "angle": 180, "layer_select": "all"},
|
| 642 |
+
{"step": 9, "description": "Fold top third down to center", "type": "valley", "line": {"start": [0.0, 0.667], "end": [1.0, 0.667]}, "angle": 180, "layer_select": "all"},
|
| 643 |
+
{"step": 10, "description": "Fold bottom third up to center", "type": "valley", "line": {"start": [0.0, 0.333], "end": [1.0, 0.333]}, "angle": 180, "layer_select": "all"},
|
| 644 |
+
{"step": 11, "description": "Unfold top and bottom, and unfold left/right corners", "type": "unfold", "line": {"start": [0.0, 0.333], "end": [1.0, 0.333]}, "angle": 0, "layer_select": "all"},
|
| 645 |
+
{"step": 12, "description": "Raise left and right walls using existing creases, tuck corners in", "type": "valley", "line": {"start": [0.25, 0.0], "end": [0.25, 1.0]}, "angle": 90, "layer_select": "all"},
|
| 646 |
+
{"step": 13, "description": "Fold flaps over into box and lock walls in place", "type": "valley", "line": {"start": [0.0, 0.5], "end": [1.0, 0.5]}, "angle": 90, "layer_select": "top"},
|
| 647 |
+
],
|
| 648 |
+
},
|
| 649 |
+
|
| 650 |
+
"fortune_teller": {
|
| 651 |
+
"name": "Fortune Teller (Cootie Catcher)",
|
| 652 |
+
"difficulty": "simple",
|
| 653 |
+
"base": None,
|
| 654 |
+
"total_steps": 8,
|
| 655 |
+
"description": "Classic fortune teller: fold corners to center, flip, repeat.",
|
| 656 |
+
"steps": [
|
| 657 |
+
{"step": 1, "description": "Fold in half diagonally (crease only)", "type": "valley", "line": {"start": [0.0, 0.0], "end": [1.0, 1.0]}, "angle": 180, "layer_select": "all"},
|
| 658 |
+
{"step": 2, "description": "Unfold", "type": "unfold", "line": {"start": [0.0, 0.0], "end": [1.0, 1.0]}, "angle": 0, "layer_select": "all"},
|
| 659 |
+
{"step": 3, "description": "Fold bottom-left corner to center", "type": "valley", "line": {"start": [0.0, 0.0], "end": [0.5, 0.5]}, "angle": 180, "layer_select": "all"},
|
| 660 |
+
{"step": 4, "description": "Fold bottom-right corner to center", "type": "valley", "line": {"start": [1.0, 0.0], "end": [0.5, 0.5]}, "angle": 180, "layer_select": "all"},
|
| 661 |
+
{"step": 5, "description": "Fold top-left corner to center", "type": "valley", "line": {"start": [0.0, 1.0], "end": [0.5, 0.5]}, "angle": 180, "layer_select": "all"},
|
| 662 |
+
{"step": 6, "description": "Fold top-right corner to center", "type": "valley", "line": {"start": [1.0, 1.0], "end": [0.5, 0.5]}, "angle": 180, "layer_select": "all"},
|
| 663 |
+
{"step": 7, "description": "Turn over", "type": "turn_over", "line": {"start": [0.5, 0.0], "end": [0.5, 1.0]}, "angle": 0, "layer_select": "all"},
|
| 664 |
+
{"step": 8, "description": "Fold all four new corners to center again", "type": "valley", "line": {"start": [0.25, 0.25], "end": [0.5, 0.5]}, "angle": 180, "layer_select": "all"},
|
| 665 |
+
],
|
| 666 |
+
},
|
| 667 |
+
|
| 668 |
+
"waterbomb": {
|
| 669 |
+
"name": "Waterbomb (Paper Balloon)",
|
| 670 |
+
"difficulty": "simple",
|
| 671 |
+
"base": "waterbomb_base",
|
| 672 |
+
"total_steps": 12,
|
| 673 |
+
"description": "Inflatable paper balloon built on the waterbomb base.",
|
| 674 |
+
"steps": [
|
| 675 |
+
# Phase 1: Waterbomb base (steps 1-9 same as waterbomb_base)
|
| 676 |
+
{"step": 1, "description": "Fold first diagonal", "type": "valley", "line": {"start": [0.0, 0.0], "end": [1.0, 1.0]}, "angle": 180, "layer_select": "all"},
|
| 677 |
+
{"step": 2, "description": "Unfold", "type": "unfold", "line": {"start": [0.0, 0.0], "end": [1.0, 1.0]}, "angle": 0, "layer_select": "all"},
|
| 678 |
+
{"step": 3, "description": "Fold second diagonal", "type": "valley", "line": {"start": [1.0, 0.0], "end": [0.0, 1.0]}, "angle": 180, "layer_select": "all"},
|
| 679 |
+
{"step": 4, "description": "Unfold", "type": "unfold", "line": {"start": [1.0, 0.0], "end": [0.0, 1.0]}, "angle": 0, "layer_select": "all"},
|
| 680 |
+
{"step": 5, "description": "Fold in half horizontally", "type": "valley", "line": {"start": [0.0, 0.5], "end": [1.0, 0.5]}, "angle": 180, "layer_select": "all"},
|
| 681 |
+
{"step": 6, "description": "Collapse into waterbomb base (triangle)", "type": "valley", "line": {"start": [0.0, 0.0], "end": [1.0, 1.0]}, "angle": 180, "layer_select": "all"},
|
| 682 |
+
# Phase 2: Fold flaps to top
|
| 683 |
+
{"step": 7, "description": "Fold bottom-left corner of front layer up to top", "type": "valley", "line": {"start": [0.0, 0.0], "end": [0.5, 0.5]}, "angle": 180, "layer_select": "top"},
|
| 684 |
+
{"step": 8, "description": "Fold bottom-right corner of front layer up to top", "type": "valley", "line": {"start": [1.0, 0.0], "end": [0.5, 0.5]}, "angle": 180, "layer_select": "top"},
|
| 685 |
+
# Phase 3: Tuck flaps
|
| 686 |
+
{"step": 9, "description": "Fold left and right points to center", "type": "valley", "line": {"start": [0.25, 0.25], "end": [0.5, 0.5]}, "angle": 180, "layer_select": "top"},
|
| 687 |
+
{"step": 10, "description": "Tuck small triangles into pockets", "type": "valley", "line": {"start": [0.35, 0.4], "end": [0.5, 0.5]}, "angle": 180, "layer_select": "top"},
|
| 688 |
+
{"step": 11, "description": "Repeat steps 7-10 on back", "type": "valley", "line": {"start": [0.0, 0.0], "end": [0.5, 0.5]}, "angle": 180, "layer_select": "top"},
|
| 689 |
+
# Phase 4: Inflate
|
| 690 |
+
{"step": 12, "description": "Blow into hole at bottom to inflate into cube/sphere", "type": "inflate", "line": {"start": [0.5, 0.0], "end": [0.5, 0.5]}, "angle": 0, "layer_select": "all"},
|
| 691 |
+
],
|
| 692 |
+
},
|
| 693 |
+
|
| 694 |
+
"jumping_frog": {
|
| 695 |
+
"name": "Jumping Frog",
|
| 696 |
+
"difficulty": "low_intermediate",
|
| 697 |
+
"base": None,
|
| 698 |
+
"total_steps": 15,
|
| 699 |
+
"description": "A frog that jumps when you press its back. Uses pleats for the spring.",
|
| 700 |
+
"steps": [
|
| 701 |
+
{"step": 1, "description": "Fold in half horizontally", "type": "valley", "line": {"start": [0.0, 0.5], "end": [1.0, 0.5]}, "angle": 180, "layer_select": "all"},
|
| 702 |
+
{"step": 2, "description": "Fold top-left corner to right edge", "type": "valley", "line": {"start": [0.0, 1.0], "end": [1.0, 0.75]}, "angle": 180, "layer_select": "all"},
|
| 703 |
+
{"step": 3, "description": "Unfold", "type": "unfold", "line": {"start": [0.0, 1.0], "end": [1.0, 0.75]}, "angle": 0, "layer_select": "all"},
|
| 704 |
+
{"step": 4, "description": "Fold top-right corner to left edge", "type": "valley", "line": {"start": [1.0, 1.0], "end": [0.0, 0.75]}, "angle": 180, "layer_select": "all"},
|
| 705 |
+
{"step": 5, "description": "Unfold", "type": "unfold", "line": {"start": [1.0, 1.0], "end": [0.0, 0.75]}, "angle": 0, "layer_select": "all"},
|
| 706 |
+
{"step": 6, "description": "Collapse top into waterbomb-like triangle", "type": "valley", "line": {"start": [0.0, 0.75], "end": [1.0, 0.75]}, "angle": 180, "layer_select": "all"},
|
| 707 |
+
{"step": 7, "description": "Fold left point of triangle up and outward for front leg", "type": "valley", "line": {"start": [0.25, 0.75], "end": [0.5, 1.0]}, "angle": 180, "layer_select": "top"},
|
| 708 |
+
{"step": 8, "description": "Fold right point of triangle up and outward for front leg", "type": "valley", "line": {"start": [0.75, 0.75], "end": [0.5, 1.0]}, "angle": 180, "layer_select": "top"},
|
| 709 |
+
{"step": 9, "description": "Fold bottom half up to meet triangle base", "type": "valley", "line": {"start": [0.0, 0.375], "end": [1.0, 0.375]}, "angle": 180, "layer_select": "all"},
|
| 710 |
+
{"step": 10, "description": "Fold left side to center", "type": "valley", "line": {"start": [0.25, 0.0], "end": [0.25, 0.75]}, "angle": 180, "layer_select": "all"},
|
| 711 |
+
{"step": 11, "description": "Fold right side to center", "type": "valley", "line": {"start": [0.75, 0.0], "end": [0.75, 0.75]}, "angle": 180, "layer_select": "all"},
|
| 712 |
+
{"step": 12, "description": "Fold bottom up", "type": "valley", "line": {"start": [0.25, 0.25], "end": [0.75, 0.25]}, "angle": 180, "layer_select": "all"},
|
| 713 |
+
{"step": 13, "description": "Pull back legs out to sides", "type": "valley", "line": {"start": [0.375, 0.0], "end": [0.375, 0.375]}, "angle": 180, "layer_select": "top"},
|
| 714 |
+
{"step": 14, "description": "Pleat fold for spring: fold bottom half down", "type": "mountain", "line": {"start": [0.25, 0.15], "end": [0.75, 0.15]}, "angle": 180, "layer_select": "all"},
|
| 715 |
+
{"step": 15, "description": "Valley fold bottom portion back up for spring action", "type": "valley", "line": {"start": [0.25, 0.08], "end": [0.75, 0.08]}, "angle": 180, "layer_select": "all"},
|
| 716 |
+
],
|
| 717 |
+
},
|
| 718 |
+
}
|
| 719 |
+
|
| 720 |
+
|
| 721 |
+
# ---------------------------------------------------------------------------
|
| 722 |
+
# Helpers
|
| 723 |
+
# ---------------------------------------------------------------------------
|
| 724 |
+
|
| 725 |
+
def get_base_steps(base_name: str) -> list[dict]:
|
| 726 |
+
"""Return the fold steps for a given base, or empty list if not found."""
|
| 727 |
+
base = ORIGAMI_BASES.get(base_name)
|
| 728 |
+
if base is None:
|
| 729 |
+
return []
|
| 730 |
+
return list(base["steps"])
|
| 731 |
+
|
| 732 |
+
|
| 733 |
+
def get_model_steps(model_name: str) -> list[dict]:
|
| 734 |
+
"""Return the full fold steps for a known model, or empty list."""
|
| 735 |
+
model = ORIGAMI_MODELS.get(model_name)
|
| 736 |
+
if model is None:
|
| 737 |
+
return []
|
| 738 |
+
return list(model["steps"])
|
| 739 |
+
|
| 740 |
+
|
| 741 |
+
def list_known_models() -> list[str]:
|
| 742 |
+
"""Return names of all known origami models."""
|
| 743 |
+
return list(ORIGAMI_MODELS.keys())
|
| 744 |
+
|
| 745 |
+
|
| 746 |
+
def list_known_bases() -> list[str]:
|
| 747 |
+
"""Return names of all known origami bases."""
|
| 748 |
+
return list(ORIGAMI_BASES.keys())
|
| 749 |
+
|
| 750 |
+
|
| 751 |
+
def get_fold_operation(name: str) -> dict | None:
|
| 752 |
+
"""Look up a fold operation by name."""
|
| 753 |
+
return FOLD_OPERATIONS.get(name)
|
planner/parser.py
ADDED
|
@@ -0,0 +1,291 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Instruction parser: converts human text into a structured origami task.
|
| 3 |
+
|
| 4 |
+
Handles variations like:
|
| 5 |
+
- "make a paper crane"
|
| 6 |
+
- "fold me a crane"
|
| 7 |
+
- "i want to make a paper crane"
|
| 8 |
+
- "crane please"
|
| 9 |
+
- "can you fold a crane?"
|
| 10 |
+
- "pack a 1m x 1m mylar sheet as compact as possible"
|
| 11 |
+
"""
|
| 12 |
+
|
| 13 |
+
from __future__ import annotations
|
| 14 |
+
|
| 15 |
+
import re
|
| 16 |
+
from planner.knowledge import ORIGAMI_MODELS, list_known_models
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
# ---------------------------------------------------------------------------
|
| 20 |
+
# Vocabulary for matching
|
| 21 |
+
# ---------------------------------------------------------------------------
|
| 22 |
+
|
| 23 |
+
# Model aliases → canonical name
|
| 24 |
+
_MODEL_ALIASES: dict[str, str] = {
|
| 25 |
+
# Crane
|
| 26 |
+
"crane": "crane",
|
| 27 |
+
"tsuru": "crane",
|
| 28 |
+
"bird": "crane",
|
| 29 |
+
"orizuru": "crane",
|
| 30 |
+
# Boat
|
| 31 |
+
"boat": "boat",
|
| 32 |
+
"hat": "boat",
|
| 33 |
+
"paper boat": "boat",
|
| 34 |
+
"paper hat": "boat",
|
| 35 |
+
# Airplane
|
| 36 |
+
"airplane": "airplane",
|
| 37 |
+
"aeroplane": "airplane",
|
| 38 |
+
"plane": "airplane",
|
| 39 |
+
"paper airplane": "airplane",
|
| 40 |
+
"paper plane": "airplane",
|
| 41 |
+
"dart": "airplane",
|
| 42 |
+
"paper dart": "airplane",
|
| 43 |
+
# Box
|
| 44 |
+
"box": "box",
|
| 45 |
+
"masu": "box",
|
| 46 |
+
"masu box": "box",
|
| 47 |
+
"open box": "box",
|
| 48 |
+
# Fortune teller
|
| 49 |
+
"fortune teller": "fortune_teller",
|
| 50 |
+
"fortune-teller": "fortune_teller",
|
| 51 |
+
"cootie catcher": "fortune_teller",
|
| 52 |
+
"cootie-catcher": "fortune_teller",
|
| 53 |
+
"chatterbox": "fortune_teller",
|
| 54 |
+
# Waterbomb / balloon
|
| 55 |
+
"waterbomb": "waterbomb",
|
| 56 |
+
"water bomb": "waterbomb",
|
| 57 |
+
"balloon": "waterbomb",
|
| 58 |
+
"paper balloon": "waterbomb",
|
| 59 |
+
# Jumping frog
|
| 60 |
+
"jumping frog": "jumping_frog",
|
| 61 |
+
"frog": "jumping_frog",
|
| 62 |
+
"leap frog": "jumping_frog",
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
# Sorted longest-first so multi-word aliases match before single-word ones
|
| 66 |
+
_ALIAS_KEYS_SORTED = sorted(_MODEL_ALIASES.keys(), key=len, reverse=True)
|
| 67 |
+
|
| 68 |
+
_MATERIALS = {
|
| 69 |
+
"paper": "paper",
|
| 70 |
+
"mylar": "mylar",
|
| 71 |
+
"aluminum": "aluminum",
|
| 72 |
+
"aluminium": "aluminum",
|
| 73 |
+
"metal": "metal",
|
| 74 |
+
"nitinol": "nitinol",
|
| 75 |
+
"foil": "aluminum",
|
| 76 |
+
"cardboard": "cardboard",
|
| 77 |
+
"cardstock": "cardstock",
|
| 78 |
+
"fabric": "fabric",
|
| 79 |
+
"cloth": "fabric",
|
| 80 |
+
}
|
| 81 |
+
|
| 82 |
+
# Intent keywords
|
| 83 |
+
_FOLD_VERBS = {
|
| 84 |
+
"make", "fold", "create", "build", "construct", "origami",
|
| 85 |
+
"craft", "form", "shape", "assemble",
|
| 86 |
+
}
|
| 87 |
+
_PACK_VERBS = {
|
| 88 |
+
"pack", "compress", "compact", "minimize", "reduce", "stow",
|
| 89 |
+
"shrink", "deploy", "collapse",
|
| 90 |
+
}
|
| 91 |
+
_OPTIMIZE_PHRASES = [
|
| 92 |
+
"as compact as possible",
|
| 93 |
+
"minimize volume",
|
| 94 |
+
"minimize packed volume",
|
| 95 |
+
"minimize area",
|
| 96 |
+
"solar panel",
|
| 97 |
+
"stent",
|
| 98 |
+
"deployable",
|
| 99 |
+
"maximize compactness",
|
| 100 |
+
"flatten",
|
| 101 |
+
]
|
| 102 |
+
|
| 103 |
+
# Dimension patterns
|
| 104 |
+
_DIM_PATTERNS = [
|
| 105 |
+
# "10cm x 10cm", "10 cm x 10 cm"
|
| 106 |
+
re.compile(
|
| 107 |
+
r"(\d+(?:\.\d+)?)\s*(cm|mm|m|in|inch|inches|ft|feet)\s*[xX\u00d7]\s*(\d+(?:\.\d+)?)\s*(cm|mm|m|in|inch|inches|ft|feet)",
|
| 108 |
+
re.IGNORECASE,
|
| 109 |
+
),
|
| 110 |
+
# "10cm square", "1 meter square"
|
| 111 |
+
re.compile(
|
| 112 |
+
r"(\d+(?:\.\d+)?)\s*(cm|mm|m|in|inch|inches|ft|feet|meter|meters|metre|metres)\s+square",
|
| 113 |
+
re.IGNORECASE,
|
| 114 |
+
),
|
| 115 |
+
]
|
| 116 |
+
|
| 117 |
+
_UNIT_TO_M = {
|
| 118 |
+
"m": 1.0,
|
| 119 |
+
"meter": 1.0,
|
| 120 |
+
"meters": 1.0,
|
| 121 |
+
"metre": 1.0,
|
| 122 |
+
"metres": 1.0,
|
| 123 |
+
"cm": 0.01,
|
| 124 |
+
"mm": 0.001,
|
| 125 |
+
"in": 0.0254,
|
| 126 |
+
"inch": 0.0254,
|
| 127 |
+
"inches": 0.0254,
|
| 128 |
+
"ft": 0.3048,
|
| 129 |
+
"feet": 0.3048,
|
| 130 |
+
}
|
| 131 |
+
|
| 132 |
+
|
| 133 |
+
# ---------------------------------------------------------------------------
|
| 134 |
+
# Parsing helpers
|
| 135 |
+
# ---------------------------------------------------------------------------
|
| 136 |
+
|
| 137 |
+
def _normalise(text: str) -> str:
|
| 138 |
+
"""Lower-case and strip extra whitespace."""
|
| 139 |
+
return " ".join(text.lower().split())
|
| 140 |
+
|
| 141 |
+
|
| 142 |
+
def _detect_model(text: str) -> str | None:
|
| 143 |
+
"""Return canonical model name if one is mentioned, else None."""
|
| 144 |
+
norm = _normalise(text)
|
| 145 |
+
|
| 146 |
+
# Strip out constraint phrases that might contain model-name false positives
|
| 147 |
+
# e.g. "into a 15cm x 15cm x 5cm box" should not match "box" as a model
|
| 148 |
+
cleaned = re.sub(
|
| 149 |
+
r"(?:fit\s+)?(?:in(?:to)?|inside)\s+(?:a\s+)?\d.*?box",
|
| 150 |
+
"",
|
| 151 |
+
norm,
|
| 152 |
+
)
|
| 153 |
+
|
| 154 |
+
for alias in _ALIAS_KEYS_SORTED:
|
| 155 |
+
# Use word-boundary-aware search to avoid partial matches
|
| 156 |
+
pattern = r"(?:^|\b)" + re.escape(alias) + r"(?:\b|$)"
|
| 157 |
+
if re.search(pattern, cleaned):
|
| 158 |
+
return _MODEL_ALIASES[alias]
|
| 159 |
+
return None
|
| 160 |
+
|
| 161 |
+
|
| 162 |
+
def _detect_material(text: str) -> str:
|
| 163 |
+
"""Return detected material or default 'paper'."""
|
| 164 |
+
norm = _normalise(text)
|
| 165 |
+
# Check multi-word materials first (none currently, but future-proof)
|
| 166 |
+
for keyword, canonical in sorted(_MATERIALS.items(), key=lambda kv: -len(kv[0])):
|
| 167 |
+
if keyword in norm:
|
| 168 |
+
return canonical
|
| 169 |
+
return "paper"
|
| 170 |
+
|
| 171 |
+
|
| 172 |
+
def _detect_dimensions(text: str) -> dict[str, float]:
|
| 173 |
+
"""Parse explicit dimensions. Returns {"width": m, "height": m} or defaults."""
|
| 174 |
+
for pat in _DIM_PATTERNS:
|
| 175 |
+
m = pat.search(text)
|
| 176 |
+
if m:
|
| 177 |
+
groups = m.groups()
|
| 178 |
+
if len(groups) == 4:
|
| 179 |
+
# WxH pattern
|
| 180 |
+
w = float(groups[0]) * _UNIT_TO_M.get(groups[1].lower(), 1.0)
|
| 181 |
+
h = float(groups[2]) * _UNIT_TO_M.get(groups[3].lower(), 1.0)
|
| 182 |
+
return {"width": round(w, 6), "height": round(h, 6)}
|
| 183 |
+
elif len(groups) == 2:
|
| 184 |
+
# "N unit square"
|
| 185 |
+
side = float(groups[0]) * _UNIT_TO_M.get(groups[1].lower(), 1.0)
|
| 186 |
+
return {"width": round(side, 6), "height": round(side, 6)}
|
| 187 |
+
# Default: unit square
|
| 188 |
+
return {"width": 1.0, "height": 1.0}
|
| 189 |
+
|
| 190 |
+
|
| 191 |
+
def _detect_constraints(text: str) -> dict:
|
| 192 |
+
"""Detect any explicit constraints mentioned in the instruction."""
|
| 193 |
+
norm = _normalise(text)
|
| 194 |
+
constraints: dict = {}
|
| 195 |
+
|
| 196 |
+
# Target bounding box: "fit in a 15cm x 15cm x 5cm box"
|
| 197 |
+
box_pat = re.compile(
|
| 198 |
+
r"fit\s+(?:in(?:to)?|inside)\s+(?:a\s+)?(\d+(?:\.\d+)?)\s*(cm|mm|m)\s*[xX\u00d7]\s*"
|
| 199 |
+
r"(\d+(?:\.\d+)?)\s*(cm|mm|m)\s*[xX\u00d7]\s*(\d+(?:\.\d+)?)\s*(cm|mm|m)",
|
| 200 |
+
re.IGNORECASE,
|
| 201 |
+
)
|
| 202 |
+
bm = box_pat.search(norm)
|
| 203 |
+
if bm:
|
| 204 |
+
g = bm.groups()
|
| 205 |
+
constraints["target_box"] = [
|
| 206 |
+
float(g[0]) * _UNIT_TO_M.get(g[1], 1.0),
|
| 207 |
+
float(g[2]) * _UNIT_TO_M.get(g[3], 1.0),
|
| 208 |
+
float(g[4]) * _UNIT_TO_M.get(g[5], 1.0),
|
| 209 |
+
]
|
| 210 |
+
|
| 211 |
+
# Max folds
|
| 212 |
+
folds_pat = re.compile(r"(?:max(?:imum)?|at most|no more than)\s+(\d+)\s+fold", re.IGNORECASE)
|
| 213 |
+
fm = folds_pat.search(norm)
|
| 214 |
+
if fm:
|
| 215 |
+
constraints["max_folds"] = int(fm.group(1))
|
| 216 |
+
|
| 217 |
+
# Compactness emphasis
|
| 218 |
+
for phrase in _OPTIMIZE_PHRASES:
|
| 219 |
+
if phrase in norm:
|
| 220 |
+
constraints["optimize_compactness"] = True
|
| 221 |
+
break
|
| 222 |
+
|
| 223 |
+
# Must deploy
|
| 224 |
+
if "deploy" in norm or "unfold" in norm and "clean" in norm:
|
| 225 |
+
constraints["must_deploy"] = True
|
| 226 |
+
|
| 227 |
+
return constraints
|
| 228 |
+
|
| 229 |
+
|
| 230 |
+
def _detect_intent(text: str, model_name: str | None, constraints: dict) -> str:
|
| 231 |
+
"""Determine the high-level intent of the instruction."""
|
| 232 |
+
norm = _normalise(text)
|
| 233 |
+
words = set(norm.split())
|
| 234 |
+
|
| 235 |
+
# If packing / optimization phrases are present, it's an optimization task
|
| 236 |
+
if constraints.get("optimize_compactness"):
|
| 237 |
+
return "optimize_packing"
|
| 238 |
+
if words & _PACK_VERBS:
|
| 239 |
+
return "optimize_packing"
|
| 240 |
+
|
| 241 |
+
# If a known model is detected, it's a fold_model task
|
| 242 |
+
if model_name is not None:
|
| 243 |
+
return "fold_model"
|
| 244 |
+
|
| 245 |
+
# If fold verbs are present but no model, it's a free fold
|
| 246 |
+
if words & _FOLD_VERBS:
|
| 247 |
+
return "free_fold"
|
| 248 |
+
|
| 249 |
+
# Fallback: if there's a pattern keyword
|
| 250 |
+
pattern_words = {"miura", "tessellation", "pattern", "waterbomb tessellation", "pleat"}
|
| 251 |
+
if words & pattern_words:
|
| 252 |
+
return "fold_pattern"
|
| 253 |
+
|
| 254 |
+
return "free_fold"
|
| 255 |
+
|
| 256 |
+
|
| 257 |
+
# ---------------------------------------------------------------------------
|
| 258 |
+
# Public API
|
| 259 |
+
# ---------------------------------------------------------------------------
|
| 260 |
+
|
| 261 |
+
def parse_instruction(text: str) -> dict:
|
| 262 |
+
"""
|
| 263 |
+
Parse a human origami instruction into a structured task.
|
| 264 |
+
|
| 265 |
+
Args:
|
| 266 |
+
text: Natural-language instruction, e.g. "make a paper crane"
|
| 267 |
+
|
| 268 |
+
Returns:
|
| 269 |
+
{
|
| 270 |
+
"intent": "fold_model" | "fold_pattern" | "optimize_packing" | "free_fold",
|
| 271 |
+
"model_name": str or None,
|
| 272 |
+
"material": str,
|
| 273 |
+
"dimensions": {"width": float, "height": float},
|
| 274 |
+
"constraints": {...},
|
| 275 |
+
"raw_instruction": str,
|
| 276 |
+
}
|
| 277 |
+
"""
|
| 278 |
+
model_name = _detect_model(text)
|
| 279 |
+
material = _detect_material(text)
|
| 280 |
+
dimensions = _detect_dimensions(text)
|
| 281 |
+
constraints = _detect_constraints(text)
|
| 282 |
+
intent = _detect_intent(text, model_name, constraints)
|
| 283 |
+
|
| 284 |
+
return {
|
| 285 |
+
"intent": intent,
|
| 286 |
+
"model_name": model_name,
|
| 287 |
+
"material": material,
|
| 288 |
+
"dimensions": dimensions,
|
| 289 |
+
"constraints": constraints,
|
| 290 |
+
"raw_instruction": text,
|
| 291 |
+
}
|
planner/planner.py
ADDED
|
@@ -0,0 +1,305 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
OrigamiPlanner: the main orchestrator that converts human instructions
|
| 3 |
+
into structured fold plans with LLM-ready prompts.
|
| 4 |
+
|
| 5 |
+
Usage:
|
| 6 |
+
from planner.planner import OrigamiPlanner
|
| 7 |
+
|
| 8 |
+
planner = OrigamiPlanner()
|
| 9 |
+
plan = planner.plan("make a paper crane")
|
| 10 |
+
print(plan.summary())
|
| 11 |
+
prompt = plan.get_prompt_for_step(0)
|
| 12 |
+
"""
|
| 13 |
+
|
| 14 |
+
from __future__ import annotations
|
| 15 |
+
|
| 16 |
+
import json
|
| 17 |
+
from dataclasses import dataclass, field
|
| 18 |
+
|
| 19 |
+
from planner.parser import parse_instruction
|
| 20 |
+
from planner.decomposer import decompose_task
|
| 21 |
+
from planner.knowledge import ORIGAMI_MODELS, FOLD_OPERATIONS
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
# ---------------------------------------------------------------------------
|
| 25 |
+
# Material defaults (mirrors trainer/prompts.py TASK_CONFIGS)
|
| 26 |
+
# ---------------------------------------------------------------------------
|
| 27 |
+
|
| 28 |
+
_MATERIAL_DEFAULTS = {
|
| 29 |
+
"paper": {"thickness_mm": 0.1, "youngs_modulus_gpa": 2.0, "max_strain_pct": 3},
|
| 30 |
+
"mylar": {"thickness_mm": 0.05, "youngs_modulus_gpa": 4.0, "max_strain_pct": 3},
|
| 31 |
+
"aluminum": {"thickness_mm": 0.02, "youngs_modulus_gpa": 69.0, "max_strain_pct": 1},
|
| 32 |
+
"metal": {"thickness_mm": 0.05, "youngs_modulus_gpa": 200.0,"max_strain_pct": 0.5},
|
| 33 |
+
"nitinol": {"thickness_mm": 0.1, "youngs_modulus_gpa": 75.0, "max_strain_pct": 8},
|
| 34 |
+
"cardboard": {"thickness_mm": 1.0, "youngs_modulus_gpa": 1.0, "max_strain_pct": 2},
|
| 35 |
+
"cardstock": {"thickness_mm": 0.3, "youngs_modulus_gpa": 1.5, "max_strain_pct": 2},
|
| 36 |
+
"fabric": {"thickness_mm": 0.2, "youngs_modulus_gpa": 0.1, "max_strain_pct": 15},
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
# ---------------------------------------------------------------------------
|
| 41 |
+
# FoldPlan dataclass
|
| 42 |
+
# ---------------------------------------------------------------------------
|
| 43 |
+
|
| 44 |
+
@dataclass
|
| 45 |
+
class FoldPlan:
|
| 46 |
+
"""
|
| 47 |
+
A complete, executable fold plan produced by OrigamiPlanner.
|
| 48 |
+
|
| 49 |
+
Attributes:
|
| 50 |
+
instruction: The original human instruction.
|
| 51 |
+
parsed: Structured parse result (intent, model, material, etc.).
|
| 52 |
+
steps: Ordered list of sub-goal dicts from the decomposer.
|
| 53 |
+
prompts: Pre-built LLM prompts, one per step.
|
| 54 |
+
"""
|
| 55 |
+
|
| 56 |
+
instruction: str
|
| 57 |
+
parsed: dict
|
| 58 |
+
steps: list[dict]
|
| 59 |
+
prompts: list[str]
|
| 60 |
+
|
| 61 |
+
# ------------------------------------------------------------------
|
| 62 |
+
# Summaries
|
| 63 |
+
# ------------------------------------------------------------------
|
| 64 |
+
|
| 65 |
+
def summary(self) -> str:
|
| 66 |
+
"""Human-readable summary of the plan."""
|
| 67 |
+
lines: list[str] = []
|
| 68 |
+
lines.append(f"Origami Plan: {self.instruction}")
|
| 69 |
+
lines.append(f" Intent : {self.parsed['intent']}")
|
| 70 |
+
if self.parsed.get("model_name"):
|
| 71 |
+
model = ORIGAMI_MODELS.get(self.parsed["model_name"], {})
|
| 72 |
+
lines.append(f" Model : {model.get('name', self.parsed['model_name'])}")
|
| 73 |
+
lines.append(f" Difficulty: {model.get('difficulty', 'unknown')}")
|
| 74 |
+
lines.append(f" Material: {self.parsed['material']}")
|
| 75 |
+
dims = self.parsed["dimensions"]
|
| 76 |
+
lines.append(f" Sheet : {dims['width']}m x {dims['height']}m")
|
| 77 |
+
lines.append(f" Steps : {len(self.steps)}")
|
| 78 |
+
lines.append("")
|
| 79 |
+
lines.append("Step-by-step:")
|
| 80 |
+
for s in self.steps:
|
| 81 |
+
n = s["step_number"]
|
| 82 |
+
desc = s["description"]
|
| 83 |
+
n_ops = len(s.get("fold_operations", []))
|
| 84 |
+
lines.append(f" {n:>3}. {desc} ({n_ops} fold op{'s' if n_ops != 1 else ''})")
|
| 85 |
+
return "\n".join(lines)
|
| 86 |
+
|
| 87 |
+
# ------------------------------------------------------------------
|
| 88 |
+
# Prompt access
|
| 89 |
+
# ------------------------------------------------------------------
|
| 90 |
+
|
| 91 |
+
def get_prompt_for_step(self, step_index: int, current_state: dict | None = None) -> str:
|
| 92 |
+
"""
|
| 93 |
+
Get the LLM prompt for a specific step, optionally enriched with
|
| 94 |
+
the current paper state from the simulation engine.
|
| 95 |
+
|
| 96 |
+
Args:
|
| 97 |
+
step_index: Zero-based index into self.steps.
|
| 98 |
+
current_state: Optional live paper_state dict from the engine.
|
| 99 |
+
|
| 100 |
+
Returns:
|
| 101 |
+
A fully-formatted prompt string ready for the LLM.
|
| 102 |
+
"""
|
| 103 |
+
if step_index < 0 or step_index >= len(self.steps):
|
| 104 |
+
raise IndexError(f"step_index {step_index} out of range (0..{len(self.steps) - 1})")
|
| 105 |
+
|
| 106 |
+
base_prompt = self.prompts[step_index]
|
| 107 |
+
|
| 108 |
+
if current_state is None:
|
| 109 |
+
return base_prompt
|
| 110 |
+
|
| 111 |
+
# Inject live state into the prompt
|
| 112 |
+
state_block = _format_state_block(current_state)
|
| 113 |
+
return base_prompt.replace("{{CURRENT_STATE}}", state_block)
|
| 114 |
+
|
| 115 |
+
# ------------------------------------------------------------------
|
| 116 |
+
# Convenience: all fold operations flattened
|
| 117 |
+
# ------------------------------------------------------------------
|
| 118 |
+
|
| 119 |
+
def all_fold_operations(self) -> list[dict]:
|
| 120 |
+
"""Return every fold operation across all steps, in order."""
|
| 121 |
+
ops: list[dict] = []
|
| 122 |
+
for step in self.steps:
|
| 123 |
+
ops.extend(step.get("fold_operations", []))
|
| 124 |
+
return ops
|
| 125 |
+
|
| 126 |
+
def total_fold_count(self) -> int:
|
| 127 |
+
"""Total number of fold operations in the plan."""
|
| 128 |
+
return sum(len(s.get("fold_operations", [])) for s in self.steps)
|
| 129 |
+
|
| 130 |
+
|
| 131 |
+
# ---------------------------------------------------------------------------
|
| 132 |
+
# Prompt builder helpers
|
| 133 |
+
# ---------------------------------------------------------------------------
|
| 134 |
+
|
| 135 |
+
def _format_state_block(state: dict) -> str:
|
| 136 |
+
"""Format a paper_state dict as a human-readable block for the prompt."""
|
| 137 |
+
lines = ["CURRENT STATE:"]
|
| 138 |
+
if "bounding_box" in state:
|
| 139 |
+
bb = state["bounding_box"]
|
| 140 |
+
if isinstance(bb, dict):
|
| 141 |
+
lines.append(f" Bounding box: {bb.get('x', '?')}m x {bb.get('y', '?')}m x {bb.get('z', '?')}m")
|
| 142 |
+
elif isinstance(bb, (list, tuple)) and len(bb) >= 3:
|
| 143 |
+
lines.append(f" Bounding box: {bb[0]}m x {bb[1]}m x {bb[2]}m")
|
| 144 |
+
if "num_layers_at_center" in state:
|
| 145 |
+
lines.append(f" Layers at center: {state['num_layers_at_center']}")
|
| 146 |
+
if "deployment_ratio" in state:
|
| 147 |
+
lines.append(f" Deployment ratio: {state['deployment_ratio']:.3f}")
|
| 148 |
+
if "fold_angles" in state:
|
| 149 |
+
n_folds = sum(1 for a in state["fold_angles"] if a != 0)
|
| 150 |
+
lines.append(f" Active folds: {n_folds}")
|
| 151 |
+
return "\n".join(lines)
|
| 152 |
+
|
| 153 |
+
|
| 154 |
+
def _format_fold_ops_as_code(ops: list[dict]) -> str:
|
| 155 |
+
"""Format fold operations as Python list literal for inclusion in a prompt."""
|
| 156 |
+
if not ops:
|
| 157 |
+
return " # (LLM: determine fold operations for this step)\n return []"
|
| 158 |
+
|
| 159 |
+
lines = [" return ["]
|
| 160 |
+
for op in ops:
|
| 161 |
+
clean = {
|
| 162 |
+
"type": op["type"],
|
| 163 |
+
"line": op.get("line", {"start": [0, 0], "end": [1, 1]}),
|
| 164 |
+
"angle": op.get("angle", 180),
|
| 165 |
+
}
|
| 166 |
+
lines.append(f" {json.dumps(clean)},")
|
| 167 |
+
lines.append(" ]")
|
| 168 |
+
return "\n".join(lines)
|
| 169 |
+
|
| 170 |
+
|
| 171 |
+
# ---------------------------------------------------------------------------
|
| 172 |
+
# OrigamiPlanner
|
| 173 |
+
# ---------------------------------------------------------------------------
|
| 174 |
+
|
| 175 |
+
class OrigamiPlanner:
|
| 176 |
+
"""
|
| 177 |
+
Full pipeline: human instruction -> structured plan -> executable fold operations.
|
| 178 |
+
|
| 179 |
+
The planner:
|
| 180 |
+
1. Parses the instruction (parser.py)
|
| 181 |
+
2. Decomposes into sub-goals (decomposer.py)
|
| 182 |
+
3. Builds LLM-ready prompts matching trainer/prompts.py format
|
| 183 |
+
"""
|
| 184 |
+
|
| 185 |
+
def plan(self, instruction: str) -> FoldPlan:
|
| 186 |
+
"""
|
| 187 |
+
Plan an origami task from a human instruction.
|
| 188 |
+
|
| 189 |
+
Args:
|
| 190 |
+
instruction: e.g. "make a paper crane", "pack a 1m mylar sheet"
|
| 191 |
+
|
| 192 |
+
Returns:
|
| 193 |
+
A FoldPlan with steps and LLM prompts.
|
| 194 |
+
"""
|
| 195 |
+
parsed = parse_instruction(instruction)
|
| 196 |
+
steps = decompose_task(parsed)
|
| 197 |
+
prompts = [self._build_prompt(step, i, parsed) for i, step in enumerate(steps)]
|
| 198 |
+
return FoldPlan(
|
| 199 |
+
instruction=instruction,
|
| 200 |
+
parsed=parsed,
|
| 201 |
+
steps=steps,
|
| 202 |
+
prompts=prompts,
|
| 203 |
+
)
|
| 204 |
+
|
| 205 |
+
# ------------------------------------------------------------------
|
| 206 |
+
# Prompt construction
|
| 207 |
+
# ------------------------------------------------------------------
|
| 208 |
+
|
| 209 |
+
def _build_prompt(self, step: dict, step_index: int, parsed: dict) -> str:
|
| 210 |
+
"""
|
| 211 |
+
Build an LLM-ready prompt for a single sub-goal step.
|
| 212 |
+
|
| 213 |
+
The format matches trainer/prompts.py: task description at top,
|
| 214 |
+
material/constraints in the middle, and a fold_strategy() code
|
| 215 |
+
block wrapped in triple backticks at the bottom.
|
| 216 |
+
"""
|
| 217 |
+
material = parsed["material"]
|
| 218 |
+
mat_info = _MATERIAL_DEFAULTS.get(material, _MATERIAL_DEFAULTS["paper"])
|
| 219 |
+
dims = parsed["dimensions"]
|
| 220 |
+
constraints = parsed.get("constraints", {})
|
| 221 |
+
total_steps = len(parsed.get("_all_steps", [])) or step.get("step_number", 1)
|
| 222 |
+
|
| 223 |
+
# ---- Header ----
|
| 224 |
+
intent = parsed["intent"]
|
| 225 |
+
if intent == "fold_model" and parsed.get("model_name"):
|
| 226 |
+
model_info = ORIGAMI_MODELS.get(parsed["model_name"], {})
|
| 227 |
+
task_line = (
|
| 228 |
+
f"TASK: Step {step['step_number']} of {total_steps} — "
|
| 229 |
+
f"{step['description']}\n"
|
| 230 |
+
f"MODEL: {model_info.get('name', parsed['model_name'])} "
|
| 231 |
+
f"(difficulty: {model_info.get('difficulty', 'unknown')})"
|
| 232 |
+
)
|
| 233 |
+
elif intent == "optimize_packing":
|
| 234 |
+
task_line = (
|
| 235 |
+
f"TASK: Step {step['step_number']} — {step['description']}\n"
|
| 236 |
+
f"GOAL: Minimize packed volume while maintaining deployability."
|
| 237 |
+
)
|
| 238 |
+
else:
|
| 239 |
+
task_line = f"TASK: Step {step['step_number']} — {step['description']}"
|
| 240 |
+
|
| 241 |
+
# ---- Material ----
|
| 242 |
+
material_block = (
|
| 243 |
+
f"MATERIAL:\n"
|
| 244 |
+
f" - Name: {material}\n"
|
| 245 |
+
f" - Thickness: {mat_info['thickness_mm']}mm\n"
|
| 246 |
+
f" - Max strain: {mat_info['max_strain_pct']}%"
|
| 247 |
+
)
|
| 248 |
+
|
| 249 |
+
# ---- Constraints ----
|
| 250 |
+
constraint_lines = ["CONSTRAINTS:"]
|
| 251 |
+
if "max_folds" in constraints:
|
| 252 |
+
constraint_lines.append(f" - Maximum {constraints['max_folds']} fold operations")
|
| 253 |
+
if "target_box" in constraints:
|
| 254 |
+
tb = constraints["target_box"]
|
| 255 |
+
constraint_lines.append(
|
| 256 |
+
f" - Must pack into bounding box <= "
|
| 257 |
+
f"{tb[0]*100:.0f}cm x {tb[1]*100:.0f}cm x {tb[2]*100:.0f}cm"
|
| 258 |
+
)
|
| 259 |
+
if constraints.get("must_deploy"):
|
| 260 |
+
constraint_lines.append(" - Must deploy to >= 80% of original area")
|
| 261 |
+
constraint_lines.append(" - No self-intersections allowed")
|
| 262 |
+
constraints_block = "\n".join(constraint_lines)
|
| 263 |
+
|
| 264 |
+
# ---- State placeholder ----
|
| 265 |
+
state_block = (
|
| 266 |
+
f"CURRENT STATE:\n"
|
| 267 |
+
f" Sheet: {dims['width']}m x {dims['height']}m\n"
|
| 268 |
+
f" {{{{CURRENT_STATE}}}}"
|
| 269 |
+
)
|
| 270 |
+
|
| 271 |
+
# ---- Fold operations hint ----
|
| 272 |
+
ops = step.get("fold_operations", [])
|
| 273 |
+
ops_code = _format_fold_ops_as_code(ops)
|
| 274 |
+
|
| 275 |
+
# ---- Expected result ----
|
| 276 |
+
expected = step.get("expected_state", {})
|
| 277 |
+
expected_block = ""
|
| 278 |
+
if expected:
|
| 279 |
+
expected_block = f"\nEXPECTED RESULT: {json.dumps(expected)}"
|
| 280 |
+
|
| 281 |
+
# ---- Code block (matches trainer/prompts.py format) ----
|
| 282 |
+
code_block = (
|
| 283 |
+
f'Write a fold_strategy(paper_state) function that returns a list of fold operations.\n'
|
| 284 |
+
f'Each fold: {{"type": "valley"|"mountain", "line": {{"start": [x,y], "end": [x,y]}}, "angle": 0-180}}\n'
|
| 285 |
+
f'\n'
|
| 286 |
+
f'```python\n'
|
| 287 |
+
f'def fold_strategy(paper_state):\n'
|
| 288 |
+
f'{ops_code}\n'
|
| 289 |
+
f'```'
|
| 290 |
+
)
|
| 291 |
+
|
| 292 |
+
# ---- Assemble ----
|
| 293 |
+
sections = [
|
| 294 |
+
task_line,
|
| 295 |
+
"",
|
| 296 |
+
material_block,
|
| 297 |
+
"",
|
| 298 |
+
constraints_block,
|
| 299 |
+
"",
|
| 300 |
+
state_block,
|
| 301 |
+
expected_block,
|
| 302 |
+
"",
|
| 303 |
+
code_block,
|
| 304 |
+
]
|
| 305 |
+
return "\n".join(sections)
|
trainer/rewards.py
CHANGED
|
@@ -16,9 +16,43 @@ import math
|
|
| 16 |
import traceback
|
| 17 |
from typing import Callable
|
| 18 |
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 22 |
|
| 23 |
|
| 24 |
# ---------------------------------------------------------------------------
|
|
@@ -102,10 +136,15 @@ def create_sandboxed_function(code: str) -> Callable:
|
|
| 102 |
# ---------------------------------------------------------------------------
|
| 103 |
|
| 104 |
# Current task config (set by train.py before training starts)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 105 |
_current_task = {
|
| 106 |
"width": 1.0,
|
| 107 |
"height": 1.0,
|
| 108 |
-
"material":
|
| 109 |
"target_ratio": 0.5,
|
| 110 |
"max_folds": 3,
|
| 111 |
}
|
|
@@ -200,11 +239,12 @@ def physically_valid(completions, **kwargs) -> list[float]:
|
|
| 200 |
continue
|
| 201 |
|
| 202 |
try:
|
| 203 |
-
paper =
|
| 204 |
_current_task["width"],
|
| 205 |
_current_task["height"],
|
| 206 |
_current_task["material"],
|
| 207 |
)
|
|
|
|
| 208 |
final_state, applied, error = execute_fold_strategy(
|
| 209 |
strategy_fn, paper, _current_task["max_folds"]
|
| 210 |
)
|
|
@@ -217,13 +257,16 @@ def physically_valid(completions, **kwargs) -> list[float]:
|
|
| 217 |
scores.append(0.0)
|
| 218 |
continue
|
| 219 |
|
| 220 |
-
# Score based on validity
|
|
|
|
|
|
|
|
|
|
| 221 |
score = 1.0
|
| 222 |
-
score -= 2.0 *
|
| 223 |
-
score -= 2.0 *
|
| 224 |
-
if
|
| 225 |
score -= 5.0
|
| 226 |
-
max_strain =
|
| 227 |
if max_strain > _current_task["material"].max_strain:
|
| 228 |
score -= 1.0
|
| 229 |
|
|
@@ -283,11 +326,12 @@ def fold_quality(completions, **kwargs) -> list[float]:
|
|
| 283 |
continue
|
| 284 |
|
| 285 |
try:
|
| 286 |
-
paper =
|
| 287 |
_current_task["width"],
|
| 288 |
_current_task["height"],
|
| 289 |
_current_task["material"],
|
| 290 |
)
|
|
|
|
| 291 |
final_state, applied, error = execute_fold_strategy(
|
| 292 |
strategy_fn, paper, _current_task["max_folds"]
|
| 293 |
)
|
|
@@ -303,28 +347,32 @@ def fold_quality(completions, **kwargs) -> list[float]:
|
|
| 303 |
scores.append(0.0)
|
| 304 |
continue
|
| 305 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 306 |
# Compactness: main reward signal
|
| 307 |
-
compactness = 1.0 -
|
| 308 |
score = 20.0 * compactness
|
| 309 |
|
| 310 |
# Bonus for meeting target
|
| 311 |
-
if
|
| 312 |
score += 10.0
|
| 313 |
|
| 314 |
# Fold efficiency penalty
|
| 315 |
score -= 0.5 * num_folds
|
| 316 |
|
| 317 |
# Strain penalty
|
| 318 |
-
max_strain = float(final_state.strain.max()) if len(final_state.strain) > 0 else 0.0
|
| 319 |
mat_limit = _current_task["material"].max_strain
|
| 320 |
if max_strain > mat_limit:
|
| 321 |
score -= 3.0 * (max_strain / mat_limit)
|
| 322 |
|
| 323 |
if should_print:
|
| 324 |
-
print(f"Folds: {num_folds}, Ratio: {
|
| 325 |
f"Compactness: {compactness:.3f}, Score: {score:.2f}")
|
| 326 |
-
bb =
|
| 327 |
-
print(f"BBox: {bb
|
| 328 |
|
| 329 |
scores.append(score)
|
| 330 |
|
|
|
|
| 16 |
import traceback
|
| 17 |
from typing import Callable
|
| 18 |
|
| 19 |
+
# Use real engine if available, fall back to mock
|
| 20 |
+
try:
|
| 21 |
+
from engine.paper import Paper
|
| 22 |
+
from engine.fold_engine import execute_fold_strategy
|
| 23 |
+
from engine.materials import Material, get_material
|
| 24 |
+
from engine.validation import validate_paper
|
| 25 |
+
from engine.metrics import compute_metrics
|
| 26 |
+
|
| 27 |
+
def _create_sheet(width, height, material):
|
| 28 |
+
return Paper.create_flat_sheet(width, height, material)
|
| 29 |
+
|
| 30 |
+
USE_REAL_ENGINE = True
|
| 31 |
+
except ImportError:
|
| 32 |
+
from trainer.mock_env import (
|
| 33 |
+
PaperState as Paper, create_flat_sheet, execute_fold_strategy, Material
|
| 34 |
+
)
|
| 35 |
+
|
| 36 |
+
def _create_sheet(width, height, material):
|
| 37 |
+
return create_flat_sheet(width, height, material)
|
| 38 |
+
|
| 39 |
+
def validate_paper(p):
|
| 40 |
+
from types import SimpleNamespace
|
| 41 |
+
return SimpleNamespace(
|
| 42 |
+
is_valid=p.is_valid, kawasaki_valid=True, maekawa_valid=True,
|
| 43 |
+
kawasaki_violation=p.kawasaki_violation,
|
| 44 |
+
maekawa_violation=p.maekawa_violation,
|
| 45 |
+
self_intersection_count=p.self_intersections,
|
| 46 |
+
)
|
| 47 |
+
|
| 48 |
+
def compute_metrics(p, orig):
|
| 49 |
+
return {
|
| 50 |
+
"deployment_ratio": p.deployment_ratio,
|
| 51 |
+
"fold_count": sum(1 for a in p.assignments if a in ("M", "V")),
|
| 52 |
+
"max_strain": float(p.strain.max()) if len(p.strain) > 0 else 0.0,
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
USE_REAL_ENGINE = False
|
| 56 |
|
| 57 |
|
| 58 |
# ---------------------------------------------------------------------------
|
|
|
|
| 136 |
# ---------------------------------------------------------------------------
|
| 137 |
|
| 138 |
# Current task config (set by train.py before training starts)
|
| 139 |
+
if USE_REAL_ENGINE:
|
| 140 |
+
_default_material = get_material("paper")
|
| 141 |
+
else:
|
| 142 |
+
_default_material = Material()
|
| 143 |
+
|
| 144 |
_current_task = {
|
| 145 |
"width": 1.0,
|
| 146 |
"height": 1.0,
|
| 147 |
+
"material": _default_material,
|
| 148 |
"target_ratio": 0.5,
|
| 149 |
"max_folds": 3,
|
| 150 |
}
|
|
|
|
| 239 |
continue
|
| 240 |
|
| 241 |
try:
|
| 242 |
+
paper = _create_sheet(
|
| 243 |
_current_task["width"],
|
| 244 |
_current_task["height"],
|
| 245 |
_current_task["material"],
|
| 246 |
)
|
| 247 |
+
original = paper
|
| 248 |
final_state, applied, error = execute_fold_strategy(
|
| 249 |
strategy_fn, paper, _current_task["max_folds"]
|
| 250 |
)
|
|
|
|
| 257 |
scores.append(0.0)
|
| 258 |
continue
|
| 259 |
|
| 260 |
+
# Score based on validity using engine validation
|
| 261 |
+
val = validate_paper(final_state)
|
| 262 |
+
metrics = compute_metrics(final_state, original)
|
| 263 |
+
|
| 264 |
score = 1.0
|
| 265 |
+
score -= 2.0 * val.kawasaki_violation
|
| 266 |
+
score -= 2.0 * val.maekawa_violation
|
| 267 |
+
if val.self_intersection_count > 0:
|
| 268 |
score -= 5.0
|
| 269 |
+
max_strain = metrics.get("max_strain", 0.0)
|
| 270 |
if max_strain > _current_task["material"].max_strain:
|
| 271 |
score -= 1.0
|
| 272 |
|
|
|
|
| 326 |
continue
|
| 327 |
|
| 328 |
try:
|
| 329 |
+
paper = _create_sheet(
|
| 330 |
_current_task["width"],
|
| 331 |
_current_task["height"],
|
| 332 |
_current_task["material"],
|
| 333 |
)
|
| 334 |
+
original = paper
|
| 335 |
final_state, applied, error = execute_fold_strategy(
|
| 336 |
strategy_fn, paper, _current_task["max_folds"]
|
| 337 |
)
|
|
|
|
| 347 |
scores.append(0.0)
|
| 348 |
continue
|
| 349 |
|
| 350 |
+
# Use engine metrics
|
| 351 |
+
metrics = compute_metrics(final_state, original)
|
| 352 |
+
deploy_ratio = metrics.get("deployment_ratio", 1.0)
|
| 353 |
+
max_strain = metrics.get("max_strain", 0.0)
|
| 354 |
+
|
| 355 |
# Compactness: main reward signal
|
| 356 |
+
compactness = 1.0 - deploy_ratio
|
| 357 |
score = 20.0 * compactness
|
| 358 |
|
| 359 |
# Bonus for meeting target
|
| 360 |
+
if deploy_ratio <= _current_task["target_ratio"]:
|
| 361 |
score += 10.0
|
| 362 |
|
| 363 |
# Fold efficiency penalty
|
| 364 |
score -= 0.5 * num_folds
|
| 365 |
|
| 366 |
# Strain penalty
|
|
|
|
| 367 |
mat_limit = _current_task["material"].max_strain
|
| 368 |
if max_strain > mat_limit:
|
| 369 |
score -= 3.0 * (max_strain / mat_limit)
|
| 370 |
|
| 371 |
if should_print:
|
| 372 |
+
print(f"Folds: {num_folds}, Ratio: {deploy_ratio:.3f}, "
|
| 373 |
f"Compactness: {compactness:.3f}, Score: {score:.2f}")
|
| 374 |
+
bb = metrics.get("bounding_box", {})
|
| 375 |
+
print(f"BBox: {bb.get('x',0):.3f} x {bb.get('y',0):.3f} x {bb.get('z',0):.3f}")
|
| 376 |
|
| 377 |
scores.append(score)
|
| 378 |
|
trainer/train.py
CHANGED
|
@@ -20,7 +20,14 @@ if PROJECT_ROOT not in sys.path:
|
|
| 20 |
|
| 21 |
from trainer.prompts import build_prompt, SYSTEM_PROMPT, get_task_target_ratio, get_task_max_folds
|
| 22 |
from trainer.rewards import code_valid, physically_valid, fold_quality, set_task_config
|
| 23 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 24 |
|
| 25 |
# ============================================================================
|
| 26 |
# Config
|
|
@@ -82,7 +89,7 @@ def main():
|
|
| 82 |
set_task_config(
|
| 83 |
width=1.0,
|
| 84 |
height=1.0,
|
| 85 |
-
material=
|
| 86 |
target_ratio=target_ratio,
|
| 87 |
max_folds=max_folds,
|
| 88 |
)
|
|
|
|
| 20 |
|
| 21 |
from trainer.prompts import build_prompt, SYSTEM_PROMPT, get_task_target_ratio, get_task_max_folds
|
| 22 |
from trainer.rewards import code_valid, physically_valid, fold_quality, set_task_config
|
| 23 |
+
|
| 24 |
+
try:
|
| 25 |
+
from engine.materials import get_material
|
| 26 |
+
Material = type(get_material("paper")) # get the Material class
|
| 27 |
+
except ImportError:
|
| 28 |
+
from trainer.mock_env import Material
|
| 29 |
+
def get_material(name):
|
| 30 |
+
return Material()
|
| 31 |
|
| 32 |
# ============================================================================
|
| 33 |
# Config
|
|
|
|
| 89 |
set_task_config(
|
| 90 |
width=1.0,
|
| 91 |
height=1.0,
|
| 92 |
+
material=get_material("paper"),
|
| 93 |
target_ratio=target_ratio,
|
| 94 |
max_folds=max_folds,
|
| 95 |
)
|