Spaces:
Sleeping
Sleeping
| 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)] | |
| ) |