Spaces:
Running
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 +2 -1
- app.py +186 -33
- tests/test_nozzle_spacing.py +94 -0
- tests/test_vector_gcode.py +30 -0
- vector_toolpath.py +16 -9
|
@@ -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
|
|
@@ -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
|
| 2454 |
-
|
| 2455 |
-
|
| 2456 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
| 2467 |
records_by_nozzle = _records_by_nozzle(records)
|
| 2468 |
ordered_nozzles = _ordered_nozzle_numbers(records)
|
| 2469 |
|
| 2470 |
-
|
| 2471 |
-
|
| 2472 |
-
|
| 2473 |
-
|
| 2474 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2475 |
|
| 2476 |
-
|
| 2477 |
-
|
| 2478 |
-
|
| 2479 |
-
|
| 2480 |
-
|
| 2481 |
-
|
| 2482 |
-
|
| 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 |
-
|
| 2491 |
-
|
| 2492 |
-
|
| 2493 |
-
|
| 2494 |
-
|
| 2495 |
-
|
| 2496 |
-
|
| 2497 |
-
|
| 2498 |
-
|
| 2499 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 += (
|
|
@@ -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},
|
|
@@ -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")
|
|
@@ -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
|
| 1773 |
-
|
| 1774 |
-
|
| 1775 |
-
|
| 1776 |
-
|
|
|
|
|
|
|
| 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),
|
| 1790 |
stack.bounds[0],
|
| 1791 |
stack.bounds[1],
|
| 1792 |
)
|
| 1793 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1794 |
return (
|
| 1795 |
-
_snap_to_grid(
|
| 1796 |
-
_snap_to_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:
|