File size: 9,764 Bytes
760cec5 |
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 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 |
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 environment variables from .env
load_dotenv(override=True)
title = "3D Designer Agent"
# Set the Streamlit layout to wide
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."
)
# Cache SCAD generation to avoid repeated API calls for same prompt+history
@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)
# Build conversation messages including history
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)
# Dialog for downloading model in chosen format
@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():
# Sidebar for custom OpenAI API key
api_key = st.sidebar.text_input("OpenAI API Key", type="password", value=os.getenv("OPENAI_API_KEY", ""))
# Display large app logo and updated title
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")
# Initialize chat history
if "history" not in st.session_state:
st.session_state.history = []
# Replay full conversation 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)
# Single button to open download dialog
if st.button("Download Model", key=f"download-model-{idx}"):
download_model_dialog(msg["stl_path"], msg["3mf_path"])
# Add parameter adjustment UI tied to this history message
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:
# Use a form so inputs don't trigger reruns until submitted
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:
# Apply new parameter values
new_code = apply_scad_parameters(msg["scad_code"], updated)
# Overwrite SCAD file
with open(msg["scad_path"], "w") as f:
f.write(new_code)
# Regenerate only STL preview for speed
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
# Update history message in place
msg["scad_code"] = new_code
msg["content"] = new_code
msg["stl_path"] = stl_only_path
# Rerun to refresh UI
st.rerun()
# Accept new user input and handle conversation state
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
# Add user message to history
st.session_state.history.append({"role": "user", "content": user_input})
# Generate SCAD and 3D files
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
# Add assistant message to history and rerun to display via history loop
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"]
})
# Rerun to update chat history display
st.rerun()
# Fixed footer always visible
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() |