Spaces:
Running
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>
- README.md +4 -2
- app.py +136 -12
- tests/test_nozzle_spacing.py +79 -0
- tests/test_vector_gcode.py +148 -0
- vector_toolpath.py +201 -10
|
@@ -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
|
|
|
|
|
|
|
| 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
|
|
@@ -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 |
-
|
|
|
|
|
|
|
| 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 |
-
|
| 3829 |
-
" \nOverlapping Layers
|
| 3830 |
-
"
|
| 3831 |
)
|
| 3832 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
| 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 |
-
|
| 3972 |
-
|
| 3973 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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.
|
|
@@ -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 |
|
|
@@ -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")
|
|
@@ -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. `
|
| 2059 |
-
|
| 2060 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 2110 |
-
|
| 2111 |
-
|
| 2112 |
-
|
| 2113 |
-
|
| 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
|