|
import streamlit as st |
|
from openai import OpenAI |
|
import subprocess |
|
import tempfile |
|
import os |
|
from dotenv import load_dotenv |
|
import trimesh |
|
import plotly.graph_objects as go |
|
import re |
|
|
|
|
|
load_dotenv(override=True) |
|
title = "3D Designer Agent" |
|
|
|
|
|
st.set_page_config(page_title=title, layout="wide") |
|
|
|
SYSTEM_PROMPT = ( |
|
"You are an expert in OpenSCAD. Given a user prompt describing a 3D printable model, " |
|
"generate a parametric OpenSCAD script that fulfills the description. " |
|
"Only return the raw .scad code without any explanations or markdown formatting." |
|
) |
|
|
|
|
|
|
|
@st.cache_data |
|
def generate_scad(prompt: str, history: tuple[tuple[str, str]], api_key: str) -> str: |
|
""" |
|
Uses OpenAI API to generate OpenSCAD code from a user prompt. |
|
""" |
|
client = OpenAI(api_key=api_key) |
|
|
|
messages = [{"role": "system", "content": SYSTEM_PROMPT}] |
|
for role, content in history: |
|
messages.append({"role": role, "content": content}) |
|
messages.append({"role": "user", "content": prompt}) |
|
response = client.chat.completions.create( |
|
model="o4-mini", |
|
messages=messages, |
|
max_completion_tokens=4500, |
|
) |
|
code = response.choices[0].message.content.strip() |
|
return code |
|
|
|
|
|
@st.cache_data |
|
def generate_3d_files(scad_path: str, formats: list[str] = ["stl", "3mf"]) -> dict[str, str]: |
|
""" |
|
Generate 3D files from a SCAD file using OpenSCAD CLI for specified formats. |
|
Returns a mapping from format extension to output file path. |
|
Throws CalledProcessError on failure. |
|
""" |
|
paths: dict[str, str] = {} |
|
for fmt in formats: |
|
output_path = scad_path.replace(".scad", f".{fmt}") |
|
subprocess.run(["openscad", "-o", output_path, scad_path], check=True, capture_output=True, text=True) |
|
paths[fmt] = output_path |
|
return paths |
|
|
|
|
|
def parse_scad_parameters(code: str) -> dict[str, float]: |
|
params: dict[str, float] = {} |
|
for line in code.splitlines(): |
|
m = re.match(r"(\w+)\s*=\s*([0-9\.]+)\s*;", line) |
|
if m: |
|
params[m.group(1)] = float(m.group(2)) |
|
return params |
|
|
|
|
|
def apply_scad_parameters(code: str, params: dict[str, float]) -> str: |
|
def repl(match): |
|
name = match.group(1) |
|
if name in params: |
|
return f"{name} = {params[name]};" |
|
return match.group(0) |
|
return re.sub(r"(\w+)\s*=\s*[0-9\.]+\s*;", repl, code) |
|
|
|
|
|
|
|
@st.dialog("Download Model") |
|
def download_model_dialog(stl_path: str, threemf_path: str): |
|
choice = st.radio("Choose file format", ["STL", "3MF"] ) |
|
if choice == "STL": |
|
with open(stl_path, "rb") as f: |
|
st.download_button( |
|
label="Download STL File", data=f, file_name="model.stl", |
|
mime="application/sla", on_click="ignore" |
|
) |
|
else: |
|
with open(threemf_path, "rb") as f: |
|
st.download_button( |
|
label="Download 3MF File", data=f, file_name="model.3mf", |
|
mime="application/octet-stream", on_click="ignore" |
|
) |
|
if st.button("Close"): |
|
st.rerun() |
|
|
|
|
|
def main(): |
|
|
|
api_key = st.sidebar.text_input("OpenAI API Key", type="password", value=os.getenv("OPENAI_API_KEY", "")) |
|
|
|
|
|
st.logo("https://media.githubusercontent.com/media/nchourrout/Chat-To-STL/main/logo.png", size="large") |
|
st.title(title) |
|
st.write("Enter a description for your 3D model, and this app will generate an STL file using OpenSCAD and OpenAI.") |
|
|
|
if not api_key: |
|
st.warning("π Please enter an OpenAI API key in the sidebar to generate a model.") |
|
st.image("demo.gif") |
|
|
|
|
|
if "history" not in st.session_state: |
|
st.session_state.history = [] |
|
|
|
|
|
for idx, msg in enumerate(st.session_state.history): |
|
with st.chat_message(msg["role"]): |
|
if msg["role"] == "user": |
|
st.write(msg["content"]) |
|
else: |
|
with st.expander("Generated OpenSCAD Code", expanded=False): |
|
st.code(msg["scad_code"], language="c") |
|
mesh = trimesh.load(msg["stl_path"]) |
|
fig = go.Figure(data=[go.Mesh3d( |
|
x=mesh.vertices[:,0], y=mesh.vertices[:,1], z=mesh.vertices[:,2], |
|
i=mesh.faces[:,0], j=mesh.faces[:,1], k=mesh.faces[:,2], |
|
color='lightblue', opacity=0.50 |
|
)]) |
|
fig.update_layout(scene=dict(aspectmode='data'), margin=dict(l=0, r=0, b=0, t=0)) |
|
st.plotly_chart(fig, use_container_width=True, height=600) |
|
|
|
if st.button("Download Model", key=f"download-model-{idx}"): |
|
download_model_dialog(msg["stl_path"], msg["3mf_path"]) |
|
|
|
if msg["role"] == "assistant": |
|
params = parse_scad_parameters(msg["scad_code"]) |
|
with st.expander("Adjust parameters", expanded=False): |
|
if not params: |
|
st.write("No numeric parameters detected in the SCAD code.") |
|
else: |
|
|
|
with st.form(key=f"param-form-{idx}"): |
|
updated: dict[str, float] = {} |
|
for name, default in params.items(): |
|
updated[name] = st.number_input(name, value=default, key=f"{idx}-{name}") |
|
regenerate = st.form_submit_button("Regenerate Preview") |
|
if regenerate: |
|
|
|
new_code = apply_scad_parameters(msg["scad_code"], updated) |
|
|
|
with open(msg["scad_path"], "w") as f: |
|
f.write(new_code) |
|
|
|
try: |
|
stl_only_path = generate_3d_files(msg["scad_path"], formats=["stl"])["stl"] |
|
except subprocess.CalledProcessError as e: |
|
st.error(f"OpenSCAD failed with exit code {e.returncode}") |
|
return |
|
|
|
msg["scad_code"] = new_code |
|
msg["content"] = new_code |
|
msg["stl_path"] = stl_only_path |
|
|
|
st.rerun() |
|
|
|
|
|
if user_input := st.chat_input("Describe the desired object"): |
|
if not api_key: |
|
st.error("π Please enter an OpenAI API key in the sidebar to generate a model.") |
|
return |
|
|
|
|
|
st.session_state.history.append({"role": "user", "content": user_input}) |
|
|
|
with st.spinner("Generating and rendering your model..."): |
|
history_for_api = tuple( |
|
(m["role"], m["content"]) |
|
for m in st.session_state.history |
|
if "content" in m and "role" in m |
|
) |
|
scad_code = generate_scad(user_input, history_for_api, api_key) |
|
with tempfile.NamedTemporaryFile(suffix=".scad", delete=False) as scad_file: |
|
scad_file.write(scad_code.encode("utf-8")) |
|
scad_path = scad_file.name |
|
try: |
|
file_paths = generate_3d_files(scad_path) |
|
except subprocess.CalledProcessError as e: |
|
st.error(f"OpenSCAD failed with exit code {e.returncode}") |
|
st.subheader("OpenSCAD stdout") |
|
st.code(e.stdout or "<no stdout>") |
|
st.subheader("OpenSCAD stderr") |
|
st.code(e.stderr or "<no stderr>") |
|
return |
|
|
|
st.session_state.history.append({ |
|
"role": "assistant", |
|
"content": scad_code, |
|
"scad_code": scad_code, |
|
"scad_path": scad_path, |
|
"stl_path": file_paths["stl"], |
|
"3mf_path": file_paths["3mf"] |
|
}) |
|
|
|
st.rerun() |
|
|
|
|
|
st.markdown( |
|
""" |
|
<style> |
|
footer { |
|
position: fixed; |
|
bottom: 0; |
|
left: 0; |
|
width: 100%; |
|
text-align: center; |
|
padding: 10px 0; |
|
background-color: var(--secondary-background-color); |
|
color: var(--text-color); |
|
z-index: 1000; |
|
} |
|
footer a { |
|
color: var(--primary-color); |
|
} |
|
</style> |
|
<footer>Made by <a href="https://flowful.ai" target="_blank">flowful.ai</a> | <a href="https://medium.com/@nchourrout/vibe-modeling-turning-prompts-into-parametric-3d-prints-a63405d36824" target="_blank">Examples</a></footer> |
|
""", unsafe_allow_html=True |
|
) |
|
|
|
|
|
if __name__ == "__main__": |
|
main() |