File size: 5,716 Bytes
e32c964
 
 
 
 
 
 
 
 
 
e660ef7
e32c964
 
e660ef7
e32c964
 
e660ef7
e32c964
e660ef7
 
e32c964
2330e12
 
e32c964
2330e12
 
 
e32c964
 
 
 
 
 
 
 
 
 
 
 
 
e660ef7
e32c964
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
38cfebe
 
 
 
 
 
 
e32c964
38cfebe
e32c964
 
 
 
38cfebe
e32c964
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
"""
Safe CadQuery code execution engine.
Executes LLM-generated CadQuery code in a sandboxed namespace,
validates the result, and exports to STEP/STL.
"""

import io
import sys
import traceback
from pathlib import Path
from typing import Any, Optional

import cadquery as cq
from pydantic import BaseModel, Field


class ExecutionResult(BaseModel):
    """Result of executing a CadQuery script."""
    model_config = {"arbitrary_types_allowed": True}

    success: bool
    result: Optional[Any] = Field(default=None, exclude=True)  # cq.Workplane
    code: str = Field(default="", exclude=True)
    error: Optional[str] = None
    stdout: str = Field(default="", exclude=True)
    volume: float = Field(default=0.0, serialization_alias="volume_mm3")
    bounding_box: tuple = Field(default=(), serialization_alias="bounding_box_mm")
    face_count: int = 0
    edge_count: int = 0

    def summary(self) -> str:
        if not self.success:
            return f"FAILED: {self.error}"
        bb = self.bounding_box
        return (
            f"OK | Volume: {self.volume:.1f} mm³ | "
            f"BBox: {bb[0]:.1f}×{bb[1]:.1f}×{bb[2]:.1f} mm | "
            f"Faces: {self.face_count} | Edges: {self.edge_count}"
        )



# Allowed imports in the sandboxed namespace
SAFE_NAMESPACE = {
    "cq": cq,
    "cadquery": cq,
    "math": __import__("math"),
    "__builtins__": {
        "range": range,
        "len": len,
        "abs": abs,
        "min": min,
        "max": max,
        "round": round,
        "int": int,
        "float": float,
        "tuple": tuple,
        "list": list,
        "True": True,
        "False": False,
        "None": None,
        "print": print,
        "enumerate": enumerate,
        "zip": zip,
    },
}


def sanitize_code(code: str) -> str:
    """Clean up LLM output — strip markdown fences, trailing whitespace,
    and redundant import statements (already in namespace)."""
    code = code.strip()

    # Remove markdown code fences if present
    if code.startswith("```python"):
        code = code[len("```python"):]
    elif code.startswith("```"):
        code = code[3:]
    if code.endswith("```"):
        code = code[:-3]

    # Strip import lines for modules already in namespace
    lines = code.strip().splitlines()
    cleaned = []
    for line in lines:
        stripped = line.strip()
        # Keep the line unless it's a redundant cadquery/math import
        if stripped.startswith("import cadquery") or stripped.startswith("from cadquery"):
            continue
        if stripped == "import math":
            continue
        cleaned.append(line)

    return "\n".join(cleaned).strip()


def execute_cadquery(code: str) -> ExecutionResult:
    """
    Execute a CadQuery script string and return the result.
    The script must assign its output to a variable called `result`.
    """
    code = sanitize_code(code)

    # Capture stdout
    old_stdout = sys.stdout
    sys.stdout = captured = io.StringIO()

    namespace = dict(SAFE_NAMESPACE)

    try:
        exec(code, namespace)  # noqa: S102
    except Exception:
        sys.stdout = old_stdout
        return ExecutionResult(
            success=False,
            code=code,
            error=traceback.format_exc(),
            stdout=captured.getvalue(),
        )

    sys.stdout = old_stdout
    stdout_text = captured.getvalue()

    # Extract the result
    result_obj = namespace.get("result")
    if result_obj is None:
        return ExecutionResult(
            success=False,
            code=code,
            error="Script did not assign a value to `result`.",
            stdout=stdout_text,
        )

    if not isinstance(result_obj, cq.Workplane):
        return ExecutionResult(
            success=False,
            code=code,
            error=f"Expected cq.Workplane, got {type(result_obj).__name__}",
            stdout=stdout_text,
        )

    # Extract geometry metadata
    try:
        shape = result_obj.val()
        bb = result_obj.val().BoundingBox()
        bbox_dims = (bb.xlen, bb.ylen, bb.zlen)
        volume = shape.Volume()
        faces = len(result_obj.faces().vals())
        edges = len(result_obj.edges().vals())
    except Exception as e:
        return ExecutionResult(
            success=False,
            code=code,
            error=f"Geometry extraction failed: {e}",
            stdout=stdout_text,
        )

    return ExecutionResult(
        success=True,
        result=result_obj,
        code=code,
        stdout=stdout_text,
        volume=volume,
        bounding_box=bbox_dims,
        face_count=faces,
        edge_count=edges,
    )


def export_step(result: cq.Workplane, path: str | Path) -> Path:
    """Export a CadQuery workplane to STEP format."""
    path = Path(path)
    cq.exporters.export(result, str(path), exportType="STEP")
    return path


def export_stl(result: cq.Workplane, path: str | Path, tolerance: float = 0.01) -> Path:
    """Export a CadQuery workplane to STL format."""
    path = Path(path)
    cq.exporters.export(result, str(path), exportType="STL", tolerance=tolerance)
    return path


def export_3mf(result: cq.Workplane, path: str | Path) -> Path:
    """Export a CadQuery workplane to 3MF format (slicer-ready)."""
    path = Path(path)
    cq.exporters.export(result, str(path), exportType="3MF")
    return path


def export_all(result: cq.Workplane, base_path: str | Path) -> dict[str, Path]:
    """Export to STEP, STL, and 3MF."""
    base = Path(base_path)
    return {
        "step": export_step(result, base.with_suffix(".step")),
        "stl": export_stl(result, base.with_suffix(".stl")),
        "3mf": export_3mf(result, base.with_suffix(".3mf")),
    }