Spaces:
Running
Running
Infinite stls and nozzle spacing
Browse files- README.md +13 -12
- app.py +0 -0
- tests/test_nozzle_spacing.py +157 -0
README.md
CHANGED
|
@@ -10,7 +10,7 @@ short_description: Upload STLs, export TIFF stacks, and generate G-code.
|
|
| 10 |
|
| 11 |
# STL to G-Code Gradio App
|
| 12 |
|
| 13 |
-
This project provides a Gradio app that takes
|
| 14 |
|
| 15 |
## Prerequisites
|
| 16 |
|
|
@@ -37,24 +37,25 @@ Then open the local Gradio URL in your browser, upload STL files or load the bun
|
|
| 37 |
|
| 38 |
## What the app does
|
| 39 |
|
| 40 |
-
- Uploads
|
| 41 |
-
- Loads bundled sample STL files
|
| 42 |
-
-
|
|
|
|
| 43 |
- Shows model extents, face count, vertex count, and watertight status
|
| 44 |
-
-
|
| 45 |
- Lets you choose layer height and XY pixel size
|
| 46 |
- Produces one `.tif` image per slice
|
| 47 |
- Encodes material as black (`0`) and empty space as white (`255`) in each TIFF slice
|
| 48 |
- Lets you step through the slice stack in the browser
|
| 49 |
- Exports a ZIP containing the generated TIFF images
|
| 50 |
- Combines generated stacks into a reference TIFF stack
|
| 51 |
-
- Converts generated TIFF ZIPs into G-code files with pressure, valve, and port settings per shape
|
| 52 |
- Offers two G-code generation options: **Use G1 for all moves** (no rapid travel command) and **Use Reference Stack for motion** (all shapes share one nozzle path; each dispenses only its own geometry)
|
| 53 |
-
- Calculates X/Y nozzle spacing from
|
| 54 |
-
- Previews
|
| 55 |
-
- Visualizes generated or uploaded G-code tool paths, with the source selectable from
|
| 56 |
- Renders the tool path as a fast line plot or an animated 3D tube plot (play/pause, speed, scrub, frame-step, nozzle marker)
|
| 57 |
-
- Plots
|
| 58 |
|
| 59 |
## Behavior and Implementation Notes
|
| 60 |
|
|
@@ -93,14 +94,14 @@ The parser also handles standard slicer G-code: single-axis and Z-only moves, ax
|
|
| 93 |
|
| 94 |
### G-code Visualization
|
| 95 |
|
| 96 |
-
The G-code visualization tab renders
|
| 97 |
|
| 98 |
- **Line Plot** — fast thin scatter lines (print and travel), with color/opacity controls.
|
| 99 |
- **Tube Plot with Animation** — mm-width filament tubes (circular, capped, lit) with a client-side build animation (play/pause, speed, scrub, frame-step) and a moving nozzle marker. Filament/travel widths default to the layer height and its quarter.
|
| 100 |
|
| 101 |
### Parallel Printing Visualization
|
| 102 |
|
| 103 |
-
The fourth tab plots
|
| 104 |
|
| 105 |
It can also **export the animation as a GIF**, rendered server-side with Matplotlib (the `Agg` CPU backend — no WebGL, no headless browser, and no `ffmpeg`, so it works locally and on Hugging Face). The GIF is line-style with faint grey travel and white, black-outlined nozzle markers drawn on top; controls cover duration, frames per second, elevation/azimuth viewing angle, and travel opacity (0 hides travel).
|
| 106 |
|
|
|
|
| 10 |
|
| 11 |
# STL to G-Code Gradio App
|
| 12 |
|
| 13 |
+
This project provides a Gradio app that takes any number of uploaded STL files, shows a selected-shape 3D viewer, slices each model along the Z axis, saves slices as TIFF images, generates G-code from those TIFF stacks, previews the resulting tool path (a fast line plot or an animated 3D tube plot), and can visualize the shapes printing in parallel and export that animation as a GIF.
|
| 14 |
|
| 15 |
## Prerequisites
|
| 16 |
|
|
|
|
| 37 |
|
| 38 |
## What the app does
|
| 39 |
|
| 40 |
+
- Uploads any number of `.stl` files with a single multi-file uploader
|
| 41 |
+
- Loads bundled sample STL files and merges them with already uploaded STLs
|
| 42 |
+
- Syncs the uploaded STL list back into Shape Settings if the table and uploader get out of step
|
| 43 |
+
- Shows an interactive selected-shape 3D viewer for rotating each model
|
| 44 |
- Shows model extents, face count, vertex count, and watertight status
|
| 45 |
+
- Scales loaded STLs from editable target X/Y/Z dimensions in the Shape Settings table; new rows default to the STL's original dimensions, **Reset Dimensions** restores them, and **Keep Proportions** updates the other target sides from the edited side
|
| 46 |
- Lets you choose layer height and XY pixel size
|
| 47 |
- Produces one `.tif` image per slice
|
| 48 |
- Encodes material as black (`0`) and empty space as white (`255`) in each TIFF slice
|
| 49 |
- Lets you step through the slice stack in the browser
|
| 50 |
- Exports a ZIP containing the generated TIFF images
|
| 51 |
- Combines generated stacks into a reference TIFF stack
|
| 52 |
+
- Converts generated TIFF ZIPs into G-code files with pressure, valve, and port settings per shape from the Shape Settings table
|
| 53 |
- Offers two G-code generation options: **Use G1 for all moves** (no rapid travel command) and **Use Reference Stack for motion** (all shapes share one nozzle path; each dispenses only its own geometry)
|
| 54 |
+
- Calculates X/Y nozzle spacing from an editable adjacent-pair spacing table, then visualizes the resulting nozzle layout
|
| 55 |
+
- Previews selected generated G-code inline
|
| 56 |
+
- Visualizes generated or uploaded G-code tool paths, with the source selectable from any active generated shape or an uploaded file
|
| 57 |
- Renders the tool path as a fast line plot or an animated 3D tube plot (play/pause, speed, scrub, frame-step, nozzle marker)
|
| 58 |
+
- Plots the generated shapes using the configured nozzle spacing and animates them printing in parallel, with a server-side GIF export of that animation
|
| 59 |
|
| 60 |
## Behavior and Implementation Notes
|
| 61 |
|
|
|
|
| 94 |
|
| 95 |
### G-code Visualization
|
| 96 |
|
| 97 |
+
The G-code visualization tab renders generated shape G-code or an uploaded `.txt`, `.gcode`, or `.nc` file. It parses `G0`/`G1` movement lines, supports relative (`G91`) and absolute (`G90`) positioning, and offers two render modes:
|
| 98 |
|
| 99 |
- **Line Plot** — fast thin scatter lines (print and travel), with color/opacity controls.
|
| 100 |
- **Tube Plot with Animation** — mm-width filament tubes (circular, capped, lit) with a client-side build animation (play/pause, speed, scrub, frame-step) and a moving nozzle marker. Filament/travel widths default to the layer height and its quarter.
|
| 101 |
|
| 102 |
### Parallel Printing Visualization
|
| 103 |
|
| 104 |
+
The fourth tab plots the generated shapes' G-code at once using the nozzle spacing configured on the TIFF-to-G-code tab, each in its own color. Like the visualization tab it has a fast **Line Plot** and an animated **Tube Plot**; the animation advances all parts on a shared cumulative-path-length timeline, so a shorter part finishes first.
|
| 105 |
|
| 106 |
It can also **export the animation as a GIF**, rendered server-side with Matplotlib (the `Agg` CPU backend — no WebGL, no headless browser, and no `ffmpeg`, so it works locally and on Hugging Face). The GIF is line-style with faint grey travel and white, black-outlined nozzle markers drawn on top; controls cover duration, frames per second, elevation/azimuth viewing angle, and travel opacity (0 hides travel).
|
| 107 |
|
app.py
CHANGED
|
The diff for this file is too large to render.
See raw diff
|
|
|
tests/test_nozzle_spacing.py
CHANGED
|
@@ -3,7 +3,15 @@ from __future__ import annotations
|
|
| 3 |
import numpy as np
|
| 4 |
|
| 5 |
from app import (
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6 |
_format_nozzle_spacing_status,
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7 |
_resolve_nozzle_layout,
|
| 8 |
)
|
| 9 |
|
|
@@ -82,6 +90,43 @@ def test_same_spacing_reuses_first_pair_values_for_second_pair() -> None:
|
|
| 82 |
assert spacings[1] == {"from": 2, "to": 3, "dx": 7.0, "dy": 2.0}
|
| 83 |
|
| 84 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 85 |
def test_spacing_status_lists_all_nozzle_pair_distances() -> None:
|
| 86 |
parts = [
|
| 87 |
_part(1, ((0.0, 0.0, 0.0), (10.0, 10.0, 1.0))),
|
|
@@ -103,3 +148,115 @@ def test_spacing_status_lists_all_nozzle_pair_distances() -> None:
|
|
| 103 |
assert "Nozzle 1 -> 2: Delta X 13.00 mm, Delta Y 2.00 mm" in status
|
| 104 |
assert "Nozzle 1 -> 3: Delta X 20.00 mm, Delta Y 4.00 mm" in status
|
| 105 |
assert "Nozzle 2 -> 3: Delta X 7.00 mm, Delta Y 2.00 mm" in status
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3 |
import numpy as np
|
| 4 |
|
| 5 |
from app import (
|
| 6 |
+
ADVANCED_NOZZLE_SPACING_HEADERS,
|
| 7 |
+
SCALE_MODE_UNIFORM_FACTOR,
|
| 8 |
+
SIMPLE_NOZZLE_SPACING_HEADERS,
|
| 9 |
+
delete_shape_from_settings,
|
| 10 |
_format_nozzle_spacing_status,
|
| 11 |
+
_shape_settings_rows,
|
| 12 |
+
_spacing_args_from_table,
|
| 13 |
+
_spacing_table_update,
|
| 14 |
+
normalize_shape_dimensions_for_mode,
|
| 15 |
_resolve_nozzle_layout,
|
| 16 |
)
|
| 17 |
|
|
|
|
| 90 |
assert spacings[1] == {"from": 2, "to": 3, "dx": 7.0, "dy": 2.0}
|
| 91 |
|
| 92 |
|
| 93 |
+
def test_individual_spacing_has_no_fixed_shape_limit() -> None:
|
| 94 |
+
parts = [
|
| 95 |
+
_part(1, ((0.0, 0.0, 0.0), (10.0, 10.0, 1.0))),
|
| 96 |
+
_part(2, ((0.0, 0.0, 0.0), (4.0, 10.0, 1.0))),
|
| 97 |
+
_part(3, ((0.0, 0.0, 0.0), (6.0, 10.0, 1.0))),
|
| 98 |
+
_part(4, ((0.0, 0.0, 0.0), (2.0, 10.0, 1.0))),
|
| 99 |
+
_part(5, ((0.0, 0.0, 0.0), (3.0, 10.0, 1.0))),
|
| 100 |
+
_part(6, ((0.0, 0.0, 0.0), (4.0, 10.0, 1.0))),
|
| 101 |
+
_part(7, ((0.0, 0.0, 0.0), (5.0, 10.0, 1.0))),
|
| 102 |
+
]
|
| 103 |
+
|
| 104 |
+
offsets, spacings = _resolve_nozzle_layout(
|
| 105 |
+
parts,
|
| 106 |
+
False,
|
| 107 |
+
1.0,
|
| 108 |
+
0.0,
|
| 109 |
+
2.0,
|
| 110 |
+
3.0,
|
| 111 |
+
4.0,
|
| 112 |
+
-1.0,
|
| 113 |
+
6.0,
|
| 114 |
+
2.0,
|
| 115 |
+
1.0,
|
| 116 |
+
1.0,
|
| 117 |
+
-3.0,
|
| 118 |
+
0.5,
|
| 119 |
+
)
|
| 120 |
+
|
| 121 |
+
np.testing.assert_allclose(offsets[1], (0.0, 0.0))
|
| 122 |
+
np.testing.assert_allclose(offsets[2], (11.0, 0.0))
|
| 123 |
+
np.testing.assert_allclose(offsets[3], (17.0, 3.0))
|
| 124 |
+
np.testing.assert_allclose(offsets[4], (27.0, 2.0))
|
| 125 |
+
np.testing.assert_allclose(offsets[7], (40.0, 5.5))
|
| 126 |
+
assert spacings[2] == {"from": 3, "to": 4, "dx": 10.0, "dy": -1.0}
|
| 127 |
+
assert spacings[5] == {"from": 6, "to": 7, "dx": 1.0, "dy": 0.5}
|
| 128 |
+
|
| 129 |
+
|
| 130 |
def test_spacing_status_lists_all_nozzle_pair_distances() -> None:
|
| 131 |
parts = [
|
| 132 |
_part(1, ((0.0, 0.0, 0.0), (10.0, 10.0, 1.0))),
|
|
|
|
| 148 |
assert "Nozzle 1 -> 2: Delta X 13.00 mm, Delta Y 2.00 mm" in status
|
| 149 |
assert "Nozzle 1 -> 3: Delta X 20.00 mm, Delta Y 4.00 mm" in status
|
| 150 |
assert "Nozzle 2 -> 3: Delta X 7.00 mm, Delta Y 2.00 mm" in status
|
| 151 |
+
|
| 152 |
+
|
| 153 |
+
def test_keep_proportions_uses_most_recently_edited_dimension() -> None:
|
| 154 |
+
records = [
|
| 155 |
+
{
|
| 156 |
+
"idx": 1,
|
| 157 |
+
"name": "wide_part",
|
| 158 |
+
"stl_path": "wide_part.stl",
|
| 159 |
+
"original_x": 10.0,
|
| 160 |
+
"original_y": 20.0,
|
| 161 |
+
"original_z": 5.0,
|
| 162 |
+
"target_x": 10.0,
|
| 163 |
+
"target_y": 20.0,
|
| 164 |
+
"target_z": 5.0,
|
| 165 |
+
"pressure": 25.0,
|
| 166 |
+
"valve": 4,
|
| 167 |
+
"port": 1,
|
| 168 |
+
"color": "#000000",
|
| 169 |
+
"last_scaled_axis": "target_x",
|
| 170 |
+
}
|
| 171 |
+
]
|
| 172 |
+
settings = _shape_settings_rows(records)
|
| 173 |
+
settings[0][3] = 50.0
|
| 174 |
+
|
| 175 |
+
updated_records, updated_settings = normalize_shape_dimensions_for_mode(
|
| 176 |
+
records,
|
| 177 |
+
settings,
|
| 178 |
+
SCALE_MODE_UNIFORM_FACTOR,
|
| 179 |
+
)
|
| 180 |
+
|
| 181 |
+
assert updated_records[0]["last_scaled_axis"] == "target_y"
|
| 182 |
+
assert updated_settings[0][2:5] == [25.0, 50.0, 12.5]
|
| 183 |
+
|
| 184 |
+
|
| 185 |
+
def test_simple_spacing_table_uses_one_shared_spacing_row() -> None:
|
| 186 |
+
records = [
|
| 187 |
+
{"idx": 1, "name": "first"},
|
| 188 |
+
{"idx": 2, "name": "second"},
|
| 189 |
+
{"idx": 3, "name": "third"},
|
| 190 |
+
]
|
| 191 |
+
|
| 192 |
+
update = _spacing_table_update(records, [["Shape 1", "Shape 2", 7.0, -1.5]], False)
|
| 193 |
+
gap12x, gap12y, gap23x, gap23y, extra = _spacing_args_from_table(update["value"], False)
|
| 194 |
+
|
| 195 |
+
assert update["headers"] == SIMPLE_NOZZLE_SPACING_HEADERS
|
| 196 |
+
assert update["value"] == [["Same spacing", "All neighboring shapes", 7.0, -1.5]]
|
| 197 |
+
assert (gap12x, gap12y, gap23x, gap23y, extra) == (7.0, -1.5, 7.0, -1.5, [])
|
| 198 |
+
|
| 199 |
+
|
| 200 |
+
def test_advanced_spacing_table_uses_named_shape_pairs() -> None:
|
| 201 |
+
records = [
|
| 202 |
+
{"idx": 1, "name": "first"},
|
| 203 |
+
{"idx": 2, "name": "second"},
|
| 204 |
+
{"idx": 3, "name": "third"},
|
| 205 |
+
]
|
| 206 |
+
|
| 207 |
+
update = _spacing_table_update(
|
| 208 |
+
records,
|
| 209 |
+
[["Same spacing", "All neighboring shapes", 4.0, 0.5], ["ignored", "ignored", 8.0, 1.0]],
|
| 210 |
+
True,
|
| 211 |
+
)
|
| 212 |
+
gap12x, gap12y, gap23x, gap23y, extra = _spacing_args_from_table(update["value"], True)
|
| 213 |
+
|
| 214 |
+
assert update["headers"] == ADVANCED_NOZZLE_SPACING_HEADERS
|
| 215 |
+
assert update["value"][0][:2] == ["Shape 1: first", "Shape 2: second"]
|
| 216 |
+
assert update["value"][1][:2] == ["Shape 2: second", "Shape 3: third"]
|
| 217 |
+
assert (gap12x, gap12y, gap23x, gap23y, extra) == (4.0, 0.5, 8.0, 1.0, [])
|
| 218 |
+
|
| 219 |
+
|
| 220 |
+
def test_delete_shape_reindexes_without_losing_shape_data() -> None:
|
| 221 |
+
class Event:
|
| 222 |
+
index = (1, 9)
|
| 223 |
+
|
| 224 |
+
records = [
|
| 225 |
+
{"idx": 1, "name": "first", "stl_path": "first.stl", "target_x": 10.0, "target_y": 11.0, "target_z": 12.0, "pressure": 25, "valve": 4, "port": 1, "color": "#111111"},
|
| 226 |
+
{"idx": 2, "name": "middle", "stl_path": "middle.stl", "target_x": 20.0, "target_y": 21.0, "target_z": 22.0, "pressure": 30, "valve": 5, "port": 2, "color": "#222222"},
|
| 227 |
+
{"idx": 3, "name": "last", "stl_path": "last.stl", "target_x": 30.0, "target_y": 31.0, "target_z": 32.0, "pressure": 35, "valve": 6, "port": 3, "color": "#333333"},
|
| 228 |
+
]
|
| 229 |
+
|
| 230 |
+
outputs = delete_shape_from_settings(records, _shape_settings_rows(records), None, False, 0.0, Event())
|
| 231 |
+
updated_records = outputs[1]
|
| 232 |
+
updated_settings = outputs[2]
|
| 233 |
+
|
| 234 |
+
assert [record["idx"] for record in updated_records] == [1, 2]
|
| 235 |
+
assert [record["stl_path"] for record in updated_records] == ["first.stl", "last.stl"]
|
| 236 |
+
assert [record["target_x"] for record in updated_records] == [10.0, 30.0]
|
| 237 |
+
assert updated_settings[1][0] == 2
|
| 238 |
+
assert updated_settings[1][1] == "last"
|
| 239 |
+
assert updated_settings[1][2:5] == [30.0, 31.0, 32.0]
|
| 240 |
+
|
| 241 |
+
|
| 242 |
+
def test_delete_shape_cooldown_blocks_immediate_second_delete() -> None:
|
| 243 |
+
class Event:
|
| 244 |
+
index = (1, 9)
|
| 245 |
+
|
| 246 |
+
records = [
|
| 247 |
+
{"idx": 1, "name": "first", "stl_path": "first.stl", "target_x": 10.0, "target_y": 11.0, "target_z": 12.0, "pressure": 25, "valve": 4, "port": 1, "color": "#111111"},
|
| 248 |
+
{"idx": 2, "name": "middle", "stl_path": "middle.stl", "target_x": 20.0, "target_y": 21.0, "target_z": 22.0, "pressure": 30, "valve": 5, "port": 2, "color": "#222222"},
|
| 249 |
+
{"idx": 3, "name": "last", "stl_path": "last.stl", "target_x": 30.0, "target_y": 31.0, "target_z": 32.0, "pressure": 35, "valve": 6, "port": 3, "color": "#333333"},
|
| 250 |
+
]
|
| 251 |
+
|
| 252 |
+
first_outputs = delete_shape_from_settings(records, _shape_settings_rows(records), None, False, 0.0, Event())
|
| 253 |
+
second_outputs = delete_shape_from_settings(
|
| 254 |
+
first_outputs[1],
|
| 255 |
+
first_outputs[2],
|
| 256 |
+
None,
|
| 257 |
+
False,
|
| 258 |
+
first_outputs[-1],
|
| 259 |
+
Event(),
|
| 260 |
+
)
|
| 261 |
+
|
| 262 |
+
assert [record["name"] for record in second_outputs[1]] == ["first", "last"]
|