optigami / planner /planner.py
sissississi's picture
Add physics engine (Layer 4) and instruction planner (Layer 1)
efeed27
raw
history blame
11.9 kB
"""
OrigamiPlanner: the main orchestrator that converts human instructions
into structured fold plans with LLM-ready prompts.
Usage:
from planner.planner import OrigamiPlanner
planner = OrigamiPlanner()
plan = planner.plan("make a paper crane")
print(plan.summary())
prompt = plan.get_prompt_for_step(0)
"""
from __future__ import annotations
import json
from dataclasses import dataclass, field
from planner.parser import parse_instruction
from planner.decomposer import decompose_task
from planner.knowledge import ORIGAMI_MODELS, FOLD_OPERATIONS
# ---------------------------------------------------------------------------
# Material defaults (mirrors trainer/prompts.py TASK_CONFIGS)
# ---------------------------------------------------------------------------
_MATERIAL_DEFAULTS = {
"paper": {"thickness_mm": 0.1, "youngs_modulus_gpa": 2.0, "max_strain_pct": 3},
"mylar": {"thickness_mm": 0.05, "youngs_modulus_gpa": 4.0, "max_strain_pct": 3},
"aluminum": {"thickness_mm": 0.02, "youngs_modulus_gpa": 69.0, "max_strain_pct": 1},
"metal": {"thickness_mm": 0.05, "youngs_modulus_gpa": 200.0,"max_strain_pct": 0.5},
"nitinol": {"thickness_mm": 0.1, "youngs_modulus_gpa": 75.0, "max_strain_pct": 8},
"cardboard": {"thickness_mm": 1.0, "youngs_modulus_gpa": 1.0, "max_strain_pct": 2},
"cardstock": {"thickness_mm": 0.3, "youngs_modulus_gpa": 1.5, "max_strain_pct": 2},
"fabric": {"thickness_mm": 0.2, "youngs_modulus_gpa": 0.1, "max_strain_pct": 15},
}
# ---------------------------------------------------------------------------
# FoldPlan dataclass
# ---------------------------------------------------------------------------
@dataclass
class FoldPlan:
"""
A complete, executable fold plan produced by OrigamiPlanner.
Attributes:
instruction: The original human instruction.
parsed: Structured parse result (intent, model, material, etc.).
steps: Ordered list of sub-goal dicts from the decomposer.
prompts: Pre-built LLM prompts, one per step.
"""
instruction: str
parsed: dict
steps: list[dict]
prompts: list[str]
# ------------------------------------------------------------------
# Summaries
# ------------------------------------------------------------------
def summary(self) -> str:
"""Human-readable summary of the plan."""
lines: list[str] = []
lines.append(f"Origami Plan: {self.instruction}")
lines.append(f" Intent : {self.parsed['intent']}")
if self.parsed.get("model_name"):
model = ORIGAMI_MODELS.get(self.parsed["model_name"], {})
lines.append(f" Model : {model.get('name', self.parsed['model_name'])}")
lines.append(f" Difficulty: {model.get('difficulty', 'unknown')}")
lines.append(f" Material: {self.parsed['material']}")
dims = self.parsed["dimensions"]
lines.append(f" Sheet : {dims['width']}m x {dims['height']}m")
lines.append(f" Steps : {len(self.steps)}")
lines.append("")
lines.append("Step-by-step:")
for s in self.steps:
n = s["step_number"]
desc = s["description"]
n_ops = len(s.get("fold_operations", []))
lines.append(f" {n:>3}. {desc} ({n_ops} fold op{'s' if n_ops != 1 else ''})")
return "\n".join(lines)
# ------------------------------------------------------------------
# Prompt access
# ------------------------------------------------------------------
def get_prompt_for_step(self, step_index: int, current_state: dict | None = None) -> str:
"""
Get the LLM prompt for a specific step, optionally enriched with
the current paper state from the simulation engine.
Args:
step_index: Zero-based index into self.steps.
current_state: Optional live paper_state dict from the engine.
Returns:
A fully-formatted prompt string ready for the LLM.
"""
if step_index < 0 or step_index >= len(self.steps):
raise IndexError(f"step_index {step_index} out of range (0..{len(self.steps) - 1})")
base_prompt = self.prompts[step_index]
if current_state is None:
return base_prompt
# Inject live state into the prompt
state_block = _format_state_block(current_state)
return base_prompt.replace("{{CURRENT_STATE}}", state_block)
# ------------------------------------------------------------------
# Convenience: all fold operations flattened
# ------------------------------------------------------------------
def all_fold_operations(self) -> list[dict]:
"""Return every fold operation across all steps, in order."""
ops: list[dict] = []
for step in self.steps:
ops.extend(step.get("fold_operations", []))
return ops
def total_fold_count(self) -> int:
"""Total number of fold operations in the plan."""
return sum(len(s.get("fold_operations", [])) for s in self.steps)
# ---------------------------------------------------------------------------
# Prompt builder helpers
# ---------------------------------------------------------------------------
def _format_state_block(state: dict) -> str:
"""Format a paper_state dict as a human-readable block for the prompt."""
lines = ["CURRENT STATE:"]
if "bounding_box" in state:
bb = state["bounding_box"]
if isinstance(bb, dict):
lines.append(f" Bounding box: {bb.get('x', '?')}m x {bb.get('y', '?')}m x {bb.get('z', '?')}m")
elif isinstance(bb, (list, tuple)) and len(bb) >= 3:
lines.append(f" Bounding box: {bb[0]}m x {bb[1]}m x {bb[2]}m")
if "num_layers_at_center" in state:
lines.append(f" Layers at center: {state['num_layers_at_center']}")
if "deployment_ratio" in state:
lines.append(f" Deployment ratio: {state['deployment_ratio']:.3f}")
if "fold_angles" in state:
n_folds = sum(1 for a in state["fold_angles"] if a != 0)
lines.append(f" Active folds: {n_folds}")
return "\n".join(lines)
def _format_fold_ops_as_code(ops: list[dict]) -> str:
"""Format fold operations as Python list literal for inclusion in a prompt."""
if not ops:
return " # (LLM: determine fold operations for this step)\n return []"
lines = [" return ["]
for op in ops:
clean = {
"type": op["type"],
"line": op.get("line", {"start": [0, 0], "end": [1, 1]}),
"angle": op.get("angle", 180),
}
lines.append(f" {json.dumps(clean)},")
lines.append(" ]")
return "\n".join(lines)
# ---------------------------------------------------------------------------
# OrigamiPlanner
# ---------------------------------------------------------------------------
class OrigamiPlanner:
"""
Full pipeline: human instruction -> structured plan -> executable fold operations.
The planner:
1. Parses the instruction (parser.py)
2. Decomposes into sub-goals (decomposer.py)
3. Builds LLM-ready prompts matching trainer/prompts.py format
"""
def plan(self, instruction: str) -> FoldPlan:
"""
Plan an origami task from a human instruction.
Args:
instruction: e.g. "make a paper crane", "pack a 1m mylar sheet"
Returns:
A FoldPlan with steps and LLM prompts.
"""
parsed = parse_instruction(instruction)
steps = decompose_task(parsed)
prompts = [self._build_prompt(step, i, parsed) for i, step in enumerate(steps)]
return FoldPlan(
instruction=instruction,
parsed=parsed,
steps=steps,
prompts=prompts,
)
# ------------------------------------------------------------------
# Prompt construction
# ------------------------------------------------------------------
def _build_prompt(self, step: dict, step_index: int, parsed: dict) -> str:
"""
Build an LLM-ready prompt for a single sub-goal step.
The format matches trainer/prompts.py: task description at top,
material/constraints in the middle, and a fold_strategy() code
block wrapped in triple backticks at the bottom.
"""
material = parsed["material"]
mat_info = _MATERIAL_DEFAULTS.get(material, _MATERIAL_DEFAULTS["paper"])
dims = parsed["dimensions"]
constraints = parsed.get("constraints", {})
total_steps = len(parsed.get("_all_steps", [])) or step.get("step_number", 1)
# ---- Header ----
intent = parsed["intent"]
if intent == "fold_model" and parsed.get("model_name"):
model_info = ORIGAMI_MODELS.get(parsed["model_name"], {})
task_line = (
f"TASK: Step {step['step_number']} of {total_steps} — "
f"{step['description']}\n"
f"MODEL: {model_info.get('name', parsed['model_name'])} "
f"(difficulty: {model_info.get('difficulty', 'unknown')})"
)
elif intent == "optimize_packing":
task_line = (
f"TASK: Step {step['step_number']}{step['description']}\n"
f"GOAL: Minimize packed volume while maintaining deployability."
)
else:
task_line = f"TASK: Step {step['step_number']}{step['description']}"
# ---- Material ----
material_block = (
f"MATERIAL:\n"
f" - Name: {material}\n"
f" - Thickness: {mat_info['thickness_mm']}mm\n"
f" - Max strain: {mat_info['max_strain_pct']}%"
)
# ---- Constraints ----
constraint_lines = ["CONSTRAINTS:"]
if "max_folds" in constraints:
constraint_lines.append(f" - Maximum {constraints['max_folds']} fold operations")
if "target_box" in constraints:
tb = constraints["target_box"]
constraint_lines.append(
f" - Must pack into bounding box <= "
f"{tb[0]*100:.0f}cm x {tb[1]*100:.0f}cm x {tb[2]*100:.0f}cm"
)
if constraints.get("must_deploy"):
constraint_lines.append(" - Must deploy to >= 80% of original area")
constraint_lines.append(" - No self-intersections allowed")
constraints_block = "\n".join(constraint_lines)
# ---- State placeholder ----
state_block = (
f"CURRENT STATE:\n"
f" Sheet: {dims['width']}m x {dims['height']}m\n"
f" {{{{CURRENT_STATE}}}}"
)
# ---- Fold operations hint ----
ops = step.get("fold_operations", [])
ops_code = _format_fold_ops_as_code(ops)
# ---- Expected result ----
expected = step.get("expected_state", {})
expected_block = ""
if expected:
expected_block = f"\nEXPECTED RESULT: {json.dumps(expected)}"
# ---- Code block (matches trainer/prompts.py format) ----
code_block = (
f'Write a fold_strategy(paper_state) function that returns a list of fold operations.\n'
f'Each fold: {{"type": "valley"|"mountain", "line": {{"start": [x,y], "end": [x,y]}}, "angle": 0-180}}\n'
f'\n'
f'```python\n'
f'def fold_strategy(paper_state):\n'
f'{ops_code}\n'
f'```'
)
# ---- Assemble ----
sections = [
task_line,
"",
material_block,
"",
constraints_block,
"",
state_block,
expected_block,
"",
code_block,
]
return "\n".join(sections)