CyGuy8 Claude Fable 5 commited on
Commit
a63a4fc
·
1 Parent(s): 83af5eb

Overlapping Rows interlock, overlap size + direction options, auto-align on generate

Browse files

- Overlapping Rows: each interior split cut becomes a comb within every
layer - alternate raster rows (riding the shared scanline grid, one
band per row) reach past the seam in opposite directions so
neighbouring pieces interlock like fingers; the phase flips per layer
so teeth also interlock in Z. Exact tiling: no gaps, no double-print.
- Row Overlap Direction: comb only the column seams (X), only the row
seams (Y), or both.
- Overlap (mm) input sets the depth of both the row and layer
interlocks (blank/0 = one filament width), auto-capped so comb
boundaries can never cross.
- Generate G-Code now runs Auto Align Split Parts automatically when
the table holds split pieces, before the parallel re-render - the
Visualization tab comes up already reassembled. No-op for unsplit
tables.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

Files changed (5) hide show
  1. README.md +4 -2
  2. app.py +136 -12
  3. tests/test_nozzle_spacing.py +79 -0
  4. tests/test_vector_gcode.py +148 -0
  5. vector_toolpath.py +201 -10
README.md CHANGED
@@ -104,7 +104,9 @@ The **Multi-Nozzle Split** accordion on the **Shapes & G-Code** tab can split on
104
  - Each layer's geometry is clipped against equal-size grid cells, so every piece keeps exact vector outlines.
105
  - The selected shape is replaced in Shape Settings by one generated record per grid cell, named by row and column.
106
  - Nozzle and valve numbers are assigned sequentially from the starting values, and **Generate G-Code** produces separate G-code for each piece.
107
- - **Overlapping Layers** alternates the interior cut lines by one filament width per layer so neighbouring pieces interlock.
 
 
108
  - **Multi-material assemblies split as one shape**: if the selected shape shares its nozzle with other shapes, the whole group is split together — every material is clipped by the same cell grid over the group's combined bounds. Pieces are emitted cell by cell: each cell's pieces share a nozzle (so every cell is itself a multi-material group, keeping the alignment and seam-free contour behavior), each piece gets its own valve, and cells where a material has no geometry are skipped. Auto Align works on the result like any other split.
109
 
110
  ### G-code XY Step Size
@@ -124,7 +126,7 @@ The **Multi-Nozzle Split** accordion on the **Shapes & G-Code** tab can split on
124
  - **Combined reference outline for motion** (always on): 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 whenever shapes are sliced or G-code is generated. Contour tracing stays synchronized too: every shape traces every traced shape's contour, opening its valve only on its own outline.
125
  - Generated files contain only machine commands (no metadata comments). The toolpath's world anchor — the position, in the shape's own frame, that the relative moves start from — is kept on the shape's session record and used to place parallel parts so split pieces reassemble.
126
  - **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 rectangular loops from the outside toward the center, then reverses from center to edge on the next layer; the loops live on one family anchored to the shape frame, so every layer (and every split sibling) walks the same rectangles and the walls stack — outer loops that cannot touch a layer's material are skipped instead of traveled. `Circle Spiral raster` prints concentric circles stepping inward by one line width per revolution — each revolution stays at a constant radius, so the walls are smooth true circles — then reverses outward on the next layer. The outermost revolution is a perimeter wall hugging the layer's material edge (half a bead inside its farthest boundary point), so the printed silhouette follows the shape smoothly; a matching inner wall hugs a central hole when there is one. The fill rings between the walls come from one global radii grid anchored at the shape frame's center, so interior rings stack exactly across layers, and rings that cannot touch a layer's material (or would overlap a wall bead) are skipped instead of traveled. Walls always dispense even under partial infill, like contour tracing; elsewhere the valve opens only where the path is inside material. Under shared reference motion every shape's own wall radius joins the one shared ring set (all heads travel all walls, each dispenses only its own), and rings that would graze a shape's boundary are suppressed for that shape — so each parallel shape keeps a smooth, complete outer circle regardless of its dimensions.
127
- - **Auto Align Split Parts**: in Nozzle Spacing, computes exact per-connection grid gaps from the split pieces' generated G-code (world toolpath 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.
128
  - **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). Multi-material assembly parts (shapes sharing a nozzle) work the same way: boundary within half a bead of a sibling material counts as an internal interface and is not contoured — only the assembled shape's true outer surface is traced, and a part fully embedded in the assembly gets no contour at all.
129
 
130
  ### Print vs Travel Classification
 
104
  - Each layer's geometry is clipped against equal-size grid cells, so every piece keeps exact vector outlines.
105
  - The selected shape is replaced in Shape Settings by one generated record per grid cell, named by row and column.
106
  - Nozzle and valve numbers are assigned sequentially from the starting values, and **Generate G-Code** produces separate G-code for each piece.
107
+ - **Overlapping Layers** alternates the interior cut lines sideways between layers so stacked pieces interlock.
108
+ - **Overlapping Rows** turns each interior cut into a comb within every layer: alternate raster rows (aligned to the shared scanline grid) reach across the seam in opposite directions, so neighbouring pieces interlock like fingers. **Row Overlap Direction** picks which seams comb: X only (column cuts), Y only (row cuts), or both.
109
+ - **Overlap (mm)** sets the depth of both interlocks (blank or 0 falls back to one filament width). The two options can be combined.
110
  - **Multi-material assemblies split as one shape**: if the selected shape shares its nozzle with other shapes, the whole group is split together — every material is clipped by the same cell grid over the group's combined bounds. Pieces are emitted cell by cell: each cell's pieces share a nozzle (so every cell is itself a multi-material group, keeping the alignment and seam-free contour behavior), each piece gets its own valve, and cells where a material has no geometry are skipped. Auto Align works on the result like any other split.
111
 
112
  ### G-code XY Step Size
 
126
  - **Combined reference outline for motion** (always on): 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 whenever shapes are sliced or G-code is generated. Contour tracing stays synchronized too: every shape traces every traced shape's contour, opening its valve only on its own outline.
127
  - Generated files contain only machine commands (no metadata comments). The toolpath's world anchor — the position, in the shape's own frame, that the relative moves start from — is kept on the shape's session record and used to place parallel parts so split pieces reassemble.
