CyGuy8 Claude Fable 5 commited on
Commit
52d8525
Β·
1 Parent(s): a63a4fc

Auto Align: grid layout with rigid assembly block, no overlaps, centered pieces

Browse files

- Auto Align Split Parts now computes explicit positions and encodes them
into the spacing table: each run of split siblings is placed as ONE
rigid block at its exact world offsets, and every other shape flows
into the split's grid arrangement, pushed sideways until it clears
everything already placed (default spacings as the margins). New grid
rows start below the true lowest edge of all placed parts, so no shape
can ever land inside the assembly - fixing the overlaps that appeared
whether the pieces came before or after the other shapes.
- The pair-gap table is solved by inverting the layout's chaining math
(rounding compensated pair-to-pair), so the rendered layout reproduces
the computed positions exactly.
- Split pieces now center their nominal cell on the shared motion's
common center (grid-snapped) instead of anchoring at the frame corner:
material sits in the middle of the print path, removing the one-sided
travel, while seams keep their exact one-fil pitch.

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

README.md CHANGED
@@ -84,6 +84,7 @@ Whenever shapes are sliced (automatically, during G-code generation or a split),
84
 
85
  - Shapes are aligned by centering each shape's XY bounding box on a common center before the union.
86
  - Alignment is centered placement (in exact millimetres), not bottom-left anchoring.
 
87
 
88
  ### Multi-Material Assemblies (shared nozzle numbers)
89
 
@@ -126,7 +127,7 @@ The **Multi-Nozzle Split** accordion on the **Shapes & G-Code** tab can split on
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
 
84
 
85
  - Shapes are aligned by centering each shape's XY bounding box on a common center before the union.
86
  - Alignment is centered placement (in exact millimetres), not bottom-left anchoring.
87
+ - Grid-split pieces center their nominal cell on the same common center (snapped to the filament grid so seams keep their exact one-fil pitch): each piece's material sits in the middle of the shared path instead of off in a corner, minimizing one-sided travel.
88
 
89
  ### Multi-Material Assemblies (shared nozzle numbers)
90
 
 
127
  - **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.
128
  - 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.
129
  - **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.
130
+ - **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. The layout keeps the split's grid arrangement: the reassembled pieces form one rigid block, and non-split shapes flow into the remaining grid slots, always kept clear of everything already placed (with the default column/row spacing as the margins) β€” nothing can overlap.
131
  - **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.
132
 
133
  ### Print vs Travel Classification
