CyGuy8 Claude Fable 5 commited on
Commit
859a75d
·
1 Parent(s): 4d202b8

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>

Files changed (5) hide show
  1. README.md +2 -2
  2. stl_slicer.py +6 -0
  3. tests/test_vector_gcode.py +157 -0
  4. vector_gcode.py +9 -10
  5. vector_toolpath.py +223 -22
README.md CHANGED
@@ -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
 
stl_slicer.py CHANGED
@@ -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:
tests/test_vector_gcode.py CHANGED
@@ -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")
vector_gcode.py CHANGED
@@ -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 to the motion stack's frame (a split
244
- # piece's frame is its parent shape's bounds) so lines stack across
245
- # layers and stay on one continuous grid across split pieces.
 
246
  frame_stack = motion if motion is not None else shape
247
  if frame_stack.scan_frame is not None:
248
- frame_x_min, frame_y_min, frame_x_max, frame_y_max = frame_stack.scan_frame
249
  else:
250
  (frame_x_min, frame_y_min, _fz), (frame_x_max, frame_y_max, _fz2) = frame_stack.bounds
251
- scan_anchors = (
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
- scan_anchors=scan_anchors,
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
 
vector_toolpath.py CHANGED
@@ -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
- contour = _rotate_closed_contour_to_nearest_border(
922
- contour,
923
- nearest_x,
924
- nearest_y,
925
- approach_dx,
926
- approach_dy,
927
- )
928
- if contour[0] != contour[-1]:
929
- contour = [*contour, contour[0]]
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- scan_anchors: tuple[float, float] | None = None,
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
- `scan_anchors` = (x_anchor, y_anchor) pins the axis rasters' scanlines to
1009
- a global grid so lines stack across layers and across split pieces.
 
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
- if scan_anchors is None:
1085
- axis_anchor = None
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