CyGuy8 commited on
Commit
5a79264
·
0 Parent(s):

Initial ParallelPrint import

Browse files
.codex/config.toml ADDED
@@ -0,0 +1 @@
 
 
1
+ sandbox_mode = "workspace-write"
.codex/environments/environment.toml ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # THIS IS AUTOGENERATED. DO NOT EDIT MANUALLY
2
+ version = 1
3
+ name = "STLtoGCode"
4
+
5
+ [setup]
6
+ script = ""
7
+
8
+ [[actions]]
9
+ name = "Run"
10
+ icon = "run"
11
+ command = "uv run gradio app.py"
.gitattributes ADDED
@@ -0,0 +1 @@
 
 
1
+ *.stl filter=lfs diff=lfs merge=lfs -text
.gitignore ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ .venv/
2
+ __pycache__/
3
+ .pytest_cache/
4
+ gradio.out.log
5
+ gradio.err.log
6
+ .claude/
AGENTS.md ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Project Instructions
2
+
3
+ ## Python Environment
4
+
5
+ - Always use `uv` to run Python scripts and manage dependencies — never use `pip` or `python` directly
6
+ - Run scripts with `uv run python script.py` instead of `python script.py`
7
+ - Install packages with `uv add package-name` instead of `pip install`
8
+ - To run one-off commands: `uv run <command>`
9
+ - The project uses `uv` for virtual environment management; do not create venvs manually with `python -m venv`
10
+
11
+ ## Common Commands
12
+
13
+ - `uv run python script.py` — run a script
14
+ - `uv add <package>` — add a dependency
15
+ - `uv sync` — install all dependencies from lockfile
16
+ - `uv run pytest` — run tests
17
+
18
+ ## Dependencies
19
+
20
+ - After any `uv add` / `uv remove`, regenerate the Hugging Face requirements file or the deploy will not get the change — the Space installs from `requirements.txt`, not from `uv.lock`:
21
+ `uv export --format requirements.txt --no-hashes --no-dev --frozen --output-file requirements.txt`
22
+ - Commit `pyproject.toml`, `uv.lock`, and `requirements.txt` together when dependencies change.
23
+
24
+ ## Rendering Constraints (Hugging Face)
25
+
26
+ - Hugging Face Spaces run headless with no GPU/WebGL and no guaranteed `ffmpeg`.
27
+ - For any server-side image or animation rendering, use CPU-only paths: Matplotlib's `Agg` backend, GIF via Pillow.
28
+ - Avoid `kaleido` / Plotly static-image export (3D is extremely slow headless) and ffmpeg-dependent MP4 output. Both were tried and proved unreliable here; the parallel-print GIF export uses Matplotlib `Agg` instead.
29
+
30
+ ## Local Tooling
31
+
32
+ - `.claude/` (e.g. `launch.json` for the local preview server) is gitignored and not deployed.
33
+
34
+ ## Hugging Face Deployment
35
+
36
+ - `.stl` files must be tracked by Git LFS (`*.stl filter=lfs` in `.gitattributes`)
37
+ - Verify Git LFS is available before push: `git lfs version`
38
+ - Confirm tracked LFS files: `git lfs ls-files`
39
+ - Standard push sequence: `git push origin main` then `git push hf-space main`
40
+ - If Hugging Face rejects binaries, re-check `.gitattributes` and LFS status before retrying
41
+ - `git lfs migrate` rewrites history; only use it intentionally and coordinate with collaborators first
CLAUDE.md ADDED
@@ -0,0 +1 @@
 
 
1
+ @AGENTS.md
README.md ADDED
@@ -0,0 +1,148 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: STL to G-Code Slicer
3
+ sdk: gradio
4
+ sdk_version: 6.10.0
5
+ python_version: "3.12"
6
+ app_file: app.py
7
+ fullWidth: true
8
+ short_description: Upload STLs, export TIFF stacks, and generate G-code.
9
+ ---
10
+
11
+ # STL to G-Code Gradio App
12
+
13
+ This project provides a Gradio app that takes up to three uploaded STL files, shows interactive 3D viewers, 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 all three shapes printing in parallel and export that animation as a GIF.
14
+
15
+ ## Prerequisites
16
+
17
+ - Python 3.11 or newer for local development
18
+ - `uv` for dependency management and script execution
19
+ - Git LFS for the bundled `.stl` sample files
20
+
21
+ ## Run
22
+
23
+ ```powershell
24
+ uv sync --all-groups
25
+ uv run python app.py
26
+ ```
27
+
28
+ For reload mode during development, run:
29
+
30
+ ```powershell
31
+ uv run gradio app.py
32
+ ```
33
+
34
+ When `app.py` changes, Gradio will automatically rerun the file and refresh the demo.
35
+
36
+ Then open the local Gradio URL in your browser, upload STL files or load the bundled samples, and generate the TIFF stacks.
37
+
38
+ ## What the app does
39
+
40
+ - Uploads up to three `.stl` files
41
+ - Loads bundled sample STL files
42
+ - Shows interactive 3D viewers for rotating each model
43
+ - Shows model extents, face count, vertex count, and watertight status
44
+ - Optionally scales loaded STLs per shape, either by fitting target X/Y/Z dimensions or by applying one uniform scale factor to all axes
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
+ - Previews each shape's generated G-code inline (text boxes under the downloads)
54
+ - Visualizes generated or uploaded G-code tool paths, with the source selectable from Shape 1/2/3 or an uploaded file
55
+ - Renders the tool path as a fast line plot or an animated 3D tube plot (play/pause, speed, scrub, frame-step, nozzle marker)
56
+ - Plots all three shapes side by side (offset in X) and animates them printing in parallel, with a server-side GIF export of that animation
57
+
58
+ ## Behavior and Implementation Notes
59
+
60
+ ### Reference TIFF Stack Alignment
61
+
62
+ When you click **Generate Reference TIFF Stack**, the app combines available TIFF stacks layer-by-layer.
63
+
64
+ - If source TIFFs have different dimensions, each layer is placed on a canvas using the largest width and height.
65
+ - Layers are centered in X and Y before merging.
66
+ - Pixel merge uses a black-wins rule: a pixel is black in the reference if any source has black at that pixel.
67
+ - Alignment is centered image placement, not bottom-left anchoring.
68
+ - If image-size differences are odd, centering may produce a one-pixel shift due to integer rounding.
69
+
70
+ ### G-code XY Step Size
71
+
72
+ - G-code generation uses the slicer's `Pixel Size/Fill Width` for XY step distance by passing `fil_width=pixel_size` into `generate_snake_path_gcode()`.
73
+
74
+ ### G-code Output
75
+
76
+ - Generated G-code starts in relative coordinate mode (`G91`).
77
+ - `G0` is travel and `G1` is print/feed.
78
+ - The app generates print/feed moves from material pixels and travel moves between material regions.
79
+ - Generated files include pressure preset commands and WAGO valve commands based on the selected pressure, valve, and port.
80
+ - Pressure increases by `0.1` psi per layer by default.
81
+ - **Use G1 for all moves**: when enabled, every movement line is emitted as `G1` (no `G0` rapid travel); the WAGO valve still marks where material is dispensed. Applies to all shapes.
82
+ - **Use Reference Stack for motion**: when enabled, every shape's snake-path *motion* is taken from the combined Reference TIFF Stack while each shape's *valve/dispensing* comes from its own slices — so parallel print heads share one synchronized nozzle path and each deposits only its own geometry. Requires generating the Reference TIFF Stack on the first tab first; shapes are skipped with a message if it is missing.
83
+
84
+ ### Print vs Travel Classification
85
+
86
+ When parsing G-code for visualization, the app decides print vs travel as follows:
87
+
88
+ - If the file contains `WAGO_ValveCommands`, the valve state (open/closed) determines print vs travel. This overrides `G0`/`G1`, because some generators emit every move as `G1`, or invert `G0`/`G1` relative to the valve.
89
+ - Otherwise it falls back to the convention `G1` = print, `G0` = travel.
90
+
91
+ The parser also handles standard slicer G-code: single-axis and Z-only moves, axes in any order, and `F`/`E` tokens (feed rate, extrusion) are ignored for geometry.
92
+
93
+ ### G-code Visualization
94
+
95
+ The G-code visualization tab renders the generated Shape 1/2/3 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:
96
+
97
+ - **Line Plot** — fast thin scatter lines (print and travel), with color/opacity controls.
98
+ - **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.
99
+
100
+ ### Parallel Printing Visualization
101
+
102
+ The fourth tab plots all three shapes' G-code at once, offset along X so they do not overlap, 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.
103
+
104
+ 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).
105
+
106
+ ## Dependency Updates
107
+
108
+ The parallel-print GIF export requires `matplotlib` (rendered with the CPU `Agg` backend so it runs on Hugging Face).
109
+
110
+ When dependencies change, update the lockfile and refresh the Hugging Face `requirements.txt` export — the Space installs from `requirements.txt`, not from the lockfile:
111
+
112
+ ```powershell
113
+ uv sync --all-groups
114
+ uv export --format requirements.txt --no-hashes --no-dev --frozen --output-file requirements.txt
115
+ ```
116
+
117
+ ## Test
118
+
119
+ ```powershell
120
+ uv run pytest
121
+ ```
122
+
123
+ ## Hugging Face Deployment
124
+
125
+ This repository tracks `.stl` files with Git LFS (see `.gitattributes`).
126
+
127
+ Before your first push on a machine:
128
+
129
+ ```powershell
130
+ git lfs install
131
+ git lfs pull
132
+ ```
133
+
134
+ Recommended push flow:
135
+
136
+ ```powershell
137
+ git push origin main
138
+ git push hf-space main
139
+ ```
140
+
141
+ If Hugging Face rejects a push for binary files, verify LFS setup first:
142
+
143
+ ```powershell
144
+ git lfs version
145
+ git lfs ls-files
146
+ ```
147
+
148
+ Warning: `git lfs migrate` rewrites commit history. Use it only when you intentionally want history rewritten and all collaborators are aligned.
app.py ADDED
The diff for this file is too large to render. See raw diff
 
