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()