app.py CHANGED
@@ -2450,10 +2450,19 @@ def _auto_align_grid_spacing_rows(
2450
 
2451
  Every generated G-code file records its PathOrigin: the world position of
2452
  the relative toolpath's start. Anchoring each piece back into its world
2453
- frame turns the required pair gaps into the actual world-frame gaps
2454
- between the parts' toolpath bounding boxes β€” which automatically accounts
2455
- for the raster pattern, filament width, travel buffers, reference-stack
2456
- motion, and overlapping-layer splits. No hardcoded offsets.
 
 
 
 
 
 
 
 
 
2457
  """
2458
  spacing_rows, column_count, row_count = _grid_spacing_rows(
2459
  records,
@@ -2463,40 +2472,179 @@ def _auto_align_grid_spacing_rows(
2463
  row_spacing,
2464
  )
2465
  parts, _messages = _parts_from_records(records)
2466
- world = _nozzle_world_bounds(_group_parts_by_nozzle(parts))
 
2467
  records_by_nozzle = _records_by_nozzle(records)
2468
  ordered_nozzles = _ordered_nozzle_numbers(records)
2469
 
2470
- aligned_count = 0
2471
- missing_count = 0
2472
- for index, (first, second) in enumerate(zip(ordered_nozzles, ordered_nozzles[1:])):
2473
- if not _split_pair_was_created_together(records_by_nozzle, first, second):
2474
- continue
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2475
 
2476
- second_column = (index + 1) % column_count
2477
- if second_column == 0:
2478
- # Row transition: `second` opens a new grid row. The layout places
2479
- # it relative to the previous row's first nozzle (x) and the
2480
- # previous row's lowest edge (y).
2481
- previous_row = ordered_nozzles[index + 1 - column_count : index + 1]
2482
- anchors = [second, *previous_row]
2483
- else:
2484
- row_first = ordered_nozzles[index + 1 - second_column]
2485
- anchors = [first, second, row_first]
2486
- if any(nozzle not in world for nozzle in anchors):
2487
- missing_count += 1
2488
  continue
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2489
 
2490
- (second_min_x, second_min_y), _second_max = world[second]
2491
- if second_column == 0:
2492
- gap_x = second_min_x - world[previous_row[0]][0][0]
2493
- gap_y = second_min_y - max(world[nozzle][1][1] for nozzle in previous_row)
2494
- else:
2495
- gap_x = second_min_x - world[first][1][0]
2496
- gap_y = second_min_y - world[row_first][0][1]
2497
- spacing_rows[index][2] = round(gap_x, 4)
2498
- spacing_rows[index][3] = round(gap_y, 4)
2499
- aligned_count += 1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2500
  return spacing_rows, column_count, row_count, aligned_count, missing_count
2501
 
2502
 
@@ -2535,6 +2683,10 @@ def auto_align_split_parts(
2535
  row_spacing: Any,
2536
  ) -> tuple:
2537
  records = records or []
 
 
 
 
2538
  grid_shape = _split_grid_shape(records)
2539
  if grid_shape is not None:
2540
  columns, rows = grid_shape
@@ -2568,7 +2720,8 @@ def auto_align_split_parts(
2568
  status = (
2569
  f"Auto aligned {aligned_count} split nozzle connection(s) from the generated "
2570
  f"G-code in a {column_count} x {row_count} grid. The exact per-connection "
2571
- "gaps are in the Advanced Grid Spacing table."
 
2572
  )
2573
  if missing_count:
2574
  status += (
 
2450
 
2451
  Every generated G-code file records its PathOrigin: the world position of
2452
  the relative toolpath's start. Anchoring each piece back into its world
2453
+ frame gives the exact relative offsets that reassemble the pieces β€”
2454
+ accounting automatically for the raster pattern, filament width, travel
2455
+ buffers, reference-stack motion, and interlocking splits.
2456
+
2457
+ Two stages: (1) compute a DESIRED position for every nozzle's toolpath
2458
+ box β€” each run of split siblings placed as ONE rigid block at its world
2459
+ offsets, every other shape flowed into the grid row-major and pushed
2460
+ sideways until it clears EVERYTHING already placed (the default
2461
+ spacings are the flow margins); (2) encode those positions into the
2462
+ pair-gap table by inverting `_resolve_nozzle_grid_layout`'s chaining,
2463
+ so the layout reproduces them exactly. Nothing can overlap, and the
2464
+ grid arrangement (pieces in their split grid, other shapes in the
2465
+ remaining slots) is preserved.
2466
  """
2467
  spacing_rows, column_count, row_count = _grid_spacing_rows(
2468
  records,
 
2472
  row_spacing,
2473
  )
2474
  parts, _messages = _parts_from_records(records)
2475
+ grouped = _group_parts_by_nozzle(parts)
2476
+ world = _nozzle_world_bounds(grouped)
2477
  records_by_nozzle = _records_by_nozzle(records)
2478
  ordered_nozzles = _ordered_nozzle_numbers(records)
2479
 
2480
+ pairs = list(zip(ordered_nozzles, ordered_nozzles[1:]))
2481
+ sibling_pairs = {
2482
+ index
2483
+ for index, (first, second) in enumerate(pairs)
2484
+ if _split_pair_was_created_together(records_by_nozzle, first, second)
2485
+ }
2486
+ aligned_pairs = {
2487
+ index
2488
+ for index in sibling_pairs
2489
+ if pairs[index][0] in world and pairs[index][1] in world
2490
+ }
2491
+ aligned_count = len(aligned_pairs)
2492
+ missing_count = len(sibling_pairs) - aligned_count
2493
+ if aligned_count == 0 or any(nozzle not in grouped for nozzle in ordered_nozzles):
2494
+ if any(nozzle not in grouped for nozzle in ordered_nozzles) and sibling_pairs:
2495
+ return spacing_rows, column_count, row_count, 0, len(sibling_pairs)
2496
+ return spacing_rows, column_count, row_count, 0, missing_count
2497
+
2498
+ bounds = {nozzle: _nozzle_group_bounds(grouped, nozzle) for nozzle in ordered_nozzles}
2499
+ size = {
2500
+ nozzle: (
2501
+ bounds[nozzle][1][0] - bounds[nozzle][0][0],
2502
+ bounds[nozzle][1][1] - bounds[nozzle][0][1],
2503
+ )
2504
+ for nozzle in ordered_nozzles
2505
+ }
2506
 
2507
+ # Rigid clusters: maximal runs of consecutive nozzles linked by aligned
2508
+ # sibling connections.
2509
+ cluster_of: dict[int, tuple[int, ...]] = {}
2510
+ run: list[int] = []
2511
+ for index, nozzle in enumerate(ordered_nozzles):
2512
+ if run and (index - 1) in aligned_pairs:
2513
+ run.append(nozzle)
 
 
 
 
 
2514
  continue
2515
+ if len(run) > 1:
2516
+ for member in run:
2517
+ cluster_of[member] = tuple(run)
2518
+ run = [nozzle]
2519
+ if len(run) > 1:
2520
+ for member in run:
2521
+ cluster_of[member] = tuple(run)
2522
+
2523
+ # ---- Stage 1: desired positions (box min-corners). --------------------
2524
+ eps = 1e-6
2525
+ x_gap = _coerce_float(column_spacing, 5.0)
2526
+ y_gap = _coerce_float(row_spacing, 5.0)
2527
+ placed: list[tuple[float, float, float, float]] = []
2528
+ targets: dict[int, tuple[float, float]] = {}
2529
+
2530
+ def _push_clear(x: float, y: float, width: float, height: float) -> float:
2531
+ pushed = True
2532
+ while pushed:
2533
+ pushed = False
2534
+ for bx0, by0, bx1, by1 in placed:
2535
+ if (
2536
+ x < bx1 - eps
2537
+ and x + width > bx0 + eps
2538
+ and y < by1 - eps
2539
+ and y + height > by0 + eps
2540
+ ):
2541
+ x = bx1 + x_gap
2542
+ pushed = True
2543
+ return x
2544
+
2545
+ for row in range(row_count):
2546
+ row_nozzles = ordered_nozzles[row * column_count:(row + 1) * column_count]
2547
+ if not row_nozzles:
2548
+ break
2549
+ row_y: float | None = None
2550
+ cursor_x = 0.0
2551
+ for nozzle in row_nozzles:
2552
+ width, height = size[nozzle]
2553
+ if nozzle in targets:
2554
+ # Cluster member already placed with its block.
2555
+ cursor_x = max(cursor_x, targets[nozzle][0] + width + x_gap)
2556
+ if row_y is None:
2557
+ row_y = targets[nozzle][1]
2558
+ continue
2559
+ if row_y is None:
2560
+ row_y = (max(box[3] for box in placed) + y_gap) if placed else 0.0
2561
+ members = cluster_of.get(nozzle)
2562
+ if members:
2563
+ # Place the whole assembly as one rigid block: every
2564
+ # member's frame at its exact world offset.
2565
+ base_x, base_y = world[nozzle][0]
2566
+ rel = {
2567
+ member: (
2568
+ world[member][0][0] - base_x,
2569
+ world[member][0][1] - base_y,
2570
+ )
2571
+ for member in members
2572
+ }
2573
+ block_min_x = min(rel[member][0] for member in members)
2574
+ block_min_y = min(rel[member][1] for member in members)
2575
+ block_w = max(rel[m][0] + size[m][0] for m in members) - block_min_x
2576
+ block_h = max(rel[m][1] + size[m][1] for m in members) - block_min_y
2577
+ block_x = _push_clear(cursor_x, row_y, block_w, block_h)
2578
+ for member in members:
2579
+ targets[member] = (
2580
+ block_x + rel[member][0] - block_min_x,
2581
+ row_y + rel[member][1] - block_min_y,
2582
+ )
2583
+ m_w, m_h = size[member]
2584
+ placed.append(
2585
+ (
2586
+ targets[member][0],
2587
+ targets[member][1],
2588
+ targets[member][0] + m_w,
2589
+ targets[member][1] + m_h,
2590
+ )
2591
+ )
2592
+ cursor_x = block_x + block_w + x_gap
2593
+ else:
2594
+ target_x = _push_clear(cursor_x, row_y, width, height)
2595
+ targets[nozzle] = (target_x, row_y)
2596
+ placed.append((target_x, row_y, target_x + width, row_y + height))
2597
+ cursor_x = target_x + width + x_gap
2598
+
2599
+ # The layout pins the first nozzle's box at the origin.
2600
+ base_x, base_y = targets[ordered_nozzles[0]]
2601
+ targets = {
2602
+ nozzle: (position[0] - base_x, position[1] - base_y)
2603
+ for nozzle, position in targets.items()
2604
+ }
2605
 
2606
+ # ---- Stage 2: encode positions into the pair-gap table. ---------------
2607
+ # Mirrors `_resolve_nozzle_grid_layout`'s individual-spacing chaining and
2608
+ # solves each gap; state advances on the ROUNDED values so rounding
2609
+ # errors are compensated in the following gaps instead of accumulating.
2610
+ offsets: dict[int, tuple[float, float]] = {}
2611
+ row_start_x = 0.0
2612
+ row_min_y = 0.0
2613
+ row_bottom = 0.0
2614
+ for row in range(row_count):
2615
+ row_start_index = row * column_count
2616
+ row_nozzles = ordered_nozzles[row_start_index:row_start_index + column_count]
2617
+ if not row_nozzles:
2618
+ break
2619
+ for col, nozzle in enumerate(row_nozzles):
2620
+ (xmin, ymin, _), _ = bounds[nozzle]
2621
+ target_x, target_y = targets[nozzle]
2622
+ if col == 0:
2623
+ if row == 0:
2624
+ actual_x, actual_y = 0.0, 0.0
2625
+ else:
2626
+ pair_index = row_start_index - 1
2627
+ shift_x = round(target_x - row_start_x, 4)
2628
+ row_gap = round(target_y - row_bottom, 4)
2629
+ spacing_rows[pair_index][2] = shift_x
2630
+ spacing_rows[pair_index][3] = row_gap
2631
+ row_start_x += shift_x
2632
+ row_min_y = row_bottom + row_gap
2633
+ actual_x, actual_y = row_start_x, row_min_y
2634
+ else:
2635
+ pair_index = row_start_index + col - 1
2636
+ prev = row_nozzles[col - 1]
2637
+ prev_right = offsets[prev][0] + bounds[prev][1][0]
2638
+ gap_x = round(target_x - prev_right, 4)
2639
+ gap_y = round(target_y - row_min_y, 4)
2640
+ spacing_rows[pair_index][2] = gap_x
2641
+ spacing_rows[pair_index][3] = gap_y
2642
+ actual_x = prev_right + gap_x
2643
+ actual_y = row_min_y + gap_y
2644
+ offsets[nozzle] = (actual_x - xmin, actual_y - ymin)
2645
+ row_bottom = max(
2646
+ offsets[nozzle][1] + bounds[nozzle][1][1] for nozzle in row_nozzles
2647
+ )
2648
  return spacing_rows, column_count, row_count, aligned_count, missing_count
2649
 
2650
 
 
2683
  row_spacing: Any,
2684
  ) -> tuple:
