Spaces:
Running
Running
Add spiral raster patterns
Browse files- README.md +1 -1
- tests/test_tiff_to_gcode.py +142 -0
- 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 |
-
|
| 1016 |
-
|
| 1017 |
-
|
| 1018 |
-
|
| 1019 |
-
|
| 1020 |
-
|
| 1021 |
-
|
| 1022 |
-
|
| 1023 |
-
|
| 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 (
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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,
|