gcode_viewer.py ADDED
@@ -0,0 +1,807 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import math
4
+ import re
5
+ from pathlib import Path
6
+
7
+ import numpy as np
8
+ import plotly.graph_objects as go
9
+
10
+
11
+ # A move is any G0/G1 (or G00/G01) line. Coordinates may list any subset of
12
+ # X/Y/Z in any order, mixed with other tokens (F feed rate, E extrusion); only
13
+ # the axes named on a line change. This matches standard slicer/firmware G-code
14
+ # as well as this app's own always-paired "X Y" output.
15
+ _CMD_RE = re.compile(r"^G0*([01])(?![0-9])", re.IGNORECASE)
16
+ _AXIS_RE = re.compile(r"([XYZ])\s*([-+]?(?:\d*\.\d+|\d+\.?))", re.IGNORECASE)
17
+ # Pneumatic valve toggle: WAGO_ValveCommands(<valve>, <0=close|1=open>). Some
18
+ # generators emit every move as G1 and convey extrusion only through the valve.
19
+ _VALVE_RE = re.compile(r"WAGO_ValveCommands\(\s*(\d+)\s*,\s*(\d+)\s*\)", re.IGNORECASE)
20
+
21
+
22
+ def parse_gcode_path(gcode_text: str) -> dict:
23
+ relative = True
24
+ x = y = z = 0.0
25
+
26
+ # Decide how to tell print from travel. The valve physically controls
27
+ # material flow, so when valve commands are present they are the ground
28
+ # truth: valve open = printing, valve closed = travel. This is correct for
29
+ # the app's own output (where valve state and G1/G0 agree) and for external
30
+ # generators whose G0/G1 labels are unreliable — some omit G0 entirely
31
+ # (every move G1), others invert G0/G1 relative to the valve. Only fall back
32
+ # to the G1 = print / G0 = travel convention when there is no valve to read.
33
+ use_valve = bool(_VALVE_RE.search(gcode_text))
34
+ open_valves: set[str] = set()
35
+
36
+ print_segments: list[list[tuple[float, float, float]]] = []
37
+ travel_segments: list[list[tuple[float, float, float]]] = []
38
+ moves: list[dict] = []
39
+ current_kind: str | None = None
40
+ current_segment: list[tuple[float, float, float]] = []
41
+
42
+ all_x: list[float] = []
43
+ all_y: list[float] = []
44
+ all_z: list[float] = []
45
+
46
+ def flush_segment() -> None:
47
+ nonlocal current_segment, current_kind
48
+ if current_segment and current_kind is not None:
49
+ target = print_segments if current_kind == "print" else travel_segments
50
+ target.append(current_segment)
51
+ current_segment = []
52
+ current_kind = None
53
+
54
+ for raw_line in gcode_text.splitlines():
55
+ line = raw_line.strip()
56
+ if not line:
57
+ flush_segment()
58
+ continue
59
+
60
+ # Drop inline comments so axis letters in comment text are never read.
61
+ code = line.split(";", 1)[0].strip()
62
+ upper = code.upper()
63
+ if upper.startswith("G90"):
64
+ relative = False
65
+ continue
66
+ if upper.startswith("G91"):
67
+ relative = True
68
+ continue
69
+
70
+ cmd_match = _CMD_RE.match(code)
71
+ if not cmd_match:
72
+ # Track valve open/close so all-G1 files can be split into
73
+ # print (valve open) and travel (valve closed) runs.
74
+ valve_match = _VALVE_RE.search(code)
75
+ if valve_match:
76
+ valve, state = valve_match.group(1), valve_match.group(2)
77
+ if state == "0":
78
+ open_valves.discard(valve)
79
+ else:
80
+ open_valves.add(valve)
81
+ flush_segment()
82
+ continue
83
+
84
+ axes = {a.upper(): float(v) for a, v in _AXIS_RE.findall(code)}
85
+ if not axes:
86
+ # A G0/G1 with no coordinates (e.g. "G1 F1800") is not a move.
87
+ flush_segment()
88
+ continue
89
+
90
+ prev_pos = (x, y, z)
91
+
92
+ if relative:
93
+ x += axes.get("X", 0.0)
94
+ y += axes.get("Y", 0.0)
95
+ z += axes.get("Z", 0.0)
96
+ else:
97
+ if "X" in axes:
98
+ x = axes["X"]
99
+ if "Y" in axes:
100
+ y = axes["Y"]
101
+ if "Z" in axes:
102
+ z = axes["Z"]
103
+
104
+ if use_valve:
105
+ kind = "print" if open_valves else "travel"
106
+ else:
107
+ kind = "print" if cmd_match.group(1) == "1" else "travel"
108
+ moves.append({"kind": kind, "start": prev_pos, "end": (x, y, z)})
109
+
110
+ if kind != current_kind:
111
+ flush_segment()
112
+ current_kind = kind
113
+ current_segment = [prev_pos]
114
+
115
+ current_segment.append((x, y, z))
116
+ all_x.append(x)
117
+ all_y.append(y)
118
+ all_z.append(z)
119
+
120
+ flush_segment()
121
+
122
+ if all_x:
123
+ bounds = (
124
+ (min(all_x), min(all_y), min(all_z)),
125
+ (max(all_x), max(all_y), max(all_z)),
126
+ )
127
+ else:
128
+ bounds = ((0.0, 0.0, 0.0), (0.0, 0.0, 0.0))
129
+
130
+ # Assign a layer index to every move. Layers are the distinct Z heights at
131
+ # which printing (G1) happens; travel moves (including the Z lift between
132
+ # layers) are attributed to the layer of the next print move so a layer's
133
+ # timeline starts with the approach travel and ends with its last print.
134
+ print_z = sorted({round(m["end"][2], 6) for m in moves if m["kind"] == "print"})
135
+ z_to_layer = {z: i for i, z in enumerate(print_z)}
136
+ next_print_layer = len(print_z) - 1 if print_z else 0
137
+ for move in reversed(moves):
138
+ if move["kind"] == "print":
139
+ next_print_layer = z_to_layer[round(move["end"][2], 6)]
140
+ move["layer"] = next_print_layer
141
+
142
+ return {
143
+ "print_segments": print_segments,
144
+ "travel_segments": travel_segments,
145
+ "moves": moves,
146
+ "layer_count": len(print_z),
147
+ "bounds": bounds,
148
+ "point_count": len(all_x),
149
+ }
150
+
151
+
152
+ def _move_length(move: dict) -> float:
153
+ (x0, y0, z0), (x1, y1, z1) = move["start"], move["end"]
154
+ return math.sqrt((x1 - x0) ** 2 + (y1 - y0) ** 2 + (z1 - z0) ** 2)
155
+
156
+
157
+ def _chronological_trace_arrays(moves: list[dict]) -> dict:
158
+ """Build per-kind polyline arrays with a shared time axis for animation.
159
+
160
+ Each point gets a timestamp equal to the cumulative path length (print +
161
+ travel) at which the nozzle reaches it, so the browser can reveal both
162
+ traces in lockstep by slicing at a time cutoff. Gap markers (None) close a
163
+ trace's polyline whenever the move kind switches and carry the timestamp
164
+ of the segment they terminate.
165
+ """
166
+ arrays: dict[str, dict[str, list]] = {
167
+ "print": {"x": [], "y": [], "z": [], "t": []},
168
+ "travel": {"x": [], "y": [], "z": [], "t": []},
169
+ }
170
+ cum = 0.0
171
+ prev_kind: str | None = None
172
+ layer_end: dict[int, float] = {}
173
+
174
+ for move in moves:
175
+ trace = arrays[move["kind"]]
176
+ if move["kind"] != prev_kind:
177
+ if prev_kind is not None:
178
+ prev_trace = arrays[prev_kind]
179
+ prev_trace["x"].append(None)
180
+ prev_trace["y"].append(None)
181
+ prev_trace["z"].append(None)
182
+ prev_trace["t"].append(cum)
183
+ sx, sy, sz = move["start"]
184
+ trace["x"].append(sx)
185
+ trace["y"].append(sy)
186
+ trace["z"].append(sz)
187
+ trace["t"].append(cum)
188
+ prev_kind = move["kind"]
189
+ cum += _move_length(move)
190
+ ex, ey, ez = move["end"]
191
+ trace["x"].append(ex)
192
+ trace["y"].append(ey)
193
+ trace["z"].append(ez)
194
+ trace["t"].append(cum)
195
+ layer_end[move["layer"]] = cum
196
+
197
+ layer_count = (max(layer_end) + 1) if layer_end else 0
198
+ layer_t_end: list[float] = []
199
+ last = 0.0
200
+ for i in range(layer_count):
201
+ last = max(last, layer_end.get(i, last))
202
+ layer_t_end.append(last)
203
+
204
+ return {
205
+ "print": arrays["print"],
206
+ "travel": arrays["travel"],
207
+ "total_length": cum,
208
+ "layer_t_end": layer_t_end,
209
+ }
210
+
211
+
212
+ def _path_arrays(moves: list[dict]) -> dict:
213
+ """Chronological nozzle positions with cumulative-length timestamps."""
214
+ xs = [moves[0]["start"][0]]
215
+ ys = [moves[0]["start"][1]]
216
+ zs = [moves[0]["start"][2]]
217
+ ts = [0.0]
218
+ cum = 0.0
219
+ for move in moves:
220
+ cum += _move_length(move)
221
+ ex, ey, ez = move["end"]
222
+ xs.append(ex)
223
+ ys.append(ey)
224
+ zs.append(ez)
225
+ ts.append(cum)
226
+ return {"x": xs, "y": ys, "z": zs, "t": ts}
227
+
228
+
229
+ def _build_path_tube(
230
+ moves: list[dict],
231
+ radius: float,
232
+ kind: str = "print",
233
+ sides: int = 12,
234
+ max_rings: int = 5000,
235
+ ) -> dict:
236
+ """Extrude a tube of physical radius along the moves of the given kind.
237
+
238
+ Returns Mesh3d-ready vertex and face arrays. Rings are laid down in
239
+ chronological order and long moves are subdivided, so faces can be
240
+ revealed progressively by slicing the (sorted) per-face timestamps.
241
+ """
242
+ # Group consecutive moves of this kind into continuous runs (global times).
243
+ runs: list[tuple[list, list]] = []
244
+ cur_pts: list | None = None
245
+ cur_ts: list | None = None
246
+ cum = 0.0
247
+ for move in moves:
248
+ length = _move_length(move)
249
+ if move["kind"] == kind:
250
+ if cur_pts is None:
251
+ cur_pts = [move["start"]]
252
+ cur_ts = [cum]
253
+ cur_pts.append(move["end"])
254
+ cur_ts.append(cum + length)
255
+ elif cur_pts is not None:
256
+ runs.append((cur_pts, cur_ts))
257
+ cur_pts = cur_ts = None
258
+ cum += length
259
+ if cur_pts is not None:
260
+ runs.append((cur_pts, cur_ts))
261
+
262
+ total_print = sum(ts[-1] - ts[0] for _pts, ts in runs)
263
+ step = max(radius * 2.0, total_print / max_rings) if total_print > 0 else radius
264
+
265
+ xs: list[float] = []
266
+ ys: list[float] = []
267
+ zs: list[float] = []
268
+ fi: list[int] = []
269
+ fj: list[int] = []
270
+ fk: list[int] = []
271
+ face_t: list[float] = []
272
+ angles = np.linspace(0.0, 2.0 * np.pi, sides, endpoint=False)
273
+ cos_a, sin_a = np.cos(angles), np.sin(angles)
274
+
275
+ for pts, ts in runs:
276
+ # Subdivide long moves so the tube grows smoothly during playback.
277
+ sub_p = [np.asarray(pts[0], dtype=float)]
278
+ sub_t = [ts[0]]
279
+ for a in range(len(pts) - 1):
280
+ p0 = np.asarray(pts[a], dtype=float)
281
+ p1 = np.asarray(pts[a + 1], dtype=float)
282
+ seg = float(np.linalg.norm(p1 - p0))
283
+ pieces = max(1, math.ceil(seg / step))
284
+ for s in range(1, pieces + 1):
285
+ f = s / pieces
286
+ sub_p.append(p0 + (p1 - p0) * f)
287
+ sub_t.append(ts[a] + (ts[a + 1] - ts[a]) * f)
288
+
289
+ points = np.vstack(sub_p)
290
+ n_rings = len(points)
291
+ if n_rings < 2:
292
+ continue
293
+
294
+ # Per-ring tangents (averaged at interior points) and a perpendicular
295
+ # frame; vertical tangents fall back to the X axis for the side vector.
296
+ tangents = np.zeros_like(points)
297
+ tangents[1:-1] = points[2:] - points[:-2]
298
+ tangents[0] = points[1] - points[0]
299
+ tangents[-1] = points[-1] - points[-2]
300
+ norms = np.linalg.norm(tangents, axis=1, keepdims=True)
301
+ norms[norms == 0] = 1.0
302
+ tangents /= norms
303
+
304
+ side_vec = np.cross(tangents, np.array([0.0, 0.0, 1.0]))
305
+ side_norm = np.linalg.norm(side_vec, axis=1)
306
+ vertical = side_norm < 1e-6
307
+ if vertical.any():
308
+ side_vec[vertical] = np.cross(tangents[vertical], np.array([1.0, 0.0, 0.0]))
309
+ side_vec /= np.maximum(np.linalg.norm(side_vec, axis=1, keepdims=True), 1e-12)
310
+ up_vec = np.cross(side_vec, tangents)
311
+
312
+ base = len(xs)
313
+ rings = (
314
+ points[:, None, :]
315
+ + radius * (cos_a[None, :, None] * side_vec[:, None, :]
316
+ + sin_a[None, :, None] * up_vec[:, None, :])
317
+ )
318
+ flat = np.round(rings.reshape(-1, 3), 4)
319
+ xs.extend(flat[:, 0].tolist())
320
+ ys.extend(flat[:, 1].tolist())
321
+ zs.extend(flat[:, 2].tolist())
322
+
323
+ # Center vertices for the end caps that close the tube.
324
+ cap_start = len(xs)
325
+ xs.append(round(float(points[0][0]), 4))
326
+ ys.append(round(float(points[0][1]), 4))
327
+ zs.append(round(float(points[0][2]), 4))
328
+ cap_end = len(xs)
329
+ xs.append(round(float(points[-1][0]), 4))
330
+ ys.append(round(float(points[-1][1]), 4))
331
+ zs.append(round(float(points[-1][2]), 4))
332
+
333
+ t_start = round(sub_t[0], 4)
334
+ t_end = round(sub_t[-1], 4)
335
+
336
+ # Start cap (fan around the first ring).
337
+ for k in range(sides):
338
+ k_next = (k + 1) % sides
339
+ fi.append(cap_start)
340
+ fj.append(base + k_next)
341
+ fk.append(base + k)
342
+ face_t.append(t_start)
343
+
344
+ for r in range(n_rings - 1):
345
+ r0 = base + r * sides
346
+ r1 = r0 + sides
347
+ t_face = round(sub_t[r + 1], 4)
348
+ for k in range(sides):
349
+ k_next = (k + 1) % sides
350
+ fi.extend((r0 + k, r0 + k))
351
+ fj.extend((r1 + k, r1 + k_next))
352
+ fk.extend((r1 + k_next, r0 + k_next))
353
+ face_t.extend((t_face, t_face))
354
+
355
+ # End cap (fan around the last ring).
356
+ last_ring = base + (n_rings - 1) * sides
357
+ for k in range(sides):
358
+ k_next = (k + 1) % sides
359
+ fi.append(cap_end)
360
+ fj.append(last_ring + k)
361
+ fk.append(last_ring + k_next)
362
+ face_t.append(t_end)
363
+
364
+ return {"x": xs, "y": ys, "z": zs, "i": fi, "j": fj, "k": fk, "face_t": face_t}
365
+
366
+
367
+ def _segments_to_xyz(
368
+ segments: list[list[tuple[float, float, float]]],
369
+ ) -> tuple[list[float | None], list[float | None], list[float | None]]:
370
+ xs: list[float | None] = []
371
+ ys: list[float | None] = []
372
+ zs: list[float | None] = []
373
+ for segment in segments:
374
+ for px, py, pz in segment:
375
+ xs.append(px)
376
+ ys.append(py)
377
+ zs.append(pz)
378
+ xs.append(None)
379
+ ys.append(None)
380
+ zs.append(None)
381
+ return xs, ys, zs
382
+
383
+
384
+ def build_toolpath_figure(
385
+ parsed: dict,
386
+ travel_opacity: float = 0.2,
387
+ print_opacity: float = 1.0,
388
+ travel_color: str = "#969696",
389
+ print_color: str = "#1f77b4",
390
+ print_width: float = 0.8,
391
+ travel_width: float = 0.2,
392
+ tube: bool = True,
393
+ ) -> go.Figure:
394
+ moves = parsed.get("moves") or []
395
+
396
+ fig = go.Figure()
397
+ meta = None
398
+
399
+ def add_tube_trace(tube: dict, name: str, color: str, opacity: float) -> None:
400
+ fig.add_trace(
401
+ go.Mesh3d(
402
+ x=tube["x"],
403
+ y=tube["y"],
404
+ z=tube["z"],
405
+ i=tube["i"],
406
+ j=tube["j"],
407
+ k=tube["k"],
408
+ color=color,
409
+ opacity=opacity,
410
+ name=name,
411
+ showlegend=True,
412
+ hoverinfo="skip",
413
+ lighting=dict(ambient=0.55, diffuse=0.8, specular=0.15, roughness=0.6),
414
+ )
415
+ )
416
+
417
+ if moves and tube:
418
+ chrono = _chronological_trace_arrays(moves)
419
+
420
+ # Physical-width tubes along both paths: filament-like rendering whose
421
+ # thickness scales with zoom (widths are diameters in mm).
422
+ travel_tube = _build_path_tube(
423
+ moves, radius=max(travel_width, 0.05) / 2.0, kind="travel"
424
+ )
425
+ if travel_tube["i"]:
426
+ add_tube_trace(travel_tube, "Travel (G0)", travel_color, travel_opacity)
427
+
428
+ print_tube = _build_path_tube(
429
+ moves, radius=max(print_width, 0.05) / 2.0, kind="print"
430
+ )
431
+ if print_tube["i"]:
432
+ add_tube_trace(print_tube, "Print (G1)", print_color, print_opacity)
433
+
434
+ # Nozzle position marker, driven client-side during playback.
435
+ end_x, end_y, end_z = moves[-1]["end"]
436
+ fig.add_trace(
437
+ go.Scatter3d(
438
+ x=[end_x],
439
+ y=[end_y],
440
+ z=[end_z],
441
+ mode="markers",
442
+ name="Nozzle",
443
+ marker=dict(size=5, color="#d62728"),
444
+ showlegend=False,
445
+ hoverinfo="skip",
446
+ )
447
+ )
448
+
449
+ path = _path_arrays(moves)
450
+ meta = {
451
+ "animation": {
452
+ "travel_face_t": travel_tube["face_t"],
453
+ "print_face_t": print_tube["face_t"],
454
+ "path_x": path["x"],
455
+ "path_y": path["y"],
456
+ "path_z": path["z"],
457
+ "path_t": path["t"],
458
+ "layer_t_end": chrono["layer_t_end"],
459
+ "total_length": chrono["total_length"],
460
+ }
461
+ }
462
+ else:
463
+ travel_xs, travel_ys, travel_zs = _segments_to_xyz(parsed["travel_segments"])
464
+ if travel_xs:
465
+ fig.add_trace(
466
+ go.Scatter3d(
467
+ x=travel_xs,
468
+ y=travel_ys,
469
+ z=travel_zs,
470
+ mode="lines",
471
+ name="Travel (G0)",
472
+ opacity=travel_opacity,
473
+ line=dict(color=travel_color, width=2),
474
+ hoverinfo="skip",
475
+ )
476
+ )
477
+ print_xs, print_ys, print_zs = _segments_to_xyz(parsed["print_segments"])
478
+ if print_xs:
479
+ fig.add_trace(
480
+ go.Scatter3d(
481
+ x=print_xs,
482
+ y=print_ys,
483
+ z=print_zs,
484
+ mode="lines",
485
+ name="Print (G1)",
486
+ opacity=print_opacity,
487
+ line=dict(color=print_color, width=4),
488
+ hovertemplate="X=%{x:.2f}<br>Y=%{y:.2f}<br>Z=%{z:.2f}<extra></extra>",
489
+ )
490
+ )
491
+
492
+ (x_min, y_min, z_min), (x_max, y_max, z_max) = parsed["bounds"]
493
+ fig.update_layout(
494
+ meta=meta,
495
+ height=700,
496
+ uirevision="toolpath",
497
+ scene=dict(
498
+ xaxis_title="X (mm)",
499
+ yaxis_title="Y (mm)",
500
+ zaxis_title="Z (mm)",
501
+ aspectmode="data",
502
+ ),
503
+ margin=dict(l=0, r=0, t=30, b=0),
504
+ legend=dict(orientation="h", yanchor="bottom", y=1.0, xanchor="left", x=0.0),
505
+ title=(
506
+ f"Tool path — {len(parsed['print_segments'])} print / "
507
+ f"{len(parsed['travel_segments'])} travel segments "
508
+ f"X[{x_min:.1f},{x_max:.1f}] Y[{y_min:.1f},{y_max:.1f}] "
509
+ f"Z[{z_min:.1f},{z_max:.1f}]"
510
+ ),
511
+ )
512
+ return fig
513
+
514
+
515
+ def build_parallel_figure(
516
+ parts: list[dict],
517
+ gap: float = 5.0,
518
+ filament_width: float = 0.8,
519
+ travel_width: float = 0.2,
520
+ travel_opacity: float = 0.2,
521
+ print_opacity: float = 1.0,
522
+ tube: bool = True,
523
+ ) -> go.Figure:
524
+ """Render several parsed shapes side by side, offset along X so they don't
525
+ overlap. `tube` True draws filament tubes with a shared-time animation
526
+ timeline; False draws fast thin scatter lines (no animation).
527
+
528
+ `parts` is a list of {"idx": int, "color": str, "parsed": dict}. Each part's
529
+ print and travel traces (and, in tube mode, a nozzle marker) are named by idx
530
+ so the client-side animation/recolor can address them.
531
+ """
532
+ fig = go.Figure()
533
+ anim_parts: list[dict] = []
534
+ total_length = 0.0
535
+ rendered = False
536
+ n_parts = 0
537
+ bx0 = by0 = bz0 = float("inf")
538
+ bx1 = by1 = bz1 = float("-inf")
539
+
540
+ running_x = 0.0
541
+ for part in parts:
542
+ idx = part["idx"]
543
+ color = part["color"]
544
+ parsed = part["parsed"]
545
+ moves = parsed.get("moves") or []
546
+ if not moves:
547
+ continue
548
+
549
+ (pxmin, pymin, pzmin), (pxmax, pymax, pzmax) = parsed["bounds"]
550
+ width = pxmax - pxmin
551
+ x_off = running_x - pxmin
552
+ running_x += width + gap
553
+
554
+ if tube:
555
+ print_tube = _build_path_tube(moves, radius=max(filament_width, 0.05) / 2.0, kind="print")
556
+ travel_tube = _build_path_tube(moves, radius=max(travel_width, 0.05) / 2.0, kind="travel")
557
+ path = _path_arrays(moves)
558
+
559
+ px = [v + x_off for v in print_tube["x"]]
560
+ tx = [v + x_off for v in travel_tube["x"]]
561
+ path_x = [v + x_off for v in path["x"]]
562
+
563
+ if travel_tube["i"]:
564
+ fig.add_trace(
565
+ go.Mesh3d(
566
+ x=tx, y=travel_tube["y"], z=travel_tube["z"],
567
+ i=travel_tube["i"], j=travel_tube["j"], k=travel_tube["k"],
568
+ color=color, opacity=travel_opacity, name=f"Travel {idx}",
569
+ showlegend=False, hoverinfo="skip",
570
+ lighting=dict(ambient=0.6, diffuse=0.8, specular=0.1, roughness=0.6),
571
+ )
572
+ )
573
+ if print_tube["i"]:
574
+ fig.add_trace(
575
+ go.Mesh3d(
576
+ x=px, y=print_tube["y"], z=print_tube["z"],
577
+ i=print_tube["i"], j=print_tube["j"], k=print_tube["k"],
578
+ color=color, opacity=print_opacity, name=f"Shape {idx}",
579
+ showlegend=True, hoverinfo="skip",
580
+ lighting=dict(ambient=0.55, diffuse=0.8, specular=0.15, roughness=0.6),
581
+ )
582
+ )
583
+ fig.add_trace(
584
+ go.Scatter3d(
585
+ x=[path_x[-1]], y=[path["y"][-1]], z=[path["z"][-1]],
586
+ mode="markers", name=f"Nozzle {idx}",
587
+ marker=dict(size=4, color=color), showlegend=False, hoverinfo="skip",
588
+ )
589
+ )
590
+
591
+ part_total = path["t"][-1] if path["t"] else 0.0
592
+ total_length = max(total_length, part_total)
593
+ anim_parts.append({
594
+ "printName": f"Shape {idx}",
595
+ "travelName": f"Travel {idx}",
596
+ "nozzleName": f"Nozzle {idx}",
597
+ "print_face_t": print_tube["face_t"],
598
+ "travel_face_t": travel_tube["face_t"],
599
+ "path_x": path_x, "path_y": path["y"], "path_z": path["z"], "path_t": path["t"],
600
+ })
601
+ else:
602
+ t_xs, t_ys, t_zs = _segments_to_xyz(parsed["travel_segments"])
603
+ p_xs, p_ys, p_zs = _segments_to_xyz(parsed["print_segments"])
604
+ t_xs = [v + x_off if v is not None else None for v in t_xs]
605
+ p_xs = [v + x_off if v is not None else None for v in p_xs]
606
+ if t_xs:
607
+ fig.add_trace(
608
+ go.Scatter3d(
609
+ x=t_xs, y=t_ys, z=t_zs, mode="lines", name=f"Travel {idx}",
610
+ opacity=travel_opacity, line=dict(color=color, width=2),
611
+ showlegend=False, hoverinfo="skip",
612
+ )
613
+ )
614
+ if p_xs:
615
+ fig.add_trace(
616
+ go.Scatter3d(
617
+ x=p_xs, y=p_ys, z=p_zs, mode="lines", name=f"Shape {idx}",
618
+ opacity=print_opacity, line=dict(color=color, width=4),
619
+ showlegend=True, hoverinfo="skip",
620
+ )
621
+ )
622
+
623
+ rendered = True
624
+ n_parts += 1
625
+ bx0 = min(bx0, pxmin + x_off); bx1 = max(bx1, pxmax + x_off)
626
+ by0 = min(by0, pymin); by1 = max(by1, pymax)
627
+ bz0 = min(bz0, pzmin); bz1 = max(bz1, pzmax)
628
+
629
+ if not rendered:
630
+ fig.update_layout(height=700)
631
+ return fig
632
+
633
+ meta = {"animation": {"total_length": total_length, "parts": anim_parts}} if anim_parts else None
634
+ pad = max(bx1 - bx0, by1 - by0, bz1 - bz0, 1.0) * 0.05
635
+ fig.update_layout(
636
+ meta=meta,
637
+ height=700,
638
+ uirevision="parallel",
639
+ scene=dict(
640
+ xaxis_title="X (mm)", yaxis_title="Y (mm)", zaxis_title="Z (mm)",
641
+ xaxis_range=[bx0 - pad, bx1 + pad],
642
+ yaxis_range=[by0 - pad, by1 + pad],
643
+ zaxis_range=[bz0 - pad, bz1 + pad],
644
+ aspectmode="data",
645
+ ),
646
+ margin=dict(l=0, r=0, t=30, b=0),
647
+ legend=dict(orientation="h", yanchor="bottom", y=1.0, xanchor="left", x=0.0),
648
+ title=f"Parallel print — {n_parts} part(s)",
649
+ )
650
+ return fig
651
+
652
+
653
+ def build_parallel_gif(
654
+ parts: list[dict],
655
+ out_path: str | Path,
656
+ gap: float = 5.0,
657
+ duration: float = 6.0,
658
+ fps: int = 10,
659
+ travel_opacity: float = 0.15,
660
+ travel_color: str = "#9a9a9a",
661
+ print_width: float = 4.0,
662
+ travel_width: float = 1.5,
663
+ elev: float = 22.0,
664
+ azim: float = -60.0,
665
+ progress_cb=None,
666
+ ) -> Path | None:
667
+ """Render the parallel print as an animated GIF using Matplotlib (CPU Agg
668
+ backend — no WebGL/headless browser, works on Hugging Face).
669
+
670
+ Each part's toolpath is drawn as growing colored lines (print solid, travel
671
+ faint), three parts in parallel on a shared cumulative-length time axis.
672
+ `parts` is a list of {"idx": int, "color": str, "parsed": dict}.
673
+ """
674
+ import matplotlib
675
+ matplotlib.use("Agg")
676
+ import matplotlib.pyplot as plt
677
+ from matplotlib.animation import FuncAnimation, PillowWriter
678
+ from mpl_toolkits.mplot3d.art3d import Line3DCollection
679
+
680
+ pdata: list[dict] = []
681
+ running_x = 0.0
682
+ total_length = 0.0
683
+ bx0 = by0 = bz0 = float("inf")
684
+ bx1 = by1 = bz1 = float("-inf")
685
+
686
+ for part in parts:
687
+ parsed = part["parsed"]
688
+ moves = parsed.get("moves") or []
689
+ if not moves:
690
+ continue
691
+ (pxmin, pymin, pzmin), (pxmax, pymax, pzmax) = parsed["bounds"]
692
+ x_off = running_x - pxmin
693
+ running_x += (pxmax - pxmin) + gap
694
+
695
+ cum = 0.0
696
+ mlist: list[tuple] = []
697
+ for m in moves:
698
+ s = (m["start"][0] + x_off, m["start"][1], m["start"][2])
699
+ e = (m["end"][0] + x_off, m["end"][1], m["end"][2])
700
+ seg_len = math.dist(s, e)
701
+ mlist.append((m["kind"], s, e, cum, cum + seg_len))
702
+ cum += seg_len
703
+ total_length = max(total_length, cum)
704
+ pdata.append({
705
+ "color": part["color"], "moves": mlist,
706
+ "last": mlist[-1][2], "first": mlist[0][1],
707
+ })
708
+
709
+ bx0 = min(bx0, pxmin + x_off); bx1 = max(bx1, pxmax + x_off)
710
+ by0 = min(by0, pymin); by1 = max(by1, pymax)
711
+ bz0 = min(bz0, pzmin); bz1 = max(bz1, pzmax)
712
+
713
+ if not pdata or total_length <= 0:
714
+ return None
715
+
716
+ n_frames = max(2, int(round(duration * fps)))
717
+
718
+ def segs_at(mlist: list[tuple], kind: str, cutoff: float) -> list:
719
+ out = []
720
+ for k, s, e, t0, t1 in mlist:
721
+ if k != kind:
722
+ continue
723
+ if t1 <= cutoff:
724
+ out.append([s, e])
725
+ elif t0 < cutoff:
726
+ f = (cutoff - t0) / (t1 - t0) if t1 > t0 else 1.0
727
+ ei = (s[0] + (e[0] - s[0]) * f, s[1] + (e[1] - s[1]) * f, s[2] + (e[2] - s[2]) * f)
728
+ out.append([s, ei])
729
+ return out
730
+
731
+ def nozzle_at(mlist: list[tuple], cutoff: float) -> tuple:
732
+ last = mlist[0][1]
733
+ for _k, s, e, t0, t1 in mlist:
734
+ if cutoff >= t1:
735
+ last = e
736
+ elif t0 <= cutoff <= t1:
737
+ f = (cutoff - t0) / (t1 - t0) if t1 > t0 else 1.0
738
+ return (s[0] + (e[0] - s[0]) * f, s[1] + (e[1] - s[1]) * f, s[2] + (e[2] - s[2]) * f)
739
+ else:
740
+ return last
741
+ return last
742
+
743
+ fig = plt.figure(figsize=(8, 6), dpi=100)
744
+ ax = fig.add_subplot(111, projection="3d")
745
+ # Honour explicit zorder instead of depth-sorting, so the nozzle markers
746
+ # always draw on top of the toolpath lines.
747
+ try:
748
+ ax.computed_zorder = False
749
+ except Exception:
750
+ pass
751
+ pad = max(bx1 - bx0, by1 - by0, bz1 - bz0, 1.0) * 0.05
752
+ ax.set_xlim(bx0 - pad, bx1 + pad)
753
+ ax.set_ylim(by0 - pad, by1 + pad)
754
+ ax.set_zlim(bz0 - pad, bz1 + pad)
755
+ try:
756
+ ax.set_box_aspect((bx1 - bx0 + 1e-6, by1 - by0 + 1e-6, bz1 - bz0 + 1e-6))
757
+ except Exception:
758
+ pass
759
+ ax.set_xlabel("X (mm)"); ax.set_ylabel("Y (mm)"); ax.set_zlabel("Z (mm)")
760
+ ax.view_init(elev=elev, azim=azim)
761
+
762
+ artists = []
763
+ for pd in pdata:
764
+ # Seed with a degenerate segment: matplotlib 3.11's add_collection3d
765
+ # errors on an empty collection. Axis limits are fixed above, so this
766
+ # placeholder doesn't affect scaling; update() replaces it each frame.
767
+ seed = [[pd["first"], pd["first"]]]
768
+ # Travel drawn in neutral grey (distinct from the part's print color)
769
+ # and faint, so travel and print are easy to tell apart.
770
+ travel_col = Line3DCollection(seed, colors=travel_color, linewidths=travel_width, alpha=travel_opacity, zorder=1)
771
+ print_col = Line3DCollection(seed, colors=pd["color"], linewidths=print_width, zorder=2)
772
+ ax.add_collection3d(travel_col)
773
+ ax.add_collection3d(print_col)
774
+ # Nozzle marker: white fill with a black outline, drawn on top (high
775
+ # zorder + computed_zorder disabled) so it stays visible against any
776
+ # part color and the light background.
777
+ noz = ax.scatter(
778
+ [pd["last"][0]], [pd["last"][1]], [pd["last"][2]],
779
+ color="white", edgecolors="black", linewidths=1.4, s=90,
780
+ depthshade=False, zorder=10,
781
+ )
782
+ artists.append((print_col, travel_col, noz))
783
+
784
+ def update(frame: int):
785
+ cutoff = (frame / (n_frames - 1)) * total_length
786
+ if progress_cb is not None:
787
+ progress_cb(frame, n_frames)
788
+ drawn = []
789
+ for (print_col, travel_col, noz), pd in zip(artists, pdata):
790
+ print_col.set_segments(segs_at(pd["moves"], "print", cutoff))
791
+ travel_col.set_segments(segs_at(pd["moves"], "travel", cutoff))
792
+ nx, ny, nz = nozzle_at(pd["moves"], cutoff)
793
+ noz._offsets3d = ([nx], [ny], [nz])
794
+ drawn += [print_col, travel_col, noz]
795
+ return drawn
796
+
797
+ anim = FuncAnimation(fig, update, frames=n_frames, blit=False)
798
+ out_path = Path(out_path)
799
+ anim.save(str(out_path), writer=PillowWriter(fps=int(fps)))
800
+ plt.close(fig)
801
+ return out_path
802
+
803
+
804
+ def render_gcode_file(path: str | Path) -> tuple[go.Figure, dict]:
805
+ text = Path(path).read_text()
806
+ parsed = parse_gcode_path(text)
807
+ return build_toolpath_figure(parsed), parsed
pyproject.toml ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [project]
2
+ name = "stl-to-gcode"
3
+ version = "0.1.0"
4
+ description = "Gradio app for slicing STL files into TIFF image stacks."
5
+ readme = "README.md"
6
+ requires-python = ">=3.11"
7
+ dependencies = [
8
+ "gradio>=5.23.0",
9
+ "matplotlib>=3.11.0",
10
+ "networkx>=3.4.2",
11
+ "numpy>=2.2.0",
12
+ "pillow>=11.1.0",
13
+ "plotly>=6.7.0",
14
+ "scipy>=1.15.2",
15
+ "shapely>=2.0.7",
16
+ "trimesh>=4.6.5",
17
+ ]
18
+
19
+ [dependency-groups]
20
+ dev = [
21
+ "pytest>=8.3.5",
22
+ ]
23
+
24
+ [tool.uv]
25
+ package = false
requirements.txt ADDED
@@ -0,0 +1,197 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # This file was autogenerated by uv via the following command:
2
+ # uv export --format requirements.txt --no-hashes --no-dev --frozen --output-file requirements.txt
3
+ aiofiles==24.1.0
4
+ # via gradio
5
+ annotated-doc==0.0.4
6
+ # via
7
+ # fastapi
8
+ # typer
9
+ annotated-types==0.7.0
10
+ # via pydantic
11
+ anyio==4.13.0
12
+ # via
13
+ # gradio
14
+ # httpx
15
+ # starlette
16
+ audioop-lts==0.2.2 ; python_full_version >= '3.13'
17
+ # via gradio
18
+ brotli==1.2.0
19
+ # via gradio
20
+ certifi==2026.2.25
21
+ # via
22
+ # httpcore
23
+ # httpx
24
+ click==8.3.1
25
+ # via
26
+ # typer
27
+ # uvicorn
28
+ colorama==0.4.6 ; sys_platform == 'win32'
29
+ # via
30
+ # click
31
+ # tqdm
32
+ contourpy==1.3.3
33
+ # via matplotlib
34
+ cycler==0.12.1
35
+ # via matplotlib
36
+ fastapi==0.135.2
37
+ # via gradio
38
+ ffmpy==1.0.0
39
+ # via gradio
40
+ filelock==3.25.2
41
+ # via huggingface-hub
42
+ fonttools==4.63.0
43
+ # via matplotlib
44
+ fsspec==2026.3.0
45
+ # via
46
+ # gradio-client
47
+ # huggingface-hub
48
+ gradio==6.10.0
49
+ # via stl-to-gcode
50
+ gradio-client==2.4.0
51
+ # via
52
+ # gradio
53
+ # hf-gradio
54
+ groovy==0.1.2
55
+ # via gradio
56
+ h11==0.16.0
57
+ # via
58
+ # httpcore
59
+ # uvicorn
60
+ hf-gradio==0.3.0
61
+ # via gradio
62
+ hf-xet==1.4.2 ; platform_machine == 'AMD64' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'arm64' or platform_machine == 'x86_64'
63
+ # via huggingface-hub
64
+ httpcore==1.0.9
65
+ # via httpx
66
+ httpx==0.28.1
67
+ # via
68
+ # gradio
69
+ # gradio-client
70
+ # huggingface-hub
71
+ # safehttpx
72
+ huggingface-hub==1.8.0
73
+ # via
74
+ # gradio
75
+ # gradio-client
76
+ idna==3.11
77
+ # via
78
+ # anyio
79
+ # httpx
80
+ jinja2==3.1.6
81
+ # via gradio
82
+ kiwisolver==1.5.0
83
+ # via matplotlib
84
+ markdown-it-py==4.0.0
85
+ # via rich
86
+ markupsafe==3.0.3
87
+ # via
88
+ # gradio
89
+ # jinja2
90
+ matplotlib==3.11.0
91
+ # via stl-to-gcode
92
+ mdurl==0.1.2
93
+ # via markdown-it-py
94
+ narwhals==2.19.0
95
+ # via plotly
96
+ networkx==3.6.1
97
+ # via stl-to-gcode
98
+ numpy==2.4.4
99
+ # via
100
+ # contourpy
101
+ # gradio
102
+ # matplotlib
103
+ # pandas
104
+ # scipy
105
+ # shapely
106
+ # stl-to-gcode
107
+ # trimesh
108
+ orjson==3.11.7
109
+ # via gradio
110
+ packaging==26.0
111
+ # via
112
+ # gradio
113
+ # gradio-client
114
+ # huggingface-hub
115
+ # matplotlib
116
+ # plotly
117
+ pandas==3.0.1
118
+ # via gradio
119
+ pillow==12.1.1
120
+ # via
121
+ # gradio
122
+ # matplotlib
123
+ # stl-to-gcode
124
+ plotly==6.7.0
125
+ # via stl-to-gcode
126
+ pydantic==2.12.5
127
+ # via
128
+ # fastapi
129
+ # gradio
130
+ pydantic-core==2.41.5
131
+ # via pydantic
132
+ pydub==0.25.1
133
+ # via gradio
134
+ pygments==2.20.0
135
+ # via rich
136
+ pyparsing==3.3.2
137
+ # via matplotlib
138
+ python-dateutil==2.9.0.post0
139
+ # via
140
+ # matplotlib
141
+ # pandas
142
+ python-multipart==0.0.22
143
+ # via gradio
144
+ pytz==2026.1.post1
145
+ # via gradio
146
+ pyyaml==6.0.3
147
+ # via
148
+ # gradio
149
+ # huggingface-hub
150
+ rich==14.3.3
151
+ # via typer
152
+ safehttpx==0.1.7
153
+ # via gradio
154
+ scipy==1.17.1
155
+ # via stl-to-gcode
156
+ semantic-version==2.10.0
157
+ # via gradio
158
+ shapely==2.1.2
159
+ # via stl-to-gcode
160
+ shellingham==1.5.4
161
+ # via typer
162
+ six==1.17.0
163
+ # via python-dateutil
164
+ starlette==0.52.1
165
+ # via
166
+ # fastapi
167
+ # gradio
168
+ tomlkit==0.13.3
169
+ # via gradio
170
+ tqdm==4.67.3
171
+ # via huggingface-hub
172
+ trimesh==4.11.5
173
+ # via stl-to-gcode
174
+ typer==0.24.1
175
+ # via
176
+ # gradio
177
+ # hf-gradio
178
+ # huggingface-hub
179
+ typing-extensions==4.15.0
180
+ # via
181
+ # anyio
182
+ # fastapi
183
+ # gradio
184
+ # gradio-client
185
+ # huggingface-hub
186
+ # pydantic
187
+ # pydantic-core
188
+ # starlette
189
+ # typing-inspection
190
+ typing-inspection==0.4.2
191
+ # via
192
+ # fastapi
193
+ # pydantic
194
+ tzdata==2025.3 ; sys_platform == 'emscripten' or sys_platform == 'win32'
195
+ # via pandas
196
+ uvicorn==0.42.0
197
+ # via gradio
sample_stls/Hollow_Pyramid.stl ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:045df2fcadec01360e37467bb29d791e9b63552d32ececa388f2e57ce95cb0db
3
+ size 9860
sample_stls/Rounded_Cube_Through_Holes.stl ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:e0d8a159cc44350ea75c63699960216a7f097ccd2321698918c1bd5bace49510
3
+ size 2299409
sample_stls/halfsphere.stl ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:3011534784a9849354d408401c37c6bfb59116e9a19163adac953047b362178c
3
+ size 362001
stl_slicer.py ADDED
@@ -0,0 +1,272 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import math
4
+ import tempfile
5
+ import uuid
6
+ import zipfile
7
+ from dataclasses import dataclass
8
+ from pathlib import Path
9
+ from typing import Callable, Sequence
10
+
11
+ import numpy as np
12
+ from PIL import Image, ImageDraw
13
+ from shapely.geometry import GeometryCollection, MultiPolygon, Polygon
14
+ import trimesh
15
+
16
+
17
+ ProgressCallback = Callable[[int, int], None] | None
18
+ ScaleFactors = tuple[float, float, float]
19
+
20
+
21
+ @dataclass(slots=True)
22
+ class SliceStack:
23
+ output_dir: Path
24
+ zip_path: Path
25
+ tiff_paths: list[Path]
26
+ z_values: list[float]
27
+ image_size: tuple[int, int]
28
+ bounds: tuple[tuple[float, float, float], tuple[float, float, float]]
29
+ layer_height: float
30
+ pixel_size: float
31
+
32
+
33
+ def load_mesh(stl_path: str | Path) -> trimesh.Trimesh:
34
+ loaded = trimesh.load(stl_path, force="scene")
35
+ if isinstance(loaded, trimesh.Scene):
36
+ if not loaded.geometry:
37
+ raise ValueError("The STL file does not contain any mesh geometry.")
38
+ mesh = trimesh.util.concatenate(tuple(loaded.geometry.values()))
39
+ else:
40
+ mesh = loaded
41
+
42
+ if not isinstance(mesh, trimesh.Trimesh) or mesh.is_empty:
43
+ raise ValueError("Unable to load a valid mesh from the STL file.")
44
+
45
+ return mesh
46
+
47
+
48
+ def _normalize_scale_factors(scale_factors: Sequence[float] | None) -> ScaleFactors:
49
+ if scale_factors is None:
50
+ return (1.0, 1.0, 1.0)
51
+
52
+ values = tuple(float(value) for value in scale_factors)
53
+ if len(values) != 3:
54
+ raise ValueError("Scale factors must contain X, Y, and Z values.")
55
+
56
+ if any(value <= 0 for value in values):
57
+ raise ValueError("Scale factors must be greater than zero.")
58
+
59
+ return (values[0], values[1], values[2])
60
+
61
+
62
+ def scale_mesh(mesh: trimesh.Trimesh, scale_factors: Sequence[float] | None = None) -> trimesh.Trimesh:
63
+ """Return a copy of `mesh` scaled around its minimum XYZ corner."""
64
+ sx, sy, sz = _normalize_scale_factors(scale_factors)
65
+ scaled = mesh.copy()
66
+
67
+ if math.isclose(sx, 1.0) and math.isclose(sy, 1.0) and math.isclose(sz, 1.0):
68
+ return scaled
69
+
70
+ anchor = np.asarray(mesh.bounds[0], dtype=float)
71
+ transform = np.eye(4)
72
+ transform[0, 0] = sx
73
+ transform[1, 1] = sy
74
+ transform[2, 2] = sz
75
+ transform[:3, 3] = anchor * (1.0 - np.array([sx, sy, sz], dtype=float))
76
+ scaled.apply_transform(transform)
77
+ return scaled
78
+
79
+
80
+ def scale_factors_for_target_extents(
81
+ mesh: trimesh.Trimesh,
82
+ target_extents: Sequence[float],
83
+ ) -> ScaleFactors:
84
+ target = _normalize_scale_factors(target_extents)
85
+ extents = np.asarray(mesh.extents, dtype=float)
86
+ if np.any(extents <= 0):
87
+ raise ValueError("Cannot scale a mesh with a zero-sized X, Y, or Z extent.")
88
+
89
+ return (
90
+ target[0] / float(extents[0]),
91
+ target[1] / float(extents[1]),
92
+ target[2] / float(extents[2]),
93
+ )
94
+
95
+
96
+ def calculate_z_levels(z_min: float, z_max: float, layer_height: float) -> list[float]:
97
+ if layer_height <= 0:
98
+ raise ValueError("Layer height must be greater than zero.")
99
+
100
+ thickness = z_max - z_min
101
+ if thickness <= 0:
102
+ return [z_min]
103
+
104
+ layer_count = max(1, math.ceil(thickness / layer_height))
105
+ top_guard = math.nextafter(z_max, z_min)
106
+
107
+ return [
108
+ min(z_min + ((index + 0.5) * layer_height), top_guard)
109
+ for index in range(layer_count)
110
+ ]
111
+
112
+
113
+ def _to_pixel_ring(
114
+ coords: np.ndarray,
115
+ x_min: float,
116
+ y_min: float,
117
+ pixel_size: float,
118
+ image_height: int,
119
+ ) -> list[tuple[int, int]]:
120
+ pixels: list[tuple[int, int]] = []
121
+ for x_value, y_value in coords:
122
+ x_pixel = int(round((float(x_value) - x_min) / pixel_size))
123
+ y_pixel = int(round((float(y_value) - y_min) / pixel_size))
124
+ pixels.append((x_pixel, image_height - 1 - y_pixel))
125
+ return pixels
126
+
127
+
128
+ def _ring_to_world_xy(ring_coords: object, to_3d: np.ndarray) -> np.ndarray:
129
+ planar = np.asarray(ring_coords, dtype=float)
130
+ if planar.ndim != 2 or planar.shape[1] < 2:
131
+ raise ValueError("Encountered an invalid polygon ring while slicing.")
132
+
133
+ planar_3d = np.column_stack([planar[:, 0], planar[:, 1], np.zeros(len(planar))])
134
+ world = trimesh.transform_points(planar_3d, to_3d)
135
+ return world[:, :2]
136
+
137
+
138
+ def _compose_even_odd_polygons(polygons: list[Polygon]) -> list[Polygon]:
139
+ geometry: Polygon | MultiPolygon | GeometryCollection | None = None
140
+ for polygon in polygons:
141
+ geometry = polygon if geometry is None else geometry.symmetric_difference(polygon)
142
+
143
+ if geometry is None or geometry.is_empty:
144
+ return []
145
+
146
+ if isinstance(geometry, Polygon):
147
+ return [geometry]
148
+
149
+ if isinstance(geometry, MultiPolygon):
150
+ return list(geometry.geoms)
151
+
152
+ if isinstance(geometry, GeometryCollection):
153
+ return [geom for geom in geometry.geoms if isinstance(geom, Polygon) and not geom.is_empty]
154
+
155
+ return []
156
+
157
+
158
+ def _extract_world_polygons(section: trimesh.path.Path3D) -> list[tuple[np.ndarray, list[np.ndarray]]]:
159
+ if hasattr(section, "to_2D"):
160
+ planar, to_3d = section.to_2D()
161
+ else:
162
+ planar, to_3d = section.to_planar()
163
+
164
+ composed_polygons = _compose_even_odd_polygons(list(planar.polygons_closed))
165
+ polygons: list[tuple[np.ndarray, list[np.ndarray]]] = []
166
+ for polygon in composed_polygons:
167
+ exterior = _ring_to_world_xy(polygon.exterior.coords, to_3d)
168
+ holes = [_ring_to_world_xy(interior.coords, to_3d) for interior in polygon.interiors]
169
+ polygons.append((exterior, holes))
170
+
171
+ return polygons
172
+
173
+
174
+ def _render_slice(
175
+ section: trimesh.path.Path3D | None,
176
+ x_min: float,
177
+ y_min: float,
178
+ image_size: tuple[int, int],
179
+ pixel_size: float,
180
+ ) -> Image.Image:
181
+ image = Image.new("L", image_size, 255)
182
+
183
+ if section is None:
184
+ return image
185
+
186
+ polygons = _extract_world_polygons(section)
187
+ if not polygons:
188
+ return image
189
+
190
+ draw = ImageDraw.Draw(image)
191
+ for exterior, holes in polygons:
192
+ draw.polygon(
193
+ _to_pixel_ring(exterior, x_min, y_min, pixel_size, image.height),
194
+ fill=0,
195
+ )
196
+ for hole in holes:
197
+ draw.polygon(
198
+ _to_pixel_ring(hole, x_min, y_min, pixel_size, image.height),
199
+ fill=255,
200
+ )
201
+
202
+ return image
203
+
204
+
205
+ def _make_output_paths(stl_path: Path, output_root: str | Path | None) -> tuple[Path, Path]:
206
+ root = Path(output_root) if output_root else Path(tempfile.mkdtemp(prefix="stl_slices_"))
207
+ stem = stl_path.stem or "mesh"
208
+ job_dir = root / f"{stem}_{uuid.uuid4().hex[:8]}"
209
+ slices_dir = job_dir / "tiff_slices"
210
+ slices_dir.mkdir(parents=True, exist_ok=True)
211
+ zip_path = job_dir / f"{stem}_tiff_slices.zip"
212
+ return slices_dir, zip_path
213
+
214
+
215
+ def _zip_tiffs(tiff_paths: list[Path], zip_path: Path) -> None:
216
+ with zipfile.ZipFile(zip_path, mode="w", compression=zipfile.ZIP_DEFLATED) as archive:
217
+ for tiff_path in tiff_paths:
218
+ archive.write(tiff_path, arcname=tiff_path.name)
219
+
220
+
221
+ def slice_stl_to_tiffs(
222
+ stl_path: str | Path,
223
+ layer_height: float,
224
+ pixel_size: float,
225
+ output_root: str | Path | None = None,
226
+ progress_callback: ProgressCallback = None,
227
+ scale_factors: Sequence[float] | None = None,
228
+ ) -> SliceStack:
229
+ if pixel_size <= 0:
230
+ raise ValueError("Pixel size must be greater than zero.")
231
+
232
+ stl_path = Path(stl_path)
233
+ mesh = scale_mesh(load_mesh(stl_path), scale_factors)
234
+ bounds = mesh.bounds
235
+ (x_min, y_min, z_min), (x_max, y_max, z_max) = bounds
236
+
237
+ z_values = calculate_z_levels(float(z_min), float(z_max), layer_height)
238
+ width = max(1, math.ceil((float(x_max) - float(x_min)) / pixel_size) + 1)
239
+ height = max(1, math.ceil((float(y_max) - float(y_min)) / pixel_size) + 1)
240
+ image_size = (width, height)
241
+
242
+ output_dir, zip_path = _make_output_paths(stl_path, output_root)
243
+ tiff_paths: list[Path] = []
244
+
245
+ for index, z_value in enumerate(z_values):
246
+ section = mesh.section(
247
+ plane_origin=np.array([0.0, 0.0, z_value], dtype=float),
248
+ plane_normal=np.array([0.0, 0.0, 1.0], dtype=float),
249
+ )
250
+ image = _render_slice(section, float(x_min), float(y_min), image_size, pixel_size)
251
+ tiff_path = output_dir / f"slice_{index:04d}.tif"
252
+ image.save(tiff_path, compression="tiff_deflate")
253
+ tiff_paths.append(tiff_path)
254
+
255
+ if progress_callback is not None:
256
+ progress_callback(index + 1, len(z_values))
257
+
258
+ _zip_tiffs(tiff_paths, zip_path)
259
+
260
+ return SliceStack(
261
+ output_dir=output_dir,
262
+ zip_path=zip_path,
263
+ tiff_paths=tiff_paths,
264
+ z_values=z_values,
265
+ image_size=image_size,
266
+ bounds=(
267
+ (float(x_min), float(y_min), float(z_min)),
268
+ (float(x_max), float(y_max), float(z_max)),
269
+ ),
270
+ layer_height=layer_height,
271
+ pixel_size=pixel_size,
272
+ )
tests/conftest.py ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import sys
4
+ from pathlib import Path
5
+
6
+
7
+ PROJECT_ROOT = Path(__file__).resolve().parents[1]
8
+ if str(PROJECT_ROOT) not in sys.path:
9
+ sys.path.insert(0, str(PROJECT_ROOT))
tests/test_app_scaling.py ADDED
@@ -0,0 +1,42 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import numpy as np
4
+ import trimesh
5
+
6
+ from app import (
7
+ SCALE_MODE_TARGET_DIMENSIONS,
8
+ SCALE_MODE_UNIFORM_FACTOR,
9
+ _resolve_mesh_scale_factors,
10
+ )
11
+
12
+
13
+ def test_resolve_mesh_scale_factors_uses_uniform_factor_for_all_axes() -> None:
14
+ mesh = trimesh.creation.box(extents=(2.0, 4.0, 8.0))
15
+
16
+ scale_factors = _resolve_mesh_scale_factors(
17
+ mesh,
18
+ scale_to_target=True,
19
+ scale_mode=SCALE_MODE_UNIFORM_FACTOR,
20
+ target_x=10.0,
21
+ target_y=20.0,
22
+ target_z=30.0,
23
+ uniform_scale=1.5,
24
+ )
25
+
26
+ assert scale_factors == (1.5, 1.5, 1.5)
27
+
28
+
29
+ def test_resolve_mesh_scale_factors_fits_each_axis_in_target_mode() -> None:
30
+ mesh = trimesh.creation.box(extents=(2.0, 4.0, 8.0))
31
+
32
+ scale_factors = _resolve_mesh_scale_factors(
33
+ mesh,
34
+ scale_to_target=True,
35
+ scale_mode=SCALE_MODE_TARGET_DIMENSIONS,
36
+ target_x=10.0,
37
+ target_y=20.0,
38
+ target_z=4.0,
39
+ uniform_scale=1.5,
40
+ )
41
+
42
+ np.testing.assert_allclose(scale_factors, (5.0, 5.0, 0.5))
tests/test_gcode_viewer.py ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from gcode_viewer import parse_gcode_path
4
+
5
+
6
+ def test_parse_gcode_classifies_g0_as_travel_and_g1_as_print() -> None:
7
+ parsed = parse_gcode_path(
8
+ "\n".join(
9
+ [
10
+ "G91",
11
+ "G0 X1 Y0 ; travel",
12
+ "G1 X0 Y1 ; print",
13
+ ]
14
+ )
15
+ )
16
+
17
+ assert len(parsed["travel_segments"]) == 1
18
+ assert len(parsed["print_segments"]) == 1
tests/test_stl_slicer.py ADDED
@@ -0,0 +1,85 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import numpy as np
4
+ from PIL import Image
5
+ from shapely.geometry import Polygon
6
+ import trimesh
7
+
8
+ from stl_slicer import (
9
+ _compose_even_odd_polygons,
10
+ calculate_z_levels,
11
+ scale_factors_for_target_extents,
12
+ scale_mesh,
13
+ slice_stl_to_tiffs,
14
+ )
15
+
16
+
17
+ def test_calculate_z_levels_creates_single_layer_for_thin_mesh() -> None:
18
+ z_values = calculate_z_levels(0.0, 0.01, 0.1)
19
+
20
+ assert len(z_values) == 1
21
+ assert 0.0 <= z_values[0] < 0.01
22
+
23
+
24
+ def test_slice_stl_to_tiffs_creates_non_empty_tiffs(tmp_path) -> None:
25
+ mesh = trimesh.creation.box(extents=(2.0, 2.0, 2.0))
26
+ stl_path = tmp_path / "cube.stl"
27
+ mesh.export(stl_path)
28
+
29
+ stack = slice_stl_to_tiffs(
30
+ stl_path,
31
+ layer_height=0.5,
32
+ pixel_size=0.25,
33
+ output_root=tmp_path / "generated",
34
+ )
35
+
36
+ assert len(stack.tiff_paths) == 4
37
+ assert stack.zip_path.exists()
38
+ assert all(path.exists() for path in stack.tiff_paths)
39
+
40
+ with Image.open(stack.tiff_paths[0]) as first_image:
41
+ pixels = np.array(first_image)
42
+
43
+ assert np.any(pixels == 0)
44
+
45
+
46
+ def test_scale_mesh_matches_target_extents_and_preserves_min_corner() -> None:
47
+ mesh = trimesh.creation.box(extents=(2.0, 4.0, 5.0))
48
+ mesh.apply_translation((5.0, 6.0, 7.0))
49
+
50
+ target_extents = (10.0, 8.0, 2.5)
51
+ scale_factors = scale_factors_for_target_extents(mesh, target_extents)
52
+ scaled = scale_mesh(mesh, scale_factors)
53
+
54
+ np.testing.assert_allclose(scaled.extents, target_extents)
55
+ np.testing.assert_allclose(scaled.bounds[0], mesh.bounds[0])
56
+
57
+
58
+ def test_slice_stl_to_tiffs_applies_scale_factors(tmp_path) -> None:
59
+ mesh = trimesh.creation.box(extents=(2.0, 2.0, 2.0))
60
+ stl_path = tmp_path / "cube.stl"
61
+ mesh.export(stl_path)
62
+
63
+ stack = slice_stl_to_tiffs(
64
+ stl_path,
65
+ layer_height=0.5,
66
+ pixel_size=0.25,
67
+ output_root=tmp_path / "scaled",
68
+ scale_factors=(2.0, 1.0, 0.5),
69
+ )
70
+
71
+ bounds = np.array(stack.bounds)
72
+ np.testing.assert_allclose(bounds[1] - bounds[0], (4.0, 2.0, 1.0))
73
+ assert stack.image_size == (17, 9)
74
+ assert len(stack.tiff_paths) == 2
75
+
76
+
77
+ def test_compose_even_odd_polygons_preserves_holes() -> None:
78
+ outer = Polygon([(0, 0), (10, 0), (10, 10), (0, 10)])
79
+ inner = Polygon([(3, 3), (7, 3), (7, 7), (3, 7)])
80
+
81
+ composed = _compose_even_odd_polygons([outer, inner])
82
+
83
+ assert len(composed) == 1
84
+ assert composed[0].area == outer.area - inner.area
85
+ assert len(composed[0].interiors) == 1
tests/test_tiff_to_gcode.py ADDED
@@ -0,0 +1,63 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import zipfile
4
+
5
+ from PIL import Image
6
+
7
+ from tiff_to_gcode import generate_snake_path_gcode
8
+
9
+
10
+ def test_gcode_header_writes_presets_before_initial_aux_commands(tmp_path) -> None:
11
+ tiff_path = tmp_path / "slice_0000.tif"
12
+ Image.new("L", (1, 1), 0).save(tiff_path)
13
+
14
+ zip_path = tmp_path / "slices.zip"
15
+ with zipfile.ZipFile(zip_path, mode="w") as archive:
16
+ archive.write(tiff_path, arcname=tiff_path.name)
17
+
18
+ gcode_path = generate_snake_path_gcode(
19
+ zip_path,
20
+ shape_name="header_order",
21
+ pressure=25,
22
+ valve=7,
23
+ port=3,
24
+ )
25
+
26
+ lines = [
27
+ line.strip()
28
+ for line in gcode_path.read_text().splitlines()
29
+ if line.strip()
30
+ ]
31
+
32
+ assert lines[0] == "G91"
33
+ assert lines[1].startswith("{preset}serialPort3.write(")
34
+ assert lines[2].startswith("{preset}serialPort3.write(")
35
+ assert lines[3].startswith("{aux_command}WAGO_ValveCommands(")
36
+ assert lines[4].startswith("{aux_command}WAGO_ValveCommands(")
37
+
38
+
39
+ def test_gcode_uses_g1_for_print_and_g0_for_travel(tmp_path) -> None:
40
+ tiff_path = tmp_path / "slice_0000.tif"
41
+ Image.new("L", (1, 1), 0).save(tiff_path)
42
+
43
+ zip_path = tmp_path / "slices.zip"
44
+ with zipfile.ZipFile(zip_path, mode="w") as archive:
45
+ archive.write(tiff_path, arcname=tiff_path.name)
46
+
47
+ gcode_path = generate_snake_path_gcode(
48
+ zip_path,
49
+ shape_name="move_types",
50
+ pressure=25,
51
+ valve=7,
52
+ port=3,
53
+ )
54
+
55
+ move_lines = [
56
+ line.strip()
57
+ for line in gcode_path.read_text().splitlines()
58
+ if line.startswith(("G0", "G1"))
59
+ ]
60
+
61
+ assert any(line.startswith("G1") and "; Color 255" in line for line in move_lines)
62
+ assert all(not line.startswith("G0") for line in move_lines if "; Color 255" in line)
63
+ assert all(not line.startswith("G1") for line in move_lines if "; Color 0" in line)
tiff_to_gcode.py ADDED
@@ -0,0 +1,378 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import tempfile
5
+ import zipfile
6
+ from codecs import encode
7
+ from pathlib import Path
8
+ from textwrap import wrap
9
+
10
+ import numpy as np
11
+ from PIL import Image
12
+
13
+
14
+ def _setpress(pressure: float) -> str:
15
+ pressure_str = str(int(pressure * 10)).zfill(4)
16
+ command_bytes = bytes("08PS " + pressure_str, "utf-8")
17
+ hex_command = encode(command_bytes, "hex").decode("utf-8")
18
+ format_command = "\\x" + "\\x".join(
19
+ hex_command[i : i + 2] for i in range(0, len(hex_command), 2)
20
+ )
21
+
22
+ hex_pairs = wrap(hex_command, 2)
23
+ decimal_sum = sum(int(pair, 16) for pair in hex_pairs)
24
+ checksum_bin = bin(decimal_sum % 256)[2:].zfill(8)
25
+ inverted = int("".join("1" if c == "0" else "0" for c in checksum_bin), 2) + 1
26
+ checksum_hex = hex(inverted)[2:].upper()
27
+ format_checksum = "\\x" + "\\x".join(
28
+ checksum_hex[i : i + 2] for i in range(0, len(checksum_hex), 2)
29
+ )
30
+
31
+ return "b'" + "\\x05\\x02" + format_command + format_checksum + "\\x03" + "'"
32
+
33
+
34
+ def _togglepress() -> str:
35
+ return "b'\\x05\\x02\\x30\\x34\\x44\\x49\\x20\\x20\\x43\\x46\\x03'"
36
+
37
+
38
+ def _setpress_cmd(port: str, pressure: float, start: bool) -> str:
39
+ insert = "{preset}" if start else ""
40
+ return f"\n\r{insert}{port}.write({_setpress(pressure)})"
41
+
42
+
43
+ def _toggle_cmd(port: str, start: bool) -> str:
44
+ insert = "{preset}" if start else ""
45
+ return f"\n\r{insert}{port}.write({_togglepress()})"
46
+
47
+
48
+ def _valve_cmd(valve: int, command: int) -> str:
49
+ return f"\n{{aux_command}}WAGO_ValveCommands({valve}, {command})\n"
50
+
51
+
52
+ def _gcode_layer(
53
+ path_img: np.ndarray,
54
+ color_img: np.ndarray,
55
+ output_list: list[dict],
56
+ pixel_size: float,
57
+ direction: int,
58
+ layer_number: int,
59
+ ) -> int:
60
+ mask = path_img > 0
61
+ first_nonblack = np.where(mask.any(axis=1), mask.argmax(axis=1), -1)
62
+ last_nonblack = np.where(
63
+ mask.any(axis=1),
64
+ mask.shape[1] - 1 - np.fliplr(mask).argmax(axis=1),
65
+ -1,
66
+ )
67
+
68
+ stored_gcode: list[dict] = []
69
+ nonblank_rows = np.where(first_nonblack != -1)[0]
70
+
71
+ for idx, i in enumerate(nonblank_rows):
72
+ f_idx, l_idx = int(first_nonblack[i]), int(last_nonblack[i])
73
+ if f_idx == -1:
74
+ continue
75
+
76
+ if direction < 0:
77
+ rng = range(f_idx, l_idx + 1)
78
+ else:
79
+ rng = range(l_idx, f_idx - 1, -1)
80
+ direction *= -1
81
+
82
+ prev_color = None
83
+ color_len = 0
84
+ buffer = direction
85
+ stored_gcode.append({"X": buffer * pixel_size, "Y": 0, "Color": 0})
86
+
87
+ for j in rng:
88
+ this_color = int(color_img[i, j])
89
+ if prev_color is None:
90
+ prev_color = this_color
91
+ color_len = 1
92
+ elif this_color == prev_color:
93
+ color_len += 1
94
+ else:
95
+ stored_gcode.append(
96
+ {
97
+ "X": direction * color_len * pixel_size,
98
+ "Y": 0,
99
+ "Color": prev_color,
100
+ }
101
+ )
102
+ color_len = 1
103
+ prev_color = this_color
104
+
105
+ if color_len > 0:
106
+ stored_gcode.append(
107
+ {
108
+ "X": direction * color_len * pixel_size,
109
+ "Y": 0,
110
+ "Color": prev_color,
111
+ }
112
+ )
113
+
114
+ stored_gcode.append({"X": buffer * pixel_size, "Y": 0, "Color": 0})
115
+
116
+ curr_x = l_idx if direction > 0 else f_idx
117
+ curr_x += buffer
118
+
119
+ if idx + 1 < len(nonblank_rows):
120
+ next_i = int(nonblank_rows[idx + 1])
121
+ y_travel_dist = next_i - int(i)
122
+ nf, nl = int(first_nonblack[next_i]), int(last_nonblack[next_i])
123
+ if nf == -1:
124
+ continue
125
+ next_start = nf if direction < 0 else nl
126
+ travel_x = (next_start + buffer) - curr_x
127
+ y_dir = -1 if layer_number % 2 == 1 else 1
128
+ stored_gcode.append(
129
+ {
130
+ "X": travel_x * pixel_size,
131
+ "Y": y_travel_dist * pixel_size * y_dir,
132
+ "Color": 0,
133
+ }
134
+ )
135
+
136
+ output_list.extend(stored_gcode)
137
+ return direction
138
+
139
+
140
+ def _sort_key(filename: str) -> int:
141
+ digits = "".join(filter(str.isdigit, filename))
142
+ return int(digits) if digits else 2**31
143
+
144
+
145
+ def _extract_zip_tiffs(zip_path: Path, dest: Path) -> list[Path]:
146
+ with zipfile.ZipFile(zip_path) as archive:
147
+ archive.extractall(dest)
148
+
149
+ tiffs: list[Path] = []
150
+ for root, _, files in os.walk(dest):
151
+ for name in files:
152
+ if name.lower().endswith((".tif", ".tiff")):
153
+ tiffs.append(Path(root) / name)
154
+ tiffs.sort(key=lambda p: _sort_key(p.name))
155
+ return tiffs
156
+
157
+
158
+ def _load_grayscale(path: Path, invert: bool) -> np.ndarray:
159
+ with Image.open(path) as image:
160
+ array = np.array(image.convert("L"), dtype=np.uint8)
161
+ if invert:
162
+ array = 255 - array
163
+ return array
164
+
165
+
166
+ def _center_on_canvas(
167
+ img: np.ndarray, canvas_h: int, canvas_w: int, fill: int = 0
168
+ ) -> np.ndarray:
169
+ """Place `img` centred on a (canvas_h, canvas_w) canvas filled with `fill`.
170
+
171
+ Mirrors the centring used to build the reference stack, so a shape's slice
172
+ lines up pixel-for-pixel with the reference (motion) slice of the same layer.
173
+ """
174
+ h, w = img.shape[:2]
175
+ out = np.full((canvas_h, canvas_w), fill, dtype=img.dtype)
176
+ y_off = max(0, (canvas_h - h) // 2)
177
+ x_off = max(0, (canvas_w - w) // 2)
178
+ out[y_off : y_off + h, x_off : x_off + w] = img[: canvas_h, : canvas_w]
179
+ return out
180
+
181
+
182
+ def generate_snake_path_gcode(
183
+ zip_path: str | Path,
184
+ shape_name: str,
185
+ pressure: float,
186
+ valve: int,
187
+ port: int,
188
+ layer_height: float = 0.8,
189
+ fil_width: float = 0.8,
190
+ invert: bool = True,
191
+ increase_pressure_per_layer: float = 0.1,
192
+ all_g1: bool = False,
193
+ motion_tiffs: list[str] | None = None,
194
+ ) -> Path:
195
+ zip_path = Path(zip_path)
196
+ if not zip_path.exists():
197
+ raise FileNotFoundError(f"ZIP file not found: {zip_path}")
198
+
199
+ work_dir = Path(tempfile.mkdtemp(prefix="tiff_gcode_"))
200
+ extract_dir = work_dir / "tiffs"
201
+ extract_dir.mkdir(parents=True, exist_ok=True)
202
+ tiff_files = _extract_zip_tiffs(zip_path, extract_dir)
203
+ if not tiff_files:
204
+ raise ValueError("No TIFF files found in the ZIP archive.")
205
+
206
+ off_color = 0
207
+ com_port = f"serialPort{port}"
208
+ color_dict: dict[int, int] = {0: 100, 255: valve}
209
+
210
+ # Two non-flipped source image lists. The "path" images drive the nozzle
211
+ # motion (which rows are swept, the sweep extent, the inter-layer shifts);
212
+ # the "color" images decide the valve state (material) at each swept pixel.
213
+ # Normally both are this shape's own slices. When reference motion tiffs are
214
+ # supplied, motion comes from the combined reference stack while the valve is
215
+ # still driven by this shape's slices, centred onto the reference canvas — so
216
+ # parallel heads share one motion path but each dispenses only its geometry.
217
+ shape_imgs = [_load_grayscale(p, invert=invert) for p in tiff_files]
218
+
219
+ if motion_tiffs:
220
+ motion_paths = sorted(
221
+ (Path(p) for p in motion_tiffs), key=lambda p: _sort_key(p.name)
222
+ )
223
+ path_ref_list = [_load_grayscale(p, invert=invert) for p in motion_paths]
224
+ if not path_ref_list:
225
+ raise ValueError("No reference TIFF files provided for motion.")
226
+ color_ref_list: list[np.ndarray] = []
227
+ for li, motion_img in enumerate(path_ref_list):
228
+ h_c, w_c = motion_img.shape[:2]
229
+ if li < len(shape_imgs):
230
+ color_ref_list.append(
231
+ _center_on_canvas(shape_imgs[li], h_c, w_c, fill=off_color)
232
+ )
233
+ else:
234
+ # Reference is taller than this shape: move but dispense nothing.
235
+ color_ref_list.append(np.full((h_c, w_c), off_color, dtype=np.uint8))
236
+ else:
237
+ path_ref_list = [im.copy() for im in shape_imgs]
238
+ color_ref_list = [im.copy() for im in shape_imgs]
239
+
240
+ setpress_lines = [_setpress_cmd(com_port, pressure, start=True)]
241
+ pressure_on_lines = [_toggle_cmd(com_port, start=True)]
242
+ pressure_off_lines = [_toggle_cmd(com_port, start=False)]
243
+
244
+ gcode_list: list[dict] = []
245
+ dist_sign_long = 1
246
+ current_offsets_x: list[int] = []
247
+ use_flip_y = False
248
+ direction = -1
249
+
250
+ for layers in range(len(path_ref_list)):
251
+ current_image_ref = path_ref_list[layers]
252
+ last_image_ref = path_ref_list[layers - 1] if layers > 0 else None
253
+ y_ref = current_image_ref.shape[0]
254
+
255
+ def find_first_valid_y(row: np.ndarray | None, flip: bool = False) -> int | None:
256
+ if row is None:
257
+ return None
258
+ row_data = np.flip(row) if flip else row
259
+ for j, pixel in enumerate(row_data):
260
+ if np.any(pixel) != off_color:
261
+ return y_ref - 1 - j if flip else j
262
+ return None
263
+
264
+ last_x = last_y = None
265
+ if current_offsets_x:
266
+ use_flip_x = layers % 2 == 1
267
+ last_x = current_offsets_x[-1] if use_flip_x else current_offsets_x[0]
268
+ last_row = (
269
+ last_image_ref[last_x] if last_image_ref is not None else None
270
+ )
271
+ last_y = find_first_valid_y(last_row, flip=use_flip_y)
272
+ current_offsets_x.clear()
273
+
274
+ current_offsets_x = [
275
+ i for i, row in enumerate(current_image_ref) if np.any(row) != off_color
276
+ ]
277
+
278
+ first_x = first_y = None
279
+ if current_offsets_x:
280
+ use_flip_x = layers % 2 == 1
281
+ first_x = current_offsets_x[-1] if use_flip_x else current_offsets_x[0]
282
+ first_row = current_image_ref[first_x]
283
+ first_y = find_first_valid_y(first_row, flip=use_flip_y)
284
+
285
+ if None in (last_x, last_y, first_x, first_y):
286
+ shift_x = shift_y = 0
287
+ else:
288
+ shift_x = (first_x - last_x) * fil_width
289
+ shift_y = (first_y - last_y) * fil_width * dist_sign_long
290
+ if use_flip_y:
291
+ shift_y = -shift_y
292
+
293
+ if len(current_offsets_x) % 2 == 1:
294
+ use_flip_y = not use_flip_y
295
+
296
+ if layers > 0:
297
+ gcode_list.append(
298
+ {"X": shift_y, "Y": shift_x, "Z": layer_height, "Color": 0}
299
+ )
300
+
301
+ for row in current_image_ref:
302
+ if all(p == off_color for p in row):
303
+ dist_sign_long = -dist_sign_long
304
+ dist_sign_long = -dist_sign_long
305
+
306
+ # Flip path and color together on even layers so they stay aligned.
307
+ even_layer = (layers + 1) % 2 == 0
308
+ ref_for_path = (
309
+ np.flipud(current_image_ref) if even_layer else current_image_ref.copy()
310
+ )
311
+ current_image = (
312
+ np.flipud(color_ref_list[layers]) if even_layer else color_ref_list[layers]
313
+ )
314
+
315
+ if layers == 0:
316
+ direction = -1
317
+ direction = _gcode_layer(
318
+ ref_for_path,
319
+ current_image,
320
+ gcode_list,
321
+ fil_width,
322
+ direction,
323
+ layers,
324
+ )
325
+
326
+ gcode_path = work_dir / f"{shape_name}_SnakePath_gcode.txt"
327
+ pressure_cur = float(pressure)
328
+
329
+ with open(gcode_path, "w") as f:
330
+ f.write("G91\n")
331
+ for line in setpress_lines:
332
+ f.write(f"{line}\n")
333
+ for line in pressure_on_lines:
334
+ f.write(f"{line}\n")
335
+ for color in color_dict:
336
+ f.write(_valve_cmd(color_dict[color], 0))
337
+
338
+ pressure_next: str | None = None
339
+ for i, move in enumerate(gcode_list):
340
+ prev_color = gcode_list[i - 1]["Color"] if i > 0 else 0
341
+ cur_color = move["Color"]
342
+ if prev_color != cur_color:
343
+ if cur_color == off_color:
344
+ f.write(_valve_cmd(color_dict[prev_color], 0))
345
+ else:
346
+ if prev_color == off_color:
347
+ f.write(_valve_cmd(color_dict[cur_color], 1))
348
+ else:
349
+ f.write(_valve_cmd(color_dict[cur_color], 1))
350
+ f.write(_valve_cmd(color_dict[prev_color], 0))
351
+
352
+ # When all_g1 is set, every move is emitted as G1 regardless of
353
+ # valve state; the valve commands still mark print vs travel.
354
+ move_type = "G1" if (all_g1 or cur_color != off_color) else "G0"
355
+ if "Z" in move:
356
+ line = (
357
+ f"{move_type} X{move['X']} Y{move['Y']} Z{move['Z']} "
358
+ f"; Color {move['Color']}"
359
+ )
360
+ pressure_cur += increase_pressure_per_layer
361
+ pressure_next = _setpress_cmd(com_port, pressure_cur, start=False)
362
+ else:
363
+ line = (
364
+ f"{move_type} X{move['X']} Y{move['Y']} ; Color {move['Color']}"
365
+ )
366
+ pressure_next = None
367
+
368
+ f.write(f"{line}\n")
369
+ if pressure_next is not None:
370
+ f.write(f"{pressure_next}\n")
371
+ pressure_next = None
372
+
373
+ for color in color_dict:
374
+ f.write(_valve_cmd(color_dict[color], 0))
375
+ for line in pressure_off_lines:
376
+ f.write(f"{line}\n")
377
+
378
+ return gcode_path
uv.lock ADDED
The diff for this file is too large to render. See raw diff