CyGuy8 commited on
Commit
aff1067
·
1 Parent(s): 270d866

Add woodpile raster pattern

Browse files
Files changed (4) hide show
  1. README.md +2 -1
  2. app.py +14 -2
  3. tests/test_tiff_to_gcode.py +51 -1
  4. 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 two G-code generation options: **Use G1 for all moves** (no rapid travel command) and **Use Reference Stack for motion** (all shapes share one nozzle path; each dispenses only its own geometry)
54
  - Calculates X/Y nozzle spacing from an editable adjacent-pair spacing table, then visualizes the resulting nozzle layout
55
  - Previews selected generated G-code inline
56
  - Visualizes generated or uploaded G-code tool paths, with the source selectable from any active generated shape or an uploaded file
@@ -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 generate_snake_path_gcode
 
 
 
 
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
- gcode_list: list[dict] = []
245
- dist_sign_long = 1
246
- current_offsets_x: list[int] = []
247
- use_flip_y = False
248
- direction = -1
249
-
250
- for layers in range(len(path_ref_list)):
251
- current_image_ref = path_ref_list[layers]
252
- last_image_ref = path_ref_list[layers - 1] if layers > 0 else None
253
- y_ref = current_image_ref.shape[0]
254
-
255
- def find_first_valid_y(row: np.ndarray | None, flip: bool = False) -> int | None:
256
- if row is None:
 
 
 
 
 
 
 
 
 
 
 
 
 
257
  return None
258
- row_data = np.flip(row) if flip else row
259
- for j, pixel in enumerate(row_data):
260
- if np.any(pixel) != off_color:
261
- return y_ref - 1 - j if flip else j
262
- return None
263
-
264
- last_x = last_y = None
265
- if current_offsets_x:
266
- use_flip_x = layers % 2 == 1
267
- last_x = current_offsets_x[-1] if use_flip_x else current_offsets_x[0]
268
- last_row = (
269
- last_image_ref[last_x] if last_image_ref is not None else None
270
- )
271
- last_y = find_first_valid_y(last_row, flip=use_flip_y)
272
- current_offsets_x.clear()
273
-
274
- current_offsets_x = [
275
- i for i, row in enumerate(current_image_ref) if np.any(row) != off_color
276
- ]
277
-
278
- first_x = first_y = None
279
- if current_offsets_x:
280
- use_flip_x = layers % 2 == 1
281
- first_x = current_offsets_x[-1] if use_flip_x else current_offsets_x[0]
282
- first_row = current_image_ref[first_x]
283
- first_y = find_first_valid_y(first_row, flip=use_flip_y)
284
-
285
- if None in (last_x, last_y, first_x, first_y):
286
- shift_x = shift_y = 0
287
- else:
288
- shift_x = (first_x - last_x) * fil_width
289
- shift_y = (first_y - last_y) * fil_width * dist_sign_long
290
- if use_flip_y:
291
- shift_y = -shift_y
292
 
293
- if len(current_offsets_x) % 2 == 1:
294
- use_flip_y = not use_flip_y
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
295
 
296
- if layers > 0:
297
- gcode_list.append(
298
- {"X": shift_y, "Y": shift_x, "Z": layer_height, "Color": 0}
299
- )
 
 
 
300
 
301
- for row in current_image_ref:
302
- if all(p == off_color for p in row):
 
303
  dist_sign_long = -dist_sign_long
304
- dist_sign_long = -dist_sign_long
305
 
306
- # Flip path and color together on even layers so they stay aligned.
307
- even_layer = (layers + 1) % 2 == 0
308
- ref_for_path = (
309
- np.flipud(current_image_ref) if even_layer else current_image_ref.copy()
310
- )
311
- current_image = (
312
- np.flipud(color_ref_list[layers]) if even_layer else color_ref_list[layers]
313
- )
314
 
315
- if layers == 0:
316
- direction = -1
317
- direction = _gcode_layer(
318
- ref_for_path,
319
- current_image,
320
- gcode_list,
321
- fil_width,
322
- direction,
323
- layers,
324
- )
325
 
326
  gcode_path = work_dir / f"{shape_name}_SnakePath_gcode.txt"
327
  pressure_cur = float(pressure)
 
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)