""" 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)