Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
102 changes: 102 additions & 0 deletions opencad_agent/planner.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,45 @@
from __future__ import annotations

import re
from typing import Any, Callable

from opencad_agent.models import OperationExecution
from opencad_agent.tools import ToolRuntime


def _parse_cube_dimensions(message: str) -> tuple[float, float, float] | None:
"""Extract cube/box dimensions from a user message.

Supports formats like:
- "30x30x30 mm" or "30x30x30mm"
- "30 mm cube" or "30mm cube"
- "cube of 30 mm" or "cube of 30mm"
- "box 10x20x30"

Returns (length, width, height) or None if not found.
"""
lowered = message.lower()

# Pattern: NxNxN (e.g., "30x30x30 mm")
match = re.search(r"(\d+(?:\.\d+)?)\s*x\s*(\d+(?:\.\d+)?)\s*x\s*(\d+(?:\.\d+)?)", lowered)
if match:
return float(match.group(1)), float(match.group(2)), float(match.group(3))

# Pattern: N mm cube or Nmm cube (uniform cube)
match = re.search(r"(\d+(?:\.\d+)?)\s*(?:mm)?\s*cube", lowered)
if match:
size = float(match.group(1))
return size, size, size

# Pattern: cube of N mm or cube of Nmm
match = re.search(r"cube\s+of\s+(\d+(?:\.\d+)?)", lowered)
if match:
size = float(match.group(1))
return size, size, size

return None


class OpenCadPlanner:
def execute(self, message: str, runtime: ToolRuntime, reasoning: bool = False) -> tuple[str, list[OperationExecution]]:
lowered = message.lower()
Expand All @@ -21,6 +55,18 @@ def execute(self, message: str, runtime: ToolRuntime, reasoning: bool = False) -
response = "Mounting bracket feature sequence generated and executed."
return response, operations

# Handle cube/box requests with dimension parsing
if "cube" in lowered or "box" in lowered:
dims = _parse_cube_dimensions(message)
if dims:
length, width, height = dims
operations = self._build_box(runtime, length, width, height)
if reasoning:
response = f"Plan: create {length}x{width}x{height} box via sketch and extrude. Sequence executed."
else:
response = f"Created a {length}x{width}x{height} box."
return response, operations

operations = self._build_simple_feature(runtime)
response = "Executed a minimal sketch-to-extrude sequence for the request."
if reasoning:
Expand All @@ -43,6 +89,43 @@ def _safe_call(
operations.append(OperationExecution(tool=tool, status="error", arguments=arguments, result=error))
raise

def _build_box(self, runtime: ToolRuntime, length: float, width: float, height: float) -> list[OperationExecution]:
"""Build a box/cube with the specified dimensions via sketch + extrude."""
operations: list[OperationExecution] = []

box_sketch_args = {
"name": "Box Base Sketch",
"entities": {
"l1": {"id": "l1", "type": "line", "start": (0.0, 0.0), "end": (length, 0.0)},
"l2": {"id": "l2", "type": "line", "start": (length, 0.0), "end": (length, width)},
"l3": {"id": "l3", "type": "line", "start": (length, width), "end": (0.0, width)},
"l4": {"id": "l4", "type": "line", "start": (0.0, width), "end": (0.0, 0.0)},
},
"profile_order": ["l1", "l2", "l3", "l4"],
"constraints": [
{"id": "d1", "type": "distance", "a": "l1", "value": length},
{"id": "d2", "type": "distance", "a": "l2", "value": width},
],
}

sketch = self._safe_call(
operations,
"add_sketch",
box_sketch_args,
lambda: {"sketch_id": runtime.add_sketch(**box_sketch_args)},
)
sketch_id = str(sketch["sketch_id"])

extrude_args = {"sketch_id": sketch_id, "depth": height, "name": "Box Extrude"}
self._safe_call(
operations,
"extrude",
extrude_args,
lambda: {"feature_id": runtime.extrude(**extrude_args)},
)

return operations

def _build_simple_feature(self, runtime: ToolRuntime) -> list[OperationExecution]:
operations: list[OperationExecution] = []

Expand Down Expand Up @@ -256,6 +339,25 @@ def generate_code(self, message: str) -> str:
').offset(0.4, name="Carrier Reinforcement")\n'
)

# Handle cube/box requests
if "cube" in lowered or "box" in lowered:
dims = _parse_cube_dimensions(message)
if dims:
length, width, height = dims
# Check if it's a uniform cube
if length == width == height:
return (
f'"""Generated OpenCAD example: {length}mm cube."""\n\n'
"from opencad import Part\n\n\n"
f'Part(name="Generated Cube").box({length}, {length}, {length}, name="Cube")\n'
)
else:
return (
f'"""Generated OpenCAD example: {length}x{width}x{height} box."""\n\n'
"from opencad import Part\n\n\n"
f'Part(name="Generated Box").box({length}, {width}, {height}, name="Box")\n'
)

