Spaces:
Running
Running
noahlee1234 commited on
Commit ·
e5a7f86
1
Parent(s): 73bbb49
feat: add 1d_path mode and DXF export/download support
Browse files- apps/cad-worker/main.py +29 -3
- apps/gradio-demo/app/main.py +31 -8
apps/cad-worker/main.py
CHANGED
|
@@ -61,7 +61,7 @@ image = (
|
|
| 61 |
# ---------------------------------------------------------------------------
|
| 62 |
|
| 63 |
_VALID_MODES = {"part", "assembly", "sketch"}
|
| 64 |
-
_VALID_OUTPUTS = {"3d_solid", "surface", "2d_vector"}
|
| 65 |
_MAX_PROMPT_CHARS = int(os.environ.get("NATURALCAD_MAX_PROMPT_CHARS", "1200"))
|
| 66 |
|
| 67 |
_RATE_WINDOW_SECONDS = int(os.environ.get("NATURALCAD_RATE_WINDOW_SECONDS", "60"))
|
|
@@ -401,6 +401,11 @@ _OUTPUT_RULES = {
|
|
| 401 |
"Extrude with a minimal thickness of 1 mm so the geometry exports as STL/STEP. "
|
| 402 |
"result must be a Part (bp.part)."
|
| 403 |
),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 404 |
}
|
| 405 |
|
| 406 |
_MODE_HINTS = {
|
|
@@ -646,7 +651,7 @@ def generate_cad(prompt: str, mode: str = "part", output_type: str = "3d_solid")
|
|
| 646 |
if log_generated_code:
|
| 647 |
_log_info(f"Generated code:\n{generated_code}")
|
| 648 |
|
| 649 |
-
from build123d import
|
| 650 |
|
| 651 |
with tempfile.TemporaryDirectory() as tmpdir:
|
| 652 |
script_path = Path(tmpdir) / "script.py"
|
|
@@ -727,13 +732,14 @@ def generate_cad(prompt: str, mode: str = "part", output_type: str = "3d_solid")
|
|
| 727 |
}
|
| 728 |
|
| 729 |
# ----------------------------------------------------------------
|
| 730 |
-
# Export: STL, STEP, GLB
|
| 731 |
# ----------------------------------------------------------------
|
| 732 |
shape = result_shape
|
| 733 |
urls = {}
|
| 734 |
stl_path = Path(tmpdir) / "output.stl"
|
| 735 |
step_path = Path(tmpdir) / "output.step"
|
| 736 |
glb_path = Path(tmpdir) / "output.glb"
|
|
|
|
| 737 |
|
| 738 |
try:
|
| 739 |
export_stl(shape, str(stl_path))
|
|
@@ -765,6 +771,24 @@ def generate_cad(prompt: str, mode: str = "part", output_type: str = "3d_solid")
|
|
| 765 |
except Exception as e:
|
| 766 |
_log_error(f"GLB export failed: {e}")
|
| 767 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 768 |
# ----------------------------------------------------------------
|
| 769 |
# Upload to Supabase storage
|
| 770 |
# ----------------------------------------------------------------
|
|
@@ -772,6 +796,8 @@ def generate_cad(prompt: str, mode: str = "part", output_type: str = "3d_solid")
|
|
| 772 |
("stl", stl_path, "model/stl"),
|
| 773 |
("step", step_path, "application/octet-stream"),
|
| 774 |
]
|
|
|
|
|
|
|
| 775 |
if store_glb:
|
| 776 |
file_pairs.append(("glb", glb_path, "model/gltf-binary"))
|
| 777 |
for fmt, file_path, content_type in file_pairs:
|
|
|
|
| 61 |
# ---------------------------------------------------------------------------
|
| 62 |
|
| 63 |
_VALID_MODES = {"part", "assembly", "sketch"}
|
| 64 |
+
_VALID_OUTPUTS = {"3d_solid", "surface", "2d_vector", "1d_path"}
|
| 65 |
_MAX_PROMPT_CHARS = int(os.environ.get("NATURALCAD_MAX_PROMPT_CHARS", "1200"))
|
| 66 |
|
| 67 |
_RATE_WINDOW_SECONDS = int(os.environ.get("NATURALCAD_RATE_WINDOW_SECONDS", "60"))
|
|
|
|
| 401 |
"Extrude with a minimal thickness of 1 mm so the geometry exports as STL/STEP. "
|
| 402 |
"result must be a Part (bp.part)."
|
| 403 |
),
|
| 404 |
+
"1d_path": (
|
| 405 |
+
"Output goal: a 1D path-style layout (linework/centerlines). Build the geometry from lines/arcs on Plane.XY. "
|
| 406 |
+
"For compatibility with STL/STEP preview, give the path a minimal thickness (about 1 mm) by using a thin profile. "
|
| 407 |
+
"result must be a Part (bp.part)."
|
| 408 |
+
),
|
| 409 |
}
|
| 410 |
|
| 411 |
_MODE_HINTS = {
|
|
|
|
| 651 |
if log_generated_code:
|
| 652 |
_log_info(f"Generated code:\n{generated_code}")
|
| 653 |
|
| 654 |
+
from build123d import Axis, ExportDXF, Unit, export_step, export_stl
|
| 655 |
|
| 656 |
with tempfile.TemporaryDirectory() as tmpdir:
|
| 657 |
script_path = Path(tmpdir) / "script.py"
|
|
|
|
| 732 |
}
|
| 733 |
|
| 734 |
# ----------------------------------------------------------------
|
| 735 |
+
# Export: STL, STEP, GLB, DXF
|
| 736 |
# ----------------------------------------------------------------
|
| 737 |
shape = result_shape
|
| 738 |
urls = {}
|
| 739 |
stl_path = Path(tmpdir) / "output.stl"
|
| 740 |
step_path = Path(tmpdir) / "output.step"
|
| 741 |
glb_path = Path(tmpdir) / "output.glb"
|
| 742 |
+
dxf_path = Path(tmpdir) / "output.dxf"
|
| 743 |
|
| 744 |
try:
|
| 745 |
export_stl(shape, str(stl_path))
|
|
|
|
| 771 |
except Exception as e:
|
| 772 |
_log_error(f"GLB export failed: {e}")
|
| 773 |
|
| 774 |
+
try:
|
| 775 |
+
if output_type in {"2d_vector", "1d_path"}:
|
| 776 |
+
exporter = ExportDXF(unit=Unit.MM)
|
| 777 |
+
if output_type == "1d_path":
|
| 778 |
+
exporter.add_shape(shape.edges())
|
| 779 |
+
else:
|
| 780 |
+
faces = shape.faces()
|
| 781 |
+
if faces:
|
| 782 |
+
top_face = faces.sort_by(Axis.Z)[-1]
|
| 783 |
+
wires = [top_face.outer_wire(), *list(top_face.inner_wires())]
|
| 784 |
+
exporter.add_shape(wires)
|
| 785 |
+
else:
|
| 786 |
+
exporter.add_shape(shape.edges())
|
| 787 |
+
exporter.write(str(dxf_path))
|
| 788 |
+
_log_info(f"DXF exported: {dxf_path.exists()}")
|
| 789 |
+
except Exception as e:
|
| 790 |
+
_log_error(f"DXF export failed: {e}")
|
| 791 |
+
|
| 792 |
# ----------------------------------------------------------------
|
| 793 |
# Upload to Supabase storage
|
| 794 |
# ----------------------------------------------------------------
|
|
|
|
| 796 |
("stl", stl_path, "model/stl"),
|
| 797 |
("step", step_path, "application/octet-stream"),
|
| 798 |
]
|
| 799 |
+
if dxf_path.exists():
|
| 800 |
+
file_pairs.append(("dxf", dxf_path, "application/dxf"))
|
| 801 |
if store_glb:
|
| 802 |
file_pairs.append(("glb", glb_path, "model/gltf-binary"))
|
| 803 |
for fmt, file_path, content_type in file_pairs:
|
apps/gradio-demo/app/main.py
CHANGED
|
@@ -40,6 +40,7 @@ EXAMPLE_PROMPTS = [
|
|
| 40 |
["Industrial notched tower block, 140 mm tall", "part", "3d_solid"],
|
| 41 |
["Smooth roof canopy surface, 200 mm span, shallow rise", "part", "surface"],
|
| 42 |
["Bracket plate profile with 6 holes for a laser-cut sketch", "sketch", "2d_vector"],
|
|
|
|
| 43 |
]
|
| 44 |
|
| 45 |
DEFAULT_CODE = '''from build123d import *
|
|
@@ -531,7 +532,7 @@ def generate_from_prompt(prompt: str, mode: str, output_type: str):
|
|
| 531 |
result = json.loads(response.read().decode())
|
| 532 |
|
| 533 |
if "error" in result:
|
| 534 |
-
return None, None, None, f"Error from backend:\n{result['error']}", "Backend generation failed."
|
| 535 |
|
| 536 |
urls = result.get("urls", {})
|
| 537 |
code = result.get("generated_code", "")
|
|
@@ -540,6 +541,7 @@ def generate_from_prompt(prompt: str, mode: str, output_type: str):
|
|
| 540 |
glb_url = urls.get("glb")
|
| 541 |
stl_url = urls.get("stl")
|
| 542 |
step_url = urls.get("step")
|
|
|
|
| 543 |
|
| 544 |
# Download files to artifacts directory (same as local mode)
|
| 545 |
# so Gradio can serve them properly
|
|
@@ -550,6 +552,7 @@ def generate_from_prompt(prompt: str, mode: str, output_type: str):
|
|
| 550 |
glb_file = None
|
| 551 |
stl_file = None
|
| 552 |
step_file = None
|
|
|
|
| 553 |
|
| 554 |
if glb_url:
|
| 555 |
glb_path = run_dir / f"{run_id}.glb"
|
|
@@ -602,6 +605,23 @@ def generate_from_prompt(prompt: str, mode: str, output_type: str):
|
|
| 602 |
_log_error(f"STEP download failed: {e}")
|
| 603 |
step_file = None
|
| 604 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 605 |
if not glb_file and stl_file:
|
| 606 |
try:
|
| 607 |
glb_path = run_dir / f"{run_id}.glb"
|
|
@@ -623,32 +643,34 @@ def generate_from_prompt(prompt: str, mode: str, output_type: str):
|
|
| 623 |
final_summary = f"Model ready!{' · ' + job_id[:8] if job_id else ''}"
|
| 624 |
preview_file = glb_file
|
| 625 |
|
| 626 |
-
return preview_file, stl_file, step_file, combined_logs, final_summary
|
| 627 |
except error.HTTPError as exc:
|
| 628 |
body = exc.read().decode() if exc.fp else ""
|
| 629 |
try:
|
| 630 |
detail = json.loads(body).get("error", body) if body else str(exc)
|
| 631 |
except Exception:
|
| 632 |
detail = body or str(exc)
|
| 633 |
-
return None, None, None, f"Backend HTTP {exc.code}: {detail}", "Generation failed."
|
| 634 |
except error.URLError as exc:
|
| 635 |
if isinstance(exc.reason, TimeoutError) or "timed out" in str(exc.reason).lower():
|
| 636 |
return (
|
| 637 |
None,
|
| 638 |
None,
|
| 639 |
None,
|
|
|
|
| 640 |
(
|
| 641 |
f"Backend timeout after {BACKEND_TIMEOUT_SECONDS:.0f}s. "
|
| 642 |
"Try a shorter prompt (fewer clauses), or retry."
|
| 643 |
),
|
| 644 |
"Generation timed out.",
|
| 645 |
)
|
| 646 |
-
return None, None, None, f"Backend error: {exc}", "Generation failed."
|
| 647 |
except TimeoutError:
|
| 648 |
return (
|
| 649 |
None,
|
| 650 |
None,
|
| 651 |
None,
|
|
|
|
| 652 |
(
|
| 653 |
f"Backend timeout after {BACKEND_TIMEOUT_SECONDS:.0f}s. "
|
| 654 |
"Try a shorter prompt (fewer clauses), or retry."
|
|
@@ -656,7 +678,7 @@ def generate_from_prompt(prompt: str, mode: str, output_type: str):
|
|
| 656 |
"Generation timed out.",
|
| 657 |
)
|
| 658 |
except Exception as exc:
|
| 659 |
-
return None, None, None, f"Backend error: {exc}", "Generation failed."
|
| 660 |
|
| 661 |
# Fallback to local code stub if backend is missing
|
| 662 |
spec = {
|
|
@@ -667,7 +689,7 @@ def generate_from_prompt(prompt: str, mode: str, output_type: str):
|
|
| 667 |
code = render_code_from_spec(spec)
|
| 668 |
combined_logs = f"Local fallback:\n{code}"
|
| 669 |
final_summary = "Code generated."
|
| 670 |
-
return None, None, None, combined_logs, final_summary
|
| 671 |
|
| 672 |
|
| 673 |
def use_example(prompt: str, mode: str, output_type: str):
|
|
@@ -693,7 +715,7 @@ def build_ui() -> gr.Blocks:
|
|
| 693 |
)
|
| 694 |
with gr.Row():
|
| 695 |
mode_picker = gr.Dropdown(choices=["part", "assembly", "sketch"], value="part", label="Mode")
|
| 696 |
-
output_picker = gr.Dropdown(choices=["3d_solid", "surface", "2d_vector"], value="3d_solid", label="Output")
|
| 697 |
generate_btn = gr.Button("Generate Model", variant="primary")
|
| 698 |
gr.Markdown("### Try one of these")
|
| 699 |
gr.Examples(
|
|
@@ -709,6 +731,7 @@ def build_ui() -> gr.Blocks:
|
|
| 709 |
with gr.Row():
|
| 710 |
stl_download = gr.File(label="Download STL")
|
| 711 |
step_download = gr.File(label="Download STEP")
|
|
|
|
| 712 |
status_output = gr.Markdown("Ready. Use the mouse to orbit, pan, and zoom the model.")
|
| 713 |
|
| 714 |
log_output = gr.Textbox(
|
|
@@ -722,7 +745,7 @@ def build_ui() -> gr.Blocks:
|
|
| 722 |
generate_btn.click(
|
| 723 |
fn=generate_from_prompt,
|
| 724 |
inputs=[prompt_input, mode_picker, output_picker],
|
| 725 |
-
outputs=[model_viewer, stl_download, step_download, log_output, status_output],
|
| 726 |
)
|
| 727 |
|
| 728 |
return demo
|
|
|
|
| 40 |
["Industrial notched tower block, 140 mm tall", "part", "3d_solid"],
|
| 41 |
["Smooth roof canopy surface, 200 mm span, shallow rise", "part", "surface"],
|
| 42 |
["Bracket plate profile with 6 holes for a laser-cut sketch", "sketch", "2d_vector"],
|
| 43 |
+
["Single-line floor route centerline with 3 jogs, 120 mm long", "sketch", "1d_path"],
|
| 44 |
]
|
| 45 |
|
| 46 |
DEFAULT_CODE = '''from build123d import *
|
|
|
|
| 532 |
result = json.loads(response.read().decode())
|
| 533 |
|
| 534 |
if "error" in result:
|
| 535 |
+
return None, None, None, None, f"Error from backend:\n{result['error']}", "Backend generation failed."
|
| 536 |
|
| 537 |
urls = result.get("urls", {})
|
| 538 |
code = result.get("generated_code", "")
|
|
|
|
| 541 |
glb_url = urls.get("glb")
|
| 542 |
stl_url = urls.get("stl")
|
| 543 |
step_url = urls.get("step")
|
| 544 |
+
dxf_url = urls.get("dxf")
|
| 545 |
|
| 546 |
# Download files to artifacts directory (same as local mode)
|
| 547 |
# so Gradio can serve them properly
|
|
|
|
| 552 |
glb_file = None
|
| 553 |
stl_file = None
|
| 554 |
step_file = None
|
| 555 |
+
dxf_file = None
|
| 556 |
|
| 557 |
if glb_url:
|
| 558 |
glb_path = run_dir / f"{run_id}.glb"
|
|
|
|
| 605 |
_log_error(f"STEP download failed: {e}")
|
| 606 |
step_file = None
|
| 607 |
|
| 608 |
+
if dxf_url:
|
| 609 |
+
dxf_path = run_dir / f"{run_id}.dxf"
|
| 610 |
+
_log_info(f"Downloading DXF from {dxf_url}")
|
| 611 |
+
try:
|
| 612 |
+
with request.urlopen(dxf_url) as r:
|
| 613 |
+
data = r.read()
|
| 614 |
+
_log_info(f"Downloaded DXF bytes: {len(data)}")
|
| 615 |
+
if len(data) < 100:
|
| 616 |
+
_log_error("DXF file too small")
|
| 617 |
+
with open(dxf_path, "wb") as f:
|
| 618 |
+
f.write(data)
|
| 619 |
+
dxf_file = str(dxf_path)
|
| 620 |
+
_log_info(f"DXF saved to {dxf_file}, size {os.path.getsize(dxf_file)}")
|
| 621 |
+
except Exception as e:
|
| 622 |
+
_log_error(f"DXF download failed: {e}")
|
| 623 |
+
dxf_file = None
|
| 624 |
+
|
| 625 |
if not glb_file and stl_file:
|
| 626 |
try:
|
| 627 |
glb_path = run_dir / f"{run_id}.glb"
|
|
|
|
| 643 |
final_summary = f"Model ready!{' · ' + job_id[:8] if job_id else ''}"
|
| 644 |
preview_file = glb_file
|
| 645 |
|
| 646 |
+
return preview_file, stl_file, step_file, dxf_file, combined_logs, final_summary
|
| 647 |
except error.HTTPError as exc:
|
| 648 |
body = exc.read().decode() if exc.fp else ""
|
| 649 |
try:
|
| 650 |
detail = json.loads(body).get("error", body) if body else str(exc)
|
| 651 |
except Exception:
|
| 652 |
detail = body or str(exc)
|
| 653 |
+
return None, None, None, None, f"Backend HTTP {exc.code}: {detail}", "Generation failed."
|
| 654 |
except error.URLError as exc:
|
| 655 |
if isinstance(exc.reason, TimeoutError) or "timed out" in str(exc.reason).lower():
|
| 656 |
return (
|
| 657 |
None,
|
| 658 |
None,
|
| 659 |
None,
|
| 660 |
+
None,
|
| 661 |
(
|
| 662 |
f"Backend timeout after {BACKEND_TIMEOUT_SECONDS:.0f}s. "
|
| 663 |
"Try a shorter prompt (fewer clauses), or retry."
|
| 664 |
),
|
| 665 |
"Generation timed out.",
|
| 666 |
)
|
| 667 |
+
return None, None, None, None, f"Backend error: {exc}", "Generation failed."
|
| 668 |
except TimeoutError:
|
| 669 |
return (
|
| 670 |
None,
|
| 671 |
None,
|
| 672 |
None,
|
| 673 |
+
None,
|
| 674 |
(
|
| 675 |
f"Backend timeout after {BACKEND_TIMEOUT_SECONDS:.0f}s. "
|
| 676 |
"Try a shorter prompt (fewer clauses), or retry."
|
|
|
|
| 678 |
"Generation timed out.",
|
| 679 |
)
|
| 680 |
except Exception as exc:
|
| 681 |
+
return None, None, None, None, f"Backend error: {exc}", "Generation failed."
|
| 682 |
|
| 683 |
# Fallback to local code stub if backend is missing
|
| 684 |
spec = {
|
|
|
|
| 689 |
code = render_code_from_spec(spec)
|
| 690 |
combined_logs = f"Local fallback:\n{code}"
|
| 691 |
final_summary = "Code generated."
|
| 692 |
+
return None, None, None, None, combined_logs, final_summary
|
| 693 |
|
| 694 |
|
| 695 |
def use_example(prompt: str, mode: str, output_type: str):
|
|
|
|
| 715 |
)
|
| 716 |
with gr.Row():
|
| 717 |
mode_picker = gr.Dropdown(choices=["part", "assembly", "sketch"], value="part", label="Mode")
|
| 718 |
+
output_picker = gr.Dropdown(choices=["3d_solid", "surface", "2d_vector", "1d_path"], value="3d_solid", label="Output")
|
| 719 |
generate_btn = gr.Button("Generate Model", variant="primary")
|
| 720 |
gr.Markdown("### Try one of these")
|
| 721 |
gr.Examples(
|
|
|
|
| 731 |
with gr.Row():
|
| 732 |
stl_download = gr.File(label="Download STL")
|
| 733 |
step_download = gr.File(label="Download STEP")
|
| 734 |
+
dxf_download = gr.File(label="Download DXF")
|
| 735 |
status_output = gr.Markdown("Ready. Use the mouse to orbit, pan, and zoom the model.")
|
| 736 |
|
| 737 |
log_output = gr.Textbox(
|
|
|
|
| 745 |
generate_btn.click(
|
| 746 |
fn=generate_from_prompt,
|
| 747 |
inputs=[prompt_input, mode_picker, output_picker],
|
| 748 |
+
outputs=[model_viewer, stl_download, step_download, dxf_download, log_output, status_output],
|
| 749 |
)
|
| 750 |
|
| 751 |
return demo
|