3D-CAD-AI-Agent / app.py
stardust-coder's picture
first commit
5128ccd
Raw
History Blame Contribute Delete
21 kB
import os
import re
import sys
import base64
import shutil
import mimetypes
import subprocess
import tempfile
from pathlib import Path
import time
# =========================
# Paths / environment
# =========================
BASE_DIR = Path.cwd().resolve()
GRADIO_TEMP_DIR = BASE_DIR / "gradio_tmp"
ARTIFACT_DIR = BASE_DIR / "outputs"
GRADIO_TEMP_DIR.mkdir(parents=True, exist_ok=True)
ARTIFACT_DIR.mkdir(parents=True, exist_ok=True)
os.environ["GRADIO_TEMP_DIR"] = str(GRADIO_TEMP_DIR)
import gradio as gr
from anthropic import Anthropic
from openai import OpenAI
# =========================
# Clients
# =========================
ANTHROPIC_API_KEY = os.getenv("ANTHROPIC_API_KEY")
OPENAI_API_KEY = os.getenv("OPENAI_API_KEY")
if not ANTHROPIC_API_KEY:
raise RuntimeError("ANTHROPIC_API_KEY is not set")
if not OPENAI_API_KEY:
raise RuntimeError("OPENAI_API_KEY is not set")
anthropic_client = Anthropic(api_key=ANTHROPIC_API_KEY)
openai_client = OpenAI(api_key=OPENAI_API_KEY)
# =========================
# Model options
# =========================
CLAUDE_MODELS = [
"claude-opus-4-7",
"claude-opus-4-6",
"claude-sonnet-4-6",
"claude-sonnet-4-5",
"claude-haiku-4-5-20251001"
]
OPENAI_MODELS = [
"gpt-5.4-pro-2026-03-05",
"gpt-5.2-2025-12-11",
"gpt-5-nano-2025-08-07",
]
DEFAULT_CLAUDE_MODEL = "claude-opus-4-7"
DEFAULT_OPENAI_MODEL = "gpt-5-nano-2025-08-07"
# =========================
# Helpers
# =========================
def strip_code_fences(text: str) -> str:
text = text.strip()
match = re.search(r"```(?:python)?\s*(.*?)```", text, re.DOTALL | re.IGNORECASE)
if match:
return match.group(1).strip()
return text
def detect_media_type(image_path: str) -> str:
media_type, _ = mimetypes.guess_type(image_path)
return media_type or "application/octet-stream"
def copy_to_artifact(src_path: Path, suffix: str) -> str:
dst = ARTIFACT_DIR / f"{next(tempfile._get_candidate_names())}{suffix}"
shutil.copy2(src_path, dst)
return str(dst.resolve())
def truncate_text(text: str, max_chars: int = 4000) -> str:
if not text:
return ""
return text[-max_chars:]
def append_log(log: str, message: str) -> str:
return (log + message.rstrip() + "\n").strip() + "\n"
# =========================
# LLM steps
# =========================
def generate_cad_code(text_prompt, image_path, step_path, claude_model):
content = []
spec_text = (text_prompt or "").strip()
if spec_text:
content.append(
{
"type": "text",
"text": f"""Generate executable CadQuery Python code to build a 3D CAD model.
Requirements:
- Use CadQuery only
- Save files in the current working directory
- Export BOTH final files:
- ./output.step
- ./output.stl
- ALSO export progressive STL snapshots during construction:
- ./snap_001.stl
- ./snap_002.stl
- ./snap_003.stl
- ...
- The script must be directly executable with `python model.py`
- Do not use markdown fences
- Output ONLY valid Python code
- At the end, ensure the exports are actually executed
Important implementation rules:
- Build the model in multiple clear steps
- Keep the current shape in a variable named `result`
- After every major modeling step, export `result` to a new snapshot STL
- Use zero-padded numbering like snap_001.stl
- At the end, export the final `result` to output.step and output.stl
Specification:
{spec_text}
""",
}
)
if image_path:
with open(image_path, "rb") as f:
img_b64 = base64.b64encode(f.read()).decode("utf-8")
content.append(
{
"type": "image",
"source": {
"type": "base64",
"media_type": detect_media_type(image_path),
"data": img_b64,
},
}
)
if step_path:
uploaded_file = anthropic_client.beta.files.upload(
file=(
Path(step_path).name,
open(step_path, "rb"),
"text/plain",
)
)
content.append(
{
"type": "document",
"source": {
"type": "file",
"file_id": uploaded_file.id,
},
"title": "input.step",
"context": "This is a STEP CAD file in plain text format.",
}
)
if not content:
raise gr.Error("Text CAD specification or image is required.")
response = anthropic_client.beta.messages.create(
model=claude_model,
max_tokens=16000,
betas=["files-api-2025-04-14"],
messages=[
{
"role": "user",
"content": content
}
],
)
code = "".join(
block.text
for block in response.content
if getattr(block, "type", None) == "text"
).strip()
code = strip_code_fences(code)
if not code:
raise gr.Error("Claude returned empty code.")
return code
def translate_to_japanese(text, openai_model):
if not text or not text.strip():
return ""
response = openai_client.responses.create(
model=openai_model,
instructions=(
"You are a professional translator. "
"Translate the user's English text into natural, concise Japanese. "
"Preserve structure, numbering, and technical meaning. "
"Return only the Japanese translation."
),
input=text,
)
return response.output_text.strip()
def parse_plan_and_code(response_text: str):
plan_text = ""
code_text = response_text.strip()
if "[PLAN]" in response_text and "[CODE]" in response_text:
before_code, after_code = response_text.split("[CODE]", 1)
_, plan_part = before_code.split("[PLAN]", 1)
plan_text = plan_part.strip()
code_text = after_code.strip()
code_text = strip_code_fences(code_text)
return plan_text, code_text
def generate_cad_artifacts(text_prompt, image_path, step_path, claude_model):
content = []
spec_text = (text_prompt or "").strip()
if spec_text:
content.append(
{
"type": "text",
"text": f"""Generate a 3D CAD model in CadQuery.
Return output in exactly this format:
[PLAN]
Write 4 to 8 concrete build steps for the model.
Each step should explain:
- what geometry or feature is being created
- why it is needed
- any important design intent, constraint, or tradeoff
The PLAN should be specific and descriptive, suitable for showing as a "Build Progress" log.
[CODE]
Return executable Python code using CadQuery only.
Requirements for the code:
- Use CadQuery only
- Save files in the current working directory
- Export BOTH final files:
- ./output.step
- ./output.stl
- ALSO export progressive STL snapshots during construction:
- ./snap_001.stl
- ./snap_002.stl
- ./snap_003.stl
- ...
- The script must be directly executable with `python model.py`
- Do not use markdown fences
- Output ONLY the PLAN block and the CODE block
- At the end, ensure the exports are actually executed
Important implementation rules:
- Build the model in multiple clear steps
- Keep the current shape in a variable named `result`
- After every major modeling step, export `result` to a new snapshot STL
- Use zero-padded numbering like snap_001.stl
- At the end, export the final `result` to output.step and output.stl
- The number and order of snapshot exports should roughly align with the PLAN steps
Specification:
{spec_text}
""",
}
)
if image_path:
with open(image_path, "rb") as f:
img_b64 = base64.b64encode(f.read()).decode("utf-8")
content.append(
{
"type": "image",
"source": {
"type": "base64",
"media_type": detect_media_type(image_path),
"data": img_b64,
},
}
)
if step_path:
uploaded_file = anthropic_client.beta.files.upload(
file=(
Path(step_path).name,
open(step_path, "rb"),
"text/plain",
)
)
content.append(
{
"type": "document",
"source": {
"type": "file",
"file_id": uploaded_file.id,
},
"title": "input.step",
"context": "This is a STEP CAD file in plain text format.",
}
)
if not content:
raise gr.Error("Text CAD specification or image is required.")
response = anthropic_client.beta.messages.create(
model=claude_model,
max_tokens=16000,
betas=["files-api-2025-04-14"],
messages=[
{
"role": "user",
"content": content
}
],
)
raw_text = "".join(
block.text
for block in response.content
if getattr(block, "type", None) == "text"
).strip()
if not raw_text:
raise gr.Error("Claude returned empty output.")
plan_text, code = parse_plan_and_code(raw_text)
if not code:
raise gr.Error("Claude returned empty code.")
return plan_text, code
def run_cadquery(code):
with tempfile.TemporaryDirectory(dir=str(GRADIO_TEMP_DIR)) as tmpdir:
tmpdir = Path(tmpdir)
script_path = tmpdir / "model.py"
with open(script_path, "w", encoding="utf-8") as f:
f.write(code)
result = subprocess.run(
[sys.executable, str(script_path)],
cwd=str(tmpdir),
capture_output=True,
text=True,
timeout=180,
)
if result.returncode != 0:
raise gr.Error(
"CadQuery execution failed.\n\n"
f"STDOUT:\n{truncate_text(result.stdout)}\n\n"
f"STDERR:\n{truncate_text(result.stderr)}"
)
step_path = tmpdir / "output.step"
stl_path = tmpdir / "output.stl"
files = [p.name for p in tmpdir.iterdir()]
if not step_path.exists() and not stl_path.exists():
raise gr.Error(
"CAD script finished but did not generate output.step or output.stl.\n\n"
f"Files found in temp dir: {files}\n\n"
"Make sure the generated CadQuery code exports exactly "
"./output.step and ./output.stl"
)
if not step_path.exists():
raise gr.Error(
"output.step was not created.\n\n"
f"Files found in temp dir: {files}"
)
if not stl_path.exists():
raise gr.Error(
"output.stl was not created.\n\n"
f"Files found in temp dir: {files}"
)
final_step = copy_to_artifact(step_path, ".step")
final_stl = copy_to_artifact(stl_path, ".stl")
return final_step, final_stl
def run_cadquery_progressive(code):
with tempfile.TemporaryDirectory(dir=str(GRADIO_TEMP_DIR)) as tmpdir:
tmpdir = Path(tmpdir)
script_path = tmpdir / "model.py"
with open(script_path, "w", encoding="utf-8") as f:
f.write(code)
proc = subprocess.Popen(
[sys.executable, str(script_path)],
cwd=str(tmpdir),
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
)
seen = set()
last_snapshot_path = None
while True:
snap_files = sorted(tmpdir.glob("snap_*.stl"))
for snap in snap_files:
if snap.name not in seen:
seen.add(snap.name)
copied = copy_to_artifact(snap, ".stl")
last_snapshot_path = copied
yield {
"viewer": copied,
"status": f"Rendering step {snap.stem}...",
"step_file": None,
"review": None,
"done": False,
}
ret = proc.poll()
if ret is not None:
break
time.sleep(0.4)
stdout, stderr = proc.communicate()
if proc.returncode != 0:
raise gr.Error(
"CadQuery execution failed.\n\n"
f"STDOUT:\n{truncate_text(stdout)}\n\n"
f"STDERR:\n{truncate_text(stderr)}"
)
step_path = tmpdir / "output.step"
stl_path = tmpdir / "output.stl"
files = [p.name for p in tmpdir.iterdir()]
if not step_path.exists() or not stl_path.exists():
raise gr.Error(
"CAD script finished but final output.step/output.stl was missing.\n\n"
f"Files found: {files}"
)
final_step = copy_to_artifact(step_path, ".step")
final_stl = copy_to_artifact(stl_path, ".stl")
yield {
"viewer": final_stl,
"status": "Final model completed.",
"step_file": final_step,
"review": None,
"done": True,
}
import base64
import tempfile
from pathlib import Path
def _stl_to_png(stl_path):
try:
import trimesh
mesh = trimesh.load_mesh(stl_path)
png = mesh.scene().save_image(resolution=(512, 512))
# 👇 STEP/STLと同じ場所に保存
out_path = Path(stl_path).with_suffix(".png")
with open(out_path, "wb") as f:
f.write(png)
return str(out_path)
except Exception:
return None
def _image_to_data_url(path):
with open(path, "rb") as f:
b64 = base64.b64encode(f.read()).decode("utf-8")
return f"data:image/png;base64,{b64}"
def gpt_check(step_path, openai_model):
if not step_path or not os.path.exists(step_path):
return "STEP file not found, so review was skipped."
size = os.path.getsize(step_path)
# 🔹 STEP 読み込み(軽く truncate)
try:
with open(step_path, "r", encoding="utf-8", errors="ignore") as f:
step_text = f.read()
except Exception:
step_text = ""
# 🔹 STL を推定して PNG 化
stl_path = Path(step_path).with_suffix(".stl")
preview_png = _stl_to_png(str(stl_path)) if stl_path.exists() else None
# 🔹 GPT に渡す内容
content = [
{
"type": "input_text",
"text": f"""A CAD STEP file was generated.
File size: {size} bytes
Below is part of the STEP file:
{step_text}
Tasks:
- Check if the STEP structure looks syntactically valid
- Point out possible corruption or missing sections
- Infer possible geometry issues from structure
- If an image is provided, also check visual problems (holes, broken shapes, weird proportions)
Keep it concise. 出力は日本語で。
""",
}
]
# 🔹 画像も渡す(あれば)
if preview_png:
content.append(
{
"type": "input_image",
"image_url": _image_to_data_url(preview_png),
}
)
response = openai_client.responses.create(
model=openai_model,
input=[{"role": "user", "content": content}],
)
return response.output_text.strip()
# =========================
# Pipeline
# =========================
def pipeline(text_prompt, image, step_file, claude_model, openai_model):
log = ""
if text_prompt:
log += f"🧠 Request: {text_prompt[:120]}\n\n"
else:
log += "🧠 Request: image-based CAD generation\n\n"
log += "🧠 Analyzing request...\n"
log += "🧠 Thinking...\n Please wait for 10 seconds ... \n"
yield None, None, log, None, None
yield None, None, log, None, None
plan_text, code = generate_cad_artifacts(text_prompt, image, step_file, claude_model)
plan_text = translate_to_japanese(plan_text, openai_model)
def extract_plan_steps(plan_text: str):
steps = []
for line in plan_text.splitlines():
line = line.strip()
if not line:
continue
if line[0].isdigit() or line.startswith("-"):
steps.append(line)
return steps
plan_steps = extract_plan_steps(plan_text)
log += "🏗 Build Progress\n"
if not plan_steps:
log += "- No explicit design plan was returned.\n"
log += "\n✅ CAD code generated\n"
log += "⚙️ Starting CAD build...\n"
yield None, code, log, None, None
final_step_path = None
final_stl_path = None
step_idx = 0
for update in run_cadquery_progressive(code):
viewer_value = update["viewer"]
step_file_value = update["step_file"]
if update["done"]:
log += "▶ Final model completed.\n"
else:
step_idx += 1
if step_idx <= len(plan_steps):
log += f"▶ Step {step_idx}: {plan_steps[step_idx - 1]}\n"
else:
log += f"▶ Executing build snapshot {step_idx}\n"
yield viewer_value, code, log, step_file_value, None
if update["done"]:
final_step_path = step_file_value
final_stl_path = viewer_value
time.sleep(5)
review = "無し"
yield final_stl_path, code, log, final_step_path, review
# =========================
# UI
# =========================
with gr.Blocks(delete_cache=(86400, 86400)) as demo:
gr.Markdown("## 3D CAD AIエージェント🛠")
with gr.Row():
with gr.Column(scale=1):
claude_model = gr.Radio(
choices=CLAUDE_MODELS,
value=DEFAULT_CLAUDE_MODEL,
label="Claude model for CAD code generation",
visible=False
)
openai_model = gr.Radio(
choices=OPENAI_MODELS,
value=DEFAULT_OPENAI_MODEL,
label="OpenAI model for STEP review",
visible=False
)
templates = {
"自由入力": "",
"ボルト": "A bolt with a hexagonal socket head and a cylindrical shaft",
"筐体": "A 60mm x 40mm x 10mm enclosure with 4 corner holes",
"修正": "修正箇所:"
}
text_prompt = gr.Textbox(
label="Text CAD specification\n仕様を記載してください",
lines=3,
placeholder="自由入力、またはテンプレートを選択"
)
template = gr.Dropdown(
choices=list(templates.keys()),
value="自由入力",
label="テンプレート"
)
template.change(
lambda x: templates[x],
inputs=template,
outputs=text_prompt,
)
with gr.Accordion("", open=False):
image_input = gr.Image(
label="2D drawing (optional)\n2D図面をアップロードすることも可能です",
type="filepath"
)
step_input = gr.File(
label="STEPファイルをアップロード",
file_types=[".step", ".stp"],
type="filepath"
)
run_btn = gr.Button("Generate CAD")
with gr.Column(scale=4):
viewer = gr.Model3D(label="CAD Viewer (STL)", height=560, camera_position=(20, 30, 100))
with gr.Column(scale=1):
status_box = gr.Textbox(label="🤖", lines=6, autoscroll=True)
review_box = gr.Textbox(label="Model Check", lines=8)
with gr.Accordion("コード", open=False):
code_box = gr.Code(label="Generated CAD Code", language="python")
step_file = gr.File(label="Download STEP")
edit_button = gr.Button("編集する")
edit_message = gr.Markdown("")
def use_output_as_input(step_output_path):
if step_output_path is None:
return None
src = Path(step_output_path)
output_dir = Path(tempfile.mkdtemp())
copied_path = output_dir / src.name
shutil.copy(src, copied_path)
return str(copied_path), "✅ 編集可能になりました"
edit_button.click(
fn=use_output_as_input,
inputs=[step_file],
outputs=[step_input, edit_message]
)
run_btn.click(
fn=pipeline,
inputs=[text_prompt, image_input, step_input, claude_model, openai_model],
outputs=[viewer, code_box, status_box, step_file, review_box]
)
demo.launch(
allowed_paths=[str(ARTIFACT_DIR), str(GRADIO_TEMP_DIR)]
)