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

Add spiral raster patterns

Browse files
Files changed (3) hide show
  1. README.md +1 -1
  2. tests/test_tiff_to_gcode.py +142 -0
  3. tiff_to_gcode.py +284 -11
README.md CHANGED
@@ -93,7 +93,7 @@ The **Multi-Nozzle Split** accordion on the **STL to TIFF Slicer** tab can split
93
  - Pressure increases by `0.1` psi per layer by default.
94
  - **Use G1 for all moves**: when enabled, every movement line is emitted as `G1` (no `G0` rapid travel); the WAGO valve still marks where material is dispensed. Applies to all shapes.
95
  - **Use 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. The reference stack is generated automatically with TIFF stacks; shapes are skipped with a message if it is missing.
96
- - **Raster Pattern**: `X-direction raster` keeps the existing X-direction back-and-forth raster on every layer. `Y-direction raster` rasters every layer in Y. `Woodpile raster` alternates the raster axis by layer, switching between X-direction and Y-direction sweeps.
97
  - **Auto Align Split Parts**: in Nozzle Spacing, fills Grid Layout gaps for split-piece alignment. X-direction raster uses X `-3.2` mm and Y `-0.8` mm; Y-direction raster switches those values.
98
  - **Contour Tracing**: enabled per row in Shape Settings. The app uses the shape-optimized row-envelope tracer, travels from the layer raster end to the nearest contour point, prints the contour, then returns to the raster endpoint before the next layer.
99
 
 
93
  - Pressure increases by `0.1` psi per layer by default.
94
  - **Use G1 for all moves**: when enabled, every movement line is emitted as `G1` (no `G0` rapid travel); the WAGO valve still marks where material is dispensed. Applies to all shapes.
95
  - **Use 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. The reference stack is generated automatically with TIFF stacks; shapes are skipped with a message if it is missing.
96
+ - **Raster Pattern**: `X-direction raster` keeps the existing X-direction back-and-forth raster on every layer. `Y-direction raster` rasters every layer in Y. `Woodpile raster` alternates the raster axis by layer, switching between X-direction and Y-direction sweeps. `Rectangular Spiral raster` walks each layer from the outer layer bounds toward the center, then reverses from center to edge on the next layer. `Circle Spiral raster` uses a shrinking circular spiral from the layer bounds toward the center, then reverses outward on the next layer.
97
  - **Auto Align Split Parts**: in Nozzle Spacing, fills Grid Layout gaps for split-piece alignment. X-direction raster uses X `-3.2` mm and Y `-0.8` mm; Y-direction raster switches those values.
98
  - **Contour Tracing**: enabled per row in Shape Settings. The app uses the shape-optimized row-envelope tracer, travels from the layer raster end to the nearest contour point, prints the contour, then returns to the raster endpoint before the next layer.
99
 
tests/test_tiff_to_gcode.py CHANGED
@@ -1,5 +1,6 @@
1
  from __future__ import annotations
2
 
 
3
  import zipfile
4
 
5
  import numpy as np
@@ -7,11 +8,17 @@ from PIL import Image
7
 
8
  from tiff_to_gcode import (
9
  CONTOUR_MODE_ROW_ENVELOPE,
 
 
10
  RASTER_PATTERN_SAME_DIRECTION,
11
  RASTER_PATTERN_WOODPILE,
12
  RASTER_PATTERN_Y_DIRECTION,
13
  _build_contour_layers,
14
  _append_layer_contours,
 
 
 
 
15
  _trace_mask_contours,
16
  _trace_row_envelope_contours,
17
  generate_snake_path_gcode,
@@ -722,6 +729,141 @@ def test_woodpile_raster_switches_print_axis_between_layers(tmp_path) -> None:
722
  assert actual_restart_distance < old_restart_distance
723
 
724
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
725
  def test_y_direction_raster_prints_each_layer_along_y_axis(tmp_path) -> None:
726
  tiff_paths = []
727
  for index in range(2):
 
1
  from __future__ import annotations
2
 
3
+ import math
4
  import zipfile
5
 
6
  import numpy as np
 
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,
 
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):
tiff_to_gcode.py CHANGED
@@ -1,5 +1,6 @@
1
  from __future__ import annotations
2
 
 
3
  import os
4
  import tempfile
5
  import zipfile
@@ -15,10 +16,14 @@ from PIL import Image
15
  RASTER_PATTERN_SAME_DIRECTION = "X-direction raster"
16
  RASTER_PATTERN_Y_DIRECTION = "Y-direction raster"
17
  RASTER_PATTERN_WOODPILE = "Woodpile raster"
 
 
18
  RASTER_PATTERN_CHOICES = (
19
  RASTER_PATTERN_SAME_DIRECTION,
20
  RASTER_PATTERN_Y_DIRECTION,
21
  RASTER_PATTERN_WOODPILE,
 
 
22
  )
23
  CONTOUR_MODE_EXACT = "Exact pixel border"
24
  CONTOUR_MODE_ROW_ENVELOPE = "Shape-optimized row envelope"
@@ -28,6 +33,10 @@ CONTOUR_MODE_CHOICES = (
28
  )
29
 
30
  def _normalize_raster_pattern(pattern: str | None) -> str:
 
 
 
 
31
  if pattern == RASTER_PATTERN_WOODPILE:
32
  return RASTER_PATTERN_WOODPILE
33
  if pattern == RASTER_PATTERN_Y_DIRECTION:
@@ -996,6 +1005,250 @@ def _oriented_woodpile_layer_segments(
996
  )
997
 
998
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
999
  def _build_footprint_raster_gcode_list(
1000
  path_ref_list: list[np.ndarray],
1001
  color_ref_list: list[np.ndarray],
@@ -1012,16 +1265,31 @@ def _build_footprint_raster_gcode_list(
1012
  contour_layers = contour_layers or []
1013
 
1014
  for layer_number, (path_img, color_img) in enumerate(zip(path_ref_list, color_ref_list)):
1015
- raster_axis = _raster_axis_for_pattern(raster_pattern, layer_number)
1016
- segments = _oriented_woodpile_layer_segments(
1017
- path_img,
1018
- color_img,
1019
- pixel_size,
1020
- raster_axis,
1021
- current_x,
1022
- current_y,
1023
- prefer_default=not raster_origin_initialized,
1024
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1025
  if not segments:
1026
  if layer_number > 0:
1027
  gcode_list.append({"X": 0.0, "Y": 0.0, "Z": layer_height, "Color": 0})
@@ -1206,7 +1474,12 @@ def generate_snake_path_gcode(
1206
  pressure_on_lines = [_toggle_cmd(com_port, start=True)]
1207
  pressure_off_lines = [_toggle_cmd(com_port, start=False)]
1208
 
1209
- if raster_pattern in (RASTER_PATTERN_Y_DIRECTION, RASTER_PATTERN_WOODPILE):
 
 
 
 
 
1210
  gcode_list = _build_footprint_raster_gcode_list(
1211
  path_ref_list,
1212
  color_ref_list,
 
1
  from __future__ import annotations
2
 
3
+ import math
4
  import os
5
  import tempfile
6
  import zipfile
 
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"
 
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:
 
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],
 
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})
 
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,