File size: 8,073 Bytes
8f1f637
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
fa1e9b4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8f1f637
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""Sandboxed execution of generator code -> validated STL/GLB mesh.

Generated Python runs in a restricted namespace exposing only trimesh + numpy.
On failure the stderr/exception is returned so the generator can self-repair.
"""
from __future__ import annotations

import importlib
import os
import re
import textwrap

import numpy as np
import trimesh


# LLMs frequently miscall trimesh.creation.revolve (e.g. passing `sections` both positionally
# and as a keyword → "got multiple values for keyword argument 'sections'"). We always want a
# full 360° solid, so wrap it to accept any convention and normalize to revolve(profile, sections=).
def _install_tolerant_revolve():
    real = trimesh.creation.revolve
    if getattr(real, "_cf_patched", False):
        return

    def revolve(*args, **kwargs):
        ls = kwargs.pop("linestring", None)
        if ls is None and args:
            ls, args = args[0], args[1:]
        sections = kwargs.get("sections")
        if sections is None:
            ints = [a for a in args if isinstance(a, int) and a > 3]
            sections = ints[-1] if ints else 64
        return real(ls, sections=int(sections))

    revolve._cf_patched = True
    trimesh.creation.revolve = revolve


_install_tolerant_revolve()


def _repair_hint(error: str) -> str:
    """Targeted guidance for known failure modes so the model doesn't repeat the mistake."""
    e = error.lower()
    tips = []
    if "syntaxerror" in e:
        tips.append("A comment likely wrapped onto a line without '#'. Remove comments entirely or "
                    "keep each on ONE line starting with '#'.")
    if "revolve" in e:
        tips.append("CORRECT revolve usage: `result = trimesh.creation.revolve(profile, sections=64)` "
                    "where profile = np.array([[r0,h0],[r1,h1],...]) of [radius,height] points. Pass "
                    "ONLY profile and sections=; do NOT pass angle/cap/any other positional arg.")
    if "apply_rotation" in e:
        tips.append("There is no apply_rotation; use "
                    "mesh.apply_transform(trimesh.transformations.rotation_matrix(angle_rad,[x,y,z])).")
    if "polygon" in e or "extrude_polygon" in e:
        tips.append("extrude_polygon needs a shapely Polygon: "
                    "from shapely.geometry import Polygon; trimesh.creation.extrude_polygon(Polygon([(x,y),...]), height=H).")
    tips.append("If a richer builder keeps failing, FALL BACK to primitives "
                "(box/cylinder/sphere/torus) + trimesh.boolean.union/difference — those always work.")
    return " ".join(tips)


def _sanitize(code: str) -> str:
    """Strip markdown fences and normalize indentation from LLM-emitted code."""
    code = code.strip()
    if code.startswith("```"):
        code = re.sub(r"^```[a-zA-Z0-9]*\n", "", code)
        code = re.sub(r"\n```$", "", code.rstrip())
    # common failure: whole block is uniformly indented -> dedent fixes it
    return _autofix_syntax(textwrap.dedent(code).strip() + "\n")


def _autofix_syntax(code: str, max_drops: int = 8) -> str:
    """Blank out lines that raise SyntaxError (e.g. a comment that wrapped without '#').
    Such lines are stray prose with no execution value; blanking keeps line numbers stable."""
    lines = code.split("\n")
    for _ in range(max_drops):
        try:
            compile("\n".join(lines), "<gen>", "exec")
            break
        except SyntaxError as exc:
            if exc.lineno and 1 <= exc.lineno <= len(lines) and lines[exc.lineno - 1].strip():
                lines[exc.lineno - 1] = ""
            else:
                break
    return "\n".join(lines)

_ALLOWED_IMPORTS = {"trimesh", "numpy", "math", "shapely"}


def _safe_import(name, globals=None, locals=None, fromlist=(), level=0):
    """Restricted __import__: only trimesh / numpy / math (and submodules)."""
    root = name.split(".")[0]
    if root not in _ALLOWED_IMPORTS:
        raise ImportError(f"import of '{name}' is not allowed in the sandbox")
    return importlib.import_module(name)