128
  - **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 rectangular loops from the outside toward the center, then reverses from center to edge on the next layer; the loops live on one family anchored to the shape frame, so every layer (and every split sibling) walks the same rectangles and the walls stack — outer loops that cannot touch a layer's material are skipped instead of traveled. `Circle Spiral raster` prints concentric circles stepping inward by one line width per revolution — each revolution stays at a constant radius, so the walls are smooth true circles — then reverses outward on the next layer. The outermost revolution is a perimeter wall hugging the layer's material edge (half a bead inside its farthest boundary point), so the printed silhouette follows the shape smoothly; a matching inner wall hugs a central hole when there is one. The fill rings between the walls come from one global radii grid anchored at the shape frame's center, so interior rings stack exactly across layers, and rings that cannot touch a layer's material (or would overlap a wall bead) are skipped instead of traveled. Walls always dispense even under partial infill, like contour tracing; elsewhere the valve opens only where the path is inside material. Under shared reference motion every shape's own wall radius joins the one shared ring set (all heads travel all walls, each dispenses only its own), and rings that would graze a shape's boundary are suppressed for that shape — so each parallel shape keeps a smooth, complete outer circle regardless of its dimensions.
129
+ - **Auto Align Split Parts**: in Nozzle Spacing, computes exact per-connection grid gaps from the split pieces' generated G-code (world toolpath 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. **Generate G-Code runs it automatically** whenever the table contains split pieces, so the parallel view comes up already reassembled; the button remains for re-aligning after manual spacing edits.
130
  - **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). Multi-material assembly parts (shapes sharing a nozzle) work the same way: boundary within half a bead of a sibling material counts as an internal interface and is not contoured — only the assembled shape's true outer surface is traced, and a part fully embedded in the assembly gets no contour at all.
131
 
132
  ### Print vs Travel Classification
app.py CHANGED
@@ -2591,6 +2591,26 @@ def auto_align_split_parts(
2591
  )
2592
 
