Spaces:
Running
Running
Commit ·
5a79264
0
Parent(s):
Initial ParallelPrint import
Browse files- .codex/config.toml +1 -0
- .codex/environments/environment.toml +11 -0
- .gitattributes +1 -0
- .gitignore +6 -0
- AGENTS.md +41 -0
- CLAUDE.md +1 -0
- README.md +148 -0
- app.py +0 -0
- gcode_viewer.py +807 -0
- pyproject.toml +25 -0
- requirements.txt +197 -0
- sample_stls/Hollow_Pyramid.stl +3 -0
- sample_stls/Rounded_Cube_Through_Holes.stl +3 -0
- sample_stls/halfsphere.stl +3 -0
- stl_slicer.py +272 -0
- tests/conftest.py +9 -0
- tests/test_app_scaling.py +42 -0
- tests/test_gcode_viewer.py +18 -0
- tests/test_stl_slicer.py +85 -0
- tests/test_tiff_to_gcode.py +63 -0
- tiff_to_gcode.py +378 -0
- uv.lock +0 -0
.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
|
|
|