Spaces:
Running
Add 45-degree woodpile raster and seam-free split contours
Browse files- Grid-split pieces store contour paths clipped from the PARENT outline, so
contour tracing never runs along the cut seams between sibling pieces:
edge pieces trace open arcs of the true outer surface, fully interior
pieces trace nothing, and holes inside one piece stay closed rings; the
contour printer approaches an open arc's nearer end and does not close it
- New "45-degree Woodpile" raster: the sweep rotates 45 degrees per layer
(0/45/90/135), reusing the axis raster in a rotated frame anchored to the
shared scan frame so buffers, valve gating, infill selection, and seam
grid continuity all carry over to the diagonal layers
- Rename patterns to "90 deg Woodpile raster" / "45 deg Woodpile raster"
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- README.md +2 -2
- stl_slicer.py +6 -0
- tests/test_vector_gcode.py +157 -0
- vector_gcode.py +9 -10
- vector_toolpath.py +223 -22
|
@@ -94,9 +94,9 @@ The **Multi-Nozzle Split** accordion on the **Shapes & Slicing** tab can split o
|
|
| 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 combined reference outline for motion**: when enabled, every shape's *motion* is taken from the combined reference layer union while each shape's *valve/dispensing* comes from its own layer polygons — so parallel print heads share one synchronized nozzle path and each deposits only its own geometry. The reference union is rebuilt automatically when shapes are sliced. Contour tracing stays synchronized too: every shape traces every traced shape's contour, opening its valve only on its own outline.
|
| 96 |
- Every generated file starts with a `; PathOrigin X.. Y..` comment: the world position (in the shape's own frame) that the relative toolpath starts from. Tools use it to place parallel parts so split pieces reassemble.
|
| 97 |
-
- **Raster Pattern**: `X-direction raster` sweeps every layer back-and-forth in X. `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. Spiral motion covers the layer bounds; the valve opens only where the path is inside material.
|
| 98 |
- **Auto Align Split Parts**: in Nozzle Spacing, computes exact per-connection grid gaps from the split pieces' generated G-code (`PathOrigin` anchors + toolpath bounds), sets the grid columns/rows from the split, and fills the Advanced Grid Spacing table. Works for every raster pattern, filament width, reference-motion setting, and overlapping-layer split. Requires the pieces' G-code to be generated first.
|
| 99 |
-
- **Contour Tracing**: enabled per row in Shape Settings. The app traces the layer polygon's boundary rings (holes traced separately), travels from the layer raster end to the nearest contour point, prints the contour, then returns to the raster endpoint before the next layer.
|
| 100 |
|
| 101 |
### Print vs Travel Classification
|
| 102 |
|
|
|
|
| 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 combined reference outline for motion**: when enabled, every shape's *motion* is taken from the combined reference layer union while each shape's *valve/dispensing* comes from its own layer polygons — so parallel print heads share one synchronized nozzle path and each deposits only its own geometry. The reference union is rebuilt automatically when shapes are sliced. Contour tracing stays synchronized too: every shape traces every traced shape's contour, opening its valve only on its own outline.
|
| 96 |
- Every generated file starts with a `; PathOrigin X.. Y..` comment: the world position (in the shape's own frame) that the relative toolpath starts from. Tools use it to place parallel parts so split pieces reassemble.
|
| 97 |
+
- **Raster Pattern**: `X-direction raster` sweeps every layer back-and-forth in X. `Y-direction raster` rasters every layer in Y. `90° Woodpile raster` alternates the raster axis by layer, switching between X-direction and Y-direction sweeps. `45° Woodpile raster` rotates the sweep 45 degrees per layer, cycling 0, 45, 90, 135 degrees. `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. Spiral motion covers the layer bounds; the valve opens only where the path is inside material.
|
| 98 |
- **Auto Align Split Parts**: in Nozzle Spacing, computes exact per-connection grid gaps from the split pieces' generated G-code (`PathOrigin` anchors + toolpath bounds), sets the grid columns/rows from the split, and fills the Advanced Grid Spacing table. Works for every raster pattern, filament width, reference-motion setting, and overlapping-layer split. Requires the pieces' G-code to be generated first.
|
| 99 |
+
- **Contour Tracing**: enabled per row in Shape Settings. The app traces the layer polygon's boundary rings (holes traced separately), travels from the layer raster end to the nearest contour point, prints the contour, then returns to the raster endpoint before the next layer. For grid-split pieces only the parent shape's true outer surface is traced — the cut seams between sibling pieces are excluded (open arcs are printed end-to-end without closing the loop; fully interior pieces get no contour).
|
| 100 |
|
| 101 |
### Print vs Travel Classification
|
| 102 |
|
|
@@ -44,6 +44,12 @@ class LayerStack:
|
|
| 44 |
scan_frame: tuple[float, float, float, float] | None = None
|
| 45 |
align_center: tuple[float, float] | None = None
|
| 46 |
align_grid: float | None = None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 47 |
|
| 48 |
|
| 49 |
def load_mesh(stl_path: str | Path) -> trimesh.Trimesh:
|
|
|
|
| 44 |
scan_frame: tuple[float, float, float, float] | None = None
|
| 45 |
align_center: tuple[float, float] | None = None
|
| 46 |
align_grid: float | None = None
|
| 47 |
+
# Grid-split pieces: per-layer contour polylines from the PARENT shape's
|
| 48 |
+
# boundary clipped to this piece's cell — cut seams between sibling
|
| 49 |
+
# pieces are excluded, so contour tracing only outlines the true outer
|
| 50 |
+
# surface. None means "derive contours from the layer polygons" (whole
|
| 51 |
+
# shapes). Paths may be open arcs; closed rings keep first == last.
|
| 52 |
+
contour_paths: list[list[list[tuple[float, float]]]] | None = None
|
| 53 |
|
| 54 |
|
| 55 |
def load_mesh(stl_path: str | Path) -> trimesh.Trimesh:
|
|
@@ -416,6 +416,76 @@ def test_y_direction_raster_prints_each_layer_along_y_axis(tmp_path) -> None:
|
|
| 416 |
assert first_layer_change["start"][:2] == first_layer_change["end"][:2]
|
| 417 |
|
| 418 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 419 |
def test_raster_crosses_interior_holes_with_valve_off(tmp_path) -> None:
|
| 420 |
hollow = Polygon(
|
| 421 |
box(0.0, 0.0, 6.0, 6.0).exterior.coords,
|
|
@@ -1059,6 +1129,93 @@ def test_split_overlap_seam_raster_distance_is_equal_on_both_sides() -> None:
|
|
| 1059 |
assert abs(left_distance - right_distance) < 1e-9
|
| 1060 |
|
| 1061 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1062 |
def test_split_layer_stack_grid_overlap_alternates_between_layers() -> None:
|
| 1063 |
layer = box(0.0, 0.0, 4.0, 2.0)
|
| 1064 |
stack = _stack(layer, layer, name="interlock")
|
|
|
|
| 416 |
assert first_layer_change["start"][:2] == first_layer_change["end"][:2]
|
| 417 |
|
| 418 |
|
| 419 |
+
def test_diagonal_woodpile_rotates_45_degrees_per_layer(tmp_path) -> None:
|
| 420 |
+
from vector_toolpath import RASTER_PATTERN_DIAGONAL_WOODPILE
|
| 421 |
+
|
| 422 |
+
layer = box(0.0, 0.0, 8.0, 8.0)
|
| 423 |
+
gcode_path = generate_vector_gcode(
|
| 424 |
+
_stack(layer, layer, layer, layer),
|
| 425 |
+
shape_name="diagonal",
|
| 426 |
+
pressure=25,
|
| 427 |
+
valve=7,
|
| 428 |
+
port=3,
|
| 429 |
+
fil_width=1.0,
|
| 430 |
+
layer_height=1.0,
|
| 431 |
+
raster_pattern=RASTER_PATTERN_DIAGONAL_WOODPILE,
|
| 432 |
+
output_dir=tmp_path,
|
| 433 |
+
)
|
| 434 |
+
|
| 435 |
+
moves = _moves_with_colors(gcode_path.read_text())
|
| 436 |
+
directions_by_layer: dict[float, set[float]] = {}
|
| 437 |
+
intercepts_45: set[float] = set()
|
| 438 |
+
for move in moves:
|
| 439 |
+
if move["color"] != 255:
|
| 440 |
+
continue
|
| 441 |
+
z = round(move["start"][2], 6)
|
| 442 |
+
dx = move["end"][0] - move["start"][0]
|
| 443 |
+
dy = move["end"][1] - move["start"][1]
|
| 444 |
+
angle = round(math.degrees(math.atan2(dy, dx)) % 180.0, 1)
|
| 445 |
+
directions_by_layer.setdefault(z, set()).add(angle)
|
| 446 |
+
if z == 1.0:
|
| 447 |
+
intercepts_45.add(
|
| 448 |
+
round((move["start"][1] - move["start"][0]) / math.sqrt(2), 5)
|
| 449 |
+
)
|
| 450 |
+
|
| 451 |
+
# The raster angle cycles 0 -> 45 -> 90 -> 135 across layers.
|
| 452 |
+
assert directions_by_layer == {
|
| 453 |
+
0.0: {0.0},
|
| 454 |
+
1.0: {45.0},
|
| 455 |
+
2.0: {90.0},
|
| 456 |
+
3.0: {135.0},
|
| 457 |
+
}
|
| 458 |
+
# Diagonal lines keep an exact one-fil perpendicular pitch.
|
| 459 |
+
ordered = sorted(intercepts_45)
|
| 460 |
+
assert len(ordered) > 3
|
| 461 |
+
assert {round(b - a, 4) for a, b in zip(ordered, ordered[1:])} == {1.0}
|
| 462 |
+
|
| 463 |
+
|
| 464 |
+
def test_diagonal_woodpile_shares_reference_motion(tmp_path) -> None:
|
| 465 |
+
from vector_toolpath import RASTER_PATTERN_DIAGONAL_WOODPILE
|
| 466 |
+
|
| 467 |
+
big = _stack(*([box(0.0, 0.0, 8.0, 8.0)] * 4), name="big")
|
| 468 |
+
small = _stack(*([box(2.0, 2.0, 6.0, 6.0)] * 4), name="small")
|
| 469 |
+
reference = build_reference_stack([big, small], grid=1.0)
|
| 470 |
+
|
| 471 |
+
totals = []
|
| 472 |
+
for stack, label in ((big, "dbig"), (small, "dsmall")):
|
| 473 |
+
gcode_path = generate_vector_gcode(
|
| 474 |
+
stack,
|
| 475 |
+
shape_name=label,
|
| 476 |
+
pressure=25,
|
| 477 |
+
valve=7,
|
| 478 |
+
port=3,
|
| 479 |
+
fil_width=1.0,
|
| 480 |
+
motion=reference,
|
| 481 |
+
raster_pattern=RASTER_PATTERN_DIAGONAL_WOODPILE,
|
| 482 |
+
output_dir=tmp_path / label,
|
| 483 |
+
)
|
| 484 |
+
totals.append(_total_length(_moves_with_colors(gcode_path.read_text())))
|
| 485 |
+
|
| 486 |
+
assert abs(totals[0] - totals[1]) < 1e-2
|
| 487 |
+
|
| 488 |
+
|
| 489 |
def test_raster_crosses_interior_holes_with_valve_off(tmp_path) -> None:
|
| 490 |
hollow = Polygon(
|
| 491 |
box(0.0, 0.0, 6.0, 6.0).exterior.coords,
|
|
|
|
| 1129 |
assert abs(left_distance - right_distance) < 1e-9
|
| 1130 |
|
| 1131 |
|
| 1132 |
+
def test_split_contour_paths_exclude_the_cut_seams() -> None:
|
| 1133 |
+
layer = MultiPolygon([box(0.0, 0.0, 9.0, 4.0)])
|
| 1134 |
+
stack = _stack(layer, name="bar")
|
| 1135 |
+
|
| 1136 |
+
left, middle, right = split_layer_stack_grid(stack, columns=3, rows=1, grid=1.0)
|
| 1137 |
+
|
| 1138 |
+
# Middle piece: only the parent's top and bottom edges, as open arcs —
|
| 1139 |
+
# no vertical paths along the cuts at x=3 and x=6.
|
| 1140 |
+
assert middle.contour_paths[0] == [
|
| 1141 |
+
[(3.0, 0.0), (6.0, 0.0)],
|
| 1142 |
+
[(3.0, 4.0), (6.0, 4.0)],
|
| 1143 |
+
]
|
| 1144 |
+
# Edge pieces get one open C-shaped path around their outer three sides.
|
| 1145 |
+
(left_path,) = left.contour_paths[0]
|
| 1146 |
+
assert left_path[0] != left_path[-1]
|
| 1147 |
+
assert all(abs(x - 3.0) > 1e-9 or y in (0.0, 4.0) for x, y in left_path)
|
| 1148 |
+
|
| 1149 |
+
# A fully interior piece has no contour at all.
|
| 1150 |
+
grid = split_layer_stack_grid(
|
| 1151 |
+
_stack(MultiPolygon([box(0.0, 0.0, 9.0, 9.0)]), name="sq"),
|
| 1152 |
+
columns=3,
|
| 1153 |
+
rows=3,
|
| 1154 |
+
grid=1.0,
|
| 1155 |
+
)
|
| 1156 |
+
assert grid[4].contour_paths[0] == []
|
| 1157 |
+
|
| 1158 |
+
# A hole entirely inside one piece stays a closed ring.
|
| 1159 |
+
hollow = MultiPolygon(
|
| 1160 |
+
[
|
| 1161 |
+
Polygon(
|
| 1162 |
+
box(0.0, 0.0, 9.0, 4.0).exterior.coords,
|
| 1163 |
+
[list(box(1.0, 1.0, 2.0, 2.0).exterior.coords)],
|
| 1164 |
+
)
|
| 1165 |
+
]
|
| 1166 |
+
)
|
| 1167 |
+
hole_left, _hm, _hr = split_layer_stack_grid(
|
| 1168 |
+
_stack(hollow, name="hollow"), columns=3, rows=1, grid=1.0
|
| 1169 |
+
)
|
| 1170 |
+
closed_paths = [p for p in hole_left.contour_paths[0] if p[0] == p[-1]]
|
| 1171 |
+
assert len(closed_paths) == 1
|
| 1172 |
+
|
| 1173 |
+
|
| 1174 |
+
def test_split_contour_gcode_never_traces_the_cuts(tmp_path) -> None:
|
| 1175 |
+
layer = MultiPolygon([box(0.0, 0.0, 9.0, 4.0)])
|
| 1176 |
+
stack = _stack(layer, layer, name="bar")
|
| 1177 |
+
pieces = split_layer_stack_grid(stack, columns=3, rows=1, grid=1.0)
|
| 1178 |
+
reference = build_reference_stack(pieces, grid=1.0)
|
| 1179 |
+
sources = [
|
| 1180 |
+
ContourSource(owner_idx=index + 1, stack=piece)
|
| 1181 |
+
for index, piece in enumerate(pieces)
|
| 1182 |
+
]
|
| 1183 |
+
|
| 1184 |
+
all_moves = []
|
| 1185 |
+
for index, piece in enumerate(pieces):
|
| 1186 |
+
gcode_path = generate_vector_gcode(
|
| 1187 |
+
piece,
|
| 1188 |
+
shape_name=f"seam{index}",
|
| 1189 |
+
pressure=25,
|
| 1190 |
+
valve=4 + index,
|
| 1191 |
+
port=3,
|
| 1192 |
+
fil_width=1.0,
|
| 1193 |
+
motion=reference,
|
| 1194 |
+
contour_sources=sources,
|
| 1195 |
+
active_contour_owner=index + 1,
|
| 1196 |
+
output_dir=tmp_path / f"seam{index}",
|
| 1197 |
+
)
|
| 1198 |
+
all_moves.append(_moves_with_colors(gcode_path.read_text()))
|
| 1199 |
+
|
| 1200 |
+
# All heads still share one motion path, contours included.
|
| 1201 |
+
totals = {round(_total_length(moves), 4) for moves in all_moves}
|
| 1202 |
+
assert len(totals) == 1
|
| 1203 |
+
assert len({moves[-1]["end"] for moves in all_moves}) == 1
|
| 1204 |
+
|
| 1205 |
+
# The middle piece's contour arcs are horizontal: with the horizontal
|
| 1206 |
+
# X-raster infill, it must emit NO vertical print move at all (a vertical
|
| 1207 |
+
# print could only be a traced cut seam).
|
| 1208 |
+
middle = all_moves[1]
|
| 1209 |
+
vertical_prints = [
|
| 1210 |
+
move
|
| 1211 |
+
for move in middle
|
| 1212 |
+
if move["color"] == 255
|
| 1213 |
+
and abs(move["end"][0] - move["start"][0]) < 1e-9
|
| 1214 |
+
and abs(move["end"][1] - move["start"][1]) > 1e-9
|
| 1215 |
+
]
|
| 1216 |
+
assert vertical_prints == []
|
| 1217 |
+
|
| 1218 |
+
|
| 1219 |
def test_split_layer_stack_grid_overlap_alternates_between_layers() -> None:
|
| 1220 |
layer = box(0.0, 0.0, 4.0, 2.0)
|
| 1221 |
stack = _stack(layer, layer, name="interlock")
|
|
@@ -17,6 +17,7 @@ from stl_slicer import LayerStack
|
|
| 17 |
from vector_toolpath import (
|
| 18 |
RASTER_PATTERN_CHOICES,
|
| 19 |
RASTER_PATTERN_CIRCLE_SPIRAL,
|
|
|
|
| 20 |
RASTER_PATTERN_RECTANGULAR_SPIRAL,
|
| 21 |
RASTER_PATTERN_SAME_DIRECTION,
|
| 22 |
RASTER_PATTERN_WOODPILE,
|
|
@@ -25,7 +26,6 @@ from vector_toolpath import (
|
|
| 25 |
_centering_delta,
|
| 26 |
_lead_in_moves,
|
| 27 |
_normalize_raster_pattern,
|
| 28 |
-
_scan_anchor,
|
| 29 |
align_stack_to,
|
| 30 |
build_contour_layers,
|
| 31 |
plan_layer_moves,
|
|
@@ -34,6 +34,7 @@ from vector_toolpath import (
|
|
| 34 |
__all__ = [
|
| 35 |
"RASTER_PATTERN_CHOICES",
|
| 36 |
"RASTER_PATTERN_CIRCLE_SPIRAL",
|
|
|
|
| 37 |
"RASTER_PATTERN_RECTANGULAR_SPIRAL",
|
| 38 |
"RASTER_PATTERN_SAME_DIRECTION",
|
| 39 |
"RASTER_PATTERN_WOODPILE",
|
|
@@ -240,18 +241,16 @@ def generate_vector_gcode(
|
|
| 240 |
reference=contour_reference,
|
| 241 |
)
|
| 242 |
|
| 243 |
-
# Anchor the raster scan grid
|
| 244 |
-
# piece's frame is its parent shape's
|
| 245 |
-
# layers and stay on one continuous grid
|
|
|
|
| 246 |
frame_stack = motion if motion is not None else shape
|
| 247 |
if frame_stack.scan_frame is not None:
|
| 248 |
-
|
| 249 |
else:
|
| 250 |
(frame_x_min, frame_y_min, _fz), (frame_x_max, frame_y_max, _fz2) = frame_stack.bounds
|
| 251 |
-
|
| 252 |
-
_scan_anchor(frame_x_min, frame_x_max, fil_width),
|
| 253 |
-
_scan_anchor(frame_y_min, frame_y_max, fil_width),
|
| 254 |
-
)
|
| 255 |
|
| 256 |
gcode_list, toolpath_origin = plan_layer_moves(
|
| 257 |
motion_layers,
|
|
@@ -262,7 +261,7 @@ def generate_vector_gcode(
|
|
| 262 |
contour_layers,
|
| 263 |
active_contour_owner,
|
| 264 |
shared_motion=motion is not None,
|
| 265 |
-
|
| 266 |
infill_fraction=max(0.0, min(1.0, float(infill))),
|
| 267 |
)
|
| 268 |
|
|
|
|
| 17 |
from vector_toolpath import (
|
| 18 |
RASTER_PATTERN_CHOICES,
|
| 19 |
RASTER_PATTERN_CIRCLE_SPIRAL,
|
| 20 |
+
RASTER_PATTERN_DIAGONAL_WOODPILE,
|
| 21 |
RASTER_PATTERN_RECTANGULAR_SPIRAL,
|
| 22 |
RASTER_PATTERN_SAME_DIRECTION,
|
| 23 |
RASTER_PATTERN_WOODPILE,
|
|
|
|
| 26 |
_centering_delta,
|
| 27 |
_lead_in_moves,
|
| 28 |
_normalize_raster_pattern,
|
|
|
|
| 29 |
align_stack_to,
|
| 30 |
build_contour_layers,
|
| 31 |
plan_layer_moves,
|
|
|
|
| 34 |
__all__ = [
|
| 35 |
"RASTER_PATTERN_CHOICES",
|
| 36 |
"RASTER_PATTERN_CIRCLE_SPIRAL",
|
| 37 |
+
"RASTER_PATTERN_DIAGONAL_WOODPILE",
|
| 38 |
"RASTER_PATTERN_RECTANGULAR_SPIRAL",
|
| 39 |
"RASTER_PATTERN_SAME_DIRECTION",
|
| 40 |
"RASTER_PATTERN_WOODPILE",
|
|
|
|
| 241 |
reference=contour_reference,
|
| 242 |
)
|
| 243 |
|
| 244 |
+
# Anchor the raster scan grid (and the diagonal-raster pivot) to the
|
| 245 |
+
# motion stack's frame (a split piece's frame is its parent shape's
|
| 246 |
+
# bounds) so lines stack across layers and stay on one continuous grid
|
| 247 |
+
# across split pieces.
|
| 248 |
frame_stack = motion if motion is not None else shape
|
| 249 |
if frame_stack.scan_frame is not None:
|
| 250 |
+
scan_frame = frame_stack.scan_frame
|
| 251 |
else:
|
| 252 |
(frame_x_min, frame_y_min, _fz), (frame_x_max, frame_y_max, _fz2) = frame_stack.bounds
|
| 253 |
+
scan_frame = (frame_x_min, frame_y_min, frame_x_max, frame_y_max)
|
|
|
|
|
|
|
|
|
|
| 254 |
|
| 255 |
gcode_list, toolpath_origin = plan_layer_moves(
|
| 256 |
motion_layers,
|
|
|
|
| 261 |
contour_layers,
|
| 262 |
active_contour_owner,
|
| 263 |
shared_motion=motion is not None,
|
| 264 |
+
scan_frame=scan_frame,
|
| 265 |
infill_fraction=max(0.0, min(1.0, float(infill))),
|
| 266 |
)
|
| 267 |
|
|
@@ -13,10 +13,10 @@ import math
|
|
| 13 |
from dataclasses import dataclass
|
| 14 |
|
| 15 |
from shapely import prepare
|
| 16 |
-
from shapely.affinity import translate
|
| 17 |
-
from shapely.geometry import LineString, MultiPolygon, Point, box
|
| 18 |
from shapely.geometry.polygon import orient
|
| 19 |
-
from shapely.ops import unary_union
|
| 20 |
|
| 21 |
from stl_slicer import LayerStack, _as_multipolygon
|
| 22 |
|
|
@@ -25,13 +25,15 @@ EPS = 1e-6
|
|
| 25 |
|
| 26 |
RASTER_PATTERN_SAME_DIRECTION = "X-direction raster"
|
| 27 |
RASTER_PATTERN_Y_DIRECTION = "Y-direction raster"
|
| 28 |
-
RASTER_PATTERN_WOODPILE = "Woodpile raster"
|
|
|
|
| 29 |
RASTER_PATTERN_RECTANGULAR_SPIRAL = "Rectangular Spiral raster"
|
| 30 |
RASTER_PATTERN_CIRCLE_SPIRAL = "Circle Spiral raster"
|
| 31 |
RASTER_PATTERN_CHOICES = (
|
| 32 |
RASTER_PATTERN_SAME_DIRECTION,
|
| 33 |
RASTER_PATTERN_Y_DIRECTION,
|
| 34 |
RASTER_PATTERN_WOODPILE,
|
|
|
|
| 35 |
RASTER_PATTERN_RECTANGULAR_SPIRAL,
|
| 36 |
RASTER_PATTERN_CIRCLE_SPIRAL,
|
| 37 |
)
|
|
@@ -56,9 +58,25 @@ def _raster_axis_for_pattern(pattern: str, layer_number: int) -> str:
|
|
| 56 |
return "Y"
|
| 57 |
if pattern == RASTER_PATTERN_WOODPILE and layer_number % 2 == 1:
|
| 58 |
return "Y"
|
|
|
|
|
|
|
| 59 |
return "X"
|
| 60 |
|
| 61 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 62 |
def _iter_linestrings(geometry: object):
|
| 63 |
if geometry is None or getattr(geometry, "is_empty", True):
|
| 64 |
return
|
|
@@ -496,6 +514,76 @@ def _oriented_axis_raster_segments(
|
|
| 496 |
)
|
| 497 |
|
| 498 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 499 |
def _extend_polyline_ends(
|
| 500 |
points: list[tuple[float, float]],
|
| 501 |
length: float,
|
|
@@ -834,6 +922,29 @@ def build_contour_layers(
|
|
| 834 |
if stack is None or not stack.layers:
|
| 835 |
continue
|
| 836 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 837 |
if reference is not None:
|
| 838 |
layers = align_stack_to(stack, reference, n_layers)
|
| 839 |
else:
|
|
@@ -918,15 +1029,28 @@ def _append_layer_contours(
|
|
| 918 |
else:
|
| 919 |
nearest_x, nearest_y = current_x, current_y
|
| 920 |
approach_dx, approach_dy = 0.0, 0.0
|
| 921 |
-
|
| 922 |
-
|
| 923 |
-
|
| 924 |
-
|
| 925 |
-
|
| 926 |
-
|
| 927 |
-
|
| 928 |
-
|
| 929 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 930 |
start_x, start_y = contour[0]
|
| 931 |
use_infill_reference = False
|
| 932 |
current_x, current_y = _append_relative_move(
|
|
@@ -1000,13 +1124,14 @@ def plan_layer_moves(
|
|
| 1000 |
contour_layers: list[list[dict]] | None = None,
|
| 1001 |
active_contour_owner: int | None = None,
|
| 1002 |
shared_motion: bool = False,
|
| 1003 |
-
|
| 1004 |
infill_fraction: float = 1.0,
|
| 1005 |
) -> tuple[list[dict], tuple[float, float]]:
|
| 1006 |
"""Assemble per-layer segments into a relative move list for all patterns.
|
| 1007 |
|
| 1008 |
-
`
|
| 1009 |
-
|
|
|
|
| 1010 |
|
| 1011 |
`infill_fraction` < 1 skips dispensing on evenly-distributed lines (grid
|
| 1012 |
lines for axis rasters, rings/revolutions for spirals) while the motion
|
|
@@ -1018,6 +1143,11 @@ def plan_layer_moves(
|
|
| 1018 |
"""
|
| 1019 |
raster_pattern = _normalize_raster_pattern(raster_pattern)
|
| 1020 |
infill_keep = _infill_line_keep(infill_fraction)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1021 |
gcode_list: list[dict] = []
|
| 1022 |
current_x = 0.0
|
| 1023 |
current_y = 0.0
|
|
@@ -1079,14 +1209,28 @@ def plan_layer_moves(
|
|
| 1079 |
return infill_keep(max(0, int(round(inset / fil_width))))
|
| 1080 |
|
| 1081 |
segments = _classify_polyline(points, valve, keep_point=keep_point)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1082 |
else:
|
| 1083 |
raster_axis = _raster_axis_for_pattern(raster_pattern, layer_number)
|
| 1084 |
-
|
| 1085 |
-
|
| 1086 |
-
else
|
| 1087 |
-
# Axis "X" sweeps along X with rows stacked in Y, so its
|
| 1088 |
-
# scanlines use the Y anchor (and vice versa).
|
| 1089 |
-
axis_anchor = scan_anchors[0] if raster_axis == "Y" else scan_anchors[1]
|
| 1090 |
segments = _oriented_axis_raster_segments(
|
| 1091 |
motion,
|
| 1092 |
valve,
|
|
@@ -1409,6 +1553,44 @@ def _shifted_split_edges(
|
|
| 1409 |
return adjusted
|
| 1410 |
|
| 1411 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1412 |
def split_layer_stack_grid(
|
| 1413 |
stack: LayerStack,
|
| 1414 |
columns: int,
|
|
@@ -1458,9 +1640,11 @@ def split_layer_stack_grid(
|
|
| 1458 |
x_cell = col_index - 1
|
| 1459 |
|
| 1460 |
layers: list[MultiPolygon] = []
|
|
|
|
| 1461 |
for layer_number, layer in enumerate(stack.layers):
|
| 1462 |
if layer is None or layer.is_empty:
|
| 1463 |
layers.append(MultiPolygon())
|
|
|
|
| 1464 |
continue
|
| 1465 |
x_edges = layer_x_edges[layer_number]
|
| 1466 |
y_edges = layer_y_edges[layer_number]
|
|
@@ -1472,6 +1656,22 @@ def split_layer_stack_grid(
|
|
| 1472 |
)
|
| 1473 |
layers.append(_as_multipolygon(layer.intersection(cell)))
|
| 1474 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1475 |
pieces.append(
|
| 1476 |
LayerStack(
|
| 1477 |
layers=layers,
|
|
@@ -1483,6 +1683,7 @@ def split_layer_stack_grid(
|
|
| 1483 |
layer_height=stack.layer_height,
|
| 1484 |
name=f"{base_name}_r{row_index:02d}_c{col_index:02d}",
|
| 1485 |
scan_frame=scan_frame,
|
|
|
|
| 1486 |
)
|
| 1487 |
)
|
| 1488 |
|
|
|
|
| 13 |
from dataclasses import dataclass
|
| 14 |
|
| 15 |
from shapely import prepare
|
| 16 |
+
from shapely.affinity import rotate, translate
|
| 17 |
+
from shapely.geometry import LineString, MultiLineString, MultiPolygon, Point, box
|
| 18 |
from shapely.geometry.polygon import orient
|
| 19 |
+
from shapely.ops import linemerge, unary_union
|
| 20 |
|
| 21 |
from stl_slicer import LayerStack, _as_multipolygon
|
| 22 |
|
|
|
|
| 25 |
|
| 26 |
RASTER_PATTERN_SAME_DIRECTION = "X-direction raster"
|
| 27 |
RASTER_PATTERN_Y_DIRECTION = "Y-direction raster"
|
| 28 |
+
RASTER_PATTERN_WOODPILE = "90° Woodpile raster"
|
| 29 |
+
RASTER_PATTERN_DIAGONAL_WOODPILE = "45° Woodpile raster"
|
| 30 |
RASTER_PATTERN_RECTANGULAR_SPIRAL = "Rectangular Spiral raster"
|
| 31 |
RASTER_PATTERN_CIRCLE_SPIRAL = "Circle Spiral raster"
|
| 32 |
RASTER_PATTERN_CHOICES = (
|
| 33 |
RASTER_PATTERN_SAME_DIRECTION,
|
| 34 |
RASTER_PATTERN_Y_DIRECTION,
|
| 35 |
RASTER_PATTERN_WOODPILE,
|
| 36 |
+
RASTER_PATTERN_DIAGONAL_WOODPILE,
|
| 37 |
RASTER_PATTERN_RECTANGULAR_SPIRAL,
|
| 38 |
RASTER_PATTERN_CIRCLE_SPIRAL,
|
| 39 |
)
|
|
|
|
| 58 |
return "Y"
|
| 59 |
if pattern == RASTER_PATTERN_WOODPILE and layer_number % 2 == 1:
|
| 60 |
return "Y"
|
| 61 |
+
if pattern == RASTER_PATTERN_DIAGONAL_WOODPILE and layer_number % 4 == 2:
|
| 62 |
+
return "Y"
|
| 63 |
return "X"
|
| 64 |
|
| 65 |
|
| 66 |
+
def _diagonal_layer_angle(layer_number: int) -> float | None:
|
| 67 |
+
"""Diagonal-woodpile raster angle for a layer; None for the axis layers.
|
| 68 |
+
|
| 69 |
+
The pattern cycles 0, 45, 90, 135 degrees: axis layers (0/90) go through
|
| 70 |
+
the regular X/Y raster; only the odd layers need the rotated raster.
|
| 71 |
+
"""
|
| 72 |
+
phase = layer_number % 4
|
| 73 |
+
if phase == 1:
|
| 74 |
+
return 45.0
|
| 75 |
+
if phase == 3:
|
| 76 |
+
return 135.0
|
| 77 |
+
return None
|
| 78 |
+
|
| 79 |
+
|
| 80 |
def _iter_linestrings(geometry: object):
|
| 81 |
if geometry is None or getattr(geometry, "is_empty", True):
|
| 82 |
return
|
|
|
|
| 514 |
)
|
| 515 |
|
| 516 |
|
| 517 |
+
def _rotated_raster_segments(
|
| 518 |
+
motion: MultiPolygon,
|
| 519 |
+
valve: MultiPolygon,
|
| 520 |
+
fil_width: float,
|
| 521 |
+
angle_degrees: float,
|
| 522 |
+
current_x: float,
|
| 523 |
+
current_y: float,
|
| 524 |
+
prefer_default: bool,
|
| 525 |
+
frame: tuple[float, float, float, float],
|
| 526 |
+
infill_keep=None,
|
| 527 |
+
) -> list[Seg]:
|
| 528 |
+
"""Snake raster at an arbitrary angle, reusing the axis raster machinery.
|
| 529 |
+
|
| 530 |
+
Rotates motion/valve by -angle about the scan frame's centre, rasters
|
| 531 |
+
with the standard X-axis sweep (snake, buffers, valve gating, scan grid,
|
| 532 |
+
infill selection all identical), then rotates the segments back. The
|
| 533 |
+
pivot and the rotated scan anchor both derive from the shared frame, so
|
| 534 |
+
split pieces and reference-motion shapes keep one continuous diagonal
|
| 535 |
+
line grid across seams.
|
| 536 |
+
"""
|
| 537 |
+
if motion is None or motion.is_empty:
|
| 538 |
+
return []
|
| 539 |
+
|
| 540 |
+
pivot_x = (frame[0] + frame[2]) / 2.0
|
| 541 |
+
pivot_y = (frame[1] + frame[3]) / 2.0
|
| 542 |
+
pivot = (pivot_x, pivot_y)
|
| 543 |
+
|
| 544 |
+
rotated_motion = _as_multipolygon(rotate(motion, -angle_degrees, origin=pivot))
|
| 545 |
+
if valve is motion:
|
| 546 |
+
rotated_valve = rotated_motion
|
| 547 |
+
elif valve is None or valve.is_empty:
|
| 548 |
+
rotated_valve = MultiPolygon()
|
| 549 |
+
else:
|
| 550 |
+
rotated_valve = _as_multipolygon(rotate(valve, -angle_degrees, origin=pivot))
|
| 551 |
+
|
| 552 |
+
rotated_frame_bounds = rotate(box(*frame), -angle_degrees, origin=pivot).bounds
|
| 553 |
+
anchor = _scan_anchor(rotated_frame_bounds[1], rotated_frame_bounds[3], fil_width)
|
| 554 |
+
|
| 555 |
+
theta = math.radians(-angle_degrees)
|
| 556 |
+
cos_f, sin_f = math.cos(theta), math.sin(theta)
|
| 557 |
+
rotated_current_x = pivot_x + (current_x - pivot_x) * cos_f - (current_y - pivot_y) * sin_f
|
| 558 |
+
rotated_current_y = pivot_y + (current_x - pivot_x) * sin_f + (current_y - pivot_y) * cos_f
|
| 559 |
+
|
| 560 |
+
segments = _oriented_axis_raster_segments(
|
| 561 |
+
rotated_motion,
|
| 562 |
+
rotated_valve,
|
| 563 |
+
fil_width,
|
| 564 |
+
"X",
|
| 565 |
+
rotated_current_x,
|
| 566 |
+
rotated_current_y,
|
| 567 |
+
prefer_default=prefer_default,
|
| 568 |
+
scan_anchor=anchor,
|
| 569 |
+
infill_keep=infill_keep,
|
| 570 |
+
)
|
| 571 |
+
|
| 572 |
+
theta_back = math.radians(angle_degrees)
|
| 573 |
+
cos_b, sin_b = math.cos(theta_back), math.sin(theta_back)
|
| 574 |
+
|
| 575 |
+
def back(x: float, y: float) -> tuple[float, float]:
|
| 576 |
+
return (
|
| 577 |
+
pivot_x + (x - pivot_x) * cos_b - (y - pivot_y) * sin_b,
|
| 578 |
+
pivot_y + (x - pivot_x) * sin_b + (y - pivot_y) * cos_b,
|
| 579 |
+
)
|
| 580 |
+
|
| 581 |
+
return [
|
| 582 |
+
(*back(x0, y0), *back(x1, y1), color)
|
| 583 |
+
for x0, y0, x1, y1, color in segments
|
| 584 |
+
]
|
| 585 |
+
|
| 586 |
+
|
| 587 |
def _extend_polyline_ends(
|
| 588 |
points: list[tuple[float, float]],
|
| 589 |
length: float,
|
|
|
|
| 922 |
if stack is None or not stack.layers:
|
| 923 |
continue
|
| 924 |
|
| 925 |
+
if reference is not None:
|
| 926 |
+
delta_x, delta_y = _centering_delta(stack, reference)
|
| 927 |
+
else:
|
| 928 |
+
delta_x = delta_y = 0.0
|
| 929 |
+
|
| 930 |
+
if stack.contour_paths is not None:
|
| 931 |
+
# Split pieces carry seam-free contour paths computed from the
|
| 932 |
+
# parent shape's outline; use them directly.
|
| 933 |
+
for layer_number in range(min(n_layers, len(stack.contour_paths))):
|
| 934 |
+
contours = [
|
| 935 |
+
[(x + delta_x, y + delta_y) for x, y in path]
|
| 936 |
+
for path in stack.contour_paths[layer_number]
|
| 937 |
+
if len(path) >= 2
|
| 938 |
+
]
|
| 939 |
+
if contours:
|
| 940 |
+
contour_layers[layer_number].append(
|
| 941 |
+
{
|
| 942 |
+
"owner_idx": source.owner_idx,
|
| 943 |
+
"contours": contours,
|
| 944 |
+
}
|
| 945 |
+
)
|
| 946 |
+
continue
|
| 947 |
+
|
| 948 |
if reference is not None:
|
| 949 |
layers = align_stack_to(stack, reference, n_layers)
|
| 950 |
else:
|
|
|
|
| 1029 |
else:
|
| 1030 |
nearest_x, nearest_y = current_x, current_y
|
| 1031 |
approach_dx, approach_dy = 0.0, 0.0
|
| 1032 |
+
is_closed = len(contour) >= 4 and contour[0] == contour[-1]
|
| 1033 |
+
if is_closed:
|
| 1034 |
+
contour = _rotate_closed_contour_to_nearest_border(
|
| 1035 |
+
contour,
|
| 1036 |
+
nearest_x,
|
| 1037 |
+
nearest_y,
|
| 1038 |
+
approach_dx,
|
| 1039 |
+
approach_dy,
|
| 1040 |
+
)
|
| 1041 |
+
if contour[0] != contour[-1]:
|
| 1042 |
+
contour = [*contour, contour[0]]
|
| 1043 |
+
else:
|
| 1044 |
+
# Open arc (a split piece's seam-free outline): approach the
|
| 1045 |
+
# nearer end and print to the other. Never close the loop —
|
| 1046 |
+
# the closing chord would run along the cut seam this path
|
| 1047 |
+
# deliberately excludes.
|
| 1048 |
+
if _point_distance_sq(
|
| 1049 |
+
nearest_x, nearest_y, contour[-1][0], contour[-1][1]
|
| 1050 |
+
) < _point_distance_sq(
|
| 1051 |
+
nearest_x, nearest_y, contour[0][0], contour[0][1]
|
| 1052 |
+
):
|
| 1053 |
+
contour = list(reversed(contour))
|
| 1054 |
start_x, start_y = contour[0]
|
| 1055 |
use_infill_reference = False
|
| 1056 |
current_x, current_y = _append_relative_move(
|
|
|
|
| 1124 |
contour_layers: list[list[dict]] | None = None,
|
| 1125 |
active_contour_owner: int | None = None,
|
| 1126 |
shared_motion: bool = False,
|
| 1127 |
+
scan_frame: tuple[float, float, float, float] | None = None,
|
| 1128 |
infill_fraction: float = 1.0,
|
| 1129 |
) -> tuple[list[dict], tuple[float, float]]:
|
| 1130 |
"""Assemble per-layer segments into a relative move list for all patterns.
|
| 1131 |
|
| 1132 |
+
`scan_frame` (an XY box) pins the rasters' scanlines to a global grid so
|
| 1133 |
+
lines stack across layers and across split pieces, and provides the
|
| 1134 |
+
rotation pivot for diagonal layers.
|
| 1135 |
|
| 1136 |
`infill_fraction` < 1 skips dispensing on evenly-distributed lines (grid
|
| 1137 |
lines for axis rasters, rings/revolutions for spirals) while the motion
|
|
|
|
| 1143 |
"""
|
| 1144 |
raster_pattern = _normalize_raster_pattern(raster_pattern)
|
| 1145 |
infill_keep = _infill_line_keep(infill_fraction)
|
| 1146 |
+
if scan_frame is not None:
|
| 1147 |
+
anchor_x = _scan_anchor(scan_frame[0], scan_frame[2], fil_width)
|
| 1148 |
+
anchor_y = _scan_anchor(scan_frame[1], scan_frame[3], fil_width)
|
| 1149 |
+
else:
|
| 1150 |
+
anchor_x = anchor_y = None
|
| 1151 |
gcode_list: list[dict] = []
|
| 1152 |
current_x = 0.0
|
| 1153 |
current_y = 0.0
|
|
|
|
| 1209 |
return infill_keep(max(0, int(round(inset / fil_width))))
|
| 1210 |
|
| 1211 |
segments = _classify_polyline(points, valve, keep_point=keep_point)
|
| 1212 |
+
elif (
|
| 1213 |
+
raster_pattern == RASTER_PATTERN_DIAGONAL_WOODPILE
|
| 1214 |
+
and _diagonal_layer_angle(layer_number) is not None
|
| 1215 |
+
):
|
| 1216 |
+
angle = _diagonal_layer_angle(layer_number)
|
| 1217 |
+
frame = scan_frame if scan_frame is not None else motion.bounds
|
| 1218 |
+
segments = _rotated_raster_segments(
|
| 1219 |
+
motion,
|
| 1220 |
+
valve,
|
| 1221 |
+
fil_width,
|
| 1222 |
+
angle,
|
| 1223 |
+
current_x,
|
| 1224 |
+
current_y,
|
| 1225 |
+
prefer_default=not raster_origin_initialized,
|
| 1226 |
+
frame=frame,
|
| 1227 |
+
infill_keep=infill_keep,
|
| 1228 |
+
)
|
| 1229 |
else:
|
| 1230 |
raster_axis = _raster_axis_for_pattern(raster_pattern, layer_number)
|
| 1231 |
+
# Axis "X" sweeps along X with rows stacked in Y, so its
|
| 1232 |
+
# scanlines use the Y anchor (and vice versa).
|
| 1233 |
+
axis_anchor = anchor_x if raster_axis == "Y" else anchor_y
|
|
|
|
|
|
|
|
|
|
| 1234 |
segments = _oriented_axis_raster_segments(
|
| 1235 |
motion,
|
| 1236 |
valve,
|
|
|
|
| 1553 |
return adjusted
|
| 1554 |
|
| 1555 |
|
| 1556 |
+
def _clip_contour_paths(
|
| 1557 |
+
source_lines: object,
|
| 1558 |
+
cell: object,
|
| 1559 |
+
) -> list[list[tuple[float, float]]]:
|
| 1560 |
+
"""Contour polylines of a split piece: the parent outline inside the cell.
|
| 1561 |
+
|
| 1562 |
+
Clipping the PARENT's boundary linework (instead of taking the piece
|
| 1563 |
+
polygon's own boundary) excludes the cut seams between sibling pieces —
|
| 1564 |
+
only the true outer surface remains. Results are merged into maximal
|
| 1565 |
+
polylines and ordered/oriented deterministically so pieces sharing
|
| 1566 |
+
reference motion trace them identically.
|
| 1567 |
+
"""
|
| 1568 |
+
clipped = source_lines.intersection(cell)
|
| 1569 |
+
pieces = list(_iter_linestrings(clipped))
|
| 1570 |
+
if not pieces:
|
| 1571 |
+
return []
|
| 1572 |
+
merged = linemerge(MultiLineString(pieces)) if len(pieces) > 1 else pieces[0]
|
| 1573 |
+
|
| 1574 |
+
paths: list[list[tuple[float, float]]] = []
|
| 1575 |
+
for line in _iter_linestrings(merged):
|
| 1576 |
+
coords = [(float(x), float(y)) for x, y in line.coords]
|
| 1577 |
+
if len(coords) < 2 or LineString(coords).length <= EPS:
|
| 1578 |
+
continue
|
| 1579 |
+
# Normalize open-path orientation (closed rings keep first == last).
|
| 1580 |
+
if coords[0] != coords[-1] and coords[-1] < coords[0]:
|
| 1581 |
+
coords.reverse()
|
| 1582 |
+
paths.append(coords)
|
| 1583 |
+
|
| 1584 |
+
paths.sort(
|
| 1585 |
+
key=lambda path: (
|
| 1586 |
+
min(point[1] for point in path),
|
| 1587 |
+
min(point[0] for point in path),
|
| 1588 |
+
-len(path),
|
| 1589 |
+
)
|
| 1590 |
+
)
|
| 1591 |
+
return paths
|
| 1592 |
+
|
| 1593 |
+
|
| 1594 |
def split_layer_stack_grid(
|
| 1595 |
stack: LayerStack,
|
| 1596 |
columns: int,
|
|
|
|
| 1640 |
x_cell = col_index - 1
|
| 1641 |
|
| 1642 |
layers: list[MultiPolygon] = []
|
| 1643 |
+
contour_paths: list[list[list[tuple[float, float]]]] = []
|
| 1644 |
for layer_number, layer in enumerate(stack.layers):
|
| 1645 |
if layer is None or layer.is_empty:
|
| 1646 |
layers.append(MultiPolygon())
|
| 1647 |
+
contour_paths.append([])
|
| 1648 |
continue
|
| 1649 |
x_edges = layer_x_edges[layer_number]
|
| 1650 |
y_edges = layer_y_edges[layer_number]
|
|
|
|
| 1656 |
)
|
| 1657 |
layers.append(_as_multipolygon(layer.intersection(cell)))
|
| 1658 |
|
| 1659 |
+
# Contours come from the parent's outline (or, when
|
| 1660 |
+
# re-splitting a piece, its already-seam-free paths) so the
|
| 1661 |
+
# cut lines between siblings are never traced.
|
| 1662 |
+
if stack.contour_paths is not None:
|
| 1663 |
+
parent_paths = (
|
| 1664 |
+
stack.contour_paths[layer_number]
|
| 1665 |
+
if layer_number < len(stack.contour_paths)
|
| 1666 |
+
else []
|
| 1667 |
+
)
|
| 1668 |
+
source_lines = MultiLineString(
|
| 1669 |
+
[path for path in parent_paths if len(path) >= 2]
|
| 1670 |
+
)
|
| 1671 |
+
else:
|
| 1672 |
+
source_lines = layer.boundary
|
| 1673 |
+
contour_paths.append(_clip_contour_paths(source_lines, cell))
|
| 1674 |
+
|
| 1675 |
pieces.append(
|
| 1676 |
LayerStack(
|
| 1677 |
layers=layers,
|
|
|
|
| 1683 |
layer_height=stack.layer_height,
|
| 1684 |
name=f"{base_name}_r{row_index:02d}_c{col_index:02d}",
|
| 1685 |
scan_frame=scan_frame,
|
| 1686 |
+
contour_paths=contour_paths,
|
| 1687 |
)
|
| 1688 |
)
|
| 1689 |
|