2593
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2594
  def update_nozzle_grid_preset(
2595
  preset: str | None,
2596
  records: list[dict] | None,
@@ -3703,6 +3723,9 @@ def _split_group_records(
3703
  split_column_count: int,
3704
  split_row_count: int,
3705
  overlapping_layers: bool,
 
 
 
3706
  starting_nozzle: Any,
3707
  starting_valve: Any,
3708
  fil_width: float,
@@ -3755,7 +3778,9 @@ def _split_group_records(
3755
  columns=split_column_count,
3756
  rows=split_row_count,
3757
  overlapping_layers=bool(overlapping_layers),
3758
- overlap=float(fil_width) if overlapping_layers else 0.0,
 
 
3759
  grid=float(fil_width),
3760
  frame=frame,
3761
  ),
@@ -3824,12 +3849,47 @@ def _split_group_records(
3824
  f"Each cell's pieces share a nozzle (nozzles {first_nozzle}-"
3825
  f"{first_nozzle + cell_count - 1}); valves {first_valve}-{valve_cursor - 1}."
3826
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3827
  if overlapping_layers:
3828
- status += (
3829
- " \nOverlapping Layers is enabled: split boundaries alternate by one "
3830
- "filament width per layer so neighbouring pieces interlock."
3831
  )
3832
- return _outputs(next_records, split_selected, status)
 
 
 
 
 
 
 
 
 
3833
 
3834
 
3835
  def split_selected_shape_for_grid(
@@ -3845,8 +3905,17 @@ def split_selected_shape_for_grid(
3845
  layer_height: float = 0.8,
3846
  scale_mode: str | None = None,
3847
  undo_stack: list[list[dict]] | None = None,
 
 
 
3848
  ) -> tuple:
3849
  records = _apply_shape_settings(records or [], settings_table)
 
 
 
 
 
 
3850
  # One-button flow: shapes are sliced (or re-sliced when their settings
3851
  # changed) straight from the table here, so splitting never needs a
3852
  # separate slicing step.
@@ -3912,6 +3981,9 @@ def split_selected_shape_for_grid(
3912
  split_column_count,
3913
  split_row_count,
3914
  overlapping_layers,
 
 
 
3915
  starting_nozzle,
3916
  starting_valve,
3917
  fil_width,
@@ -3925,7 +3997,9 @@ def split_selected_shape_for_grid(
3925
  columns=split_column_count,
3926
  rows=split_row_count,
3927
  overlapping_layers=bool(overlapping_layers),
3928
- overlap=float(fil_width) if overlapping_layers else 0.0,
 
 
3929
  # Whole-fil cells (last piece absorbs the remainder): keeps the
3930
  # required nozzle spacing uniform under shared reference motion.
3931
  grid=float(fil_width),
@@ -3968,11 +4042,9 @@ def split_selected_shape_for_grid(
3968
  f"({split_column_count} columns x {split_row_count} rows). \n"
3969
  f"Nozzles {first_nozzle}-{first_nozzle + len(pieces) - 1}; valves {first_valve}-{first_valve + len(pieces) - 1}."
3970
  )
3971
- if overlapping_layers:
3972
- status += (
3973
- " \nOverlapping Layers is enabled: split boundaries alternate by one "
3974
- "filament width per layer so neighbouring pieces interlock."
3975
- )
3976
  return _outputs(next_records, split_selected, status)
3977
 
3978
 
@@ -4973,7 +5045,34 @@ def build_dynamic_demo() -> gr.Blocks:
4973
  split_rows = gr.Number(label="Rows (Y)", value=1, minimum=1, step=1)
4974
  split_start_nozzle = gr.Number(label="Starting Nozzle", value=1, minimum=1, step=1)
4975
  split_start_valve = gr.Number(label="Starting Valve", value=4, minimum=1, step=1)
4976
- split_overlapping_layers = gr.Checkbox(label="Overlapping Layers", value=False)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4977
  with gr.Row():
4978
  split_button = gr.Button("Split Selected Shape into Grid Pieces", variant="primary", scale=3)
4979
  split_undo_button = gr.Button("Undo Split", variant="secondary", size="sm", scale=1, min_width=110)
@@ -5335,6 +5434,9 @@ def build_dynamic_demo() -> gr.Blocks:
5335
  layer_height,
5336
  scale_mode,
5337
  split_undo,
 
 
 
5338
  ],
5339
  outputs=[
5340
  shape_records,
@@ -5542,6 +5644,28 @@ def build_dynamic_demo() -> gr.Blocks:
5542
  inputs=stale_inputs,
5543
  outputs=[gcode_stale_banner],
5544
  queue=False,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5545
  ).then(
5546
  # Fresh files: refresh the parallel view so the Visualization
5547
  # tab always shows the current print.
 
2591
  )
2592
 
2593
 
2594
+ def auto_align_split_parts_after_generate(
2595
+ records: list[dict] | None,
2596
+ columns: Any,
2597
+ rows: Any,
2598
+ column_spacing: Any,
2599
+ row_spacing: Any,
2600
+ ) -> tuple:
2601
+ """Auto-run of Auto Align Split Parts right after Generate G-Code.
2602
+
2603
+ Runs before the post-generate parallel render so the Visualization tab
2604
+ comes up with the pieces already reassembled. Silent no-op when the
2605
+ table holds no split pieces, so plain generations leave the spacing
2606
+ controls and status untouched.
2607
+ """
2608
+ records = records or []
2609
+ if not any(record.get("split_group_id") for record in records):
2610
+ return tuple(gr.skip() for _ in range(7))
2611
+ return auto_align_split_parts(records, columns, rows, column_spacing, row_spacing)
2612
+
2613
+
2614
  def update_nozzle_grid_preset(
2615
  preset: str | None,
2616
  records: list[dict] | None,
 
3723
  split_column_count: int,
3724
  split_row_count: int,
3725
  overlapping_layers: bool,
3726
+ overlapping_rows: bool,
3727
+ overlap_mm: float,
3728
+ row_overlap_axes: str,
3729
  starting_nozzle: Any,
3730
  starting_valve: Any,
3731
  fil_width: float,
 
3778
  columns=split_column_count,
3779
  rows=split_row_count,
3780
  overlapping_layers=bool(overlapping_layers),
3781
+ overlapping_rows=bool(overlapping_rows),
3782
+ row_overlap_axes=row_overlap_axes,
3783
+ overlap=overlap_mm if (overlapping_layers or overlapping_rows) else 0.0,
3784
  grid=float(fil_width),
3785
  frame=frame,
3786
  ),
 
3849
  f"Each cell's pieces share a nozzle (nozzles {first_nozzle}-"
3850
  f"{first_nozzle + cell_count - 1}); valves {first_valve}-{valve_cursor - 1}."
3851
  )
3852
+ status += _split_overlap_status(
3853
+ overlapping_layers, overlapping_rows, overlap_mm, row_overlap_axes
3854
+ )
3855
+ return _outputs(next_records, split_selected, status)
3856
+
3857
+
3858
+ ROW_OVERLAP_AXES_CHOICES = ["Both", "X only", "Y only"]
3859
+
3860
+
3861
+ def _row_overlap_axes_value(choice: Any) -> str:
3862
+ """UI direction choice -> engine axes value ("both" | "x" | "y")."""
3863
+ text = str(choice or "").strip().lower()
3864
+ if text.startswith("x"):
3865
+ return "x"
3866
+ if text.startswith("y"):
3867
+ return "y"
3868
+ return "both"
3869
+
3870
+
3871
+ def _split_overlap_status(
3872
+ overlapping_layers: bool,
3873
+ overlapping_rows: bool,
3874
+ overlap_mm: float,
3875
+ row_overlap_axes: str = "both",
3876
+ ) -> str:
3877
+ parts = []
3878
  if overlapping_layers:
3879
+ parts.append(
3880
+ f" \nOverlapping Layers: split boundaries alternate by {overlap_mm:g} mm "
3881
+ "per layer so stacked pieces interlock."
3882
  )
3883
+ if overlapping_rows:
3884
+ seams = {
3885
+ "x": "the column seams (X direction)",
3886
+ "y": "the row seams (Y direction)",
3887
+ }.get(row_overlap_axes, "each seam")
3888
+ parts.append(
3889
+ f" \nOverlapping Rows: alternate raster rows reach {overlap_mm:g} mm "
3890
+ f"across {seams} so neighbouring pieces interlock within every layer."
3891
+ )
3892
+ return "".join(parts)
3893
 
3894
 
3895
  def split_selected_shape_for_grid(
 
3905
  layer_height: float = 0.8,
3906
  scale_mode: str | None = None,
3907
  undo_stack: list[list[dict]] | None = None,
3908
+ overlapping_rows: bool = False,
3909
+ overlap_size: float | None = None,
3910
+ row_overlap_direction: str | None = None,
3911
  ) -> tuple:
3912
  records = _apply_shape_settings(records or [], settings_table)
3913
+ # Overlap depth for both interlock styles; blank/zero falls back to one
3914
+ # filament width (the pre-option behavior).
3915
+ overlap_mm = round(_coerce_float(overlap_size, float(fil_width)), 3)
3916
+ if overlap_mm <= 0.0:
3917
+ overlap_mm = float(fil_width)
3918
+ row_overlap_axes = _row_overlap_axes_value(row_overlap_direction)
3919
  # One-button flow: shapes are sliced (or re-sliced when their settings
3920
  # changed) straight from the table here, so splitting never needs a
3921
  # separate slicing step.
 
3981
  split_column_count,
3982
  split_row_count,
3983
  overlapping_layers,
3984
+ overlapping_rows,
3985
+ overlap_mm,
3986
+ row_overlap_axes,
3987
  starting_nozzle,
3988
  starting_valve,
3989
  fil_width,
 
3997
  columns=split_column_count,
3998
  rows=split_row_count,
3999
  overlapping_layers=bool(overlapping_layers),
4000
+ overlapping_rows=bool(overlapping_rows),
4001
+ row_overlap_axes=row_overlap_axes,
4002
+ overlap=overlap_mm if (overlapping_layers or overlapping_rows) else 0.0,
4003
  # Whole-fil cells (last piece absorbs the remainder): keeps the
4004
  # required nozzle spacing uniform under shared reference motion.
4005
  grid=float(fil_width),
 
4042
  f"({split_column_count} columns x {split_row_count} rows). \n"
4043
  f"Nozzles {first_nozzle}-{first_nozzle + len(pieces) - 1}; valves {first_valve}-{first_valve + len(pieces) - 1}."
4044
  )
4045
+ status += _split_overlap_status(
4046
+ overlapping_layers, overlapping_rows, overlap_mm, row_overlap_axes
4047
+ )
 
 
4048
  return _outputs(next_records, split_selected, status)
4049
 
4050
 
 
5045
  split_rows = gr.Number(label="Rows (Y)", value=1, minimum=1, step=1)
5046
  split_start_nozzle = gr.Number(label="Starting Nozzle", value=1, minimum=1, step=1)
5047
  split_start_valve = gr.Number(label="Starting Valve", value=4, minimum=1, step=1)
5048
+ with gr.Row():
5049
+ split_overlapping_layers = gr.Checkbox(
5050
+ label="Overlapping Layers",
5051
+ info="Cut lines alternate sideways between layers",
5052
+ value=False,
5053
+ scale=2,
5054
+ )
5055
+ split_overlapping_rows = gr.Checkbox(
5056
+ label="Overlapping Rows",
5057
+ info="Alternate raster rows reach across each seam",
5058
+ value=False,
5059
+ scale=2,
5060
+ )
5061
+ split_row_overlap_direction = gr.Dropdown(
5062
+ label="Row Overlap Direction",
5063
+ info="Which seams the rows reach across",
5064
+ choices=ROW_OVERLAP_AXES_CHOICES,
5065
+ value=ROW_OVERLAP_AXES_CHOICES[0],
5066
+ scale=1,
5067
+ )
5068
+ split_overlap = gr.Number(
5069
+ label="Overlap (mm)",
5070
+ info="Depth of both interlocks; 0 = one filament width",
5071
+ value=0.8,
5072
+ minimum=0.0,
5073
+ step=0.1,
5074
+ scale=1,
5075
+ )
5076
  with gr.Row():
5077
  split_button = gr.Button("Split Selected Shape into Grid Pieces", variant="primary", scale=3)
5078
  split_undo_button = gr.Button("Undo Split", variant="secondary", size="sm", scale=1, min_width=110)
 
5434
  layer_height,
5435
  scale_mode,
5436
  split_undo,
5437
+ split_overlapping_rows,
5438
+ split_overlap,
5439
+ split_row_overlap_direction,
5440
  ],
5441
  outputs=[
5442
  shape_records,
 
5644
  inputs=stale_inputs,
5645
  outputs=[gcode_stale_banner],
5646
  queue=False,
5647
+ ).then(
5648
+ # Fresh PathOrigins: reassemble split pieces automatically so the
5649
+ # Visualization tab needs no manual Auto Align click. Runs BEFORE
5650
+ # the parallel render below, which reads the spacing table.
5651
+ fn=auto_align_split_parts_after_generate,
5652
+ inputs=[
5653
+ shape_records,
5654
+ nozzle_grid_columns,
5655
+ nozzle_grid_rows,
5656
+ nozzle_grid_column_spacing,
5657
+ nozzle_grid_row_spacing,
5658
+ ],
5659
+ outputs=[
5660
+ nozzle_grid_columns,
5661
+ nozzle_grid_rows,
5662
+ nozzle_grid_column_spacing,
5663
+ nozzle_grid_row_spacing,
5664
+ nozzle_grid_use_individual_spacing,
5665
+ nozzle_grid_spacing_table,
5666
+ nozzle_spacing_status,
5667
+ ],
5668
+ queue=False,
5669
  ).then(
5670
  # Fresh files: refresh the parallel view so the Visualization
5671
  # tab always shows the current print.
tests/test_nozzle_spacing.py CHANGED
@@ -748,6 +748,29 @@ def test_auto_align_grid_spacing_skips_unsplit_records() -> None:
748
  assert rows == [["Nozzle 1: Shape 1", "Nozzle 2: Shape 2", 10.0, 0.0]]
749
 
750
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
751
  def test_grid_spacing_rows_follow_row_major_pattern() -> None:
752
  records = [
753
  {"idx": 1, "name": "first", "nozzle": 1},
@@ -1003,6 +1026,62 @@ def test_group_split_splits_all_materials_on_one_shared_grid() -> None:
1003
  assert x <= 10.0 - 0.5 + 1e-9 or y <= 5.0 - 0.5 + 1e-9
1004
 
1005
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1006
  def test_describe_split_source_warns_about_group_splits() -> None:
1007
  from shapely.geometry import MultiPolygon, box
1008
 
 
748
  assert rows == [["Nozzle 1: Shape 1", "Nozzle 2: Shape 2", 10.0, 0.0]]
749
 
750
 
751
+ def test_generate_runs_auto_align_only_for_split_pieces(tmp_path) -> None:
752
+ from app import auto_align_split_parts_after_generate
753
+
754
+ # Plain (unsplit) shapes: a silent skip that touches nothing on the
755
+ # Visualization tab.
756
+ plain = [
757
+ {"idx": 1, "name": "first", "nozzle": 1},
758
+ {"idx": 2, "name": "second", "nozzle": 2},
759
+ ]
760
+ outputs = auto_align_split_parts_after_generate(plain, 2, 1, 5.0, 5.0)
761
+ assert len(outputs) == 7
762
+ assert all(output == {"__type__": "update"} for output in outputs)
763
+
764
+ # Split pieces with generated G-code: delegates to Auto Align, which
765
+ # fills the advanced spacing table and reports the alignment.
766
+ records = _split_piece_records(tmp_path, columns=2, rows=1)
767
+ outputs = auto_align_split_parts_after_generate(records, 2, 1, 5.0, 5.0)
768
+ status = outputs[-1]
769
+ assert "Auto aligned" in status
770
+ table_update = outputs[5]
771
+ assert table_update.get("value")
772
+
773
+
774
  def test_grid_spacing_rows_follow_row_major_pattern() -> None:
775
  records = [
776
  {"idx": 1, "name": "first", "nozzle": 1},
 
1026
  assert x <= 10.0 - 0.5 + 1e-9 or y <= 5.0 - 0.5 + 1e-9
1027
 
1028
 
1029
+ def test_split_overlapping_rows_option_combs_pieces() -> None:
1030
+ from shapely.geometry import MultiPolygon, box
1031
+
1032
+ from app import _slice_params_snapshot, split_selected_shape_for_grid
1033
+ from stl_slicer import LayerStack
1034
+
1035
+ layer = MultiPolygon([box(0.0, 0.0, 8.0, 6.0)])
1036
+ stack = LayerStack(
1037
+ layers=[layer, layer],
1038
+ z_values=[0.4, 1.2],
1039
+ bounds=((0.0, 0.0, 0.0), (8.0, 6.0, 1.6)),
1040
+ layer_height=0.8,
1041
+ name="comb",
1042
+ )
1043
+ record = {
1044
+ "idx": 1,
1045
+ "name": "comb",
1046
+ "stl_path": "comb.stl",
1047
+ "target_x": 8.0,
1048
+ "target_y": 6.0,
1049
+ "target_z": 1.6,
1050
+ "pressure": 25.0,
1051
+ "valve": 4,
1052
+ "nozzle": 1,
1053
+ "port": 1,
1054
+ "color": "#111111",
1055
+ "layer_stack": stack,
1056
+ }
1057
+ record["slice_params"] = _slice_params_snapshot(record, 0.8, None, None)
1058
+
1059
+ outputs = split_selected_shape_for_grid(
1060
+ [record],
1061
+ None, # selected -> defaults to the first record
1062
+ None, # settings table
1063
+ 2, # columns
1064
+ 1, # rows
1065
+ False, # overlapping layers
1066
+ 5, # starting nozzle
1067
+ 9, # starting valve
1068
+ 1.0, # fil width
1069
+ overlapping_rows=True,
1070
+ overlap_size=0.5,
1071
+ )
1072
+ next_records = outputs[0]
1073
+ status = outputs[7]
1074
+
1075
+ pieces = [rec for rec in next_records if rec.get("split_group_id")]
1076
+ assert len(pieces) == 2
1077
+ left_stack = pieces[0]["layer_stack"]
1078
+ # Alternate raster-row teeth reach the chosen 0.5 mm past the x=4 cut...
1079
+ assert abs(left_stack.layers[0].bounds[2] - 4.5) < 1e-9
1080
+ # ...while the nominal piece bounds (and targets) stay the plain cell.
1081
+ assert pieces[0]["layer_stack"].bounds[1][0] == 4.0
1082
+ assert "Overlapping Rows" in status and "0.5 mm" in status
1083
+
1084
+
1085
  def test_describe_split_source_warns_about_group_splits() -> None:
1086
  from shapely.geometry import MultiPolygon, box
1087
 
tests/test_vector_gcode.py CHANGED
@@ -1683,6 +1683,154 @@ def test_split_contour_gcode_never_traces_the_cuts(tmp_path) -> None:
1683
  assert vertical_prints == []
1684
 
1685
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1686
  def test_split_layer_stack_grid_overlap_alternates_between_layers() -> None:
1687
  layer = box(0.0, 0.0, 4.0, 2.0)
1688
  stack = _stack(layer, layer, name="interlock")
 
1683
  assert vertical_prints == []
1684
 
1685
 
1686
+ def test_split_overlapping_rows_comb_within_each_layer() -> None:
1687
+ layer = box(0.0, 0.0, 8.0, 6.0)
1688
+ stack = _stack(layer, layer, name="comb")
1689
+
1690
+ left, right = split_layer_stack_grid(
1691
+ stack, columns=2, rows=1, overlapping_rows=True, overlap=1.0, grid=1.0
1692
+ )
1693
+
1694
+ # Scan frame (0,0,8,6) at 1 mm pitch: rows at y=0.5..5.5, so band k spans
1695
+ # y in [k, k+1]. The x=4 seam combs: even bands push the boundary to 5,
1696
+ # odd bands pull it to 3 (on layer 0). Probes are inset from the band
1697
+ # edges so they don't graze the neighbouring tooth's boundary segment.
1698
+ band0 = box(0.0, 0.1, 8.0, 0.9)
1699
+ band1 = box(0.0, 1.1, 8.0, 1.9)
1700
+ assert abs(left.layers[0].intersection(band0).bounds[2] - 5.0) < 1e-9
1701
+ assert abs(left.layers[0].intersection(band1).bounds[2] - 3.0) < 1e-9
1702
+ assert abs(right.layers[0].intersection(band0).bounds[0] - 5.0) < 1e-9
1703
+ assert abs(right.layers[0].intersection(band1).bounds[0] - 3.0) < 1e-9
1704
+ # The comb phase flips between layers so the teeth interlock in Z too.
1705
+ assert abs(left.layers[1].intersection(band0).bounds[2] - 3.0) < 1e-9
1706
+
1707
+ # The pieces still tile the parent exactly: no gap, no double-print.
1708
+ for layer_number in range(2):
1709
+ union = left.layers[layer_number].union(right.layers[layer_number])
1710
+ assert abs(union.area - layer.area) < 1e-6
1711
+ doubled = left.layers[layer_number].intersection(right.layers[layer_number])
1712
+ assert doubled.area < 1e-9
1713
+
1714
+ # Nominal bounds stay the un-shifted cells.
1715
+ assert left.bounds == ((0.0, 0.0, 0.0), (4.0, 6.0, 2.0))
1716
+ assert right.bounds == ((4.0, 0.0, 0.0), (8.0, 6.0, 2.0))
1717
+
1718
+
1719
+ def test_split_overlapping_rows_raster_rows_alternate_across_the_seam() -> None:
1720
+ from vector_toolpath import _axis_raster_segments
1721
+
1722
+ layer = box(0.0, 0.0, 8.0, 6.0)
1723
+ stack = _stack(layer, layer, name="combr")
1724
+ left, right = split_layer_stack_grid(
1725
+ stack, columns=2, rows=1, overlapping_rows=True, overlap=1.0, grid=1.0
1726
+ )
1727
+
1728
+ def print_spans(piece: LayerStack) -> dict:
1729
+ spans: dict = {}
1730
+ segments = _axis_raster_segments(
1731
+ piece.layers[0], piece.layers[0], 1.0, "X", scan_anchor=0.5
1732
+ )
1733
+ for seg in segments:
1734
+ if seg[4] != 255:
1735
+ continue
1736
+ row = round(seg[1], 6)
1737
+ lo, hi = spans.get(row, (min(seg[0], seg[2]), max(seg[0], seg[2])))
1738
+ spans[row] = (min(lo, seg[0], seg[2]), max(hi, seg[0], seg[2]))
1739
+ return spans
1740
+
1741
+ left_spans = print_spans(left)
1742
+ right_spans = print_spans(right)
1743
+ # Even rows: the left head prints past the seam to x=5 and the right head
1744
+ # takes over exactly there; odd rows mirror at x=3 — the interleaved
1745
+ # finger pattern.
1746
+ assert left_spans[0.5] == (0.0, 5.0)
1747
+ assert right_spans[0.5] == (5.0, 8.0)
1748
+ assert left_spans[1.5] == (0.0, 3.0)
1749
+ assert right_spans[1.5] == (3.0, 8.0)
1750
+
1751
+
1752
+ def test_split_overlapping_rows_axes_restrict_which_seams_comb() -> None:
1753
+ layer = box(0.0, 0.0, 8.0, 8.0)
1754
+ stack = _stack(layer, name="axes")
1755
+
1756
+ # X only: the vertical (column) seam combs, the horizontal seam stays a
1757
+ # straight cut at y=4.
1758
+ pieces = split_layer_stack_grid(
1759
+ stack,
1760
+ columns=2,
1761
+ rows=2,
1762
+ overlapping_rows=True,
1763
+ row_overlap_axes="x",
1764
+ overlap=1.0,
1765
+ grid=1.0,
1766
+ )
1767
+ top_left = pieces[0].layers[0] # row 1 = top strip
1768
+ bottom_left = pieces[2].layers[0]
1769
+ band0 = box(0.0, 0.1, 8.0, 0.9)
1770
+ band1 = box(0.0, 1.1, 8.0, 1.9)
1771
+ teeth_x = {
1772
+ round(bottom_left.intersection(band0).bounds[2], 6),
1773
+ round(bottom_left.intersection(band1).bounds[2], 6),
1774
+ }
1775
+ assert teeth_x == {3.0, 5.0}
1776
+ assert abs(top_left.bounds[1] - 4.0) < 1e-9 # straight horizontal cut
1777
+ assert abs(bottom_left.bounds[3] - 4.0) < 1e-9
1778
+
1779
+ # Y only: mirrored — the horizontal seam combs, the vertical cut at x=4
1780
+ # stays straight.
1781
+ pieces = split_layer_stack_grid(
1782
+ stack,
1783
+ columns=2,
1784
+ rows=2,
1785
+ overlapping_rows=True,
1786
+ row_overlap_axes="y",
1787
+ overlap=1.0,
1788
+ grid=1.0,
1789
+ )
1790
+ bottom_left = pieces[2].layers[0]
1791
+ col_band0 = box(0.1, 0.0, 0.9, 8.0)
1792
+ col_band1 = box(1.1, 0.0, 1.9, 8.0)
1793
+ teeth_y = {
1794
+ round(bottom_left.intersection(col_band0).bounds[3], 6),
1795
+ round(bottom_left.intersection(col_band1).bounds[3], 6),
1796
+ }
1797
+ assert teeth_y == {3.0, 5.0}
1798
+ assert abs(bottom_left.bounds[2] - 4.0) < 1e-9 # straight vertical cut
1799
+
1800
+ # Pieces always tile the parent, whichever axes comb.
1801
+ total = pieces[0].layers[0]
1802
+ for piece in pieces[1:]:
1803
+ total = total.union(piece.layers[0])
1804
+ assert abs(total.area - layer.area) < 1e-6
1805
+
1806
+
1807
+ def test_split_overlapping_rows_and_layers_combine() -> None:
1808
+ layer = box(0.0, 0.0, 8.0, 6.0)
1809
+ stack = _stack(layer, layer, name="both")
1810
+
1811
+ left, right = split_layer_stack_grid(
1812
+ stack,
1813
+ columns=2,
1814
+ rows=1,
1815
+ overlapping_layers=True,
1816
+ overlapping_rows=True,
1817
+ overlap=0.5,
1818
+ grid=1.0,
1819
+ )
1820
+
1821
+ # Layer shift moves the seam to 4.5 (layer 0), the comb then swings the
1822
+ # per-band boundary to 5.0 / 4.0 around it. Tiling stays exact.
1823
+ band0 = box(0.0, 0.1, 8.0, 0.9)
1824
+ band1 = box(0.0, 1.1, 8.0, 1.9)
1825
+ assert abs(left.layers[0].intersection(band0).bounds[2] - 5.0) < 1e-9
1826
+ assert abs(left.layers[0].intersection(band1).bounds[2] - 4.0) < 1e-9
1827
+ for layer_number in range(2):
1828
+ union = left.layers[layer_number].union(right.layers[layer_number])
1829
+ assert abs(union.area - layer.area) < 1e-6
1830
+ doubled = left.layers[layer_number].intersection(right.layers[layer_number])
1831
+ assert doubled.area < 1e-9
1832
+
1833
+
1834
  def test_split_layer_stack_grid_overlap_alternates_between_layers() -> None:
1835
  layer = box(0.0, 0.0, 4.0, 2.0)
1836
  stack = _stack(layer, layer, name="interlock")
vector_toolpath.py CHANGED
@@ -14,7 +14,7 @@ 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
 
@@ -1965,6 +1965,153 @@ def _shifted_split_edges(
1965
  return adjusted
1966
 
1967
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1968
  def _linework_to_paths(geometry: object) -> list[list[tuple[float, float]]]:
1969
  """Merge linework into maximal polylines, ordered/oriented
1970
  deterministically so shapes sharing reference motion trace them
@@ -2049,15 +2196,24 @@ def split_layer_stack_grid(
2049
  overlap: float = 0.0,
2050
  grid: float | None = None,
2051
  frame: tuple[float, float, float, float] | None = None,
 
 
2052
  ) -> list[LayerStack]:
2053
  """Split a sliced shape into a rows x columns grid of piece stacks.
2054
 
2055
  Pieces are returned row-major with row 1 the top strip (max-Y side),
2056
  matching the legacy image-grid ordering. With `overlapping_layers`, the
2057
  interior cut lines alternate by ±overlap between layers so neighbouring
2058
- pieces interlock. `grid` (the fil width) sizes the cells in whole grid
2059
- multiples (see `_base_split_edges`). Piece `bounds` are the nominal
2060
- (un-shifted) cell boxes.
 
 
 
 
 
 
 
2061
 
2062
  `frame` overrides the XY box the cell grid is computed over. Splitting
2063
  every member of a multi-material group with the group's combined bounds
@@ -2089,6 +2245,27 @@ def split_layer_stack_grid(
2089
  # one-fil-width pitch instead of each piece re-centring its own lines.
2090
  scan_frame = stack.scan_frame or (x_min, y_min, x_max, y_max)
2091
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2092
  base_name = stack.name or "shape"
2093
  pieces: list[LayerStack] = []
2094
  for row_index in range(1, rows + 1):
@@ -2106,12 +2283,26 @@ def split_layer_stack_grid(
2106
  continue
2107
  x_edges = layer_x_edges[layer_number]
2108
  y_edges = layer_y_edges[layer_number]
2109
- cell = box(
2110
- x_edges[x_cell],
2111
- y_edges[y_cell],
2112
- x_edges[x_cell + 1],
2113
- y_edges[y_cell + 1],
2114
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2115
  layers.append(_as_multipolygon(layer.intersection(cell)))
2116
 
2117
  # Contours come from the parent's outline (or, when
 
14
 
15
  from shapely import prepare
16
  from shapely.affinity import rotate, translate
17
+ from shapely.geometry import LineString, MultiLineString, MultiPolygon, Point, Polygon, box
18
  from shapely.geometry.polygon import orient
19
  from shapely.ops import linemerge, unary_union
20
 
 
1965
  return adjusted
1966
 
1967
 
1968
+ def _comb_amplitude(edges: list[float], overlap: float) -> float:
1969
+ """Row-overlap tooth depth, capped so comb boundaries can never cross.
1970
+
1971
+ Adjacent interior boundaries comb in opposite phases, converging by twice
1972
+ the amplitude within a band — so with several interior boundaries the
1973
+ teeth may reach at most half the narrowest cell. With a single interior
1974
+ boundary (2 cells) only the outer edges bound the teeth.
1975
+ """
1976
+ count = len(edges) - 1
1977
+ if count <= 1 or overlap <= 0.0:
1978
+ return 0.0
1979
+ min_width = min(edges[i + 1] - edges[i] for i in range(count))
1980
+ limit = min_width * (0.999 if count == 2 else 0.4995)
1981
+ return max(0.0, min(overlap, limit))
1982
+
1983
+
1984
+ def _comb_boundary(
1985
+ edges: list[float],
1986
+ boundary_index: int,
1987
+ band_index: int,
1988
+ layer_index: int,
1989
+ amplitude: float,
1990
+ ) -> float:
1991
+ """Comb boundary position at one raster-row band.
1992
+
1993
+ A pure function of (boundary, band, layer), so the two cells sharing a
1994
+ boundary always compute the same tooth positions and tile exactly. The
1995
+ parity convention extends `_shifted_split_edges`, so combining
1996
+ Overlapping Rows with Overlapping Layers keeps both alternations.
1997
+ """
1998
+ direction = 1 if (band_index + boundary_index + layer_index) % 2 == 1 else -1
1999
+ return edges[boundary_index] + direction * amplitude
2000
+
2001
+
2002
+ def _comb_profile_polygon(
2003
+ span_lo: float,
2004
+ span_hi: float,
2005
+ lo_for_band,
2006
+ hi_for_band,
2007
+ anchor: float,
2008
+ pitch: float,
2009
+ comb_axis: str,
2010
+ ) -> Polygon:
2011
+ """Staircase polygon: the combed extent per raster-row band.
2012
+
2013
+ Bands are one `pitch` tall, centred on the scanline grid
2014
+ {anchor + k*pitch} (band cuts sit midway BETWEEN scanlines), so each
2015
+ tooth covers exactly one raster row. `comb_axis` "X" means the combed
2016
+ coordinate is X with bands stacked along Y; "Y" is the transpose.
2017
+ """
2018
+ cuts: list[float] = []
2019
+ k = math.floor((span_lo - anchor) / pitch) - 2
2020
+ while True:
2021
+ cut = anchor + (k + 0.5) * pitch
2022
+ if cut >= span_hi - EPS:
2023
+ break
2024
+ if cut > span_lo + EPS:
2025
+ cuts.append(cut)
2026
+ k += 1
2027
+ stops = [span_lo, *cuts, span_hi]
2028
+
2029
+ lows: list[float] = []
2030
+ highs: list[float] = []
2031
+ for index in range(len(stops) - 1):
2032
+ mid = (stops[index] + stops[index + 1]) / 2.0
2033
+ band = math.floor((mid - anchor) / pitch + 0.5)
2034
+ lows.append(lo_for_band(band))
2035
+ highs.append(hi_for_band(band))
2036
+
2037
+ ring: list[tuple[float, float]] = []
2038
+ for index in range(len(stops) - 1):
2039
+ ring.append((lows[index], stops[index]))
2040
+ ring.append((lows[index], stops[index + 1]))
2041
+ for index in reversed(range(len(stops) - 1)):
2042
+ ring.append((highs[index], stops[index + 1]))
2043
+ ring.append((highs[index], stops[index]))
2044
+ if comb_axis == "Y":
2045
+ ring = [(stop, value) for value, stop in ring]
2046
+ deduped = [pt for idx, pt in enumerate(ring) if idx == 0 or pt != ring[idx - 1]]
2047
+ return Polygon(deduped)
2048
+
2049
+
2050
+ def _comb_cell_geometry(
2051
+ x_edges: list[float],
2052
+ y_edges: list[float],
2053
+ x_cell: int,
2054
+ y_cell: int,
2055
+ comb_x: float,
2056
+ comb_y: float,
2057
+ pitch: float,
2058
+ anchor_x: float,
2059
+ anchor_y: float,
2060
+ layer_index: int,
2061
+ ):
2062
+ """One split cell with Overlapping Rows: interior boundaries comb.
2063
+
2064
+ With teeth on both axes the cell is the intersection of the two full-
2065
+ frame comb profiles: a point's column is decided by the x-comb at its
2066
+ y-band and its row by the y-comb at its x-band, which assigns every
2067
+ point to exactly one cell — the pieces still tile the parent exactly.
2068
+ """
2069
+ columns = len(x_edges) - 1
2070
+ rows = len(y_edges) - 1
2071
+
2072
+ def x_lo(band: int) -> float:
2073
+ if x_cell == 0 or comb_x <= 0.0:
2074
+ return x_edges[x_cell]
2075
+ return _comb_boundary(x_edges, x_cell, band, layer_index, comb_x)
2076
+
2077
+ def x_hi(band: int) -> float:
2078
+ if x_cell + 1 == columns or comb_x <= 0.0:
2079
+ return x_edges[x_cell + 1]
2080
+ return _comb_boundary(x_edges, x_cell + 1, band, layer_index, comb_x)
2081
+
2082
+ def y_lo(band: int) -> float:
2083
+ if y_cell == 0 or comb_y <= 0.0:
2084
+ return y_edges[y_cell]
2085
+ return _comb_boundary(y_edges, y_cell, band, layer_index, comb_y)
2086
+
2087
+ def y_hi(band: int) -> float:
2088
+ if y_cell + 1 == rows or comb_y <= 0.0:
2089
+ return y_edges[y_cell + 1]
2090
+ return _comb_boundary(y_edges, y_cell + 1, band, layer_index, comb_y)
2091
+
2092
+ combs_x = comb_x > 0.0 and columns > 1
2093
+ combs_y = comb_y > 0.0 and rows > 1
2094
+ if not combs_x and not combs_y:
2095
+ return box(
2096
+ x_edges[x_cell], y_edges[y_cell], x_edges[x_cell + 1], y_edges[y_cell + 1]
2097
+ )
2098
+ if combs_x and not combs_y:
2099
+ return _comb_profile_polygon(
2100
+ y_edges[y_cell], y_edges[y_cell + 1], x_lo, x_hi, anchor_y, pitch, "X"
2101
+ )
2102
+ if combs_y and not combs_x:
2103
+ return _comb_profile_polygon(
2104
+ x_edges[x_cell], x_edges[x_cell + 1], y_lo, y_hi, anchor_x, pitch, "Y"
2105
+ )
2106
+ profile_x = _comb_profile_polygon(
2107
+ y_edges[0], y_edges[-1], x_lo, x_hi, anchor_y, pitch, "X"
2108
+ )
2109
+ profile_y = _comb_profile_polygon(
2110
+ x_edges[0], x_edges[-1], y_lo, y_hi, anchor_x, pitch, "Y"
2111
+ )
2112
+ return profile_x.intersection(profile_y)
2113
+
2114
+
2115
  def _linework_to_paths(geometry: object) -> list[list[tuple[float, float]]]:
2116
  """Merge linework into maximal polylines, ordered/oriented
2117
  deterministically so shapes sharing reference motion trace them
 
2196
  overlap: float = 0.0,
2197
  grid: float | None = None,
2198
  frame: tuple[float, float, float, float] | None = None,
2199
+ overlapping_rows: bool = False,
2200
+ row_overlap_axes: str = "both",
2201
  ) -> list[LayerStack]:
2202
  """Split a sliced shape into a rows x columns grid of piece stacks.
2203
 
2204
  Pieces are returned row-major with row 1 the top strip (max-Y side),
2205
  matching the legacy image-grid ordering. With `overlapping_layers`, the
2206
  interior cut lines alternate by ±overlap between layers so neighbouring
2207
+ pieces interlock. With `overlapping_rows`, each interior cut becomes a
2208
+ comb within every layer: alternate raster-row bands (one `grid` pitch
2209
+ each, aligned to the shared scanline grid) extend ±overlap past the cut
2210
+ in opposite directions, so neighbouring pieces interlock like fingers —
2211
+ this needs `grid` to place the bands. `row_overlap_axes` restricts which
2212
+ cuts comb: "x" only the column cuts (teeth reach along X), "y" only the
2213
+ row cuts (teeth reach along Y), "both" (default) all interior cuts.
2214
+ `grid` (the fil width) sizes the cells in whole grid multiples (see
2215
+ `_base_split_edges`). Piece `bounds` are the nominal (un-shifted) cell
2216
+ boxes.
2217
 
2218
  `frame` overrides the XY box the cell grid is computed over. Splitting
2219
  every member of a multi-material group with the group's combined bounds
 
2245
  # one-fil-width pitch instead of each piece re-centring its own lines.
2246
  scan_frame = stack.scan_frame or (x_min, y_min, x_max, y_max)
2247
 
2248
+ # Overlapping Rows: comb bands ride the same scanline grid the raster
2249
+ # uses, so each tooth covers exactly one raster row.
2250
+ pitch = float(grid) if grid and float(grid) > 0.0 else None
2251
+ axes = str(row_overlap_axes or "both").strip().lower()
2252
+ comb_rows_x = (
2253
+ bool(overlapping_rows)
2254
+ and axes in ("both", "x")
2255
+ and columns > 1
2256
+ and overlap > 0.0
2257
+ and pitch is not None
2258
+ )
2259
+ comb_rows_y = (
2260
+ bool(overlapping_rows)
2261
+ and axes in ("both", "y")
2262
+ and rows > 1
2263
+ and overlap > 0.0
2264
+ and pitch is not None
2265
+ )
2266
+ anchor_x = _scan_anchor(scan_frame[0], scan_frame[2], pitch) if pitch else 0.0
2267
+ anchor_y = _scan_anchor(scan_frame[1], scan_frame[3], pitch) if pitch else 0.0
2268
+
2269
  base_name = stack.name or "shape"
2270
  pieces: list[LayerStack] = []
2271
  for row_index in range(1, rows + 1):
 
2283
  continue
2284
  x_edges = layer_x_edges[layer_number]
2285
  y_edges = layer_y_edges[layer_number]
2286
+ if comb_rows_x or comb_rows_y:
2287
+ cell = _comb_cell_geometry(
2288
+ x_edges,
2289
+ y_edges,
2290
+ x_cell,
2291
+ y_cell,
2292
+ _comb_amplitude(x_edges, overlap) if comb_rows_x else 0.0,
2293
+ _comb_amplitude(y_edges, overlap) if comb_rows_y else 0.0,
2294
+ pitch,
2295
+ anchor_x,
2296
+ anchor_y,
2297
+ layer_number,
2298
+ )
2299
+ else:
2300
+ cell = box(
2301
+ x_edges[x_cell],
2302
+ y_edges[y_cell],
2303
+ x_edges[x_cell + 1],
2304
+ y_edges[y_cell + 1],
2305
+ )
2306
  layers.append(_as_multipolygon(layer.intersection(cell)))
2307
 
2308
  # Contours come from the parent's outline (or, when