def run_trimesh_code(code: str, out_dir: str, stem: str = "clone") -> tuple[bool, dict]:
    """Exec generator code, expecting it to assign `result` (a Trimesh).

    Returns (ok, info). On success info has stl_path, glb_path, and mesh stats.
    On failure info has {"error": <message>} for self-repair.
    """
    # Restricted globals: no builtins beyond a safe minimal set, only trimesh + np.
    safe_builtins = {
        "range": range, "len": len, "min": min, "max": max, "abs": abs,
        "round": round, "float": float, "int": int, "list": list, "dict": dict,
        "tuple": tuple, "enumerate": enumerate, "zip": zip, "sum": sum,
        "__import__": _safe_import,
    }
    ns: dict = {"__builtins__": safe_builtins, "trimesh": trimesh, "np": np}
    code = _sanitize(code)
    try:
        exec(code, ns)  # noqa: S102 — sandboxed namespace, hackathon scope
    except Exception as e:  # noqa: BLE001
        return False, {"error": f"{type(e).__name__}: {e}"}

    result = ns.get("result")
    if not isinstance(result, trimesh.Trimesh):
        return False, {"error": "code did not assign a trimesh.Trimesh to `result`"}
    if result.is_empty or len(result.vertices) == 0:
        return False, {"error": "resulting mesh is empty"}

    os.makedirs(out_dir, exist_ok=True)
    stl_path = os.path.join(out_dir, f"{stem}.stl")
    glb_path = os.path.join(out_dir, f"{stem}.glb")
    result.export(stl_path)
    result.export(glb_path)

    bbox = (result.bounds[1] - result.bounds[0]).tolist()
    return True, {
        "stl_path": stl_path,
        "glb_path": glb_path,
        "stats": {
            "watertight": bool(result.is_watertight),
            "volume_mm3": round(float(result.volume), 1) if result.is_watertight else None,
            "bbox_mm": [round(b, 1) for b in bbox],
            "n_vertices": len(result.vertices),
            "n_faces": len(result.faces),
        },
    }


async def make_candidate(plan, spec, generator_fn, out_dir, stem, *, variant_hint=None, max_repairs=1):
    """Generate one candidate mesh (with light self-repair). Returns an info dict
    (stl_path/glb_path/stats/code/meta) or None. Used for best-of-N parallel generation."""
    feedback = variant_hint
    for _ in range(max_repairs + 1):
        artifact, meta = await generator_fn(plan, spec, feedback=feedback)
        ok, info = run_trimesh_code(artifact.code, out_dir, stem)
        if ok:
            info["code"], info["meta"] = artifact.code, meta
            return info
        feedback = (f"Your previous code failed: {info['error']}. {_repair_hint(info['error'])} "
                    "Return corrected complete code, no fences, no leading indentation, assign `result`.")
    return None


async def generate_mesh(plan, spec, generator_fn, out_dir: str, max_repairs: int = 3):
    """Generate code -> exec -> on failure feed error back to the generator (<=max_repairs).

    Yields (event_text, meta_or_none, info_or_none) tuples for streaming to the UI;
    the final yielded info dict (when ok) carries stl/glb paths + stats.
    """
    feedback = None
    last_code = ""
    for attempt in range(max_repairs + 1):
        artifact, meta = await generator_fn(plan, spec, feedback=feedback)
        last_code = artifact.code
        ok, info = run_trimesh_code(artifact.code, out_dir)
        if ok:
            info["code"] = last_code
            yield ("generator", meta, info)
            return
        # show the model its OWN broken code + the error so it can fix the exact line
        feedback = (f"Your previous code:\n```python\n{artifact.code}\n```\n"
                    f"failed with: {info['error']}\n{_repair_hint(info['error'])}\n"
                    "Return corrected COMPLETE code. No markdown fences, no leading indentation, "
                    "assign the final mesh to `result`.")
        yield (f"generator (repair {attempt + 1}: {info['error']})", meta, None)
    # exhausted repairs
    yield ("generator-failed", None, {"error": feedback, "code": last_code})