Spaces:
Running
Running
Add woodpile raster pattern
Browse files- README.md +2 -1
- app.py +14 -2
- tests/test_tiff_to_gcode.py +51 -1
- tiff_to_gcode.py +264 -74
README.md
CHANGED
|
@@ -50,7 +50,7 @@ Then open the local Gradio URL in your browser, upload STL files or load the bun
|
|
| 50 |
- Exports a ZIP containing the generated TIFF images
|
| 51 |
- Combines generated stacks into a reference TIFF stack
|
| 52 |
- Converts generated TIFF ZIPs into G-code files with pressure, valve, and port settings per shape from the Shape Settings table
|
| 53 |
-
- Offers
|
| 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
|
|
@@ -82,6 +82,7 @@ When you click **Generate Reference TIFF Stack**, the app combines available TIF
|
|
| 82 |
- Pressure increases by `0.1` psi per layer by default.
|
| 83 |
- **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.
|
| 84 |
- **Use Reference Stack for motion**: when enabled, every shape's snake-path *motion* is taken from the combined Reference TIFF Stack while each shape's *valve/dispensing* comes from its own slices — so parallel print heads share one synchronized nozzle path and each deposits only its own geometry. Requires generating the Reference TIFF Stack on the first tab first; shapes are skipped with a message if it is missing.
|
|
|
|
| 85 |
|
| 86 |
### Print vs Travel Classification
|
| 87 |
|
|
|
|
| 50 |
- Exports a ZIP containing the generated TIFF images
|
| 51 |
- Combines generated stacks into a reference TIFF stack
|
| 52 |
- Converts generated TIFF ZIPs into G-code files with pressure, valve, and port settings per shape from the Shape Settings table
|
| 53 |
+
- Offers 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)
|
| 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
|
|
|
|
| 82 |
- Pressure increases by `0.1` psi per layer by default.
|
| 83 |
- **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.
|
| 84 |
- **Use Reference Stack for motion**: when enabled, every shape's snake-path *motion* is taken from the combined Reference TIFF Stack while each shape's *valve/dispensing* comes from its own slices — so parallel print heads share one synchronized nozzle path and each deposits only its own geometry. Requires generating the Reference TIFF Stack on the first tab first; shapes are skipped with a message if it is missing.
|
| 85 |
+
- **Raster Pattern**: `Same-direction raster` keeps the existing back-and-forth raster direction on every layer. `Woodpile raster` alternates the raster axis by layer, switching between X-direction and Y-direction sweeps.
|
| 86 |
|
| 87 |
### Print vs Travel Classification
|
| 88 |
|
app.py
CHANGED
|
@@ -36,7 +36,11 @@ from stl_slicer import (
|
|
| 36 |
scale_mesh,
|
| 37 |
slice_stl_to_tiffs,
|
| 38 |
)
|
| 39 |
-
from tiff_to_gcode import
|
|
|
|
|
|
|
|
|
|
|
|
|
| 40 |
|
| 41 |
|
| 42 |
ViewerState = dict[str, Any]
|
|
@@ -2208,6 +2212,7 @@ def generate_dynamic_gcode(
|
|
| 2208 |
settings_table: Any,
|
| 2209 |
all_g1: bool,
|
| 2210 |
use_reference_motion: bool,
|
|
|
|
| 2211 |
ref_state: ViewerState | None,
|
| 2212 |
layer_height: float,
|
| 2213 |
pixel_size: float,
|
|
@@ -2235,6 +2240,7 @@ def generate_dynamic_gcode(
|
|
| 2235 |
fil_width=float(pixel_size),
|
| 2236 |
all_g1=bool(all_g1),
|
| 2237 |
motion_tiffs=motion_tiffs,
|
|
|
|
| 2238 |
)
|
| 2239 |
record["gcode_path"] = str(gcode_path)
|
| 2240 |
messages.append(f"Shape {record['idx']}: wrote `{gcode_path.name}`.")
|
|
@@ -2568,6 +2574,12 @@ def build_dynamic_demo() -> gr.Blocks:
|
|
| 2568 |
value=True,
|
| 2569 |
)
|
| 2570 |
gcode_all_g1 = gr.Checkbox(label="Move at one constant speed (no fast travel moves)", value=True)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2571 |
gcode_button = gr.Button("Generate G-Code", variant="primary")
|
| 2572 |
gcode_downloads = gr.File(label="Download G-Code Files", file_count="multiple", interactive=False, elem_classes=["gcode-download"])
|
| 2573 |
gcode_status = gr.Markdown("")
|
|
@@ -2727,7 +2739,7 @@ def build_dynamic_demo() -> gr.Blocks:
|
|
| 2727 |
|
| 2728 |
gcode_button.click(
|
| 2729 |
fn=generate_dynamic_gcode,
|
| 2730 |
-
inputs=[shape_records, shape_settings, gcode_all_g1, gcode_use_ref_motion, ref_state, layer_height, pixel_size],
|
| 2731 |
outputs=[shape_records, gcode_downloads, gcode_status, gcode_text_source, gcode_source],
|
| 2732 |
)
|
| 2733 |
gcode_text_source.change(fn=load_selected_gcode_text, inputs=[shape_records, gcode_text_source], outputs=[gcode_text])
|
|
|
|
| 36 |
scale_mesh,
|
| 37 |
slice_stl_to_tiffs,
|
| 38 |
)
|
| 39 |
+
from tiff_to_gcode import (
|
| 40 |
+
RASTER_PATTERN_CHOICES,
|
| 41 |
+
RASTER_PATTERN_SAME_DIRECTION,
|
| 42 |
+
generate_snake_path_gcode,
|
| 43 |
+
)
|
| 44 |
|
| 45 |
|
| 46 |
ViewerState = dict[str, Any]
|
|
|
|
| 2212 |
settings_table: Any,
|
| 2213 |
all_g1: bool,
|
| 2214 |
use_reference_motion: bool,
|
| 2215 |
+
raster_pattern: str | None,
|
| 2216 |
ref_state: ViewerState | None,
|
| 2217 |
layer_height: float,
|
| 2218 |
pixel_size: float,
|
|
|
|
| 2240 |
fil_width=float(pixel_size),
|
| 2241 |
all_g1=bool(all_g1),
|
| 2242 |
motion_tiffs=motion_tiffs,
|
| 2243 |
+
raster_pattern=raster_pattern,
|
| 2244 |
)
|
| 2245 |
record["gcode_path"] = str(gcode_path)
|
| 2246 |
messages.append(f"Shape {record['idx']}: wrote `{gcode_path.name}`.")
|
|
|
|
| 2574 |
value=True,
|
| 2575 |
)
|
| 2576 |
gcode_all_g1 = gr.Checkbox(label="Move at one constant speed (no fast travel moves)", value=True)
|
| 2577 |
+
gcode_raster_pattern = gr.Dropdown(
|
| 2578 |
+
label="Raster Pattern",
|
| 2579 |
+
choices=list(RASTER_PATTERN_CHOICES),
|
| 2580 |
+
value=RASTER_PATTERN_SAME_DIRECTION,
|
| 2581 |
+
allow_custom_value=False,
|
| 2582 |
+
)
|
| 2583 |
gcode_button = gr.Button("Generate G-Code", variant="primary")
|
| 2584 |
gcode_downloads = gr.File(label="Download G-Code Files", file_count="multiple", interactive=False, elem_classes=["gcode-download"])
|
| 2585 |
gcode_status = gr.Markdown("")
|
|
|
|
| 2739 |
|
| 2740 |
gcode_button.click(
|
| 2741 |
fn=generate_dynamic_gcode,
|
| 2742 |
+
inputs=[shape_records, shape_settings, gcode_all_g1, gcode_use_ref_motion, gcode_raster_pattern, ref_state, layer_height, pixel_size],
|
| 2743 |
outputs=[shape_records, gcode_downloads, gcode_status, gcode_text_source, gcode_source],
|
| 2744 |
)
|
| 2745 |
gcode_text_source.change(fn=load_selected_gcode_text, inputs=[shape_records, gcode_text_source], outputs=[gcode_text])
|
tests/test_tiff_to_gcode.py
CHANGED
|
@@ -4,7 +4,7 @@ import zipfile
|
|
| 4 |
|
| 5 |
from PIL import Image
|
| 6 |
|
| 7 |
-
from tiff_to_gcode import generate_snake_path_gcode
|
| 8 |
|
| 9 |
|
| 10 |
def test_gcode_header_writes_presets_before_initial_aux_commands(tmp_path) -> None:
|
|
@@ -61,3 +61,53 @@ def test_gcode_uses_g1_for_print_and_g0_for_travel(tmp_path) -> None:
|
|
| 61 |
assert any(line.startswith("G1") and "; Color 255" in line for line in move_lines)
|
| 62 |
assert all(not line.startswith("G0") for line in move_lines if "; Color 255" in line)
|
| 63 |
assert all(not line.startswith("G1") for line in move_lines if "; Color 0" in line)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4 |
|
| 5 |
from PIL import Image
|
| 6 |
|
| 7 |
+
from tiff_to_gcode import RASTER_PATTERN_WOODPILE, generate_snake_path_gcode
|
| 8 |
|
| 9 |
|
| 10 |
def test_gcode_header_writes_presets_before_initial_aux_commands(tmp_path) -> None:
|
|
|
|
| 61 |
assert any(line.startswith("G1") and "; Color 255" in line for line in move_lines)
|
| 62 |
assert all(not line.startswith("G0") for line in move_lines if "; Color 255" in line)
|
| 63 |
assert all(not line.startswith("G1") for line in move_lines if "; Color 0" in line)
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
def test_woodpile_raster_switches_print_axis_between_layers(tmp_path) -> None:
|
| 67 |
+
tiff_paths = []
|
| 68 |
+
for index in range(4):
|
| 69 |
+
tiff_path = tmp_path / f"slice_{index:04d}.tif"
|
| 70 |
+
Image.new("L", (3, 2), 0).save(tiff_path)
|
| 71 |
+
tiff_paths.append(tiff_path)
|
| 72 |
+
|
| 73 |
+
zip_path = tmp_path / "slices.zip"
|
| 74 |
+
with zipfile.ZipFile(zip_path, mode="w") as archive:
|
| 75 |
+
for tiff_path in tiff_paths:
|
| 76 |
+
archive.write(tiff_path, arcname=tiff_path.name)
|
| 77 |
+
|
| 78 |
+
gcode_path = generate_snake_path_gcode(
|
| 79 |
+
zip_path,
|
| 80 |
+
shape_name="woodpile",
|
| 81 |
+
pressure=25,
|
| 82 |
+
valve=7,
|
| 83 |
+
port=3,
|
| 84 |
+
raster_pattern=RASTER_PATTERN_WOODPILE,
|
| 85 |
+
)
|
| 86 |
+
|
| 87 |
+
move_lines = [
|
| 88 |
+
line.strip()
|
| 89 |
+
for line in gcode_path.read_text().splitlines()
|
| 90 |
+
if line.startswith(("G0", "G1"))
|
| 91 |
+
]
|
| 92 |
+
z_move_index = next(i for i, line in enumerate(move_lines) if " Z" in line)
|
| 93 |
+
first_layer_prints = [line for line in move_lines[:z_move_index] if line.startswith("G1") and "; Color 255" in line]
|
| 94 |
+
second_layer_prints = [line for line in move_lines[z_move_index + 1 :] if line.startswith("G1") and "; Color 255" in line]
|
| 95 |
+
|
| 96 |
+
assert any("X" in line and "Y0" in line for line in first_layer_prints)
|
| 97 |
+
assert any("X0" in line and "Y" in line for line in second_layer_prints)
|
| 98 |
+
|
| 99 |
+
x = y = 0.0
|
| 100 |
+
x_positions = [x]
|
| 101 |
+
y_positions = [y]
|
| 102 |
+
for line in move_lines:
|
| 103 |
+
for token in line.split():
|
| 104 |
+
if token.startswith("X"):
|
| 105 |
+
x += float(token[1:])
|
| 106 |
+
if token.startswith("Y"):
|
| 107 |
+
y += float(token[1:])
|
| 108 |
+
x_positions.append(x)
|
| 109 |
+
y_positions.append(y)
|
| 110 |
+
assert min(x_positions) >= 0.0
|
| 111 |
+
assert max(x_positions) <= 3.0
|
| 112 |
+
assert min(y_positions) >= 0.0
|
| 113 |
+
assert max(y_positions) <= 2.0
|
tiff_to_gcode.py
CHANGED
|
@@ -11,6 +11,17 @@ import numpy as np
|
|
| 11 |
from PIL import Image
|
| 12 |
|
| 13 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
def _setpress(pressure: float) -> str:
|
| 15 |
pressure_str = str(int(pressure * 10)).zfill(4)
|
| 16 |
command_bytes = bytes("08PS " + pressure_str, "utf-8")
|
|
@@ -179,6 +190,175 @@ def _center_on_canvas(
|
|
| 179 |
return out
|
| 180 |
|
| 181 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 182 |
def generate_snake_path_gcode(
|
| 183 |
zip_path: str | Path,
|
| 184 |
shape_name: str,
|
|
@@ -191,10 +371,12 @@ def generate_snake_path_gcode(
|
|
| 191 |
increase_pressure_per_layer: float = 0.1,
|
| 192 |
all_g1: bool = False,
|
| 193 |
motion_tiffs: list[str] | None = None,
|
|
|
|
| 194 |
) -> Path:
|
| 195 |
zip_path = Path(zip_path)
|
| 196 |
if not zip_path.exists():
|
| 197 |
raise FileNotFoundError(f"ZIP file not found: {zip_path}")
|
|
|
|
| 198 |
|
| 199 |
work_dir = Path(tempfile.mkdtemp(prefix="tiff_gcode_"))
|
| 200 |
extract_dir = work_dir / "tiffs"
|
|
@@ -241,87 +423,95 @@ def generate_snake_path_gcode(
|
|
| 241 |
pressure_on_lines = [_toggle_cmd(com_port, start=True)]
|
| 242 |
pressure_off_lines = [_toggle_cmd(com_port, start=False)]
|
| 243 |
|
| 244 |
-
|
| 245 |
-
|
| 246 |
-
|
| 247 |
-
|
| 248 |
-
|
| 249 |
-
|
| 250 |
-
|
| 251 |
-
|
| 252 |
-
|
| 253 |
-
|
| 254 |
-
|
| 255 |
-
|
| 256 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 257 |
return None
|
| 258 |
-
row_data = np.flip(row) if flip else row
|
| 259 |
-
for j, pixel in enumerate(row_data):
|
| 260 |
-
if np.any(pixel) != off_color:
|
| 261 |
-
return y_ref - 1 - j if flip else j
|
| 262 |
-
return None
|
| 263 |
-
|
| 264 |
-
last_x = last_y = None
|
| 265 |
-
if current_offsets_x:
|
| 266 |
-
use_flip_x = layers % 2 == 1
|
| 267 |
-
last_x = current_offsets_x[-1] if use_flip_x else current_offsets_x[0]
|
| 268 |
-
last_row = (
|
| 269 |
-
last_image_ref[last_x] if last_image_ref is not None else None
|
| 270 |
-
)
|
| 271 |
-
last_y = find_first_valid_y(last_row, flip=use_flip_y)
|
| 272 |
-
current_offsets_x.clear()
|
| 273 |
-
|
| 274 |
-
current_offsets_x = [
|
| 275 |
-
i for i, row in enumerate(current_image_ref) if np.any(row) != off_color
|
| 276 |
-
]
|
| 277 |
-
|
| 278 |
-
first_x = first_y = None
|
| 279 |
-
if current_offsets_x:
|
| 280 |
-
use_flip_x = layers % 2 == 1
|
| 281 |
-
first_x = current_offsets_x[-1] if use_flip_x else current_offsets_x[0]
|
| 282 |
-
first_row = current_image_ref[first_x]
|
| 283 |
-
first_y = find_first_valid_y(first_row, flip=use_flip_y)
|
| 284 |
-
|
| 285 |
-
if None in (last_x, last_y, first_x, first_y):
|
| 286 |
-
shift_x = shift_y = 0
|
| 287 |
-
else:
|
| 288 |
-
shift_x = (first_x - last_x) * fil_width
|
| 289 |
-
shift_y = (first_y - last_y) * fil_width * dist_sign_long
|
| 290 |
-
if use_flip_y:
|
| 291 |
-
shift_y = -shift_y
|
| 292 |
|
| 293 |
-
|
| 294 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 295 |
|
| 296 |
-
|
| 297 |
-
|
| 298 |
-
|
| 299 |
-
|
|
|
|
|
|
|
|
|
|
| 300 |
|
| 301 |
-
|
| 302 |
-
|
|
|
|
| 303 |
dist_sign_long = -dist_sign_long
|
| 304 |
-
dist_sign_long = -dist_sign_long
|
| 305 |
|
| 306 |
-
|
| 307 |
-
|
| 308 |
-
|
| 309 |
-
|
| 310 |
-
|
| 311 |
-
|
| 312 |
-
|
| 313 |
-
|
| 314 |
|
| 315 |
-
|
| 316 |
-
|
| 317 |
-
|
| 318 |
-
|
| 319 |
-
|
| 320 |
-
|
| 321 |
-
|
| 322 |
-
|
| 323 |
-
|
| 324 |
-
|
| 325 |
|
| 326 |
gcode_path = work_dir / f"{shape_name}_SnakePath_gcode.txt"
|
| 327 |
pressure_cur = float(pressure)
|
|
|
|
| 11 |
from PIL import Image
|
| 12 |
|
| 13 |
|
| 14 |
+
RASTER_PATTERN_SAME_DIRECTION = "Same-direction raster"
|
| 15 |
+
RASTER_PATTERN_WOODPILE = "Woodpile raster"
|
| 16 |
+
RASTER_PATTERN_CHOICES = (RASTER_PATTERN_SAME_DIRECTION, RASTER_PATTERN_WOODPILE)
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def _normalize_raster_pattern(pattern: str | None) -> str:
|
| 20 |
+
if pattern == RASTER_PATTERN_WOODPILE:
|
| 21 |
+
return RASTER_PATTERN_WOODPILE
|
| 22 |
+
return RASTER_PATTERN_SAME_DIRECTION
|
| 23 |
+
|
| 24 |
+
|
| 25 |
def _setpress(pressure: float) -> str:
|
| 26 |
pressure_str = str(int(pressure * 10)).zfill(4)
|
| 27 |
command_bytes = bytes("08PS " + pressure_str, "utf-8")
|
|
|
|
| 190 |
return out
|
| 191 |
|
| 192 |
|
| 193 |
+
def _append_relative_move(
|
| 194 |
+
output_list: list[dict],
|
| 195 |
+
current_x: float,
|
| 196 |
+
current_y: float,
|
| 197 |
+
target_x: float,
|
| 198 |
+
target_y: float,
|
| 199 |
+
color: int,
|
| 200 |
+
z_step: float | None = None,
|
| 201 |
+
) -> tuple[float, float]:
|
| 202 |
+
dx = target_x - current_x
|
| 203 |
+
dy = target_y - current_y
|
| 204 |
+
if dx == 0 and dy == 0 and z_step is None:
|
| 205 |
+
return current_x, current_y
|
| 206 |
+
move = {"X": dx, "Y": dy, "Color": color}
|
| 207 |
+
if z_step is not None:
|
| 208 |
+
move["Z"] = z_step
|
| 209 |
+
output_list.append(move)
|
| 210 |
+
return target_x, target_y
|
| 211 |
+
|
| 212 |
+
|
| 213 |
+
def _woodpile_layer_segments(
|
| 214 |
+
path_img: np.ndarray,
|
| 215 |
+
color_img: np.ndarray,
|
| 216 |
+
pixel_size: float,
|
| 217 |
+
raster_axis: str,
|
| 218 |
+
) -> list[tuple[float, float, float, float, int]]:
|
| 219 |
+
mask = path_img > 0
|
| 220 |
+
segments: list[tuple[float, float, float, float, int]] = []
|
| 221 |
+
|
| 222 |
+
if raster_axis == "Y":
|
| 223 |
+
first_nonblank = np.where(mask.any(axis=0), mask.argmax(axis=0), -1)
|
| 224 |
+
last_nonblank = np.where(
|
| 225 |
+
mask.any(axis=0),
|
| 226 |
+
mask.shape[0] - 1 - np.flipud(mask).argmax(axis=0),
|
| 227 |
+
-1,
|
| 228 |
+
)
|
| 229 |
+
for col_number, col in enumerate(np.where(first_nonblank != -1)[0]):
|
| 230 |
+
f_idx, l_idx = int(first_nonblank[col]), int(last_nonblank[col])
|
| 231 |
+
if f_idx == -1:
|
| 232 |
+
continue
|
| 233 |
+
forward = col_number % 2 == 0
|
| 234 |
+
row_values = list(range(f_idx, l_idx + 1)) if forward else list(range(l_idx, f_idx - 1, -1))
|
| 235 |
+
run_start = row_values[0]
|
| 236 |
+
prev_row = row_values[0]
|
| 237 |
+
prev_color = int(color_img[prev_row, col])
|
| 238 |
+
x = (int(col) + 0.5) * pixel_size
|
| 239 |
+
for row in row_values[1:]:
|
| 240 |
+
this_color = int(color_img[row, col])
|
| 241 |
+
if this_color == prev_color:
|
| 242 |
+
prev_row = row
|
| 243 |
+
continue
|
| 244 |
+
if forward:
|
| 245 |
+
start_y = run_start * pixel_size
|
| 246 |
+
end_y = (prev_row + 1) * pixel_size
|
| 247 |
+
else:
|
| 248 |
+
start_y = (run_start + 1) * pixel_size
|
| 249 |
+
end_y = prev_row * pixel_size
|
| 250 |
+
segments.append((x, start_y, x, end_y, prev_color))
|
| 251 |
+
run_start = prev_row = row
|
| 252 |
+
prev_color = this_color
|
| 253 |
+
if forward:
|
| 254 |
+
start_y = run_start * pixel_size
|
| 255 |
+
end_y = (prev_row + 1) * pixel_size
|
| 256 |
+
else:
|
| 257 |
+
start_y = (run_start + 1) * pixel_size
|
| 258 |
+
end_y = prev_row * pixel_size
|
| 259 |
+
segments.append((x, start_y, x, end_y, prev_color))
|
| 260 |
+
return segments
|
| 261 |
+
|
| 262 |
+
first_nonblank = np.where(mask.any(axis=1), mask.argmax(axis=1), -1)
|
| 263 |
+
last_nonblank = np.where(
|
| 264 |
+
mask.any(axis=1),
|
| 265 |
+
mask.shape[1] - 1 - np.fliplr(mask).argmax(axis=1),
|
| 266 |
+
-1,
|
| 267 |
+
)
|
| 268 |
+
for row_number, row in enumerate(np.where(first_nonblank != -1)[0]):
|
| 269 |
+
f_idx, l_idx = int(first_nonblank[row]), int(last_nonblank[row])
|
| 270 |
+
if f_idx == -1:
|
| 271 |
+
continue
|
| 272 |
+
forward = row_number % 2 == 0
|
| 273 |
+
col_values = list(range(f_idx, l_idx + 1)) if forward else list(range(l_idx, f_idx - 1, -1))
|
| 274 |
+
run_start = col_values[0]
|
| 275 |
+
prev_col = col_values[0]
|
| 276 |
+
prev_color = int(color_img[row, prev_col])
|
| 277 |
+
y = (int(row) + 0.5) * pixel_size
|
| 278 |
+
for col in col_values[1:]:
|
| 279 |
+
this_color = int(color_img[row, col])
|
| 280 |
+
if this_color == prev_color:
|
| 281 |
+
prev_col = col
|
| 282 |
+
continue
|
| 283 |
+
if forward:
|
| 284 |
+
start_x = run_start * pixel_size
|
| 285 |
+
end_x = (prev_col + 1) * pixel_size
|
| 286 |
+
else:
|
| 287 |
+
start_x = (run_start + 1) * pixel_size
|
| 288 |
+
end_x = prev_col * pixel_size
|
| 289 |
+
segments.append((start_x, y, end_x, y, prev_color))
|
| 290 |
+
run_start = prev_col = col
|
| 291 |
+
prev_color = this_color
|
| 292 |
+
if forward:
|
| 293 |
+
start_x = run_start * pixel_size
|
| 294 |
+
end_x = (prev_col + 1) * pixel_size
|
| 295 |
+
else:
|
| 296 |
+
start_x = (run_start + 1) * pixel_size
|
| 297 |
+
end_x = prev_col * pixel_size
|
| 298 |
+
segments.append((start_x, y, end_x, y, prev_color))
|
| 299 |
+
return segments
|
| 300 |
+
|
| 301 |
+
|
| 302 |
+
def _build_woodpile_gcode_list(
|
| 303 |
+
path_ref_list: list[np.ndarray],
|
| 304 |
+
color_ref_list: list[np.ndarray],
|
| 305 |
+
pixel_size: float,
|
| 306 |
+
layer_height: float,
|
| 307 |
+
) -> list[dict]:
|
| 308 |
+
gcode_list: list[dict] = []
|
| 309 |
+
current_x = 0.0
|
| 310 |
+
current_y = 0.0
|
| 311 |
+
|
| 312 |
+
for layer_number, (path_img, color_img) in enumerate(zip(path_ref_list, color_ref_list)):
|
| 313 |
+
raster_axis = "X" if layer_number % 2 == 0 else "Y"
|
| 314 |
+
segments = _woodpile_layer_segments(path_img, color_img, pixel_size, raster_axis)
|
| 315 |
+
if not segments:
|
| 316 |
+
if layer_number > 0:
|
| 317 |
+
gcode_list.append({"X": 0.0, "Y": 0.0, "Z": layer_height, "Color": 0})
|
| 318 |
+
continue
|
| 319 |
+
|
| 320 |
+
first_x, first_y = segments[0][0], segments[0][1]
|
| 321 |
+
if layer_number > 0:
|
| 322 |
+
current_x, current_y = _append_relative_move(
|
| 323 |
+
gcode_list,
|
| 324 |
+
current_x,
|
| 325 |
+
current_y,
|
| 326 |
+
first_x,
|
| 327 |
+
first_y,
|
| 328 |
+
0,
|
| 329 |
+
z_step=layer_height,
|
| 330 |
+
)
|
| 331 |
+
else:
|
| 332 |
+
current_x, current_y = _append_relative_move(
|
| 333 |
+
gcode_list,
|
| 334 |
+
current_x,
|
| 335 |
+
current_y,
|
| 336 |
+
first_x,
|
| 337 |
+
first_y,
|
| 338 |
+
0,
|
| 339 |
+
)
|
| 340 |
+
|
| 341 |
+
for start_x, start_y, end_x, end_y, color in segments:
|
| 342 |
+
current_x, current_y = _append_relative_move(
|
| 343 |
+
gcode_list,
|
| 344 |
+
current_x,
|
| 345 |
+
current_y,
|
| 346 |
+
start_x,
|
| 347 |
+
start_y,
|
| 348 |
+
0,
|
| 349 |
+
)
|
| 350 |
+
current_x, current_y = _append_relative_move(
|
| 351 |
+
gcode_list,
|
| 352 |
+
current_x,
|
| 353 |
+
current_y,
|
| 354 |
+
end_x,
|
| 355 |
+
end_y,
|
| 356 |
+
color,
|
| 357 |
+
)
|
| 358 |
+
|
| 359 |
+
return gcode_list
|
| 360 |
+
|
| 361 |
+
|
| 362 |
def generate_snake_path_gcode(
|
| 363 |
zip_path: str | Path,
|
| 364 |
shape_name: str,
|
|
|
|
| 371 |
increase_pressure_per_layer: float = 0.1,
|
| 372 |
all_g1: bool = False,
|
| 373 |
motion_tiffs: list[str] | None = None,
|
| 374 |
+
raster_pattern: str | None = RASTER_PATTERN_SAME_DIRECTION,
|
| 375 |
) -> Path:
|
| 376 |
zip_path = Path(zip_path)
|
| 377 |
if not zip_path.exists():
|
| 378 |
raise FileNotFoundError(f"ZIP file not found: {zip_path}")
|
| 379 |
+
raster_pattern = _normalize_raster_pattern(raster_pattern)
|
| 380 |
|
| 381 |
work_dir = Path(tempfile.mkdtemp(prefix="tiff_gcode_"))
|
| 382 |
extract_dir = work_dir / "tiffs"
|
|
|
|
| 423 |
pressure_on_lines = [_toggle_cmd(com_port, start=True)]
|
| 424 |
pressure_off_lines = [_toggle_cmd(com_port, start=False)]
|
| 425 |
|
| 426 |
+
if raster_pattern == RASTER_PATTERN_WOODPILE:
|
| 427 |
+
gcode_list = _build_woodpile_gcode_list(
|
| 428 |
+
path_ref_list,
|
| 429 |
+
color_ref_list,
|
| 430 |
+
fil_width,
|
| 431 |
+
layer_height,
|
| 432 |
+
)
|
| 433 |
+
else:
|
| 434 |
+
gcode_list: list[dict] = []
|
| 435 |
+
dist_sign_long = 1
|
| 436 |
+
current_offsets_x: list[int] = []
|
| 437 |
+
use_flip_y = False
|
| 438 |
+
direction = -1
|
| 439 |
+
|
| 440 |
+
for layers in range(len(path_ref_list)):
|
| 441 |
+
current_image_ref = path_ref_list[layers]
|
| 442 |
+
last_image_ref = path_ref_list[layers - 1] if layers > 0 else None
|
| 443 |
+
y_ref = current_image_ref.shape[0]
|
| 444 |
+
|
| 445 |
+
def find_first_valid_y(row: np.ndarray | None, flip: bool = False) -> int | None:
|
| 446 |
+
if row is None:
|
| 447 |
+
return None
|
| 448 |
+
row_data = np.flip(row) if flip else row
|
| 449 |
+
for j, pixel in enumerate(row_data):
|
| 450 |
+
if np.any(pixel) != off_color:
|
| 451 |
+
return y_ref - 1 - j if flip else j
|
| 452 |
return None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 453 |
|
| 454 |
+
last_x = last_y = None
|
| 455 |
+
if current_offsets_x:
|
| 456 |
+
use_flip_x = layers % 2 == 1
|
| 457 |
+
last_x = current_offsets_x[-1] if use_flip_x else current_offsets_x[0]
|
| 458 |
+
last_row = (
|
| 459 |
+
last_image_ref[last_x] if last_image_ref is not None else None
|
| 460 |
+
)
|
| 461 |
+
last_y = find_first_valid_y(last_row, flip=use_flip_y)
|
| 462 |
+
current_offsets_x.clear()
|
| 463 |
+
|
| 464 |
+
current_offsets_x = [
|
| 465 |
+
i for i, row in enumerate(current_image_ref) if np.any(row) != off_color
|
| 466 |
+
]
|
| 467 |
+
|
| 468 |
+
first_x = first_y = None
|
| 469 |
+
if current_offsets_x:
|
| 470 |
+
use_flip_x = layers % 2 == 1
|
| 471 |
+
first_x = current_offsets_x[-1] if use_flip_x else current_offsets_x[0]
|
| 472 |
+
first_row = current_image_ref[first_x]
|
| 473 |
+
first_y = find_first_valid_y(first_row, flip=use_flip_y)
|
| 474 |
+
|
| 475 |
+
if None in (last_x, last_y, first_x, first_y):
|
| 476 |
+
shift_x = shift_y = 0
|
| 477 |
+
else:
|
| 478 |
+
shift_x = (first_x - last_x) * fil_width
|
| 479 |
+
shift_y = (first_y - last_y) * fil_width * dist_sign_long
|
| 480 |
+
if use_flip_y:
|
| 481 |
+
shift_y = -shift_y
|
| 482 |
|
| 483 |
+
if len(current_offsets_x) % 2 == 1:
|
| 484 |
+
use_flip_y = not use_flip_y
|
| 485 |
+
|
| 486 |
+
if layers > 0:
|
| 487 |
+
gcode_list.append(
|
| 488 |
+
{"X": shift_y, "Y": shift_x, "Z": layer_height, "Color": 0}
|
| 489 |
+
)
|
| 490 |
|
| 491 |
+
for row in current_image_ref:
|
| 492 |
+
if all(p == off_color for p in row):
|
| 493 |
+
dist_sign_long = -dist_sign_long
|
| 494 |
dist_sign_long = -dist_sign_long
|
|
|
|
| 495 |
|
| 496 |
+
# Flip path and color together on even layers so they stay aligned.
|
| 497 |
+
even_layer = (layers + 1) % 2 == 0
|
| 498 |
+
ref_for_path = (
|
| 499 |
+
np.flipud(current_image_ref) if even_layer else current_image_ref.copy()
|
| 500 |
+
)
|
| 501 |
+
current_image = (
|
| 502 |
+
np.flipud(color_ref_list[layers]) if even_layer else color_ref_list[layers]
|
| 503 |
+
)
|
| 504 |
|
| 505 |
+
if layers == 0:
|
| 506 |
+
direction = -1
|
| 507 |
+
direction = _gcode_layer(
|
| 508 |
+
ref_for_path,
|
| 509 |
+
current_image,
|
| 510 |
+
gcode_list,
|
| 511 |
+
fil_width,
|
| 512 |
+
direction,
|
| 513 |
+
layers,
|
| 514 |
+
)
|
| 515 |
|
| 516 |
gcode_path = work_dir / f"{shape_name}_SnakePath_gcode.txt"
|
| 517 |
pressure_cur = float(pressure)
|