Spaces:
Running
Running
Add vector G-code pipeline
Browse files- README.md +29 -30
- app.py +201 -950
- gcode_viewer.py +5 -1
- stl_slicer.py +53 -91
- tests/test_app_scaling.py +41 -161
- tests/test_gcode_viewer.py +32 -0
- tests/test_nozzle_spacing.py +19 -16
- tests/test_stl_slicer.py +32 -33
- tests/test_vector_gcode.py +823 -0
- vector_gcode.py +271 -0
- vector_toolpath.py +1209 -0
README.md
CHANGED
|
@@ -5,12 +5,12 @@ sdk_version: 6.10.0
|
|
| 5 |
python_version: "3.12"
|
| 6 |
app_file: app.py
|
| 7 |
fullWidth: true
|
| 8 |
-
short_description: Upload STLs,
|
| 9 |
---
|
| 10 |
|
| 11 |
# STL to G-Code Gradio App
|
| 12 |
|
| 13 |
-
This project provides a Gradio app that takes any number of uploaded STL files, shows a selected-shape 3D viewer, slices each model along the Z axis
|
| 14 |
|
| 15 |
## Prerequisites
|
| 16 |
|
|
@@ -33,7 +33,7 @@ uv run gradio app.py
|
|
| 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
|
| 37 |
|
| 38 |
## What the app does
|
| 39 |
|
|
@@ -43,17 +43,14 @@ Then open the local Gradio URL in your browser, upload STL files or load the bun
|
|
| 43 |
- Shows an interactive selected-shape 3D viewer for rotating each model
|
| 44 |
- Shows model extents, face count, vertex count, and watertight status
|
| 45 |
- Scales loaded STLs from editable target X/Y/Z dimensions in the Shape Settings table; new rows default to the STL's original dimensions, **Reset Dimensions** restores them, and **Keep Proportions** updates the other target sides from the edited side
|
| 46 |
-
-
|
| 47 |
-
-
|
| 48 |
-
-
|
| 49 |
-
-
|
| 50 |
-
-
|
| 51 |
-
-
|
| 52 |
-
-
|
| 53 |
-
-
|
| 54 |
-
- Converts generated TIFF ZIPs into G-code files with pressure, valve, nozzle, and port settings per shape from the Shape Settings table
|
| 55 |
-
- Appends a shape-optimized outer contour after each enabled shape layer by tracing that layer's active row envelope
|
| 56 |
-
- Offers G-code generation options for raster pattern, **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)
|
| 57 |
- Calculates X/Y nozzle spacing from an editable adjacent-pair spacing table, then visualizes the resulting nozzle layout
|
| 58 |
- Previews selected generated G-code inline
|
| 59 |
- Visualizes generated or uploaded G-code tool paths, with the source selectable from any active generated shape or an uploaded file
|
|
@@ -62,40 +59,42 @@ Then open the local Gradio URL in your browser, upload STL files or load the bun
|
|
| 62 |
|
| 63 |
## Behavior and Implementation Notes
|
| 64 |
|
| 65 |
-
###
|
| 66 |
|
| 67 |
-
|
| 68 |
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
-
|
|
|
|
| 74 |
|
| 75 |
### Multi-Nozzle Split
|
| 76 |
|
| 77 |
-
The **Multi-Nozzle Split** accordion on the **
|
| 78 |
|
| 79 |
-
- Each
|
| 80 |
- The selected shape is replaced in Shape Settings by one generated record per grid cell, named by row and column.
|
| 81 |
-
- Nozzle and valve numbers are assigned sequentially from the starting values, and the
|
|
|
|
| 82 |
|
| 83 |
### G-code XY Step Size
|
| 84 |
|
| 85 |
-
- G-code generation uses the slicer's `
|
| 86 |
|
| 87 |
### G-code Output
|
| 88 |
|
| 89 |
- Generated G-code starts in relative coordinate mode (`G91`).
|
| 90 |
- `G0` is travel and `G1` is print/feed.
|
| 91 |
-
- The app generates print/feed moves
|
| 92 |
- Generated files include pressure preset commands and WAGO valve commands based on the selected pressure, valve, and port; the nozzle number controls layout/spacing assignment.
|
| 93 |
- Pressure increases by `0.1` psi per layer by default.
|
| 94 |
- **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.
|
| 95 |
-
- **Use
|
| 96 |
-
- **Raster Pattern**: `X-direction raster`
|
| 97 |
- **Auto Align Split Parts**: in Nozzle Spacing, fills Grid Layout gaps for split-piece alignment. X-direction raster uses X `-3.2` mm and Y `-0.8` mm; Y-direction raster switches those values.
|
| 98 |
-
- **Contour Tracing**: enabled per row in Shape Settings. The app
|
| 99 |
|
| 100 |
### Print vs Travel Classification
|
| 101 |
|
|
@@ -115,7 +114,7 @@ The G-code visualization tab renders generated shape G-code or an uploaded `.txt
|
|
| 115 |
|
| 116 |
### Parallel Printing Visualization
|
| 117 |
|
| 118 |
-
The fourth tab plots the generated shapes' G-code at once using the nozzle spacing configured on the
|
| 119 |
|
| 120 |
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).
|
| 121 |
|
|
|
|
| 5 |
python_version: "3.12"
|
| 6 |
app_file: app.py
|
| 7 |
fullWidth: true
|
| 8 |
+
short_description: Upload STLs, slice to vector outlines, and generate G-code.
|
| 9 |
---
|
| 10 |
|
| 11 |
# STL to G-Code Gradio App
|
| 12 |
|
| 13 |
+
This project provides a Gradio app that takes any number of uploaded STL files, shows a selected-shape 3D viewer, slices each model along the Z axis into per-layer vector outlines (shapely polygons), generates parallel-nozzle G-code directly from those outlines, previews the resulting tool path (a fast line plot or an animated 3D tube plot), and can visualize the shapes printing in parallel and export that animation as a GIF.
|
| 14 |
|
| 15 |
## Prerequisites
|
| 16 |
|
|
|
|
| 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 slice the shapes.
|
| 37 |
|
| 38 |
## What the app does
|
| 39 |
|
|
|
|
| 43 |
- Shows an interactive selected-shape 3D viewer for rotating each model
|
| 44 |
- Shows model extents, face count, vertex count, and watertight status
|
| 45 |
- Scales loaded STLs from editable target X/Y/Z dimensions in the Shape Settings table; new rows default to the STL's original dimensions, **Reset Dimensions** restores them, and **Keep Proportions** updates the other target sides from the edited side
|
| 46 |
+
- Lets you choose layer height and filament/line width
|
| 47 |
+
- Slices each shape into per-layer vector outlines held in memory (no intermediate image files)
|
| 48 |
+
- Automatically unions the sliced shapes into a combined reference layer set whenever shapes are sliced
|
| 49 |
+
- Splits one sliced shape's geometry into an editable row/column grid for multi-nozzle printing of one large shape
|
| 50 |
+
- Converts sliced layers into G-code files with pressure, valve, nozzle, and port settings per shape from the Shape Settings table
|
| 51 |
+
- Appends a shape outline contour after each enabled shape layer by tracing that layer's polygon boundary
|
| 52 |
+
- Offers G-code generation options for raster pattern, **Use G1 for all moves** (no rapid travel command), and **Use combined reference outline for motion** (all shapes share one nozzle path; each dispenses only its own geometry)
|
| 53 |
+
- Re-slices shapes automatically during G-code generation when their slices are missing or stale, so "upload, then Generate G-Code" works in one click
|
|
|
|
|
|
|
|
|
|
| 54 |
- Calculates X/Y nozzle spacing from an editable adjacent-pair spacing table, then visualizes the resulting nozzle layout
|
| 55 |
- Previews selected generated G-code inline
|
| 56 |
- Visualizes generated or uploaded G-code tool paths, with the source selectable from any active generated shape or an uploaded file
|
|
|
|
| 59 |
|
| 60 |
## Behavior and Implementation Notes
|
| 61 |
|
| 62 |
+
### Vector Slicing
|
| 63 |
|
| 64 |
+
Slicing uses `trimesh` cross-sections composed into shapely polygons (`slice_stl_to_layers` in `stl_slicer.py`). Each layer is a `MultiPolygon` in world-XY millimetres; the whole shape is a `LayerStack` (layers, z-values, bounds, layer height) held in the Gradio session state — no files are written until G-code is generated.
|
| 65 |
|
| 66 |
+
### Reference Layer Union
|
| 67 |
+
|
| 68 |
+
When you click **Slice Shapes**, the app automatically unions the sliced shapes layer-by-layer into a combined reference layer set (used for shared-motion G-code).
|
| 69 |
+
|
| 70 |
+
- Shapes are aligned by centering each shape's XY bounding box on a common center before the union.
|
| 71 |
+
- Alignment is centered placement (in exact millimetres), not bottom-left anchoring.
|
| 72 |
|
| 73 |
### Multi-Nozzle Split
|
| 74 |
|
| 75 |
+
The **Multi-Nozzle Split** accordion on the **Shapes & Slicing** tab can split one sliced shape into a grid of print-ready piece stacks. Choose a source shape that has been sliced, set the number of columns and rows, choose the starting nozzle and valve numbers, then click **Split Selected Shape into Grid Pieces**.
|
| 76 |
|
| 77 |
+
- Each layer's geometry is clipped against equal-size grid cells, so every piece keeps exact vector outlines.
|
| 78 |
- The selected shape is replaced in Shape Settings by one generated record per grid cell, named by row and column.
|
| 79 |
+
- Nozzle and valve numbers are assigned sequentially from the starting values, and the **Generate G-Code** tab can generate separate G-code for each piece.
|
| 80 |
+
- **Overlapping Layers** alternates the interior cut lines by one filament width per layer so neighbouring pieces interlock.
|
| 81 |
|
| 82 |
### G-code XY Step Size
|
| 83 |
|
| 84 |
+
- G-code generation uses the slicer tab's `Filament/Line Width` as the raster line spacing by passing `fil_width` into `generate_vector_gcode()`.
|
| 85 |
|
| 86 |
### G-code Output
|
| 87 |
|
| 88 |
- Generated G-code starts in relative coordinate mode (`G91`).
|
| 89 |
- `G0` is travel and `G1` is print/feed.
|
| 90 |
+
- The app generates print/feed moves where the tool path is inside a shape's layer polygons and travel moves elsewhere.
|
| 91 |
- Generated files include pressure preset commands and WAGO valve commands based on the selected pressure, valve, and port; the nozzle number controls layout/spacing assignment.
|
| 92 |
- Pressure increases by `0.1` psi per layer by default.
|
| 93 |
- **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.
|
| 94 |
+
- **Use combined reference outline for motion**: when enabled, every shape's *motion* is taken from the combined reference layer union while each shape's *valve/dispensing* comes from its own layer polygons — so parallel print heads share one synchronized nozzle path and each deposits only its own geometry. The reference union is rebuilt automatically when shapes are sliced.
|
| 95 |
+
- **Raster Pattern**: `X-direction raster` sweeps every layer back-and-forth in X. `Y-direction raster` rasters every layer in Y. `Woodpile raster` alternates the raster axis by layer, switching between X-direction and Y-direction sweeps. `Rectangular Spiral raster` walks each layer from the outer layer bounds toward the center, then reverses from center to edge on the next layer. `Circle Spiral raster` uses a shrinking circular spiral from the layer bounds toward the center, then reverses outward on the next layer. Spiral motion covers the layer bounds; the valve opens only where the path is inside material.
|
| 96 |
- **Auto Align Split Parts**: in Nozzle Spacing, fills Grid Layout gaps for split-piece alignment. X-direction raster uses X `-3.2` mm and Y `-0.8` mm; Y-direction raster switches those values.
|
| 97 |
+
- **Contour Tracing**: enabled per row in Shape Settings. The app traces the layer polygon's boundary rings (holes traced separately), travels from the layer raster end to the nearest contour point, prints the contour, then returns to the raster endpoint before the next layer.
|
| 98 |
|
| 99 |
### Print vs Travel Classification
|
| 100 |
|
|
|
|
| 114 |
|
| 115 |
### Parallel Printing Visualization
|
| 116 |
|
| 117 |
+
The fourth tab plots the generated shapes' G-code at once using the nozzle spacing configured on the Generate G-Code tab, each in its own color. Shape Settings maps each STL to a nozzle number, so multiple shapes can share one nozzle offset while valves remain independent. 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.
|
| 118 |
|
| 119 |
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).
|
| 120 |
|
app.py
CHANGED
|
@@ -1,10 +1,9 @@
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
| 3 |
-
import tempfile
|
| 4 |
import math
|
|
|
|
| 5 |
import time
|
| 6 |
import warnings
|
| 7 |
-
import zipfile
|
| 8 |
from pathlib import Path
|
| 9 |
from typing import Any
|
| 10 |
|
|
@@ -20,7 +19,6 @@ warnings.filterwarnings(
|
|
| 20 |
|
| 21 |
import gradio as gr
|
| 22 |
import numpy as np
|
| 23 |
-
from PIL import Image, ImageDraw, ImageFont
|
| 24 |
import trimesh
|
| 25 |
|
| 26 |
from gcode_viewer import (
|
|
@@ -31,22 +29,23 @@ from gcode_viewer import (
|
|
| 31 |
parse_gcode_path,
|
| 32 |
)
|
| 33 |
from stl_slicer import (
|
| 34 |
-
|
| 35 |
load_mesh,
|
| 36 |
scale_factors_for_target_extents,
|
| 37 |
scale_mesh,
|
| 38 |
-
|
| 39 |
)
|
| 40 |
-
from
|
| 41 |
-
|
| 42 |
RASTER_PATTERN_CHOICES,
|
| 43 |
RASTER_PATTERN_SAME_DIRECTION,
|
| 44 |
RASTER_PATTERN_Y_DIRECTION,
|
| 45 |
-
|
|
|
|
|
|
|
| 46 |
)
|
| 47 |
|
| 48 |
|
| 49 |
-
ViewerState = dict[str, Any]
|
| 50 |
SAMPLE_STL_FILENAMES = ("Hollow_Pyramid.stl", "Rounded_Cube_Through_Holes.stl", "halfsphere.stl")
|
| 51 |
SAMPLE_STL_DIR = Path(__file__).resolve().parent / "sample_stls"
|
| 52 |
DEFAULT_TARGET_EXTENTS = (20.0, 20.0, 20.0)
|
|
@@ -783,54 +782,6 @@ PARALLEL_CONTROLS_HTML = """
|
|
| 783 |
"""
|
| 784 |
|
| 785 |
|
| 786 |
-
def _read_slice_preview(path: str) -> Image.Image:
|
| 787 |
-
with Image.open(path) as image:
|
| 788 |
-
preview = image.copy()
|
| 789 |
-
|
| 790 |
-
# Upscale low-resolution TIFF previews so they fill the viewer area better.
|
| 791 |
-
min_display_side = 480
|
| 792 |
-
width, height = preview.size
|
| 793 |
-
max_dim = max(width, height)
|
| 794 |
-
if max_dim > 0 and max_dim < min_display_side:
|
| 795 |
-
scale = min_display_side / max_dim
|
| 796 |
-
new_size = (
|
| 797 |
-
max(1, int(round(width * scale))),
|
| 798 |
-
max(1, int(round(height * scale))),
|
| 799 |
-
)
|
| 800 |
-
preview = preview.resize(new_size, resample=Image.Resampling.NEAREST)
|
| 801 |
-
|
| 802 |
-
return preview
|
| 803 |
-
|
| 804 |
-
|
| 805 |
-
def _empty_state() -> ViewerState:
|
| 806 |
-
return {
|
| 807 |
-
"tiff_paths": [],
|
| 808 |
-
"z_values": [],
|
| 809 |
-
"pixel_size": 0.0,
|
| 810 |
-
"x_min": 0.0,
|
| 811 |
-
"y_min": 0.0,
|
| 812 |
-
"image_width": 0,
|
| 813 |
-
"image_height": 0,
|
| 814 |
-
}
|
| 815 |
-
|
| 816 |
-
|
| 817 |
-
def _reset_slider() -> dict[str, Any]:
|
| 818 |
-
return gr.update(minimum=0, maximum=0, value=0, step=1, interactive=False)
|
| 819 |
-
|
| 820 |
-
|
| 821 |
-
def _stack_to_state(stack: SliceStack) -> ViewerState:
|
| 822 |
-
(x_min, y_min, _z_min), (_x_max, _y_max, _z_max) = stack.bounds
|
| 823 |
-
return {
|
| 824 |
-
"tiff_paths": [str(path) for path in stack.tiff_paths],
|
| 825 |
-
"z_values": stack.z_values,
|
| 826 |
-
"pixel_size": stack.pixel_size,
|
| 827 |
-
"x_min": x_min,
|
| 828 |
-
"y_min": y_min,
|
| 829 |
-
"image_width": stack.image_size[0],
|
| 830 |
-
"image_height": stack.image_size[1],
|
| 831 |
-
}
|
| 832 |
-
|
| 833 |
-
|
| 834 |
def _format_model_details(
|
| 835 |
source_name: str,
|
| 836 |
mesh,
|
|
@@ -865,190 +816,6 @@ def _format_model_details(
|
|
| 865 |
return "\n".join(lines)
|
| 866 |
|
| 867 |
|
| 868 |
-
def _slice_label(state: ViewerState, index: int) -> str:
|
| 869 |
-
path = Path(state["tiff_paths"][index]).name
|
| 870 |
-
z_value = state["z_values"][index]
|
| 871 |
-
total = len(state["tiff_paths"])
|
| 872 |
-
return f"Slice {index + 1} / {total} | z = {z_value:.4f} | {path}"
|
| 873 |
-
|
| 874 |
-
|
| 875 |
-
def _annotate_preview(
|
| 876 |
-
image: Image.Image,
|
| 877 |
-
pixel_size: float,
|
| 878 |
-
x_min: float,
|
| 879 |
-
y_min: float,
|
| 880 |
-
orig_width: int,
|
| 881 |
-
orig_height: int,
|
| 882 |
-
) -> Image.Image:
|
| 883 |
-
"""Draw a blue origin crosshair with axis labels and a scale bar."""
|
| 884 |
-
rgb = image.convert("RGB")
|
| 885 |
-
draw = ImageDraw.Draw(rgb)
|
| 886 |
-
|
| 887 |
-
preview_w, preview_h = rgb.size
|
| 888 |
-
scale_x = preview_w / orig_width if orig_width else 1.0
|
| 889 |
-
scale_y = preview_h / orig_height if orig_height else 1.0
|
| 890 |
-
|
| 891 |
-
BLUE = (50, 120, 255)
|
| 892 |
-
|
| 893 |
-
try:
|
| 894 |
-
font = ImageFont.load_default(size=14)
|
| 895 |
-
except TypeError:
|
| 896 |
-
font = ImageFont.load_default()
|
| 897 |
-
try:
|
| 898 |
-
small_font = ImageFont.load_default(size=12)
|
| 899 |
-
except TypeError:
|
| 900 |
-
small_font = font
|
| 901 |
-
|
| 902 |
-
# --- Origin crosshair & axis indicators ---
|
| 903 |
-
origin_px = (0.0 - x_min) / pixel_size
|
| 904 |
-
origin_py_from_bottom = (0.0 - y_min) / pixel_size
|
| 905 |
-
origin_img_y = orig_height - 1 - origin_py_from_bottom
|
| 906 |
-
|
| 907 |
-
ox = int(round(origin_px * scale_x))
|
| 908 |
-
oy = int(round(origin_img_y * scale_y))
|
| 909 |
-
|
| 910 |
-
arm = 20
|
| 911 |
-
margin_edge = 8 # inset from image border for off-screen indicators
|
| 912 |
-
on_screen = 0 <= ox < preview_w and 0 <= oy < preview_h
|
| 913 |
-
|
| 914 |
-
if on_screen:
|
| 915 |
-
# +X axis (rightward)
|
| 916 |
-
x_start = max(0, ox)
|
| 917 |
-
x_end = min(preview_w - 1, ox + arm)
|
| 918 |
-
if x_end > x_start:
|
| 919 |
-
draw.line([(x_start, oy), (x_end, oy)], fill=BLUE, width=2)
|
| 920 |
-
draw.polygon(
|
| 921 |
-
[(x_end, oy), (x_end - 5, oy - 4), (x_end - 5, oy + 4)],
|
| 922 |
-
fill=BLUE,
|
| 923 |
-
)
|
| 924 |
-
if x_end + 4 < preview_w:
|
| 925 |
-
draw.text((x_end + 4, oy - 7), "X", fill=BLUE, font=small_font)
|
| 926 |
-
|
| 927 |
-
# +Y axis (upward in world = upward in image)
|
| 928 |
-
y_end = max(0, oy - arm)
|
| 929 |
-
y_start = min(preview_h - 1, oy)
|
| 930 |
-
if y_start > y_end:
|
| 931 |
-
draw.line([(ox, y_start), (ox, y_end)], fill=BLUE, width=2)
|
| 932 |
-
draw.polygon(
|
| 933 |
-
[(ox, y_end), (ox - 4, y_end + 5), (ox + 4, y_end + 5)],
|
| 934 |
-
fill=BLUE,
|
| 935 |
-
)
|
| 936 |
-
if y_end - 16 >= 0:
|
| 937 |
-
draw.text((ox + 5, y_end - 16), "Y", fill=BLUE, font=small_font)
|
| 938 |
-
|
| 939 |
-
# -X stub (leftward from origin)
|
| 940 |
-
stub = min(8, max(0, ox))
|
| 941 |
-
if stub > 0:
|
| 942 |
-
draw.line([(ox - stub, oy), (ox, oy)], fill=BLUE, width=2)
|
| 943 |
-
|
| 944 |
-
# -Y stub (downward from origin in image)
|
| 945 |
-
stub_y = min(8, max(0, preview_h - 1 - oy))
|
| 946 |
-
if stub_y > 0:
|
| 947 |
-
draw.line([(ox, oy), (ox, oy + stub_y)], fill=BLUE, width=2)
|
| 948 |
-
|
| 949 |
-
# Origin label
|
| 950 |
-
lx = ox + arm + 4 if ox + arm + 40 < preview_w else ox - 45
|
| 951 |
-
ly = oy + 6
|
| 952 |
-
if 0 <= ly < preview_h:
|
| 953 |
-
draw.text((max(0, lx), ly), "(0, 0)", fill=BLUE, font=small_font)
|
| 954 |
-
|
| 955 |
-
else:
|
| 956 |
-
# Origin is off-screen — draw edge indicator(s) pointing toward it.
|
| 957 |
-
arrow_len = 14
|
| 958 |
-
arrow_half = 5
|
| 959 |
-
|
| 960 |
-
# Compute direction label text showing approximate origin coordinates
|
| 961 |
-
origin_x_mm = x_min
|
| 962 |
-
origin_y_mm = y_min
|
| 963 |
-
coord_text = f"Origin ({-origin_x_mm:+.1f}, {-origin_y_mm:+.1f})"
|
| 964 |
-
|
| 965 |
-
if ox < 0:
|
| 966 |
-
# Origin is to the LEFT — draw left-pointing arrow on left edge
|
| 967 |
-
ay = max(margin_edge + arrow_half, min(preview_h - margin_edge - arrow_half, oy))
|
| 968 |
-
draw.polygon(
|
| 969 |
-
[(margin_edge, ay), (margin_edge + arrow_len, ay - arrow_half), (margin_edge + arrow_len, ay + arrow_half)],
|
| 970 |
-
fill=BLUE,
|
| 971 |
-
)
|
| 972 |
-
draw.text((margin_edge + arrow_len + 4, ay - 7), coord_text, fill=BLUE, font=small_font)
|
| 973 |
-
elif ox >= preview_w:
|
| 974 |
-
# Origin is to the RIGHT
|
| 975 |
-
ay = max(margin_edge + arrow_half, min(preview_h - margin_edge - arrow_half, oy))
|
| 976 |
-
rx = preview_w - margin_edge
|
| 977 |
-
draw.polygon(
|
| 978 |
-
[(rx, ay), (rx - arrow_len, ay - arrow_half), (rx - arrow_len, ay + arrow_half)],
|
| 979 |
-
fill=BLUE,
|
| 980 |
-
)
|
| 981 |
-
tw = len(coord_text) * 7
|
| 982 |
-
draw.text((max(0, rx - arrow_len - tw - 4), ay - 7), coord_text, fill=BLUE, font=small_font)
|
| 983 |
-
|
| 984 |
-
if oy < 0:
|
| 985 |
-
# Origin is ABOVE — draw upward-pointing arrow on top edge
|
| 986 |
-
ax = max(margin_edge + arrow_half, min(preview_w - margin_edge - arrow_half, ox))
|
| 987 |
-
draw.polygon(
|
| 988 |
-
[(ax, margin_edge), (ax - arrow_half, margin_edge + arrow_len), (ax + arrow_half, margin_edge + arrow_len)],
|
| 989 |
-
fill=BLUE,
|
| 990 |
-
)
|
| 991 |
-
elif oy >= preview_h:
|
| 992 |
-
# Origin is BELOW — draw downward-pointing arrow on bottom edge
|
| 993 |
-
ax = max(margin_edge + arrow_half, min(preview_w - margin_edge - arrow_half, ox))
|
| 994 |
-
by = preview_h - margin_edge
|
| 995 |
-
draw.polygon(
|
| 996 |
-
[(ax, by), (ax - arrow_half, by - arrow_len), (ax + arrow_half, by - arrow_len)],
|
| 997 |
-
fill=BLUE,
|
| 998 |
-
)
|
| 999 |
-
# If we didn't already draw a left/right label, label here
|
| 1000 |
-
if 0 <= ox < preview_w:
|
| 1001 |
-
draw.text((ax + arrow_half + 4, by - arrow_len - 2), coord_text, fill=BLUE, font=small_font)
|
| 1002 |
-
|
| 1003 |
-
# --- Scale bar (bottom-left) ---
|
| 1004 |
-
image_width_mm = orig_width * pixel_size
|
| 1005 |
-
target_bar_mm = image_width_mm * 0.2
|
| 1006 |
-
nice = [0.05, 0.1, 0.2, 0.5, 1, 2, 5, 10, 20, 50, 100, 200, 500]
|
| 1007 |
-
bar_mm = min(nice, key=lambda v: abs(v - target_bar_mm))
|
| 1008 |
-
|
| 1009 |
-
bar_px = (bar_mm / pixel_size) * scale_x
|
| 1010 |
-
margin = 12
|
| 1011 |
-
bar_y = preview_h - margin
|
| 1012 |
-
bar_x0 = margin
|
| 1013 |
-
bar_x1 = bar_x0 + bar_px
|
| 1014 |
-
cap = 5
|
| 1015 |
-
|
| 1016 |
-
draw.line([(int(bar_x0), int(bar_y)), (int(bar_x1), int(bar_y))], fill=BLUE, width=3)
|
| 1017 |
-
draw.line([(int(bar_x0), int(bar_y - cap)), (int(bar_x0), int(bar_y + cap))], fill=BLUE, width=2)
|
| 1018 |
-
draw.line([(int(bar_x1), int(bar_y - cap)), (int(bar_x1), int(bar_y + cap))], fill=BLUE, width=2)
|
| 1019 |
-
|
| 1020 |
-
bar_label = f"{bar_mm:g} mm"
|
| 1021 |
-
draw.text((int(bar_x0), int(bar_y - 20)), bar_label, fill=BLUE, font=font)
|
| 1022 |
-
|
| 1023 |
-
return rgb
|
| 1024 |
-
|
| 1025 |
-
|
| 1026 |
-
def _render_selected_slice(state: ViewerState, index: int) -> tuple[str, Image.Image | None]:
|
| 1027 |
-
tiff_paths = state.get("tiff_paths", [])
|
| 1028 |
-
if not tiff_paths:
|
| 1029 |
-
return "No slice stack loaded yet.", None
|
| 1030 |
-
|
| 1031 |
-
bounded_index = max(0, min(int(index), len(tiff_paths) - 1))
|
| 1032 |
-
selected_path = tiff_paths[bounded_index]
|
| 1033 |
-
preview = _read_slice_preview(selected_path)
|
| 1034 |
-
|
| 1035 |
-
pixel_size = state.get("pixel_size", 0.0)
|
| 1036 |
-
if pixel_size and pixel_size > 0:
|
| 1037 |
-
preview = _annotate_preview(
|
| 1038 |
-
preview,
|
| 1039 |
-
pixel_size=pixel_size,
|
| 1040 |
-
x_min=state.get("x_min", 0.0),
|
| 1041 |
-
y_min=state.get("y_min", 0.0),
|
| 1042 |
-
orig_width=state.get("image_width", 0) or preview.size[0],
|
| 1043 |
-
orig_height=state.get("image_height", 0) or preview.size[1],
|
| 1044 |
-
)
|
| 1045 |
-
|
| 1046 |
-
return (
|
| 1047 |
-
_slice_label(state, bounded_index),
|
| 1048 |
-
preview,
|
| 1049 |
-
)
|
| 1050 |
-
|
| 1051 |
-
|
| 1052 |
def _opacity_to_alpha(opacity: float) -> int:
|
| 1053 |
bounded = max(0.05, min(float(opacity), 1.0))
|
| 1054 |
return int(round(255 * bounded))
|
|
@@ -1383,10 +1150,6 @@ def load_single_model(
|
|
| 1383 |
return _viewer_update(glb_path), _format_model_details(Path(stl_file).name, mesh, scale_factors)
|
| 1384 |
|
| 1385 |
|
| 1386 |
-
def jump_to_slice(state: ViewerState, index: float) -> tuple[str, Image.Image | None]:
|
| 1387 |
-
return _render_selected_slice(state, int(index))
|
| 1388 |
-
|
| 1389 |
-
|
| 1390 |
GCODE_SOURCE_UPLOAD = "Upload G-Code file"
|
| 1391 |
|
| 1392 |
|
|
@@ -1718,342 +1481,6 @@ def _format_nozzle_spacing_status(
|
|
| 1718 |
return " \n".join(lines)
|
| 1719 |
|
| 1720 |
|
| 1721 |
-
def shift_slice(state: ViewerState, index: float, delta: int) -> tuple[int, str, Image.Image | None]:
|
| 1722 |
-
tiff_paths = state.get("tiff_paths", [])
|
| 1723 |
-
if not tiff_paths:
|
| 1724 |
-
return 0, "No slice stack loaded yet.", None
|
| 1725 |
-
|
| 1726 |
-
new_index = max(0, min(int(index) + delta, len(tiff_paths) - 1))
|
| 1727 |
-
label, preview = _render_selected_slice(state, new_index)
|
| 1728 |
-
return new_index, label, preview
|
| 1729 |
-
|
| 1730 |
-
|
| 1731 |
-
def generate_reference_stack(
|
| 1732 |
-
*states: ViewerState,
|
| 1733 |
-
progress: gr.Progress = gr.Progress(),
|
| 1734 |
-
) -> tuple:
|
| 1735 |
-
"""Combine all available TIFF stacks into a single reference stack.
|
| 1736 |
-
|
| 1737 |
-
For each pixel in each layer the result is black (0) when *any* source
|
| 1738 |
-
stack has a black pixel at that position, and white (255) only when *all*
|
| 1739 |
-
sources are white. Images of different sizes are centred on a canvas
|
| 1740 |
-
sized to the largest dimensions.
|
| 1741 |
-
"""
|
| 1742 |
-
active_states = [s for s in states if s.get("tiff_paths")]
|
| 1743 |
-
|
| 1744 |
-
if not active_states:
|
| 1745 |
-
return (
|
| 1746 |
-
_empty_state(),
|
| 1747 |
-
_reset_slider(),
|
| 1748 |
-
"No TIFF stacks available. Generate TIFF stacks first.",
|
| 1749 |
-
None,
|
| 1750 |
-
)
|
| 1751 |
-
|
| 1752 |
-
max_layers = max(len(s["tiff_paths"]) for s in active_states)
|
| 1753 |
-
|
| 1754 |
-
# Determine the largest image dimensions across all stacks.
|
| 1755 |
-
max_width = 0
|
| 1756 |
-
max_height = 0
|
| 1757 |
-
source_sizes: list[tuple[int, int]] = []
|
| 1758 |
-
for state in active_states:
|
| 1759 |
-
w = state.get("image_width", 0)
|
| 1760 |
-
h = state.get("image_height", 0)
|
| 1761 |
-
if not w or not h:
|
| 1762 |
-
with Image.open(state["tiff_paths"][0]) as img:
|
| 1763 |
-
w, h = img.size
|
| 1764 |
-
source_sizes.append((w, h))
|
| 1765 |
-
max_width = max(max_width, w)
|
| 1766 |
-
max_height = max(max_height, h)
|
| 1767 |
-
|
| 1768 |
-
# Compute annotation metadata from the first active state, accounting for
|
| 1769 |
-
# the centering offset applied to its image on the larger canvas.
|
| 1770 |
-
first = active_states[0]
|
| 1771 |
-
first_w, first_h = source_sizes[0]
|
| 1772 |
-
ref_pixel_size = first.get("pixel_size", 0.0)
|
| 1773 |
-
x_off_first = (max_width - first_w) // 2
|
| 1774 |
-
y_off_first = (max_height - first_h) // 2
|
| 1775 |
-
ref_x_min = first.get("x_min", 0.0) - x_off_first * ref_pixel_size
|
| 1776 |
-
ref_y_min = first.get("y_min", 0.0) - y_off_first * ref_pixel_size
|
| 1777 |
-
|
| 1778 |
-
output_dir = Path(tempfile.mkdtemp(prefix="reference_stack_"))
|
| 1779 |
-
slices_dir = output_dir / "tiff_slices"
|
| 1780 |
-
slices_dir.mkdir(parents=True, exist_ok=True)
|
| 1781 |
-
|
| 1782 |
-
tiff_paths: list[Path] = []
|
| 1783 |
-
z_values: list[float] = []
|
| 1784 |
-
|
| 1785 |
-
for layer_idx in range(max_layers):
|
| 1786 |
-
progress(
|
| 1787 |
-
layer_idx / max_layers,
|
| 1788 |
-
desc=f"Compositing reference layer {layer_idx + 1}/{max_layers}",
|
| 1789 |
-
)
|
| 1790 |
-
|
| 1791 |
-
# Start with an all-white canvas.
|
| 1792 |
-
ref_array = np.full((max_height, max_width), 255, dtype=np.uint8)
|
| 1793 |
-
|
| 1794 |
-
for state in active_states:
|
| 1795 |
-
paths = state["tiff_paths"]
|
| 1796 |
-
if layer_idx >= len(paths):
|
| 1797 |
-
continue # Stack exhausted – contributes white.
|
| 1798 |
-
|
| 1799 |
-
with Image.open(paths[layer_idx]) as img:
|
| 1800 |
-
arr = np.asarray(img)
|
| 1801 |
-
|
| 1802 |
-
h, w = arr.shape[:2]
|
| 1803 |
-
y_off = (max_height - h) // 2
|
| 1804 |
-
x_off = (max_width - w) // 2
|
| 1805 |
-
|
| 1806 |
-
# Black (0) wins: pixel-wise minimum keeps any black pixel.
|
| 1807 |
-
region = ref_array[y_off : y_off + h, x_off : x_off + w]
|
| 1808 |
-
ref_array[y_off : y_off + h, x_off : x_off + w] = np.minimum(region, arr)
|
| 1809 |
-
|
| 1810 |
-
ref_image = Image.fromarray(ref_array, mode="L")
|
| 1811 |
-
tiff_path = slices_dir / f"ref_slice_{layer_idx:04d}.tif"
|
| 1812 |
-
ref_image.save(tiff_path, compression="tiff_deflate")
|
| 1813 |
-
tiff_paths.append(tiff_path)
|
| 1814 |
-
|
| 1815 |
-
# Use z-value from the first active state that covers this layer.
|
| 1816 |
-
z_val = 0.0
|
| 1817 |
-
for state in active_states:
|
| 1818 |
-
if layer_idx < len(state["z_values"]):
|
| 1819 |
-
z_val = state["z_values"][layer_idx]
|
| 1820 |
-
break
|
| 1821 |
-
z_values.append(z_val)
|
| 1822 |
-
|
| 1823 |
-
ref_state: ViewerState = {
|
| 1824 |
-
"tiff_paths": [str(p) for p in tiff_paths],
|
| 1825 |
-
"z_values": z_values,
|
| 1826 |
-
"pixel_size": ref_pixel_size,
|
| 1827 |
-
"x_min": ref_x_min,
|
| 1828 |
-
"y_min": ref_y_min,
|
| 1829 |
-
"image_width": max_width,
|
| 1830 |
-
"image_height": max_height,
|
| 1831 |
-
}
|
| 1832 |
-
|
| 1833 |
-
label, preview = _render_selected_slice(ref_state, 0)
|
| 1834 |
-
slider = gr.update(
|
| 1835 |
-
minimum=0,
|
| 1836 |
-
maximum=max(0, len(tiff_paths) - 1),
|
| 1837 |
-
value=0,
|
| 1838 |
-
step=1,
|
| 1839 |
-
interactive=len(tiff_paths) > 1,
|
| 1840 |
-
)
|
| 1841 |
-
|
| 1842 |
-
return ref_state, slider, label, preview
|
| 1843 |
-
|
| 1844 |
-
|
| 1845 |
-
def _zip_tiff_paths(tiff_paths: list[Path], zip_path: Path) -> None:
|
| 1846 |
-
with zipfile.ZipFile(zip_path, mode="w", compression=zipfile.ZIP_DEFLATED) as archive:
|
| 1847 |
-
for tiff_path in tiff_paths:
|
| 1848 |
-
archive.write(tiff_path, arcname=tiff_path.name)
|
| 1849 |
-
|
| 1850 |
-
|
| 1851 |
-
def _partition_length(length: int, count: int) -> list[tuple[int, int]]:
|
| 1852 |
-
base = length // count
|
| 1853 |
-
remainder = length % count
|
| 1854 |
-
spans: list[tuple[int, int]] = []
|
| 1855 |
-
start = 0
|
| 1856 |
-
for index in range(count):
|
| 1857 |
-
size = base + (1 if index < remainder else 0)
|
| 1858 |
-
end = start + size
|
| 1859 |
-
spans.append((start, end))
|
| 1860 |
-
start = end
|
| 1861 |
-
return spans
|
| 1862 |
-
|
| 1863 |
-
|
| 1864 |
-
def _padded_grid_axis(length: int, count: int) -> dict[str, Any]:
|
| 1865 |
-
cell_size = max(1, math.ceil(length / count))
|
| 1866 |
-
padded_length = cell_size * count
|
| 1867 |
-
total_pad = padded_length - length
|
| 1868 |
-
leading_pad = total_pad // 2
|
| 1869 |
-
trailing_pad = total_pad - leading_pad
|
| 1870 |
-
spans = [
|
| 1871 |
-
(index * cell_size, (index + 1) * cell_size)
|
| 1872 |
-
for index in range(count)
|
| 1873 |
-
]
|
| 1874 |
-
return {
|
| 1875 |
-
"cell_size": cell_size,
|
| 1876 |
-
"padded_length": padded_length,
|
| 1877 |
-
"leading_pad": leading_pad,
|
| 1878 |
-
"trailing_pad": trailing_pad,
|
| 1879 |
-
"spans": spans,
|
| 1880 |
-
}
|
| 1881 |
-
|
| 1882 |
-
|
| 1883 |
-
def _layer_split_spans(length: int, count: int, layer_index: int, overlap_pixels: int) -> list[tuple[int, int]]:
|
| 1884 |
-
base_spans = _partition_length(length, count)
|
| 1885 |
-
if overlap_pixels <= 0 or count <= 1:
|
| 1886 |
-
return base_spans
|
| 1887 |
-
|
| 1888 |
-
boundaries = [base_spans[0][0], *[end for _start, end in base_spans]]
|
| 1889 |
-
adjusted = list(boundaries)
|
| 1890 |
-
for boundary_index in range(1, len(boundaries) - 1):
|
| 1891 |
-
direction = 1 if (layer_index + boundary_index) % 2 == 1 else -1
|
| 1892 |
-
lower = adjusted[boundary_index - 1] + 1
|
| 1893 |
-
upper = boundaries[boundary_index + 1] - 1
|
| 1894 |
-
adjusted[boundary_index] = max(lower, min(upper, boundaries[boundary_index] + direction * overlap_pixels))
|
| 1895 |
-
return [(adjusted[index], adjusted[index + 1]) for index in range(count)]
|
| 1896 |
-
|
| 1897 |
-
|
| 1898 |
-
def split_tiff_stack_grid(
|
| 1899 |
-
state: ViewerState,
|
| 1900 |
-
base_name: str = "split_shape",
|
| 1901 |
-
columns: float = 2,
|
| 1902 |
-
rows: float = 1,
|
| 1903 |
-
overlapping_layers: bool | None = False,
|
| 1904 |
-
progress: gr.Progress = gr.Progress(),
|
| 1905 |
-
) -> list[dict[str, Any]]:
|
| 1906 |
-
tiff_paths = [Path(path) for path in state.get("tiff_paths", [])]
|
| 1907 |
-
if not tiff_paths:
|
| 1908 |
-
raise ValueError("Generate a TIFF stack for the selected shape before splitting it.")
|
| 1909 |
-
|
| 1910 |
-
with Image.open(tiff_paths[0]) as first_image:
|
| 1911 |
-
width, height = first_image.size
|
| 1912 |
-
column_count = max(1, _coerce_int(columns, 2))
|
| 1913 |
-
row_count = max(1, _coerce_int(rows, 1))
|
| 1914 |
-
if column_count > width:
|
| 1915 |
-
raise ValueError(f"Cannot split {width}-pixel-wide TIFF slices into {column_count} columns.")
|
| 1916 |
-
if row_count > height:
|
| 1917 |
-
raise ValueError(f"Cannot split {height}-pixel-tall TIFF slices into {row_count} rows.")
|
| 1918 |
-
|
| 1919 |
-
safe_name = "".join(ch if ch.isalnum() or ch in {"-", "_"} else "_" for ch in base_name).strip("_") or "split_shape"
|
| 1920 |
-
output_dir = Path(tempfile.mkdtemp(prefix=f"{safe_name}_split_"))
|
| 1921 |
-
x_axis = _padded_grid_axis(width, column_count)
|
| 1922 |
-
y_axis = _padded_grid_axis(height, row_count)
|
| 1923 |
-
x_spans = x_axis["spans"]
|
| 1924 |
-
y_spans = y_axis["spans"]
|
| 1925 |
-
overlap_pixels = 1 if overlapping_layers else 0
|
| 1926 |
-
overlap_x = overlap_pixels if column_count > 1 else 0
|
| 1927 |
-
overlap_y = overlap_pixels if row_count > 1 else 0
|
| 1928 |
-
padded_width = int(x_axis["padded_length"])
|
| 1929 |
-
padded_height = int(y_axis["padded_length"])
|
| 1930 |
-
working_width = padded_width + (2 * overlap_x)
|
| 1931 |
-
working_height = padded_height + (2 * overlap_y)
|
| 1932 |
-
source_x = int(x_axis["leading_pad"]) + overlap_x
|
| 1933 |
-
source_y = int(y_axis["leading_pad"]) + overlap_y
|
| 1934 |
-
working_x_min = base_x_min = float(state.get("x_min", 0.0) or 0.0)
|
| 1935 |
-
working_y_min = base_y_min = float(state.get("y_min", 0.0) or 0.0)
|
| 1936 |
-
pixel_size = float(state.get("pixel_size", 0.0) or 0.0)
|
| 1937 |
-
working_x_min = base_x_min - (source_x * pixel_size)
|
| 1938 |
-
working_y_min = base_y_min - ((int(y_axis["trailing_pad"]) + overlap_y) * pixel_size)
|
| 1939 |
-
pieces: list[dict[str, Any]] = []
|
| 1940 |
-
for row_index, (y_start, y_end) in enumerate(y_spans, start=1):
|
| 1941 |
-
for col_index, (x_start, x_end) in enumerate(x_spans, start=1):
|
| 1942 |
-
piece_dir = output_dir / f"r{row_index:02d}_c{col_index:02d}_tiff_slices"
|
| 1943 |
-
piece_dir.mkdir(parents=True, exist_ok=True)
|
| 1944 |
-
pieces.append({
|
| 1945 |
-
"row": row_index,
|
| 1946 |
-
"col": col_index,
|
| 1947 |
-
"x_start": x_start,
|
| 1948 |
-
"x_end": x_end,
|
| 1949 |
-
"y_start": y_start,
|
| 1950 |
-
"y_end": y_end,
|
| 1951 |
-
"tiff_dir": piece_dir,
|
| 1952 |
-
"tiff_paths": [],
|
| 1953 |
-
})
|
| 1954 |
-
|
| 1955 |
-
for index, source_path in enumerate(tiff_paths):
|
| 1956 |
-
progress(index / max(len(tiff_paths), 1), desc=f"Splitting layer {index + 1}/{len(tiff_paths)}")
|
| 1957 |
-
with Image.open(source_path) as image:
|
| 1958 |
-
layer = image.convert("L")
|
| 1959 |
-
if layer.size != (width, height):
|
| 1960 |
-
raise ValueError("All TIFF slices must have the same dimensions to split the stack.")
|
| 1961 |
-
|
| 1962 |
-
padded_layer = Image.new("L", (working_width, working_height), 255)
|
| 1963 |
-
padded_layer.paste(layer, (source_x, source_y))
|
| 1964 |
-
layer_x_spans = [
|
| 1965 |
-
(x_start + overlap_x, x_end + overlap_x)
|
| 1966 |
-
for x_start, x_end in _layer_split_spans(
|
| 1967 |
-
padded_width,
|
| 1968 |
-
column_count,
|
| 1969 |
-
index,
|
| 1970 |
-
overlap_x,
|
| 1971 |
-
)
|
| 1972 |
-
]
|
| 1973 |
-
layer_y_spans = [
|
| 1974 |
-
(y_start + overlap_y, y_end + overlap_y)
|
| 1975 |
-
for y_start, y_end in _layer_split_spans(
|
| 1976 |
-
padded_height,
|
| 1977 |
-
row_count,
|
| 1978 |
-
index,
|
| 1979 |
-
overlap_y,
|
| 1980 |
-
)
|
| 1981 |
-
]
|
| 1982 |
-
for piece in pieces:
|
| 1983 |
-
canvas_x_start = piece["x_start"]
|
| 1984 |
-
canvas_x_end = piece["x_end"]
|
| 1985 |
-
canvas_y_start = piece["y_start"]
|
| 1986 |
-
canvas_y_end = piece["y_end"]
|
| 1987 |
-
if overlapping_layers:
|
| 1988 |
-
canvas_x_start -= overlap_x
|
| 1989 |
-
canvas_x_end += overlap_x
|
| 1990 |
-
canvas_y_start -= overlap_y
|
| 1991 |
-
canvas_y_end += overlap_y
|
| 1992 |
-
canvas_x_start += overlap_x
|
| 1993 |
-
canvas_x_end += overlap_x
|
| 1994 |
-
canvas_y_start += overlap_y
|
| 1995 |
-
canvas_y_end += overlap_y
|
| 1996 |
-
x_start, x_end = layer_x_spans[piece["col"] - 1]
|
| 1997 |
-
y_start, y_end = layer_y_spans[piece["row"] - 1]
|
| 1998 |
-
if overlapping_layers:
|
| 1999 |
-
piece_image = Image.new("L", (canvas_x_end - canvas_x_start, canvas_y_end - canvas_y_start), 255)
|
| 2000 |
-
piece_image.paste(
|
| 2001 |
-
padded_layer.crop((x_start, y_start, x_end, y_end)),
|
| 2002 |
-
(x_start - canvas_x_start, y_start - canvas_y_start),
|
| 2003 |
-
)
|
| 2004 |
-
else:
|
| 2005 |
-
piece_image = padded_layer.crop((x_start, y_start, x_end, y_end))
|
| 2006 |
-
piece_path = piece["tiff_dir"] / f"slice_{index:04d}.tif"
|
| 2007 |
-
piece_image.save(piece_path, compression="tiff_deflate")
|
| 2008 |
-
piece["tiff_paths"].append(piece_path)
|
| 2009 |
-
|
| 2010 |
-
z_values = list(state.get("z_values", []))
|
| 2011 |
-
if len(z_values) < len(tiff_paths):
|
| 2012 |
-
z_values.extend([0.0] * (len(tiff_paths) - len(z_values)))
|
| 2013 |
-
|
| 2014 |
-
for piece in pieces:
|
| 2015 |
-
canvas_x_start = piece["x_start"]
|
| 2016 |
-
canvas_x_end = piece["x_end"]
|
| 2017 |
-
canvas_y_start = piece["y_start"]
|
| 2018 |
-
canvas_y_end = piece["y_end"]
|
| 2019 |
-
if overlapping_layers:
|
| 2020 |
-
canvas_x_start -= overlap_x
|
| 2021 |
-
canvas_x_end += overlap_x
|
| 2022 |
-
canvas_y_start -= overlap_y
|
| 2023 |
-
canvas_y_end += overlap_y
|
| 2024 |
-
canvas_x_start += overlap_x
|
| 2025 |
-
canvas_x_end += overlap_x
|
| 2026 |
-
canvas_y_start += overlap_y
|
| 2027 |
-
canvas_y_end += overlap_y
|
| 2028 |
-
piece_width = canvas_x_end - canvas_x_start
|
| 2029 |
-
piece_height = canvas_y_end - canvas_y_start
|
| 2030 |
-
zip_path = output_dir / f"{safe_name}_r{piece['row']:02d}_c{piece['col']:02d}_tiff_slices.zip"
|
| 2031 |
-
_zip_tiff_paths(piece["tiff_paths"], zip_path)
|
| 2032 |
-
piece_state: ViewerState = {
|
| 2033 |
-
"tiff_paths": [str(path) for path in piece["tiff_paths"]],
|
| 2034 |
-
"z_values": z_values[: len(piece["tiff_paths"])],
|
| 2035 |
-
"pixel_size": pixel_size,
|
| 2036 |
-
"x_min": working_x_min + (canvas_x_start * pixel_size),
|
| 2037 |
-
"y_min": working_y_min + ((working_height - canvas_y_end) * pixel_size),
|
| 2038 |
-
"image_width": piece_width,
|
| 2039 |
-
"image_height": piece_height,
|
| 2040 |
-
"zip_path": str(zip_path),
|
| 2041 |
-
"overlapping_layers": bool(overlapping_layers),
|
| 2042 |
-
}
|
| 2043 |
-
piece["state"] = piece_state
|
| 2044 |
-
piece["zip_path"] = zip_path
|
| 2045 |
-
return pieces
|
| 2046 |
-
|
| 2047 |
-
|
| 2048 |
-
def split_tiff_stack_left_right(
|
| 2049 |
-
state: ViewerState,
|
| 2050 |
-
base_name: str = "split_shape",
|
| 2051 |
-
progress: gr.Progress = gr.Progress(),
|
| 2052 |
-
) -> tuple[ViewerState, ViewerState, Path, Path]:
|
| 2053 |
-
pieces = split_tiff_stack_grid(state, base_name=base_name, columns=2, rows=1, progress=progress)
|
| 2054 |
-
return pieces[0]["state"], pieces[1]["state"], pieces[0]["zip_path"], pieces[1]["zip_path"]
|
| 2055 |
-
|
| 2056 |
-
|
| 2057 |
SHAPE_SETTINGS_HEADERS = [
|
| 2058 |
"Shape",
|
| 2059 |
"STL",
|
|
@@ -2234,8 +1661,8 @@ def _records_from_files(files: Any, previous_records: list[dict] | None = None)
|
|
| 2234 |
"port": previous.get("port", 1),
|
| 2235 |
"color": previous.get("color", _default_color(index)),
|
| 2236 |
"contour_tracing": previous.get("contour_tracing", False),
|
| 2237 |
-
"
|
| 2238 |
-
"
|
| 2239 |
"gcode_path": previous.get("gcode_path"),
|
| 2240 |
})
|
| 2241 |
return records
|
|
@@ -2697,7 +2124,6 @@ def sync_uploaded_shapes(
|
|
| 2697 |
_dropdown_update(next_records),
|
| 2698 |
_gcode_dropdown_update(next_records),
|
| 2699 |
_gcode_dropdown_update(next_records, include_upload=True),
|
| 2700 |
-
[record.get("zip_path") for record in next_records if record.get("zip_path")],
|
| 2701 |
[record.get("gcode_path") for record in next_records if record.get("gcode_path")],
|
| 2702 |
)
|
| 2703 |
|
|
@@ -2741,7 +2167,6 @@ def _shape_delete_outputs(
|
|
| 2741 |
_dropdown_update(records),
|
| 2742 |
_gcode_dropdown_update(records),
|
| 2743 |
_gcode_dropdown_update(records, include_upload=True),
|
| 2744 |
-
[record.get("zip_path") for record in records if record.get("zip_path")],
|
| 2745 |
[record.get("gcode_path") for record in records if record.get("gcode_path")],
|
| 2746 |
float(last_delete_at or 0.0),
|
| 2747 |
)
|
|
@@ -2889,9 +2314,9 @@ def show_selected_model(
|
|
| 2889 |
records = _apply_shape_settings(records or [], settings_table)
|
| 2890 |
pos = _selected_record_index(records, selected)
|
| 2891 |
if pos < 0:
|
| 2892 |
-
return _viewer_update(None), "No model loaded."
|
| 2893 |
record = records[pos]
|
| 2894 |
-
|
| 2895 |
record.get("stl_path"),
|
| 2896 |
opacity,
|
| 2897 |
True,
|
|
@@ -2900,185 +2325,89 @@ def show_selected_model(
|
|
| 2900 |
record.get("target_y"),
|
| 2901 |
record.get("target_z"),
|
| 2902 |
)
|
| 2903 |
-
state = record.get("tiff_state") or _empty_state()
|
| 2904 |
-
label, preview = _render_selected_slice(state, 0)
|
| 2905 |
-
slider = gr.update(
|
| 2906 |
-
minimum=0,
|
| 2907 |
-
maximum=max(0, len(state.get("tiff_paths", [])) - 1),
|
| 2908 |
-
value=0,
|
| 2909 |
-
step=1,
|
| 2910 |
-
interactive=len(state.get("tiff_paths", [])) > 1,
|
| 2911 |
-
)
|
| 2912 |
-
return viewer, details, slider, label, preview
|
| 2913 |
|
| 2914 |
|
| 2915 |
-
def
|
| 2916 |
-
|
| 2917 |
-
|
| 2918 |
-
|
| 2919 |
-
|
|
|
|
|
|
|
|
|
|
| 2920 |
|
| 2921 |
|
| 2922 |
-
def
|
| 2923 |
-
|
| 2924 |
-
|
| 2925 |
-
|
| 2926 |
-
|
| 2927 |
-
|
| 2928 |
-
|
| 2929 |
-
|
| 2930 |
-
|
| 2931 |
-
|
| 2932 |
-
|
| 2933 |
-
|
| 2934 |
-
|
| 2935 |
-
|
| 2936 |
-
|
| 2937 |
-
|
| 2938 |
-
|
| 2939 |
-
|
| 2940 |
-
|
| 2941 |
-
|
| 2942 |
-
|
| 2943 |
-
|
| 2944 |
-
value=0,
|
| 2945 |
-
step=1,
|
| 2946 |
-
interactive=len(state.get("tiff_paths", [])) > 1,
|
| 2947 |
)
|
| 2948 |
-
|
|
|
|
|
|
|
| 2949 |
|
| 2950 |
|
| 2951 |
-
def
|
| 2952 |
records: list[dict] | None,
|
| 2953 |
settings_table: Any,
|
| 2954 |
layer_height: float,
|
| 2955 |
-
pixel_size: float,
|
| 2956 |
scale_mode: str | None,
|
| 2957 |
progress: gr.Progress = gr.Progress(),
|
| 2958 |
) -> tuple:
|
| 2959 |
records = _apply_shape_settings(records or [], settings_table)
|
| 2960 |
if not records:
|
| 2961 |
-
return
|
| 2962 |
-
records,
|
| 2963 |
-
[],
|
| 2964 |
-
"Upload at least one STL first.",
|
| 2965 |
-
_dropdown_update(records),
|
| 2966 |
-
_reset_slider(),
|
| 2967 |
-
"No slice stack loaded yet.",
|
| 2968 |
-
None,
|
| 2969 |
-
_empty_state(),
|
| 2970 |
-
_reset_slider(),
|
| 2971 |
-
"No reference stack generated yet.",
|
| 2972 |
-
None,
|
| 2973 |
-
)
|
| 2974 |
total = len(records)
|
| 2975 |
messages: list[str] = []
|
| 2976 |
for pos, record in enumerate(records):
|
| 2977 |
stl_path = record.get("stl_path")
|
| 2978 |
if not stl_path:
|
| 2979 |
-
|
|
|
|
|
|
|
|
|
|
| 2980 |
continue
|
| 2981 |
|
| 2982 |
def report_progress(cur: int, tot: int, offset: int = pos) -> None:
|
| 2983 |
progress((offset + cur / tot) / total, desc=f"Slicing shape {offset + 1} of {total}...")
|
| 2984 |
|
| 2985 |
-
mesh = load_mesh(stl_path)
|
| 2986 |
-
scale_factors = _resolve_mesh_scale_factors(
|
| 2987 |
-
mesh,
|
| 2988 |
-
True,
|
| 2989 |
-
scale_mode,
|
| 2990 |
-
record.get("target_x"),
|
| 2991 |
-
record.get("target_y"),
|
| 2992 |
-
record.get("target_z"),
|
| 2993 |
-
)
|
| 2994 |
try:
|
| 2995 |
-
stack =
|
| 2996 |
-
|
| 2997 |
-
|
| 2998 |
-
|
| 2999 |
-
|
| 3000 |
-
scale_factors=scale_factors,
|
| 3001 |
)
|
| 3002 |
-
record["tiff_state"] = _stack_to_state(stack)
|
| 3003 |
-
record["zip_path"] = str(stack.zip_path)
|
| 3004 |
-
messages.append(f"Shape {record['idx']}: wrote `{stack.zip_path.name}`.")
|
| 3005 |
except Exception as exc:
|
| 3006 |
messages.append(f"Shape {record['idx']}: failed ({exc}).")
|
| 3007 |
-
|
| 3008 |
-
if
|
| 3009 |
-
messages.append("Reference
|
| 3010 |
else:
|
| 3011 |
-
messages.append("Reference
|
| 3012 |
-
|
| 3013 |
-
return (
|
| 3014 |
-
records,
|
| 3015 |
-
[record.get("zip_path") for record in records if record.get("zip_path")],
|
| 3016 |
-
"\n".join(messages),
|
| 3017 |
-
selected_update,
|
| 3018 |
-
slider,
|
| 3019 |
-
label,
|
| 3020 |
-
preview,
|
| 3021 |
-
ref_state,
|
| 3022 |
-
ref_slider,
|
| 3023 |
-
ref_label,
|
| 3024 |
-
ref_preview,
|
| 3025 |
-
)
|
| 3026 |
-
|
| 3027 |
-
|
| 3028 |
-
def generate_dynamic_reference_stack(records: list[dict] | None, progress: gr.Progress = gr.Progress()) -> tuple:
|
| 3029 |
-
states = [record.get("tiff_state") or _empty_state() for record in (records or [])]
|
| 3030 |
-
return generate_reference_stack(*states, progress=progress)
|
| 3031 |
-
|
| 3032 |
|
| 3033 |
-
def _split_piece_choice(piece: dict[str, Any]) -> str:
|
| 3034 |
-
return f"R{piece['row']} C{piece['col']}"
|
| 3035 |
|
| 3036 |
-
|
| 3037 |
-
|
| 3038 |
-
choices = [_split_piece_choice(piece) for piece in pieces]
|
| 3039 |
-
value = selected if selected in choices else (choices[0] if choices else None)
|
| 3040 |
-
return gr.update(choices=choices, value=value)
|
| 3041 |
-
|
| 3042 |
-
|
| 3043 |
-
def _selected_split_piece(pieces: list[dict[str, Any]] | None, selected: str | None) -> dict[str, Any] | None:
|
| 3044 |
-
if not pieces:
|
| 3045 |
-
return None
|
| 3046 |
-
for piece in pieces:
|
| 3047 |
-
if _split_piece_choice(piece) == selected:
|
| 3048 |
-
return piece
|
| 3049 |
-
return pieces[0]
|
| 3050 |
-
|
| 3051 |
-
|
| 3052 |
-
def preview_selected_split_piece(pieces: list[dict[str, Any]] | None, selected: str | None) -> tuple:
|
| 3053 |
-
piece = _selected_split_piece(pieces, selected)
|
| 3054 |
-
if not piece:
|
| 3055 |
-
return _reset_slider(), "No split stack generated yet.", None
|
| 3056 |
-
state = piece.get("state") or _empty_state()
|
| 3057 |
-
label, preview = _render_selected_slice(state, 0)
|
| 3058 |
-
slider = gr.update(
|
| 3059 |
-
minimum=0,
|
| 3060 |
-
maximum=max(0, len(state.get("tiff_paths", [])) - 1),
|
| 3061 |
-
value=0,
|
| 3062 |
-
step=1,
|
| 3063 |
-
interactive=len(state.get("tiff_paths", [])) > 1,
|
| 3064 |
-
)
|
| 3065 |
-
return slider, label, preview
|
| 3066 |
-
|
| 3067 |
-
|
| 3068 |
-
def jump_to_selected_split_piece(pieces: list[dict[str, Any]] | None, selected: str | None, index: float) -> tuple:
|
| 3069 |
-
piece = _selected_split_piece(pieces, selected)
|
| 3070 |
-
if not piece:
|
| 3071 |
-
return "No split stack generated yet.", None
|
| 3072 |
-
return _render_selected_slice(piece.get("state") or _empty_state(), int(index))
|
| 3073 |
-
|
| 3074 |
-
|
| 3075 |
-
def shift_selected_split_piece(pieces: list[dict[str, Any]] | None, selected: str | None, index: float, delta: int) -> tuple:
|
| 3076 |
-
piece = _selected_split_piece(pieces, selected)
|
| 3077 |
-
if not piece:
|
| 3078 |
-
return gr.update(value=0), "No split stack generated yet.", None
|
| 3079 |
-
state = piece.get("state") or _empty_state()
|
| 3080 |
-
new_index, label, preview = shift_slice(state, index, delta)
|
| 3081 |
-
return gr.update(value=new_index), label, preview
|
| 3082 |
|
| 3083 |
|
| 3084 |
def split_selected_shape_for_grid(
|
|
@@ -3092,153 +2421,138 @@ def split_selected_shape_for_grid(
|
|
| 3092 |
overlapping_layers: bool,
|
| 3093 |
starting_nozzle: float,
|
| 3094 |
starting_valve: float,
|
| 3095 |
-
|
| 3096 |
) -> tuple:
|
| 3097 |
records = _apply_shape_settings(records or [], settings_table)
|
| 3098 |
-
|
| 3099 |
-
|
| 3100 |
return (
|
| 3101 |
-
|
| 3102 |
-
_shape_settings_rows(
|
| 3103 |
-
_spacing_table_update(
|
| 3104 |
-
_dropdown_update(
|
| 3105 |
-
|
| 3106 |
-
|
| 3107 |
-
|
| 3108 |
-
|
| 3109 |
-
|
| 3110 |
-
_gcode_dropdown_update(records),
|
| 3111 |
-
_gcode_dropdown_update(records, include_upload=True),
|
| 3112 |
-
_dropdown_update(records),
|
| 3113 |
-
[],
|
| 3114 |
-
[],
|
| 3115 |
-
_split_piece_dropdown_update([]),
|
| 3116 |
-
_reset_slider(),
|
| 3117 |
-
"No split stack generated yet.",
|
| 3118 |
-
None,
|
| 3119 |
-
"Generate TIFF stacks for a shape before splitting it.",
|
| 3120 |
)
|
| 3121 |
|
|
|
|
|
|
|
|
|
|
| 3122 |
pos = _selected_record_index(records, selected)
|
| 3123 |
if pos < 0:
|
| 3124 |
pos = 0
|
| 3125 |
source = records[pos]
|
| 3126 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3127 |
try:
|
| 3128 |
-
pieces =
|
| 3129 |
-
|
| 3130 |
-
|
| 3131 |
-
|
| 3132 |
-
|
| 3133 |
-
|
| 3134 |
-
progress=progress,
|
| 3135 |
)
|
| 3136 |
except Exception as exc:
|
| 3137 |
-
|
| 3138 |
-
return (
|
| 3139 |
-
records,
|
| 3140 |
-
_shape_settings_rows(records),
|
| 3141 |
-
_spacing_table_update(records, existing_spacing, use_individual_spacing),
|
| 3142 |
-
_dropdown_update(records, selected),
|
| 3143 |
-
_reset_slider(),
|
| 3144 |
-
"No slice stack loaded yet.",
|
| 3145 |
-
None,
|
| 3146 |
-
[record.get("zip_path") for record in records if record.get("zip_path")],
|
| 3147 |
-
[record.get("gcode_path") for record in records if record.get("gcode_path")],
|
| 3148 |
-
_gcode_dropdown_update(records),
|
| 3149 |
-
_gcode_dropdown_update(records, include_upload=True),
|
| 3150 |
-
_dropdown_update(records, selected),
|
| 3151 |
-
[],
|
| 3152 |
-
[],
|
| 3153 |
-
_split_piece_dropdown_update([]),
|
| 3154 |
-
_reset_slider(),
|
| 3155 |
-
"No split stack generated yet.",
|
| 3156 |
-
None,
|
| 3157 |
-
f"Split failed: {exc}",
|
| 3158 |
-
)
|
| 3159 |
|
| 3160 |
base_name = str(source.get("name") or f"Shape {source.get('idx', pos + 1)}")
|
| 3161 |
first_nozzle = max(1, _coerce_int(starting_nozzle, 1))
|
| 3162 |
first_valve = max(1, _coerce_int(starting_valve, _coerce_int(source.get("valve", 4), 4)))
|
| 3163 |
-
split_column_count = max(1, _coerce_int(columns, 2))
|
| 3164 |
-
split_row_count = max(1, _coerce_int(rows, 1))
|
| 3165 |
split_group_id = f"split-{int(time.time() * 1_000_000)}-{source.get('idx', pos + 1)}"
|
| 3166 |
split_records: list[dict] = []
|
| 3167 |
for index, piece in enumerate(pieces):
|
| 3168 |
-
|
| 3169 |
-
|
| 3170 |
-
|
| 3171 |
piece_record = dict(source)
|
| 3172 |
piece_record.update({
|
| 3173 |
-
"name": f"{base_name} - R{
|
| 3174 |
"stl_path": None,
|
| 3175 |
-
"target_x":
|
| 3176 |
-
"target_y":
|
| 3177 |
"nozzle": first_nozzle + index,
|
| 3178 |
"valve": first_valve + index,
|
| 3179 |
"split_group_id": split_group_id,
|
| 3180 |
"split_index": index,
|
| 3181 |
-
"split_row":
|
| 3182 |
-
"split_col":
|
| 3183 |
"split_rows": split_row_count,
|
| 3184 |
"split_columns": split_column_count,
|
| 3185 |
-
"
|
| 3186 |
-
"
|
| 3187 |
"gcode_path": None,
|
| 3188 |
})
|
| 3189 |
split_records.append(piece_record)
|
| 3190 |
next_records = _reindex_shape_records([*records[:pos], *split_records, *records[pos + 1:]])
|
| 3191 |
split_selected = _shape_choice(next_records[pos]) if pos < len(next_records) else None
|
| 3192 |
-
selected_update, main_slider, main_label, main_preview = _tiff_preview_update(next_records, split_selected)
|
| 3193 |
-
slider, label, preview = preview_selected_split_piece(pieces, None)
|
| 3194 |
status = (
|
| 3195 |
-
f"Split Shape {source.get('idx', pos + 1)} into {len(pieces)} print-ready stacks "
|
| 3196 |
-
f"({
|
| 3197 |
f"Nozzles {first_nozzle}-{first_nozzle + len(pieces) - 1}; valves {first_valve}-{first_valve + len(pieces) - 1}."
|
| 3198 |
)
|
| 3199 |
if overlapping_layers:
|
| 3200 |
-
status +=
|
| 3201 |
-
|
| 3202 |
-
|
| 3203 |
-
|
| 3204 |
-
|
| 3205 |
-
selected_update,
|
| 3206 |
-
main_slider,
|
| 3207 |
-
main_label,
|
| 3208 |
-
main_preview,
|
| 3209 |
-
[record.get("zip_path") for record in next_records if record.get("zip_path")],
|
| 3210 |
-
[record.get("gcode_path") for record in next_records if record.get("gcode_path")],
|
| 3211 |
-
_gcode_dropdown_update(next_records),
|
| 3212 |
-
_gcode_dropdown_update(next_records, include_upload=True),
|
| 3213 |
-
_dropdown_update(next_records),
|
| 3214 |
-
[str(piece["zip_path"]) for piece in pieces],
|
| 3215 |
-
pieces,
|
| 3216 |
-
_split_piece_dropdown_update(pieces),
|
| 3217 |
-
slider,
|
| 3218 |
-
label,
|
| 3219 |
-
preview,
|
| 3220 |
-
status,
|
| 3221 |
-
)
|
| 3222 |
|
| 3223 |
|
| 3224 |
-
def _contour_tracing_sources(records: list[dict]) -> list[
|
| 3225 |
-
sources: list[
|
| 3226 |
for record in records:
|
| 3227 |
if not record.get("contour_tracing"):
|
| 3228 |
continue
|
| 3229 |
-
|
| 3230 |
-
|
| 3231 |
-
|
| 3232 |
-
|
| 3233 |
-
|
| 3234 |
-
|
| 3235 |
-
|
| 3236 |
-
|
| 3237 |
-
|
| 3238 |
-
sources.append(source)
|
| 3239 |
return sources
|
| 3240 |
|
| 3241 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3242 |
def generate_dynamic_gcode(
|
| 3243 |
records: list[dict] | None,
|
| 3244 |
settings_table: Any,
|
|
@@ -3250,40 +2564,43 @@ def generate_dynamic_gcode(
|
|
| 3250 |
lead_in_length: float,
|
| 3251 |
lead_in_clearance: float,
|
| 3252 |
lead_in_lines: float,
|
| 3253 |
-
|
| 3254 |
layer_height: float,
|
| 3255 |
-
|
|
|
|
| 3256 |
) -> tuple:
|
| 3257 |
records = _apply_shape_settings(records or [], settings_table)
|
| 3258 |
-
motion_tiffs = (ref_state or {}).get("tiff_paths") if use_reference_motion else None
|
| 3259 |
-
contour_sources = _contour_tracing_sources(records)
|
| 3260 |
messages: list[str] = []
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3261 |
if contour_sources:
|
| 3262 |
-
enabled = ", ".join(f"Shape {source
|
| 3263 |
-
messages.append(f"
|
| 3264 |
for record in records:
|
| 3265 |
-
|
| 3266 |
-
if not
|
| 3267 |
-
messages.append(f"Shape {record['idx']}: skipped (no
|
| 3268 |
continue
|
| 3269 |
-
if use_reference_motion and
|
| 3270 |
-
messages.append(f"Shape {record['idx']}: skipped (Reference motion selected, but no
|
| 3271 |
continue
|
| 3272 |
-
shape_name = str(record.get("name") or
|
| 3273 |
try:
|
| 3274 |
-
gcode_path =
|
| 3275 |
-
|
| 3276 |
shape_name=shape_name,
|
| 3277 |
pressure=float(record.get("pressure", 25.0)),
|
| 3278 |
valve=int(record.get("valve", 4)),
|
| 3279 |
port=int(record.get("port", 1)),
|
| 3280 |
layer_height=float(layer_height),
|
| 3281 |
-
fil_width=float(
|
| 3282 |
pressure_ramp_enabled=bool(pressure_ramp_enabled),
|
| 3283 |
all_g1=bool(all_g1),
|
| 3284 |
-
|
| 3285 |
raster_pattern=raster_pattern,
|
| 3286 |
-
|
| 3287 |
active_contour_owner=int(record.get("idx", 0)),
|
| 3288 |
lead_in_enabled=bool(lead_in_enabled),
|
| 3289 |
lead_in_length=float(lead_in_length),
|
|
@@ -3296,6 +2613,7 @@ def generate_dynamic_gcode(
|
|
| 3296 |
messages.append(f"Shape {record['idx']}: failed ({exc}).")
|
| 3297 |
return (
|
| 3298 |
records,
|
|
|
|
| 3299 |
[record.get("gcode_path") for record in records if record.get("gcode_path")],
|
| 3300 |
"\n".join(messages),
|
| 3301 |
_gcode_dropdown_update(records),
|
|
@@ -3323,7 +2641,7 @@ def _parts_from_records(records: list[dict] | None) -> tuple[list[dict], list[st
|
|
| 3323 |
path = record.get("gcode_path")
|
| 3324 |
idx = int(record.get("idx", len(parts) + 1))
|
| 3325 |
if not path:
|
| 3326 |
-
messages.append(f"Shape {idx}: no G-code (generate it on the
|
| 3327 |
continue
|
| 3328 |
try:
|
| 3329 |
parsed = parse_gcode_path(Path(path).read_text())
|
|
@@ -3461,7 +2779,7 @@ def render_dynamic_parallel(
|
|
| 3461 |
records = _apply_shape_settings(records or [], settings_table)
|
| 3462 |
parts, messages = _parts_from_records(records)
|
| 3463 |
if not parts:
|
| 3464 |
-
return None, "No shape G-code available. Generate G-code on the
|
| 3465 |
offsets, spacings = _resolve_layout_from_spacing_controls(
|
| 3466 |
parts,
|
| 3467 |
layout_mode,
|
|
@@ -3556,17 +2874,16 @@ def export_dynamic_parallel_gif(
|
|
| 3556 |
return str(result) if result else None
|
| 3557 |
|
| 3558 |
def build_dynamic_demo() -> gr.Blocks:
|
| 3559 |
-
with gr.Blocks(title="STL
|
| 3560 |
shape_records = gr.State([])
|
| 3561 |
last_shape_delete_at = gr.State(0.0)
|
| 3562 |
-
|
| 3563 |
-
split_piece_states = gr.State([])
|
| 3564 |
|
| 3565 |
-
with gr.Tab("
|
| 3566 |
gr.Markdown(
|
| 3567 |
"""
|
| 3568 |
-
#
|
| 3569 |
-
Upload any number of STL files, edit per-shape dimensions and print settings in the table, then
|
| 3570 |
"""
|
| 3571 |
)
|
| 3572 |
with gr.Row():
|
|
@@ -3600,8 +2917,8 @@ def build_dynamic_demo() -> gr.Blocks:
|
|
| 3600 |
)
|
| 3601 |
with gr.Row():
|
| 3602 |
layer_height = gr.Number(label="Layer Height (mm)", value=0.8, minimum=0.0001, step=0.01)
|
| 3603 |
-
|
| 3604 |
-
generate_button = gr.Button("
|
| 3605 |
|
| 3606 |
with gr.Accordion("Multi-Nozzle Split", open=False, elem_classes=["settings-accordion"]):
|
| 3607 |
with gr.Row():
|
|
@@ -3614,18 +2931,7 @@ def build_dynamic_demo() -> gr.Blocks:
|
|
| 3614 |
split_start_valve = gr.Number(label="Starting Valve", value=4, minimum=1, step=1)
|
| 3615 |
split_overlapping_layers = gr.Checkbox(label="Overlapping Layers", value=False)
|
| 3616 |
split_button = gr.Button("Split Selected Shape into Grid Pieces", variant="primary")
|
| 3617 |
-
split_status = gr.Markdown("
|
| 3618 |
-
split_downloads = gr.File(label="Download Split TIFF ZIPs", file_count="multiple", interactive=False)
|
| 3619 |
-
with gr.Row():
|
| 3620 |
-
with gr.Column(scale=1, min_width=260):
|
| 3621 |
-
split_piece_source = gr.Dropdown(label="Preview Generated Piece", choices=[], value=None, allow_custom_value=False)
|
| 3622 |
-
with gr.Row():
|
| 3623 |
-
split_piece_prev = gr.Button("Prev", scale=1, min_width=90, size="sm")
|
| 3624 |
-
split_piece_next = gr.Button("Next", scale=1, min_width=90, size="sm")
|
| 3625 |
-
split_piece_slider = gr.Slider(label="Piece Slice Index", minimum=0, maximum=0, value=0, step=1, interactive=False)
|
| 3626 |
-
split_piece_label = gr.Markdown("No split stack generated yet.")
|
| 3627 |
-
with gr.Column(scale=2, min_width=420):
|
| 3628 |
-
split_piece_preview = gr.Image(label="Generated Piece Preview", type="pil", image_mode="RGB", height=330)
|
| 3629 |
|
| 3630 |
with gr.Accordion("Selected Shape Preview", open=False, elem_classes=["settings-accordion"]):
|
| 3631 |
with gr.Row():
|
|
@@ -3644,40 +2950,17 @@ def build_dynamic_demo() -> gr.Blocks:
|
|
| 3644 |
with gr.Column(scale=1, min_width=300):
|
| 3645 |
model_details = gr.Markdown("No model loaded.")
|
| 3646 |
|
| 3647 |
-
with gr.Row():
|
| 3648 |
-
with gr.Column(scale=2, min_width=420):
|
| 3649 |
-
slice_preview = gr.Image(label="Selected Slice Preview", type="pil", image_mode="RGB", height=320)
|
| 3650 |
-
with gr.Column(scale=1, min_width=300):
|
| 3651 |
-
slice_label = gr.Markdown("No slice stack loaded yet.")
|
| 3652 |
-
with gr.Row():
|
| 3653 |
-
prev_button = gr.Button("Prev", scale=1, min_width=90, size="sm")
|
| 3654 |
-
next_button = gr.Button("Next", scale=1, min_width=90, size="sm")
|
| 3655 |
-
slice_slider = gr.Slider(label="Slice Index", minimum=0, maximum=0, value=0, step=1, interactive=False)
|
| 3656 |
-
|
| 3657 |
-
tiff_downloads = gr.File(label="Download TIFF ZIPs", file_count="multiple", interactive=False)
|
| 3658 |
slicer_status = gr.Markdown("")
|
| 3659 |
|
| 3660 |
-
|
| 3661 |
-
with gr.Row():
|
| 3662 |
-
with gr.Column(scale=1, min_width=200):
|
| 3663 |
-
ref_generate_button = gr.Button("Generate Reference TIFF Stack", variant="primary")
|
| 3664 |
-
with gr.Column(scale=3, min_width=250):
|
| 3665 |
-
ref_slice_label = gr.Markdown("No reference stack generated yet.")
|
| 3666 |
-
ref_slice_preview = gr.Image(label="Reference Slice Preview", type="pil", image_mode="RGB", height=270)
|
| 3667 |
-
with gr.Row():
|
| 3668 |
-
ref_prev_button = gr.Button("Prev", scale=1, min_width=90, size="sm")
|
| 3669 |
-
ref_next_button = gr.Button("Next", scale=1, min_width=90, size="sm")
|
| 3670 |
-
ref_slice_slider = gr.Slider(label="Slice Index", minimum=0, maximum=0, value=0, step=1, interactive=False)
|
| 3671 |
-
|
| 3672 |
-
with gr.Tab("TIFF Slices to GCode"):
|
| 3673 |
gr.Markdown(
|
| 3674 |
"""
|
| 3675 |
-
#
|
| 3676 |
-
Generate G-code for every
|
| 3677 |
"""
|
| 3678 |
)
|
| 3679 |
gcode_use_ref_motion = gr.Checkbox(
|
| 3680 |
-
label="Use
|
| 3681 |
value=True,
|
| 3682 |
)
|
| 3683 |
gcode_all_g1 = gr.Checkbox(label="Move at one constant speed (no fast travel moves)", value=True)
|
|
@@ -3798,7 +3081,7 @@ def build_dynamic_demo() -> gr.Blocks:
|
|
| 3798 |
with gr.Tab("Parallel Printing Visualization"):
|
| 3799 |
gr.Markdown(
|
| 3800 |
"### Parallel Printing Visualization\n"
|
| 3801 |
-
"Plots all generated shapes using the nozzle spacing configured on the
|
| 3802 |
)
|
| 3803 |
with gr.Row():
|
| 3804 |
with gr.Column(scale=1, min_width=340):
|
|
@@ -3837,7 +3120,7 @@ def build_dynamic_demo() -> gr.Blocks:
|
|
| 3837 |
nozzle_grid_use_individual_spacing,
|
| 3838 |
]
|
| 3839 |
|
| 3840 |
-
shape_sync_outputs = [shape_records, shape_settings, nozzle_spacing_table, selected_shape, gcode_text_source, gcode_source,
|
| 3841 |
stl_upload.change(fn=sync_uploaded_shapes, inputs=[stl_upload, shape_records, shape_settings, nozzle_spacing_table, nozzle_use_individual_spacing], outputs=shape_sync_outputs).then(
|
| 3842 |
fn=lambda records: _dropdown_update(records),
|
| 3843 |
inputs=[shape_records],
|
|
@@ -3899,9 +3182,9 @@ def build_dynamic_demo() -> gr.Blocks:
|
|
| 3899 |
outputs=[nozzle_grid_spacing_table],
|
| 3900 |
queue=False,
|
| 3901 |
)
|
| 3902 |
-
selected_shape.change(fn=show_selected_model, inputs=preview_inputs, outputs=[model_viewer, model_details
|
| 3903 |
-
refresh_preview_button.click(fn=show_selected_model, inputs=preview_inputs, outputs=[model_viewer, model_details
|
| 3904 |
-
model_opacity.change(fn=show_selected_model, inputs=preview_inputs, outputs=[model_viewer, model_details
|
| 3905 |
scale_mode.change(
|
| 3906 |
fn=normalize_shape_dimensions_for_mode,
|
| 3907 |
inputs=[shape_records, shape_settings, scale_mode],
|
|
@@ -3910,7 +3193,7 @@ def build_dynamic_demo() -> gr.Blocks:
|
|
| 3910 |
).then(
|
| 3911 |
fn=show_selected_model,
|
| 3912 |
inputs=preview_inputs,
|
| 3913 |
-
outputs=[model_viewer, model_details
|
| 3914 |
)
|
| 3915 |
reset_dimensions_button.click(
|
| 3916 |
fn=reset_shape_dimensions,
|
|
@@ -3919,39 +3202,19 @@ def build_dynamic_demo() -> gr.Blocks:
|
|
| 3919 |
).then(
|
| 3920 |
fn=show_selected_model,
|
| 3921 |
inputs=preview_inputs,
|
| 3922 |
-
outputs=[model_viewer, model_details
|
| 3923 |
)
|
| 3924 |
|
| 3925 |
-
slice_slider.release(fn=jump_to_selected_slice, inputs=[shape_records, selected_shape, slice_slider], outputs=[slice_label, slice_preview], queue=False)
|
| 3926 |
-
prev_button.click(fn=lambda records, selected, idx: shift_selected_slice(records, selected, idx, -1), inputs=[shape_records, selected_shape, slice_slider], outputs=[slice_slider, slice_label, slice_preview], queue=False)
|
| 3927 |
-
next_button.click(fn=lambda records, selected, idx: shift_selected_slice(records, selected, idx, 1), inputs=[shape_records, selected_shape, slice_slider], outputs=[slice_slider, slice_label, slice_preview], queue=False)
|
| 3928 |
-
|
| 3929 |
generate_button.click(
|
| 3930 |
-
fn=
|
| 3931 |
-
inputs=[shape_records, shape_settings, layer_height,
|
| 3932 |
-
outputs=[
|
| 3933 |
-
shape_records,
|
| 3934 |
-
tiff_downloads,
|
| 3935 |
-
slicer_status,
|
| 3936 |
-
selected_shape,
|
| 3937 |
-
slice_slider,
|
| 3938 |
-
slice_label,
|
| 3939 |
-
slice_preview,
|
| 3940 |
-
ref_state,
|
| 3941 |
-
ref_slice_slider,
|
| 3942 |
-
ref_slice_label,
|
| 3943 |
-
ref_slice_preview,
|
| 3944 |
-
],
|
| 3945 |
).then(
|
| 3946 |
fn=lambda records: _dropdown_update(records),
|
| 3947 |
inputs=[shape_records],
|
| 3948 |
outputs=[split_source],
|
| 3949 |
queue=False,
|
| 3950 |
)
|
| 3951 |
-
ref_generate_button.click(fn=generate_dynamic_reference_stack, inputs=[shape_records], outputs=[ref_state, ref_slice_slider, ref_slice_label, ref_slice_preview])
|
| 3952 |
-
ref_slice_slider.release(fn=jump_to_slice, inputs=[ref_state, ref_slice_slider], outputs=[ref_slice_label, ref_slice_preview], queue=False)
|
| 3953 |
-
ref_prev_button.click(fn=lambda sv, idx: shift_slice(sv, idx, -1), inputs=[ref_state, ref_slice_slider], outputs=[ref_slice_slider, ref_slice_label, ref_slice_preview], queue=False)
|
| 3954 |
-
ref_next_button.click(fn=lambda sv, idx: shift_slice(sv, idx, 1), inputs=[ref_state, ref_slice_slider], outputs=[ref_slice_slider, ref_slice_label, ref_slice_preview], queue=False)
|
| 3955 |
|
| 3956 |
split_refresh_sources.click(fn=lambda records: _dropdown_update(records), inputs=[shape_records], outputs=[split_source], queue=False)
|
| 3957 |
split_button.click(
|
|
@@ -3967,42 +3230,29 @@ def build_dynamic_demo() -> gr.Blocks:
|
|
| 3967 |
split_overlapping_layers,
|
| 3968 |
split_start_nozzle,
|
| 3969 |
split_start_valve,
|
|
|
|
| 3970 |
],
|
| 3971 |
outputs=[
|
| 3972 |
shape_records,
|
| 3973 |
shape_settings,
|
| 3974 |
nozzle_spacing_table,
|
| 3975 |
selected_shape,
|
| 3976 |
-
slice_slider,
|
| 3977 |
-
slice_label,
|
| 3978 |
-
slice_preview,
|
| 3979 |
-
tiff_downloads,
|
| 3980 |
gcode_downloads,
|
| 3981 |
gcode_text_source,
|
| 3982 |
gcode_source,
|
| 3983 |
split_source,
|
| 3984 |
-
split_downloads,
|
| 3985 |
-
split_piece_states,
|
| 3986 |
-
split_piece_source,
|
| 3987 |
-
split_piece_slider,
|
| 3988 |
-
split_piece_label,
|
| 3989 |
-
split_piece_preview,
|
| 3990 |
split_status,
|
| 3991 |
],
|
| 3992 |
).then(
|
| 3993 |
fn=generate_dynamic_reference_stack,
|
| 3994 |
inputs=[shape_records],
|
| 3995 |
-
outputs=[
|
| 3996 |
).then(
|
| 3997 |
fn=_grid_spacing_table_update,
|
| 3998 |
inputs=grid_spacing_refresh_inputs,
|
| 3999 |
outputs=[nozzle_grid_spacing_table],
|
| 4000 |
queue=False,
|
| 4001 |
)
|
| 4002 |
-
split_piece_source.change(fn=preview_selected_split_piece, inputs=[split_piece_states, split_piece_source], outputs=[split_piece_slider, split_piece_label, split_piece_preview], queue=False)
|
| 4003 |
-
split_piece_slider.release(fn=jump_to_selected_split_piece, inputs=[split_piece_states, split_piece_source, split_piece_slider], outputs=[split_piece_label, split_piece_preview], queue=False)
|
| 4004 |
-
split_piece_prev.click(fn=lambda pieces, selected, idx: shift_selected_split_piece(pieces, selected, idx, -1), inputs=[split_piece_states, split_piece_source, split_piece_slider], outputs=[split_piece_slider, split_piece_label, split_piece_preview], queue=False)
|
| 4005 |
-
split_piece_next.click(fn=lambda pieces, selected, idx: shift_selected_split_piece(pieces, selected, idx, 1), inputs=[split_piece_states, split_piece_source, split_piece_slider], outputs=[split_piece_slider, split_piece_label, split_piece_preview], queue=False)
|
| 4006 |
|
| 4007 |
gcode_button.click(
|
| 4008 |
fn=generate_dynamic_gcode,
|
|
@@ -4017,11 +3267,12 @@ def build_dynamic_demo() -> gr.Blocks:
|
|
| 4017 |
gcode_lead_in_length,
|
| 4018 |
gcode_lead_in_clearance,
|
| 4019 |
gcode_lead_in_lines,
|
| 4020 |
-
|
| 4021 |
layer_height,
|
| 4022 |
-
|
|
|
|
| 4023 |
],
|
| 4024 |
-
outputs=[shape_records, gcode_downloads, gcode_status, gcode_text_source, gcode_source],
|
| 4025 |
).then(
|
| 4026 |
fn=load_selected_gcode_text,
|
| 4027 |
inputs=[shape_records, gcode_text_source],
|
|
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
|
|
|
| 3 |
import math
|
| 4 |
+
import tempfile
|
| 5 |
import time
|
| 6 |
import warnings
|
|
|
|
| 7 |
from pathlib import Path
|
| 8 |
from typing import Any
|
| 9 |
|
|
|
|
| 19 |
|
| 20 |
import gradio as gr
|
| 21 |
import numpy as np
|
|
|
|
| 22 |
import trimesh
|
| 23 |
|
| 24 |
from gcode_viewer import (
|
|
|
|
| 29 |
parse_gcode_path,
|
| 30 |
)
|
| 31 |
from stl_slicer import (
|
| 32 |
+
LayerStack,
|
| 33 |
load_mesh,
|
| 34 |
scale_factors_for_target_extents,
|
| 35 |
scale_mesh,
|
| 36 |
+
slice_stl_to_layers,
|
| 37 |
)
|
| 38 |
+
from vector_gcode import generate_vector_gcode
|
| 39 |
+
from vector_toolpath import (
|
| 40 |
RASTER_PATTERN_CHOICES,
|
| 41 |
RASTER_PATTERN_SAME_DIRECTION,
|
| 42 |
RASTER_PATTERN_Y_DIRECTION,
|
| 43 |
+
ContourSource,
|
| 44 |
+
build_reference_stack,
|
| 45 |
+
split_layer_stack_grid,
|
| 46 |
)
|
| 47 |
|
| 48 |
|
|
|
|
| 49 |
SAMPLE_STL_FILENAMES = ("Hollow_Pyramid.stl", "Rounded_Cube_Through_Holes.stl", "halfsphere.stl")
|
| 50 |
SAMPLE_STL_DIR = Path(__file__).resolve().parent / "sample_stls"
|
| 51 |
DEFAULT_TARGET_EXTENTS = (20.0, 20.0, 20.0)
|
|
|
|
| 782 |
"""
|
| 783 |
|
| 784 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 785 |
def _format_model_details(
|
| 786 |
source_name: str,
|
| 787 |
mesh,
|
|
|
|
| 816 |
return "\n".join(lines)
|
| 817 |
|
| 818 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 819 |
def _opacity_to_alpha(opacity: float) -> int:
|
| 820 |
bounded = max(0.05, min(float(opacity), 1.0))
|
| 821 |
return int(round(255 * bounded))
|
|
|
|
| 1150 |
return _viewer_update(glb_path), _format_model_details(Path(stl_file).name, mesh, scale_factors)
|
| 1151 |
|
| 1152 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1153 |
GCODE_SOURCE_UPLOAD = "Upload G-Code file"
|
| 1154 |
|
| 1155 |
|
|
|
|
| 1481 |
return " \n".join(lines)
|
| 1482 |
|
| 1483 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1484 |
SHAPE_SETTINGS_HEADERS = [
|
| 1485 |
"Shape",
|
| 1486 |
"STL",
|
|
|
|
| 1661 |
"port": previous.get("port", 1),
|
| 1662 |
"color": previous.get("color", _default_color(index)),
|
| 1663 |
"contour_tracing": previous.get("contour_tracing", False),
|
| 1664 |
+
"layer_stack": previous.get("layer_stack"),
|
| 1665 |
+
"slice_params": previous.get("slice_params"),
|
| 1666 |
"gcode_path": previous.get("gcode_path"),
|
| 1667 |
})
|
| 1668 |
return records
|
|
|
|
| 2124 |
_dropdown_update(next_records),
|
| 2125 |
_gcode_dropdown_update(next_records),
|
| 2126 |
_gcode_dropdown_update(next_records, include_upload=True),
|
|
|
|
| 2127 |
[record.get("gcode_path") for record in next_records if record.get("gcode_path")],
|
| 2128 |
)
|
| 2129 |
|
|
|
|
| 2167 |
_dropdown_update(records),
|
| 2168 |
_gcode_dropdown_update(records),
|
| 2169 |
_gcode_dropdown_update(records, include_upload=True),
|
|
|
|
| 2170 |
[record.get("gcode_path") for record in records if record.get("gcode_path")],
|
| 2171 |
float(last_delete_at or 0.0),
|
| 2172 |
)
|
|
|
|
| 2314 |
records = _apply_shape_settings(records or [], settings_table)
|
| 2315 |
pos = _selected_record_index(records, selected)
|
| 2316 |
if pos < 0:
|
| 2317 |
+
return _viewer_update(None), "No model loaded."
|
| 2318 |
record = records[pos]
|
| 2319 |
+
return load_single_model(
|
| 2320 |
record.get("stl_path"),
|
| 2321 |
opacity,
|
| 2322 |
True,
|
|
|
|
| 2325 |
record.get("target_y"),
|
| 2326 |
record.get("target_z"),
|
| 2327 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2328 |
|
| 2329 |
|
| 2330 |
+
def _slice_params_snapshot(record: dict, layer_height: float, scale_mode: str | None) -> dict:
|
| 2331 |
+
return {
|
| 2332 |
+
"layer_height": float(layer_height),
|
| 2333 |
+
"scale_mode": _normalize_scale_mode(scale_mode),
|
| 2334 |
+
"target_x": record.get("target_x"),
|
| 2335 |
+
"target_y": record.get("target_y"),
|
| 2336 |
+
"target_z": record.get("target_z"),
|
| 2337 |
+
}
|
| 2338 |
|
| 2339 |
|
| 2340 |
+
def _slice_record(
|
| 2341 |
+
record: dict,
|
| 2342 |
+
layer_height: float,
|
| 2343 |
+
scale_mode: str | None,
|
| 2344 |
+
progress_callback=None,
|
| 2345 |
+
) -> LayerStack:
|
| 2346 |
+
stl_path = record["stl_path"]
|
| 2347 |
+
mesh = load_mesh(stl_path)
|
| 2348 |
+
scale_factors = _resolve_mesh_scale_factors(
|
| 2349 |
+
mesh,
|
| 2350 |
+
True,
|
| 2351 |
+
scale_mode,
|
| 2352 |
+
record.get("target_x"),
|
| 2353 |
+
record.get("target_y"),
|
| 2354 |
+
record.get("target_z"),
|
| 2355 |
+
)
|
| 2356 |
+
stack = slice_stl_to_layers(
|
| 2357 |
+
stl_path,
|
| 2358 |
+
layer_height=float(layer_height),
|
| 2359 |
+
progress_callback=progress_callback,
|
| 2360 |
+
scale_factors=scale_factors,
|
| 2361 |
+
name=str(record.get("name") or Path(stl_path).stem),
|
|
|
|
|
|
|
|
|
|
| 2362 |
)
|
| 2363 |
+
record["layer_stack"] = stack
|
| 2364 |
+
record["slice_params"] = _slice_params_snapshot(record, layer_height, scale_mode)
|
| 2365 |
+
return stack
|
| 2366 |
|
| 2367 |
|
| 2368 |
+
def generate_dynamic_layer_stacks(
|
| 2369 |
records: list[dict] | None,
|
| 2370 |
settings_table: Any,
|
| 2371 |
layer_height: float,
|
|
|
|
| 2372 |
scale_mode: str | None,
|
| 2373 |
progress: gr.Progress = gr.Progress(),
|
| 2374 |
) -> tuple:
|
| 2375 |
records = _apply_shape_settings(records or [], settings_table)
|
| 2376 |
if not records:
|
| 2377 |
+
return records, "Upload at least one STL first.", None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2378 |
total = len(records)
|
| 2379 |
messages: list[str] = []
|
| 2380 |
for pos, record in enumerate(records):
|
| 2381 |
stl_path = record.get("stl_path")
|
| 2382 |
if not stl_path:
|
| 2383 |
+
if record.get("layer_stack") is not None:
|
| 2384 |
+
messages.append(f"Shape {record['idx']}: kept existing split-piece slices.")
|
| 2385 |
+
else:
|
| 2386 |
+
messages.append(f"Shape {record['idx']}: skipped (no STL file).")
|
| 2387 |
continue
|
| 2388 |
|
| 2389 |
def report_progress(cur: int, tot: int, offset: int = pos) -> None:
|
| 2390 |
progress((offset + cur / tot) / total, desc=f"Slicing shape {offset + 1} of {total}...")
|
| 2391 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2392 |
try:
|
| 2393 |
+
stack = _slice_record(record, layer_height, scale_mode, report_progress)
|
| 2394 |
+
(x_min, y_min, _z_min), (x_max, y_max, _z_max) = stack.bounds
|
| 2395 |
+
messages.append(
|
| 2396 |
+
f"Shape {record['idx']}: sliced {len(stack.layers)} layers "
|
| 2397 |
+
f"(footprint {x_max - x_min:.2f} x {y_max - y_min:.2f} mm)."
|
|
|
|
| 2398 |
)
|
|
|
|
|
|
|
|
|
|
| 2399 |
except Exception as exc:
|
| 2400 |
messages.append(f"Shape {record['idx']}: failed ({exc}).")
|
| 2401 |
+
ref_layers = generate_dynamic_reference_stack(records)
|
| 2402 |
+
if ref_layers is not None:
|
| 2403 |
+
messages.append("Reference layers: updated automatically.")
|
| 2404 |
else:
|
| 2405 |
+
messages.append("Reference layers: skipped (no sliced shapes available).")
|
| 2406 |
+
return records, "\n".join(messages), ref_layers
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2407 |
|
|
|
|
|
|
|
| 2408 |
|
| 2409 |
+
def generate_dynamic_reference_stack(records: list[dict] | None) -> LayerStack | None:
|
| 2410 |
+
return build_reference_stack([record.get("layer_stack") for record in (records or [])])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2411 |
|
| 2412 |
|
| 2413 |
def split_selected_shape_for_grid(
|
|
|
|
| 2421 |
overlapping_layers: bool,
|
| 2422 |
starting_nozzle: float,
|
| 2423 |
starting_valve: float,
|
| 2424 |
+
fil_width: float,
|
| 2425 |
) -> tuple:
|
| 2426 |
records = _apply_shape_settings(records or [], settings_table)
|
| 2427 |
+
|
| 2428 |
+
def _outputs(next_records: list[dict], selected_value: str | None, status: str) -> tuple:
|
| 2429 |
return (
|
| 2430 |
+
next_records,
|
| 2431 |
+
_shape_settings_rows(next_records),
|
| 2432 |
+
_spacing_table_update(next_records, existing_spacing, use_individual_spacing),
|
| 2433 |
+
_dropdown_update(next_records, selected_value),
|
| 2434 |
+
[record.get("gcode_path") for record in next_records if record.get("gcode_path")],
|
| 2435 |
+
_gcode_dropdown_update(next_records),
|
| 2436 |
+
_gcode_dropdown_update(next_records, include_upload=True),
|
| 2437 |
+
_dropdown_update(next_records, selected_value),
|
| 2438 |
+
status,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2439 |
)
|
| 2440 |
|
| 2441 |
+
if not records:
|
| 2442 |
+
return _outputs(records, None, "Slice a shape before splitting it.")
|
| 2443 |
+
|
| 2444 |
pos = _selected_record_index(records, selected)
|
| 2445 |
if pos < 0:
|
| 2446 |
pos = 0
|
| 2447 |
source = records[pos]
|
| 2448 |
+
stack = source.get("layer_stack")
|
| 2449 |
+
if stack is None or not getattr(stack, "layers", None):
|
| 2450 |
+
return _outputs(
|
| 2451 |
+
records,
|
| 2452 |
+
selected,
|
| 2453 |
+
f"Split failed: Shape {source.get('idx', pos + 1)} has no sliced layers yet - press Slice Shapes first.",
|
| 2454 |
+
)
|
| 2455 |
+
|
| 2456 |
+
split_column_count = max(1, _coerce_int(columns, 2))
|
| 2457 |
+
split_row_count = max(1, _coerce_int(rows, 1))
|
| 2458 |
try:
|
| 2459 |
+
pieces = split_layer_stack_grid(
|
| 2460 |
+
stack,
|
| 2461 |
+
columns=split_column_count,
|
| 2462 |
+
rows=split_row_count,
|
| 2463 |
+
overlapping_layers=bool(overlapping_layers),
|
| 2464 |
+
overlap=float(fil_width) if overlapping_layers else 0.0,
|
|
|
|
| 2465 |
)
|
| 2466 |
except Exception as exc:
|
| 2467 |
+
return _outputs(records, selected, f"Split failed: {exc}")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2468 |
|
| 2469 |
base_name = str(source.get("name") or f"Shape {source.get('idx', pos + 1)}")
|
| 2470 |
first_nozzle = max(1, _coerce_int(starting_nozzle, 1))
|
| 2471 |
first_valve = max(1, _coerce_int(starting_valve, _coerce_int(source.get("valve", 4), 4)))
|
|
|
|
|
|
|
| 2472 |
split_group_id = f"split-{int(time.time() * 1_000_000)}-{source.get('idx', pos + 1)}"
|
| 2473 |
split_records: list[dict] = []
|
| 2474 |
for index, piece in enumerate(pieces):
|
| 2475 |
+
row_index = index // split_column_count + 1
|
| 2476 |
+
col_index = index % split_column_count + 1
|
| 2477 |
+
(piece_x_min, piece_y_min, _z_min), (piece_x_max, piece_y_max, _z_max) = piece.bounds
|
| 2478 |
piece_record = dict(source)
|
| 2479 |
piece_record.update({
|
| 2480 |
+
"name": f"{base_name} - R{row_index}C{col_index}",
|
| 2481 |
"stl_path": None,
|
| 2482 |
+
"target_x": (piece_x_max - piece_x_min) or source.get("target_x", DEFAULT_TARGET_EXTENTS[0]),
|
| 2483 |
+
"target_y": (piece_y_max - piece_y_min) or source.get("target_y", DEFAULT_TARGET_EXTENTS[1]),
|
| 2484 |
"nozzle": first_nozzle + index,
|
| 2485 |
"valve": first_valve + index,
|
| 2486 |
"split_group_id": split_group_id,
|
| 2487 |
"split_index": index,
|
| 2488 |
+
"split_row": row_index,
|
| 2489 |
+
"split_col": col_index,
|
| 2490 |
"split_rows": split_row_count,
|
| 2491 |
"split_columns": split_column_count,
|
| 2492 |
+
"layer_stack": piece,
|
| 2493 |
+
"slice_params": source.get("slice_params"),
|
| 2494 |
"gcode_path": None,
|
| 2495 |
})
|
| 2496 |
split_records.append(piece_record)
|
| 2497 |
next_records = _reindex_shape_records([*records[:pos], *split_records, *records[pos + 1:]])
|
| 2498 |
split_selected = _shape_choice(next_records[pos]) if pos < len(next_records) else None
|
|
|
|
|
|
|
| 2499 |
status = (
|
| 2500 |
+
f"Split Shape {source.get('idx', pos + 1)} into {len(pieces)} print-ready piece stacks "
|
| 2501 |
+
f"({split_column_count} columns x {split_row_count} rows). \n"
|
| 2502 |
f"Nozzles {first_nozzle}-{first_nozzle + len(pieces) - 1}; valves {first_valve}-{first_valve + len(pieces) - 1}."
|
| 2503 |
)
|
| 2504 |
if overlapping_layers:
|
| 2505 |
+
status += (
|
| 2506 |
+
" \nOverlapping Layers is enabled: split boundaries alternate by one "
|
| 2507 |
+
"filament width per layer so neighbouring pieces interlock."
|
| 2508 |
+
)
|
| 2509 |
+
return _outputs(next_records, split_selected, status)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2510 |
|
| 2511 |
|
| 2512 |
+
def _contour_tracing_sources(records: list[dict]) -> list[ContourSource]:
|
| 2513 |
+
sources: list[ContourSource] = []
|
| 2514 |
for record in records:
|
| 2515 |
if not record.get("contour_tracing"):
|
| 2516 |
continue
|
| 2517 |
+
stack = record.get("layer_stack")
|
| 2518 |
+
if stack is None or not getattr(stack, "layers", None):
|
| 2519 |
+
continue
|
| 2520 |
+
sources.append(
|
| 2521 |
+
ContourSource(
|
| 2522 |
+
owner_idx=int(record.get("idx", len(sources) + 1)),
|
| 2523 |
+
stack=stack,
|
| 2524 |
+
)
|
| 2525 |
+
)
|
|
|
|
| 2526 |
return sources
|
| 2527 |
|
| 2528 |
|
| 2529 |
+
def _ensure_records_sliced(
|
| 2530 |
+
records: list[dict],
|
| 2531 |
+
layer_height: float,
|
| 2532 |
+
scale_mode: str | None,
|
| 2533 |
+
messages: list[str],
|
| 2534 |
+
) -> bool:
|
| 2535 |
+
"""Re-slice records whose layers are missing or stale for the current settings."""
|
| 2536 |
+
resliced = False
|
| 2537 |
+
for record in records:
|
| 2538 |
+
stl_path = record.get("stl_path")
|
| 2539 |
+
if not stl_path:
|
| 2540 |
+
continue # Split pieces carry their clipped layers; nothing to re-slice.
|
| 2541 |
+
current = _slice_params_snapshot(record, layer_height, scale_mode)
|
| 2542 |
+
if record.get("layer_stack") is not None and record.get("slice_params") == current:
|
| 2543 |
+
continue
|
| 2544 |
+
try:
|
| 2545 |
+
stack = _slice_record(record, layer_height, scale_mode)
|
| 2546 |
+
messages.append(
|
| 2547 |
+
f"Shape {record['idx']}: sliced automatically ({len(stack.layers)} layers)."
|
| 2548 |
+
)
|
| 2549 |
+
except Exception as exc:
|
| 2550 |
+
record["layer_stack"] = None
|
| 2551 |
+
messages.append(f"Shape {record['idx']}: slicing failed ({exc}).")
|
| 2552 |
+
resliced = True
|
| 2553 |
+
return resliced
|
| 2554 |
+
|
| 2555 |
+
|
| 2556 |
def generate_dynamic_gcode(
|
| 2557 |
records: list[dict] | None,
|
| 2558 |
settings_table: Any,
|
|
|
|
| 2564 |
lead_in_length: float,
|
| 2565 |
lead_in_clearance: float,
|
| 2566 |
lead_in_lines: float,
|
| 2567 |
+
ref_layers: LayerStack | None,
|
| 2568 |
layer_height: float,
|
| 2569 |
+
fil_width: float,
|
| 2570 |
+
scale_mode: str | None,
|
| 2571 |
) -> tuple:
|
| 2572 |
records = _apply_shape_settings(records or [], settings_table)
|
|
|
|
|
|
|
| 2573 |
messages: list[str] = []
|
| 2574 |
+
if _ensure_records_sliced(records, layer_height, scale_mode, messages):
|
| 2575 |
+
# Anything re-sliced invalidates the previous reference union.
|
| 2576 |
+
ref_layers = generate_dynamic_reference_stack(records)
|
| 2577 |
+
contour_sources = _contour_tracing_sources(records)
|
| 2578 |
if contour_sources:
|
| 2579 |
+
enabled = ", ".join(f"Shape {source.owner_idx}" for source in contour_sources)
|
| 2580 |
+
messages.append(f"Contour tracing enabled for {enabled}.")
|
| 2581 |
for record in records:
|
| 2582 |
+
stack = record.get("layer_stack")
|
| 2583 |
+
if stack is None or not getattr(stack, "layers", None):
|
| 2584 |
+
messages.append(f"Shape {record['idx']}: skipped (no sliced layers).")
|
| 2585 |
continue
|
| 2586 |
+
if use_reference_motion and ref_layers is None:
|
| 2587 |
+
messages.append(f"Shape {record['idx']}: skipped (Reference motion selected, but no shapes are sliced).")
|
| 2588 |
continue
|
| 2589 |
+
shape_name = str(record.get("name") or stack.name or f"shape_{record['idx']}").replace(" ", "_")
|
| 2590 |
try:
|
| 2591 |
+
gcode_path = generate_vector_gcode(
|
| 2592 |
+
stack,
|
| 2593 |
shape_name=shape_name,
|
| 2594 |
pressure=float(record.get("pressure", 25.0)),
|
| 2595 |
valve=int(record.get("valve", 4)),
|
| 2596 |
port=int(record.get("port", 1)),
|
| 2597 |
layer_height=float(layer_height),
|
| 2598 |
+
fil_width=float(fil_width),
|
| 2599 |
pressure_ramp_enabled=bool(pressure_ramp_enabled),
|
| 2600 |
all_g1=bool(all_g1),
|
| 2601 |
+
motion=ref_layers if use_reference_motion else None,
|
| 2602 |
raster_pattern=raster_pattern,
|
| 2603 |
+
contour_sources=contour_sources,
|
| 2604 |
active_contour_owner=int(record.get("idx", 0)),
|
| 2605 |
lead_in_enabled=bool(lead_in_enabled),
|
| 2606 |
lead_in_length=float(lead_in_length),
|
|
|
|
| 2613 |
messages.append(f"Shape {record['idx']}: failed ({exc}).")
|
| 2614 |
return (
|
| 2615 |
records,
|
| 2616 |
+
ref_layers,
|
| 2617 |
[record.get("gcode_path") for record in records if record.get("gcode_path")],
|
| 2618 |
"\n".join(messages),
|
| 2619 |
_gcode_dropdown_update(records),
|
|
|
|
| 2641 |
path = record.get("gcode_path")
|
| 2642 |
idx = int(record.get("idx", len(parts) + 1))
|
| 2643 |
if not path:
|
| 2644 |
+
messages.append(f"Shape {idx}: no G-code (generate it on the Generate G-Code tab).")
|
| 2645 |
continue
|
| 2646 |
try:
|
| 2647 |
parsed = parse_gcode_path(Path(path).read_text())
|
|
|
|
| 2779 |
records = _apply_shape_settings(records or [], settings_table)
|
| 2780 |
parts, messages = _parts_from_records(records)
|
| 2781 |
if not parts:
|
| 2782 |
+
return None, "No shape G-code available. Generate G-code on the Generate G-Code tab first."
|
| 2783 |
offsets, spacings = _resolve_layout_from_spacing_controls(
|
| 2784 |
parts,
|
| 2785 |
layout_mode,
|
|
|
|
| 2874 |
return str(result) if result else None
|
| 2875 |
|
| 2876 |
def build_dynamic_demo() -> gr.Blocks:
|
| 2877 |
+
with gr.Blocks(title="ParallelPrint: STL to G-Code", css=APP_CSS, head=APP_HEAD + TOOLPATH_ANIM_HEAD + PARALLEL_ANIM_HEAD) as demo:
|
| 2878 |
shape_records = gr.State([])
|
| 2879 |
last_shape_delete_at = gr.State(0.0)
|
| 2880 |
+
ref_layers = gr.State(None)
|
|
|
|
| 2881 |
|
| 2882 |
+
with gr.Tab("Shapes & Slicing"):
|
| 2883 |
gr.Markdown(
|
| 2884 |
"""
|
| 2885 |
+
# Shapes & Slicing
|
| 2886 |
+
Upload any number of STL files, edit per-shape dimensions and print settings in the table, then slice each shape into per-layer outlines.
|
| 2887 |
"""
|
| 2888 |
)
|
| 2889 |
with gr.Row():
|
|
|
|
| 2917 |
)
|
| 2918 |
with gr.Row():
|
| 2919 |
layer_height = gr.Number(label="Layer Height (mm)", value=0.8, minimum=0.0001, step=0.01)
|
| 2920 |
+
fil_width = gr.Number(label="Filament/Line Width (mm)", value=0.8, minimum=0.0001, step=0.01)
|
| 2921 |
+
generate_button = gr.Button("Slice Shapes", variant="primary")
|
| 2922 |
|
| 2923 |
with gr.Accordion("Multi-Nozzle Split", open=False, elem_classes=["settings-accordion"]):
|
| 2924 |
with gr.Row():
|
|
|
|
| 2931 |
split_start_valve = gr.Number(label="Starting Valve", value=4, minimum=1, step=1)
|
| 2932 |
split_overlapping_layers = gr.Checkbox(label="Overlapping Layers", value=False)
|
| 2933 |
split_button = gr.Button("Split Selected Shape into Grid Pieces", variant="primary")
|
| 2934 |
+
split_status = gr.Markdown("Slice a shape, then split it for multi-nozzle printing.")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2935 |
|
| 2936 |
with gr.Accordion("Selected Shape Preview", open=False, elem_classes=["settings-accordion"]):
|
| 2937 |
with gr.Row():
|
|
|
|
| 2950 |
with gr.Column(scale=1, min_width=300):
|
| 2951 |
model_details = gr.Markdown("No model loaded.")
|
| 2952 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2953 |
slicer_status = gr.Markdown("")
|
| 2954 |
|
| 2955 |
+
with gr.Tab("Generate G-Code"):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2956 |
gr.Markdown(
|
| 2957 |
"""
|
| 2958 |
+
# Generate G-Code
|
| 2959 |
+
Generate G-code for every sliced shape. Pressure, valve, nozzle, port, and color come from the Shape Settings table.
|
| 2960 |
"""
|
| 2961 |
)
|
| 2962 |
gcode_use_ref_motion = gr.Checkbox(
|
| 2963 |
+
label="Use combined reference outline for motion (all shapes share one nozzle path; each dispenses only its own geometry).",
|
| 2964 |
value=True,
|
| 2965 |
)
|
| 2966 |
gcode_all_g1 = gr.Checkbox(label="Move at one constant speed (no fast travel moves)", value=True)
|
|
|
|
| 3081 |
with gr.Tab("Parallel Printing Visualization"):
|
| 3082 |
gr.Markdown(
|
| 3083 |
"### Parallel Printing Visualization\n"
|
| 3084 |
+
"Plots all generated shapes using the nozzle spacing configured on the Generate G-Code tab."
|
| 3085 |
)
|
| 3086 |
with gr.Row():
|
| 3087 |
with gr.Column(scale=1, min_width=340):
|
|
|
|
| 3120 |
nozzle_grid_use_individual_spacing,
|
| 3121 |
]
|
| 3122 |
|
| 3123 |
+
shape_sync_outputs = [shape_records, shape_settings, nozzle_spacing_table, selected_shape, gcode_text_source, gcode_source, gcode_downloads]
|
| 3124 |
stl_upload.change(fn=sync_uploaded_shapes, inputs=[stl_upload, shape_records, shape_settings, nozzle_spacing_table, nozzle_use_individual_spacing], outputs=shape_sync_outputs).then(
|
| 3125 |
fn=lambda records: _dropdown_update(records),
|
| 3126 |
inputs=[shape_records],
|
|
|
|
| 3182 |
outputs=[nozzle_grid_spacing_table],
|
| 3183 |
queue=False,
|
| 3184 |
)
|
| 3185 |
+
selected_shape.change(fn=show_selected_model, inputs=preview_inputs, outputs=[model_viewer, model_details])
|
| 3186 |
+
refresh_preview_button.click(fn=show_selected_model, inputs=preview_inputs, outputs=[model_viewer, model_details])
|
| 3187 |
+
model_opacity.change(fn=show_selected_model, inputs=preview_inputs, outputs=[model_viewer, model_details])
|
| 3188 |
scale_mode.change(
|
| 3189 |
fn=normalize_shape_dimensions_for_mode,
|
| 3190 |
inputs=[shape_records, shape_settings, scale_mode],
|
|
|
|
| 3193 |
).then(
|
| 3194 |
fn=show_selected_model,
|
| 3195 |
inputs=preview_inputs,
|
| 3196 |
+
outputs=[model_viewer, model_details],
|
| 3197 |
)
|
| 3198 |
reset_dimensions_button.click(
|
| 3199 |
fn=reset_shape_dimensions,
|
|
|
|
| 3202 |
).then(
|
| 3203 |
fn=show_selected_model,
|
| 3204 |
inputs=preview_inputs,
|
| 3205 |
+
outputs=[model_viewer, model_details],
|
| 3206 |
)
|
| 3207 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3208 |
generate_button.click(
|
| 3209 |
+
fn=generate_dynamic_layer_stacks,
|
| 3210 |
+
inputs=[shape_records, shape_settings, layer_height, scale_mode],
|
| 3211 |
+
outputs=[shape_records, slicer_status, ref_layers],
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3212 |
).then(
|
| 3213 |
fn=lambda records: _dropdown_update(records),
|
| 3214 |
inputs=[shape_records],
|
| 3215 |
outputs=[split_source],
|
| 3216 |
queue=False,
|
| 3217 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3218 |
|
| 3219 |
split_refresh_sources.click(fn=lambda records: _dropdown_update(records), inputs=[shape_records], outputs=[split_source], queue=False)
|
| 3220 |
split_button.click(
|
|
|
|
| 3230 |
split_overlapping_layers,
|
| 3231 |
split_start_nozzle,
|
| 3232 |
split_start_valve,
|
| 3233 |
+
fil_width,
|
| 3234 |
],
|
| 3235 |
outputs=[
|
| 3236 |
shape_records,
|
| 3237 |
shape_settings,
|
| 3238 |
nozzle_spacing_table,
|
| 3239 |
selected_shape,
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3240 |
gcode_downloads,
|
| 3241 |
gcode_text_source,
|
| 3242 |
gcode_source,
|
| 3243 |
split_source,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3244 |
split_status,
|
| 3245 |
],
|
| 3246 |
).then(
|
| 3247 |
fn=generate_dynamic_reference_stack,
|
| 3248 |
inputs=[shape_records],
|
| 3249 |
+
outputs=[ref_layers],
|
| 3250 |
).then(
|
| 3251 |
fn=_grid_spacing_table_update,
|
| 3252 |
inputs=grid_spacing_refresh_inputs,
|
| 3253 |
outputs=[nozzle_grid_spacing_table],
|
| 3254 |
queue=False,
|
| 3255 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3256 |
|
| 3257 |
gcode_button.click(
|
| 3258 |
fn=generate_dynamic_gcode,
|
|
|
|
| 3267 |
gcode_lead_in_length,
|
| 3268 |
gcode_lead_in_clearance,
|
| 3269 |
gcode_lead_in_lines,
|
| 3270 |
+
ref_layers,
|
| 3271 |
layer_height,
|
| 3272 |
+
fil_width,
|
| 3273 |
+
scale_mode,
|
| 3274 |
],
|
| 3275 |
+
outputs=[shape_records, ref_layers, gcode_downloads, gcode_status, gcode_text_source, gcode_source],
|
| 3276 |
).then(
|
| 3277 |
fn=load_selected_gcode_text,
|
| 3278 |
inputs=[shape_records, gcode_text_source],
|
gcode_viewer.py
CHANGED
|
@@ -13,7 +13,11 @@ import plotly.graph_objects as go
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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)
|
|
|
|
| 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 |
+
# The value accepts a lowercase scientific exponent ("X-5.1e-08") so
|
| 17 |
+
# Python-formatted floats never get truncated to their mantissa ("X-5.1").
|
| 18 |
+
# Uppercase "E" is deliberately NOT treated as an exponent: in compact G-code
|
| 19 |
+
# it starts the extrusion token ("X1.2E3" means X=1.2, E=3).
|
| 20 |
+
_AXIS_RE = re.compile(r"([XYZxyz])\s*([-+]?(?:\d*\.\d+|\d+\.?)(?:e[-+]?\d+)?)")
|
| 21 |
# Pneumatic valve toggle: WAGO_ValveCommands(<valve>, <0=close|1=open>). Some
|
| 22 |
# generators emit every move as G1 and convey extrusion only through the valve.
|
| 23 |
_VALVE_RE = re.compile(r"WAGO_ValveCommands\(\s*(\d+)\s*,\s*(\d+)\s*\)", re.IGNORECASE)
|
stl_slicer.py
CHANGED
|
@@ -1,33 +1,37 @@
|
|
| 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
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 26 |
z_values: list[float]
|
| 27 |
-
|
| 28 |
-
bounds: tuple[tuple[float, float, float], tuple[float, float, float]]
|
| 29 |
layer_height: float
|
| 30 |
-
|
| 31 |
|
| 32 |
|
| 33 |
def load_mesh(stl_path: str | Path) -> trimesh.Trimesh:
|
|
@@ -110,21 +114,6 @@ def calculate_z_levels(z_min: float, z_max: float, layer_height: float) -> list[
|
|
| 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:
|
|
@@ -171,102 +160,75 @@ def _extract_world_polygons(section: trimesh.path.Path3D) -> list[tuple[np.ndarr
|
|
| 171 |
return polygons
|
| 172 |
|
| 173 |
|
| 174 |
-
def
|
| 175 |
-
|
| 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 |
-
|
| 184 |
-
|
|
|
|
|
|
|
|
|
|
| 185 |
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 189 |
|
| 190 |
-
|
| 191 |
-
|
| 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 |
-
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
zip_path = job_dir / f"{stem}_tiff_slices.zip"
|
| 212 |
-
return slices_dir, zip_path
|
| 213 |
-
|
| 214 |
|
| 215 |
-
|
| 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
|
| 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 |
-
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
|
| 232 |
stl_path = Path(stl_path)
|
| 233 |
mesh = scale_mesh(load_mesh(stl_path), scale_factors)
|
| 234 |
-
|
| 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 |
-
|
| 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 |
-
|
| 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 |
-
|
| 272 |
)
|
|
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
| 3 |
import math
|
|
|
|
|
|
|
|
|
|
| 4 |
from dataclasses import dataclass
|
| 5 |
from pathlib import Path
|
| 6 |
from typing import Callable, Sequence
|
| 7 |
|
| 8 |
import numpy as np
|
|
|
|
| 9 |
from shapely.geometry import GeometryCollection, MultiPolygon, Polygon
|
| 10 |
+
from shapely.validation import make_valid
|
| 11 |
import trimesh
|
| 12 |
|
| 13 |
|
| 14 |
ProgressCallback = Callable[[int, int], None] | None
|
| 15 |
ScaleFactors = tuple[float, float, float]
|
| 16 |
+
Bounds3D = tuple[tuple[float, float, float], tuple[float, float, float]]
|
| 17 |
+
|
| 18 |
+
MIN_POLYGON_AREA = 1e-9
|
| 19 |
|
| 20 |
|
| 21 |
@dataclass(slots=True)
|
| 22 |
+
class LayerStack:
|
| 23 |
+
"""A sliced shape as per-layer vector outlines in world-XY millimetres.
|
| 24 |
+
|
| 25 |
+
`bounds` is the scaled mesh's axis-aligned bounding box. For grid-split
|
| 26 |
+
pieces it is the piece's nominal cell box, which keeps reference-stack
|
| 27 |
+
centring stable even when the clipped geometry shrinks.
|
| 28 |
+
"""
|
| 29 |
+
|
| 30 |
+
layers: list[MultiPolygon]
|
| 31 |
z_values: list[float]
|
| 32 |
+
bounds: Bounds3D
|
|
|
|
| 33 |
layer_height: float
|
| 34 |
+
name: str = ""
|
| 35 |
|
| 36 |
|
| 37 |
def load_mesh(stl_path: str | Path) -> trimesh.Trimesh:
|
|
|
|
| 114 |
]
|
| 115 |
|
| 116 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 117 |
def _ring_to_world_xy(ring_coords: object, to_3d: np.ndarray) -> np.ndarray:
|
| 118 |
planar = np.asarray(ring_coords, dtype=float)
|
| 119 |
if planar.ndim != 2 or planar.shape[1] < 2:
|
|
|
|
| 160 |
return polygons
|
| 161 |
|
| 162 |
|
| 163 |
+
def _as_multipolygon(geometry: object, min_area: float = MIN_POLYGON_AREA) -> MultiPolygon:
|
| 164 |
+
"""Flatten any shapely result into a MultiPolygon, dropping slivers.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 165 |
|
| 166 |
+
Single choke point for shapely's habit of returning mixed geometry types
|
| 167 |
+
from overlay operations (Polygon / MultiPolygon / GeometryCollection /
|
| 168 |
+
lines / points / empties).
|
| 169 |
+
"""
|
| 170 |
+
polygons: list[Polygon] = []
|
| 171 |
|
| 172 |
+
def collect(geom: object) -> None:
|
| 173 |
+
if geom is None or getattr(geom, "is_empty", True):
|
| 174 |
+
return
|
| 175 |
+
if isinstance(geom, Polygon):
|
| 176 |
+
if geom.area > min_area:
|
| 177 |
+
polygons.append(geom)
|
| 178 |
+
elif isinstance(geom, (MultiPolygon, GeometryCollection)):
|
| 179 |
+
for part in geom.geoms:
|
| 180 |
+
collect(part)
|
| 181 |
|
| 182 |
+
collect(geometry)
|
| 183 |
+
return MultiPolygon(polygons)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 184 |
|
|
|
|
| 185 |
|
| 186 |
+
def _section_to_multipolygon(section: trimesh.path.Path3D | None) -> MultiPolygon:
|
| 187 |
+
if section is None:
|
| 188 |
+
return MultiPolygon()
|
| 189 |
|
| 190 |
+
polygons = [
|
| 191 |
+
Polygon(exterior, holes)
|
| 192 |
+
for exterior, holes in _extract_world_polygons(section)
|
| 193 |
+
]
|
| 194 |
+
if not polygons:
|
| 195 |
+
return MultiPolygon()
|
|
|
|
|
|
|
|
|
|
| 196 |
|
| 197 |
+
return _as_multipolygon(make_valid(MultiPolygon(polygons)))
|
|
|
|
|
|
|
|
|
|
| 198 |
|
| 199 |
|
| 200 |
+
def slice_stl_to_layers(
|
| 201 |
stl_path: str | Path,
|
| 202 |
layer_height: float,
|
|
|
|
|
|
|
| 203 |
progress_callback: ProgressCallback = None,
|
| 204 |
scale_factors: Sequence[float] | None = None,
|
| 205 |
+
name: str | None = None,
|
| 206 |
+
) -> LayerStack:
|
| 207 |
+
"""Slice an STL into per-layer vector outlines (world-XY millimetres)."""
|
|
|
|
| 208 |
stl_path = Path(stl_path)
|
| 209 |
mesh = scale_mesh(load_mesh(stl_path), scale_factors)
|
| 210 |
+
(x_min, y_min, z_min), (x_max, y_max, z_max) = mesh.bounds
|
|
|
|
| 211 |
|
| 212 |
z_values = calculate_z_levels(float(z_min), float(z_max), layer_height)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 213 |
|
| 214 |
+
layers: list[MultiPolygon] = []
|
| 215 |
for index, z_value in enumerate(z_values):
|
| 216 |
section = mesh.section(
|
| 217 |
plane_origin=np.array([0.0, 0.0, z_value], dtype=float),
|
| 218 |
plane_normal=np.array([0.0, 0.0, 1.0], dtype=float),
|
| 219 |
)
|
| 220 |
+
layers.append(_section_to_multipolygon(section))
|
|
|
|
|
|
|
|
|
|
| 221 |
|
| 222 |
if progress_callback is not None:
|
| 223 |
progress_callback(index + 1, len(z_values))
|
| 224 |
|
| 225 |
+
return LayerStack(
|
| 226 |
+
layers=layers,
|
|
|
|
|
|
|
|
|
|
|
|
|
| 227 |
z_values=z_values,
|
|
|
|
| 228 |
bounds=(
|
| 229 |
(float(x_min), float(y_min), float(z_min)),
|
| 230 |
(float(x_max), float(y_max), float(z_max)),
|
| 231 |
),
|
| 232 |
layer_height=layer_height,
|
| 233 |
+
name=name if name is not None else (stl_path.stem or "mesh"),
|
| 234 |
)
|
tests/test_app_scaling.py
CHANGED
|
@@ -2,14 +2,11 @@ from __future__ import annotations
|
|
| 2 |
|
| 3 |
import numpy as np
|
| 4 |
import trimesh
|
| 5 |
-
from PIL import Image
|
| 6 |
|
| 7 |
from app import (
|
| 8 |
SCALE_MODE_TARGET_DIMENSIONS,
|
| 9 |
SCALE_MODE_UNIFORM_FACTOR,
|
| 10 |
-
|
| 11 |
-
split_tiff_stack_grid,
|
| 12 |
-
split_tiff_stack_left_right,
|
| 13 |
_resolve_mesh_scale_factors,
|
| 14 |
_uniform_target_extents_from_anchor,
|
| 15 |
)
|
|
@@ -59,163 +56,46 @@ def test_uniform_target_extents_update_from_changed_side() -> None:
|
|
| 59 |
np.testing.assert_allclose(target_extents, (6.0, 12.0, 24.0))
|
| 60 |
|
| 61 |
|
| 62 |
-
def
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
assert outputs[9] == "No reference stack generated yet."
|
| 70 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 71 |
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
],
|
| 78 |
-
dtype=np.uint8,
|
| 79 |
)
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
"image_height": 2,
|
| 90 |
-
}
|
| 91 |
-
|
| 92 |
-
left_state, right_state, left_zip, right_zip = split_tiff_stack_left_right(state, "wide-part")
|
| 93 |
-
|
| 94 |
-
assert left_zip.exists()
|
| 95 |
-
assert right_zip.exists()
|
| 96 |
-
assert left_state["image_width"] == 3
|
| 97 |
-
assert right_state["image_width"] == 3
|
| 98 |
-
assert left_state["x_min"] == 10.0
|
| 99 |
-
assert right_state["x_min"] == 11.5
|
| 100 |
-
assert left_state["z_values"] == [1.25]
|
| 101 |
-
assert right_state["z_values"] == [1.25]
|
| 102 |
-
|
| 103 |
-
with Image.open(left_state["tiff_paths"][0]) as left_image:
|
| 104 |
-
np.testing.assert_array_equal(np.asarray(left_image), pixels[:, :3])
|
| 105 |
-
with Image.open(right_state["tiff_paths"][0]) as right_image:
|
| 106 |
-
expected = np.full((2, 3), 255, dtype=np.uint8)
|
| 107 |
-
expected[:, :2] = pixels[:, 3:]
|
| 108 |
-
np.testing.assert_array_equal(np.asarray(right_image), expected)
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
def test_split_tiff_stack_grid_preserves_pixels_and_offsets(tmp_path) -> None:
|
| 112 |
-
pixels = np.arange(20, dtype=np.uint8).reshape((4, 5))
|
| 113 |
-
tiff_path = tmp_path / "slice_0000.tif"
|
| 114 |
-
Image.fromarray(pixels, mode="L").save(tiff_path)
|
| 115 |
-
state = {
|
| 116 |
-
"tiff_paths": [str(tiff_path)],
|
| 117 |
-
"z_values": [2.0],
|
| 118 |
-
"pixel_size": 0.25,
|
| 119 |
-
"x_min": 4.0,
|
| 120 |
-
"y_min": 10.0,
|
| 121 |
-
"image_width": 5,
|
| 122 |
-
"image_height": 4,
|
| 123 |
-
}
|
| 124 |
-
|
| 125 |
-
pieces = split_tiff_stack_grid(state, "grid-part", columns=2, rows=2)
|
| 126 |
-
|
| 127 |
-
assert [(piece["row"], piece["col"]) for piece in pieces] == [(1, 1), (1, 2), (2, 1), (2, 2)]
|
| 128 |
-
assert [piece["state"]["image_width"] for piece in pieces] == [3, 3, 3, 3]
|
| 129 |
-
assert [piece["state"]["image_height"] for piece in pieces] == [2, 2, 2, 2]
|
| 130 |
-
assert [piece["state"]["x_min"] for piece in pieces] == [4.0, 4.75, 4.0, 4.75]
|
| 131 |
-
assert [piece["state"]["y_min"] for piece in pieces] == [10.5, 10.5, 10.0, 10.0]
|
| 132 |
-
assert all(piece["zip_path"].exists() for piece in pieces)
|
| 133 |
-
|
| 134 |
-
right_top = np.full((2, 3), 255, dtype=np.uint8)
|
| 135 |
-
right_top[:, :2] = pixels[:2, 3:]
|
| 136 |
-
right_bottom = np.full((2, 3), 255, dtype=np.uint8)
|
| 137 |
-
right_bottom[:, :2] = pixels[2:, 3:]
|
| 138 |
-
expected = [
|
| 139 |
-
pixels[:2, :3],
|
| 140 |
-
right_top,
|
| 141 |
-
pixels[2:, :3],
|
| 142 |
-
right_bottom,
|
| 143 |
-
]
|
| 144 |
-
for piece, expected_pixels in zip(pieces, expected):
|
| 145 |
-
with Image.open(piece["state"]["tiff_paths"][0]) as image:
|
| 146 |
-
np.testing.assert_array_equal(np.asarray(image), expected_pixels)
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
def test_split_tiff_stack_grid_pads_more_than_three_columns_to_equal_widths(tmp_path) -> None:
|
| 150 |
-
pixels = np.arange(20, dtype=np.uint8).reshape((2, 10))
|
| 151 |
-
tiff_path = tmp_path / "slice_0000.tif"
|
| 152 |
-
Image.fromarray(pixels, mode="L").save(tiff_path)
|
| 153 |
-
state = {
|
| 154 |
-
"tiff_paths": [str(tiff_path)],
|
| 155 |
-
"z_values": [0.0],
|
| 156 |
-
"pixel_size": 1.0,
|
| 157 |
-
"x_min": 100.0,
|
| 158 |
-
"y_min": 0.0,
|
| 159 |
-
"image_width": 10,
|
| 160 |
-
"image_height": 2,
|
| 161 |
-
}
|
| 162 |
-
|
| 163 |
-
pieces = split_tiff_stack_grid(state, "four-way", columns=4, rows=1)
|
| 164 |
-
|
| 165 |
-
assert [piece["state"]["image_width"] for piece in pieces] == [3, 3, 3, 3]
|
| 166 |
-
assert [piece["state"]["x_min"] for piece in pieces] == [99.0, 102.0, 105.0, 108.0]
|
| 167 |
-
|
| 168 |
-
first_expected = np.full((2, 3), 255, dtype=np.uint8)
|
| 169 |
-
first_expected[:, 1:] = pixels[:, :2]
|
| 170 |
-
last_expected = np.full((2, 3), 255, dtype=np.uint8)
|
| 171 |
-
last_expected[:, :2] = pixels[:, 8:]
|
| 172 |
-
expected = [
|
| 173 |
-
first_expected,
|
| 174 |
-
pixels[:, 2:5],
|
| 175 |
-
pixels[:, 5:8],
|
| 176 |
-
last_expected,
|
| 177 |
-
]
|
| 178 |
-
for piece, expected_pixels in zip(pieces, expected):
|
| 179 |
-
with Image.open(piece["state"]["tiff_paths"][0]) as image:
|
| 180 |
-
np.testing.assert_array_equal(np.asarray(image), expected_pixels)
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
def test_split_tiff_stack_grid_overlapping_layers_keep_small_alignment_margin(tmp_path) -> None:
|
| 184 |
-
layer0 = np.zeros((2, 6), dtype=np.uint8)
|
| 185 |
-
layer1 = np.zeros((2, 6), dtype=np.uint8)
|
| 186 |
-
layer0_path = tmp_path / "slice_0000.tif"
|
| 187 |
-
layer1_path = tmp_path / "slice_0001.tif"
|
| 188 |
-
Image.fromarray(layer0, mode="L").save(layer0_path)
|
| 189 |
-
Image.fromarray(layer1, mode="L").save(layer1_path)
|
| 190 |
-
state = {
|
| 191 |
-
"tiff_paths": [str(layer0_path), str(layer1_path)],
|
| 192 |
-
"z_values": [0.0, 1.0],
|
| 193 |
-
"pixel_size": 0.5,
|
| 194 |
-
"x_min": 10.0,
|
| 195 |
-
"y_min": -2.0,
|
| 196 |
-
"image_width": 6,
|
| 197 |
-
"image_height": 2,
|
| 198 |
-
}
|
| 199 |
-
|
| 200 |
-
left, right = split_tiff_stack_grid(state, "overlap-part", columns=2, rows=1, overlapping_layers=True)
|
| 201 |
-
|
| 202 |
-
assert left["state"]["image_width"] == 5
|
| 203 |
-
assert right["state"]["image_width"] == 5
|
| 204 |
-
assert left["state"]["x_min"] == 9.5
|
| 205 |
-
assert right["state"]["x_min"] == 11.0
|
| 206 |
-
with Image.open(left["state"]["tiff_paths"][0]) as left_layer0:
|
| 207 |
-
expected = np.full((2, 5), 255, dtype=np.uint8)
|
| 208 |
-
expected[:, 1:] = 0
|
| 209 |
-
np.testing.assert_array_equal(np.asarray(left_layer0), expected)
|
| 210 |
-
with Image.open(right["state"]["tiff_paths"][0]) as right_layer0:
|
| 211 |
-
expected = np.full((2, 5), 255, dtype=np.uint8)
|
| 212 |
-
expected[:, 2:4] = 0
|
| 213 |
-
np.testing.assert_array_equal(np.asarray(right_layer0), expected)
|
| 214 |
-
with Image.open(left["state"]["tiff_paths"][1]) as left_layer1:
|
| 215 |
-
expected = np.full((2, 5), 255, dtype=np.uint8)
|
| 216 |
-
expected[:, 1:3] = 0
|
| 217 |
-
np.testing.assert_array_equal(np.asarray(left_layer1), expected)
|
| 218 |
-
with Image.open(right["state"]["tiff_paths"][1]) as right_layer1:
|
| 219 |
-
expected = np.full((2, 5), 255, dtype=np.uint8)
|
| 220 |
-
expected[:, :4] = 0
|
| 221 |
-
np.testing.assert_array_equal(np.asarray(right_layer1), expected)
|
|
|
|
| 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 |
+
generate_dynamic_layer_stacks,
|
|
|
|
|
|
|
| 10 |
_resolve_mesh_scale_factors,
|
| 11 |
_uniform_target_extents_from_anchor,
|
| 12 |
)
|
|
|
|
| 56 |
np.testing.assert_allclose(target_extents, (6.0, 12.0, 24.0))
|
| 57 |
|
| 58 |
|
| 59 |
+
def test_generate_dynamic_layer_stacks_empty_input_resets_reference() -> None:
|
| 60 |
+
records, status, ref_layers = generate_dynamic_layer_stacks(
|
| 61 |
+
[],
|
| 62 |
+
[],
|
| 63 |
+
0.8,
|
| 64 |
+
SCALE_MODE_TARGET_DIMENSIONS,
|
| 65 |
+
)
|
|
|
|
| 66 |
|
| 67 |
+
assert records == []
|
| 68 |
+
assert status == "Upload at least one STL first."
|
| 69 |
+
assert ref_layers is None
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
def test_generate_dynamic_layer_stacks_slices_shapes_and_builds_reference(tmp_path) -> None:
|
| 73 |
+
mesh = trimesh.creation.box(extents=(2.0, 2.0, 2.0))
|
| 74 |
+
stl_path = tmp_path / "cube.stl"
|
| 75 |
+
mesh.export(stl_path)
|
| 76 |
+
records = [
|
| 77 |
+
{
|
| 78 |
+
"idx": 1,
|
| 79 |
+
"name": "cube",
|
| 80 |
+
"stl_path": str(stl_path),
|
| 81 |
+
"target_x": 2.0,
|
| 82 |
+
"target_y": 2.0,
|
| 83 |
+
"target_z": 2.0,
|
| 84 |
+
}
|
| 85 |
+
]
|
| 86 |
|
| 87 |
+
next_records, status, ref_layers = generate_dynamic_layer_stacks(
|
| 88 |
+
records,
|
| 89 |
+
None,
|
| 90 |
+
0.5,
|
| 91 |
+
SCALE_MODE_TARGET_DIMENSIONS,
|
|
|
|
|
|
|
| 92 |
)
|
| 93 |
+
|
| 94 |
+
stack = next_records[0]["layer_stack"]
|
| 95 |
+
assert stack is not None
|
| 96 |
+
assert len(stack.layers) == 4
|
| 97 |
+
assert next_records[0]["slice_params"]["layer_height"] == 0.5
|
| 98 |
+
assert "sliced 4 layers" in status
|
| 99 |
+
assert ref_layers is not None
|
| 100 |
+
assert len(ref_layers.layers) == 4
|
| 101 |
+
assert ref_layers.layers[0].area == stack.layers[0].area
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
tests/test_gcode_viewer.py
CHANGED
|
@@ -16,3 +16,35 @@ def test_parse_gcode_classifies_g0_as_travel_and_g1_as_print() -> None:
|
|
| 16 |
|
| 17 |
assert len(parsed["travel_segments"]) == 1
|
| 18 |
assert len(parsed["print_segments"]) == 1
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 16 |
|
| 17 |
assert len(parsed["travel_segments"]) == 1
|
| 18 |
assert len(parsed["print_segments"]) == 1
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def test_parse_gcode_reads_scientific_notation_instead_of_mantissa() -> None:
|
| 22 |
+
parsed = parse_gcode_path(
|
| 23 |
+
"\n".join(
|
| 24 |
+
[
|
| 25 |
+
"G91",
|
| 26 |
+
"G0 X-5.1e-08 Y0.8 ; tiny float-noise travel",
|
| 27 |
+
"G1 X4.0 Y0.0 ; print",
|
| 28 |
+
]
|
| 29 |
+
)
|
| 30 |
+
)
|
| 31 |
+
|
| 32 |
+
(segment,) = parsed["print_segments"]
|
| 33 |
+
start, end = segment[0], segment[-1]
|
| 34 |
+
# "X-5.1e-08" must move the head by ~0, not by -5.1.
|
| 35 |
+
assert abs(start[0]) < 1e-6
|
| 36 |
+
assert abs(end[0] - 4.0) < 1e-6
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def test_parse_gcode_keeps_uppercase_extrusion_token_out_of_x_axis() -> None:
|
| 40 |
+
parsed = parse_gcode_path(
|
| 41 |
+
"\n".join(
|
| 42 |
+
[
|
| 43 |
+
"G91",
|
| 44 |
+
"G1 X1.2E3 Y0.5", # compact G-code: X=1.2, E=3 (not X=1200)
|
| 45 |
+
]
|
| 46 |
+
)
|
| 47 |
+
)
|
| 48 |
+
|
| 49 |
+
(segment,) = parsed["print_segments"]
|
| 50 |
+
assert abs(segment[-1][0] - 1.2) < 1e-9
|
tests/test_nozzle_spacing.py
CHANGED
|
@@ -22,8 +22,7 @@ from app import (
|
|
| 22 |
_resolve_nozzle_grid_layout,
|
| 23 |
_resolve_nozzle_layout,
|
| 24 |
)
|
| 25 |
-
from
|
| 26 |
-
CONTOUR_MODE_ROW_ENVELOPE,
|
| 27 |
RASTER_PATTERN_SAME_DIRECTION,
|
| 28 |
RASTER_PATTERN_Y_DIRECTION,
|
| 29 |
)
|
|
@@ -256,25 +255,29 @@ def test_shape_settings_round_trip_contour_tracing_column() -> None:
|
|
| 256 |
assert updated[0]["contour_tracing"] is True
|
| 257 |
|
| 258 |
|
| 259 |
-
def
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 260 |
sources = _contour_tracing_sources(
|
| 261 |
[
|
| 262 |
-
{
|
| 263 |
-
|
| 264 |
-
|
| 265 |
-
"tiff_state": {"tiff_paths": ["slice_0000.tif"]},
|
| 266 |
-
}
|
| 267 |
]
|
| 268 |
)
|
| 269 |
|
| 270 |
-
assert sources ==
|
| 271 |
-
|
| 272 |
-
|
| 273 |
-
"contour_mode": CONTOUR_MODE_ROW_ENVELOPE,
|
| 274 |
-
"tiff_paths": ["slice_0000.tif"],
|
| 275 |
-
"zip_path": None,
|
| 276 |
-
}
|
| 277 |
-
]
|
| 278 |
|
| 279 |
|
| 280 |
def test_repeated_sample_path_gets_next_unused_nozzle() -> None:
|
|
|
|
| 22 |
_resolve_nozzle_grid_layout,
|
| 23 |
_resolve_nozzle_layout,
|
| 24 |
)
|
| 25 |
+
from vector_toolpath import (
|
|
|
|
| 26 |
RASTER_PATTERN_SAME_DIRECTION,
|
| 27 |
RASTER_PATTERN_Y_DIRECTION,
|
| 28 |
)
|
|
|
|
| 255 |
assert updated[0]["contour_tracing"] is True
|
| 256 |
|
| 257 |
|
| 258 |
+
def test_contour_tracing_sources_use_sliced_layer_stacks() -> None:
|
| 259 |
+
from shapely.geometry import MultiPolygon, box
|
| 260 |
+
|
| 261 |
+
from stl_slicer import LayerStack
|
| 262 |
+
|
| 263 |
+
stack = LayerStack(
|
| 264 |
+
layers=[MultiPolygon([box(0.0, 0.0, 1.0, 1.0)])],
|
| 265 |
+
z_values=[0.5],
|
| 266 |
+
bounds=((0.0, 0.0, 0.0), (1.0, 1.0, 1.0)),
|
| 267 |
+
layer_height=1.0,
|
| 268 |
+
name="traced",
|
| 269 |
+
)
|
| 270 |
sources = _contour_tracing_sources(
|
| 271 |
[
|
| 272 |
+
{"idx": 1, "contour_tracing": False, "layer_stack": stack},
|
| 273 |
+
{"idx": 2, "contour_tracing": True, "layer_stack": stack},
|
| 274 |
+
{"idx": 3, "contour_tracing": True, "layer_stack": None},
|
|
|
|
|
|
|
| 275 |
]
|
| 276 |
)
|
| 277 |
|
| 278 |
+
assert len(sources) == 1
|
| 279 |
+
assert sources[0].owner_idx == 2
|
| 280 |
+
assert sources[0].stack is stack
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 281 |
|
| 282 |
|
| 283 |
def test_repeated_sample_path_gets_next_unused_nozzle() -> None:
|
tests/test_stl_slicer.py
CHANGED
|
@@ -1,16 +1,17 @@
|
|
| 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 |
-
|
| 14 |
)
|
| 15 |
|
| 16 |
|
|
@@ -21,26 +22,43 @@ def test_calculate_z_levels_creates_single_layer_for_thin_mesh() -> None:
|
|
| 21 |
assert 0.0 <= z_values[0] < 0.01
|
| 22 |
|
| 23 |
|
| 24 |
-
def
|
| 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 =
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
|
|
|
|
|
|
| 34 |
)
|
|
|
|
|
|
|
| 35 |
|
| 36 |
-
|
| 37 |
-
assert
|
| 38 |
-
assert
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 39 |
|
| 40 |
-
|
| 41 |
-
|
|
|
|
|
|
|
|
|
|
| 42 |
|
| 43 |
-
|
|
|
|
|
|
|
|
|
|
| 44 |
|
| 45 |
|
| 46 |
def test_scale_mesh_matches_target_extents_and_preserves_min_corner() -> None:
|
|
@@ -55,25 +73,6 @@ def test_scale_mesh_matches_target_extents_and_preserves_min_corner() -> None:
|
|
| 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)])
|
|
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
| 3 |
import numpy as np
|
|
|
|
| 4 |
from shapely.geometry import Polygon
|
| 5 |
import trimesh
|
| 6 |
|
| 7 |
+
import pytest
|
| 8 |
+
|
| 9 |
from stl_slicer import (
|
| 10 |
_compose_even_odd_polygons,
|
| 11 |
calculate_z_levels,
|
| 12 |
scale_factors_for_target_extents,
|
| 13 |
scale_mesh,
|
| 14 |
+
slice_stl_to_layers,
|
| 15 |
)
|
| 16 |
|
| 17 |
|
|
|
|
| 22 |
assert 0.0 <= z_values[0] < 0.01
|
| 23 |
|
| 24 |
|
| 25 |
+
def test_slice_stl_to_layers_creates_layer_polygons(tmp_path) -> None:
|
| 26 |
mesh = trimesh.creation.box(extents=(2.0, 2.0, 2.0))
|
| 27 |
stl_path = tmp_path / "cube.stl"
|
| 28 |
mesh.export(stl_path)
|
| 29 |
|
| 30 |
+
stack = slice_stl_to_layers(stl_path, layer_height=0.5)
|
| 31 |
+
|
| 32 |
+
assert len(stack.layers) == 4
|
| 33 |
+
assert len(stack.z_values) == 4
|
| 34 |
+
assert all(
|
| 35 |
+
later > earlier
|
| 36 |
+
for earlier, later in zip(stack.z_values, stack.z_values[1:])
|
| 37 |
)
|
| 38 |
+
for layer in stack.layers:
|
| 39 |
+
assert layer.area == pytest.approx(4.0)
|
| 40 |
|
| 41 |
+
(x_min, y_min, z_min), (x_max, y_max, z_max) = stack.bounds
|
| 42 |
+
assert (x_max - x_min, y_max - y_min, z_max - z_min) == pytest.approx((2.0, 2.0, 2.0))
|
| 43 |
+
assert stack.name == "cube"
|
| 44 |
+
assert stack.layer_height == 0.5
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def test_slice_stl_to_layers_applies_scale_factors(tmp_path) -> None:
|
| 48 |
+
mesh = trimesh.creation.box(extents=(2.0, 2.0, 2.0))
|
| 49 |
+
stl_path = tmp_path / "cube.stl"
|
| 50 |
+
mesh.export(stl_path)
|
| 51 |
|
| 52 |
+
stack = slice_stl_to_layers(
|
| 53 |
+
stl_path,
|
| 54 |
+
layer_height=0.5,
|
| 55 |
+
scale_factors=(2.0, 1.0, 0.5),
|
| 56 |
+
)
|
| 57 |
|
| 58 |
+
bounds = np.array(stack.bounds)
|
| 59 |
+
np.testing.assert_allclose(bounds[1] - bounds[0], (4.0, 2.0, 1.0))
|
| 60 |
+
assert len(stack.layers) == 2
|
| 61 |
+
assert stack.layers[0].area == pytest.approx(8.0)
|
| 62 |
|
| 63 |
|
| 64 |
def test_scale_mesh_matches_target_extents_and_preserves_min_corner() -> None:
|
|
|
|
| 73 |
np.testing.assert_allclose(scaled.bounds[0], mesh.bounds[0])
|
| 74 |
|
| 75 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 76 |
def test_compose_even_odd_polygons_preserves_holes() -> None:
|
| 77 |
outer = Polygon([(0, 0), (10, 0), (10, 10), (0, 10)])
|
| 78 |
inner = Polygon([(3, 3), (7, 3), (7, 7), (3, 7)])
|
tests/test_vector_gcode.py
ADDED
|
@@ -0,0 +1,823 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import math
|
| 4 |
+
|
| 5 |
+
from shapely.geometry import MultiPolygon, Polygon, box
|
| 6 |
+
|
| 7 |
+
from stl_slicer import LayerStack
|
| 8 |
+
from vector_gcode import generate_vector_gcode
|
| 9 |
+
from vector_toolpath import (
|
| 10 |
+
RASTER_PATTERN_CIRCLE_SPIRAL,
|
| 11 |
+
RASTER_PATTERN_RECTANGULAR_SPIRAL,
|
| 12 |
+
RASTER_PATTERN_WOODPILE,
|
| 13 |
+
RASTER_PATTERN_Y_DIRECTION,
|
| 14 |
+
ContourSource,
|
| 15 |
+
_append_layer_contours,
|
| 16 |
+
_circle_spiral_points,
|
| 17 |
+
_layer_contour_loops,
|
| 18 |
+
_rectangular_spiral_polyline,
|
| 19 |
+
build_reference_stack,
|
| 20 |
+
split_layer_stack_grid,
|
| 21 |
+
)
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def _stack(
|
| 25 |
+
*layers: Polygon | MultiPolygon | None,
|
| 26 |
+
layer_height: float = 1.0,
|
| 27 |
+
name: str = "shape",
|
| 28 |
+
) -> LayerStack:
|
| 29 |
+
multipolygons: list[MultiPolygon] = []
|
| 30 |
+
for layer in layers:
|
| 31 |
+
if layer is None:
|
| 32 |
+
multipolygons.append(MultiPolygon())
|
| 33 |
+
elif isinstance(layer, MultiPolygon):
|
| 34 |
+
multipolygons.append(layer)
|
| 35 |
+
else:
|
| 36 |
+
multipolygons.append(MultiPolygon([layer]))
|
| 37 |
+
|
| 38 |
+
bounds_list = [layer.bounds for layer in multipolygons if not layer.is_empty]
|
| 39 |
+
if bounds_list:
|
| 40 |
+
x_min = min(b[0] for b in bounds_list)
|
| 41 |
+
y_min = min(b[1] for b in bounds_list)
|
| 42 |
+
x_max = max(b[2] for b in bounds_list)
|
| 43 |
+
y_max = max(b[3] for b in bounds_list)
|
| 44 |
+
else:
|
| 45 |
+
x_min = y_min = x_max = y_max = 0.0
|
| 46 |
+
|
| 47 |
+
return LayerStack(
|
| 48 |
+
layers=multipolygons,
|
| 49 |
+
z_values=[(index + 0.5) * layer_height for index in range(len(multipolygons))],
|
| 50 |
+
bounds=((x_min, y_min, 0.0), (x_max, y_max, len(multipolygons) * layer_height)),
|
| 51 |
+
layer_height=layer_height,
|
| 52 |
+
name=name,
|
| 53 |
+
)
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def _move_signature(gcode_text: str) -> list[tuple[float | None, float | None, float | None]]:
|
| 57 |
+
signature: list[tuple[float | None, float | None, float | None]] = []
|
| 58 |
+
for line in gcode_text.splitlines():
|
| 59 |
+
if not line.startswith(("G0", "G1")):
|
| 60 |
+
continue
|
| 61 |
+
axes: dict[str, float] = {}
|
| 62 |
+
for token in line.split():
|
| 63 |
+
if token[:1] in {"X", "Y", "Z"}:
|
| 64 |
+
axes[token[0]] = float(token[1:])
|
| 65 |
+
signature.append((axes.get("X"), axes.get("Y"), axes.get("Z")))
|
| 66 |
+
return signature
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
def _move_endpoints_for_color(gcode_text: str, color: int) -> list[tuple[float, float]]:
|
| 70 |
+
x = y = 0.0
|
| 71 |
+
endpoints: list[tuple[float, float]] = []
|
| 72 |
+
for line in gcode_text.splitlines():
|
| 73 |
+
if not line.startswith(("G0", "G1")):
|
| 74 |
+
continue
|
| 75 |
+
start = (x, y)
|
| 76 |
+
for token in line.split():
|
| 77 |
+
if token.startswith("X"):
|
| 78 |
+
x += float(token[1:])
|
| 79 |
+
if token.startswith("Y"):
|
| 80 |
+
y += float(token[1:])
|
| 81 |
+
if f"; Color {color}" in line:
|
| 82 |
+
endpoints.extend([start, (x, y)])
|
| 83 |
+
return endpoints
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
def _moves_with_colors(gcode_text: str) -> list[dict]:
|
| 87 |
+
x = y = z = 0.0
|
| 88 |
+
moves: list[dict] = []
|
| 89 |
+
for line in gcode_text.splitlines():
|
| 90 |
+
if not line.startswith(("G0", "G1")):
|
| 91 |
+
continue
|
| 92 |
+
start = (x, y, z)
|
| 93 |
+
for token in line.split():
|
| 94 |
+
if token.startswith("X"):
|
| 95 |
+
x += float(token[1:])
|
| 96 |
+
if token.startswith("Y"):
|
| 97 |
+
y += float(token[1:])
|
| 98 |
+
if token.startswith("Z"):
|
| 99 |
+
z += float(token[1:])
|
| 100 |
+
color = None
|
| 101 |
+
if "; Color " in line:
|
| 102 |
+
color = int(line.rsplit("; Color ", 1)[1])
|
| 103 |
+
moves.append({"start": start, "end": (x, y, z), "color": color})
|
| 104 |
+
return moves
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
def _pressure_set_count(gcode_text: str) -> int:
|
| 108 |
+
return gcode_text.count("\\x30\\x38\\x50\\x53") + gcode_text.count("setpress(")
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
def test_gcode_writes_fixed_point_coordinates_never_scientific(tmp_path) -> None:
|
| 112 |
+
from vector_gcode import write_gcode_file
|
| 113 |
+
|
| 114 |
+
gcode_path = tmp_path / "noise.txt"
|
| 115 |
+
write_gcode_file(
|
| 116 |
+
gcode_path,
|
| 117 |
+
[
|
| 118 |
+
{"X": -5.1e-08, "Y": 0.8, "Color": 0},
|
| 119 |
+
{"X": 1.2e-05, "Y": 0.0, "Color": 255},
|
| 120 |
+
{"X": 4.0, "Y": -0.0, "Color": 0},
|
| 121 |
+
],
|
| 122 |
+
pressure=25,
|
| 123 |
+
valve=7,
|
| 124 |
+
port=3,
|
| 125 |
+
increase_pressure_per_layer=0.1,
|
| 126 |
+
pressure_ramp_enabled=True,
|
| 127 |
+
all_g1=False,
|
| 128 |
+
)
|
| 129 |
+
|
| 130 |
+
move_lines = [
|
| 131 |
+
line for line in gcode_path.read_text().splitlines() if line.startswith(("G0", "G1"))
|
| 132 |
+
]
|
| 133 |
+
assert move_lines == [
|
| 134 |
+
"G0 X0.0 Y0.8 ; Color 0",
|
| 135 |
+
"G1 X0.000012 Y0.0 ; Color 255",
|
| 136 |
+
"G0 X4.0 Y0.0 ; Color 0",
|
| 137 |
+
]
|
| 138 |
+
|
| 139 |
+
|
| 140 |
+
def test_slanted_shape_gcode_round_trips_through_the_viewer(tmp_path) -> None:
|
| 141 |
+
from gcode_viewer import parse_gcode_path
|
| 142 |
+
|
| 143 |
+
# Slanted edges produce float-noise sweep bounds that differ between rows
|
| 144 |
+
# (the pyramid failure mode); the parsed positions must stay inside the
|
| 145 |
+
# material footprint on every layer.
|
| 146 |
+
layers = [
|
| 147 |
+
Polygon(
|
| 148 |
+
[
|
| 149 |
+
(inset, inset),
|
| 150 |
+
(20.0 - inset, inset),
|
| 151 |
+
(20.0 - inset, 20.0 - inset),
|
| 152 |
+
(inset, 20.0 - inset),
|
| 153 |
+
]
|
| 154 |
+
)
|
| 155 |
+
for inset in (0.0, 0.57735026918962, 1.15470053837925, 1.73205080756887)
|
| 156 |
+
]
|
| 157 |
+
gcode_path = generate_vector_gcode(
|
| 158 |
+
_stack(*layers),
|
| 159 |
+
shape_name="slanted",
|
| 160 |
+
pressure=25,
|
| 161 |
+
valve=7,
|
| 162 |
+
port=3,
|
| 163 |
+
fil_width=0.8,
|
| 164 |
+
layer_height=1.0,
|
| 165 |
+
output_dir=tmp_path,
|
| 166 |
+
)
|
| 167 |
+
|
| 168 |
+
parsed = parse_gcode_path(gcode_path.read_text())
|
| 169 |
+
for segment in parsed["print_segments"]:
|
| 170 |
+
for x, y, _z in segment:
|
| 171 |
+
# Origin sits one fil_width left of the layer-0 sweep start; all
|
| 172 |
+
# print positions stay within the 20 mm footprint plus buffers.
|
| 173 |
+
assert -1.0 <= x <= 21.0
|
| 174 |
+
assert -1.0 <= y <= 21.0
|
| 175 |
+
|
| 176 |
+
|
| 177 |
+
def test_gcode_header_writes_presets_before_initial_aux_commands(tmp_path) -> None:
|
| 178 |
+
gcode_path = generate_vector_gcode(
|
| 179 |
+
_stack(box(0.0, 0.0, 1.0, 1.0)),
|
| 180 |
+
shape_name="header_order",
|
| 181 |
+
pressure=25,
|
| 182 |
+
valve=7,
|
| 183 |
+
port=3,
|
| 184 |
+
fil_width=1.0,
|
| 185 |
+
output_dir=tmp_path,
|
| 186 |
+
)
|
| 187 |
+
|
| 188 |
+
lines = [
|
| 189 |
+
line.strip()
|
| 190 |
+
for line in gcode_path.read_text().splitlines()
|
| 191 |
+
if line.strip()
|
| 192 |
+
]
|
| 193 |
+
|
| 194 |
+
assert lines[0] == "G91"
|
| 195 |
+
assert lines[1] == "{aux_command}WAGO_ValveCommands(7, 0)"
|
| 196 |
+
assert lines[2] == "serialPort3.write(eval(setpress(25)))"
|
| 197 |
+
assert lines[3] == "serialPort3.write(eval(togglepress()))"
|
| 198 |
+
assert lines[4].startswith("{aux_command}WAGO_ValveCommands(")
|
| 199 |
+
assert lines[5].startswith("{aux_command}WAGO_ValveCommands(")
|
| 200 |
+
|
| 201 |
+
|
| 202 |
+
def test_gcode_lead_in_runs_once_before_first_layer(tmp_path) -> None:
|
| 203 |
+
gcode_path = generate_vector_gcode(
|
| 204 |
+
_stack(box(0.0, 0.0, 0.5, 0.5), box(0.0, 0.0, 0.5, 0.5)),
|
| 205 |
+
shape_name="lead_in",
|
| 206 |
+
pressure=25,
|
| 207 |
+
valve=7,
|
| 208 |
+
port=3,
|
| 209 |
+
fil_width=0.5,
|
| 210 |
+
layer_height=1.0,
|
| 211 |
+
lead_in_enabled=True,
|
| 212 |
+
lead_in_length=3.0,
|
| 213 |
+
lead_in_clearance=4.0,
|
| 214 |
+
lead_in_lines=3,
|
| 215 |
+
output_dir=tmp_path,
|
| 216 |
+
)
|
| 217 |
+
|
| 218 |
+
moves = _moves_with_colors(gcode_path.read_text())
|
| 219 |
+
|
| 220 |
+
assert moves[:7] == [
|
| 221 |
+
{"start": (0.0, 0.0, 0.0), "end": (-7.0, 0.0, 0.0), "color": 0},
|
| 222 |
+
{"start": (-7.0, 0.0, 0.0), "end": (-4.0, 0.0, 0.0), "color": 255},
|
| 223 |
+
{"start": (-4.0, 0.0, 0.0), "end": (-4.0, 0.5, 0.0), "color": 0},
|
| 224 |
+
{"start": (-4.0, 0.5, 0.0), "end": (-7.0, 0.5, 0.0), "color": 255},
|
| 225 |
+
{"start": (-7.0, 0.5, 0.0), "end": (-7.0, 1.0, 0.0), "color": 0},
|
| 226 |
+
{"start": (-7.0, 1.0, 0.0), "end": (-4.0, 1.0, 0.0), "color": 255},
|
| 227 |
+
{"start": (-4.0, 1.0, 0.0), "end": (0.0, 0.0, 0.0), "color": 0},
|
| 228 |
+
]
|
| 229 |
+
assert all(move["end"][2] == 0.0 for move in moves[:7])
|
| 230 |
+
|
| 231 |
+
first_z_index = next(index for index, move in enumerate(moves) if move["end"][2] > 0.0)
|
| 232 |
+
assert first_z_index > 7
|
| 233 |
+
assert not any(
|
| 234 |
+
move["start"][0] < -3.0 or move["end"][0] < -3.0
|
| 235 |
+
for move in moves[first_z_index:]
|
| 236 |
+
)
|
| 237 |
+
|
| 238 |
+
|
| 239 |
+
def test_gcode_pressure_ramp_can_be_disabled(tmp_path) -> None:
|
| 240 |
+
stack = _stack(box(0.0, 0.0, 1.0, 1.0), box(0.0, 0.0, 1.0, 1.0))
|
| 241 |
+
|
| 242 |
+
ramped_path = generate_vector_gcode(
|
| 243 |
+
stack,
|
| 244 |
+
shape_name="pressure_ramped",
|
| 245 |
+
pressure=25,
|
| 246 |
+
valve=7,
|
| 247 |
+
port=3,
|
| 248 |
+
fil_width=1.0,
|
| 249 |
+
layer_height=1.0,
|
| 250 |
+
pressure_ramp_enabled=True,
|
| 251 |
+
output_dir=tmp_path / "ramped",
|
| 252 |
+
)
|
| 253 |
+
fixed_path = generate_vector_gcode(
|
| 254 |
+
stack,
|
| 255 |
+
shape_name="pressure_fixed",
|
| 256 |
+
pressure=25,
|
| 257 |
+
valve=7,
|
| 258 |
+
port=3,
|
| 259 |
+
fil_width=1.0,
|
| 260 |
+
layer_height=1.0,
|
| 261 |
+
pressure_ramp_enabled=False,
|
| 262 |
+
output_dir=tmp_path / "fixed",
|
| 263 |
+
)
|
| 264 |
+
|
| 265 |
+
assert _pressure_set_count(ramped_path.read_text()) > 1
|
| 266 |
+
assert _pressure_set_count(fixed_path.read_text()) == 1
|
| 267 |
+
|
| 268 |
+
|
| 269 |
+
def test_gcode_uses_g1_for_print_and_g0_for_travel(tmp_path) -> None:
|
| 270 |
+
gcode_path = generate_vector_gcode(
|
| 271 |
+
_stack(box(0.0, 0.0, 1.0, 1.0)),
|
| 272 |
+
shape_name="move_types",
|
| 273 |
+
pressure=25,
|
| 274 |
+
valve=7,
|
| 275 |
+
port=3,
|
| 276 |
+
fil_width=1.0,
|
| 277 |
+
output_dir=tmp_path,
|
| 278 |
+
)
|
| 279 |
+
|
| 280 |
+
move_lines = [
|
| 281 |
+
line.strip()
|
| 282 |
+
for line in gcode_path.read_text().splitlines()
|
| 283 |
+
if line.startswith(("G0", "G1"))
|
| 284 |
+
]
|
| 285 |
+
|
| 286 |
+
assert any(line.startswith("G1") and "; Color 255" in line for line in move_lines)
|
| 287 |
+
assert all(not line.startswith("G0") for line in move_lines if "; Color 255" in line)
|
| 288 |
+
assert all(not line.startswith("G1") for line in move_lines if "; Color 0" in line)
|
| 289 |
+
|
| 290 |
+
|
| 291 |
+
def test_woodpile_raster_switches_print_axis_between_layers(tmp_path) -> None:
|
| 292 |
+
layer = box(0.0, 0.0, 3.0, 2.0)
|
| 293 |
+
gcode_path = generate_vector_gcode(
|
| 294 |
+
_stack(layer, layer, layer, layer),
|
| 295 |
+
shape_name="woodpile",
|
| 296 |
+
pressure=25,
|
| 297 |
+
valve=7,
|
| 298 |
+
port=3,
|
| 299 |
+
fil_width=1.0,
|
| 300 |
+
raster_pattern=RASTER_PATTERN_WOODPILE,
|
| 301 |
+
output_dir=tmp_path,
|
| 302 |
+
)
|
| 303 |
+
|
| 304 |
+
gcode_text = gcode_path.read_text()
|
| 305 |
+
move_lines = [
|
| 306 |
+
line.strip()
|
| 307 |
+
for line in gcode_text.splitlines()
|
| 308 |
+
if line.startswith(("G0", "G1"))
|
| 309 |
+
]
|
| 310 |
+
z_move_index = next(i for i, line in enumerate(move_lines) if " Z" in line)
|
| 311 |
+
first_layer_prints = [
|
| 312 |
+
line
|
| 313 |
+
for line in move_lines[:z_move_index]
|
| 314 |
+
if line.startswith("G1") and "; Color 255" in line
|
| 315 |
+
]
|
| 316 |
+
second_layer_end = next(
|
| 317 |
+
(i for i, line in enumerate(move_lines[z_move_index + 1 :], start=z_move_index + 1) if " Z" in line),
|
| 318 |
+
len(move_lines),
|
| 319 |
+
)
|
| 320 |
+
second_layer_prints = [
|
| 321 |
+
line
|
| 322 |
+
for line in move_lines[z_move_index + 1 : second_layer_end]
|
| 323 |
+
if line.startswith("G1") and "; Color 255" in line
|
| 324 |
+
]
|
| 325 |
+
|
| 326 |
+
assert move_lines[0] == "G0 X1.0 Y0.0 ; Color 0"
|
| 327 |
+
# Layer 0 prints sweep along X, layer 1 prints sweep along Y.
|
| 328 |
+
assert first_layer_prints
|
| 329 |
+
assert all("Y0.0" in line for line in first_layer_prints)
|
| 330 |
+
assert second_layer_prints
|
| 331 |
+
assert all("X0.0" in line for line in second_layer_prints)
|
| 332 |
+
|
| 333 |
+
x = y = 0.0
|
| 334 |
+
x_positions = [x]
|
| 335 |
+
y_positions = [y]
|
| 336 |
+
for line in move_lines:
|
| 337 |
+
for token in line.split():
|
| 338 |
+
if token.startswith("X"):
|
| 339 |
+
x += float(token[1:])
|
| 340 |
+
if token.startswith("Y"):
|
| 341 |
+
y += float(token[1:])
|
| 342 |
+
x_positions.append(x)
|
| 343 |
+
y_positions.append(y)
|
| 344 |
+
assert min(x_positions) == 0.0
|
| 345 |
+
assert max(x_positions) == 5.0
|
| 346 |
+
assert min(y_positions) == -1.5
|
| 347 |
+
assert max(y_positions) == 2.5
|
| 348 |
+
|
| 349 |
+
# Each layer restarts at the sweep-start candidate nearest the previous
|
| 350 |
+
# layer's endpoint. Candidates are the four buffered sweep corners, here
|
| 351 |
+
# in cumulative coordinates (origin = layer 0's start at world (-1, 0.5)).
|
| 352 |
+
y_axis_candidates = [(1.5, -1.5), (1.5, 2.5), (3.5, -1.5), (3.5, 2.5)]
|
| 353 |
+
x_axis_candidates = [(0.0, 0.0), (0.0, 1.0), (5.0, 0.0), (5.0, 1.0)]
|
| 354 |
+
moves = _moves_with_colors(gcode_text)
|
| 355 |
+
layer_changes = [move for move in moves if move["end"][2] > move["start"][2]]
|
| 356 |
+
assert len(layer_changes) == 3
|
| 357 |
+
for layer_number, layer_change in enumerate(layer_changes, start=1):
|
| 358 |
+
candidates = y_axis_candidates if layer_number % 2 == 1 else x_axis_candidates
|
| 359 |
+
start = layer_change["start"][:2]
|
| 360 |
+
end = layer_change["end"][:2]
|
| 361 |
+
assert end in candidates
|
| 362 |
+
best = min(math.dist(start, candidate) for candidate in candidates)
|
| 363 |
+
assert math.dist(start, end) <= best + 1e-9
|
| 364 |
+
|
| 365 |
+
|
| 366 |
+
def test_y_direction_raster_prints_each_layer_along_y_axis(tmp_path) -> None:
|
| 367 |
+
layer = box(0.0, 0.0, 3.0, 2.0)
|
| 368 |
+
gcode_path = generate_vector_gcode(
|
| 369 |
+
_stack(layer, layer),
|
| 370 |
+
shape_name="y_direction",
|
| 371 |
+
pressure=25,
|
| 372 |
+
valve=7,
|
| 373 |
+
port=3,
|
| 374 |
+
fil_width=1.0,
|
| 375 |
+
raster_pattern=RASTER_PATTERN_Y_DIRECTION,
|
| 376 |
+
output_dir=tmp_path,
|
| 377 |
+
)
|
| 378 |
+
|
| 379 |
+
gcode_text = gcode_path.read_text()
|
| 380 |
+
move_lines = [
|
| 381 |
+
line.strip()
|
| 382 |
+
for line in gcode_text.splitlines()
|
| 383 |
+
if line.startswith(("G0", "G1"))
|
| 384 |
+
]
|
| 385 |
+
print_lines = [
|
| 386 |
+
line
|
| 387 |
+
for line in move_lines
|
| 388 |
+
if line.startswith("G1") and "; Color 255" in line
|
| 389 |
+
]
|
| 390 |
+
assert print_lines
|
| 391 |
+
assert move_lines[0] == "G0 X0.0 Y1.0 ; Color 0"
|
| 392 |
+
assert all("X0.0" in line and "Y0.0" not in line for line in print_lines)
|
| 393 |
+
|
| 394 |
+
x = y = 0.0
|
| 395 |
+
x_positions = [x]
|
| 396 |
+
y_positions = [y]
|
| 397 |
+
for line in move_lines:
|
| 398 |
+
for token in line.split():
|
| 399 |
+
if token.startswith("X"):
|
| 400 |
+
x += float(token[1:])
|
| 401 |
+
if token.startswith("Y"):
|
| 402 |
+
y += float(token[1:])
|
| 403 |
+
x_positions.append(x)
|
| 404 |
+
y_positions.append(y)
|
| 405 |
+
assert min(x_positions) == 0.0
|
| 406 |
+
assert max(x_positions) == 2.0
|
| 407 |
+
assert min(y_positions) == 0.0
|
| 408 |
+
assert max(y_positions) == 4.0
|
| 409 |
+
|
| 410 |
+
moves = _moves_with_colors(gcode_text)
|
| 411 |
+
first_layer_change = next(
|
| 412 |
+
move for move in moves if move["end"][2] > move["start"][2]
|
| 413 |
+
)
|
| 414 |
+
assert first_layer_change["start"][:2] == first_layer_change["end"][:2]
|
| 415 |
+
|
| 416 |
+
|
| 417 |
+
def test_raster_crosses_interior_holes_with_valve_off(tmp_path) -> None:
|
| 418 |
+
hollow = Polygon(
|
| 419 |
+
box(0.0, 0.0, 6.0, 6.0).exterior.coords,
|
| 420 |
+
[list(box(2.0, 2.0, 4.0, 4.0).exterior.coords)],
|
| 421 |
+
)
|
| 422 |
+
gcode_path = generate_vector_gcode(
|
| 423 |
+
_stack(hollow),
|
| 424 |
+
shape_name="hollow",
|
| 425 |
+
pressure=25,
|
| 426 |
+
valve=7,
|
| 427 |
+
port=3,
|
| 428 |
+
fil_width=1.0,
|
| 429 |
+
output_dir=tmp_path,
|
| 430 |
+
)
|
| 431 |
+
|
| 432 |
+
moves = _moves_with_colors(gcode_path.read_text())
|
| 433 |
+
print_moves = [move for move in moves if move["color"] == 255]
|
| 434 |
+
travel_moves = [move for move in moves if move["color"] == 0]
|
| 435 |
+
|
| 436 |
+
# Middle sweeps must split into two print runs around the hole.
|
| 437 |
+
assert len(print_moves) == 4 + 4 # 4 full-width rows + 2 rows split in two
|
| 438 |
+
# Some interior travel (crossing the hole) exists besides the buffers.
|
| 439 |
+
assert any(
|
| 440 |
+
0.0 < move["start"][0] < 7.0 and 0.0 < move["end"][0] < 7.0
|
| 441 |
+
for move in travel_moves
|
| 442 |
+
)
|
| 443 |
+
|
| 444 |
+
|
| 445 |
+
def test_rectangular_spiral_polyline_reverses_center_to_edge() -> None:
|
| 446 |
+
inward = _rectangular_spiral_polyline((0.0, 0.0, 3.0, 3.0), 1.0)
|
| 447 |
+
outward = _rectangular_spiral_polyline((0.0, 0.0, 3.0, 3.0), 1.0, reverse=True)
|
| 448 |
+
|
| 449 |
+
assert inward[0] != inward[-1]
|
| 450 |
+
assert outward[0] == inward[-1]
|
| 451 |
+
assert outward[-1] == inward[0]
|
| 452 |
+
|
| 453 |
+
|
| 454 |
+
def test_rectangular_spiral_raster_reverses_between_layers(tmp_path) -> None:
|
| 455 |
+
layer = box(0.0, 0.0, 3.0, 3.0)
|
| 456 |
+
gcode_path = generate_vector_gcode(
|
| 457 |
+
_stack(layer, layer),
|
| 458 |
+
shape_name="rectangular_spiral",
|
| 459 |
+
pressure=25,
|
| 460 |
+
valve=7,
|
| 461 |
+
port=3,
|
| 462 |
+
fil_width=1.0,
|
| 463 |
+
layer_height=1.0,
|
| 464 |
+
raster_pattern=RASTER_PATTERN_RECTANGULAR_SPIRAL,
|
| 465 |
+
output_dir=tmp_path,
|
| 466 |
+
)
|
| 467 |
+
|
| 468 |
+
moves = _moves_with_colors(gcode_path.read_text())
|
| 469 |
+
first_layer_change = next(
|
| 470 |
+
move for move in moves if move["end"][2] > move["start"][2]
|
| 471 |
+
)
|
| 472 |
+
|
| 473 |
+
assert first_layer_change["start"][:2] == first_layer_change["end"][:2]
|
| 474 |
+
end_x, end_y, end_z = moves[-1]["end"]
|
| 475 |
+
assert abs(end_x) < 1e-9
|
| 476 |
+
assert abs(end_y) < 1e-9
|
| 477 |
+
assert end_z == 1.0
|
| 478 |
+
|
| 479 |
+
|
| 480 |
+
def test_circle_spiral_points_decrease_radius_to_center() -> None:
|
| 481 |
+
points = _circle_spiral_points(2.0, 3.0, outer_radius=4.0, pitch=1.0)
|
| 482 |
+
radii = [math.hypot(x - 2.0, y - 3.0) for x, y in points]
|
| 483 |
+
|
| 484 |
+
assert radii[0] == 4.0
|
| 485 |
+
assert radii[-1] == 0.0
|
| 486 |
+
assert all(
|
| 487 |
+
current <= previous + 1e-9
|
| 488 |
+
for previous, current in zip(radii, radii[1:])
|
| 489 |
+
)
|
| 490 |
+
|
| 491 |
+
|
| 492 |
+
def test_circle_spiral_raster_reverses_between_layers(tmp_path) -> None:
|
| 493 |
+
layer = box(0.0, 0.0, 5.0, 5.0)
|
| 494 |
+
gcode_path = generate_vector_gcode(
|
| 495 |
+
_stack(layer, layer),
|
| 496 |
+
shape_name="circle_spiral",
|
| 497 |
+
pressure=25,
|
| 498 |
+
valve=7,
|
| 499 |
+
port=3,
|
| 500 |
+
fil_width=1.0,
|
| 501 |
+
layer_height=1.0,
|
| 502 |
+
raster_pattern=RASTER_PATTERN_CIRCLE_SPIRAL,
|
| 503 |
+
output_dir=tmp_path,
|
| 504 |
+
)
|
| 505 |
+
|
| 506 |
+
moves = _moves_with_colors(gcode_path.read_text())
|
| 507 |
+
first_layer_change = next(
|
| 508 |
+
move for move in moves if move["end"][2] > move["start"][2]
|
| 509 |
+
)
|
| 510 |
+
|
| 511 |
+
assert first_layer_change["start"][:2] == first_layer_change["end"][:2]
|
| 512 |
+
end_x, end_y, end_z = moves[-1]["end"]
|
| 513 |
+
assert abs(end_x) < 1e-9
|
| 514 |
+
assert abs(end_y) < 1e-9
|
| 515 |
+
assert end_z == 1.0
|
| 516 |
+
|
| 517 |
+
|
| 518 |
+
def test_layer_contour_loops_follow_polygon_rings() -> None:
|
| 519 |
+
hollow = MultiPolygon(
|
| 520 |
+
[
|
| 521 |
+
Polygon(
|
| 522 |
+
box(0.0, 0.0, 4.0, 4.0).exterior.coords,
|
| 523 |
+
[list(box(1.0, 1.0, 2.0, 2.0).exterior.coords)],
|
| 524 |
+
)
|
| 525 |
+
]
|
| 526 |
+
)
|
| 527 |
+
|
| 528 |
+
loops = _layer_contour_loops(hollow)
|
| 529 |
+
|
| 530 |
+
assert len(loops) == 2
|
| 531 |
+
# Largest loop (the exterior) sorts first.
|
| 532 |
+
assert set(loops[0]) == {(0.0, 0.0), (4.0, 0.0), (4.0, 4.0), (0.0, 4.0)}
|
| 533 |
+
assert set(loops[1]) == {(1.0, 1.0), (2.0, 1.0), (2.0, 2.0), (1.0, 2.0)}
|
| 534 |
+
assert loops[0][0] == loops[0][-1]
|
| 535 |
+
assert loops[1][0] == loops[1][-1]
|
| 536 |
+
|
| 537 |
+
|
| 538 |
+
def test_contour_tracing_travels_to_nearest_border_after_infill(tmp_path) -> None:
|
| 539 |
+
layer = box(0.0, 0.0, 1.0, 1.0)
|
| 540 |
+
stack = _stack(layer)
|
| 541 |
+
gcode_path = generate_vector_gcode(
|
| 542 |
+
stack,
|
| 543 |
+
shape_name="nearest_border_contour",
|
| 544 |
+
pressure=25,
|
| 545 |
+
valve=7,
|
| 546 |
+
port=3,
|
| 547 |
+
fil_width=1.0,
|
| 548 |
+
all_g1=True,
|
| 549 |
+
contour_sources=[ContourSource(owner_idx=1, stack=stack)],
|
| 550 |
+
active_contour_owner=1,
|
| 551 |
+
output_dir=tmp_path,
|
| 552 |
+
)
|
| 553 |
+
|
| 554 |
+
moves = _moves_with_colors(gcode_path.read_text())
|
| 555 |
+
|
| 556 |
+
# Move 0 is the valve-settle approach, move 1 the single infill sweep.
|
| 557 |
+
assert moves[1]["color"] == 255
|
| 558 |
+
infill_end = moves[1]["end"]
|
| 559 |
+
# The contour starts printing from the point nearest the infill end,
|
| 560 |
+
# with no travel in between (the trailing buffer is rewound).
|
| 561 |
+
assert moves[2]["color"] == 255
|
| 562 |
+
assert moves[2]["start"] == infill_end
|
| 563 |
+
|
| 564 |
+
|
| 565 |
+
def test_contour_tracing_closes_loop_and_restores_raster_endpoint(tmp_path) -> None:
|
| 566 |
+
layer = box(0.0, 0.0, 2.0, 2.0)
|
| 567 |
+
stack = _stack(layer, layer)
|
| 568 |
+
gcode_path = generate_vector_gcode(
|
| 569 |
+
stack,
|
| 570 |
+
shape_name="contour_loop",
|
| 571 |
+
pressure=25,
|
| 572 |
+
valve=7,
|
| 573 |
+
port=3,
|
| 574 |
+
fil_width=1.0,
|
| 575 |
+
layer_height=1.0,
|
| 576 |
+
all_g1=True,
|
| 577 |
+
contour_sources=[ContourSource(owner_idx=1, stack=stack)],
|
| 578 |
+
active_contour_owner=1,
|
| 579 |
+
output_dir=tmp_path,
|
| 580 |
+
)
|
| 581 |
+
|
| 582 |
+
all_moves = _moves_with_colors(gcode_path.read_text())
|
| 583 |
+
for layer_z in (0.0, 1.0):
|
| 584 |
+
layer_moves = [
|
| 585 |
+
move
|
| 586 |
+
for move in all_moves
|
| 587 |
+
if move["start"][2] == layer_z and move["end"][2] == layer_z
|
| 588 |
+
]
|
| 589 |
+
layer_prints = [move for move in layer_moves if move["color"] == 255]
|
| 590 |
+
assert layer_prints
|
| 591 |
+
|
| 592 |
+
# The contour is a closed loop: the last print returns to where the
|
| 593 |
+
# contour started.
|
| 594 |
+
contour_prints = layer_prints[2:]
|
| 595 |
+
assert contour_prints
|
| 596 |
+
assert contour_prints[-1]["end"] == contour_prints[0]["start"]
|
| 597 |
+
|
| 598 |
+
# After the contour, a travel move restores the raster endpoint.
|
| 599 |
+
last_print_index = max(
|
| 600 |
+
idx for idx, move in enumerate(layer_moves) if move["color"] == 255
|
| 601 |
+
)
|
| 602 |
+
trailing = layer_moves[last_print_index + 1 :]
|
| 603 |
+
assert trailing
|
| 604 |
+
assert all(move["color"] == 0 for move in trailing)
|
| 605 |
+
|
| 606 |
+
|
| 607 |
+
def test_contour_tracing_keeps_hollow_rings_separate() -> None:
|
| 608 |
+
output = [{"X": 0.0, "Y": 0.0, "Color": 255}]
|
| 609 |
+
contour_layers = [
|
| 610 |
+
[
|
| 611 |
+
{
|
| 612 |
+
"owner_idx": 1,
|
| 613 |
+
"contours": [
|
| 614 |
+
[(0.0, 0.0), (4.0, 0.0), (4.0, 4.0), (0.0, 4.0), (0.0, 0.0)],
|
| 615 |
+
[(1.0, 1.0), (2.0, 1.0), (2.0, 2.0), (1.0, 2.0), (1.0, 1.0)],
|
| 616 |
+
],
|
| 617 |
+
}
|
| 618 |
+
]
|
| 619 |
+
]
|
| 620 |
+
|
| 621 |
+
current_x, current_y = _append_layer_contours(
|
| 622 |
+
output,
|
| 623 |
+
0.0,
|
| 624 |
+
0.0,
|
| 625 |
+
contour_layers,
|
| 626 |
+
layer_number=0,
|
| 627 |
+
active_owner_idx=1,
|
| 628 |
+
)
|
| 629 |
+
|
| 630 |
+
contour_print_moves = [move for move in output[1:] if move["Color"] == 255]
|
| 631 |
+
|
| 632 |
+
assert len(contour_print_moves) == 8
|
| 633 |
+
assert (current_x, current_y) == (1.0, 1.0)
|
| 634 |
+
|
| 635 |
+
|
| 636 |
+
def test_contour_tracing_skips_inactive_nozzle_outline(tmp_path) -> None:
|
| 637 |
+
blank_stack = _stack(None)
|
| 638 |
+
contour_stack = _stack(box(0.0, 0.0, 1.0, 1.0))
|
| 639 |
+
contour_sources = [ContourSource(owner_idx=1, stack=contour_stack)]
|
| 640 |
+
|
| 641 |
+
active_path = generate_vector_gcode(
|
| 642 |
+
blank_stack,
|
| 643 |
+
shape_name="active_contour",
|
| 644 |
+
pressure=25,
|
| 645 |
+
valve=7,
|
| 646 |
+
port=3,
|
| 647 |
+
fil_width=1.0,
|
| 648 |
+
all_g1=True,
|
| 649 |
+
contour_sources=contour_sources,
|
| 650 |
+
active_contour_owner=1,
|
| 651 |
+
output_dir=tmp_path / "active",
|
| 652 |
+
)
|
| 653 |
+
inactive_path = generate_vector_gcode(
|
| 654 |
+
blank_stack,
|
| 655 |
+
shape_name="inactive_contour",
|
| 656 |
+
pressure=25,
|
| 657 |
+
valve=7,
|
| 658 |
+
port=3,
|
| 659 |
+
fil_width=1.0,
|
| 660 |
+
all_g1=True,
|
| 661 |
+
contour_sources=contour_sources,
|
| 662 |
+
active_contour_owner=2,
|
| 663 |
+
output_dir=tmp_path / "inactive",
|
| 664 |
+
)
|
| 665 |
+
|
| 666 |
+
active_text = active_path.read_text()
|
| 667 |
+
inactive_text = inactive_path.read_text()
|
| 668 |
+
|
| 669 |
+
assert _move_signature(active_text)
|
| 670 |
+
assert _move_signature(inactive_text) == []
|
| 671 |
+
assert any(
|
| 672 |
+
line.startswith("G1") and "; Color 255" in line
|
| 673 |
+
for line in active_text.splitlines()
|
| 674 |
+
)
|
| 675 |
+
assert not any("; Color 255" in line for line in inactive_text.splitlines())
|
| 676 |
+
|
| 677 |
+
|
| 678 |
+
def test_inactive_contour_tracing_preserves_original_raster_moves(tmp_path) -> None:
|
| 679 |
+
layer = box(1.0, 1.0, 3.0, 2.0)
|
| 680 |
+
stack = _stack(layer, layer)
|
| 681 |
+
|
| 682 |
+
original_path = generate_vector_gcode(
|
| 683 |
+
stack,
|
| 684 |
+
shape_name="original_raster",
|
| 685 |
+
pressure=25,
|
| 686 |
+
valve=7,
|
| 687 |
+
port=3,
|
| 688 |
+
fil_width=1.0,
|
| 689 |
+
all_g1=True,
|
| 690 |
+
output_dir=tmp_path / "original",
|
| 691 |
+
)
|
| 692 |
+
inactive_path = generate_vector_gcode(
|
| 693 |
+
stack,
|
| 694 |
+
shape_name="inactive_contour_raster",
|
| 695 |
+
pressure=25,
|
| 696 |
+
valve=7,
|
| 697 |
+
port=3,
|
| 698 |
+
fil_width=1.0,
|
| 699 |
+
all_g1=True,
|
| 700 |
+
contour_sources=[ContourSource(owner_idx=2, stack=stack)],
|
| 701 |
+
active_contour_owner=1,
|
| 702 |
+
output_dir=tmp_path / "inactive",
|
| 703 |
+
)
|
| 704 |
+
|
| 705 |
+
assert _move_signature(inactive_path.read_text()) == _move_signature(
|
| 706 |
+
original_path.read_text()
|
| 707 |
+
)
|
| 708 |
+
|
| 709 |
+
|
| 710 |
+
def test_reference_motion_shares_path_and_gates_valve_per_shape(tmp_path) -> None:
|
| 711 |
+
small = _stack(box(0.0, 0.0, 2.0, 2.0), name="small")
|
| 712 |
+
big = _stack(box(0.0, 0.0, 4.0, 4.0), name="big")
|
| 713 |
+
reference = build_reference_stack([small, big])
|
| 714 |
+
assert reference is not None
|
| 715 |
+
|
| 716 |
+
def _generate(stack: LayerStack, label: str):
|
| 717 |
+
return generate_vector_gcode(
|
| 718 |
+
stack,
|
| 719 |
+
shape_name=label,
|
| 720 |
+
pressure=25,
|
| 721 |
+
valve=7,
|
| 722 |
+
port=3,
|
| 723 |
+
fil_width=1.0,
|
| 724 |
+
motion=reference,
|
| 725 |
+
output_dir=tmp_path / label,
|
| 726 |
+
)
|
| 727 |
+
|
| 728 |
+
small_moves = _moves_with_colors(_generate(small, "small").read_text())
|
| 729 |
+
big_moves = _moves_with_colors(_generate(big, "big").read_text())
|
| 730 |
+
|
| 731 |
+
# Both shapes follow the same shared motion path: identical final position
|
| 732 |
+
# and identical total path length (the moves split at different valve
|
| 733 |
+
# boundaries, but the traversed polyline is the same).
|
| 734 |
+
assert small_moves[-1]["end"] == big_moves[-1]["end"]
|
| 735 |
+
|
| 736 |
+
def _total_length(moves: list[dict]) -> float:
|
| 737 |
+
return sum(math.dist(move["start"][:2], move["end"][:2]) for move in moves)
|
| 738 |
+
|
| 739 |
+
assert abs(_total_length(small_moves) - _total_length(big_moves)) < 1e-6
|
| 740 |
+
|
| 741 |
+
def _print_length(moves: list[dict]) -> float:
|
| 742 |
+
return sum(
|
| 743 |
+
math.dist(move["start"][:2], move["end"][:2])
|
| 744 |
+
for move in moves
|
| 745 |
+
if move["color"] == 255
|
| 746 |
+
)
|
| 747 |
+
|
| 748 |
+
# The big shape dispenses over more of the shared path than the small one.
|
| 749 |
+
assert _print_length(small_moves) > 0
|
| 750 |
+
assert _print_length(big_moves) > _print_length(small_moves)
|
| 751 |
+
# The small shape's total print length matches its own area coverage:
|
| 752 |
+
# 2mm-wide rows on the shared 4-row sweep -> only rows inside the small box.
|
| 753 |
+
assert _print_length(small_moves) < _print_length(big_moves) / 2 + 4.0
|
| 754 |
+
|
| 755 |
+
|
| 756 |
+
def test_build_reference_stack_unions_center_aligned_layers() -> None:
|
| 757 |
+
first = _stack(box(0.0, 0.0, 2.0, 2.0), name="first")
|
| 758 |
+
second = _stack(box(10.0, 10.0, 14.0, 14.0), name="second")
|
| 759 |
+
|
| 760 |
+
reference = build_reference_stack([first, second])
|
| 761 |
+
|
| 762 |
+
assert reference is not None
|
| 763 |
+
# The second stack is re-centred onto the first stack's bbox centre (1, 1).
|
| 764 |
+
assert reference.bounds == ((-1.0, -1.0, 0.0), (3.0, 3.0, 1.0))
|
| 765 |
+
assert reference.layers[0].area == 16.0
|
| 766 |
+
assert len(reference.layers) == 1
|
| 767 |
+
assert reference.z_values == [0.5]
|
| 768 |
+
|
| 769 |
+
|
| 770 |
+
def test_split_layer_stack_grid_produces_row_major_cells() -> None:
|
| 771 |
+
layer = box(10.0, -2.0, 12.5, -1.0)
|
| 772 |
+
stack = _stack(layer, name="strip")
|
| 773 |
+
|
| 774 |
+
pieces = split_layer_stack_grid(stack, columns=2, rows=1)
|
| 775 |
+
|
| 776 |
+
assert [piece.name for piece in pieces] == ["strip_r01_c01", "strip_r01_c02"]
|
| 777 |
+
assert pieces[0].bounds[0][0] == 10.0
|
| 778 |
+
assert pieces[1].bounds[0][0] == 11.25
|
| 779 |
+
total_area = sum(piece.layers[0].area for piece in pieces)
|
| 780 |
+
assert abs(total_area - layer.area) < 1e-9
|
| 781 |
+
|
| 782 |
+
|
| 783 |
+
def test_split_layer_stack_grid_orders_rows_top_down() -> None:
|
| 784 |
+
layer = box(0.0, 0.0, 4.0, 4.0)
|
| 785 |
+
stack = _stack(layer, name="grid")
|
| 786 |
+
|
| 787 |
+
pieces = split_layer_stack_grid(stack, columns=2, rows=2)
|
| 788 |
+
|
| 789 |
+
assert [piece.name for piece in pieces] == [
|
| 790 |
+
"grid_r01_c01",
|
| 791 |
+
"grid_r01_c02",
|
| 792 |
+
"grid_r02_c01",
|
| 793 |
+
"grid_r02_c02",
|
| 794 |
+
]
|
| 795 |
+
# Row 1 is the top strip (max-Y side).
|
| 796 |
+
assert pieces[0].bounds == ((0.0, 2.0, 0.0), (2.0, 4.0, 1.0))
|
| 797 |
+
assert pieces[3].bounds == ((2.0, 0.0, 0.0), (4.0, 2.0, 1.0))
|
| 798 |
+
assert all(piece.layers[0].area == 4.0 for piece in pieces)
|
| 799 |
+
|
| 800 |
+
|
| 801 |
+
def test_split_layer_stack_grid_overlap_alternates_between_layers() -> None:
|
| 802 |
+
layer = box(0.0, 0.0, 4.0, 2.0)
|
| 803 |
+
stack = _stack(layer, layer, name="interlock")
|
| 804 |
+
|
| 805 |
+
pieces = split_layer_stack_grid(
|
| 806 |
+
stack,
|
| 807 |
+
columns=2,
|
| 808 |
+
rows=1,
|
| 809 |
+
overlapping_layers=True,
|
| 810 |
+
overlap=0.5,
|
| 811 |
+
)
|
| 812 |
+
|
| 813 |
+
left, right = pieces
|
| 814 |
+
# The cut line alternates by +/- overlap between layers, so each piece's
|
| 815 |
+
# area differs between layer 0 and layer 1 while the totals stay constant.
|
| 816 |
+
assert left.layers[0].area != left.layers[1].area
|
| 817 |
+
assert abs(left.layers[0].area - left.layers[1].area) == 2.0 # 2*(0.5*2)
|
| 818 |
+
for index in range(2):
|
| 819 |
+
combined = left.layers[index].area + right.layers[index].area
|
| 820 |
+
assert abs(combined - layer.area) < 1e-9
|
| 821 |
+
# Nominal bounds stay the un-shifted cells.
|
| 822 |
+
assert left.bounds == ((0.0, 0.0, 0.0), (2.0, 2.0, 2.0))
|
| 823 |
+
assert right.bounds == ((2.0, 0.0, 0.0), (4.0, 2.0, 2.0))
|
vector_gcode.py
ADDED
|
@@ -0,0 +1,271 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""G-code emission for vector layer stacks.
|
| 2 |
+
|
| 3 |
+
Writes the same machine dialect as the old TIFF pipeline: G91 relative moves,
|
| 4 |
+
G0 travel / G1 print (or all-G1), WAGO valve commands on every valve-state
|
| 5 |
+
change, and serial pressure preset/toggle commands with an optional per-layer
|
| 6 |
+
pressure ramp.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
|
| 11 |
+
import tempfile
|
| 12 |
+
from codecs import encode
|
| 13 |
+
from pathlib import Path
|
| 14 |
+
from textwrap import wrap
|
| 15 |
+
|
| 16 |
+
from stl_slicer import LayerStack
|
| 17 |
+
from vector_toolpath import (
|
| 18 |
+
RASTER_PATTERN_CHOICES,
|
| 19 |
+
RASTER_PATTERN_CIRCLE_SPIRAL,
|
| 20 |
+
RASTER_PATTERN_RECTANGULAR_SPIRAL,
|
| 21 |
+
RASTER_PATTERN_SAME_DIRECTION,
|
| 22 |
+
RASTER_PATTERN_WOODPILE,
|
| 23 |
+
RASTER_PATTERN_Y_DIRECTION,
|
| 24 |
+
ContourSource,
|
| 25 |
+
_lead_in_moves,
|
| 26 |
+
_normalize_raster_pattern,
|
| 27 |
+
align_stack_to,
|
| 28 |
+
build_contour_layers,
|
| 29 |
+
plan_layer_moves,
|
| 30 |
+
)
|
| 31 |
+
|
| 32 |
+
__all__ = [
|
| 33 |
+
"RASTER_PATTERN_CHOICES",
|
| 34 |
+
"RASTER_PATTERN_CIRCLE_SPIRAL",
|
| 35 |
+
"RASTER_PATTERN_RECTANGULAR_SPIRAL",
|
| 36 |
+
"RASTER_PATTERN_SAME_DIRECTION",
|
| 37 |
+
"RASTER_PATTERN_WOODPILE",
|
| 38 |
+
"RASTER_PATTERN_Y_DIRECTION",
|
| 39 |
+
"ContourSource",
|
| 40 |
+
"generate_vector_gcode",
|
| 41 |
+
"write_gcode_file",
|
| 42 |
+
]
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def _setpress(pressure: float) -> str:
|
| 46 |
+
pressure_str = str(int(pressure * 10)).zfill(4)
|
| 47 |
+
command_bytes = bytes("08PS " + pressure_str, "utf-8")
|
| 48 |
+
hex_command = encode(command_bytes, "hex").decode("utf-8")
|
| 49 |
+
format_command = "\\x" + "\\x".join(
|
| 50 |
+
hex_command[i : i + 2] for i in range(0, len(hex_command), 2)
|
| 51 |
+
)
|
| 52 |
+
|
| 53 |
+
hex_pairs = wrap(hex_command, 2)
|
| 54 |
+
decimal_sum = sum(int(pair, 16) for pair in hex_pairs)
|
| 55 |
+
checksum_bin = bin(decimal_sum % 256)[2:].zfill(8)
|
| 56 |
+
inverted = int("".join("1" if c == "0" else "0" for c in checksum_bin), 2) + 1
|
| 57 |
+
checksum_hex = hex(inverted)[2:].upper()
|
| 58 |
+
format_checksum = "\\x" + "\\x".join(
|
| 59 |
+
checksum_hex[i : i + 2] for i in range(0, len(checksum_hex), 2)
|
| 60 |
+
)
|
| 61 |
+
|
| 62 |
+
return "b'" + "\\x05\\x02" + format_command + format_checksum + "\\x03" + "'"
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
def _togglepress() -> str:
|
| 66 |
+
return "b'\\x05\\x02\\x30\\x34\\x44\\x49\\x20\\x20\\x43\\x46\\x03'"
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
def _setpress_cmd(port: str, pressure: float, start: bool) -> str:
|
| 70 |
+
if start:
|
| 71 |
+
return f"\n\r{port}.write(eval(setpress({pressure:g})))"
|
| 72 |
+
insert = ""
|
| 73 |
+
return f"\n\r{insert}{port}.write({_setpress(pressure)})"
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
def _toggle_cmd(port: str, start: bool) -> str:
|
| 77 |
+
if start:
|
| 78 |
+
return f"\n\r{port}.write(eval(togglepress()))"
|
| 79 |
+
insert = ""
|
| 80 |
+
return f"\n\r{insert}{port}.write({_togglepress()})"
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
def _valve_cmd(valve: int, command: int) -> str:
|
| 84 |
+
return f"\n{{aux_command}}WAGO_ValveCommands({valve}, {command})\n"
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
def _coord(value: float) -> str:
|
| 88 |
+
"""Format a coordinate in fixed-point notation, never scientific.
|
| 89 |
+
|
| 90 |
+
Python's repr writes small floats as e.g. "-5.1e-08", which G-code axis
|
| 91 |
+
parsers (including this project's viewer) misread as "-5.1".
|
| 92 |
+
"""
|
| 93 |
+
text = f"{float(value):.6f}".rstrip("0")
|
| 94 |
+
if text.endswith("."):
|
| 95 |
+
text += "0"
|
| 96 |
+
if text in ("-0.0", "-0"):
|
| 97 |
+
return "0.0"
|
| 98 |
+
return text
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
def write_gcode_file(
|
| 102 |
+
gcode_path: Path,
|
| 103 |
+
gcode_list: list[dict],
|
| 104 |
+
pressure: float,
|
| 105 |
+
valve: int,
|
| 106 |
+
port: int,
|
| 107 |
+
increase_pressure_per_layer: float,
|
| 108 |
+
pressure_ramp_enabled: bool,
|
| 109 |
+
all_g1: bool,
|
| 110 |
+
) -> None:
|
| 111 |
+
off_color = 0
|
| 112 |
+
com_port = f"serialPort{port}"
|
| 113 |
+
color_dict: dict[int, int] = {0: 100, 255: valve}
|
| 114 |
+
|
| 115 |
+
setpress_lines = [_setpress_cmd(com_port, pressure, start=True)]
|
| 116 |
+
pressure_on_lines = [_toggle_cmd(com_port, start=True)]
|
| 117 |
+
pressure_off_lines = [_toggle_cmd(com_port, start=False)]
|
| 118 |
+
|
| 119 |
+
pressure_cur = float(pressure)
|
| 120 |
+
|
| 121 |
+
with open(gcode_path, "w") as f:
|
| 122 |
+
f.write("G91\n")
|
| 123 |
+
f.write(_valve_cmd(valve, 0))
|
| 124 |
+
for line in setpress_lines:
|
| 125 |
+
f.write(f"{line}\n")
|
| 126 |
+
for line in pressure_on_lines:
|
| 127 |
+
f.write(f"{line}\n")
|
| 128 |
+
for color in color_dict:
|
| 129 |
+
f.write(_valve_cmd(color_dict[color], 0))
|
| 130 |
+
|
| 131 |
+
pressure_next: str | None = None
|
| 132 |
+
for i, move in enumerate(gcode_list):
|
| 133 |
+
prev_color = gcode_list[i - 1]["Color"] if i > 0 else 0
|
| 134 |
+
cur_color = move["Color"]
|
| 135 |
+
if prev_color != cur_color:
|
| 136 |
+
if cur_color == off_color:
|
| 137 |
+
f.write(_valve_cmd(color_dict[prev_color], 0))
|
| 138 |
+
else:
|
| 139 |
+
if prev_color == off_color:
|
| 140 |
+
f.write(_valve_cmd(color_dict[cur_color], 1))
|
| 141 |
+
else:
|
| 142 |
+
f.write(_valve_cmd(color_dict[cur_color], 1))
|
| 143 |
+
f.write(_valve_cmd(color_dict[prev_color], 0))
|
| 144 |
+
|
| 145 |
+
# When all_g1 is set, every move is emitted as G1 regardless of
|
| 146 |
+
# valve state; the valve commands still mark print vs travel.
|
| 147 |
+
move_type = "G1" if (all_g1 or cur_color != off_color) else "G0"
|
| 148 |
+
if "Z" in move:
|
| 149 |
+
line = (
|
| 150 |
+
f"{move_type} X{_coord(move['X'])} Y{_coord(move['Y'])} "
|
| 151 |
+
f"Z{_coord(move['Z'])} ; Color {move['Color']}"
|
| 152 |
+
)
|
| 153 |
+
if pressure_ramp_enabled:
|
| 154 |
+
pressure_cur += increase_pressure_per_layer
|
| 155 |
+
pressure_next = _setpress_cmd(com_port, pressure_cur, start=False)
|
| 156 |
+
else:
|
| 157 |
+
pressure_next = None
|
| 158 |
+
else:
|
| 159 |
+
line = (
|
| 160 |
+
f"{move_type} X{_coord(move['X'])} Y{_coord(move['Y'])} "
|
| 161 |
+
f"; Color {move['Color']}"
|
| 162 |
+
)
|
| 163 |
+
pressure_next = None
|
| 164 |
+
|
| 165 |
+
f.write(f"{line}\n")
|
| 166 |
+
if pressure_next is not None:
|
| 167 |
+
f.write(f"{pressure_next}\n")
|
| 168 |
+
pressure_next = None
|
| 169 |
+
|
| 170 |
+
for color in color_dict:
|
| 171 |
+
f.write(_valve_cmd(color_dict[color], 0))
|
| 172 |
+
for line in pressure_off_lines:
|
| 173 |
+
f.write(f"{line}\n")
|
| 174 |
+
|
| 175 |
+
|
| 176 |
+
def generate_vector_gcode(
|
| 177 |
+
shape: LayerStack,
|
| 178 |
+
*,
|
| 179 |
+
shape_name: str,
|
| 180 |
+
pressure: float,
|
| 181 |
+
valve: int,
|
| 182 |
+
port: int,
|
| 183 |
+
fil_width: float,
|
| 184 |
+
layer_height: float | None = None,
|
| 185 |
+
raster_pattern: str | None = RASTER_PATTERN_SAME_DIRECTION,
|
| 186 |
+
motion: LayerStack | None = None,
|
| 187 |
+
contour_sources: list[ContourSource] | None = None,
|
| 188 |
+
active_contour_owner: int | None = None,
|
| 189 |
+
increase_pressure_per_layer: float = 0.1,
|
| 190 |
+
pressure_ramp_enabled: bool = True,
|
| 191 |
+
all_g1: bool = False,
|
| 192 |
+
lead_in_enabled: bool = False,
|
| 193 |
+
lead_in_length: float = 5.0,
|
| 194 |
+
lead_in_clearance: float = 5.0,
|
| 195 |
+
lead_in_lines: int = 3,
|
| 196 |
+
output_dir: str | Path | None = None,
|
| 197 |
+
) -> Path:
|
| 198 |
+
"""Generate G-code for one sliced shape.
|
| 199 |
+
|
| 200 |
+
Without `motion`, the shape's own layers drive both the nozzle path and
|
| 201 |
+
the valve. With `motion` (the combined reference stack), the nozzle
|
| 202 |
+
follows the shared reference path while the valve opens only inside this
|
| 203 |
+
shape's own geometry, aligned into the reference frame — so parallel
|
| 204 |
+
heads share one motion but each dispenses only its own shape.
|
| 205 |
+
"""
|
| 206 |
+
if shape is None or not shape.layers:
|
| 207 |
+
raise ValueError("The shape has no sliced layers to generate G-code from.")
|
| 208 |
+
if fil_width <= 0:
|
| 209 |
+
raise ValueError("Filament width must be greater than zero.")
|
| 210 |
+
|
| 211 |
+
raster_pattern = _normalize_raster_pattern(raster_pattern)
|
| 212 |
+
if layer_height is None:
|
| 213 |
+
layer_height = shape.layer_height
|
| 214 |
+
|
| 215 |
+
if motion is not None:
|
| 216 |
+
if not motion.layers:
|
| 217 |
+
raise ValueError("The reference stack has no layers for motion.")
|
| 218 |
+
motion_layers = motion.layers
|
| 219 |
+
valve_layers = align_stack_to(shape, motion, len(motion.layers))
|
| 220 |
+
contour_reference = motion
|
| 221 |
+
else:
|
| 222 |
+
motion_layers = shape.layers
|
| 223 |
+
valve_layers = shape.layers
|
| 224 |
+
contour_reference = None
|
| 225 |
+
|
| 226 |
+
contour_layers = build_contour_layers(
|
| 227 |
+
contour_sources,
|
| 228 |
+
len(motion_layers),
|
| 229 |
+
reference=contour_reference,
|
| 230 |
+
)
|
| 231 |
+
|
| 232 |
+
gcode_list = plan_layer_moves(
|
| 233 |
+
motion_layers,
|
| 234 |
+
valve_layers,
|
| 235 |
+
fil_width,
|
| 236 |
+
float(layer_height),
|
| 237 |
+
raster_pattern,
|
| 238 |
+
contour_layers,
|
| 239 |
+
active_contour_owner,
|
| 240 |
+
)
|
| 241 |
+
|
| 242 |
+
lead_in = _lead_in_moves(
|
| 243 |
+
lead_in_enabled,
|
| 244 |
+
lead_in_length,
|
| 245 |
+
lead_in_clearance,
|
| 246 |
+
lead_in_lines,
|
| 247 |
+
fil_width,
|
| 248 |
+
255,
|
| 249 |
+
0,
|
| 250 |
+
)
|
| 251 |
+
if lead_in:
|
| 252 |
+
gcode_list = [*lead_in, *gcode_list]
|
| 253 |
+
|
| 254 |
+
if output_dir is None:
|
| 255 |
+
output_dir = Path(tempfile.mkdtemp(prefix="vector_gcode_"))
|
| 256 |
+
else:
|
| 257 |
+
output_dir = Path(output_dir)
|
| 258 |
+
output_dir.mkdir(parents=True, exist_ok=True)
|
| 259 |
+
|
| 260 |
+
gcode_path = output_dir / f"{shape_name}_SnakePath_gcode.txt"
|
| 261 |
+
write_gcode_file(
|
| 262 |
+
gcode_path,
|
| 263 |
+
gcode_list,
|
| 264 |
+
pressure=float(pressure),
|
| 265 |
+
valve=int(valve),
|
| 266 |
+
port=int(port),
|
| 267 |
+
increase_pressure_per_layer=float(increase_pressure_per_layer),
|
| 268 |
+
pressure_ramp_enabled=bool(pressure_ramp_enabled),
|
| 269 |
+
all_g1=bool(all_g1),
|
| 270 |
+
)
|
| 271 |
+
return gcode_path
|
vector_toolpath.py
ADDED
|
@@ -0,0 +1,1209 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Vector toolpath generation: shapely layer polygons -> printable move lists.
|
| 2 |
+
|
| 3 |
+
Replaces the pixel-raster core of the old TIFF pipeline. All geometry lives in
|
| 4 |
+
world-XY millimetres; every motion segment is a 5-tuple
|
| 5 |
+
``(x0, y0, x1, y1, color)`` where color 255 means the dispensing valve is open
|
| 6 |
+
and 0 means travel. `fil_width` is both the raster line spacing and the
|
| 7 |
+
filament width.
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
from __future__ import annotations
|
| 11 |
+
|
| 12 |
+
import math
|
| 13 |
+
from dataclasses import dataclass
|
| 14 |
+
|
| 15 |
+
from shapely import prepare
|
| 16 |
+
from shapely.affinity import translate
|
| 17 |
+
from shapely.geometry import LineString, MultiPolygon, Point, box
|
| 18 |
+
from shapely.geometry.polygon import orient
|
| 19 |
+
from shapely.ops import unary_union
|
| 20 |
+
|
| 21 |
+
from stl_slicer import LayerStack, _as_multipolygon
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
EPS = 1e-6
|
| 25 |
+
|
| 26 |
+
RASTER_PATTERN_SAME_DIRECTION = "X-direction raster"
|
| 27 |
+
RASTER_PATTERN_Y_DIRECTION = "Y-direction raster"
|
| 28 |
+
RASTER_PATTERN_WOODPILE = "Woodpile raster"
|
| 29 |
+
RASTER_PATTERN_RECTANGULAR_SPIRAL = "Rectangular Spiral raster"
|
| 30 |
+
RASTER_PATTERN_CIRCLE_SPIRAL = "Circle Spiral raster"
|
| 31 |
+
RASTER_PATTERN_CHOICES = (
|
| 32 |
+
RASTER_PATTERN_SAME_DIRECTION,
|
| 33 |
+
RASTER_PATTERN_Y_DIRECTION,
|
| 34 |
+
RASTER_PATTERN_WOODPILE,
|
| 35 |
+
RASTER_PATTERN_RECTANGULAR_SPIRAL,
|
| 36 |
+
RASTER_PATTERN_CIRCLE_SPIRAL,
|
| 37 |
+
)
|
| 38 |
+
|
| 39 |
+
Seg = tuple[float, float, float, float, int]
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
@dataclass(slots=True)
|
| 43 |
+
class ContourSource:
|
| 44 |
+
owner_idx: int
|
| 45 |
+
stack: LayerStack
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def _normalize_raster_pattern(pattern: str | None) -> str:
|
| 49 |
+
if pattern in RASTER_PATTERN_CHOICES:
|
| 50 |
+
return pattern
|
| 51 |
+
return RASTER_PATTERN_SAME_DIRECTION
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def _raster_axis_for_pattern(pattern: str, layer_number: int) -> str:
|
| 55 |
+
if pattern == RASTER_PATTERN_Y_DIRECTION:
|
| 56 |
+
return "Y"
|
| 57 |
+
if pattern == RASTER_PATTERN_WOODPILE and layer_number % 2 == 1:
|
| 58 |
+
return "Y"
|
| 59 |
+
return "X"
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
def _iter_linestrings(geometry: object):
|
| 63 |
+
if geometry is None or getattr(geometry, "is_empty", True):
|
| 64 |
+
return
|
| 65 |
+
geom_type = geometry.geom_type
|
| 66 |
+
if geom_type == "LineString":
|
| 67 |
+
yield geometry
|
| 68 |
+
elif geom_type in ("MultiLineString", "GeometryCollection"):
|
| 69 |
+
for part in geometry.geoms:
|
| 70 |
+
yield from _iter_linestrings(part)
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
def _iter_coords(geometry: object):
|
| 74 |
+
if geometry is None or getattr(geometry, "is_empty", True):
|
| 75 |
+
return
|
| 76 |
+
geom_type = geometry.geom_type
|
| 77 |
+
if geom_type == "Point":
|
| 78 |
+
yield geometry.x, geometry.y
|
| 79 |
+
elif geom_type == "LineString":
|
| 80 |
+
for coord in geometry.coords:
|
| 81 |
+
yield coord[0], coord[1]
|
| 82 |
+
elif geom_type in ("MultiPoint", "MultiLineString", "GeometryCollection"):
|
| 83 |
+
for part in geometry.geoms:
|
| 84 |
+
yield from _iter_coords(part)
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
def _point_distance_sq(ax: float, ay: float, bx: float, by: float) -> float:
|
| 88 |
+
return (ax - bx) ** 2 + (ay - by) ** 2
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
def _closest_point_on_segment(
|
| 92 |
+
px: float,
|
| 93 |
+
py: float,
|
| 94 |
+
ax: float,
|
| 95 |
+
ay: float,
|
| 96 |
+
bx: float,
|
| 97 |
+
by: float,
|
| 98 |
+
) -> tuple[float, float, float]:
|
| 99 |
+
dx = bx - ax
|
| 100 |
+
dy = by - ay
|
| 101 |
+
length_sq = dx * dx + dy * dy
|
| 102 |
+
if length_sq == 0:
|
| 103 |
+
return ax, ay, 0.0
|
| 104 |
+
t = ((px - ax) * dx + (py - ay) * dy) / length_sq
|
| 105 |
+
t = max(0.0, min(1.0, t))
|
| 106 |
+
return ax + t * dx, ay + t * dy, t
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
def _append_colored_segment(
|
| 110 |
+
segments: list[Seg],
|
| 111 |
+
start_x: float,
|
| 112 |
+
start_y: float,
|
| 113 |
+
end_x: float,
|
| 114 |
+
end_y: float,
|
| 115 |
+
color: int,
|
| 116 |
+
) -> None:
|
| 117 |
+
if start_x == end_x and start_y == end_y:
|
| 118 |
+
return
|
| 119 |
+
|
| 120 |
+
if segments:
|
| 121 |
+
prev_start_x, prev_start_y, prev_end_x, prev_end_y, prev_color = segments[-1]
|
| 122 |
+
if (
|
| 123 |
+
prev_color == color
|
| 124 |
+
and prev_end_x == start_x
|
| 125 |
+
and prev_end_y == start_y
|
| 126 |
+
):
|
| 127 |
+
prev_dx = prev_end_x - prev_start_x
|
| 128 |
+
prev_dy = prev_end_y - prev_start_y
|
| 129 |
+
next_dx = end_x - start_x
|
| 130 |
+
next_dy = end_y - start_y
|
| 131 |
+
if abs((prev_dx * next_dy) - (prev_dy * next_dx)) < 1e-9:
|
| 132 |
+
segments[-1] = (
|
| 133 |
+
prev_start_x,
|
| 134 |
+
prev_start_y,
|
| 135 |
+
end_x,
|
| 136 |
+
end_y,
|
| 137 |
+
color,
|
| 138 |
+
)
|
| 139 |
+
return
|
| 140 |
+
|
| 141 |
+
segments.append((start_x, start_y, end_x, end_y, color))
|
| 142 |
+
|
| 143 |
+
|
| 144 |
+
def _append_relative_move(
|
| 145 |
+
output_list: list[dict],
|
| 146 |
+
current_x: float,
|
| 147 |
+
current_y: float,
|
| 148 |
+
target_x: float,
|
| 149 |
+
target_y: float,
|
| 150 |
+
color: int,
|
| 151 |
+
z_step: float | None = None,
|
| 152 |
+
) -> tuple[float, float]:
|
| 153 |
+
# Round away GEOS float noise (sub-micron residues, -0.0) so the emitted
|
| 154 |
+
# G-code stays clean; position tracking keeps the exact targets. Six
|
| 155 |
+
# decimals (1 nm) is far below any printable resolution, and rounding here
|
| 156 |
+
# also guarantees the writer can format every delta without scientific
|
| 157 |
+
# notation (which G-code parsers misread: "X-5.1e-08" parses as X-5.1).
|
| 158 |
+
dx = round(target_x - current_x, 6) + 0.0
|
| 159 |
+
dy = round(target_y - current_y, 6) + 0.0
|
| 160 |
+
if dx == 0 and dy == 0 and z_step is None:
|
| 161 |
+
return current_x, current_y
|
| 162 |
+
move = {"X": dx, "Y": dy, "Color": color}
|
| 163 |
+
if z_step is not None:
|
| 164 |
+
move["Z"] = z_step
|
| 165 |
+
output_list.append(move)
|
| 166 |
+
return target_x, target_y
|
| 167 |
+
|
| 168 |
+
|
| 169 |
+
def _chord_runs(
|
| 170 |
+
region: MultiPolygon,
|
| 171 |
+
axis: str,
|
| 172 |
+
coord: float,
|
| 173 |
+
eps: float = EPS,
|
| 174 |
+
) -> list[tuple[float, float]]:
|
| 175 |
+
"""Sorted, merged (lo, hi) intervals where an axis-aligned scanline is inside `region`.
|
| 176 |
+
|
| 177 |
+
Axis "X": horizontal scanline at y=coord, runs measured along X.
|
| 178 |
+
Axis "Y": vertical scanline at x=coord, runs measured along Y.
|
| 179 |
+
"""
|
| 180 |
+
if region is None or region.is_empty:
|
| 181 |
+
return []
|
| 182 |
+
|
| 183 |
+
min_x, min_y, max_x, max_y = region.bounds
|
| 184 |
+
if axis == "Y":
|
| 185 |
+
if coord < min_x - eps or coord > max_x + eps:
|
| 186 |
+
return []
|
| 187 |
+
line = LineString([(coord, min_y - 1.0), (coord, max_y + 1.0)])
|
| 188 |
+
index = 1
|
| 189 |
+
else:
|
| 190 |
+
if coord < min_y - eps or coord > max_y + eps:
|
| 191 |
+
return []
|
| 192 |
+
line = LineString([(min_x - 1.0, coord), (max_x + 1.0, coord)])
|
| 193 |
+
index = 0
|
| 194 |
+
|
| 195 |
+
runs: list[tuple[float, float]] = []
|
| 196 |
+
for piece in _iter_linestrings(region.intersection(line)):
|
| 197 |
+
if piece.length <= eps:
|
| 198 |
+
continue
|
| 199 |
+
values = [coords[index] for coords in piece.coords]
|
| 200 |
+
runs.append((min(values), max(values)))
|
| 201 |
+
|
| 202 |
+
runs.sort()
|
| 203 |
+
merged: list[tuple[float, float]] = []
|
| 204 |
+
for lo, hi in runs:
|
| 205 |
+
if merged and lo <= merged[-1][1] + eps:
|
| 206 |
+
merged[-1] = (merged[-1][0], max(merged[-1][1], hi))
|
| 207 |
+
else:
|
| 208 |
+
merged.append((lo, hi))
|
| 209 |
+
return merged
|
| 210 |
+
|
| 211 |
+
|
| 212 |
+
def _point_along(
|
| 213 |
+
t: float,
|
| 214 |
+
x0: float,
|
| 215 |
+
y0: float,
|
| 216 |
+
x1: float,
|
| 217 |
+
y1: float,
|
| 218 |
+
) -> tuple[float, float]:
|
| 219 |
+
if t <= 0.0:
|
| 220 |
+
return x0, y0
|
| 221 |
+
if t >= 1.0:
|
| 222 |
+
return x1, y1
|
| 223 |
+
return x0 + (x1 - x0) * t, y0 + (y1 - y0) * t
|
| 224 |
+
|
| 225 |
+
|
| 226 |
+
def _classify_polyline(
|
| 227 |
+
points: list[tuple[float, float]],
|
| 228 |
+
valve: MultiPolygon,
|
| 229 |
+
eps: float = EPS,
|
| 230 |
+
) -> list[Seg]:
|
| 231 |
+
"""Split a motion polyline at valve-boundary crossings and color the pieces.
|
| 232 |
+
|
| 233 |
+
Preserves path order (a whole-polyline shapely intersection would not).
|
| 234 |
+
Pieces on the boundary count as printing (`covers`), matching the raster
|
| 235 |
+
convention that boundary-grazing sweeps dispense.
|
| 236 |
+
"""
|
| 237 |
+
segments: list[Seg] = []
|
| 238 |
+
has_valve = valve is not None and not valve.is_empty
|
| 239 |
+
if has_valve:
|
| 240 |
+
prepare(valve)
|
| 241 |
+
boundary = valve.boundary
|
| 242 |
+
|
| 243 |
+
for (x0, y0), (x1, y1) in zip(points, points[1:]):
|
| 244 |
+
dx = x1 - x0
|
| 245 |
+
dy = y1 - y0
|
| 246 |
+
seg_len = math.hypot(dx, dy)
|
| 247 |
+
if seg_len <= eps:
|
| 248 |
+
continue
|
| 249 |
+
|
| 250 |
+
if not has_valve:
|
| 251 |
+
_append_colored_segment(segments, x0, y0, x1, y1, 0)
|
| 252 |
+
continue
|
| 253 |
+
|
| 254 |
+
ts = {0.0, 1.0}
|
| 255 |
+
crossings = LineString([(x0, y0), (x1, y1)]).intersection(boundary)
|
| 256 |
+
for cx, cy in _iter_coords(crossings):
|
| 257 |
+
t = ((cx - x0) * dx + (cy - y0) * dy) / (seg_len * seg_len)
|
| 258 |
+
ts.add(max(0.0, min(1.0, t)))
|
| 259 |
+
|
| 260 |
+
t_eps = eps / seg_len
|
| 261 |
+
ordered = sorted(ts)
|
| 262 |
+
deduped = [ordered[0]]
|
| 263 |
+
for t in ordered[1:]:
|
| 264 |
+
if t - deduped[-1] > t_eps:
|
| 265 |
+
deduped.append(t)
|
| 266 |
+
else:
|
| 267 |
+
deduped[-1] = max(deduped[-1], t)
|
| 268 |
+
|
| 269 |
+
for t0, t1 in zip(deduped, deduped[1:]):
|
| 270 |
+
if (t1 - t0) * seg_len <= eps:
|
| 271 |
+
continue
|
| 272 |
+
mid_x, mid_y = _point_along((t0 + t1) / 2.0, x0, y0, x1, y1)
|
| 273 |
+
color = 255 if valve.covers(Point(mid_x, mid_y)) else 0
|
| 274 |
+
start = _point_along(t0, x0, y0, x1, y1)
|
| 275 |
+
end = _point_along(t1, x0, y0, x1, y1)
|
| 276 |
+
_append_colored_segment(segments, *start, *end, color)
|
| 277 |
+
|
| 278 |
+
return segments
|
| 279 |
+
|
| 280 |
+
|
| 281 |
+
def _scan_coords(lo: float, hi: float, fil_width: float) -> list[float]:
|
| 282 |
+
"""Scanline positions at half-fil_width offsets; always at least one line."""
|
| 283 |
+
if hi - lo < fil_width:
|
| 284 |
+
return [(lo + hi) / 2.0]
|
| 285 |
+
|
| 286 |
+
coords: list[float] = []
|
| 287 |
+
value = lo + fil_width / 2.0
|
| 288 |
+
limit = hi - fil_width / 2.0 + 1e-9
|
| 289 |
+
while value <= limit:
|
| 290 |
+
coords.append(value)
|
| 291 |
+
value += fil_width
|
| 292 |
+
return coords
|
| 293 |
+
|
| 294 |
+
|
| 295 |
+
def _axis_raster_segments(
|
| 296 |
+
motion: MultiPolygon,
|
| 297 |
+
valve: MultiPolygon,
|
| 298 |
+
fil_width: float,
|
| 299 |
+
axis: str,
|
| 300 |
+
reverse_order: bool = False,
|
| 301 |
+
start_forward: bool = True,
|
| 302 |
+
) -> list[Seg]:
|
| 303 |
+
"""Snake raster of `motion`, dispensing only inside `valve`.
|
| 304 |
+
|
| 305 |
+
Axis "X" sweeps along X with rows stacked in Y; axis "Y" sweeps along Y
|
| 306 |
+
with columns stacked in X. Each sweep spans from the first to the last
|
| 307 |
+
motion chord (crossing interior gaps valve-off) and gets a fil_width
|
| 308 |
+
valve-settle travel buffer before and after.
|
| 309 |
+
"""
|
| 310 |
+
if motion is None or motion.is_empty:
|
| 311 |
+
return []
|
| 312 |
+
|
| 313 |
+
min_x, min_y, max_x, max_y = motion.bounds
|
| 314 |
+
if axis == "Y":
|
| 315 |
+
scan_lo, scan_hi = min_x, max_x
|
| 316 |
+
else:
|
| 317 |
+
scan_lo, scan_hi = min_y, max_y
|
| 318 |
+
|
| 319 |
+
coords = _scan_coords(scan_lo, scan_hi, fil_width)
|
| 320 |
+
if reverse_order:
|
| 321 |
+
coords = coords[::-1]
|
| 322 |
+
|
| 323 |
+
segments: list[Seg] = []
|
| 324 |
+
sweep_number = 0
|
| 325 |
+
for coord in coords:
|
| 326 |
+
motion_runs = _chord_runs(motion, axis, coord)
|
| 327 |
+
if not motion_runs:
|
| 328 |
+
continue
|
| 329 |
+
|
| 330 |
+
sweep_lo = motion_runs[0][0]
|
| 331 |
+
sweep_hi = motion_runs[-1][1]
|
| 332 |
+
valve_runs = []
|
| 333 |
+
for lo, hi in _chord_runs(valve, axis, coord):
|
| 334 |
+
lo = max(lo, sweep_lo)
|
| 335 |
+
hi = min(hi, sweep_hi)
|
| 336 |
+
if hi - lo > EPS:
|
| 337 |
+
valve_runs.append((lo, hi))
|
| 338 |
+
|
| 339 |
+
def emit(a: float, b: float, color: int) -> None:
|
| 340 |
+
if axis == "Y":
|
| 341 |
+
_append_colored_segment(segments, coord, a, coord, b, color)
|
| 342 |
+
else:
|
| 343 |
+
_append_colored_segment(segments, a, coord, b, coord, color)
|
| 344 |
+
|
| 345 |
+
forward = (sweep_number % 2 == 0) == start_forward
|
| 346 |
+
sweep_number += 1
|
| 347 |
+
|
| 348 |
+
if forward:
|
| 349 |
+
emit(sweep_lo - fil_width, sweep_lo, 0)
|
| 350 |
+
current = sweep_lo
|
| 351 |
+
for lo, hi in valve_runs:
|
| 352 |
+
if lo - current > EPS:
|
| 353 |
+
emit(current, lo, 0)
|
| 354 |
+
current = lo
|
| 355 |
+
start = max(lo, current)
|
| 356 |
+
if hi - start > EPS:
|
| 357 |
+
emit(start, hi, 255)
|
| 358 |
+
current = hi
|
| 359 |
+
if sweep_hi - current > EPS:
|
| 360 |
+
emit(current, sweep_hi, 0)
|
| 361 |
+
current = sweep_hi
|
| 362 |
+
emit(current, current + fil_width, 0)
|
| 363 |
+
else:
|
| 364 |
+
emit(sweep_hi + fil_width, sweep_hi, 0)
|
| 365 |
+
current = sweep_hi
|
| 366 |
+
for lo, hi in reversed(valve_runs):
|
| 367 |
+
if current - hi > EPS:
|
| 368 |
+
emit(current, hi, 0)
|
| 369 |
+
current = hi
|
| 370 |
+
start = min(hi, current)
|
| 371 |
+
if start - lo > EPS:
|
| 372 |
+
emit(start, lo, 255)
|
| 373 |
+
current = lo
|
| 374 |
+
if current - sweep_lo > EPS:
|
| 375 |
+
emit(current, sweep_lo, 0)
|
| 376 |
+
current = sweep_lo
|
| 377 |
+
emit(current, current - fil_width, 0)
|
| 378 |
+
|
| 379 |
+
return segments
|
| 380 |
+
|
| 381 |
+
|
| 382 |
+
def _oriented_axis_raster_segments(
|
| 383 |
+
motion: MultiPolygon,
|
| 384 |
+
valve: MultiPolygon,
|
| 385 |
+
fil_width: float,
|
| 386 |
+
axis: str,
|
| 387 |
+
current_x: float,
|
| 388 |
+
current_y: float,
|
| 389 |
+
prefer_default: bool = False,
|
| 390 |
+
) -> list[Seg]:
|
| 391 |
+
"""Pick the raster orientation whose start is nearest the current position."""
|
| 392 |
+
default_segments = _axis_raster_segments(motion, valve, fil_width, axis)
|
| 393 |
+
if prefer_default or not default_segments:
|
| 394 |
+
return default_segments
|
| 395 |
+
|
| 396 |
+
candidates: list[list[Seg]] = [default_segments]
|
| 397 |
+
for reverse_order in (False, True):
|
| 398 |
+
for start_forward in (False, True):
|
| 399 |
+
segments = _axis_raster_segments(
|
| 400 |
+
motion,
|
| 401 |
+
valve,
|
| 402 |
+
fil_width,
|
| 403 |
+
axis,
|
| 404 |
+
reverse_order=reverse_order,
|
| 405 |
+
start_forward=start_forward,
|
| 406 |
+
)
|
| 407 |
+
if segments and segments not in candidates:
|
| 408 |
+
candidates.append(segments)
|
| 409 |
+
|
| 410 |
+
return min(
|
| 411 |
+
candidates,
|
| 412 |
+
key=lambda segments: _point_distance_sq(
|
| 413 |
+
current_x,
|
| 414 |
+
current_y,
|
| 415 |
+
segments[0][0],
|
| 416 |
+
segments[0][1],
|
| 417 |
+
),
|
| 418 |
+
)
|
| 419 |
+
|
| 420 |
+
|
| 421 |
+
def _extend_polyline_ends(
|
| 422 |
+
points: list[tuple[float, float]],
|
| 423 |
+
length: float,
|
| 424 |
+
) -> list[tuple[float, float]]:
|
| 425 |
+
"""Extend both polyline ends by `length` along their local directions."""
|
| 426 |
+
if len(points) < 2:
|
| 427 |
+
return points
|
| 428 |
+
|
| 429 |
+
(x0, y0), (x1, y1) = points[0], points[1]
|
| 430 |
+
distance = math.hypot(x1 - x0, y1 - y0)
|
| 431 |
+
if distance > 0:
|
| 432 |
+
points = [
|
| 433 |
+
(x0 - (x1 - x0) / distance * length, y0 - (y1 - y0) / distance * length),
|
| 434 |
+
*points,
|
| 435 |
+
]
|
| 436 |
+
|
| 437 |
+
(xa, ya), (xb, yb) = points[-2], points[-1]
|
| 438 |
+
distance = math.hypot(xb - xa, yb - ya)
|
| 439 |
+
if distance > 0:
|
| 440 |
+
points = [
|
| 441 |
+
*points,
|
| 442 |
+
(xb + (xb - xa) / distance * length, yb + (yb - ya) / distance * length),
|
| 443 |
+
]
|
| 444 |
+
return points
|
| 445 |
+
|
| 446 |
+
|
| 447 |
+
def _rectangular_spiral_polyline(
|
| 448 |
+
bounds: tuple[float, float, float, float],
|
| 449 |
+
fil_width: float,
|
| 450 |
+
reverse: bool = False,
|
| 451 |
+
) -> list[tuple[float, float]]:
|
| 452 |
+
"""Corner polyline spiralling from the bounds inward (or outward if reversed)."""
|
| 453 |
+
min_x, min_y, max_x, max_y = bounds
|
| 454 |
+
half = fil_width / 2.0
|
| 455 |
+
left = min_x + half
|
| 456 |
+
right = max_x - half
|
| 457 |
+
bottom = min_y + half
|
| 458 |
+
top = max_y - half
|
| 459 |
+
if right < left:
|
| 460 |
+
left = right = (min_x + max_x) / 2.0
|
| 461 |
+
if top < bottom:
|
| 462 |
+
bottom = top = (min_y + max_y) / 2.0
|
| 463 |
+
|
| 464 |
+
eps = 1e-9
|
| 465 |
+
points: list[tuple[float, float]] = []
|
| 466 |
+
|
| 467 |
+
def add(x: float, y: float) -> None:
|
| 468 |
+
if not points or _point_distance_sq(points[-1][0], points[-1][1], x, y) > eps:
|
| 469 |
+
points.append((x, y))
|
| 470 |
+
|
| 471 |
+
while left <= right + eps and bottom <= top + eps:
|
| 472 |
+
add(left, top)
|
| 473 |
+
add(right, top)
|
| 474 |
+
top -= fil_width
|
| 475 |
+
|
| 476 |
+
if bottom <= top + eps:
|
| 477 |
+
add(right, bottom)
|
| 478 |
+
right -= fil_width
|
| 479 |
+
|
| 480 |
+
if bottom <= top + eps:
|
| 481 |
+
add(left, bottom)
|
| 482 |
+
bottom += fil_width
|
| 483 |
+
|
| 484 |
+
if left <= right + eps:
|
| 485 |
+
if bottom <= top + eps:
|
| 486 |
+
add(left, top)
|
| 487 |
+
left += fil_width
|
| 488 |
+
|
| 489 |
+
if len(points) == 1:
|
| 490 |
+
center_x, center_y = points[0]
|
| 491 |
+
points = [(center_x - half, center_y), (center_x + half, center_y)]
|
| 492 |
+
if reverse:
|
| 493 |
+
points.reverse()
|
| 494 |
+
return points
|
| 495 |
+
|
| 496 |
+
if reverse:
|
| 497 |
+
points.reverse()
|
| 498 |
+
return _extend_polyline_ends(points, half)
|
| 499 |
+
|
| 500 |
+
|
| 501 |
+
def _circle_spiral_points(
|
| 502 |
+
center_x: float,
|
| 503 |
+
center_y: float,
|
| 504 |
+
outer_radius: float,
|
| 505 |
+
pitch: float,
|
| 506 |
+
) -> list[tuple[float, float]]:
|
| 507 |
+
if outer_radius <= 0.0:
|
| 508 |
+
return [(center_x, center_y)]
|
| 509 |
+
|
| 510 |
+
pitch = max(float(pitch), 1e-9)
|
| 511 |
+
sample_spacing = pitch
|
| 512 |
+
theta_max = (outer_radius / pitch) * 2.0 * math.pi
|
| 513 |
+
theta = 0.0
|
| 514 |
+
points = [(center_x + outer_radius, center_y)]
|
| 515 |
+
|
| 516 |
+
while theta < theta_max:
|
| 517 |
+
radius = max(outer_radius - (pitch * theta / (2.0 * math.pi)), 0.0)
|
| 518 |
+
d_theta = min(math.pi / 10.0, sample_spacing / max(radius, pitch))
|
| 519 |
+
theta = min(theta + d_theta, theta_max)
|
| 520 |
+
radius = max(outer_radius - (pitch * theta / (2.0 * math.pi)), 0.0)
|
| 521 |
+
points.append(
|
| 522 |
+
(
|
| 523 |
+
center_x + (radius * math.cos(theta)),
|
| 524 |
+
center_y + (radius * math.sin(theta)),
|
| 525 |
+
)
|
| 526 |
+
)
|
| 527 |
+
|
| 528 |
+
if points[-1] != (center_x, center_y):
|
| 529 |
+
points.append((center_x, center_y))
|
| 530 |
+
return points
|
| 531 |
+
|
| 532 |
+
|
| 533 |
+
def _circle_spiral_polyline(
|
| 534 |
+
bounds: tuple[float, float, float, float],
|
| 535 |
+
fil_width: float,
|
| 536 |
+
reverse: bool = False,
|
| 537 |
+
) -> list[tuple[float, float]]:
|
| 538 |
+
min_x, min_y, max_x, max_y = bounds
|
| 539 |
+
center_x = (min_x + max_x) / 2.0
|
| 540 |
+
center_y = (min_y + max_y) / 2.0
|
| 541 |
+
outer_radius = max(
|
| 542 |
+
math.hypot(corner_x - center_x, corner_y - center_y)
|
| 543 |
+
for corner_x, corner_y in (
|
| 544 |
+
(min_x, min_y),
|
| 545 |
+
(max_x, min_y),
|
| 546 |
+
(max_x, max_y),
|
| 547 |
+
(min_x, max_y),
|
| 548 |
+
)
|
| 549 |
+
)
|
| 550 |
+
|
| 551 |
+
points = _circle_spiral_points(center_x, center_y, outer_radius, fil_width)
|
| 552 |
+
if reverse:
|
| 553 |
+
points.reverse()
|
| 554 |
+
return points
|
| 555 |
+
|
| 556 |
+
|
| 557 |
+
def _contour_area2(points: list[tuple[float, float]]) -> float:
|
| 558 |
+
return sum(
|
| 559 |
+
x0 * y1 - x1 * y0
|
| 560 |
+
for (x0, y0), (x1, y1) in zip(points, points[1:])
|
| 561 |
+
)
|
| 562 |
+
|
| 563 |
+
|
| 564 |
+
def _contour_sort_key(points: list[tuple[float, float]]) -> tuple[float, float, float]:
|
| 565 |
+
xs = [point[0] for point in points]
|
| 566 |
+
ys = [point[1] for point in points]
|
| 567 |
+
return (-abs(_contour_area2(points)), min(ys), min(xs))
|
| 568 |
+
|
| 569 |
+
|
| 570 |
+
def _layer_contour_loops(layer: MultiPolygon) -> list[list[tuple[float, float]]]:
|
| 571 |
+
"""Closed contour loops of a layer: exterior rings first, holes after."""
|
| 572 |
+
loops: list[list[tuple[float, float]]] = []
|
| 573 |
+
for polygon in layer.geoms:
|
| 574 |
+
oriented = orient(polygon)
|
| 575 |
+
for ring in (oriented.exterior, *oriented.interiors):
|
| 576 |
+
simplified = ring.simplify(0)
|
| 577 |
+
coords = [(float(x), float(y)) for x, y in simplified.coords]
|
| 578 |
+
if len(coords) >= 4:
|
| 579 |
+
loops.append(coords)
|
| 580 |
+
|
| 581 |
+
loops.sort(key=_contour_sort_key)
|
| 582 |
+
return loops
|
| 583 |
+
|
| 584 |
+
|
| 585 |
+
def _rotate_closed_contour_to_nearest_border(
|
| 586 |
+
contour: list[tuple[float, float]],
|
| 587 |
+
current_x: float,
|
| 588 |
+
current_y: float,
|
| 589 |
+
approach_dx: float = 0.0,
|
| 590 |
+
approach_dy: float = 0.0,
|
| 591 |
+
) -> list[tuple[float, float]]:
|
| 592 |
+
if len(contour) < 3:
|
| 593 |
+
return contour
|
| 594 |
+
|
| 595 |
+
ring = contour[:-1] if contour[0] == contour[-1] else contour
|
| 596 |
+
if len(ring) < 2:
|
| 597 |
+
return contour
|
| 598 |
+
|
| 599 |
+
best_idx = 0
|
| 600 |
+
best_t = 0.0
|
| 601 |
+
best_point = ring[0]
|
| 602 |
+
best_dist = float("inf")
|
| 603 |
+
for idx, (ax, ay) in enumerate(ring):
|
| 604 |
+
bx, by = ring[(idx + 1) % len(ring)]
|
| 605 |
+
point_x, point_y, t = _closest_point_on_segment(
|
| 606 |
+
current_x,
|
| 607 |
+
current_y,
|
| 608 |
+
ax,
|
| 609 |
+
ay,
|
| 610 |
+
bx,
|
| 611 |
+
by,
|
| 612 |
+
)
|
| 613 |
+
distance = _point_distance_sq(current_x, current_y, point_x, point_y)
|
| 614 |
+
if distance < best_dist:
|
| 615 |
+
best_dist = distance
|
| 616 |
+
best_idx = idx
|
| 617 |
+
best_t = t
|
| 618 |
+
best_point = (point_x, point_y)
|
| 619 |
+
|
| 620 |
+
eps = 1e-9
|
| 621 |
+
|
| 622 |
+
def choose_direction(
|
| 623 |
+
forward: list[tuple[float, float]],
|
| 624 |
+
reverse: list[tuple[float, float]],
|
| 625 |
+
) -> list[tuple[float, float]]:
|
| 626 |
+
if (
|
| 627 |
+
len(forward) < 2
|
| 628 |
+
or len(reverse) < 2
|
| 629 |
+
or (approach_dx == 0 and approach_dy == 0)
|
| 630 |
+
):
|
| 631 |
+
return forward
|
| 632 |
+
|
| 633 |
+
def score(candidate: list[tuple[float, float]]) -> float:
|
| 634 |
+
tx = candidate[1][0] - candidate[0][0]
|
| 635 |
+
ty = candidate[1][1] - candidate[0][1]
|
| 636 |
+
# Positive when the shape interior, opposite the approach vector,
|
| 637 |
+
# is to the left of the contour's first move.
|
| 638 |
+
return (ty * approach_dx) - (tx * approach_dy)
|
| 639 |
+
|
| 640 |
+
return reverse if score(reverse) > score(forward) else forward
|
| 641 |
+
|
| 642 |
+
if best_t <= eps:
|
| 643 |
+
forward = ring[best_idx:] + ring[:best_idx] + [ring[best_idx]]
|
| 644 |
+
reverse_ring = list(reversed(ring))
|
| 645 |
+
reverse_idx = reverse_ring.index(ring[best_idx])
|
| 646 |
+
reverse = (
|
| 647 |
+
reverse_ring[reverse_idx:]
|
| 648 |
+
+ reverse_ring[:reverse_idx]
|
| 649 |
+
+ [reverse_ring[reverse_idx]]
|
| 650 |
+
)
|
| 651 |
+
return choose_direction(forward, reverse)
|
| 652 |
+
|
| 653 |
+
next_idx = (best_idx + 1) % len(ring)
|
| 654 |
+
if best_t >= 1.0 - eps:
|
| 655 |
+
forward = ring[next_idx:] + ring[:next_idx] + [ring[next_idx]]
|
| 656 |
+
reverse_ring = list(reversed(ring))
|
| 657 |
+
reverse_idx = reverse_ring.index(ring[next_idx])
|
| 658 |
+
reverse = (
|
| 659 |
+
reverse_ring[reverse_idx:]
|
| 660 |
+
+ reverse_ring[:reverse_idx]
|
| 661 |
+
+ [reverse_ring[reverse_idx]]
|
| 662 |
+
)
|
| 663 |
+
return choose_direction(forward, reverse)
|
| 664 |
+
|
| 665 |
+
forward = [best_point]
|
| 666 |
+
for step in range(1, len(ring) + 1):
|
| 667 |
+
forward.append(ring[(best_idx + step) % len(ring)])
|
| 668 |
+
forward.append(best_point)
|
| 669 |
+
|
| 670 |
+
reverse = [best_point]
|
| 671 |
+
for step in range(0, len(ring)):
|
| 672 |
+
reverse.append(ring[(best_idx - step) % len(ring)])
|
| 673 |
+
reverse.append(best_point)
|
| 674 |
+
return choose_direction(forward, reverse)
|
| 675 |
+
|
| 676 |
+
|
| 677 |
+
def _last_print_reference(output_list: list[dict]) -> tuple[float, float, float, float]:
|
| 678 |
+
x = y = 0.0
|
| 679 |
+
last_x = last_y = 0.0
|
| 680 |
+
last_dx = last_dy = 0.0
|
| 681 |
+
for move in output_list:
|
| 682 |
+
dx = float(move.get("X", 0.0))
|
| 683 |
+
dy = float(move.get("Y", 0.0))
|
| 684 |
+
x += dx
|
| 685 |
+
y += dy
|
| 686 |
+
if move.get("Color") == 255 and "Z" not in move:
|
| 687 |
+
last_x = x
|
| 688 |
+
last_y = y
|
| 689 |
+
last_dx = dx
|
| 690 |
+
last_dy = dy
|
| 691 |
+
return last_x, last_y, last_dx, last_dy
|
| 692 |
+
|
| 693 |
+
|
| 694 |
+
def _rewind_trailing_travel(
|
| 695 |
+
output_list: list[dict],
|
| 696 |
+
current_x: float,
|
| 697 |
+
current_y: float,
|
| 698 |
+
) -> tuple[float, float]:
|
| 699 |
+
if not output_list:
|
| 700 |
+
return current_x, current_y
|
| 701 |
+
|
| 702 |
+
last_move = output_list[-1]
|
| 703 |
+
if last_move.get("Color") != 0 or "Z" in last_move:
|
| 704 |
+
return current_x, current_y
|
| 705 |
+
|
| 706 |
+
has_layer_print = any(
|
| 707 |
+
move.get("Color") == 255 and "Z" not in move
|
| 708 |
+
for move in reversed(output_list[:-1])
|
| 709 |
+
)
|
| 710 |
+
if not has_layer_print:
|
| 711 |
+
return current_x, current_y
|
| 712 |
+
|
| 713 |
+
output_list.pop()
|
| 714 |
+
return (
|
| 715 |
+
current_x - float(last_move.get("X", 0.0)),
|
| 716 |
+
current_y - float(last_move.get("Y", 0.0)),
|
| 717 |
+
)
|
| 718 |
+
|
| 719 |
+
|
| 720 |
+
def build_contour_layers(
|
| 721 |
+
sources: list[ContourSource] | None,
|
| 722 |
+
n_layers: int,
|
| 723 |
+
reference: LayerStack | None = None,
|
| 724 |
+
) -> list[list[dict]]:
|
| 725 |
+
"""Per-layer contour loops per owning shape.
|
| 726 |
+
|
| 727 |
+
When `reference` is given (reference-motion mode) each source stack is
|
| 728 |
+
translated by its centering delta so contours land in the shared frame.
|
| 729 |
+
"""
|
| 730 |
+
contour_layers: list[list[dict]] = [[] for _ in range(n_layers)]
|
| 731 |
+
for source in sources or []:
|
| 732 |
+
stack = source.stack
|
| 733 |
+
if stack is None or not stack.layers:
|
| 734 |
+
continue
|
| 735 |
+
|
| 736 |
+
if reference is not None:
|
| 737 |
+
layers = align_stack_to(stack, reference, n_layers)
|
| 738 |
+
else:
|
| 739 |
+
layers = stack.layers
|
| 740 |
+
|
| 741 |
+
for layer_number in range(min(n_layers, len(layers))):
|
| 742 |
+
layer = layers[layer_number]
|
| 743 |
+
if layer is None or layer.is_empty:
|
| 744 |
+
continue
|
| 745 |
+
contours = _layer_contour_loops(layer)
|
| 746 |
+
if contours:
|
| 747 |
+
contour_layers[layer_number].append(
|
| 748 |
+
{
|
| 749 |
+
"owner_idx": source.owner_idx,
|
| 750 |
+
"contours": contours,
|
| 751 |
+
}
|
| 752 |
+
)
|
| 753 |
+
|
| 754 |
+
return contour_layers
|
| 755 |
+
|
| 756 |
+
|
| 757 |
+
def _append_layer_contours(
|
| 758 |
+
output_list: list[dict],
|
| 759 |
+
current_x: float,
|
| 760 |
+
current_y: float,
|
| 761 |
+
contour_layers: list[list[dict]],
|
| 762 |
+
layer_number: int,
|
| 763 |
+
active_owner_idx: int | None,
|
| 764 |
+
origin_x: float = 0.0,
|
| 765 |
+
origin_y: float = 0.0,
|
| 766 |
+
) -> tuple[float, float]:
|
| 767 |
+
# `origin_x/origin_y` is the world position of the move list's start:
|
| 768 |
+
# _last_print_reference sums relative moves from zero, so its result must
|
| 769 |
+
# be shifted back into the world frame the contours live in.
|
| 770 |
+
if layer_number >= len(contour_layers):
|
| 771 |
+
return current_x, current_y
|
| 772 |
+
|
| 773 |
+
active_sources = [
|
| 774 |
+
source
|
| 775 |
+
for source in contour_layers[layer_number]
|
| 776 |
+
if source.get("owner_idx") == active_owner_idx
|
| 777 |
+
and any(len(contour) >= 2 for contour in source.get("contours", []))
|
| 778 |
+
]
|
| 779 |
+
if not active_sources:
|
| 780 |
+
return current_x, current_y
|
| 781 |
+
|
| 782 |
+
current_x, current_y = _rewind_trailing_travel(
|
| 783 |
+
output_list,
|
| 784 |
+
current_x,
|
| 785 |
+
current_y,
|
| 786 |
+
)
|
| 787 |
+
|
| 788 |
+
use_infill_reference = True
|
| 789 |
+
|
| 790 |
+
for source in active_sources:
|
| 791 |
+
color = 255
|
| 792 |
+
for contour in source.get("contours", []):
|
| 793 |
+
if len(contour) < 2:
|
| 794 |
+
continue
|
| 795 |
+
if use_infill_reference:
|
| 796 |
+
nearest_x, nearest_y, approach_dx, approach_dy = _last_print_reference(
|
| 797 |
+
output_list
|
| 798 |
+
)
|
| 799 |
+
nearest_x += origin_x
|
| 800 |
+
nearest_y += origin_y
|
| 801 |
+
else:
|
| 802 |
+
nearest_x, nearest_y = current_x, current_y
|
| 803 |
+
approach_dx, approach_dy = 0.0, 0.0
|
| 804 |
+
contour = _rotate_closed_contour_to_nearest_border(
|
| 805 |
+
contour,
|
| 806 |
+
nearest_x,
|
| 807 |
+
nearest_y,
|
| 808 |
+
approach_dx,
|
| 809 |
+
approach_dy,
|
| 810 |
+
)
|
| 811 |
+
if contour[0] != contour[-1]:
|
| 812 |
+
contour = [*contour, contour[0]]
|
| 813 |
+
start_x, start_y = contour[0]
|
| 814 |
+
use_infill_reference = False
|
| 815 |
+
current_x, current_y = _append_relative_move(
|
| 816 |
+
output_list,
|
| 817 |
+
current_x,
|
| 818 |
+
current_y,
|
| 819 |
+
start_x,
|
| 820 |
+
start_y,
|
| 821 |
+
0,
|
| 822 |
+
)
|
| 823 |
+
for target_x, target_y in contour[1:]:
|
| 824 |
+
current_x, current_y = _append_relative_move(
|
| 825 |
+
output_list,
|
| 826 |
+
current_x,
|
| 827 |
+
current_y,
|
| 828 |
+
target_x,
|
| 829 |
+
target_y,
|
| 830 |
+
color,
|
| 831 |
+
)
|
| 832 |
+
|
| 833 |
+
return current_x, current_y
|
| 834 |
+
|
| 835 |
+
|
| 836 |
+
def _lead_in_moves(
|
| 837 |
+
enabled: bool,
|
| 838 |
+
length: float,
|
| 839 |
+
clearance: float,
|
| 840 |
+
line_count: int,
|
| 841 |
+
line_spacing: float,
|
| 842 |
+
print_color: int,
|
| 843 |
+
off_color: int,
|
| 844 |
+
) -> list[dict]:
|
| 845 |
+
if not enabled:
|
| 846 |
+
return []
|
| 847 |
+
lead_length = max(0.0, float(length))
|
| 848 |
+
if lead_length <= 0.0:
|
| 849 |
+
return []
|
| 850 |
+
lead_clearance = max(0.0, float(clearance))
|
| 851 |
+
pass_count = max(1, int(line_count))
|
| 852 |
+
spacing = max(0.0, float(line_spacing))
|
| 853 |
+
|
| 854 |
+
moves: list[dict] = []
|
| 855 |
+
current_x = 0.0
|
| 856 |
+
current_y = 0.0
|
| 857 |
+
|
| 858 |
+
def append_move(dx: float, dy: float, color: int) -> None:
|
| 859 |
+
nonlocal current_x, current_y
|
| 860 |
+
if dx == 0.0 and dy == 0.0:
|
| 861 |
+
return
|
| 862 |
+
moves.append({"X": dx, "Y": dy, "Color": color})
|
| 863 |
+
current_x += dx
|
| 864 |
+
current_y += dy
|
| 865 |
+
|
| 866 |
+
append_move(-(lead_clearance + lead_length), 0.0, off_color)
|
| 867 |
+
direction = 1.0
|
| 868 |
+
for pass_index in range(pass_count):
|
| 869 |
+
append_move(direction * lead_length, 0.0, print_color)
|
| 870 |
+
direction *= -1.0
|
| 871 |
+
if pass_index < pass_count - 1:
|
| 872 |
+
append_move(0.0, spacing, off_color)
|
| 873 |
+
append_move(-current_x, -current_y, off_color)
|
| 874 |
+
return moves
|
| 875 |
+
|
| 876 |
+
|
| 877 |
+
def plan_layer_moves(
|
| 878 |
+
motion_layers: list[MultiPolygon],
|
| 879 |
+
valve_layers: list[MultiPolygon],
|
| 880 |
+
fil_width: float,
|
| 881 |
+
layer_height: float,
|
| 882 |
+
raster_pattern: str,
|
| 883 |
+
contour_layers: list[list[dict]] | None = None,
|
| 884 |
+
active_contour_owner: int | None = None,
|
| 885 |
+
) -> list[dict]:
|
| 886 |
+
"""Assemble per-layer segments into a relative move list for all patterns."""
|
| 887 |
+
raster_pattern = _normalize_raster_pattern(raster_pattern)
|
| 888 |
+
gcode_list: list[dict] = []
|
| 889 |
+
current_x = 0.0
|
| 890 |
+
current_y = 0.0
|
| 891 |
+
origin_x = 0.0
|
| 892 |
+
origin_y = 0.0
|
| 893 |
+
raster_origin_initialized = False
|
| 894 |
+
contour_layers = contour_layers or []
|
| 895 |
+
|
| 896 |
+
for layer_number, (motion, valve) in enumerate(zip(motion_layers, valve_layers)):
|
| 897 |
+
if motion is None or motion.is_empty:
|
| 898 |
+
segments: list[Seg] = []
|
| 899 |
+
elif raster_pattern == RASTER_PATTERN_CIRCLE_SPIRAL:
|
| 900 |
+
points = _circle_spiral_polyline(
|
| 901 |
+
motion.bounds,
|
| 902 |
+
fil_width,
|
| 903 |
+
reverse=layer_number % 2 == 1,
|
| 904 |
+
)
|
| 905 |
+
segments = _classify_polyline(points, valve)
|
| 906 |
+
elif raster_pattern == RASTER_PATTERN_RECTANGULAR_SPIRAL:
|
| 907 |
+
points = _rectangular_spiral_polyline(
|
| 908 |
+
motion.bounds,
|
| 909 |
+
fil_width,
|
| 910 |
+
reverse=layer_number % 2 == 1,
|
| 911 |
+
)
|
| 912 |
+
segments = _classify_polyline(points, valve)
|
| 913 |
+
else:
|
| 914 |
+
raster_axis = _raster_axis_for_pattern(raster_pattern, layer_number)
|
| 915 |
+
segments = _oriented_axis_raster_segments(
|
| 916 |
+
motion,
|
| 917 |
+
valve,
|
| 918 |
+
fil_width,
|
| 919 |
+
raster_axis,
|
| 920 |
+
current_x,
|
| 921 |
+
current_y,
|
| 922 |
+
prefer_default=not raster_origin_initialized,
|
| 923 |
+
)
|
| 924 |
+
|
| 925 |
+
if not segments:
|
| 926 |
+
if layer_number > 0:
|
| 927 |
+
gcode_list.append({"X": 0.0, "Y": 0.0, "Z": layer_height, "Color": 0})
|
| 928 |
+
layer_end_x, layer_end_y = current_x, current_y
|
| 929 |
+
current_x, current_y = _append_layer_contours(
|
| 930 |
+
gcode_list,
|
| 931 |
+
current_x,
|
| 932 |
+
current_y,
|
| 933 |
+
contour_layers,
|
| 934 |
+
layer_number,
|
| 935 |
+
active_contour_owner,
|
| 936 |
+
origin_x,
|
| 937 |
+
origin_y,
|
| 938 |
+
)
|
| 939 |
+
current_x, current_y = _append_relative_move(
|
| 940 |
+
gcode_list,
|
| 941 |
+
current_x,
|
| 942 |
+
current_y,
|
| 943 |
+
layer_end_x,
|
| 944 |
+
layer_end_y,
|
| 945 |
+
0,
|
| 946 |
+
)
|
| 947 |
+
continue
|
| 948 |
+
|
| 949 |
+
first_x, first_y = segments[0][0], segments[0][1]
|
| 950 |
+
if not raster_origin_initialized:
|
| 951 |
+
if layer_number > 0:
|
| 952 |
+
current_x, current_y = _append_relative_move(
|
| 953 |
+
gcode_list,
|
| 954 |
+
current_x,
|
| 955 |
+
current_y,
|
| 956 |
+
current_x,
|
| 957 |
+
current_y,
|
| 958 |
+
0,
|
| 959 |
+
z_step=layer_height,
|
| 960 |
+
)
|
| 961 |
+
current_x, current_y = first_x, first_y
|
| 962 |
+
origin_x, origin_y = first_x, first_y
|
| 963 |
+
raster_origin_initialized = True
|
| 964 |
+
elif layer_number > 0:
|
| 965 |
+
current_x, current_y = _append_relative_move(
|
| 966 |
+
gcode_list,
|
| 967 |
+
current_x,
|
| 968 |
+
current_y,
|
| 969 |
+
first_x,
|
| 970 |
+
first_y,
|
| 971 |
+
0,
|
| 972 |
+
z_step=layer_height,
|
| 973 |
+
)
|
| 974 |
+
else:
|
| 975 |
+
current_x, current_y = _append_relative_move(
|
| 976 |
+
gcode_list,
|
| 977 |
+
current_x,
|
| 978 |
+
current_y,
|
| 979 |
+
first_x,
|
| 980 |
+
first_y,
|
| 981 |
+
0,
|
| 982 |
+
)
|
| 983 |
+
|
| 984 |
+
for start_x, start_y, end_x, end_y, color in segments:
|
| 985 |
+
current_x, current_y = _append_relative_move(
|
| 986 |
+
gcode_list,
|
| 987 |
+
current_x,
|
| 988 |
+
current_y,
|
| 989 |
+
start_x,
|
| 990 |
+
start_y,
|
| 991 |
+
0,
|
| 992 |
+
)
|
| 993 |
+
current_x, current_y = _append_relative_move(
|
| 994 |
+
gcode_list,
|
| 995 |
+
current_x,
|
| 996 |
+
current_y,
|
| 997 |
+
end_x,
|
| 998 |
+
end_y,
|
| 999 |
+
color,
|
| 1000 |
+
)
|
| 1001 |
+
|
| 1002 |
+
layer_end_x, layer_end_y = current_x, current_y
|
| 1003 |
+
current_x, current_y = _append_layer_contours(
|
| 1004 |
+
gcode_list,
|
| 1005 |
+
current_x,
|
| 1006 |
+
current_y,
|
| 1007 |
+
contour_layers,
|
| 1008 |
+
layer_number,
|
| 1009 |
+
active_contour_owner,
|
| 1010 |
+
origin_x,
|
| 1011 |
+
origin_y,
|
| 1012 |
+
)
|
| 1013 |
+
current_x, current_y = _append_relative_move(
|
| 1014 |
+
gcode_list,
|
| 1015 |
+
current_x,
|
| 1016 |
+
current_y,
|
| 1017 |
+
layer_end_x,
|
| 1018 |
+
layer_end_y,
|
| 1019 |
+
0,
|
| 1020 |
+
)
|
| 1021 |
+
|
| 1022 |
+
return gcode_list
|
| 1023 |
+
|
| 1024 |
+
|
| 1025 |
+
def _stack_center(stack: LayerStack) -> tuple[float, float]:
|
| 1026 |
+
(x_min, y_min, _z_min), (x_max, y_max, _z_max) = stack.bounds
|
| 1027 |
+
return ((x_min + x_max) / 2.0, (y_min + y_max) / 2.0)
|
| 1028 |
+
|
| 1029 |
+
|
| 1030 |
+
def _centering_delta(stack: LayerStack, reference: LayerStack) -> tuple[float, float]:
|
| 1031 |
+
reference_x, reference_y = _stack_center(reference)
|
| 1032 |
+
center_x, center_y = _stack_center(stack)
|
| 1033 |
+
return reference_x - center_x, reference_y - center_y
|
| 1034 |
+
|
| 1035 |
+
|
| 1036 |
+
def align_stack_to(
|
| 1037 |
+
stack: LayerStack,
|
| 1038 |
+
reference: LayerStack,
|
| 1039 |
+
n_layers: int,
|
| 1040 |
+
) -> list[MultiPolygon]:
|
| 1041 |
+
"""Translate a stack's layers into the reference frame, padded with empties."""
|
| 1042 |
+
delta_x, delta_y = _centering_delta(stack, reference)
|
| 1043 |
+
layers: list[MultiPolygon] = []
|
| 1044 |
+
for index in range(n_layers):
|
| 1045 |
+
if index < len(stack.layers) and not stack.layers[index].is_empty:
|
| 1046 |
+
layers.append(
|
| 1047 |
+
_as_multipolygon(
|
| 1048 |
+
translate(stack.layers[index], xoff=delta_x, yoff=delta_y)
|
| 1049 |
+
)
|
| 1050 |
+
)
|
| 1051 |
+
else:
|
| 1052 |
+
layers.append(MultiPolygon())
|
| 1053 |
+
return layers
|
| 1054 |
+
|
| 1055 |
+
|
| 1056 |
+
def build_reference_stack(stacks: list[LayerStack | None]) -> LayerStack | None:
|
| 1057 |
+
"""Union all shapes into one shared motion stack, bbox-centres aligned.
|
| 1058 |
+
|
| 1059 |
+
Vector analog of the old centered "black wins" TIFF merge: every stack is
|
| 1060 |
+
translated so its XY bbox centre lands on the first stack's centre, then
|
| 1061 |
+
each layer is the union of the translated layers.
|
| 1062 |
+
"""
|
| 1063 |
+
valid = [stack for stack in stacks if stack is not None and stack.layers]
|
| 1064 |
+
if not valid:
|
| 1065 |
+
return None
|
| 1066 |
+
|
| 1067 |
+
n_layers = max(len(stack.layers) for stack in valid)
|
| 1068 |
+
reference_x, reference_y = _stack_center(valid[0])
|
| 1069 |
+
|
| 1070 |
+
layer_parts: list[list[MultiPolygon]] = [[] for _ in range(n_layers)]
|
| 1071 |
+
x_min = y_min = z_min = math.inf
|
| 1072 |
+
x_max = y_max = z_max = -math.inf
|
| 1073 |
+
|
| 1074 |
+
for stack in valid:
|
| 1075 |
+
center_x, center_y = _stack_center(stack)
|
| 1076 |
+
delta_x = reference_x - center_x
|
| 1077 |
+
delta_y = reference_y - center_y
|
| 1078 |
+
|
| 1079 |
+
(sx_min, sy_min, sz_min), (sx_max, sy_max, sz_max) = stack.bounds
|
| 1080 |
+
x_min = min(x_min, sx_min + delta_x)
|
| 1081 |
+
x_max = max(x_max, sx_max + delta_x)
|
| 1082 |
+
y_min = min(y_min, sy_min + delta_y)
|
| 1083 |
+
y_max = max(y_max, sy_max + delta_y)
|
| 1084 |
+
z_min = min(z_min, sz_min)
|
| 1085 |
+
z_max = max(z_max, sz_max)
|
| 1086 |
+
|
| 1087 |
+
for index, layer in enumerate(stack.layers):
|
| 1088 |
+
if layer is None or layer.is_empty:
|
| 1089 |
+
continue
|
| 1090 |
+
layer_parts[index].append(translate(layer, xoff=delta_x, yoff=delta_y))
|
| 1091 |
+
|
| 1092 |
+
layers = [
|
| 1093 |
+
_as_multipolygon(unary_union(parts)) if parts else MultiPolygon()
|
| 1094 |
+
for parts in layer_parts
|
| 1095 |
+
]
|
| 1096 |
+
|
| 1097 |
+
z_values: list[float] = []
|
| 1098 |
+
for index in range(n_layers):
|
| 1099 |
+
z_value = 0.0
|
| 1100 |
+
for stack in valid:
|
| 1101 |
+
if index < len(stack.z_values):
|
| 1102 |
+
z_value = stack.z_values[index]
|
| 1103 |
+
break
|
| 1104 |
+
z_values.append(z_value)
|
| 1105 |
+
|
| 1106 |
+
return LayerStack(
|
| 1107 |
+
layers=layers,
|
| 1108 |
+
z_values=z_values,
|
| 1109 |
+
bounds=((x_min, y_min, z_min), (x_max, y_max, z_max)),
|
| 1110 |
+
layer_height=valid[0].layer_height,
|
| 1111 |
+
name="reference",
|
| 1112 |
+
)
|
| 1113 |
+
|
| 1114 |
+
|
| 1115 |
+
def _split_boundaries(
|
| 1116 |
+
lo: float,
|
| 1117 |
+
hi: float,
|
| 1118 |
+
count: int,
|
| 1119 |
+
layer_index: int,
|
| 1120 |
+
overlap: float,
|
| 1121 |
+
) -> list[float]:
|
| 1122 |
+
"""Cell boundaries for one axis; interior ones alternate by ±overlap per layer."""
|
| 1123 |
+
edges = [lo + index * (hi - lo) / count for index in range(count + 1)]
|
| 1124 |
+
if overlap <= 0.0 or count <= 1:
|
| 1125 |
+
return edges
|
| 1126 |
+
|
| 1127 |
+
adjusted = list(edges)
|
| 1128 |
+
for boundary_index in range(1, count):
|
| 1129 |
+
direction = 1 if (layer_index + boundary_index) % 2 == 1 else -1
|
| 1130 |
+
lower = adjusted[boundary_index - 1] + overlap
|
| 1131 |
+
upper = edges[boundary_index + 1] - overlap
|
| 1132 |
+
shifted = edges[boundary_index] + direction * overlap
|
| 1133 |
+
if lower <= upper:
|
| 1134 |
+
shifted = max(lower, min(upper, shifted))
|
| 1135 |
+
else:
|
| 1136 |
+
shifted = edges[boundary_index]
|
| 1137 |
+
adjusted[boundary_index] = shifted
|
| 1138 |
+
return adjusted
|
| 1139 |
+
|
| 1140 |
+
|
| 1141 |
+
def split_layer_stack_grid(
|
| 1142 |
+
stack: LayerStack,
|
| 1143 |
+
columns: int,
|
| 1144 |
+
rows: int,
|
| 1145 |
+
overlapping_layers: bool = False,
|
| 1146 |
+
overlap: float = 0.0,
|
| 1147 |
+
) -> list[LayerStack]:
|
| 1148 |
+
"""Split a sliced shape into a rows x columns grid of piece stacks.
|
| 1149 |
+
|
| 1150 |
+
Pieces are returned row-major with row 1 the top strip (max-Y side),
|
| 1151 |
+
matching the legacy image-grid ordering. With `overlapping_layers`, the
|
| 1152 |
+
interior cut lines alternate by ±overlap between layers so neighbouring
|
| 1153 |
+
pieces interlock. Piece `bounds` are the nominal (un-shifted) cell boxes.
|
| 1154 |
+
"""
|
| 1155 |
+
columns = max(1, int(columns))
|
| 1156 |
+
rows = max(1, int(rows))
|
| 1157 |
+
(x_min, y_min, z_min), (x_max, y_max, z_max) = stack.bounds
|
| 1158 |
+
|
| 1159 |
+
overlap_x = overlap if (overlapping_layers and columns > 1) else 0.0
|
| 1160 |
+
overlap_y = overlap if (overlapping_layers and rows > 1) else 0.0
|
| 1161 |
+
|
| 1162 |
+
base_x_edges = _split_boundaries(x_min, x_max, columns, 0, 0.0)
|
| 1163 |
+
base_y_edges = _split_boundaries(y_min, y_max, rows, 0, 0.0)
|
| 1164 |
+
layer_x_edges = [
|
| 1165 |
+
_split_boundaries(x_min, x_max, columns, index, overlap_x)
|
| 1166 |
+
for index in range(len(stack.layers))
|
| 1167 |
+
]
|
| 1168 |
+
layer_y_edges = [
|
| 1169 |
+
_split_boundaries(y_min, y_max, rows, index, overlap_y)
|
| 1170 |
+
for index in range(len(stack.layers))
|
| 1171 |
+
]
|
| 1172 |
+
|
| 1173 |
+
base_name = stack.name or "shape"
|
| 1174 |
+
pieces: list[LayerStack] = []
|
| 1175 |
+
for row_index in range(1, rows + 1):
|
| 1176 |
+
# Row 1 is the top strip: count y-cells down from the max edge.
|
| 1177 |
+
y_cell = rows - row_index
|
| 1178 |
+
for col_index in range(1, columns + 1):
|
| 1179 |
+
x_cell = col_index - 1
|
| 1180 |
+
|
| 1181 |
+
layers: list[MultiPolygon] = []
|
| 1182 |
+
for layer_number, layer in enumerate(stack.layers):
|
| 1183 |
+
if layer is None or layer.is_empty:
|
| 1184 |
+
layers.append(MultiPolygon())
|
| 1185 |
+
continue
|
| 1186 |
+
x_edges = layer_x_edges[layer_number]
|
| 1187 |
+
y_edges = layer_y_edges[layer_number]
|
| 1188 |
+
cell = box(
|
| 1189 |
+
x_edges[x_cell],
|
| 1190 |
+
y_edges[y_cell],
|
| 1191 |
+
x_edges[x_cell + 1],
|
| 1192 |
+
y_edges[y_cell + 1],
|
| 1193 |
+
)
|
| 1194 |
+
layers.append(_as_multipolygon(layer.intersection(cell)))
|
| 1195 |
+
|
| 1196 |
+
pieces.append(
|
| 1197 |
+
LayerStack(
|
| 1198 |
+
layers=layers,
|
| 1199 |
+
z_values=list(stack.z_values),
|
| 1200 |
+
bounds=(
|
| 1201 |
+
(base_x_edges[x_cell], base_y_edges[y_cell], z_min),
|
| 1202 |
+
(base_x_edges[x_cell + 1], base_y_edges[y_cell + 1], z_max),
|
| 1203 |
+
),
|
| 1204 |
+
layer_height=stack.layer_height,
|
| 1205 |
+
name=f"{base_name}_r{row_index:02d}_c{col_index:02d}",
|
| 1206 |
+
)
|
| 1207 |
+
)
|
| 1208 |
+
|
| 1209 |
+
return pieces
|