CyGuy8 commited on
Commit
06dc0dc
·
1 Parent(s): 1301164

Got rid of the tiff file step

Browse files
Files changed (2) hide show
  1. tests/test_tiff_to_gcode.py +0 -1007
  2. tiff_to_gcode.py +0 -1676
tests/test_tiff_to_gcode.py DELETED
@@ -1,1007 +0,0 @@
1
- from __future__ import annotations
2
-
3
- import math
4
- import zipfile
5
-
6
- import numpy as np
7
- from PIL import Image
8
-
9
- from tiff_to_gcode import (
10
- CONTOUR_MODE_ROW_ENVELOPE,
11
- RASTER_PATTERN_CIRCLE_SPIRAL,
12
- RASTER_PATTERN_RECTANGULAR_SPIRAL,
13
- RASTER_PATTERN_SAME_DIRECTION,
14
- RASTER_PATTERN_WOODPILE,
15
- RASTER_PATTERN_Y_DIRECTION,
16
- _build_contour_layers,
17
- _append_layer_contours,
18
- _circle_spiral_layer_segments,
19
- _circle_spiral_points,
20
- _rectangular_spiral_layer_segments,
21
- _rectangular_spiral_positions,
22
- _trace_mask_contours,
23
- _trace_row_envelope_contours,
24
- generate_snake_path_gcode,
25
- )
26
-
27
-
28
- def _move_signature(gcode_text: str) -> list[tuple[float | None, float | None, float | None]]:
29
- signature: list[tuple[float | None, float | None, float | None]] = []
30
- for line in gcode_text.splitlines():
31
- if not line.startswith(("G0", "G1")):
32
- continue
33
- axes: dict[str, float] = {}
34
- for token in line.split():
35
- if token[:1] in {"X", "Y", "Z"}:
36
- axes[token[0]] = float(token[1:])
37
- signature.append((axes.get("X"), axes.get("Y"), axes.get("Z")))
38
- return signature
39
-
40
-
41
- def _move_endpoints_for_color(gcode_text: str, color: int) -> list[tuple[float, float]]:
42
- x = y = 0.0
43
- endpoints: list[tuple[float, float]] = []
44
- for line in gcode_text.splitlines():
45
- if not line.startswith(("G0", "G1")):
46
- continue
47
- start = (x, y)
48
- for token in line.split():
49
- if token.startswith("X"):
50
- x += float(token[1:])
51
- if token.startswith("Y"):
52
- y += float(token[1:])
53
- if f"; Color {color}" in line:
54
- endpoints.extend([start, (x, y)])
55
- return endpoints
56
-
57
-
58
- def _moves_with_colors(gcode_text: str) -> list[dict]:
59
- x = y = z = 0.0
60
- moves: list[dict] = []
61
- for line in gcode_text.splitlines():
62
- if not line.startswith(("G0", "G1")):
63
- continue
64
- start = (x, y, z)
65
- for token in line.split():
66
- if token.startswith("X"):
67
- x += float(token[1:])
68
- if token.startswith("Y"):
69
- y += float(token[1:])
70
- if token.startswith("Z"):
71
- z += float(token[1:])
72
- color = None
73
- if "; Color " in line:
74
- color = int(line.rsplit("; Color ", 1)[1])
75
- moves.append({"start": start, "end": (x, y, z), "color": color})
76
- return moves
77
-
78
-
79
- def _pressure_set_count(gcode_text: str) -> int:
80
- return gcode_text.count("\\x30\\x38\\x50\\x53") + gcode_text.count("setpress(")
81
-
82
-
83
- def test_trace_mask_contours_uses_tiff_pixel_border_edges() -> None:
84
- contours = _trace_mask_contours(
85
- np.array(
86
- [
87
- [True, True],
88
- [True, True],
89
- ],
90
- dtype=bool,
91
- ),
92
- pixel_size=1.0,
93
- )
94
-
95
- assert contours == [[(0.0, 0.0), (2.0, 0.0), (2.0, 2.0), (0.0, 2.0), (0.0, 0.0)]]
96
-
97
-
98
- def test_trace_row_envelope_contours_follows_left_right_row_extents() -> None:
99
- contours = _trace_row_envelope_contours(
100
- np.array(
101
- [
102
- [False, True, False],
103
- [True, True, True],
104
- ],
105
- dtype=bool,
106
- ),
107
- pixel_size=1.0,
108
- )
109
-
110
- assert contours == [
111
- [
112
- (0.0, 0.0),
113
- (-1.0, 1.0),
114
- (-1.0, 1.5),
115
- (0.0, 1.5),
116
- (1.0, 1.5),
117
- (2.0, 1.5),
118
- (2.0, 1.0),
119
- (1.0, 0.0),
120
- (1.0, -0.5),
121
- (0.0, -0.5),
122
- ]
123
- ]
124
-
125
-
126
- def test_build_contour_layers_can_use_shape_optimized_row_envelope(tmp_path) -> None:
127
- contour_img = np.array(
128
- [
129
- [0, 255, 0],
130
- [255, 255, 255],
131
- ],
132
- dtype=np.uint8,
133
- )
134
- contour_tiff = tmp_path / "contour_slice_0000.tif"
135
- Image.fromarray(contour_img).save(contour_tiff)
136
-
137
- contour_layers = _build_contour_layers(
138
- [
139
- {
140
- "owner_idx": 1,
141
- "contour_mode": CONTOUR_MODE_ROW_ENVELOPE,
142
- "tiff_paths": [str(contour_tiff)],
143
- }
144
- ],
145
- [contour_img],
146
- pixel_size=1.0,
147
- invert=False,
148
- off_color=0,
149
- work_dir=tmp_path,
150
- raster_pattern=RASTER_PATTERN_SAME_DIRECTION,
151
- )
152
-
153
- assert contour_layers[0][0]["contour_mode"] == CONTOUR_MODE_ROW_ENVELOPE
154
- assert contour_layers[0][0]["contours"][0] == [
155
- (1.0, 0.0),
156
- (0.0, 1.0),
157
- (0.0, 1.5),
158
- (1.0, 1.5),
159
- (2.0, 1.5),
160
- (3.0, 1.5),
161
- (3.0, 1.0),
162
- (2.0, 0.0),
163
- (2.0, -0.5),
164
- (1.0, -0.5),
165
- ]
166
-
167
-
168
- def test_contour_tracing_aligns_default_raster_border_pixel_frame(tmp_path) -> None:
169
- raster_tiff = tmp_path / "raster_slice_0000.tif"
170
- raster_image = Image.new("L", (7, 6), 255)
171
- raster_image.putpixel((4, 3), 0)
172
- raster_image.save(raster_tiff)
173
- raster_zip = tmp_path / "raster_slices.zip"
174
- with zipfile.ZipFile(raster_zip, mode="w") as archive:
175
- archive.write(raster_tiff, arcname=raster_tiff.name)
176
-
177
- contour_tiff = tmp_path / "contour_slice_0000.tif"
178
- contour_image = Image.new("L", (7, 6), 255)
179
- contour_image.putpixel((4, 3), 0)
180
- contour_image.save(contour_tiff)
181
-
182
- gcode_path = generate_snake_path_gcode(
183
- raster_zip,
184
- shape_name="aligned_contour",
185
- pressure=25,
186
- valve=7,
187
- port=3,
188
- fil_width=1.0,
189
- all_g1=True,
190
- contour_tiff_sets=[{"owner_idx": 1, "tiff_paths": [str(contour_tiff)]}],
191
- active_contour_owner=1,
192
- )
193
-
194
- points = _move_endpoints_for_color(gcode_path.read_text(), 255)
195
- xs = [point[0] for point in points]
196
- ys = [point[1] for point in points]
197
-
198
- assert (min(xs), max(xs)) == (1.0, 2.0)
199
- assert (min(ys), max(ys)) == (-0.5, 0.5)
200
-
201
-
202
- def test_contour_tracing_travels_to_nearest_border_after_infill(tmp_path) -> None:
203
- tiff_path = tmp_path / "slice_0000.tif"
204
- image = Image.new("L", (7, 6), 255)
205
- image.putpixel((4, 3), 0)
206
- image.save(tiff_path)
207
- zip_path = tmp_path / "slices.zip"
208
- with zipfile.ZipFile(zip_path, mode="w") as archive:
209
- archive.write(tiff_path, arcname=tiff_path.name)
210
-
211
- gcode_path = generate_snake_path_gcode(
212
- zip_path,
213
- shape_name="nearest_border_contour",
214
- pressure=25,
215
- valve=7,
216
- port=3,
217
- fil_width=1.0,
218
- all_g1=True,
219
- contour_tiff_sets=[{"owner_idx": 1, "tiff_paths": [str(tiff_path)]}],
220
- active_contour_owner=1,
221
- )
222
-
223
- moves = _moves_with_colors(gcode_path.read_text())
224
- assert moves[1] == {
225
- "start": (1.0, 0.0, 0.0),
226
- "end": (2.0, 0.0, 0.0),
227
- "color": 255,
228
- }
229
- assert moves[2]["color"] == 255
230
- assert moves[2]["start"] == (2.0, 0.0, 0.0)
231
-
232
-
233
- def test_contour_tracing_anchors_to_expanding_raster_frame(tmp_path) -> None:
234
- tiff_path = tmp_path / "slice_0000.tif"
235
- image = Image.new("L", (7, 5), 255)
236
- for col in [3]:
237
- image.putpixel((col, 0), 0)
238
- for col in range(2, 5):
239
- image.putpixel((col, 1), 0)
240
- for col in range(1, 6):
241
- image.putpixel((col, 2), 0)
242
- image.save(tiff_path)
243
-
244
- zip_path = tmp_path / "slices.zip"
245
- with zipfile.ZipFile(zip_path, mode="w") as archive:
246
- archive.write(tiff_path, arcname=tiff_path.name)
247
-
248
- gcode_path = generate_snake_path_gcode(
249
- zip_path,
250
- shape_name="expanding_contour",
251
- pressure=25,
252
- valve=7,
253
- port=3,
254
- fil_width=1.0,
255
- all_g1=True,
256
- contour_tiff_sets=[{"owner_idx": 1, "tiff_paths": [str(tiff_path)]}],
257
- active_contour_owner=1,
258
- )
259
-
260
- print_moves = [
261
- move for move in _moves_with_colors(gcode_path.read_text()) if move["color"] == 255
262
- ]
263
- contour_points = [
264
- point
265
- for move in print_moves[3:]
266
- for point in (move["start"], move["end"])
267
- ]
268
- xs = [point[0] for point in contour_points]
269
- ys = [point[1] for point in contour_points]
270
-
271
- assert print_moves[:3] == [
272
- {"start": (1.0, 0.0, 0.0), "end": (2.0, 0.0, 0.0), "color": 255},
273
- {"start": (3.0, 1.0, 0.0), "end": (0.0, 1.0, 0.0), "color": 255},
274
- {"start": (-1.0, 2.0, 0.0), "end": (4.0, 2.0, 0.0), "color": 255},
275
- ]
276
- assert print_moves[3]["start"] == (4.0, 2.0, 0.0)
277
- assert (min(xs), max(xs)) == (-1.0, 4.0)
278
- assert (min(ys), max(ys)) == (-0.5, 2.5)
279
-
280
-
281
- def test_contour_tracing_uses_shifted_layer_raster_frame(tmp_path) -> None:
282
- tiff_paths = []
283
- for index, pixel in enumerate([(4, 3), (5, 4)]):
284
- tiff_path = tmp_path / f"slice_{index:04d}.tif"
285
- image = Image.new("L", (8, 7), 255)
286
- image.putpixel(pixel, 0)
287
- image.save(tiff_path)
288
- tiff_paths.append(tiff_path)
289
-
290
- zip_path = tmp_path / "slices.zip"
291
- with zipfile.ZipFile(zip_path, mode="w") as archive:
292
- for tiff_path in tiff_paths:
293
- archive.write(tiff_path, arcname=tiff_path.name)
294
-
295
- gcode_path = generate_snake_path_gcode(
296
- zip_path,
297
- shape_name="shifted_layer_contour",
298
- pressure=25,
299
- valve=7,
300
- port=3,
301
- fil_width=1.0,
302
- layer_height=1.0,
303
- all_g1=True,
304
- contour_tiff_sets=[{"owner_idx": 1, "tiff_paths": [str(p) for p in tiff_paths]}],
305
- active_contour_owner=1,
306
- )
307
-
308
- layer_one_prints = [
309
- move
310
- for move in _moves_with_colors(gcode_path.read_text())
311
- if move["color"] == 255
312
- and move["start"][2] == 1.0
313
- and move["end"][2] == 1.0
314
- ]
315
-
316
- assert layer_one_prints[0] == {
317
- "start": (3.0, 1.0, 1.0),
318
- "end": (2.0, 1.0, 1.0),
319
- "color": 255,
320
- }
321
- contour_points = [
322
- point
323
- for move in layer_one_prints[1:]
324
- for point in (move["start"], move["end"])
325
- ]
326
- xs = [point[0] for point in contour_points]
327
- ys = [point[1] for point in contour_points]
328
-
329
- assert layer_one_prints[1]["start"] == (2.0, 1.0, 1.0)
330
- assert layer_one_prints[1]["end"] == (2.0, 0.5, 1.0)
331
- assert (min(xs), max(xs)) == (2.0, 3.0)
332
- assert (min(ys), max(ys)) == (0.5, 1.5)
333
-
334
-
335
- def test_contour_tracing_mirrors_odd_layer_y_frame(tmp_path) -> None:
336
- tiff_paths = []
337
- for index in range(2):
338
- tiff_path = tmp_path / f"slice_{index:04d}.tif"
339
- image = Image.new("L", (8, 7), 255)
340
- image.putpixel((4, 3), 0)
341
- image.putpixel((4, 4), 0)
342
- image.putpixel((5, 4), 0)
343
- image.save(tiff_path)
344
- tiff_paths.append(tiff_path)
345
-
346
- zip_path = tmp_path / "slices.zip"
347
- with zipfile.ZipFile(zip_path, mode="w") as archive:
348
- for tiff_path in tiff_paths:
349
- archive.write(tiff_path, arcname=tiff_path.name)
350
-
351
- gcode_path = generate_snake_path_gcode(
352
- zip_path,
353
- shape_name="odd_layer_y_contour",
354
- pressure=25,
355
- valve=7,
356
- port=3,
357
- fil_width=1.0,
358
- layer_height=1.0,
359
- all_g1=True,
360
- contour_tiff_sets=[{"owner_idx": 1, "tiff_paths": [str(p) for p in tiff_paths]}],
361
- active_contour_owner=1,
362
- )
363
-
364
- layer_one_prints = [
365
- move
366
- for move in _moves_with_colors(gcode_path.read_text())
367
- if move["color"] == 255
368
- and move["start"][2] == 1.0
369
- and move["end"][2] == 1.0
370
- ]
371
- contour_points = [
372
- point
373
- for move in layer_one_prints[2:]
374
- for point in (move["start"], move["end"])
375
- ]
376
- xs = [point[0] for point in contour_points]
377
- ys = [point[1] for point in contour_points]
378
-
379
- assert layer_one_prints[:2] == [
380
- {"start": (1.0, 1.0, 1.0), "end": (3.0, 1.0, 1.0), "color": 255},
381
- {"start": (2.0, 0.0, 1.0), "end": (1.0, 0.0, 1.0), "color": 255},
382
- ]
383
- assert layer_one_prints[2]["start"] == (1.0, 0.0, 1.0)
384
- assert layer_one_prints[2]["end"] == (1.0, -0.5, 1.0)
385
- assert (min(xs), max(xs)) == (1.0, 3.0)
386
- assert (min(ys), max(ys)) == (-0.5, 1.5)
387
-
388
-
389
- def test_contour_tracing_closes_loop_and_restores_raster_endpoint(tmp_path) -> None:
390
- tiff_paths = []
391
- for index in range(4):
392
- tiff_path = tmp_path / f"slice_{index:04d}.tif"
393
- image = Image.new("L", (8, 8), 255)
394
- image.putpixel((4, 2), 0)
395
- image.putpixel((4, 3), 0)
396
- image.putpixel((5, 4), 0)
397
- image.save(tiff_path)
398
- tiff_paths.append(tiff_path)
399
-
400
- zip_path = tmp_path / "slices.zip"
401
- with zipfile.ZipFile(zip_path, mode="w") as archive:
402
- for tiff_path in tiff_paths:
403
- archive.write(tiff_path, arcname=tiff_path.name)
404
-
405
- gcode_path = generate_snake_path_gcode(
406
- zip_path,
407
- shape_name="odd_layer_last_infill_anchor",
408
- pressure=25,
409
- valve=7,
410
- port=3,
411
- fil_width=1.0,
412
- layer_height=1.0,
413
- all_g1=True,
414
- contour_tiff_sets=[{"owner_idx": 1, "tiff_paths": [str(p) for p in tiff_paths]}],
415
- active_contour_owner=1,
416
- )
417
-
418
- all_moves = _moves_with_colors(gcode_path.read_text())
419
- for layer_z in (1.0, 3.0):
420
- layer_moves = [
421
- move
422
- for move in all_moves
423
- if move["start"][2] == layer_z
424
- and move["end"][2] == layer_z
425
- ]
426
- layer_prints = [
427
- move
428
- for move in layer_moves
429
- if move["color"] == 255
430
- ]
431
-
432
- assert layer_prints[:3] == [
433
- {"start": (3.0, 2.0, layer_z), "end": (2.0, 2.0, layer_z), "color": 255},
434
- {"start": (1.0, 1.0, layer_z), "end": (2.0, 1.0, layer_z), "color": 255},
435
- {"start": (2.0, 0.0, layer_z), "end": (1.0, 0.0, layer_z), "color": 255},
436
- ]
437
- contour_start = layer_prints[2]["end"]
438
- assert layer_prints[3]["start"] == contour_start
439
- last_contour_start = layer_prints[-1]["end"]
440
-
441
- last_print_index = max(
442
- idx for idx, move in enumerate(layer_moves) if move["color"] == 255
443
- )
444
- assert layer_moves[last_print_index + 1] == {
445
- "start": last_contour_start,
446
- "end": (0.0, 0.0, layer_z),
447
- "color": 0,
448
- }
449
-
450
-
451
- def test_contour_tracing_keeps_hollow_rings_separate() -> None:
452
- output = [{"X": 0.0, "Y": 0.0, "Color": 255}]
453
- contour_layers = [
454
- [
455
- {
456
- "owner_idx": 1,
457
- "contours": [
458
- [(0.0, 0.0), (4.0, 0.0), (4.0, 4.0), (0.0, 4.0), (0.0, 0.0)],
459
- [(1.0, 1.0), (2.0, 1.0), (2.0, 2.0), (1.0, 2.0), (1.0, 1.0)],
460
- ],
461
- }
462
- ]
463
- ]
464
-
465
- current_x, current_y = _append_layer_contours(
466
- output,
467
- 0.0,
468
- 0.0,
469
- contour_layers,
470
- layer_number=0,
471
- active_owner_idx=1,
472
- )
473
-
474
- contour_print_moves = [move for move in output[1:] if move["Color"] == 255]
475
-
476
- assert len(contour_print_moves) == 8
477
- assert (current_x, current_y) == (1.0, 1.0)
478
-
479
-
480
- def test_contour_tracing_follows_default_raster_layer_flip(tmp_path) -> None:
481
- tiff_paths = []
482
- motion_img = np.zeros((7, 8), dtype=np.uint8)
483
- motion_img[3, 4] = 255
484
- motion_img[4, 4] = 255
485
- motion_img[4, 5] = 255
486
- for index in range(2):
487
- tiff_path = tmp_path / f"l_shape_{index:04d}.tif"
488
- image = Image.new("L", (8, 7), 255)
489
- image.putpixel((4, 3), 0)
490
- image.putpixel((4, 4), 0)
491
- image.putpixel((5, 4), 0)
492
- image.save(tiff_path)
493
- tiff_paths.append(str(tiff_path))
494
-
495
- contour_layers = _build_contour_layers(
496
- [{"owner_idx": 1, "tiff_paths": tiff_paths}],
497
- [motion_img, motion_img],
498
- pixel_size=1.0,
499
- invert=True,
500
- off_color=0,
501
- work_dir=tmp_path,
502
- raster_pattern=RASTER_PATTERN_SAME_DIRECTION,
503
- )
504
-
505
- assert contour_layers[0][0]["contours"][0] == [
506
- (1.0, -0.5),
507
- (2.0, -0.5),
508
- (2.0, 0.5),
509
- (3.0, 0.5),
510
- (3.0, 1.5),
511
- (1.0, 1.5),
512
- (1.0, -0.5),
513
- ]
514
- assert contour_layers[1][0]["contours"][0] == [
515
- (1.0, -0.5),
516
- (3.0, -0.5),
517
- (3.0, 0.5),
518
- (2.0, 0.5),
519
- (2.0, 1.5),
520
- (1.0, 1.5),
521
- (1.0, -0.5),
522
- ]
523
-
524
-
525
- def test_gcode_header_writes_presets_before_initial_aux_commands(tmp_path) -> None:
526
- tiff_path = tmp_path / "slice_0000.tif"
527
- Image.new("L", (1, 1), 0).save(tiff_path)
528
-
529
- zip_path = tmp_path / "slices.zip"
530
- with zipfile.ZipFile(zip_path, mode="w") as archive:
531
- archive.write(tiff_path, arcname=tiff_path.name)
532
-
533
- gcode_path = generate_snake_path_gcode(
534
- zip_path,
535
- shape_name="header_order",
536
- pressure=25,
537
- valve=7,
538
- port=3,
539
- )
540
-
541
- lines = [
542
- line.strip()
543
- for line in gcode_path.read_text().splitlines()
544
- if line.strip()
545
- ]
546
-
547
- assert lines[0] == "G91"
548
- assert lines[1] == "{aux_command}WAGO_ValveCommands(7, 0)"
549
- assert lines[2] == "serialPort3.write(eval(setpress(25)))"
550
- assert lines[3] == "serialPort3.write(eval(togglepress()))"
551
- assert lines[4].startswith("{aux_command}WAGO_ValveCommands(")
552
- assert lines[5].startswith("{aux_command}WAGO_ValveCommands(")
553
-
554
-
555
- def test_gcode_lead_in_runs_once_before_first_layer(tmp_path) -> None:
556
- tiff_paths = []
557
- for index in range(2):
558
- tiff_path = tmp_path / f"slice_{index:04d}.tif"
559
- Image.new("L", (1, 1), 0).save(tiff_path)
560
- tiff_paths.append(tiff_path)
561
-
562
- zip_path = tmp_path / "slices.zip"
563
- with zipfile.ZipFile(zip_path, mode="w") as archive:
564
- for tiff_path in tiff_paths:
565
- archive.write(tiff_path, arcname=tiff_path.name)
566
-
567
- gcode_path = generate_snake_path_gcode(
568
- zip_path,
569
- shape_name="lead_in",
570
- pressure=25,
571
- valve=7,
572
- port=3,
573
- fil_width=0.5,
574
- layer_height=1.0,
575
- lead_in_enabled=True,
576
- lead_in_length=3.0,
577
- lead_in_clearance=4.0,
578
- lead_in_lines=3,
579
- )
580
-
581
- moves = _moves_with_colors(gcode_path.read_text())
582
-
583
- assert moves[:7] == [
584
- {"start": (0.0, 0.0, 0.0), "end": (-7.0, 0.0, 0.0), "color": 0},
585
- {"start": (-7.0, 0.0, 0.0), "end": (-4.0, 0.0, 0.0), "color": 255},
586
- {"start": (-4.0, 0.0, 0.0), "end": (-4.0, 0.5, 0.0), "color": 0},
587
- {"start": (-4.0, 0.5, 0.0), "end": (-7.0, 0.5, 0.0), "color": 255},
588
- {"start": (-7.0, 0.5, 0.0), "end": (-7.0, 1.0, 0.0), "color": 0},
589
- {"start": (-7.0, 1.0, 0.0), "end": (-4.0, 1.0, 0.0), "color": 255},
590
- {"start": (-4.0, 1.0, 0.0), "end": (0.0, 0.0, 0.0), "color": 0},
591
- ]
592
- assert all(move["end"][2] == 0.0 for move in moves[:7])
593
-
594
- first_z_index = next(index for index, move in enumerate(moves) if move["end"][2] > 0.0)
595
- assert first_z_index > 7
596
- assert not any(move["start"][0] < -3.0 or move["end"][0] < -3.0 for move in moves[first_z_index:])
597
-
598
-
599
- def test_gcode_pressure_ramp_can_be_disabled(tmp_path) -> None:
600
- tiff_paths = []
601
- for index in range(2):
602
- tiff_path = tmp_path / f"slice_{index:04d}.tif"
603
- Image.new("L", (1, 1), 0).save(tiff_path)
604
- tiff_paths.append(tiff_path)
605
-
606
- zip_path = tmp_path / "slices.zip"
607
- with zipfile.ZipFile(zip_path, mode="w") as archive:
608
- for tiff_path in tiff_paths:
609
- archive.write(tiff_path, arcname=tiff_path.name)
610
-
611
- ramped_path = generate_snake_path_gcode(
612
- zip_path,
613
- shape_name="pressure_ramped",
614
- pressure=25,
615
- valve=7,
616
- port=3,
617
- layer_height=1.0,
618
- pressure_ramp_enabled=True,
619
- )
620
- fixed_path = generate_snake_path_gcode(
621
- zip_path,
622
- shape_name="pressure_fixed",
623
- pressure=25,
624
- valve=7,
625
- port=3,
626
- layer_height=1.0,
627
- pressure_ramp_enabled=False,
628
- )
629
-
630
- assert _pressure_set_count(ramped_path.read_text()) > 1
631
- assert _pressure_set_count(fixed_path.read_text()) == 1
632
-
633
-
634
- def test_gcode_uses_g1_for_print_and_g0_for_travel(tmp_path) -> None:
635
- tiff_path = tmp_path / "slice_0000.tif"
636
- Image.new("L", (1, 1), 0).save(tiff_path)
637
-
638
- zip_path = tmp_path / "slices.zip"
639
- with zipfile.ZipFile(zip_path, mode="w") as archive:
640
- archive.write(tiff_path, arcname=tiff_path.name)
641
-
642
- gcode_path = generate_snake_path_gcode(
643
- zip_path,
644
- shape_name="move_types",
645
- pressure=25,
646
- valve=7,
647
- port=3,
648
- )
649
-
650
- move_lines = [
651
- line.strip()
652
- for line in gcode_path.read_text().splitlines()
653
- if line.startswith(("G0", "G1"))
654
- ]
655
-
656
- assert any(line.startswith("G1") and "; Color 255" in line for line in move_lines)
657
- assert all(not line.startswith("G0") for line in move_lines if "; Color 255" in line)
658
- assert all(not line.startswith("G1") for line in move_lines if "; Color 0" in line)
659
-
660
-
661
- def test_woodpile_raster_switches_print_axis_between_layers(tmp_path) -> None:
662
- tiff_paths = []
663
- for index in range(4):
664
- tiff_path = tmp_path / f"slice_{index:04d}.tif"
665
- Image.new("L", (3, 2), 0).save(tiff_path)
666
- tiff_paths.append(tiff_path)
667
-
668
- zip_path = tmp_path / "slices.zip"
669
- with zipfile.ZipFile(zip_path, mode="w") as archive:
670
- for tiff_path in tiff_paths:
671
- archive.write(tiff_path, arcname=tiff_path.name)
672
-
673
- gcode_path = generate_snake_path_gcode(
674
- zip_path,
675
- shape_name="woodpile",
676
- pressure=25,
677
- valve=7,
678
- port=3,
679
- fil_width=1.0,
680
- raster_pattern=RASTER_PATTERN_WOODPILE,
681
- )
682
-
683
- gcode_text = gcode_path.read_text()
684
- move_lines = [
685
- line.strip()
686
- for line in gcode_text.splitlines()
687
- if line.startswith(("G0", "G1"))
688
- ]
689
- z_move_index = next(i for i, line in enumerate(move_lines) if " Z" in line)
690
- first_layer_prints = [line for line in move_lines[:z_move_index] if line.startswith("G1") and "; Color 255" in line]
691
- second_layer_prints = [line for line in move_lines[z_move_index + 1 :] if line.startswith("G1") and "; Color 255" in line]
692
-
693
- assert move_lines[0] == "G0 X1.0 Y0.0 ; Color 0"
694
- assert any("X" in line and "Y0" in line for line in first_layer_prints)
695
- assert any("X0" in line and "Y" in line for line in second_layer_prints)
696
-
697
- x = y = 0.0
698
- x_positions = [x]
699
- y_positions = [y]
700
- for line in move_lines:
701
- for token in line.split():
702
- if token.startswith("X"):
703
- x += float(token[1:])
704
- if token.startswith("Y"):
705
- y += float(token[1:])
706
- x_positions.append(x)
707
- y_positions.append(y)
708
- assert min(x_positions) == 0.0
709
- assert max(x_positions) == 5.0
710
- assert min(y_positions) == -1.5
711
- assert max(y_positions) == 2.5
712
-
713
- moves = _moves_with_colors(gcode_text)
714
- first_layer_change = next(
715
- move for move in moves if move["end"][2] > move["start"][2]
716
- )
717
- previous_xy = first_layer_change["start"][:2]
718
- actual_restart_xy = first_layer_change["end"][:2]
719
- old_restart_xy = (1.5, -1.5)
720
- actual_restart_distance = (
721
- (previous_xy[0] - actual_restart_xy[0]) ** 2
722
- + (previous_xy[1] - actual_restart_xy[1]) ** 2
723
- )
724
- old_restart_distance = (
725
- (previous_xy[0] - old_restart_xy[0]) ** 2
726
- + (previous_xy[1] - old_restart_xy[1]) ** 2
727
- )
728
- assert actual_restart_xy != old_restart_xy
729
- assert actual_restart_distance < old_restart_distance
730
-
731
-
732
- def test_rectangular_spiral_positions_walk_edge_to_center() -> None:
733
- assert _rectangular_spiral_positions(0, 2, 0, 3) == [
734
- (0, 0),
735
- (0, 1),
736
- (0, 2),
737
- (0, 3),
738
- (1, 3),
739
- (2, 3),
740
- (2, 2),
741
- (2, 1),
742
- (2, 0),
743
- (1, 0),
744
- (1, 1),
745
- (1, 2),
746
- ]
747
-
748
-
749
- def test_rectangular_spiral_segments_can_reverse_center_to_edge() -> None:
750
- path_img = np.full((3, 3), 255, dtype=np.uint8)
751
- color_img = np.full((3, 3), 255, dtype=np.uint8)
752
-
753
- inward = _rectangular_spiral_layer_segments(path_img, color_img, 1.0)
754
- outward = _rectangular_spiral_layer_segments(
755
- path_img,
756
- color_img,
757
- 1.0,
758
- reverse=True,
759
- )
760
-
761
- assert inward[0] == (0.0, 0.5, 2.5, 0.5, 255)
762
- assert inward[-1] == (0.5, 1.5, 2.0, 1.5, 255)
763
- assert outward[0][:2] == inward[-1][2:4]
764
- assert outward[-1][2:4] == inward[0][:2]
765
-
766
-
767
- def test_rectangular_spiral_raster_reverses_between_layers(tmp_path) -> None:
768
- tiff_paths = []
769
- for index in range(2):
770
- tiff_path = tmp_path / f"slice_{index:04d}.tif"
771
- Image.new("L", (3, 3), 0).save(tiff_path)
772
- tiff_paths.append(tiff_path)
773
-
774
- zip_path = tmp_path / "slices.zip"
775
- with zipfile.ZipFile(zip_path, mode="w") as archive:
776
- for tiff_path in tiff_paths:
777
- archive.write(tiff_path, arcname=tiff_path.name)
778
-
779
- gcode_path = generate_snake_path_gcode(
780
- zip_path,
781
- shape_name="rectangular_spiral",
782
- pressure=25,
783
- valve=7,
784
- port=3,
785
- fil_width=1.0,
786
- layer_height=1.0,
787
- raster_pattern=RASTER_PATTERN_RECTANGULAR_SPIRAL,
788
- )
789
-
790
- moves = _moves_with_colors(gcode_path.read_text())
791
- first_layer_change = next(
792
- move for move in moves if move["end"][2] > move["start"][2]
793
- )
794
-
795
- assert first_layer_change["start"][:2] == first_layer_change["end"][:2]
796
- end_x, end_y, end_z = moves[-1]["end"]
797
- assert abs(end_x) < 1e-9
798
- assert abs(end_y) < 1e-9
799
- assert end_z == 1.0
800
-
801
-
802
- def test_circle_spiral_points_decrease_radius_to_center() -> None:
803
- points = _circle_spiral_points(2.0, 3.0, outer_radius=4.0, pitch=1.0)
804
- radii = [math.hypot(x - 2.0, y - 3.0) for x, y in points]
805
-
806
- assert radii[0] == 4.0
807
- assert radii[-1] == 0.0
808
- assert all(
809
- current <= previous + 1e-9
810
- for previous, current in zip(radii, radii[1:])
811
- )
812
-
813
-
814
- def test_circle_spiral_segments_can_reverse_center_to_edge() -> None:
815
- path_img = np.full((5, 5), 255, dtype=np.uint8)
816
- color_img = np.full((5, 5), 255, dtype=np.uint8)
817
-
818
- inward = _circle_spiral_layer_segments(path_img, color_img, 1.0)
819
- outward = _circle_spiral_layer_segments(
820
- path_img,
821
- color_img,
822
- 1.0,
823
- reverse=True,
824
- )
825
-
826
- assert inward
827
- assert outward
828
- assert outward[0][:2] == inward[-1][2:4]
829
- assert outward[-1][2:4] == inward[0][:2]
830
-
831
-
832
- def test_circle_spiral_raster_reverses_between_layers(tmp_path) -> None:
833
- tiff_paths = []
834
- for index in range(2):
835
- tiff_path = tmp_path / f"slice_{index:04d}.tif"
836
- Image.new("L", (5, 5), 0).save(tiff_path)
837
- tiff_paths.append(tiff_path)
838
-
839
- zip_path = tmp_path / "slices.zip"
840
- with zipfile.ZipFile(zip_path, mode="w") as archive:
841
- for tiff_path in tiff_paths:
842
- archive.write(tiff_path, arcname=tiff_path.name)
843
-
844
- gcode_path = generate_snake_path_gcode(
845
- zip_path,
846
- shape_name="circle_spiral",
847
- pressure=25,
848
- valve=7,
849
- port=3,
850
- fil_width=1.0,
851
- layer_height=1.0,
852
- raster_pattern=RASTER_PATTERN_CIRCLE_SPIRAL,
853
- )
854
-
855
- moves = _moves_with_colors(gcode_path.read_text())
856
- first_layer_change = next(
857
- move for move in moves if move["end"][2] > move["start"][2]
858
- )
859
-
860
- assert first_layer_change["start"][:2] == first_layer_change["end"][:2]
861
- end_x, end_y, end_z = moves[-1]["end"]
862
- assert abs(end_x) < 1e-9
863
- assert abs(end_y) < 1e-9
864
- assert end_z == 1.0
865
-
866
-
867
- def test_y_direction_raster_prints_each_layer_along_y_axis(tmp_path) -> None:
868
- tiff_paths = []
869
- for index in range(2):
870
- tiff_path = tmp_path / f"slice_{index:04d}.tif"
871
- Image.new("L", (3, 2), 0).save(tiff_path)
872
- tiff_paths.append(tiff_path)
873
-
874
- zip_path = tmp_path / "slices.zip"
875
- with zipfile.ZipFile(zip_path, mode="w") as archive:
876
- for tiff_path in tiff_paths:
877
- archive.write(tiff_path, arcname=tiff_path.name)
878
-
879
- gcode_path = generate_snake_path_gcode(
880
- zip_path,
881
- shape_name="y_direction",
882
- pressure=25,
883
- valve=7,
884
- port=3,
885
- fil_width=1.0,
886
- raster_pattern=RASTER_PATTERN_Y_DIRECTION,
887
- )
888
-
889
- gcode_text = gcode_path.read_text()
890
- move_lines = [
891
- line.strip()
892
- for line in gcode_text.splitlines()
893
- if line.startswith(("G0", "G1"))
894
- ]
895
- print_lines = [
896
- line
897
- for line in move_lines
898
- if line.startswith("G1") and "; Color 255" in line
899
- ]
900
- assert print_lines
901
- assert move_lines[0] == "G0 X0.0 Y1.0 ; Color 0"
902
- assert all("X0" in line and "Y0" not in line for line in print_lines)
903
-
904
- x = y = 0.0
905
- x_positions = [x]
906
- y_positions = [y]
907
- for line in move_lines:
908
- for token in line.split():
909
- if token.startswith("X"):
910
- x += float(token[1:])
911
- if token.startswith("Y"):
912
- y += float(token[1:])
913
- x_positions.append(x)
914
- y_positions.append(y)
915
- assert min(x_positions) == 0.0
916
- assert max(x_positions) == 2.0
917
- assert min(y_positions) == 0.0
918
- assert max(y_positions) == 4.0
919
-
920
- moves = _moves_with_colors(gcode_text)
921
- first_layer_change = next(
922
- move for move in moves if move["end"][2] > move["start"][2]
923
- )
924
- assert first_layer_change["start"][:2] == first_layer_change["end"][:2]
925
-
926
-
927
- def test_contour_tracing_skips_inactive_nozzle_outline(tmp_path) -> None:
928
- blank_tiff = tmp_path / "blank_slice_0000.tif"
929
- Image.new("L", (1, 1), 255).save(blank_tiff)
930
- blank_zip = tmp_path / "blank_slices.zip"
931
- with zipfile.ZipFile(blank_zip, mode="w") as archive:
932
- archive.write(blank_tiff, arcname=blank_tiff.name)
933
-
934
- contour_tiff = tmp_path / "contour_slice_0000.tif"
935
- Image.new("L", (1, 1), 0).save(contour_tiff)
936
- contour_sources = [{"owner_idx": 1, "tiff_paths": [str(contour_tiff)]}]
937
-
938
- active_path = generate_snake_path_gcode(
939
- blank_zip,
940
- shape_name="active_contour",
941
- pressure=25,
942
- valve=7,
943
- port=3,
944
- all_g1=True,
945
- contour_tiff_sets=contour_sources,
946
- active_contour_owner=1,
947
- )
948
- inactive_path = generate_snake_path_gcode(
949
- blank_zip,
950
- shape_name="inactive_contour",
951
- pressure=25,
952
- valve=7,
953
- port=3,
954
- all_g1=True,
955
- contour_tiff_sets=contour_sources,
956
- active_contour_owner=2,
957
- )
958
-
959
- active_text = active_path.read_text()
960
- inactive_text = inactive_path.read_text()
961
-
962
- assert _move_signature(active_text)
963
- assert _move_signature(inactive_text) == []
964
- assert any(
965
- line.startswith("G1") and "; Color 255" in line
966
- for line in active_text.splitlines()
967
- )
968
- assert not any("; Color 255" in line for line in inactive_text.splitlines())
969
-
970
-
971
- def test_inactive_contour_tracing_preserves_original_raster_moves(tmp_path) -> None:
972
- tiff_paths = []
973
- for index in range(2):
974
- tiff_path = tmp_path / f"slice_{index:04d}.tif"
975
- image = Image.new("L", (4, 3), 255)
976
- image.putpixel((1, 1), 0)
977
- image.putpixel((2, 1), 0)
978
- image.save(tiff_path)
979
- tiff_paths.append(tiff_path)
980
-
981
- zip_path = tmp_path / "slices.zip"
982
- with zipfile.ZipFile(zip_path, mode="w") as archive:
983
- for tiff_path in tiff_paths:
984
- archive.write(tiff_path, arcname=tiff_path.name)
985
-
986
- original_path = generate_snake_path_gcode(
987
- zip_path,
988
- shape_name="original_raster",
989
- pressure=25,
990
- valve=7,
991
- port=3,
992
- all_g1=True,
993
- )
994
- inactive_path = generate_snake_path_gcode(
995
- zip_path,
996
- shape_name="inactive_contour_raster",
997
- pressure=25,
998
- valve=7,
999
- port=3,
1000
- all_g1=True,
1001
- contour_tiff_sets=[{"owner_idx": 2, "tiff_paths": [str(p) for p in tiff_paths]}],
1002
- active_contour_owner=1,
1003
- )
1004
-
1005
- assert _move_signature(inactive_path.read_text()) == _move_signature(
1006
- original_path.read_text()
1007
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
tiff_to_gcode.py DELETED
@@ -1,1676 +0,0 @@
1
- from __future__ import annotations
2
-
3
- import math
4
- import os
5
- import tempfile
6
- import zipfile
7
- from collections import defaultdict
8
- from codecs import encode
9
- from pathlib import Path
10
- from textwrap import wrap
11
-
12
- import numpy as np
13
- from PIL import Image
14
-
15
-
16
- RASTER_PATTERN_SAME_DIRECTION = "X-direction raster"
17
- RASTER_PATTERN_Y_DIRECTION = "Y-direction raster"
18
- RASTER_PATTERN_WOODPILE = "Woodpile raster"
19
- RASTER_PATTERN_RECTANGULAR_SPIRAL = "Rectangular Spiral raster"
20
- RASTER_PATTERN_CIRCLE_SPIRAL = "Circle Spiral raster"
21
- RASTER_PATTERN_CHOICES = (
22
- RASTER_PATTERN_SAME_DIRECTION,
23
- RASTER_PATTERN_Y_DIRECTION,
24
- RASTER_PATTERN_WOODPILE,
25
- RASTER_PATTERN_RECTANGULAR_SPIRAL,
26
- RASTER_PATTERN_CIRCLE_SPIRAL,
27
- )
28
- CONTOUR_MODE_EXACT = "Exact pixel border"
29
- CONTOUR_MODE_ROW_ENVELOPE = "Shape-optimized row envelope"
30
- CONTOUR_MODE_CHOICES = (
31
- CONTOUR_MODE_EXACT,
32
- CONTOUR_MODE_ROW_ENVELOPE,
33
- )
34
-
35
- def _normalize_raster_pattern(pattern: str | None) -> str:
36
- if pattern == RASTER_PATTERN_CIRCLE_SPIRAL:
37
- return RASTER_PATTERN_CIRCLE_SPIRAL
38
- if pattern == RASTER_PATTERN_RECTANGULAR_SPIRAL:
39
- return RASTER_PATTERN_RECTANGULAR_SPIRAL
40
- if pattern == RASTER_PATTERN_WOODPILE:
41
- return RASTER_PATTERN_WOODPILE
42
- if pattern == RASTER_PATTERN_Y_DIRECTION:
43
- return RASTER_PATTERN_Y_DIRECTION
44
- return RASTER_PATTERN_SAME_DIRECTION
45
-
46
-
47
- def _normalize_contour_mode(mode: str | None) -> str:
48
- if mode == CONTOUR_MODE_ROW_ENVELOPE:
49
- return CONTOUR_MODE_ROW_ENVELOPE
50
- return CONTOUR_MODE_EXACT
51
-
52
-
53
- def _setpress(pressure: float) -> str:
54
- pressure_str = str(int(pressure * 10)).zfill(4)
55
- command_bytes = bytes("08PS " + pressure_str, "utf-8")
56
- hex_command = encode(command_bytes, "hex").decode("utf-8")
57
- format_command = "\\x" + "\\x".join(
58
- hex_command[i : i + 2] for i in range(0, len(hex_command), 2)
59
- )
60
-
61
- hex_pairs = wrap(hex_command, 2)
62
- decimal_sum = sum(int(pair, 16) for pair in hex_pairs)
63
- checksum_bin = bin(decimal_sum % 256)[2:].zfill(8)
64
- inverted = int("".join("1" if c == "0" else "0" for c in checksum_bin), 2) + 1
65
- checksum_hex = hex(inverted)[2:].upper()
66
- format_checksum = "\\x" + "\\x".join(
67
- checksum_hex[i : i + 2] for i in range(0, len(checksum_hex), 2)
68
- )
69
-
70
- return "b'" + "\\x05\\x02" + format_command + format_checksum + "\\x03" + "'"
71
-
72
-
73
- def _togglepress() -> str:
74
- return "b'\\x05\\x02\\x30\\x34\\x44\\x49\\x20\\x20\\x43\\x46\\x03'"
75
-
76
-
77
- def _setpress_cmd(port: str, pressure: float, start: bool) -> str:
78
- if start:
79
- return f"\n\r{port}.write(eval(setpress({pressure:g})))"
80
- insert = ""
81
- return f"\n\r{insert}{port}.write({_setpress(pressure)})"
82
-
83
-
84
- def _toggle_cmd(port: str, start: bool) -> str:
85
- if start:
86
- return f"\n\r{port}.write(eval(togglepress()))"
87
- insert = ""
88
- return f"\n\r{insert}{port}.write({_togglepress()})"
89
-
90
-
91
- def _valve_cmd(valve: int, command: int) -> str:
92
- return f"\n{{aux_command}}WAGO_ValveCommands({valve}, {command})\n"
93
-
94
-
95
- def _gcode_layer(
96
- path_img: np.ndarray,
97
- color_img: np.ndarray,
98
- output_list: list[dict],
99
- pixel_size: float,
100
- direction: int,
101
- layer_number: int,
102
- ) -> int:
103
- mask = path_img > 0
104
- first_nonblack = np.where(mask.any(axis=1), mask.argmax(axis=1), -1)
105
- last_nonblack = np.where(
106
- mask.any(axis=1),
107
- mask.shape[1] - 1 - np.fliplr(mask).argmax(axis=1),
108
- -1,
109
- )
110
-
111
- stored_gcode: list[dict] = []
112
- nonblank_rows = np.where(first_nonblack != -1)[0]
113
-
114
- for idx, i in enumerate(nonblank_rows):
115
- f_idx, l_idx = int(first_nonblack[i]), int(last_nonblack[i])
116
- if f_idx == -1:
117
- continue
118
-
119
- if direction < 0:
120
- rng = range(f_idx, l_idx + 1)
121
- else:
122
- rng = range(l_idx, f_idx - 1, -1)
123
- direction *= -1
124
-
125
- prev_color = None
126
- color_len = 0
127
- buffer = direction
128
- stored_gcode.append({"X": buffer * pixel_size, "Y": 0, "Color": 0})
129
-
130
- for j in rng:
131
- this_color = int(color_img[i, j])
132
- if prev_color is None:
133
- prev_color = this_color
134
- color_len = 1
135
- elif this_color == prev_color:
136
- color_len += 1
137
- else:
138
- stored_gcode.append(
139
- {
140
- "X": direction * color_len * pixel_size,
141
- "Y": 0,
142
- "Color": prev_color,
143
- }
144
- )
145
- color_len = 1
146
- prev_color = this_color
147
-
148
- if color_len > 0:
149
- stored_gcode.append(
150
- {
151
- "X": direction * color_len * pixel_size,
152
- "Y": 0,
153
- "Color": prev_color,
154
- }
155
- )
156
-
157
- stored_gcode.append({"X": buffer * pixel_size, "Y": 0, "Color": 0})
158
-
159
- curr_x = l_idx if direction > 0 else f_idx
160
- curr_x += buffer
161
-
162
- if idx + 1 < len(nonblank_rows):
163
- next_i = int(nonblank_rows[idx + 1])
164
- y_travel_dist = next_i - int(i)
165
- nf, nl = int(first_nonblack[next_i]), int(last_nonblack[next_i])
166
- if nf == -1:
167
- continue
168
- next_start = nf if direction < 0 else nl
169
- travel_x = (next_start + buffer) - curr_x
170
- y_dir = -1 if layer_number % 2 == 1 else 1
171
- stored_gcode.append(
172
- {
173
- "X": travel_x * pixel_size,
174
- "Y": y_travel_dist * pixel_size * y_dir,
175
- "Color": 0,
176
- }
177
- )
178
-
179
- output_list.extend(stored_gcode)
180
- return direction
181
-
182
-
183
- def _sort_key(filename: str) -> int:
184
- digits = "".join(filter(str.isdigit, filename))
185
- return int(digits) if digits else 2**31
186
-
187
-
188
- def _lead_in_moves(
189
- enabled: bool,
190
- length: float,
191
- clearance: float,
192
- line_count: int,
193
- line_spacing: float,
194
- print_color: int,
195
- off_color: int,
196
- ) -> list[dict]:
197
- if not enabled:
198
- return []
199
- lead_length = max(0.0, float(length))
200
- if lead_length <= 0.0:
201
- return []
202
- lead_clearance = max(0.0, float(clearance))
203
- pass_count = max(1, int(line_count))
204
- spacing = max(0.0, float(line_spacing))
205
-
206
- moves: list[dict] = []
207
- current_x = 0.0
208
- current_y = 0.0
209
-
210
- def append_move(dx: float, dy: float, color: int) -> None:
211
- nonlocal current_x, current_y
212
- if dx == 0.0 and dy == 0.0:
213
- return
214
- moves.append({"X": dx, "Y": dy, "Color": color})
215
- current_x += dx
216
- current_y += dy
217
-
218
- append_move(-(lead_clearance + lead_length), 0.0, off_color)
219
- direction = 1.0
220
- for pass_index in range(pass_count):
221
- append_move(direction * lead_length, 0.0, print_color)
222
- direction *= -1.0
223
- if pass_index < pass_count - 1:
224
- append_move(0.0, spacing, off_color)
225
- append_move(-current_x, -current_y, off_color)
226
- return moves
227
-
228
-
229
- def _extract_zip_tiffs(zip_path: Path, dest: Path) -> list[Path]:
230
- with zipfile.ZipFile(zip_path) as archive:
231
- archive.extractall(dest)
232
-
233
- tiffs: list[Path] = []
234
- for root, _, files in os.walk(dest):
235
- for name in files:
236
- if name.lower().endswith((".tif", ".tiff")):
237
- tiffs.append(Path(root) / name)
238
- tiffs.sort(key=lambda p: _sort_key(p.name))
239
- return tiffs
240
-
241
-
242
- def _load_grayscale(path: Path, invert: bool) -> np.ndarray:
243
- with Image.open(path) as image:
244
- array = np.array(image.convert("L"), dtype=np.uint8)
245
- if invert:
246
- array = 255 - array
247
- return array
248
-
249
-
250
- def _center_on_canvas(
251
- img: np.ndarray, canvas_h: int, canvas_w: int, fill: int = 0
252
- ) -> np.ndarray:
253
- """Place `img` centred on a (canvas_h, canvas_w) canvas filled with `fill`.
254
-
255
- Mirrors the centring used to build the reference stack, so a shape's slice
256
- lines up pixel-for-pixel with the reference (motion) slice of the same layer.
257
- """
258
- h, w = img.shape[:2]
259
- out = np.full((canvas_h, canvas_w), fill, dtype=img.dtype)
260
- y_off = max(0, (canvas_h - h) // 2)
261
- x_off = max(0, (canvas_w - w) // 2)
262
- out[y_off : y_off + h, x_off : x_off + w] = img[: canvas_h, : canvas_w]
263
- return out
264
-
265
-
266
- def _simplify_closed_contour(
267
- points: list[tuple[int, int]],
268
- ) -> list[tuple[int, int]]:
269
- if len(points) < 4:
270
- return points
271
- if points[0] != points[-1]:
272
- points = [*points, points[0]]
273
-
274
- ring = points[:-1]
275
- simplified: list[tuple[int, int]] = []
276
- for idx, point in enumerate(ring):
277
- prev_point = ring[idx - 1]
278
- next_point = ring[(idx + 1) % len(ring)]
279
- dx1 = point[0] - prev_point[0]
280
- dy1 = point[1] - prev_point[1]
281
- dx2 = next_point[0] - point[0]
282
- dy2 = next_point[1] - point[1]
283
- if dx1 * dy2 == dy1 * dx2:
284
- continue
285
- simplified.append(point)
286
-
287
- if len(simplified) < 3:
288
- simplified = ring
289
- simplified.append(simplified[0])
290
- return simplified
291
-
292
-
293
- def _contour_area2(points: list[tuple[float, float]]) -> float:
294
- return sum(
295
- x0 * y1 - x1 * y0
296
- for (x0, y0), (x1, y1) in zip(points, points[1:])
297
- )
298
-
299
-
300
- def _contour_sort_key(points: list[tuple[float, float]]) -> tuple[float, float, float]:
301
- xs = [point[0] for point in points]
302
- ys = [point[1] for point in points]
303
- return (-abs(_contour_area2(points)), min(ys), min(xs))
304
-
305
-
306
- def _trace_mask_contours(
307
- mask: np.ndarray,
308
- pixel_size: float,
309
- x_offset_px: float = 0.0,
310
- y_offset_px: float = 0.0,
311
- ) -> list[list[tuple[float, float]]]:
312
- if not np.any(mask):
313
- return []
314
-
315
- mask = mask.astype(bool)
316
- segments: list[tuple[tuple[int, int], tuple[int, int]]] = []
317
- height, width = mask.shape
318
-
319
- def is_on(row: int, col: int) -> bool:
320
- return 0 <= row < height and 0 <= col < width and bool(mask[row, col])
321
-
322
- for row in range(height):
323
- for col in range(width):
324
- if not mask[row, col]:
325
- continue
326
- if not is_on(row - 1, col):
327
- segments.append(((col, row), (col + 1, row)))
328
- if not is_on(row, col + 1):
329
- segments.append(((col + 1, row), (col + 1, row + 1)))
330
- if not is_on(row + 1, col):
331
- segments.append(((col + 1, row + 1), (col, row + 1)))
332
- if not is_on(row, col - 1):
333
- segments.append(((col, row + 1), (col, row)))
334
-
335
- outgoing: dict[tuple[int, int], list[tuple[int, int]]] = defaultdict(list)
336
- for start, end in segments:
337
- outgoing[start].append(end)
338
-
339
- contours: list[list[tuple[float, float]]] = []
340
- remaining = set(segments)
341
- while remaining:
342
- start, end = min(remaining)
343
- remaining.remove((start, end))
344
- contour = [start, end]
345
- current = end
346
-
347
- while current != start:
348
- next_point = next(
349
- (
350
- candidate
351
- for candidate in outgoing.get(current, [])
352
- if (current, candidate) in remaining
353
- ),
354
- None,
355
- )
356
- if next_point is None:
357
- break
358
- remaining.remove((current, next_point))
359
- current = next_point
360
- contour.append(current)
361
-
362
- if len(contour) > 3 and contour[-1] == contour[0]:
363
- simplified = _simplify_closed_contour(contour)
364
- contours.append(
365
- [
366
- ((x + x_offset_px) * pixel_size, (y + y_offset_px) * pixel_size)
367
- for x, y in simplified
368
- ]
369
- )
370
-
371
- contours.sort(key=_contour_sort_key)
372
- return contours
373
-
374
-
375
- def _trace_row_envelope_contours(
376
- mask: np.ndarray,
377
- pixel_size: float,
378
- x_offset_px: float = 0.0,
379
- y_offset_px: float = 0.0,
380
- ) -> list[list[tuple[float, float]]]:
381
- """Trace one outside contour using each active row's left/right extent."""
382
- if not np.any(mask):
383
- return []
384
-
385
- mask = mask.astype(bool)
386
- first = np.where(mask.any(axis=1), mask.argmax(axis=1), -1)
387
- last = np.where(
388
- mask.any(axis=1),
389
- mask.shape[1] - 1 - np.fliplr(mask).argmax(axis=1),
390
- -1,
391
- )
392
- rows = np.where(first != -1)[0]
393
- if len(rows) == 0:
394
- return []
395
-
396
- points: list[tuple[float, float]] = []
397
-
398
- for row in rows:
399
- points.append((float(first[row] - 1), float(row)))
400
-
401
- bottom = int(rows[-1])
402
- for col in range(int(first[bottom]) - 1, int(last[bottom]) + 1):
403
- points.append((float(col), float(bottom) + 0.5))
404
-
405
- for row in rows[::-1]:
406
- points.append((float(last[row]), float(row)))
407
-
408
- top = int(rows[0])
409
- for col in range(int(last[top]), int(first[top]) - 2, -1):
410
- points.append((float(col), float(top) - 0.5))
411
-
412
- return [
413
- [
414
- ((x + x_offset_px) * pixel_size, (y + y_offset_px) * pixel_size)
415
- for x, y in points
416
- ]
417
- ]
418
-
419
-
420
- def _append_relative_move(
421
- output_list: list[dict],
422
- current_x: float,
423
- current_y: float,
424
- target_x: float,
425
- target_y: float,
426
- color: int,
427
- z_step: float | None = None,
428
- ) -> tuple[float, float]:
429
- dx = target_x - current_x
430
- dy = target_y - current_y
431
- if dx == 0 and dy == 0 and z_step is None:
432
- return current_x, current_y
433
- move = {"X": dx, "Y": dy, "Color": color}
434
- if z_step is not None:
435
- move["Z"] = z_step
436
- output_list.append(move)
437
- return target_x, target_y
438
-
439
-
440
- def _point_distance_sq(
441
- ax: float,
442
- ay: float,
443
- bx: float,
444
- by: float,
445
- ) -> float:
446
- return (ax - bx) ** 2 + (ay - by) ** 2
447
-
448
-
449
- def _closest_point_on_segment(
450
- px: float,
451
- py: float,
452
- ax: float,
453
- ay: float,
454
- bx: float,
455
- by: float,
456
- ) -> tuple[float, float, float]:
457
- dx = bx - ax
458
- dy = by - ay
459
- length_sq = dx * dx + dy * dy
460
- if length_sq == 0:
461
- return ax, ay, 0.0
462
- t = ((px - ax) * dx + (py - ay) * dy) / length_sq
463
- t = max(0.0, min(1.0, t))
464
- return ax + t * dx, ay + t * dy, t
465
-
466
-
467
- def _rotate_closed_contour_to_nearest_border(
468
- contour: list[tuple[float, float]],
469
- current_x: float,
470
- current_y: float,
471
- approach_dx: float = 0.0,
472
- approach_dy: float = 0.0,
473
- ) -> list[tuple[float, float]]:
474
- if len(contour) < 3:
475
- return contour
476
-
477
- ring = contour[:-1] if contour[0] == contour[-1] else contour
478
- if len(ring) < 2:
479
- return contour
480
-
481
- best_idx = 0
482
- best_t = 0.0
483
- best_point = ring[0]
484
- best_dist = float("inf")
485
- for idx, (ax, ay) in enumerate(ring):
486
- bx, by = ring[(idx + 1) % len(ring)]
487
- point_x, point_y, t = _closest_point_on_segment(
488
- current_x,
489
- current_y,
490
- ax,
491
- ay,
492
- bx,
493
- by,
494
- )
495
- distance = _point_distance_sq(current_x, current_y, point_x, point_y)
496
- if distance < best_dist:
497
- best_dist = distance
498
- best_idx = idx
499
- best_t = t
500
- best_point = (point_x, point_y)
501
-
502
- eps = 1e-9
503
- def choose_direction(
504
- forward: list[tuple[float, float]],
505
- reverse: list[tuple[float, float]],
506
- ) -> list[tuple[float, float]]:
507
- if (
508
- len(forward) < 2
509
- or len(reverse) < 2
510
- or (approach_dx == 0 and approach_dy == 0)
511
- ):
512
- return forward
513
-
514
- def score(candidate: list[tuple[float, float]]) -> float:
515
- tx = candidate[1][0] - candidate[0][0]
516
- ty = candidate[1][1] - candidate[0][1]
517
- # Positive when the shape interior, opposite the approach vector,
518
- # is to the left of the contour's first move.
519
- return (ty * approach_dx) - (tx * approach_dy)
520
-
521
- return reverse if score(reverse) > score(forward) else forward
522
-
523
- if best_t <= eps:
524
- forward = ring[best_idx:] + ring[:best_idx] + [ring[best_idx]]
525
- reverse_ring = list(reversed(ring))
526
- reverse_idx = reverse_ring.index(ring[best_idx])
527
- reverse = (
528
- reverse_ring[reverse_idx:]
529
- + reverse_ring[:reverse_idx]
530
- + [reverse_ring[reverse_idx]]
531
- )
532
- return choose_direction(forward, reverse)
533
-
534
- next_idx = (best_idx + 1) % len(ring)
535
- if best_t >= 1.0 - eps:
536
- forward = ring[next_idx:] + ring[:next_idx] + [ring[next_idx]]
537
- reverse_ring = list(reversed(ring))
538
- reverse_idx = reverse_ring.index(ring[next_idx])
539
- reverse = (
540
- reverse_ring[reverse_idx:]
541
- + reverse_ring[:reverse_idx]
542
- + [reverse_ring[reverse_idx]]
543
- )
544
- return choose_direction(forward, reverse)
545
-
546
- forward = [best_point]
547
- for step in range(1, len(ring) + 1):
548
- forward.append(ring[(best_idx + step) % len(ring)])
549
- forward.append(best_point)
550
-
551
- reverse = [best_point]
552
- for step in range(0, len(ring)):
553
- reverse.append(ring[(best_idx - step) % len(ring)])
554
- reverse.append(best_point)
555
- return choose_direction(forward, reverse)
556
-
557
-
558
- def _last_print_reference(output_list: list[dict]) -> tuple[float, float, float, float]:
559
- x = y = 0.0
560
- last_x = last_y = 0.0
561
- last_dx = last_dy = 0.0
562
- for move in output_list:
563
- dx = float(move.get("X", 0.0))
564
- dy = float(move.get("Y", 0.0))
565
- x += dx
566
- y += dy
567
- if move.get("Color") == 255 and "Z" not in move:
568
- last_x = x
569
- last_y = y
570
- last_dx = dx
571
- last_dy = dy
572
- return last_x, last_y, last_dx, last_dy
573
-
574
-
575
- def _rewind_trailing_travel(
576
- output_list: list[dict],
577
- current_x: float,
578
- current_y: float,
579
- ) -> tuple[float, float]:
580
- if not output_list:
581
- return current_x, current_y
582
-
583
- last_move = output_list[-1]
584
- if last_move.get("Color") != 0 or "Z" in last_move:
585
- return current_x, current_y
586
-
587
- has_layer_print = any(
588
- move.get("Color") == 255 and "Z" not in move
589
- for move in reversed(output_list[:-1])
590
- )
591
- if not has_layer_print:
592
- return current_x, current_y
593
-
594
- output_list.pop()
595
- return (
596
- current_x - float(last_move.get("X", 0.0)),
597
- current_y - float(last_move.get("Y", 0.0)),
598
- )
599
-
600
-
601
- def _contour_source_paths(source: dict, extract_root: Path, source_pos: int) -> list[Path]:
602
- paths = source.get("tiff_paths") or source.get("paths") or []
603
- if paths:
604
- return sorted((Path(path) for path in paths), key=lambda path: _sort_key(path.name))
605
-
606
- zip_path = source.get("zip_path")
607
- if not zip_path:
608
- return []
609
- extract_dir = extract_root / f"source_{source_pos:03d}"
610
- extract_dir.mkdir(parents=True, exist_ok=True)
611
- return _extract_zip_tiffs(Path(zip_path), extract_dir)
612
-
613
-
614
- def _active_pixel_bounds(mask: np.ndarray) -> tuple[int, int, int, int] | None:
615
- rows, cols = np.where(mask)
616
- if len(rows) == 0:
617
- return None
618
- return int(rows.min()), int(rows.max()), int(cols.min()), int(cols.max())
619
-
620
-
621
- def _first_raster_row_start(mask: np.ndarray) -> tuple[int, int] | None:
622
- rows = np.where(mask.any(axis=1))[0]
623
- if len(rows) == 0:
624
- return None
625
- row = int(rows[0])
626
- cols = np.where(mask[row])[0]
627
- if len(cols) == 0:
628
- return None
629
- return row, int(cols[0])
630
-
631
-
632
- def _contour_pixel_offsets(
633
- raster_pattern: str,
634
- motion_mask: np.ndarray,
635
- contour_mask: np.ndarray,
636
- contour_mode: str = CONTOUR_MODE_EXACT,
637
- ) -> tuple[float, float]:
638
- if raster_pattern == RASTER_PATTERN_SAME_DIRECTION:
639
- first_row_start = _first_raster_row_start(motion_mask)
640
- if first_row_start is None:
641
- first_row_start = _first_raster_row_start(contour_mask)
642
- if first_row_start is None:
643
- return 0.0, 0.0
644
- first_row, first_col = first_row_start
645
- # The legacy X-raster infill path anchors every row to the first
646
- # rastered row's starting pixel. Wider lower rows can extend left of
647
- # that point, so using the layer-wide min column shifts pyramid-like
648
- # contours away from the infill.
649
- x_offset, y_offset = 1.0 - first_col, -0.5 - first_row
650
- else:
651
- x_offset, y_offset = 0.0, 0.0
652
-
653
- if contour_mode == CONTOUR_MODE_ROW_ENVELOPE:
654
- x_offset += 1.0
655
- y_offset += 0.5
656
- return x_offset, y_offset
657
-
658
-
659
- def _build_contour_layers(
660
- contour_tiff_sets: list[dict] | None,
661
- path_ref_list: list[np.ndarray],
662
- pixel_size: float,
663
- invert: bool,
664
- off_color: int,
665
- work_dir: Path,
666
- raster_pattern: str,
667
- layer_start_directions: list[int] | None = None,
668
- ) -> list[list[dict]]:
669
- contour_layers: list[list[dict]] = [[] for _ in path_ref_list]
670
- if not contour_tiff_sets:
671
- return contour_layers
672
-
673
- extract_root = work_dir / "contour_sources"
674
- for source_pos, source in enumerate(contour_tiff_sets):
675
- try:
676
- owner_idx = int(source.get("owner_idx", source.get("idx", source_pos + 1)))
677
- except (TypeError, ValueError):
678
- owner_idx = source_pos + 1
679
-
680
- contour_mode = _normalize_contour_mode(
681
- source.get("contour_mode") or source.get("mode")
682
- )
683
- source_paths = _contour_source_paths(source, extract_root, source_pos)
684
- for layer_number, path_img in enumerate(path_ref_list):
685
- if layer_number >= len(source_paths):
686
- continue
687
- canvas_h, canvas_w = path_img.shape[:2]
688
- contour_img = _load_grayscale(source_paths[layer_number], invert=invert)
689
- if contour_img.shape[:2] != (canvas_h, canvas_w):
690
- contour_img = _center_on_canvas(
691
- contour_img,
692
- canvas_h,
693
- canvas_w,
694
- fill=off_color,
695
- )
696
- contour_path_img = path_img
697
- if raster_pattern == RASTER_PATTERN_SAME_DIRECTION and layer_number % 2 == 1:
698
- contour_path_img = np.flipud(contour_path_img)
699
- contour_img = np.flipud(contour_img)
700
- if (
701
- raster_pattern == RASTER_PATTERN_SAME_DIRECTION
702
- and layer_start_directions is not None
703
- and layer_number < len(layer_start_directions)
704
- and layer_start_directions[layer_number] > 0
705
- ):
706
- contour_path_img = np.fliplr(contour_path_img)
707
- contour_img = np.fliplr(contour_img)
708
-
709
- contour_mask = contour_img > off_color
710
- x_offset_px, y_offset_px = _contour_pixel_offsets(
711
- raster_pattern,
712
- contour_path_img > off_color,
713
- contour_mask,
714
- contour_mode,
715
- )
716
- if contour_mode == CONTOUR_MODE_ROW_ENVELOPE:
717
- contours = _trace_row_envelope_contours(
718
- contour_mask,
719
- pixel_size,
720
- x_offset_px=x_offset_px,
721
- y_offset_px=y_offset_px,
722
- )
723
- else:
724
- contours = _trace_mask_contours(
725
- contour_mask,
726
- pixel_size,
727
- x_offset_px=x_offset_px,
728
- y_offset_px=y_offset_px,
729
- )
730
- if contours:
731
- contour_layers[layer_number].append(
732
- {
733
- "owner_idx": owner_idx,
734
- "contour_mode": contour_mode,
735
- "contours": contours,
736
- }
737
- )
738
-
739
- return contour_layers
740
-
741
-
742
- def _same_direction_layer_start_directions(
743
- path_ref_list: list[np.ndarray],
744
- off_color: int,
745
- ) -> list[int]:
746
- directions: list[int] = []
747
- direction = -1
748
- for path_img in path_ref_list:
749
- directions.append(direction)
750
- nonblank_rows = int(np.count_nonzero((path_img > off_color).any(axis=1)))
751
- if nonblank_rows % 2 == 1:
752
- direction *= -1
753
- return directions
754
-
755
-
756
- def _append_layer_contours(
757
- output_list: list[dict],
758
- current_x: float,
759
- current_y: float,
760
- contour_layers: list[list[dict]],
761
- layer_number: int,
762
- active_owner_idx: int | None,
763
- origin_x: float = 0.0,
764
- origin_y: float = 0.0,
765
- x_scale: float = 1.0,
766
- y_scale: float = 1.0,
767
- ) -> tuple[float, float]:
768
- if layer_number >= len(contour_layers):
769
- return current_x, current_y
770
-
771
- active_sources = [
772
- source
773
- for source in contour_layers[layer_number]
774
- if source.get("owner_idx") == active_owner_idx
775
- and any(len(contour) >= 2 for contour in source.get("contours", []))
776
- ]
777
- if not active_sources:
778
- return current_x, current_y
779
-
780
- current_x, current_y = _rewind_trailing_travel(
781
- output_list,
782
- current_x,
783
- current_y,
784
- )
785
-
786
- use_infill_reference = True
787
-
788
- for source in active_sources:
789
- color = 255
790
- for contour in source.get("contours", []):
791
- if len(contour) < 2:
792
- continue
793
- contour = [
794
- (origin_x + (x_scale * x), origin_y + (y_scale * y))
795
- for x, y in contour
796
- ]
797
- if use_infill_reference:
798
- nearest_x, nearest_y, approach_dx, approach_dy = _last_print_reference(
799
- output_list
800
- )
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 _woodpile_layer_segments(
837
- path_img: np.ndarray,
838
- color_img: np.ndarray,
839
- pixel_size: float,
840
- raster_axis: str,
841
- reverse_order: bool = False,
842
- start_forward: bool = True,
843
- ) -> list[tuple[float, float, float, float, int]]:
844
- mask = path_img > 0
845
- segments: list[tuple[float, float, float, float, int]] = []
846
-
847
- if raster_axis == "Y":
848
- first_nonblank = np.where(mask.any(axis=0), mask.argmax(axis=0), -1)
849
- last_nonblank = np.where(
850
- mask.any(axis=0),
851
- mask.shape[0] - 1 - np.flipud(mask).argmax(axis=0),
852
- -1,
853
- )
854
- columns = list(np.where(first_nonblank != -1)[0])
855
- if reverse_order:
856
- columns.reverse()
857
- for col_number, col in enumerate(columns):
858
- f_idx, l_idx = int(first_nonblank[col]), int(last_nonblank[col])
859
- if f_idx == -1:
860
- continue
861
- forward = (col_number % 2 == 0) == start_forward
862
- row_values = list(range(f_idx, l_idx + 1)) if forward else list(range(l_idx, f_idx - 1, -1))
863
- run_start = row_values[0]
864
- prev_row = row_values[0]
865
- prev_color = int(color_img[prev_row, col])
866
- x = (int(col) + 0.5) * pixel_size
867
- if forward:
868
- sweep_start_y = f_idx * pixel_size
869
- segments.append(
870
- (x, sweep_start_y - pixel_size, x, sweep_start_y, 0)
871
- )
872
- else:
873
- sweep_start_y = (l_idx + 1) * pixel_size
874
- segments.append(
875
- (x, sweep_start_y + pixel_size, x, sweep_start_y, 0)
876
- )
877
- for row in row_values[1:]:
878
- this_color = int(color_img[row, col])
879
- if this_color == prev_color:
880
- prev_row = row
881
- continue
882
- if forward:
883
- start_y = run_start * pixel_size
884
- end_y = (prev_row + 1) * pixel_size
885
- else:
886
- start_y = (run_start + 1) * pixel_size
887
- end_y = prev_row * pixel_size
888
- segments.append((x, start_y, x, end_y, prev_color))
889
- run_start = prev_row = row
890
- prev_color = this_color
891
- if forward:
892
- start_y = run_start * pixel_size
893
- end_y = (prev_row + 1) * pixel_size
894
- else:
895
- start_y = (run_start + 1) * pixel_size
896
- end_y = prev_row * pixel_size
897
- segments.append((x, start_y, x, end_y, prev_color))
898
- if forward:
899
- segments.append((x, end_y, x, end_y + pixel_size, 0))
900
- else:
901
- segments.append((x, end_y, x, end_y - pixel_size, 0))
902
- return segments
903
-
904
- first_nonblank = np.where(mask.any(axis=1), mask.argmax(axis=1), -1)
905
- last_nonblank = np.where(
906
- mask.any(axis=1),
907
- mask.shape[1] - 1 - np.fliplr(mask).argmax(axis=1),
908
- -1,
909
- )
910
- rows = list(np.where(first_nonblank != -1)[0])
911
- if reverse_order:
912
- rows.reverse()
913
- for row_number, row in enumerate(rows):
914
- f_idx, l_idx = int(first_nonblank[row]), int(last_nonblank[row])
915
- if f_idx == -1:
916
- continue
917
- forward = (row_number % 2 == 0) == start_forward
918
- col_values = list(range(f_idx, l_idx + 1)) if forward else list(range(l_idx, f_idx - 1, -1))
919
- run_start = col_values[0]
920
- prev_col = col_values[0]
921
- prev_color = int(color_img[row, prev_col])
922
- y = (int(row) + 0.5) * pixel_size
923
- if forward:
924
- sweep_start_x = f_idx * pixel_size
925
- segments.append((sweep_start_x - pixel_size, y, sweep_start_x, y, 0))
926
- else:
927
- sweep_start_x = (l_idx + 1) * pixel_size
928
- segments.append((sweep_start_x + pixel_size, y, sweep_start_x, y, 0))
929
- for col in col_values[1:]:
930
- this_color = int(color_img[row, col])
931
- if this_color == prev_color:
932
- prev_col = col
933
- continue
934
- if forward:
935
- start_x = run_start * pixel_size
936
- end_x = (prev_col + 1) * pixel_size
937
- else:
938
- start_x = (run_start + 1) * pixel_size
939
- end_x = prev_col * pixel_size
940
- segments.append((start_x, y, end_x, y, prev_color))
941
- run_start = prev_col = col
942
- prev_color = this_color
943
- if forward:
944
- start_x = run_start * pixel_size
945
- end_x = (prev_col + 1) * pixel_size
946
- else:
947
- start_x = (run_start + 1) * pixel_size
948
- end_x = prev_col * pixel_size
949
- segments.append((start_x, y, end_x, y, prev_color))
950
- if forward:
951
- segments.append((end_x, y, end_x + pixel_size, y, 0))
952
- else:
953
- segments.append((end_x, y, end_x - pixel_size, y, 0))
954
- return segments
955
-
956
-
957
- def _raster_axis_for_pattern(pattern: str, layer_number: int) -> str:
958
- if pattern == RASTER_PATTERN_Y_DIRECTION:
959
- return "Y"
960
- if pattern == RASTER_PATTERN_WOODPILE and layer_number % 2 == 1:
961
- return "Y"
962
- return "X"
963
-
964
-
965
- def _oriented_woodpile_layer_segments(
966
- path_img: np.ndarray,
967
- color_img: np.ndarray,
968
- pixel_size: float,
969
- raster_axis: str,
970
- current_x: float,
971
- current_y: float,
972
- prefer_default: bool = False,
973
- ) -> list[tuple[float, float, float, float, int]]:
974
- default_segments = _woodpile_layer_segments(
975
- path_img,
976
- color_img,
977
- pixel_size,
978
- raster_axis,
979
- )
980
- if prefer_default or not default_segments:
981
- return default_segments
982
-
983
- candidates: list[list[tuple[float, float, float, float, int]]] = [default_segments]
984
- for reverse_order in (False, True):
985
- for start_forward in (False, True):
986
- segments = _woodpile_layer_segments(
987
- path_img,
988
- color_img,
989
- pixel_size,
990
- raster_axis,
991
- reverse_order=reverse_order,
992
- start_forward=start_forward,
993
- )
994
- if segments and segments not in candidates:
995
- candidates.append(segments)
996
-
997
- return min(
998
- candidates,
999
- key=lambda segments: _point_distance_sq(
1000
- current_x,
1001
- current_y,
1002
- segments[0][0],
1003
- segments[0][1],
1004
- ),
1005
- )
1006
-
1007
-
1008
- def _rectangular_spiral_positions(
1009
- top: int,
1010
- bottom: int,
1011
- left: int,
1012
- right: int,
1013
- ) -> list[tuple[int, int]]:
1014
- positions: list[tuple[int, int]] = []
1015
- while top <= bottom and left <= right:
1016
- for col in range(left, right + 1):
1017
- positions.append((top, col))
1018
- top += 1
1019
-
1020
- for row in range(top, bottom + 1):
1021
- positions.append((row, right))
1022
- right -= 1
1023
-
1024
- if top <= bottom:
1025
- for col in range(right, left - 1, -1):
1026
- positions.append((bottom, col))
1027
- bottom -= 1
1028
-
1029
- if left <= right:
1030
- for row in range(bottom, top - 1, -1):
1031
- positions.append((row, left))
1032
- left += 1
1033
-
1034
- return positions
1035
-
1036
-
1037
- def _append_colored_segment(
1038
- segments: list[tuple[float, float, float, float, int]],
1039
- start_x: float,
1040
- start_y: float,
1041
- end_x: float,
1042
- end_y: float,
1043
- color: int,
1044
- ) -> None:
1045
- if start_x == end_x and start_y == end_y:
1046
- return
1047
-
1048
- if segments:
1049
- prev_start_x, prev_start_y, prev_end_x, prev_end_y, prev_color = segments[-1]
1050
- if (
1051
- prev_color == color
1052
- and prev_end_x == start_x
1053
- and prev_end_y == start_y
1054
- ):
1055
- prev_dx = prev_end_x - prev_start_x
1056
- prev_dy = prev_end_y - prev_start_y
1057
- next_dx = end_x - start_x
1058
- next_dy = end_y - start_y
1059
- if abs((prev_dx * next_dy) - (prev_dy * next_dx)) < 1e-9:
1060
- segments[-1] = (
1061
- prev_start_x,
1062
- prev_start_y,
1063
- end_x,
1064
- end_y,
1065
- color,
1066
- )
1067
- return
1068
-
1069
- segments.append((start_x, start_y, end_x, end_y, color))
1070
-
1071
-
1072
- def _rectangular_spiral_layer_segments(
1073
- path_img: np.ndarray,
1074
- color_img: np.ndarray,
1075
- pixel_size: float,
1076
- reverse: bool = False,
1077
- ) -> list[tuple[float, float, float, float, int]]:
1078
- bounds = _active_pixel_bounds(path_img > 0)
1079
- if bounds is None:
1080
- return []
1081
-
1082
- top, bottom, left, right = bounds
1083
- positions = _rectangular_spiral_positions(top, bottom, left, right)
1084
- if reverse:
1085
- positions.reverse()
1086
- if not positions:
1087
- return []
1088
-
1089
- def center(row: int, col: int) -> tuple[float, float]:
1090
- return (float(col) + 0.5) * pixel_size, (float(row) + 0.5) * pixel_size
1091
-
1092
- def pixel_color(row: int, col: int) -> int:
1093
- return int(color_img[row, col])
1094
-
1095
- segments: list[tuple[float, float, float, float, int]] = []
1096
- if len(positions) == 1:
1097
- row, col = positions[0]
1098
- y = (float(row) + 0.5) * pixel_size
1099
- _append_colored_segment(
1100
- segments,
1101
- float(col) * pixel_size,
1102
- y,
1103
- (float(col) + 1.0) * pixel_size,
1104
- y,
1105
- pixel_color(row, col),
1106
- )
1107
- return segments
1108
-
1109
- centers = [center(row, col) for row, col in positions]
1110
- first_dx = centers[1][0] - centers[0][0]
1111
- first_dy = centers[1][1] - centers[0][1]
1112
- last_dx = centers[-1][0] - centers[-2][0]
1113
- last_dy = centers[-1][1] - centers[-2][1]
1114
-
1115
- start_x = centers[0][0] - (first_dx * 0.5)
1116
- start_y = centers[0][1] - (first_dy * 0.5)
1117
- first_row, first_col = positions[0]
1118
- _append_colored_segment(
1119
- segments,
1120
- start_x,
1121
- start_y,
1122
- centers[0][0],
1123
- centers[0][1],
1124
- pixel_color(first_row, first_col),
1125
- )
1126
-
1127
- for idx, ((row, col), (next_row, next_col)) in enumerate(
1128
- zip(positions, positions[1:])
1129
- ):
1130
- start_center_x, start_center_y = centers[idx]
1131
- end_center_x, end_center_y = centers[idx + 1]
1132
- mid_x = (start_center_x + end_center_x) / 2.0
1133
- mid_y = (start_center_y + end_center_y) / 2.0
1134
- _append_colored_segment(
1135
- segments,
1136
- start_center_x,
1137
- start_center_y,
1138
- mid_x,
1139
- mid_y,
1140
- pixel_color(row, col),
1141
- )
1142
- _append_colored_segment(
1143
- segments,
1144
- mid_x,
1145
- mid_y,
1146
- end_center_x,
1147
- end_center_y,
1148
- pixel_color(next_row, next_col),
1149
- )
1150
-
1151
- last_row, last_col = positions[-1]
1152
- end_x = centers[-1][0] + (last_dx * 0.5)
1153
- end_y = centers[-1][1] + (last_dy * 0.5)
1154
- _append_colored_segment(
1155
- segments,
1156
- centers[-1][0],
1157
- centers[-1][1],
1158
- end_x,
1159
- end_y,
1160
- pixel_color(last_row, last_col),
1161
- )
1162
- return segments
1163
-
1164
-
1165
- def _circle_spiral_points(
1166
- center_x: float,
1167
- center_y: float,
1168
- outer_radius: float,
1169
- pitch: float,
1170
- ) -> list[tuple[float, float]]:
1171
- if outer_radius <= 0.0:
1172
- return [(center_x, center_y)]
1173
-
1174
- pitch = max(float(pitch), 1e-9)
1175
- sample_spacing = pitch
1176
- theta_max = (outer_radius / pitch) * 2.0 * math.pi
1177
- theta = 0.0
1178
- points = [(center_x + outer_radius, center_y)]
1179
-
1180
- while theta < theta_max:
1181
- radius = max(outer_radius - (pitch * theta / (2.0 * math.pi)), 0.0)
1182
- d_theta = min(math.pi / 10.0, sample_spacing / max(radius, pitch))
1183
- theta = min(theta + d_theta, theta_max)
1184
- radius = max(outer_radius - (pitch * theta / (2.0 * math.pi)), 0.0)
1185
- points.append(
1186
- (
1187
- center_x + (radius * math.cos(theta)),
1188
- center_y + (radius * math.sin(theta)),
1189
- )
1190
- )
1191
-
1192
- if points[-1] != (center_x, center_y):
1193
- points.append((center_x, center_y))
1194
- return points
1195
-
1196
-
1197
- def _circle_spiral_layer_segments(
1198
- path_img: np.ndarray,
1199
- color_img: np.ndarray,
1200
- pixel_size: float,
1201
- reverse: bool = False,
1202
- ) -> list[tuple[float, float, float, float, int]]:
1203
- bounds = _active_pixel_bounds(path_img > 0)
1204
- if bounds is None:
1205
- return []
1206
-
1207
- top, bottom, left, right = bounds
1208
- left_x = float(left) * pixel_size
1209
- right_x = float(right + 1) * pixel_size
1210
- top_y = float(top) * pixel_size
1211
- bottom_y = float(bottom + 1) * pixel_size
1212
- center_x = (left_x + right_x) / 2.0
1213
- center_y = (top_y + bottom_y) / 2.0
1214
- outer_radius = max(
1215
- math.hypot(corner_x - center_x, corner_y - center_y)
1216
- for corner_x, corner_y in (
1217
- (left_x, top_y),
1218
- (right_x, top_y),
1219
- (right_x, bottom_y),
1220
- (left_x, bottom_y),
1221
- )
1222
- )
1223
-
1224
- points = _circle_spiral_points(center_x, center_y, outer_radius, pixel_size)
1225
- if reverse:
1226
- points.reverse()
1227
-
1228
- height, width = color_img.shape[:2]
1229
-
1230
- def point_color(x: float, y: float) -> int:
1231
- col = int(math.floor(x / pixel_size))
1232
- row = int(math.floor(y / pixel_size))
1233
- if row < 0 or row >= height or col < 0 or col >= width:
1234
- return 0
1235
- return int(color_img[row, col])
1236
-
1237
- segments: list[tuple[float, float, float, float, int]] = []
1238
- for (start_x, start_y), (end_x, end_y) in zip(points, points[1:]):
1239
- mid_x = (start_x + end_x) / 2.0
1240
- mid_y = (start_y + end_y) / 2.0
1241
- _append_colored_segment(
1242
- segments,
1243
- start_x,
1244
- start_y,
1245
- end_x,
1246
- end_y,
1247
- point_color(mid_x, mid_y),
1248
- )
1249
- return segments
1250
-
1251
-
1252
- def _build_footprint_raster_gcode_list(
1253
- path_ref_list: list[np.ndarray],
1254
- color_ref_list: list[np.ndarray],
1255
- pixel_size: float,
1256
- layer_height: float,
1257
- raster_pattern: str,
1258
- contour_layers: list[list[dict]] | None = None,
1259
- active_contour_owner: int | None = None,
1260
- ) -> list[dict]:
1261
- gcode_list: list[dict] = []
1262
- current_x = 0.0
1263
- current_y = 0.0
1264
- raster_origin_initialized = False
1265
- contour_layers = contour_layers or []
1266
-
1267
- for layer_number, (path_img, color_img) in enumerate(zip(path_ref_list, color_ref_list)):
1268
- if raster_pattern == RASTER_PATTERN_CIRCLE_SPIRAL:
1269
- segments = _circle_spiral_layer_segments(
1270
- path_img,
1271
- color_img,
1272
- pixel_size,
1273
- reverse=layer_number % 2 == 1,
1274
- )
1275
- elif raster_pattern == RASTER_PATTERN_RECTANGULAR_SPIRAL:
1276
- segments = _rectangular_spiral_layer_segments(
1277
- path_img,
1278
- color_img,
1279
- pixel_size,
1280
- reverse=layer_number % 2 == 1,
1281
- )
1282
- else:
1283
- raster_axis = _raster_axis_for_pattern(raster_pattern, layer_number)
1284
- segments = _oriented_woodpile_layer_segments(
1285
- path_img,
1286
- color_img,
1287
- pixel_size,
1288
- raster_axis,
1289
- current_x,
1290
- current_y,
1291
- prefer_default=not raster_origin_initialized,
1292
- )
1293
- if not segments:
1294
- if layer_number > 0:
1295
- gcode_list.append({"X": 0.0, "Y": 0.0, "Z": layer_height, "Color": 0})
1296
- layer_end_x, layer_end_y = current_x, current_y
1297
- current_x, current_y = _append_layer_contours(
1298
- gcode_list,
1299
- current_x,
1300
- current_y,
1301
- contour_layers,
1302
- layer_number,
1303
- active_contour_owner,
1304
- )
1305
- current_x, current_y = _append_relative_move(
1306
- gcode_list,
1307
- current_x,
1308
- current_y,
1309
- layer_end_x,
1310
- layer_end_y,
1311
- 0,
1312
- )
1313
- continue
1314
-
1315
- first_x, first_y = segments[0][0], segments[0][1]
1316
- if not raster_origin_initialized:
1317
- if layer_number > 0:
1318
- current_x, current_y = _append_relative_move(
1319
- gcode_list,
1320
- current_x,
1321
- current_y,
1322
- current_x,
1323
- current_y,
1324
- 0,
1325
- z_step=layer_height,
1326
- )
1327
- current_x, current_y = first_x, first_y
1328
- raster_origin_initialized = True
1329
- elif layer_number > 0:
1330
- current_x, current_y = _append_relative_move(
1331
- gcode_list,
1332
- current_x,
1333
- current_y,
1334
- first_x,
1335
- first_y,
1336
- 0,
1337
- z_step=layer_height,
1338
- )
1339
- else:
1340
- current_x, current_y = _append_relative_move(
1341
- gcode_list,
1342
- current_x,
1343
- current_y,
1344
- first_x,
1345
- first_y,
1346
- 0,
1347
- )
1348
-
1349
- for start_x, start_y, end_x, end_y, color in segments:
1350
- current_x, current_y = _append_relative_move(
1351
- gcode_list,
1352
- current_x,
1353
- current_y,
1354
- start_x,
1355
- start_y,
1356
- 0,
1357
- )
1358
- current_x, current_y = _append_relative_move(
1359
- gcode_list,
1360
- current_x,
1361
- current_y,
1362
- end_x,
1363
- end_y,
1364
- color,
1365
- )
1366
-
1367
- layer_end_x, layer_end_y = current_x, current_y
1368
- current_x, current_y = _append_layer_contours(
1369
- gcode_list,
1370
- current_x,
1371
- current_y,
1372
- contour_layers,
1373
- layer_number,
1374
- active_contour_owner,
1375
- )
1376
- current_x, current_y = _append_relative_move(
1377
- gcode_list,
1378
- current_x,
1379
- current_y,
1380
- layer_end_x,
1381
- layer_end_y,
1382
- 0,
1383
- )
1384
-
1385
- return gcode_list
1386
-
1387
-
1388
- def generate_snake_path_gcode(
1389
- zip_path: str | Path,
1390
- shape_name: str,
1391
- pressure: float,
1392
- valve: int,
1393
- port: int,
1394
- layer_height: float = 0.8,
1395
- fil_width: float = 0.8,
1396
- invert: bool = True,
1397
- increase_pressure_per_layer: float = 0.1,
1398
- pressure_ramp_enabled: bool = True,
1399
- all_g1: bool = False,
1400
- motion_tiffs: list[str] | None = None,
1401
- raster_pattern: str | None = RASTER_PATTERN_SAME_DIRECTION,
1402
- contour_tiff_sets: list[dict] | None = None,
1403
- active_contour_owner: int | None = None,
1404
- lead_in_enabled: bool = False,
1405
- lead_in_length: float = 5.0,
1406
- lead_in_clearance: float = 5.0,
1407
- lead_in_lines: int = 3,
1408
- ) -> Path:
1409
- zip_path = Path(zip_path)
1410
- if not zip_path.exists():
1411
- raise FileNotFoundError(f"ZIP file not found: {zip_path}")
1412
- raster_pattern = _normalize_raster_pattern(raster_pattern)
1413
-
1414
- work_dir = Path(tempfile.mkdtemp(prefix="tiff_gcode_"))
1415
- extract_dir = work_dir / "tiffs"
1416
- extract_dir.mkdir(parents=True, exist_ok=True)
1417
- tiff_files = _extract_zip_tiffs(zip_path, extract_dir)
1418
- if not tiff_files:
1419
- raise ValueError("No TIFF files found in the ZIP archive.")
1420
-
1421
- off_color = 0
1422
- com_port = f"serialPort{port}"
1423
- color_dict: dict[int, int] = {0: 100, 255: valve}
1424
-
1425
- # Two non-flipped source image lists. The "path" images drive the nozzle
1426
- # motion (which rows are swept, the sweep extent, the inter-layer shifts);
1427
- # the "color" images decide the valve state (material) at each swept pixel.
1428
- # Normally both are this shape's own slices. When reference motion tiffs are
1429
- # supplied, motion comes from the combined reference stack while the valve is
1430
- # still driven by this shape's slices, centred onto the reference canvas — so
1431
- # parallel heads share one motion path but each dispenses only its geometry.
1432
- shape_imgs = [_load_grayscale(p, invert=invert) for p in tiff_files]
1433
-
1434
- if motion_tiffs:
1435
- motion_paths = sorted(
1436
- (Path(p) for p in motion_tiffs), key=lambda p: _sort_key(p.name)
1437
- )
1438
- path_ref_list = [_load_grayscale(p, invert=invert) for p in motion_paths]
1439
- if not path_ref_list:
1440
- raise ValueError("No reference TIFF files provided for motion.")
1441
- color_ref_list: list[np.ndarray] = []
1442
- for li, motion_img in enumerate(path_ref_list):
1443
- h_c, w_c = motion_img.shape[:2]
1444
- if li < len(shape_imgs):
1445
- color_ref_list.append(
1446
- _center_on_canvas(shape_imgs[li], h_c, w_c, fill=off_color)
1447
- )
1448
- else:
1449
- # Reference is taller than this shape: move but dispense nothing.
1450
- color_ref_list.append(np.full((h_c, w_c), off_color, dtype=np.uint8))
1451
- else:
1452
- path_ref_list = [im.copy() for im in shape_imgs]
1453
- color_ref_list = [im.copy() for im in shape_imgs]
1454
-
1455
- layer_start_directions = None
1456
- if raster_pattern == RASTER_PATTERN_SAME_DIRECTION:
1457
- layer_start_directions = _same_direction_layer_start_directions(
1458
- path_ref_list,
1459
- off_color,
1460
- )
1461
-
1462
- contour_layers = _build_contour_layers(
1463
- contour_tiff_sets,
1464
- path_ref_list,
1465
- fil_width,
1466
- invert,
1467
- off_color,
1468
- work_dir,
1469
- raster_pattern,
1470
- layer_start_directions,
1471
- )
1472
-
1473
- setpress_lines = [_setpress_cmd(com_port, pressure, start=True)]
1474
- pressure_on_lines = [_toggle_cmd(com_port, start=True)]
1475
- pressure_off_lines = [_toggle_cmd(com_port, start=False)]
1476
-
1477
- if raster_pattern in (
1478
- RASTER_PATTERN_Y_DIRECTION,
1479
- RASTER_PATTERN_WOODPILE,
1480
- RASTER_PATTERN_RECTANGULAR_SPIRAL,
1481
- RASTER_PATTERN_CIRCLE_SPIRAL,
1482
- ):
1483
- gcode_list = _build_footprint_raster_gcode_list(
1484
- path_ref_list,
1485
- color_ref_list,
1486
- fil_width,
1487
- layer_height,
1488
- raster_pattern,
1489
- contour_layers,
1490
- active_contour_owner,
1491
- )
1492
- else:
1493
- gcode_list: list[dict] = []
1494
- current_x = 0.0
1495
- current_y = 0.0
1496
- dist_sign_long = 1
1497
- current_offsets_x: list[int] = []
1498
- use_flip_y = False
1499
- direction = -1
1500
-
1501
- for layers in range(len(path_ref_list)):
1502
- current_image_ref = path_ref_list[layers]
1503
- last_image_ref = path_ref_list[layers - 1] if layers > 0 else None
1504
- y_ref = current_image_ref.shape[0]
1505
-
1506
- def find_first_valid_y(row: np.ndarray | None, flip: bool = False) -> int | None:
1507
- if row is None:
1508
- return None
1509
- row_data = np.flip(row) if flip else row
1510
- for j, pixel in enumerate(row_data):
1511
- if np.any(pixel) != off_color:
1512
- return y_ref - 1 - j if flip else j
1513
- return None
1514
-
1515
- last_x = last_y = None
1516
- if current_offsets_x:
1517
- use_flip_x = layers % 2 == 1
1518
- last_x = current_offsets_x[-1] if use_flip_x else current_offsets_x[0]
1519
- last_row = (
1520
- last_image_ref[last_x] if last_image_ref is not None else None
1521
- )
1522
- last_y = find_first_valid_y(last_row, flip=use_flip_y)
1523
- current_offsets_x.clear()
1524
-
1525
- current_offsets_x = [
1526
- i for i, row in enumerate(current_image_ref) if np.any(row) != off_color
1527
- ]
1528
-
1529
- first_x = first_y = None
1530
- if current_offsets_x:
1531
- use_flip_x = layers % 2 == 1
1532
- first_x = current_offsets_x[-1] if use_flip_x else current_offsets_x[0]
1533
- first_row = current_image_ref[first_x]
1534
- first_y = find_first_valid_y(first_row, flip=use_flip_y)
1535
-
1536
- if None in (last_x, last_y, first_x, first_y):
1537
- shift_x = shift_y = 0
1538
- else:
1539
- shift_x = (first_x - last_x) * fil_width
1540
- shift_y = (first_y - last_y) * fil_width * dist_sign_long
1541
- if use_flip_y:
1542
- shift_y = -shift_y
1543
-
1544
- if len(current_offsets_x) % 2 == 1:
1545
- use_flip_y = not use_flip_y
1546
-
1547
- if layers > 0:
1548
- gcode_list.append(
1549
- {"X": shift_y, "Y": shift_x, "Z": layer_height, "Color": 0}
1550
- )
1551
- current_x += shift_y
1552
- current_y += shift_x
1553
-
1554
- for row in current_image_ref:
1555
- if all(p == off_color for p in row):
1556
- dist_sign_long = -dist_sign_long
1557
- dist_sign_long = -dist_sign_long
1558
-
1559
- # Flip path and color together on even layers so they stay aligned.
1560
- even_layer = (layers + 1) % 2 == 0
1561
- ref_for_path = (
1562
- np.flipud(current_image_ref) if even_layer else current_image_ref.copy()
1563
- )
1564
- current_image = (
1565
- np.flipud(color_ref_list[layers]) if even_layer else color_ref_list[layers]
1566
- )
1567
-
1568
- if layers == 0:
1569
- direction = -1
1570
- layer_start = len(gcode_list)
1571
- layer_origin_x, layer_origin_y = current_x, current_y
1572
- layer_x_scale = 1.0 if direction < 0 else -1.0
1573
- layer_y_scale = -1.0 if layers % 2 == 1 else 1.0
1574
- direction = _gcode_layer(
1575
- ref_for_path,
1576
- current_image,
1577
- gcode_list,
1578
- fil_width,
1579
- direction,
1580
- layers,
1581
- )
1582
- for move in gcode_list[layer_start:]:
1583
- current_x += float(move.get("X", 0.0))
1584
- current_y += float(move.get("Y", 0.0))
1585
-
1586
- layer_end_x, layer_end_y = current_x, current_y
1587
- current_x, current_y = _append_layer_contours(
1588
- gcode_list,
1589
- current_x,
1590
- current_y,
1591
- contour_layers,
1592
- layers,
1593
- active_contour_owner,
1594
- layer_origin_x,
1595
- layer_origin_y,
1596
- layer_x_scale,
1597
- layer_y_scale,
1598
- )
1599
- current_x, current_y = _append_relative_move(
1600
- gcode_list,
1601
- current_x,
1602
- current_y,
1603
- layer_end_x,
1604
- layer_end_y,
1605
- off_color,
1606
- )
1607
-
1608
- lead_in = _lead_in_moves(
1609
- lead_in_enabled,
1610
- lead_in_length,
1611
- lead_in_clearance,
1612
- lead_in_lines,
1613
- fil_width,
1614
- 255,
1615
- off_color,
1616
- )
1617
- if lead_in:
1618
- gcode_list = [*lead_in, *gcode_list]
1619
-
1620
- gcode_path = work_dir / f"{shape_name}_SnakePath_gcode.txt"
1621
- pressure_cur = float(pressure)
1622
-
1623
- with open(gcode_path, "w") as f:
1624
- f.write("G91\n")
1625
- f.write(_valve_cmd(valve, 0))
1626
- for line in setpress_lines:
1627
- f.write(f"{line}\n")
1628
- for line in pressure_on_lines:
1629
- f.write(f"{line}\n")
1630
- for color in color_dict:
1631
- f.write(_valve_cmd(color_dict[color], 0))
1632
-
1633
- pressure_next: str | None = None
1634
- for i, move in enumerate(gcode_list):
1635
- prev_color = gcode_list[i - 1]["Color"] if i > 0 else 0
1636
- cur_color = move["Color"]
1637
- if prev_color != cur_color:
1638
- if cur_color == off_color:
1639
- f.write(_valve_cmd(color_dict[prev_color], 0))
1640
- else:
1641
- if prev_color == off_color:
1642
- f.write(_valve_cmd(color_dict[cur_color], 1))
1643
- else:
1644
- f.write(_valve_cmd(color_dict[cur_color], 1))
1645
- f.write(_valve_cmd(color_dict[prev_color], 0))
1646
-
1647
- # When all_g1 is set, every move is emitted as G1 regardless of
1648
- # valve state; the valve commands still mark print vs travel.
1649
- move_type = "G1" if (all_g1 or cur_color != off_color) else "G0"
1650
- if "Z" in move:
1651
- line = (
1652
- f"{move_type} X{move['X']} Y{move['Y']} Z{move['Z']} "
1653
- f"; Color {move['Color']}"
1654
- )
1655
- if pressure_ramp_enabled:
1656
- pressure_cur += increase_pressure_per_layer
1657
- pressure_next = _setpress_cmd(com_port, pressure_cur, start=False)
1658
- else:
1659
- pressure_next = None
1660
- else:
1661
- line = (
1662
- f"{move_type} X{move['X']} Y{move['Y']} ; Color {move['Color']}"
1663
- )
1664
- pressure_next = None
1665
-
1666
- f.write(f"{line}\n")
1667
- if pressure_next is not None:
1668
- f.write(f"{pressure_next}\n")
1669
- pressure_next = None
1670
-
1671
- for color in color_dict:
1672
- f.write(_valve_cmd(color_dict[color], 0))
1673
- for line in pressure_off_lines:
1674
- f.write(f"{line}\n")
1675
-
1676
- return gcode_path