mikhail-shevtsov commited on
Commit
f257a91
·
0 Parent(s):

feat: initial commit

Browse files
Files changed (13) hide show
  1. .gitignore +4 -0
  2. Dockerfile +25 -0
  3. LICENSE.md +21 -0
  4. Makefile +16 -0
  5. README.md +33 -0
  6. docker-compose.yml +9 -0
  7. requirements.txt +4 -0
  8. ruff.toml +1 -0
  9. src/.gitignore +1 -0
  10. src/3mf2mmuv3.py +153 -0
  11. src/app.py +388 -0
  12. src/input.scad.j2 +28 -0
  13. src/png23mf.py +471 -0
.gitignore ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ .gradio/
2
+ .DS_Store
3
+ __pycache__/
4
+ .venv
Dockerfile ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM openscad/openscad:trixie.2026-01-19 AS base
2
+
3
+ WORKDIR /app
4
+ ENV PYTHONUNBUFFERED=1 \
5
+ PYTHONDONTWRITEBYTECODE=1
6
+ COPY requirements.txt /app/
7
+
8
+ RUN apt-get update \
9
+ && apt-get -y install python3 python3-pip \
10
+ && pip install -r requirements.txt --break-system-packages
11
+
12
+ FROM base AS developer
13
+
14
+ ENV GRADIO_WATCH_DIRS="/app/src"
15
+
16
+ CMD ["python3", "src/app.py"]
17
+
18
+
19
+ FROM base AS executor
20
+
21
+ WORKDIR /app
22
+
23
+ COPY src/app.py src/3mf2mmuv3.py src/input.scad.j2 src/png23mf.py /app/
24
+
25
+ CMD ["python3", "app.py"]
LICENSE.md ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Wiregate LLC
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
Makefile ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ .PHONY: all .venv install clean
2
+
3
+ all: install
4
+
5
+ .venv:
6
+ @if [ ! -d .venv ]; then \
7
+ python3 -m venv .venv; \
8
+ fi
9
+
10
+ install: .venv
11
+
12
+
13
+ .venv/bin/pip install -r requirements.txt
14
+
15
+ clean:
16
+ rm -rf .venv
README.md ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # PNG to 3MF converter
2
+
3
+ Simple python app with Gradio UI that allows to create a multicolor 3mf file
4
+ suitable for 3d printing using Prusa/Orca-family slicers.
5
+
6
+ <video width="640" height="360" controls>
7
+ <source src="docs/demo.mp4" type="video/mp4">
8
+ Your browser does not support the video tag. See "docs/demo.mp4"
9
+ </video>
10
+
11
+ ## Local development
12
+
13
+ ```sh
14
+ docker compose up --build
15
+ ```
16
+
17
+ ### [Optional] code linting in IDE
18
+
19
+ Install virtualenv for IDE to resolve dependencies.
20
+
21
+ ```sh
22
+ make install
23
+ ```
24
+
25
+ ## Production build
26
+
27
+ ```sh
28
+ TARGET=executor docker compose up --build
29
+ ```
30
+
31
+ ## Usage
32
+
33
+ Navigate the page: http://localhost:7860
docker-compose.yml ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ services:
2
+ app:
3
+ build:
4
+ context: .
5
+ target: "${TARGET:-developer}"
6
+ volumes:
7
+ - "${PWD:-./}:/${TARGET:-app}"
8
+ ports:
9
+ - "7860:7860"
requirements.txt ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ gradio==6.9.0
2
+ ruff==0.15.6
3
+ scipy==1.17.1
4
+ jinja2==3.1.6
ruff.toml ADDED
@@ -0,0 +1 @@
 
 
1
+ line-length = 79
src/.gitignore ADDED
@@ -0,0 +1 @@
 
 
1
+ tmp/
src/3mf2mmuv3.py ADDED
@@ -0,0 +1,153 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/bin/env python3
2
+ """
3
+ Convert a standard 3mf generated by OpenSCAD with color information
4
+ to a MMU‑painted 3mf that PrusaSlicer expects
5
+ """
6
+
7
+ import argparse
8
+ import logging
9
+ import zipfile
10
+ from pathlib import Path
11
+ from xml.etree import ElementTree as ET
12
+
13
+ # Mapping from extruder index to paint color code used by PrusaSlicer / BambuStudio
14
+ PAINT_COLOR_MAP = [
15
+ None,
16
+ "8",
17
+ "0C",
18
+ "1C",
19
+ "2C",
20
+ "3C",
21
+ "4C",
22
+ "5C",
23
+ "6C",
24
+ "7C",
25
+ "8C",
26
+ "9C",
27
+ "AC",
28
+ "BC",
29
+ "CC",
30
+ "DC",
31
+ ]
32
+
33
+
34
+ def main():
35
+ parser = argparse.ArgumentParser()
36
+ parser.add_argument(
37
+ "--loglevel",
38
+ type=str,
39
+ choices=["INFO", "DEBUG", "WARN", "ERROR"],
40
+ default="INFO",
41
+ help="Logging Level",
42
+ )
43
+ parser.add_argument(
44
+ "--force",
45
+ default=False,
46
+ action="store_true",
47
+ help="Force overwriting the output file",
48
+ )
49
+ parser.add_argument("openscad_3mf", help="OpenSCAD 3MF input")
50
+ parser.add_argument("prusaslicer_3mf", help="PrusaSlicer 3MF output")
51
+ args = parser.parse_args()
52
+
53
+ logging.basicConfig(
54
+ format="%(asctime)s %(levelname)8s %(message)s", level=args.loglevel
55
+ )
56
+
57
+ # register the namespaces we care about
58
+ namespaces = {
59
+ "xml": "http://www.w3.org/XML/1998/namespace",
60
+ "": "http://schemas.microsoft.com/3dmanufacturing/core/2015/02",
61
+ "p": "http://schemas.microsoft.com/3dmanufacturing/production/2015/06",
62
+ "slic3rpe": "http://schemas.slic3r.org/3mf/2017/06",
63
+ }
64
+ for k, v in namespaces.items():
65
+ ET.register_namespace(k, v)
66
+
67
+ os3mf = Path(args.openscad_3mf)
68
+ ps3mf = Path(args.prusaslicer_3mf)
69
+
70
+ if not os3mf.exists():
71
+ logging.error(f"The input file {os3mf} doesn't exist")
72
+ exit(1)
73
+
74
+ if ps3mf.exists() and not args.force:
75
+ logging.error(
76
+ f"The output file {ps3mf} exists and will not be overwritten without --force"
77
+ )
78
+ exit(1)
79
+
80
+ with zipfile.ZipFile(
81
+ ps3mf, "w", compression=zipfile.ZIP_DEFLATED, compresslevel=9
82
+ ) as pszip:
83
+ with zipfile.ZipFile(os3mf, "r") as oszip:
84
+ # copy the files from the openscad 3mf to the prusaslicer 3mf
85
+ for file in [
86
+ x for x in oszip.infolist() if x.filename != "3D/3dmodel.model"
87
+ ]:
88
+ logging.info(f"Copying file {file}")
89
+ pszip.writestr(file, oszip.open(file).read())
90
+
91
+ model_raw = oszip.read("3D/3dmodel.model")
92
+
93
+ logging.info("Updating 3dmodel.model")
94
+ # Parse the XML
95
+ model_root = ET.fromstring(str(model_raw, encoding="utf-8"))
96
+
97
+ # Remove any existing Application metadata and set to PrusaSlicer
98
+ for meta in list(model_root.findall("metadata")):
99
+ if meta.get("name") == "Application":
100
+ model_root.remove(meta)
101
+ # Add Application metadata
102
+ app_meta = ET.Element(
103
+ "metadata", {"name": "Application", "preserve": "1"}
104
+ )
105
+ app_meta.text = "PrusaSlicer"
106
+ model_root.append(app_meta)
107
+
108
+ # add slic3rpe metadata
109
+ for k, v in {
110
+ "slic3rpe:Version3mf": "1",
111
+ "slic3rpe:MmPaintingVersion": "1",
112
+ "BambuStudio:3mfVersion": "1",
113
+ }.items():
114
+ e = ET.Element("metadata", {"name": k})
115
+ e.text = v
116
+ model_root.append(e)
117
+
118
+ # Set color attributes on triangles
119
+ for t in model_root.findall(
120
+ "resources/object/mesh/triangles/triangle", namespaces
121
+ ):
122
+ p1_value = t.get("p1")
123
+ if not p1_value or p1_value == "0":
124
+ continue
125
+ try:
126
+ p1_index = int(p1_value)
127
+ except ValueError:
128
+ logging.warning(
129
+ f"Triangle has non‑integer p1 value: {p1_value}"
130
+ )
131
+ continue
132
+ if p1_index < len(PAINT_COLOR_MAP):
133
+ color_code = PAINT_COLOR_MAP[p1_index]
134
+ else:
135
+ logging.warning(
136
+ f"Material number {p1_index} is greater than supported extruders"
137
+ )
138
+ color_code = None
139
+ if color_code:
140
+ t.set("slic3rpe:mmu_segmentation", color_code)
141
+ t.set("paint_color", color_code)
142
+
143
+ model_text = str(
144
+ ET.tostring(
145
+ model_root, encoding="utf-8", xml_declaration=True
146
+ ),
147
+ encoding="utf-8",
148
+ )
149
+ pszip.writestr("3D/3dmodel.model", model_text)
150
+
151
+
152
+ if __name__ == "__main__":
153
+ main()
src/app.py ADDED
@@ -0,0 +1,388 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+
3
+ import shutil
4
+ import tempfile
5
+ from pathlib import Path
6
+
7
+ import gradio as gr
8
+
9
+ from png23mf import (
10
+ calculate_zoom_value,
11
+ generate_3mf_file,
12
+ overlay_images_core,
13
+ update_height_from_width,
14
+ update_width_from_height,
15
+ )
16
+
17
+ temp_dir = Path(tempfile.gettempdir())
18
+
19
+
20
+ def overlay_images(
21
+ base_img,
22
+ overlay_img,
23
+ x,
24
+ y,
25
+ rot,
26
+ zoom,
27
+ indexed_colors,
28
+ base_thickness,
29
+ overlay_thickness,
30
+ width,
31
+ height,
32
+ use_common_colors,
33
+ morph_size,
34
+ resolution,
35
+ ):
36
+ base_rgb, masks, mask_colors = overlay_images_core(
37
+ base_img,
38
+ overlay_img,
39
+ x,
40
+ y,
41
+ rot,
42
+ zoom,
43
+ indexed_colors,
44
+ base_thickness,
45
+ overlay_thickness,
46
+ width,
47
+ height,
48
+ use_common_colors,
49
+ morph_size,
50
+ resolution,
51
+ )
52
+ if base_rgb is None:
53
+ return None, None, None, None, gr.update(interactive=False)
54
+ return base_rgb, masks, masks, mask_colors, gr.update(interactive=True)
55
+
56
+
57
+ def update_slider_ranges(base_img, overlay_img, resolution):
58
+ if base_img is None:
59
+ if overlay_img is not None:
60
+ w, h = overlay_img.size
61
+ else:
62
+ w, h = resolution, resolution
63
+ else:
64
+ w, h = base_img.size
65
+ max_dim = resolution
66
+ if max(w, h) > max_dim:
67
+ scale_factor = max_dim / max(w, h)
68
+ w = int(w * scale_factor)
69
+ h = int(h * scale_factor)
70
+ default_width = round(100, 2)
71
+ default_height = round(default_width * h / w, 2)
72
+ if overlay_img is not None:
73
+ zoom_val = calculate_zoom_value(base_img, overlay_img, 1.0, resolution)
74
+ else:
75
+ zoom_val = 1.0
76
+ return (
77
+ gr.update(minimum=-w, maximum=w, step=1, value=0),
78
+ gr.update(minimum=-h, maximum=h, step=1, value=0),
79
+ gr.update(maximum=180, step=1, value=0),
80
+ gr.update(maximum=10.0, step=0.01, value=zoom_val),
81
+ gr.update(value=4),
82
+ gr.update(value=default_width),
83
+ gr.update(value=default_height),
84
+ )
85
+
86
+
87
+ def get_user_dir(req: gr.Request) -> str:
88
+ """Create a unique directory for the user session."""
89
+ session_hash = (
90
+ str(req.session_hash) if req.session_hash is not None else "unknown"
91
+ )
92
+ user_dir = temp_dir / session_hash
93
+ user_dir.mkdir(parents=True, exist_ok=True)
94
+ return str(user_dir)
95
+
96
+
97
+ def delete_user_dir(req: gr.Request) -> None:
98
+ """Delete the user directory when the session ends."""
99
+ session_hash = (
100
+ str(req.session_hash) if req.session_hash is not None else "unknown"
101
+ )
102
+ user_dir = temp_dir / session_hash
103
+ if user_dir.exists():
104
+ shutil.rmtree(user_dir)
105
+
106
+
107
+ with gr.Blocks() as demo:
108
+ gr.Markdown(value="# PNG to scad/3mf")
109
+ gr.Markdown(
110
+ value="**Tip:** The base image must be black and white. "
111
+ "Black is treated as transparent and White will be extruded."
112
+ )
113
+ with gr.Row():
114
+ with gr.Column():
115
+ with gr.Row():
116
+ base_img = gr.Image(
117
+ label="Base Image",
118
+ type="pil",
119
+ image_mode="RGB",
120
+ )
121
+ overlay_img = gr.Image(
122
+ label="Overlay Image",
123
+ type="pil",
124
+ image_mode="RGBA",
125
+ )
126
+ with gr.Column():
127
+ x_slider = gr.Slider(
128
+ label="X (px)",
129
+ minimum=-500,
130
+ maximum=500,
131
+ step=1,
132
+ value=0,
133
+ interactive=False,
134
+ info="Overlay image X coordinate relative to base",
135
+ )
136
+ y_slider = gr.Slider(
137
+ label="Y (px)",
138
+ minimum=-500,
139
+ maximum=500,
140
+ step=1,
141
+ value=0,
142
+ interactive=False,
143
+ info="Overlay image Y coordinate relative to base",
144
+ )
145
+ rot_slider = gr.Slider(
146
+ label="Rotation (°)",
147
+ minimum=-180,
148
+ maximum=180,
149
+ step=1,
150
+ value=0,
151
+ interactive=False,
152
+ info="Rotate overlay image",
153
+ )
154
+ zoom_slider = gr.Slider(
155
+ label="Zoom",
156
+ minimum=0.01,
157
+ maximum=10.0,
158
+ step=0.01,
159
+ value=1.0,
160
+ interactive=False,
161
+ info="Make overlay image bigger or smaller",
162
+ )
163
+ indexed_slider = gr.Slider(
164
+ label="Indexed Colors",
165
+ minimum=2,
166
+ maximum=255,
167
+ step=1,
168
+ value=4,
169
+ info="Overlay image colors will be quantized",
170
+ )
171
+ use_common_colors_checkbox = gr.Checkbox(
172
+ label="Use most common colors for quantization",
173
+ value=True,
174
+ interactive=True,
175
+ info="Best for the logo's.",
176
+ )
177
+ morphology_slider = gr.Slider(
178
+ label="Morphology Size",
179
+ minimum=1,
180
+ maximum=20,
181
+ step=1,
182
+ value=1,
183
+ info=(
184
+ "Size of morphological opening/closing "
185
+ "to remove small features"
186
+ ),
187
+ )
188
+ resolution_dropdown = gr.Dropdown(
189
+ label="Resolution (px)",
190
+ choices=[512, 1024, 2048],
191
+ value=512,
192
+ interactive=True,
193
+ )
194
+
195
+ with gr.Column():
196
+ out = gr.Image(label="Result")
197
+ mask_gallery = gr.Gallery(label="Masks")
198
+ base_thickness = gr.Number(
199
+ label="Base Thickness (mm)", minimum=0, step=0.1, value=1.0
200
+ )
201
+ overlay_thickness = gr.Number(
202
+ label="Overlay Thickness (mm)", minimum=0, step=0.1, value=0.5
203
+ )
204
+ width_slider = gr.Number(
205
+ label="Width (mm)",
206
+ minimum=0,
207
+ step=0.01,
208
+ value=100,
209
+ interactive=True,
210
+ )
211
+ height_slider = gr.Number(
212
+ label="Height (mm)",
213
+ minimum=0,
214
+ step=0.01,
215
+ value=100,
216
+ interactive=True,
217
+ )
218
+ use_aspect_ratio = gr.Checkbox(
219
+ label="Use image aspect ratio (base/overlay)",
220
+ value=True,
221
+ interactive=True,
222
+ )
223
+ height_slider.input(
224
+ fn=update_width_from_height,
225
+ inputs=[
226
+ height_slider,
227
+ width_slider,
228
+ use_aspect_ratio,
229
+ base_img,
230
+ overlay_img,
231
+ ],
232
+ outputs=[width_slider],
233
+ )
234
+ width_slider.input(
235
+ fn=update_height_from_width,
236
+ inputs=[
237
+ width_slider,
238
+ height_slider,
239
+ use_aspect_ratio,
240
+ base_img,
241
+ overlay_img,
242
+ ],
243
+ outputs=[height_slider],
244
+ )
245
+ masks_state = gr.State()
246
+ mask_colors_state = gr.State()
247
+
248
+ generate_button = gr.Button("Generate scad/3mf", interactive=False)
249
+ scad_file = gr.File(label="SCAD/3mf output")
250
+
251
+ masks_state.change(
252
+ fn=lambda masks: gr.update(interactive=bool(masks)),
253
+ inputs=[masks_state],
254
+ outputs=[generate_button],
255
+ )
256
+
257
+ base_img.change(
258
+ fn=update_slider_ranges,
259
+ inputs=[base_img, overlay_img, resolution_dropdown],
260
+ outputs=[
261
+ x_slider,
262
+ y_slider,
263
+ rot_slider,
264
+ zoom_slider,
265
+ indexed_slider,
266
+ width_slider,
267
+ height_slider,
268
+ ],
269
+ )
270
+
271
+ base_img.change(
272
+ fn=lambda img: (
273
+ gr.update(interactive=True),
274
+ gr.update(interactive=True),
275
+ gr.update(interactive=True),
276
+ ),
277
+ inputs=base_img,
278
+ outputs=[width_slider, height_slider, use_aspect_ratio],
279
+ )
280
+
281
+ overlay_img.change(
282
+ fn=lambda img, base, current_zoom, res: (
283
+ gr.update(interactive=bool(img)),
284
+ gr.update(interactive=bool(img)),
285
+ gr.update(interactive=bool(img)),
286
+ gr.update(
287
+ interactive=bool(img),
288
+ value=calculate_zoom_value(base, img, current_zoom, res)
289
+ if img
290
+ else current_zoom,
291
+ ),
292
+ ),
293
+ inputs=[overlay_img, base_img, zoom_slider, resolution_dropdown],
294
+ outputs=[x_slider, y_slider, rot_slider, zoom_slider],
295
+ )
296
+
297
+ overlay_img.change(
298
+ fn=update_height_from_width,
299
+ inputs=[
300
+ width_slider,
301
+ height_slider,
302
+ use_aspect_ratio,
303
+ base_img,
304
+ overlay_img,
305
+ ],
306
+ outputs=[height_slider],
307
+ )
308
+
309
+ for comp in [
310
+ x_slider,
311
+ y_slider,
312
+ rot_slider,
313
+ zoom_slider,
314
+ indexed_slider,
315
+ use_common_colors_checkbox,
316
+ base_img,
317
+ overlay_img,
318
+ morphology_slider,
319
+ resolution_dropdown,
320
+ ]:
321
+ comp.change(
322
+ fn=overlay_images,
323
+ inputs=[
324
+ base_img,
325
+ overlay_img,
326
+ x_slider,
327
+ y_slider,
328
+ rot_slider,
329
+ zoom_slider,
330
+ indexed_slider,
331
+ base_thickness,
332
+ overlay_thickness,
333
+ width_slider,
334
+ height_slider,
335
+ use_common_colors_checkbox,
336
+ morphology_slider,
337
+ resolution_dropdown,
338
+ ],
339
+ outputs=[
340
+ out,
341
+ mask_gallery,
342
+ masks_state,
343
+ mask_colors_state,
344
+ generate_button,
345
+ ],
346
+ )
347
+
348
+ resolution_dropdown.change(
349
+ fn=update_slider_ranges,
350
+ inputs=[base_img, overlay_img, resolution_dropdown],
351
+ outputs=[
352
+ x_slider,
353
+ y_slider,
354
+ rot_slider,
355
+ zoom_slider,
356
+ indexed_slider,
357
+ width_slider,
358
+ height_slider,
359
+ ],
360
+ )
361
+
362
+ # Hidden component to hold the user directory path
363
+ user_dir = gr.Text(visible=False)
364
+
365
+ # Set the user directory when the app loads
366
+ demo.load(get_user_dir, outputs=user_dir)
367
+
368
+ generate_button.click(
369
+ fn=generate_3mf_file,
370
+ inputs=[
371
+ base_img,
372
+ masks_state,
373
+ mask_colors_state,
374
+ width_slider,
375
+ height_slider,
376
+ base_thickness,
377
+ overlay_thickness,
378
+ resolution_dropdown,
379
+ user_dir,
380
+ ],
381
+ outputs=[scad_file],
382
+ )
383
+
384
+ # Delete the user directory when the session ends
385
+ demo.unload(delete_user_dir)
386
+
387
+ if __name__ == "__main__":
388
+ demo.launch(share=False, server_name="0.0.0.0")
src/input.scad.j2 ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {% if include_base %}
2
+ module base() {
3
+ scale([{{ scale_x }}, {{ scale_y }}, {{ scale_z }}]) {
4
+ color({{ base_color }}) linear_extrude(height = 100) projection(cut = true) {
5
+ surface(file="{{ base_rel_path }}", center=false);
6
+ }
7
+ }
8
+ }
9
+ {% endif %}
10
+
11
+ module masks() {
12
+ scale([{{ scale_x }}, {{ scale_y }}, {{ overlay_scale_z }}]) {
13
+ {% for mask_path, mask_color in mask_paths_and_colors %}
14
+ color({{ mask_color }}) linear_extrude(height = 100) projection(cut = true) {
15
+ surface(file="{{ mask_path }}", center=false);
16
+ }
17
+ {% endfor %}
18
+ }
19
+ }
20
+
21
+ {% if include_base %}
22
+ difference() {
23
+ base();
24
+ masks();
25
+ }
26
+ {% endif %}
27
+
28
+ masks();
src/png23mf.py ADDED
@@ -0,0 +1,471 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+
3
+ import argparse
4
+ import os
5
+ import random
6
+ import shutil
7
+ import string
8
+ import subprocess
9
+ import tempfile
10
+ import zipfile
11
+ from pathlib import Path
12
+
13
+ import jinja2
14
+ import numpy as np
15
+ from PIL import Image
16
+ from scipy import ndimage
17
+
18
+
19
+ def generate_scad_file(
20
+ base_img,
21
+ width_mm,
22
+ height_mm,
23
+ base_thickness_mm,
24
+ overlay_thickness_mm,
25
+ resolution,
26
+ masks,
27
+ mask_colors,
28
+ ):
29
+ include_base = base_img is not None
30
+ tmp_dir = tempfile.mkdtemp(prefix="scad_")
31
+ # Base image handling
32
+ if include_base:
33
+ base = base_img.convert("RGBA")
34
+ w_base, h_base = base.size
35
+ if max(w_base, h_base) > resolution:
36
+ scale_factor = resolution / max(w_base, h_base)
37
+ new_w_base = int(w_base * scale_factor)
38
+ new_h_base = int(h_base * scale_factor)
39
+ base = base.resize(
40
+ (new_w_base, new_h_base), Image.Resampling.BICUBIC
41
+ )
42
+ w_base, h_base = base.size
43
+ base_path = Path(tmp_dir) / "base.png"
44
+ base.save(base_path)
45
+ scale_x = width_mm / w_base if w_base != 0 else 1.0
46
+ scale_y = height_mm / h_base if h_base != 0 else 1.0
47
+ scale_z = base_thickness_mm / 100.0 if base_thickness_mm != 0 else 1.0
48
+ base_rel_path = base_path.name
49
+ base_color = [0.0, 0.0, 0.0] # placeholder, will be set below
50
+ else:
51
+ # No base image: use overlay size for scaling
52
+ if masks:
53
+ w_overlay, h_overlay = masks[0].size
54
+ scale_x = width_mm / w_overlay if w_overlay != 0 else 1.0
55
+ scale_y = height_mm / h_overlay if h_overlay != 0 else 1.0
56
+ else:
57
+ scale_x = 1.0
58
+ scale_y = 1.0
59
+ scale_z = 1.0
60
+ base_rel_path = ""
61
+ base_color = [0.0, 0.0, 0.0]
62
+ overlay_scale_z = (
63
+ overlay_thickness_mm / 100.0 if overlay_thickness_mm != 0 else 1.0
64
+ )
65
+ # Save masks
66
+ mask_paths = []
67
+ for idx, mask_img in enumerate(masks):
68
+ mask_path = Path(tmp_dir) / f"mask_{idx}.png"
69
+ mask_img = mask_img.transpose(Image.Transpose.FLIP_LEFT_RIGHT)
70
+ mask_path.parent.mkdir(parents=True, exist_ok=True)
71
+ mask_img.save(mask_path)
72
+ mask_paths.append(mask_path)
73
+ # Load template
74
+ template_path = os.path.join(os.path.dirname(__file__), "input.scad.j2")
75
+ with open(template_path, "r") as f:
76
+ template_text = f.read()
77
+ template = jinja2.Template(template_text)
78
+ mask_paths_and_colors = [
79
+ (mask_path.name, mask_color)
80
+ for mask_path, mask_color in zip(mask_paths, mask_colors)
81
+ ]
82
+ if include_base:
83
+ mask_color_ints = set(
84
+ (int(r * 255), int(g * 255), int(b * 255))
85
+ for r, g, b in mask_colors
86
+ )
87
+ max_attempts = 1000
88
+ for _ in range(max_attempts):
89
+ r = random.randint(0, 255)
90
+ g = random.randint(0, 255)
91
+ b = random.randint(0, 255)
92
+ if (r, g, b) not in mask_color_ints:
93
+ break
94
+ else:
95
+ r, g, b = 0, 0, 0
96
+ base_color = [r / 255.0, g / 255.0, b / 255.0]
97
+ rendered_scad = template.render(
98
+ scale_x=scale_x,
99
+ scale_y=scale_y,
100
+ scale_z=scale_z,
101
+ base_rel_path=base_rel_path,
102
+ overlay_scale_z=overlay_scale_z,
103
+ mask_paths_and_colors=mask_paths_and_colors,
104
+ base_color=base_color,
105
+ include_base=include_base,
106
+ )
107
+ scad_path = Path(tmp_dir) / "model.scad"
108
+ with open(scad_path, "w") as f:
109
+ f.write(rendered_scad)
110
+ return str(scad_path)
111
+
112
+
113
+ def extract_masks(overlay_image, morph_size=1):
114
+ overlay_rgba = overlay_image.convert("RGBA")
115
+ overlay_np = np.array(overlay_rgba)
116
+ alpha_mask = overlay_np[..., 3] != 0
117
+ flat = overlay_np.reshape(-1, 4)
118
+ unique_colors = np.unique(flat, axis=0)
119
+ unique_colors = unique_colors[unique_colors[:, 3] != 0]
120
+ masks = []
121
+ mask_colors = []
122
+ selem = np.ones((int(morph_size), int(morph_size)), dtype=bool)
123
+ for color in unique_colors:
124
+ mask_array = (
125
+ np.all(overlay_np[..., :3] == color[:3], axis=-1) & alpha_mask
126
+ )
127
+ opened = ndimage.binary_opening(mask_array, structure=selem)
128
+ closed = ndimage.binary_closing(opened, structure=selem)
129
+ mask_image = Image.fromarray((closed.astype(np.uint8) * 255), mode="L")
130
+ masks.append(mask_image)
131
+ r = int(color[0]) / 255.0
132
+ g = int(color[1]) / 255.0
133
+ b = int(color[2]) / 255.0
134
+ mask_colors.append([r, g, b])
135
+ return masks, mask_colors
136
+
137
+
138
+ def calculate_zoom_value(base_img, overlay_img, current_zoom, resolution):
139
+ if base_img is None or overlay_img is None:
140
+ return current_zoom
141
+ base = base_img.convert("RGBA")
142
+ w_base, h_base = base.size
143
+ if max(w_base, h_base) > resolution:
144
+ scale_factor = resolution / max(w_base, h_base)
145
+ w_base = int(w_base * scale_factor)
146
+ h_base = int(h_base * scale_factor)
147
+ base_longer = max(w_base, h_base)
148
+ w_overlay, h_overlay = overlay_img.size
149
+ overlay_longer = max(w_overlay, h_overlay)
150
+ if overlay_longer > base_longer:
151
+ zoom = round(base_longer / overlay_longer, 2)
152
+ zoom = max(0.01, min(10.0, zoom))
153
+ return zoom
154
+ else:
155
+ return current_zoom
156
+
157
+
158
+ def get_most_common_colors(overlay_img, num_colors):
159
+ rgba = overlay_img.convert("RGBA")
160
+ arr = np.array(rgba)
161
+ alpha_mask = arr[..., 3] > 0
162
+ rgb_pixels = arr[alpha_mask][:, :3]
163
+ if rgb_pixels.size == 0:
164
+ return [(0, 0, 0)] * num_colors
165
+ unique_colors, counts = np.unique(rgb_pixels, axis=0, return_counts=True)
166
+ sorted_indices = np.argsort(-counts)
167
+ sorted_colors = unique_colors[sorted_indices]
168
+ top_colors = sorted_colors[:num_colors]
169
+ return [tuple(color) for color in top_colors]
170
+
171
+
172
+ def create_palette_image(colors):
173
+ palette = [0] * 768
174
+ for i, color in enumerate(colors):
175
+ if i >= 256:
176
+ break
177
+ palette[i * 3] = color[0]
178
+ palette[i * 3 + 1] = color[1]
179
+ palette[i * 3 + 2] = color[2]
180
+ last_color = colors[-1]
181
+ for i in range(len(colors), 256):
182
+ palette[i * 3] = last_color[0]
183
+ palette[i * 3 + 1] = last_color[1]
184
+ palette[i * 3 + 2] = last_color[2]
185
+ palette_img = Image.new("P", (1, 1))
186
+ palette_img.putpalette(palette)
187
+ return palette_img
188
+
189
+
190
+ def overlay_images_core(
191
+ base_img,
192
+ overlay_img,
193
+ x,
194
+ y,
195
+ rot,
196
+ zoom,
197
+ indexed_colors,
198
+ base_thickness,
199
+ overlay_thickness,
200
+ width,
201
+ height,
202
+ use_common_colors,
203
+ morph_size,
204
+ resolution,
205
+ ):
206
+ if overlay_img is None:
207
+ return None, None, None
208
+ width = round(width, 2)
209
+ height = round(height, 2)
210
+ if base_img is None:
211
+ base = overlay_img.convert("RGBA")
212
+ x, y = 0, 0
213
+ else:
214
+ base = base_img.convert("RGBA")
215
+ w_base, h_base = base.size
216
+ max_dim = resolution
217
+ if max(w_base, h_base) > max_dim:
218
+ scale_factor = max_dim / max(w_base, h_base)
219
+ new_w_base = int(w_base * scale_factor)
220
+ new_h_base = int(h_base * scale_factor)
221
+ base = base.resize(
222
+ (new_w_base, new_h_base), Image.Resampling.BICUBIC
223
+ )
224
+ overlay = overlay_img.convert("RGBA")
225
+ w, h = overlay.size
226
+ overlay = overlay.resize(
227
+ (int(w * zoom), int(h * zoom)), Image.Resampling.NEAREST
228
+ )
229
+ overlay = overlay.rotate(rot, expand=True)
230
+ alpha = overlay.split()[3]
231
+ rgb = overlay.convert("RGB")
232
+ if use_common_colors:
233
+ common_colors = get_most_common_colors(overlay_img, indexed_colors)
234
+ palette_img = create_palette_image(common_colors)
235
+ rgb_quant = rgb.quantize(palette=palette_img, dither=0).convert("RGB")
236
+ else:
237
+ rgb_quant = rgb.quantize(colors=indexed_colors).convert("RGB")
238
+ alpha = overlay.split()[3].point(lambda p: 255 if p > 0 else 0)
239
+ overlay = Image.merge("RGBA", (*rgb_quant.split(), alpha))
240
+ if base_img is not None:
241
+ base.paste(overlay, (int(x), int(y)), overlay)
242
+ overlay_on_transparent = Image.new("RGBA", base.size, (0, 0, 0, 0))
243
+ overlay_on_transparent.paste(overlay, (int(x), int(y)), overlay)
244
+ masks, mask_colors = extract_masks(overlay_on_transparent, morph_size)
245
+ return (
246
+ base.convert("RGB"),
247
+ masks,
248
+ mask_colors,
249
+ )
250
+
251
+
252
+ def update_width_from_height(
253
+ height,
254
+ width,
255
+ use_aspect_ratio,
256
+ base_img,
257
+ overlay_img,
258
+ ):
259
+ if use_aspect_ratio:
260
+ if base_img is not None:
261
+ w_base, h_base = base_img.size
262
+ elif overlay_img is not None:
263
+ w_base, h_base = overlay_img.size
264
+ else:
265
+ return width
266
+ new_width = round(height * w_base / h_base, 2)
267
+ if abs(new_width - width) > 1e-6:
268
+ return new_width
269
+ return width
270
+
271
+
272
+ def update_height_from_width(
273
+ width,
274
+ height,
275
+ use_aspect_ratio,
276
+ base_img,
277
+ overlay_img,
278
+ ):
279
+ if use_aspect_ratio:
280
+ if base_img is not None:
281
+ w_base, h_base = base_img.size
282
+ elif overlay_img is not None:
283
+ w_base, h_base = overlay_img.size
284
+ else:
285
+ return height
286
+ new_height = round(width * h_base / w_base, 2)
287
+ if abs(new_height - height) > 1e-6:
288
+ return new_height
289
+ return height
290
+
291
+
292
+ def generate_3mf_file(
293
+ base_img,
294
+ masks,
295
+ mask_colors,
296
+ width,
297
+ height,
298
+ base_thickness,
299
+ overlay_thickness,
300
+ resolution,
301
+ user_dir=None,
302
+ ):
303
+ if not masks or not mask_colors:
304
+ return None
305
+ scad_path = generate_scad_file(
306
+ base_img,
307
+ width,
308
+ height,
309
+ base_thickness,
310
+ overlay_thickness,
311
+ resolution,
312
+ masks,
313
+ mask_colors,
314
+ )
315
+ if not scad_path:
316
+ return None
317
+ scad_path_obj = Path(scad_path)
318
+ openscad_model_3mf = scad_path_obj.with_name("openscad_model.3mf")
319
+ subprocess.run(
320
+ ["openscad", "-o", str(openscad_model_3mf), str(scad_path_obj)],
321
+ check=True,
322
+ )
323
+ converted_model_3mf = openscad_model_3mf.with_name("converted_model.3mf")
324
+ script_path = Path(__file__).parent / "3mf2mmuv3.py"
325
+ subprocess.run(
326
+ [
327
+ "python3",
328
+ str(script_path),
329
+ str(openscad_model_3mf),
330
+ str(converted_model_3mf),
331
+ "--force",
332
+ ],
333
+ check=True,
334
+ )
335
+ temp_dir = scad_path_obj.parent
336
+ mask_paths = sorted(Path(temp_dir).glob("mask_*.png"))
337
+ random_string = "".join(
338
+ random.choices(string.ascii_letters + string.digits, k=6)
339
+ )
340
+ if user_dir is not None:
341
+ zip_dir = Path(user_dir)
342
+ zip_dir.mkdir(parents=True, exist_ok=True)
343
+ zip_path = zip_dir / f"model_{random_string}.zip"
344
+ else:
345
+ zip_path = Path(tempfile.gettempdir()) / f"model_{random_string}.zip"
346
+ with zipfile.ZipFile(
347
+ zip_path, "w", zipfile.ZIP_DEFLATED, compresslevel=9
348
+ ) as zf:
349
+ zf.write(scad_path_obj, arcname=scad_path_obj.name)
350
+ zf.write(openscad_model_3mf, arcname=openscad_model_3mf.name)
351
+ zf.write(converted_model_3mf, arcname=converted_model_3mf.name)
352
+ for mask_path in mask_paths:
353
+ zf.write(mask_path, arcname=mask_path.name)
354
+ if base_img is not None:
355
+ base_img_path = Path(temp_dir) / "base.png"
356
+ zf.write(base_img_path, arcname=base_img_path.name)
357
+ shutil.rmtree(temp_dir)
358
+ return str(zip_path)
359
+
360
+
361
+ def main():
362
+ parser = argparse.ArgumentParser(
363
+ description="Generate 3MF from base and overlay images."
364
+ )
365
+ parser.add_argument("base_image", help="Path to base image (PNG)")
366
+ parser.add_argument("overlay_image", help="Path to overlay image (PNG)")
367
+ parser.add_argument(
368
+ "-o", "--output", default=None, help="Output zip file path (optional)"
369
+ )
370
+ parser.add_argument(
371
+ "--width", type=float, default=100.0, help="Width in mm"
372
+ )
373
+ parser.add_argument(
374
+ "--height", type=float, default=100.0, help="Height in mm"
375
+ )
376
+ parser.add_argument(
377
+ "--base-thickness",
378
+ type=float,
379
+ default=1.0,
380
+ help="Base thickness in mm",
381
+ )
382
+ parser.add_argument(
383
+ "--overlay-thickness",
384
+ type=float,
385
+ default=0.5,
386
+ help="Overlay thickness in mm",
387
+ )
388
+ parser.add_argument(
389
+ "--resolution",
390
+ type=int,
391
+ choices=[512, 1024, 2048],
392
+ default=512,
393
+ help="Resolution in pixels",
394
+ )
395
+ parser.add_argument(
396
+ "--morph-size",
397
+ type=int,
398
+ default=1,
399
+ help="Morphology size for mask extraction",
400
+ )
401
+ # New arguments that mirror the Gradio UI
402
+ parser.add_argument("--x", type=int, default=0, help="X offset (px)")
403
+ parser.add_argument("--y", type=int, default=0, help="Y offset (px)")
404
+ parser.add_argument(
405
+ "--rot", type=int, default=0, help="Rotation (degrees)"
406
+ )
407
+ parser.add_argument("--zoom", type=float, default=1.0, help="Zoom factor")
408
+ parser.add_argument(
409
+ "--indexed-colors",
410
+ type=int,
411
+ default=4,
412
+ help="Number of colors for quantization",
413
+ )
414
+ parser.add_argument(
415
+ "--use-common-colors",
416
+ action="store_true",
417
+ default=True,
418
+ help="Use most common colors for quantization",
419
+ )
420
+ args = parser.parse_args()
421
+
422
+ # Load images
423
+ base_img = Image.open(args.base_image).convert("RGBA")
424
+ overlay_img = Image.open(args.overlay_image).convert("RGBA")
425
+
426
+ # Generate masks and mask colors using overlay images core logic
427
+ _, masks, mask_colors = overlay_images_core(
428
+ base_img,
429
+ overlay_img,
430
+ args.x,
431
+ args.y,
432
+ args.rot,
433
+ args.zoom,
434
+ args.indexed_colors,
435
+ args.base_thickness,
436
+ args.overlay_thickness,
437
+ args.width,
438
+ args.height,
439
+ args.use_common_colors,
440
+ args.morph_size,
441
+ args.resolution,
442
+ )
443
+
444
+ if not masks or not mask_colors:
445
+ print("Failed to generate masks from overlay image.")
446
+ return
447
+
448
+ zip_path = generate_3mf_file(
449
+ base_img,
450
+ masks,
451
+ mask_colors,
452
+ args.width,
453
+ args.height,
454
+ args.base_thickness,
455
+ args.overlay_thickness,
456
+ args.resolution,
457
+ )
458
+
459
+ if zip_path is None:
460
+ print("Failed to generate 3MF zip.")
461
+ return
462
+
463
+ if args.output:
464
+ shutil.move(zip_path, args.output)
465
+ print(f"Generated zip file: {args.output}")
466
+ else:
467
+ print(f"Generated zip file: {zip_path}")
468
+
469
+
470
+ if __name__ == "__main__":
471
+ main()