return (
'"""Generated OpenCAD example: simple extruded part."""\n\n'
"from opencad import Part, Sketch\n\n\n"
Expand Down
93 changes: 92 additions & 1 deletion opencad_agent/tests/test_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
from opencad_agent.api import app
from opencad_agent.llm import LiteLlmProvider
from opencad_agent.models import ChatRequest
from opencad_agent.planner import OpenCadPlanner
from opencad_agent.planner import OpenCadPlanner, _parse_cube_dimensions
from opencad_agent.prompting import build_code_generation_prompt, build_system_prompt
from opencad_agent.service import OpenCadAgentService
from opencad_agent.tools import ToolRuntime
Expand Down Expand Up @@ -275,3 +275,94 @@ def test_agent_examples_readme_includes_claude_and_gemini_commands() -> None:
assert "OPENCAD_LLM_MODEL=claude-3-5-sonnet-latest" in readme
assert "OPENCAD_LLM_PROVIDER=gemini" in readme
assert "OPENCAD_LLM_MODEL=gemini-2.0-flash" in readme


# ── Cube/Box dimension parsing tests ────────────────────────────────────


@pytest.mark.parametrize(
("message", "expected"),
[
("build a cube of 30x30x30 mm", (30.0, 30.0, 30.0)),
("create a 30x30x30mm cube", (30.0, 30.0, 30.0)),
("box 10x20x30", (10.0, 20.0, 30.0)),
("30 mm cube", (30.0, 30.0, 30.0)),
("30mm cube", (30.0, 30.0, 30.0)),
("cube of 25", (25.0, 25.0, 25.0)),
("15.5 x 20.5 x 30.5 box", (15.5, 20.5, 30.5)),
],
)
def test_parse_cube_dimensions(message: str, expected: tuple[float, float, float]) -> None:
assert _parse_cube_dimensions(message) == expected


def test_parse_cube_dimensions_returns_none_for_invalid() -> None:
assert _parse_cube_dimensions("create a sphere") is None
assert _parse_cube_dimensions("make something") is None


@pytest.mark.parametrize(
("message", "expected_in_code"),
[
("build a cube of 30x30x30 mm", '.box(30.0, 30.0, 30.0, name="Cube")'),
("create a 10x20x30 box", '.box(10.0, 20.0, 30.0, name="Box")'),
("make a 25mm cube", '.box(25.0, 25.0, 25.0, name="Cube")'),
],
)
def test_planner_generate_code_for_cubes_and_boxes(message: str, expected_in_code: str) -> None:
code = OpenCadPlanner().generate_code(message)
assert "from opencad import Part" in code
assert expected_in_code in code


def test_planner_execute_handles_cube_request() -> None:
service = OpenCadAgentService()
request = ChatRequest(
message="build a cube of 30x30x30 mm",
tree_state=_seed_tree(),
conversation_history=[],
reasoning=False,
)

response = service.chat(request)

# Should have 2 operations: add_sketch and extrude
assert len(response.operations_executed) == 2
assert all(op.status == "ok" for op in response.operations_executed)
assert response.operations_executed[0].tool == "add_sketch"
assert response.operations_executed[1].tool == "extrude"

# Verify the dimensions - sketch should be 30x30 and extrude depth 30
sketch_op = response.operations_executed[0]
extrude_op = response.operations_executed[1]
assert extrude_op.arguments["depth"] == 30.0

# Verify sketch forms a complete 30x30 square
entities = sketch_op.arguments["entities"]
assert entities["l1"]["start"] == (0.0, 0.0)
assert entities["l1"]["end"] == (30.0, 0.0)
assert entities["l2"]["start"] == (30.0, 0.0)
assert entities["l2"]["end"] == (30.0, 30.0)
assert entities["l3"]["start"] == (30.0, 30.0)
assert entities["l3"]["end"] == (0.0, 30.0)
assert entities["l4"]["start"] == (0.0, 30.0)
assert entities["l4"]["end"] == (0.0, 0.0)


def test_chat_api_can_generate_cube_code() -> None:
client = TestClient(app)
payload = {
"message": "Generate a 30x30x30 cube",
"tree_state": _seed_tree().model_dump(),
"conversation_history": [],
"reasoning": False,
"generate_code": True,
}

response = client.post("/chat", json=payload)
assert response.status_code == 200

body = response.json()
assert body["operations_executed"] == []
assert '.box(30.0, 30.0, 30.0, name="Cube")' in body["generated_code"]
assert "from opencad import Part" in body["generated_code"]