2685
  records = records or []
2686
+ # Grid layout: the chain uses the split's own grid shape, so pieces sit
2687
+ # in their split arrangement and other shapes flow into the remaining
2688
+ # grid slots. Positions are computed globally (assembly as one rigid
2689
+ # block, everything else pushed clear), so nothing can overlap.
2690
  grid_shape = _split_grid_shape(records)
2691
  if grid_shape is not None:
2692
  columns, rows = grid_shape
 
2720
  status = (
2721
  f"Auto aligned {aligned_count} split nozzle connection(s) from the generated "
2722
  f"G-code in a {column_count} x {row_count} grid. The exact per-connection "
2723
+ "gaps are in the Advanced Grid Spacing table; other shapes flow into the "
2724
+ "remaining grid slots, kept clear of the reassembled pieces."
2725
  )
2726
  if missing_count:
2727
  status += (
tests/test_nozzle_spacing.py CHANGED
@@ -733,6 +733,100 @@ def test_auto_align_reports_missing_gcode_for_split_siblings(tmp_path) -> None:
733
  assert rows == [["Nozzle 1: Shape 1", "Nozzle 2: Shape 2", 10.0, 0.0]]
734
 
735
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
736
  def test_auto_align_grid_spacing_skips_unsplit_records() -> None:
737
  records = [
738
  {"idx": 1, "name": "first", "nozzle": 1},
 
733
  assert rows == [["Nozzle 1: Shape 1", "Nozzle 2: Shape 2", 10.0, 0.0]]
734
 
735
 
736
+ def test_auto_align_pushes_non_split_shapes_clear_of_the_assembly(tmp_path) -> None:
737
+ from shapely.geometry import MultiPolygon, box
738
+
739
+ from app import _group_parts_by_nozzle, _nozzle_group_bounds
740
+ from stl_slicer import LayerStack
741
+ from vector_gcode import generate_vector_gcode
742
+
743
+ # A 2x2 split under shared reference motion: auto align pulls the rows
744
+ # together with negative gaps, so row 2's frames end HIGHER than row 1's.
745
+ records = _split_piece_records(tmp_path, columns=2, rows=2, use_reference_motion=True)
746
+
747
+ # An unrelated shape on the next nozzle. Placed only relative to row 2's
748
+ # bottom edge it would land inside row 1's frames.
749
+ layer = MultiPolygon([box(0.0, 0.0, 6.0, 5.0)])
750
+ stack = LayerStack(
751
+ layers=[layer, layer],
752
+ z_values=[0.5, 1.5],
753
+ bounds=((0.0, 0.0, 0.0), (6.0, 5.0, 2.0)),
754
+ layer_height=1.0,
755
+ name="extra",
756
+ )
757
+ origin_sink: dict = {}
758
+ gcode_path = generate_vector_gcode(
759
+ stack,
760
+ shape_name="extra",
761
+ pressure=25,
762
+ valve=9,
763
+ port=1,
764
+ fil_width=1.0,
765
+ origin_sink=origin_sink,
766
+ output_dir=tmp_path / "extra",
767
+ )
768
+ records.append(
769
+ {
770
+ "idx": 5,
771
+ "name": "extra",
772
+ "nozzle": 5,
773
+ "color": "#123456",
774
+ "gcode_path": str(gcode_path),
775
+ "path_origin": origin_sink.get("path_origin"),
776
+ }
777
+ )
778
+
779
+ from app import auto_align_split_parts
780
+
781
+ def _check_layout(records, piece_nozzles, extra_nozzle):
782
+ outputs = auto_align_split_parts(records, 2, 2, 5.0, 5.0)
783
+ spacing_rows = outputs[5]["value"]
784
+ cc = outputs[0]["value"]
785
+ rc = outputs[1]["value"]
786
+ # Grid layout: the chain keeps the split's 2-column grid shape.
787
+ assert cc == 2
788
+
789
+ parts, _messages = _parts_from_records(records)
790
+ offsets, _spacings = _resolve_nozzle_grid_layout(
791
+ parts, cc, rc, 5.0, 5.0, use_individual_spacing=True, spacing_table=spacing_rows
792
+ )
793
+ grouped = _group_parts_by_nozzle(parts)
794
+ boxes = {}
795
+ for nozzle, (offset_x, offset_y) in offsets.items():
796
+ (xmin, ymin, _), (xmax, ymax, _) = _nozzle_group_bounds(grouped, nozzle)
797
+ boxes[nozzle] = (xmin + offset_x, ymin + offset_y, xmax + offset_x, ymax + offset_y)
798
+
799
+ extra = boxes[extra_nozzle]
800
+ for nozzle in piece_nozzles:
801
+ piece = boxes[nozzle]
802
+ overlap_w = min(extra[2], piece[2]) - max(extra[0], piece[0])
803
+ overlap_h = min(extra[3], piece[3]) - max(extra[1], piece[1])
804
+ assert overlap_w <= 1e-6 or overlap_h <= 1e-6, (
805
+ f"non-split shape overlaps piece on nozzle {nozzle}: {extra} vs {piece}"
806
+ )
807
+
808
+ # The split pieces still reassemble exactly: every piece's layout
809
+ # offset differs from its world anchor by one shared constant.
810
+ anchors = {part["nozzle"]: part["parsed"]["path_origin"] for part in parts}
811
+ constants = [
812
+ (offsets[n][0] - anchors[n][0], offsets[n][1] - anchors[n][1])
813
+ for n in piece_nozzles
814
+ ]
815
+ for constant in constants[1:]:
816
+ assert abs(constant[0] - constants[0][0]) < 5e-4
817
+ assert abs(constant[1] - constants[0][1]) < 5e-4
818
+
819
+ # Pieces first (nozzles 1-4), the unrelated shape after them.
820
+ _check_layout(records, (1, 2, 3, 4), 5)
821
+
822
+ # Pieces LAST (the unrelated shape sorts first): the assembly's own
823
+ # negative gaps used to climb back over the earlier shape's row.
824
+ for record in records[:4]:
825
+ record["nozzle"] = record["nozzle"] + 1
826
+ records[4]["nozzle"] = 1
827
+ _check_layout(records, (2, 3, 4, 5), 1)
828
+
829
+
830
  def test_auto_align_grid_spacing_skips_unsplit_records() -> None:
831
  records = [
832
  {"idx": 1, "name": "first", "nozzle": 1},
tests/test_vector_gcode.py CHANGED
@@ -1683,6 +1683,36 @@ def test_split_contour_gcode_never_traces_the_cuts(tmp_path) -> None:
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")
 
1683
  assert vertical_prints == []
1684
 
1685
 
1686
+ def test_split_pieces_center_on_the_shared_motion() -> None:
1687
+ from vector_toolpath import _centering_delta
1688
+
1689
+ layer = box(0.0, 0.0, 8.0, 6.0)
1690
+ stack = _stack(layer, layer, name="center")
1691
+ pieces = split_layer_stack_grid(stack, columns=2, rows=2, grid=1.0)
1692
+ reference = build_reference_stack(pieces, grid=1.0)
1693
+
1694
+ ref_x, ref_y = reference.align_center
1695
+ placements = []
1696
+ for piece in pieces:
1697
+ delta_x, delta_y = _centering_delta(piece, reference)
1698
+ (x0, y0, _), (x1, y1, _) = piece.bounds
1699
+ center = ((x0 + x1) / 2, (y0 + y1) / 2)
1700
+ placements.append((center, (delta_x, delta_y)))
1701
+ # The piece's nominal cell lands centered on the shared motion
1702
+ # (within half a fil from the grid snap) β€” material sits mid-path,
1703
+ # not at the frame corner.
1704
+ assert abs(center[0] + delta_x - ref_x) <= 0.5 + 1e-9
1705
+ assert abs(center[1] + delta_y - ref_y) <= 0.5 + 1e-9
1706
+
1707
+ # Deltas differ between pieces by EXACT cell multiples: the pieces'
1708
+ # relative world offsets β€” and with them seams and nozzle spacing β€”
1709
+ # are preserved exactly.
1710
+ (first_center, first_delta) = placements[0]
1711
+ for center, delta in placements[1:]:
1712
+ assert abs((delta[0] - first_delta[0]) + (center[0] - first_center[0])) < 1e-9
1713
+ assert abs((delta[1] - first_delta[1]) + (center[1] - first_center[1])) < 1e-9
1714
+
1715
+
1716
  def test_split_overlapping_rows_comb_within_each_layer() -> None:
1717
  layer = box(0.0, 0.0, 8.0, 6.0)
1718
  stack = _stack(layer, layer, name="comb")
vector_toolpath.py CHANGED
@@ -1769,11 +1769,13 @@ def _centering_delta(stack: LayerStack, reference: LayerStack) -> tuple[float, f
1769
  one-fil-width line pitch.
1770
 
1771
  Split siblings (stacks sharing the reference's scan frame) are aligned by
1772
- their cell corner within that frame instead of their centre: with cells
1773
- sized in whole grid multiples the deltas β€” and with them the required
1774
- nozzle spacing β€” come out uniform across all pieces, whereas snapping the
1775
- centres would wobble by up to one fil where the last cell's width (and so
1776
- its centre phase) differs.
 
 
1777
 
1778
  Multi-material group members (stacks carrying a shared `align_frame`)
1779
  are aligned by the group frame's centre instead of their own bbox
@@ -1786,14 +1788,19 @@ def _centering_delta(stack: LayerStack, reference: LayerStack) -> tuple[float, f
1786
  and stack.scan_frame is not None
1787
  and stack.scan_frame == reference.scan_frame
1788
  ):
1789
- (stack_min_x, stack_min_y, _sz), _stack_max = (
1790
  stack.bounds[0],
1791
  stack.bounds[1],
1792
  )
1793
- frame_min_x, frame_min_y = stack.scan_frame[0], stack.scan_frame[1]
 
 
 
 
 
1794
  return (
1795
- _snap_to_grid(frame_min_x - stack_min_x, grid),
1796
- _snap_to_grid(frame_min_y - stack_min_y, grid),
1797
  )
1798
 
1799
  if reference.align_center is not None:
 
1769
  one-fil-width line pitch.
1770
 
1771
  Split siblings (stacks sharing the reference's scan frame) are aligned by
1772
+ their nominal CELL onto the reference centre, with the delta snapped to
1773
+ the fil grid: every piece's material sits in the MIDDLE of the shared
1774
+ motion instead of at the frame corner (less one-sided travel), the grid
1775
+ snap keeps every piece's world scan-grid phase intact, and β€” because
1776
+ grid-sized cells are all equal β€” every piece gets the same snap residue,
1777
+ so the pieces' relative world offsets (and with them the required
1778
+ nozzle spacing and seam pitch) stay exact.
1779
 
1780
  Multi-material group members (stacks carrying a shared `align_frame`)
1781
  are aligned by the group frame's centre instead of their own bbox
 
1788
  and stack.scan_frame is not None
1789
  and stack.scan_frame == reference.scan_frame
1790
  ):
1791
+ (stack_min_x, stack_min_y, _sz), (stack_max_x, stack_max_y, _sz2) = (
1792
  stack.bounds[0],
1793
  stack.bounds[1],
1794
  )
1795
+ if reference.align_center is not None:
1796
+ reference_x, reference_y = reference.align_center
1797
+ else:
1798
+ reference_x, reference_y = _stack_center(reference)
1799
+ cell_center_x = (stack_min_x + stack_max_x) / 2.0
1800
+ cell_center_y = (stack_min_y + stack_max_y) / 2.0
1801
  return (
1802
+ _snap_to_grid(reference_x - cell_center_x, grid),
1803
+ _snap_to_grid(reference_y - cell_center_y, grid),
1804
  )
1805
 
1806
  if reference.align_center is not None: