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

Improve parallel printing: shared contours, exact auto-align, infill %

Browse files

- Contour tracing under reference motion: every head traces every traced
shape's contour on one identical path, valve open only on its own outline
- Auto Align Split Parts computes exact per-connection gaps from PathOrigin
anchors written into each G-code file (works for all raster patterns,
reference motion, and overlapping-layer splits; sets grid dims from the
split); grid controls listen on user input only so the first click sticks
- Split pieces raster on the parent's scan grid (seams keep an exact one-fil
pitch); cells are equal whole-fil multiples with blank boundary padding,
and reference alignment snaps to the fil grid so nozzle spacing is uniform
- New per-shape Infill % column: evenly-distributed lines (rings for
spirals) skip dispensing while the motion path stays identical
- Remove the redundant Custom Spacing mode; Advanced Grid Spacing table is
always visible and its checkbox controls only whether it is used
- Fix G-code corruption on curved shapes: coordinates are written in fixed
point (never scientific notation) and the viewer parses exponents safely

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

Files changed (8) hide show
  1. README.md +6 -4
  2. app.py +198 -355
  3. gcode_viewer.py +14 -0
  4. stl_slicer.py +12 -0
  5. tests/test_nozzle_spacing.py +246 -220
  6. tests/test_vector_gcode.py +265 -4
  7. vector_gcode.py +38 -1
  8. vector_toolpath.py +331 -51
README.md CHANGED
@@ -47,11 +47,12 @@ Then open the local Gradio URL in your browser, upload STL files or load the bun
47
  - Slices each shape into per-layer vector outlines held in memory (no intermediate image files)
48
  - Automatically unions the sliced shapes into a combined reference layer set whenever shapes are sliced
49
  - Splits one sliced shape's geometry into an editable row/column grid for multi-nozzle printing of one large shape
50
- - Converts sliced layers into G-code files with pressure, valve, nozzle, and port settings per shape from the Shape Settings table
 
51
  - Appends a shape outline contour after each enabled shape layer by tracing that layer's polygon boundary
52
  - Offers G-code generation options for raster pattern, **Use G1 for all moves** (no rapid travel command), and **Use combined reference outline for motion** (all shapes share one nozzle path; each dispenses only its own geometry)
53
  - Re-slices shapes automatically during G-code generation when their slices are missing or stale, so "upload, then Generate G-Code" works in one click
54
- - Calculates X/Y nozzle spacing from an editable adjacent-pair spacing table, then visualizes the resulting nozzle layout
55
  - Previews selected generated G-code inline
56
  - Visualizes generated or uploaded G-code tool paths, with the source selectable from any active generated shape or an uploaded file
57
  - Renders the tool path as a fast line plot or an animated 3D tube plot (play/pause, speed, scrub, frame-step, nozzle marker)
@@ -91,9 +92,10 @@ The **Multi-Nozzle Split** accordion on the **Shapes & Slicing** tab can split o
91
  - Generated files include pressure preset commands and WAGO valve commands based on the selected pressure, valve, and port; the nozzle number controls layout/spacing assignment.
92
  - Pressure increases by `0.1` psi per layer by default.
93
  - **Use G1 for all moves**: when enabled, every movement line is emitted as `G1` (no `G0` rapid travel); the WAGO valve still marks where material is dispensed. Applies to all shapes.
94
- - **Use combined reference outline for motion**: when enabled, every shape's *motion* is taken from the combined reference layer union while each shape's *valve/dispensing* comes from its own layer polygons — so parallel print heads share one synchronized nozzle path and each deposits only its own geometry. The reference union is rebuilt automatically when shapes are sliced.
 
95
  - **Raster Pattern**: `X-direction raster` sweeps every layer back-and-forth in X. `Y-direction raster` rasters every layer in Y. `Woodpile raster` alternates the raster axis by layer, switching between X-direction and Y-direction sweeps. `Rectangular Spiral raster` walks each layer from the outer layer bounds toward the center, then reverses from center to edge on the next layer. `Circle Spiral raster` uses a shrinking circular spiral from the layer bounds toward the center, then reverses outward on the next layer. Spiral motion covers the layer bounds; the valve opens only where the path is inside material.
96
- - **Auto Align Split Parts**: in Nozzle Spacing, fills Grid Layout gaps for split-piece alignment. X-direction raster uses X `-3.2` mm and Y `-0.8` mm; Y-direction raster switches those values.
97
  - **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.
98
 
99
  ### Print vs Travel Classification
 
47
  - Slices each shape into per-layer vector outlines held in memory (no intermediate image files)
48
  - Automatically unions the sliced shapes into a combined reference layer set whenever shapes are sliced
49
  - Splits one sliced shape's geometry into an editable row/column grid for multi-nozzle printing of one large shape
50
+ - Converts sliced layers into G-code files with pressure, valve, nozzle, port, and infill % settings per shape from the Shape Settings table
51
+ - **Infill %** per shape skips dispensing on evenly-distributed raster lines (rings/revolutions for spiral patterns) — at 50% every other line prints — while the motion path stays exactly the same, so parallel shapes with different infill still share one print path
52
  - Appends a shape outline contour after each enabled shape layer by tracing that layer's polygon boundary
53
  - Offers G-code generation options for raster pattern, **Use G1 for all moves** (no rapid travel command), and **Use combined reference outline for motion** (all shapes share one nozzle path; each dispenses only its own geometry)
54
  - Re-slices shapes automatically during G-code generation when their slices are missing or stale, so "upload, then Generate G-Code" works in one click
55
+ - Calculates X/Y nozzle spacing from a grid layout (columns/rows plus gaps), with an optional per-connection Advanced Grid Spacing table, then visualizes the resulting nozzle layout
56
  - Previews selected generated G-code inline
57
  - Visualizes generated or uploaded G-code tool paths, with the source selectable from any active generated shape or an uploaded file
58
  - Renders the tool path as a fast line plot or an animated 3D tube plot (play/pause, speed, scrub, frame-step, nozzle marker)
 
92
  - Generated files include pressure preset commands and WAGO valve commands based on the selected pressure, valve, and port; the nozzle number controls layout/spacing assignment.
93
  - Pressure increases by `0.1` psi per layer by default.
94
  - **Use G1 for all moves**: when enabled, every movement line is emitted as `G1` (no `G0` rapid travel); the WAGO valve still marks where material is dispensed. Applies to all shapes.
95
+ - **Use combined reference outline for motion**: when enabled, every shape's *motion* is taken from the combined reference layer union while each shape's *valve/dispensing* comes from its own layer polygons — so parallel print heads share one synchronized nozzle path and each deposits only its own geometry. The reference union is rebuilt automatically when shapes are sliced. Contour tracing stays synchronized too: every shape traces every traced shape's contour, opening its valve only on its own outline.
96
+ - Every generated file starts with a `; PathOrigin X.. Y..` comment: the world position (in the shape's own frame) that the relative toolpath starts from. Tools use it to place parallel parts so split pieces reassemble.
97
  - **Raster Pattern**: `X-direction raster` sweeps every layer back-and-forth in X. `Y-direction raster` rasters every layer in Y. `Woodpile raster` alternates the raster axis by layer, switching between X-direction and Y-direction sweeps. `Rectangular Spiral raster` walks each layer from the outer layer bounds toward the center, then reverses from center to edge on the next layer. `Circle Spiral raster` uses a shrinking circular spiral from the layer bounds toward the center, then reverses outward on the next layer. Spiral motion covers the layer bounds; the valve opens only where the path is inside material.
98
+ - **Auto Align Split Parts**: in Nozzle Spacing, computes exact per-connection grid gaps from the split pieces' generated G-code (`PathOrigin` anchors + toolpath bounds), sets the grid columns/rows from the split, and fills the Advanced Grid Spacing table. Works for every raster pattern, filament width, reference-motion setting, and overlapping-layer split. Requires the pieces' G-code to be generated first.
99
  - **Contour Tracing**: enabled per row in Shape Settings. The app traces the layer polygon's boundary rings (holes traced separately), travels from the layer raster end to the nearest contour point, prints the contour, then returns to the raster endpoint before the next layer.
100
 
101
  ### Print vs Travel Classification
app.py CHANGED
@@ -55,8 +55,6 @@ SCALE_MODE_TARGET_DIMENSIONS = "Independent X/Y/Z"
55
  SCALE_MODE_UNIFORM_FACTOR = "Keep Proportions"
56
  TARGET_DIMENSION_KEYS = ("target_x", "target_y", "target_z")
57
  FRONT_CAMERA = (90, 80, None)
58
- NOZZLE_LAYOUT_GRID = "Grid Layout"
59
- NOZZLE_LAYOUT_PAIR_TABLE = "Custom Spacing"
60
  NOZZLE_LAYOUT_PRESETS = [
61
  "Custom",
62
  "One row",
@@ -68,8 +66,6 @@ NOZZLE_LAYOUT_PRESETS = [
68
  "2 x 5",
69
  "5 x 2",
70
  ]
71
- AUTO_ALIGN_X_RASTER_OFFSETS = (-3.2, -0.8)
72
- AUTO_ALIGN_Y_RASTER_OFFSETS = (-0.8, -3.2)
73
  APP_CSS = """
74
  .gradio-container {
75
  font-size: 90%;
@@ -1161,96 +1157,6 @@ PARALLEL_COLOR_CHOICES = [
1161
  DEFAULT_PARALLEL_COLORS = ("#ff7f0e", "#1f77b4", "#2ca02c")
1162
 
1163
 
1164
- def _resolve_nozzle_layout(
1165
- parts: list[dict],
1166
- same_spacing: bool | None,
1167
- part_gap_12_x: float | None,
1168
- part_gap_12_y: float | None,
1169
- part_gap_23_x: float | None,
1170
- part_gap_23_y: float | None,
1171
- *extra_pair_gaps: float | None,
1172
- ) -> tuple[dict[int, tuple[float, float]], list[dict]]:
1173
- offsets: dict[int, tuple[float, float]] = {}
1174
- spacings: list[dict] = []
1175
- if not parts:
1176
- return offsets, spacings
1177
-
1178
- grouped: dict[int, list[dict]] = {}
1179
- for part in parts:
1180
- grouped.setdefault(_record_nozzle_number(part, int(part.get("idx", 1) or 1)), []).append(part)
1181
- ordered_nozzles = sorted(grouped)
1182
- offsets[ordered_nozzles[0]] = (0.0, 0.0)
1183
-
1184
- def nozzle_bounds(nozzle: int) -> tuple[tuple[float, float, float], tuple[float, float, float]]:
1185
- mins: list[tuple[float, float, float]] = []
1186
- maxs: list[tuple[float, float, float]] = []
1187
- for part in grouped[nozzle]:
1188
- part_min, part_max = part["parsed"]["bounds"]
1189
- mins.append(part_min)
1190
- maxs.append(part_max)
1191
- return (
1192
- tuple(min(values) for values in zip(*mins)),
1193
- tuple(max(values) for values in zip(*maxs)),
1194
- )
1195
-
1196
- first_spacing = (float(part_gap_12_x or 0.0), float(part_gap_12_y or 0.0))
1197
- raw_pairs: list[tuple[float, float]] = [
1198
- first_spacing,
1199
- (float(part_gap_23_x or 0.0), float(part_gap_23_y or 0.0)),
1200
- ]
1201
- for index in range(0, len(extra_pair_gaps), 2):
1202
- raw_pairs.append((
1203
- float(extra_pair_gaps[index] or 0.0),
1204
- float(extra_pair_gaps[index + 1] or 0.0) if index + 1 < len(extra_pair_gaps) else 0.0,
1205
- ))
1206
- max_pair_count = max(0, len(ordered_nozzles) - 1)
1207
- while len(raw_pairs) < max_pair_count:
1208
- raw_pairs.append(first_spacing)
1209
- if same_spacing:
1210
- raw_pairs = [first_spacing for _ in raw_pairs]
1211
-
1212
- pair_spacing = {
1213
- (ordered_nozzles[index], ordered_nozzles[index + 1]): raw_pairs[index]
1214
- for index in range(min(len(raw_pairs), max_pair_count))
1215
- }
1216
-
1217
- def spacing_between(prev_idx: int, cur_idx: int) -> tuple[float, float]:
1218
- if (prev_idx, cur_idx) in pair_spacing:
1219
- return pair_spacing[(prev_idx, cur_idx)]
1220
- try:
1221
- start_pos = ordered_nozzles.index(prev_idx)
1222
- end_pos = ordered_nozzles.index(cur_idx)
1223
- except ValueError:
1224
- return 0.0, 0.0
1225
- if end_pos <= start_pos:
1226
- return 0.0, 0.0
1227
- total_x = 0.0
1228
- total_y = 0.0
1229
- for pair_pos in range(start_pos, end_pos):
1230
- pair = (ordered_nozzles[pair_pos], ordered_nozzles[pair_pos + 1])
1231
- step_x, step_y = pair_spacing.get(pair, first_spacing)
1232
- total_x += step_x
1233
- total_y += step_y
1234
- return total_x, total_y
1235
-
1236
- for prev_idx, cur_idx in zip(ordered_nozzles, ordered_nozzles[1:]):
1237
- gap_x, y_step = spacing_between(prev_idx, cur_idx)
1238
- prev_offset_x, prev_offset_y = offsets[prev_idx]
1239
- (_, _, _), (prev_xmax, _prev_ymax, _) = nozzle_bounds(prev_idx)
1240
- (cur_xmin, _cur_ymin, _), (_, _, _) = nozzle_bounds(cur_idx)
1241
- dx = (prev_xmax + prev_offset_x + gap_x) - cur_xmin
1242
- dy = prev_offset_y + y_step
1243
- offsets[cur_idx] = (dx, dy)
1244
- spacings.append({
1245
- "from": prev_idx,
1246
- "to": cur_idx,
1247
- "dx": dx - prev_offset_x,
1248
- "dy": y_step,
1249
- })
1250
-
1251
- return offsets, spacings
1252
-
1253
-
1254
  def _group_parts_by_nozzle(parts: list[dict]) -> dict[int, list[dict]]:
1255
  grouped: dict[int, list[dict]] = {}
1256
  for part in parts:
@@ -1390,44 +1296,6 @@ def _resolve_nozzle_grid_layout(
1390
  return offsets, spacings
1391
 
1392
 
1393
- def _resolve_layout_from_spacing_controls(
1394
- parts: list[dict],
1395
- layout_mode: str | None,
1396
- columns: Any,
1397
- rows: Any,
1398
- column_spacing: Any,
1399
- row_spacing: Any,
1400
- use_grid_individual_spacing: bool | None,
1401
- grid_spacing_table: Any,
1402
- use_individual_spacing: bool,
1403
- spacing_table: Any,
1404
- ) -> tuple[dict[int, tuple[float, float]], list[dict]]:
1405
- if layout_mode != NOZZLE_LAYOUT_PAIR_TABLE:
1406
- return _resolve_nozzle_grid_layout(
1407
- parts,
1408
- columns,
1409
- rows,
1410
- column_spacing,
1411
- row_spacing,
1412
- use_grid_individual_spacing,
1413
- grid_spacing_table,
1414
- )
1415
- gap12x, gap12y, gap23x, gap23y, extra = _spacing_args_from_table(spacing_table, use_individual_spacing)
1416
- return _resolve_nozzle_layout(
1417
- parts,
1418
- _same_spacing_from_individual(use_individual_spacing),
1419
- gap12x,
1420
- gap12y,
1421
- gap23x,
1422
- gap23y,
1423
- *extra,
1424
- )
1425
-
1426
-
1427
- def _same_spacing_from_individual(use_individual_spacing: bool | None) -> bool:
1428
- return not bool(use_individual_spacing)
1429
-
1430
-
1431
  def _format_shape_dimensions(parts: list[dict]) -> list[str]:
1432
  lines = ["**Shape dimensions from generated G-code:**"]
1433
  for part in sorted(parts, key=lambda item: item["idx"]):
@@ -1492,6 +1360,7 @@ SHAPE_SETTINGS_HEADERS = [
1492
  "Nozzle",
1493
  "Port",
1494
  "Color",
 
1495
  "Contour Tracing",
1496
  "Delete",
1497
  ]
@@ -1506,22 +1375,16 @@ SHAPE_SETTINGS_DATATYPES = [
1506
  "number",
1507
  "number",
1508
  "str",
 
1509
  "bool",
1510
  "str",
1511
  ]
1512
- SIMPLE_NOZZLE_SPACING_HEADERS = [
1513
- "Spacing Mode",
1514
- "Applies To",
1515
- "X edge spacing (mm)",
1516
- "Y nozzle spacing (mm)",
1517
- ]
1518
  ADVANCED_NOZZLE_SPACING_HEADERS = [
1519
  "From Nozzle",
1520
  "To Nozzle",
1521
  "X edge spacing (mm)",
1522
  "Y nozzle spacing (mm)",
1523
  ]
1524
- NOZZLE_SPACING_HEADERS = SIMPLE_NOZZLE_SPACING_HEADERS
1525
 
1526
 
1527
  def _normalise_rows(table: Any) -> list[list[Any]]:
@@ -1660,6 +1523,7 @@ def _records_from_files(files: Any, previous_records: list[dict] | None = None)
1660
  "nozzle": nozzle,
1661
  "port": previous.get("port", 1),
1662
  "color": previous.get("color", _default_color(index)),
 
1663
  "contour_tracing": previous.get("contour_tracing", False),
1664
  "layer_stack": previous.get("layer_stack"),
1665
  "slice_params": previous.get("slice_params"),
@@ -1690,6 +1554,7 @@ def _shape_settings_rows(records: list[dict]) -> list[list[Any]]:
1690
  _record_nozzle_number(record, int(record["idx"])),
1691
  record.get("port", 1),
1692
  record.get("color", _default_color(record["idx"])),
 
1693
  bool(record.get("contour_tracing", False)),
1694
  "Delete",
1695
  ]
@@ -1733,7 +1598,8 @@ def _apply_shape_settings(records: list[dict], settings_table: Any) -> list[dict
1733
  nozzle_pos = 7 if has_nozzle_column else None
1734
  port_pos = 8 if has_nozzle_column else 7
1735
  color_pos = 9 if has_nozzle_column else 8
1736
- contour_pos = 10 if has_nozzle_column else 9
 
1737
  copy["valve"] = _coerce_int(copy.get("valve", 4), 4)
1738
  copy["nozzle"] = _coerce_int(
1739
  row[nozzle_pos] if nozzle_pos is not None else copy.get("nozzle", copy.get("idx", 1)),
@@ -1747,6 +1613,13 @@ def _apply_shape_settings(records: list[dict], settings_table: Any) -> list[dict
1747
  copy["nozzle"] = _record_nozzle_number(copy)
1748
  if len(row) > color_pos and row[color_pos]:
1749
  copy["color"] = str(row[color_pos])
 
 
 
 
 
 
 
1750
  try:
1751
  copy["contour_tracing"] = _coerce_bool(row[contour_pos], bool(copy.get("contour_tracing", False)))
1752
  except IndexError:
@@ -1821,38 +1694,6 @@ def _spacing_pairs_from_table(spacing_table: Any) -> list[tuple[float, float]]:
1821
  return pairs
1822
 
1823
 
1824
- def _spacing_table_update(records: list[dict], existing_table: Any | None = None, use_individual_spacing: bool | None = False) -> dict[str, Any]:
1825
- pairs = _spacing_pairs_from_table(existing_table)
1826
- first_pair = pairs[0] if pairs else (5.0, 0.0)
1827
-
1828
- if not use_individual_spacing:
1829
- return gr.update(
1830
- headers=SIMPLE_NOZZLE_SPACING_HEADERS,
1831
- value=[["Same spacing", "All neighboring nozzles", first_pair[0], first_pair[1]]],
1832
- row_count=(1, "fixed"),
1833
- column_count=(len(SIMPLE_NOZZLE_SPACING_HEADERS), "fixed"),
1834
- label="Nozzle Spacing",
1835
- )
1836
-
1837
- rows: list[list[Any]] = []
1838
- ordered_nozzles = _ordered_nozzle_numbers(records)
1839
- for index, (first, second) in enumerate(zip(ordered_nozzles, ordered_nozzles[1:])):
1840
- gap_x, gap_y = pairs[index] if index < len(pairs) else first_pair
1841
- rows.append([
1842
- _nozzle_spacing_label(first, records),
1843
- _nozzle_spacing_label(second, records),
1844
- gap_x,
1845
- gap_y,
1846
- ])
1847
- return gr.update(
1848
- headers=ADVANCED_NOZZLE_SPACING_HEADERS,
1849
- value=rows,
1850
- row_count=(len(rows), "fixed"),
1851
- column_count=(len(ADVANCED_NOZZLE_SPACING_HEADERS), "fixed"),
1852
- label="Advanced Nozzle Spacing",
1853
- )
1854
-
1855
-
1856
  def _grid_spacing_rows(
1857
  records: list[dict],
1858
  columns: Any,
@@ -1904,8 +1745,6 @@ def _grid_spacing_table_update(
1904
  value=spacing_rows,
1905
  row_count=(len(spacing_rows), "fixed"),
1906
  column_count=(len(ADVANCED_NOZZLE_SPACING_HEADERS), "fixed"),
1907
- label="Advanced Grid Spacing",
1908
- visible=bool(use_individual_grid_spacing),
1909
  )
1910
 
1911
 
@@ -1932,14 +1771,73 @@ def _split_pair_was_created_together(records_by_nozzle: dict[int, list[dict]], f
1932
  return False
1933
 
1934
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1935
  def _auto_align_grid_spacing_rows(
1936
  records: list[dict],
1937
  columns: Any,
1938
  rows: Any,
1939
  column_spacing: Any,
1940
  row_spacing: Any,
1941
- raster_pattern: str | None,
1942
- ) -> tuple[list[list[Any]], int, int, int]:
 
 
 
 
 
 
 
 
1943
  spacing_rows, column_count, row_count = _grid_spacing_rows(
1944
  records,
1945
  columns,
@@ -1947,18 +1845,42 @@ def _auto_align_grid_spacing_rows(
1947
  column_spacing,
1948
  row_spacing,
1949
  )
1950
- auto_x_gap, auto_y_gap = _auto_align_split_offsets(raster_pattern)
 
1951
  records_by_nozzle = _records_by_nozzle(records)
1952
  ordered_nozzles = _ordered_nozzle_numbers(records)
 
1953
  aligned_count = 0
 
1954
  for index, (first, second) in enumerate(zip(ordered_nozzles, ordered_nozzles[1:])):
1955
  if not _split_pair_was_created_together(records_by_nozzle, first, second):
1956
  continue
1957
- gap_x, gap_y = _grid_default_gap_for_pair(index, column_count, auto_x_gap, auto_y_gap)
1958
- spacing_rows[index][2] = gap_x
1959
- spacing_rows[index][3] = gap_y
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1960
  aligned_count += 1
1961
- return spacing_rows, column_count, row_count, aligned_count
1962
 
1963
 
1964
  def apply_nozzle_grid_spacing(
@@ -1988,46 +1910,57 @@ def apply_nozzle_grid_spacing(
1988
  )
1989
 
1990
 
1991
- def _auto_align_split_offsets(raster_pattern: str | None) -> tuple[float, float]:
1992
- if raster_pattern == RASTER_PATTERN_Y_DIRECTION:
1993
- return AUTO_ALIGN_Y_RASTER_OFFSETS
1994
- return AUTO_ALIGN_X_RASTER_OFFSETS
1995
-
1996
-
1997
- def auto_align_split_parts_for_raster(
1998
  records: list[dict] | None,
1999
  columns: Any,
2000
  rows: Any,
2001
  column_spacing: Any,
2002
  row_spacing: Any,
2003
- raster_pattern: str | None,
2004
  ) -> tuple:
2005
- x_gap, y_gap = _auto_align_split_offsets(raster_pattern)
2006
- raster_label = raster_pattern or RASTER_PATTERN_SAME_DIRECTION
2007
- spacing_rows, column_count, row_count, aligned_count = _auto_align_grid_spacing_rows(
2008
- records or [],
2009
- columns,
2010
- rows,
2011
- column_spacing,
2012
- row_spacing,
2013
- raster_pattern,
2014
- )
 
 
2015
  if aligned_count <= 0:
 
 
 
 
 
 
 
 
2016
  return (
2017
- gr.update(value=NOZZLE_LAYOUT_GRID),
2018
- gr.update(visible=True),
2019
- gr.update(visible=False),
2020
  gr.update(),
2021
  gr.update(),
2022
  gr.update(),
2023
  gr.update(),
2024
- "No split-sibling nozzle connections found. Auto align was not applied.",
 
 
2025
  )
2026
 
 
 
 
 
 
 
 
 
 
 
2027
  return (
2028
- gr.update(value=NOZZLE_LAYOUT_GRID),
2029
- gr.update(visible=True),
2030
- gr.update(visible=False),
2031
  gr.update(),
2032
  gr.update(),
2033
  gr.update(value=True),
@@ -2036,13 +1969,8 @@ def auto_align_split_parts_for_raster(
2036
  value=spacing_rows,
2037
  row_count=(len(spacing_rows), "fixed"),
2038
  column_count=(len(ADVANCED_NOZZLE_SPACING_HEADERS), "fixed"),
2039
- label="Advanced Grid Spacing",
2040
- visible=True,
2041
- ),
2042
- (
2043
- f"Auto aligned {aligned_count} split nozzle connection(s) for {raster_label} "
2044
- f"in a {column_count} x {row_count} grid: Column Gap X {x_gap:.2f} mm, Row Gap Y {y_gap:.2f} mm."
2045
  ),
 
2046
  )
2047
 
2048
 
@@ -2063,11 +1991,6 @@ def update_nozzle_grid_preset(
2063
  return gr.update(value=max(1, _coerce_int(columns, 1))), gr.update(value=max(1, _coerce_int(rows, 1)))
2064
 
2065
 
2066
- def update_nozzle_spacing_mode(layout_mode: str | None) -> tuple[dict[str, Any], dict[str, Any]]:
2067
- custom_selected = layout_mode == NOZZLE_LAYOUT_PAIR_TABLE
2068
- return gr.update(visible=not custom_selected), gr.update(visible=custom_selected)
2069
-
2070
-
2071
  def update_lead_in_options_visibility(enabled: bool | None) -> dict[str, Any]:
2072
  return gr.update(visible=bool(enabled))
2073
 
@@ -2110,17 +2033,13 @@ def sync_uploaded_shapes(
2110
  files: Any,
2111
  records: list[dict] | None,
2112
  settings_table: Any | None = None,
2113
- existing_spacing: Any | None = None,
2114
- use_individual_spacing: bool | None = False,
2115
  ) -> tuple:
2116
  records = _apply_shape_settings(records or [], settings_table)
2117
  next_records = _records_from_files(files, records)
2118
  settings = _shape_settings_rows(next_records)
2119
- spacing = _spacing_table_update(next_records, existing_spacing, use_individual_spacing)
2120
  return (
2121
  next_records,
2122
  settings,
2123
- spacing,
2124
  _dropdown_update(next_records),
2125
  _gcode_dropdown_update(next_records),
2126
  _gcode_dropdown_update(next_records, include_upload=True),
@@ -2132,30 +2051,18 @@ def load_sample_shapes(
2132
  files: Any,
2133
  records: list[dict] | None,
2134
  settings_table: Any | None = None,
2135
- existing_spacing: Any | None = None,
2136
- use_individual_spacing: bool | None = False,
2137
  ) -> tuple:
2138
  records = _apply_shape_settings(records or [], settings_table)
2139
  paths = [str(SAMPLE_STL_DIR / filename) for filename in SAMPLE_STL_FILENAMES if (SAMPLE_STL_DIR / filename).exists()]
2140
  merged_paths = _append_file_paths(files, paths)
2141
  return (
2142
  gr.update(value=merged_paths),
2143
- *sync_uploaded_shapes(merged_paths, records, None, existing_spacing, use_individual_spacing),
2144
  )
2145
 
2146
 
2147
- def update_nozzle_spacing_table_mode(
2148
- records: list[dict] | None,
2149
- existing_spacing: Any | None,
2150
- use_individual_spacing: bool | None,
2151
- ) -> dict[str, Any]:
2152
- return _spacing_table_update(records or [], existing_spacing, use_individual_spacing)
2153
-
2154
-
2155
  def _shape_delete_outputs(
2156
  records: list[dict],
2157
- existing_spacing: Any | None,
2158
- use_individual_spacing: bool | None,
2159
  last_delete_at: float | None,
2160
  upload_update: Any | None = None,
2161
  ) -> tuple:
@@ -2163,7 +2070,6 @@ def _shape_delete_outputs(
2163
  upload_update if upload_update is not None else gr.update(),
2164
  records,
2165
  _shape_settings_rows(records),
2166
- _spacing_table_update(records, existing_spacing, use_individual_spacing),
2167
  _dropdown_update(records),
2168
  _gcode_dropdown_update(records),
2169
  _gcode_dropdown_update(records, include_upload=True),
@@ -2175,8 +2081,6 @@ def _shape_delete_outputs(
2175
  def delete_shape_from_settings(
2176
  records: list[dict] | None,
2177
  settings_table: Any | None,
2178
- existing_spacing: Any | None,
2179
- use_individual_spacing: bool | None,
2180
  last_delete_at: float | None,
2181
  evt: gr.SelectData,
2182
  ) -> tuple:
@@ -2185,17 +2089,17 @@ def delete_shape_from_settings(
2185
  selected = getattr(evt, "index", None)
2186
  current_records = _apply_shape_settings(records or [], settings_table)
2187
  if not isinstance(selected, (list, tuple)) or len(selected) < 2:
2188
- return _shape_delete_outputs(current_records, existing_spacing, use_individual_spacing, last_delete_at)
2189
 
2190
  try:
2191
  row_index, column_index = int(selected[0]), int(selected[1])
2192
  except (TypeError, ValueError):
2193
- return _shape_delete_outputs(current_records, existing_spacing, use_individual_spacing, last_delete_at)
2194
  delete_column_index = len(SHAPE_SETTINGS_HEADERS) - 1
2195
  if column_index != delete_column_index or row_index < 0 or row_index >= len(rows):
2196
- return _shape_delete_outputs(current_records, existing_spacing, use_individual_spacing, last_delete_at)
2197
  if last_delete_at and now - float(last_delete_at) < DELETE_SHAPE_COOLDOWN_SECONDS:
2198
- return _shape_delete_outputs(current_records, existing_spacing, use_individual_spacing, last_delete_at)
2199
 
2200
  try:
2201
  delete_idx = int(float(rows[row_index][0]))
@@ -2208,8 +2112,6 @@ def delete_shape_from_settings(
2208
  upload_paths = [record.get("stl_path") for record in next_records if record.get("stl_path")]
2209
  return _shape_delete_outputs(
2210
  next_records,
2211
- existing_spacing,
2212
- use_individual_spacing,
2213
  now,
2214
  gr.update(value=upload_paths),
2215
  )
@@ -2289,21 +2191,6 @@ def normalize_shape_dimensions_for_mode(
2289
  return normalized, _shape_settings_rows(normalized)
2290
 
2291
 
2292
- def normalize_shape_settings_and_spacing(
2293
- records: list[dict] | None,
2294
- settings_table: Any | None,
2295
- scale_mode: str | None,
2296
- existing_spacing: Any | None,
2297
- use_individual_spacing: bool | None,
2298
- ) -> tuple:
2299
- updated_records, updated_settings = normalize_shape_dimensions_for_mode(records, settings_table, scale_mode)
2300
- return (
2301
- updated_records,
2302
- updated_settings,
2303
- _spacing_table_update(updated_records, existing_spacing, use_individual_spacing),
2304
- )
2305
-
2306
-
2307
  def show_selected_model(
2308
  records: list[dict] | None,
2309
  selected: str | None,
@@ -2370,6 +2257,7 @@ def generate_dynamic_layer_stacks(
2370
  settings_table: Any,
2371
  layer_height: float,
2372
  scale_mode: str | None,
 
2373
  progress: gr.Progress = gr.Progress(),
2374
  ) -> tuple:
2375
  records = _apply_shape_settings(records or [], settings_table)
@@ -2398,7 +2286,7 @@ def generate_dynamic_layer_stacks(
2398
  )
2399
  except Exception as exc:
2400
  messages.append(f"Shape {record['idx']}: failed ({exc}).")
2401
- ref_layers = generate_dynamic_reference_stack(records)
2402
  if ref_layers is not None:
2403
  messages.append("Reference layers: updated automatically.")
2404
  else:
@@ -2406,16 +2294,22 @@ def generate_dynamic_layer_stacks(
2406
  return records, "\n".join(messages), ref_layers
2407
 
2408
 
2409
- def generate_dynamic_reference_stack(records: list[dict] | None) -> LayerStack | None:
2410
- return build_reference_stack([record.get("layer_stack") for record in (records or [])])
 
 
 
 
 
 
 
 
2411
 
2412
 
2413
  def split_selected_shape_for_grid(
2414
  records: list[dict] | None,
2415
  selected: str | None,
2416
  settings_table: Any | None,
2417
- existing_spacing: Any | None,
2418
- use_individual_spacing: bool | None,
2419
  columns: float,
2420
  rows: float,
2421
  overlapping_layers: bool,
@@ -2429,7 +2323,6 @@ def split_selected_shape_for_grid(
2429
  return (
2430
  next_records,
2431
  _shape_settings_rows(next_records),
2432
- _spacing_table_update(next_records, existing_spacing, use_individual_spacing),
2433
  _dropdown_update(next_records, selected_value),
2434
  [record.get("gcode_path") for record in next_records if record.get("gcode_path")],
2435
  _gcode_dropdown_update(next_records),
@@ -2462,6 +2355,9 @@ def split_selected_shape_for_grid(
2462
  rows=split_row_count,
2463
  overlapping_layers=bool(overlapping_layers),
2464
  overlap=float(fil_width) if overlapping_layers else 0.0,
 
 
 
2465
  )
2466
  except Exception as exc:
2467
  return _outputs(records, selected, f"Split failed: {exc}")
@@ -2571,9 +2467,13 @@ def generate_dynamic_gcode(
2571
  ) -> tuple:
2572
  records = _apply_shape_settings(records or [], settings_table)
2573
  messages: list[str] = []
2574
- if _ensure_records_sliced(records, layer_height, scale_mode, messages):
2575
- # Anything re-sliced invalidates the previous reference union.
2576
- ref_layers = generate_dynamic_reference_stack(records)
 
 
 
 
2577
  contour_sources = _contour_tracing_sources(records)
2578
  if contour_sources:
2579
  enabled = ", ".join(f"Shape {source.owner_idx}" for source in contour_sources)
@@ -2602,6 +2502,7 @@ def generate_dynamic_gcode(
2602
  raster_pattern=raster_pattern,
2603
  contour_sources=contour_sources,
2604
  active_contour_owner=int(record.get("idx", 0)),
 
2605
  lead_in_enabled=bool(lead_in_enabled),
2606
  lead_in_length=float(lead_in_length),
2607
  lead_in_clearance=float(lead_in_clearance),
@@ -2657,44 +2558,26 @@ def _parts_from_records(records: list[dict] | None) -> tuple[list[dict], list[st
2657
  return parts, messages
2658
 
2659
 
2660
- def _spacing_args_from_table(spacing_table: Any, use_individual_spacing: bool | None = False) -> tuple[float, float, float, float, list[float]]:
2661
- pairs = _spacing_pairs_from_table(spacing_table)
2662
- if not pairs:
2663
- pairs = [(5.0, 0.0)]
2664
- if not use_individual_spacing:
2665
- pairs = [pairs[0]]
2666
- while len(pairs) < 2:
2667
- pairs.append(pairs[0])
2668
- extra = [value for pair in pairs[2:] for value in pair]
2669
- return pairs[0][0], pairs[0][1], pairs[1][0], pairs[1][1], extra
2670
-
2671
-
2672
  def render_dynamic_nozzle_spacing(
2673
  records: list[dict] | None,
2674
- layout_mode: str | None,
2675
  columns: Any,
2676
  rows: Any,
2677
  column_spacing: Any,
2678
  row_spacing: Any,
2679
  use_grid_individual_spacing: bool,
2680
  grid_spacing_table: Any,
2681
- use_individual_spacing: bool,
2682
- spacing_table: Any,
2683
  ) -> tuple[Any, str]:
2684
  parts, _messages = _parts_from_records(records)
2685
  if not parts:
2686
  return None, "No shape G-code available. Generate G-code first."
2687
- offsets, spacings = _resolve_layout_from_spacing_controls(
2688
  parts,
2689
- layout_mode,
2690
  columns,
2691
  rows,
2692
  column_spacing,
2693
  row_spacing,
2694
  use_grid_individual_spacing,
2695
  grid_spacing_table,
2696
- use_individual_spacing,
2697
- spacing_table,
2698
  )
2699
  return build_nozzle_spacing_figure(parts, offsets, spacings), _format_nozzle_spacing_status(parts, offsets, spacings)
2700
 
@@ -2765,32 +2648,26 @@ def render_dynamic_parallel(
2765
  travel_opacity: float,
2766
  filament_width: float,
2767
  travel_width: float,
2768
- layout_mode: str | None,
2769
  columns: Any,
2770
  rows: Any,
2771
  column_spacing: Any,
2772
  row_spacing: Any,
2773
  use_grid_individual_spacing: bool,
2774
  grid_spacing_table: Any,
2775
- use_individual_spacing: bool,
2776
- spacing_table: Any,
2777
  tube: bool = True,
2778
  ) -> tuple[Any, str]:
2779
  records = _apply_shape_settings(records or [], settings_table)
2780
  parts, messages = _parts_from_records(records)
2781
  if not parts:
2782
  return None, "No shape G-code available. Generate G-code on the Generate G-Code tab first."
2783
- offsets, spacings = _resolve_layout_from_spacing_controls(
2784
  parts,
2785
- layout_mode,
2786
  columns,
2787
  rows,
2788
  column_spacing,
2789
  row_spacing,
2790
  use_grid_individual_spacing,
2791
  grid_spacing_table,
2792
- use_individual_spacing,
2793
- spacing_table,
2794
  )
2795
  figure = build_parallel_figure(
2796
  parts,
@@ -2824,15 +2701,12 @@ def export_dynamic_parallel_gif(
2824
  records: list[dict] | None,
2825
  settings_table: Any,
2826
  travel_opacity: float,
2827
- layout_mode: str | None,
2828
  columns: Any,
2829
  rows: Any,
2830
  column_spacing: Any,
2831
  row_spacing: Any,
2832
  use_grid_individual_spacing: bool,
2833
  grid_spacing_table: Any,
2834
- use_individual_spacing: bool,
2835
- spacing_table: Any,
2836
  duration: float,
2837
  fps: float,
2838
  elev: float,
@@ -2843,17 +2717,14 @@ def export_dynamic_parallel_gif(
2843
  parts, _messages = _parts_from_records(records)
2844
  if not parts:
2845
  return None
2846
- offsets, _spacings = _resolve_layout_from_spacing_controls(
2847
  parts,
2848
- layout_mode,
2849
  columns,
2850
  rows,
2851
  column_spacing,
2852
  row_spacing,
2853
  use_grid_individual_spacing,
2854
  grid_spacing_table,
2855
- use_individual_spacing,
2856
- spacing_table,
2857
  )
2858
 
2859
  def report(frame: int, total: int) -> None:
@@ -2986,12 +2857,7 @@ def build_dynamic_demo() -> gr.Blocks:
2986
  gcode_text = gr.Code(label="Selected G-Code", language=None, lines=18, max_lines=18, interactive=False, elem_classes=["gcode-view"])
2987
 
2988
  with gr.Accordion("Nozzle Spacing", open=False, elem_classes=["settings-accordion"]):
2989
- nozzle_layout_mode = gr.Radio(
2990
- label="Spacing Mode",
2991
- choices=[NOZZLE_LAYOUT_GRID, NOZZLE_LAYOUT_PAIR_TABLE],
2992
- value=NOZZLE_LAYOUT_GRID,
2993
- )
2994
- with gr.Group(visible=True) as nozzle_grid_group:
2995
  with gr.Row():
2996
  nozzle_grid_preset = gr.Dropdown(
2997
  label="Common Layout",
@@ -3005,28 +2871,21 @@ def build_dynamic_demo() -> gr.Blocks:
3005
  nozzle_grid_row_spacing = gr.Number(label="Row Gap (Y, mm)", value=0.0, step=0.1)
3006
  with gr.Row():
3007
  auto_align_split_parts_button = gr.Button("Auto Align Split Parts", variant="secondary", size="sm")
3008
- nozzle_grid_use_individual_spacing = gr.Checkbox(label="Use Different Grid Connection Gaps", value=False)
 
 
 
 
 
3009
  nozzle_grid_spacing_table = gr.Dataframe(
3010
  headers=ADVANCED_NOZZLE_SPACING_HEADERS,
3011
  value=[],
3012
  row_count=(0, "fixed"),
3013
  column_count=(len(ADVANCED_NOZZLE_SPACING_HEADERS), "fixed"),
3014
  interactive=True,
3015
- label="Advanced Grid Spacing",
3016
- visible=False,
3017
  elem_id="nozzle-grid-spacing-table",
3018
  )
3019
- with gr.Group(visible=False) as nozzle_custom_group:
3020
- nozzle_use_individual_spacing = gr.Checkbox(label="Use Different Values for Each Nozzle Connection", value=False)
3021
- nozzle_spacing_table = gr.Dataframe(
3022
- headers=NOZZLE_SPACING_HEADERS,
3023
- value=[["Same spacing", "All neighboring nozzles", 5.0, 0.0]],
3024
- row_count=(1, "fixed"),
3025
- column_count=(len(NOZZLE_SPACING_HEADERS), "fixed"),
3026
- interactive=True,
3027
- label="Custom Spacing",
3028
- elem_id="nozzle-spacing-table",
3029
- )
3030
  nozzle_preview_button = gr.Button("Visualize Nozzle Spacing", variant="secondary", elem_id="visualize-nozzle-spacing-button")
3031
  with gr.Row():
3032
  with gr.Column(scale=3, min_width=420):
@@ -3120,8 +2979,8 @@ def build_dynamic_demo() -> gr.Blocks:
3120
  nozzle_grid_use_individual_spacing,
3121
  ]
3122
 
3123
- shape_sync_outputs = [shape_records, shape_settings, nozzle_spacing_table, selected_shape, gcode_text_source, gcode_source, gcode_downloads]
3124
- stl_upload.change(fn=sync_uploaded_shapes, inputs=[stl_upload, shape_records, shape_settings, nozzle_spacing_table, nozzle_use_individual_spacing], outputs=shape_sync_outputs).then(
3125
  fn=lambda records: _dropdown_update(records),
3126
  inputs=[shape_records],
3127
  outputs=[split_source],
@@ -3132,7 +2991,7 @@ def build_dynamic_demo() -> gr.Blocks:
3132
  outputs=[nozzle_grid_spacing_table],
3133
  queue=False,
3134
  )
3135
- sync_uploads_button.click(fn=sync_uploaded_shapes, inputs=[stl_upload, shape_records, shape_settings, nozzle_spacing_table, nozzle_use_individual_spacing], outputs=shape_sync_outputs).then(
3136
  fn=lambda records: _dropdown_update(records),
3137
  inputs=[shape_records],
3138
  outputs=[split_source],
@@ -3143,7 +3002,7 @@ def build_dynamic_demo() -> gr.Blocks:
3143
  outputs=[nozzle_grid_spacing_table],
3144
  queue=False,
3145
  )
3146
- load_samples_button.click(fn=load_sample_shapes, inputs=[stl_upload, shape_records, shape_settings, nozzle_spacing_table, nozzle_use_individual_spacing], outputs=[stl_upload, *shape_sync_outputs]).then(
3147
  fn=lambda records: _dropdown_update(records),
3148
  inputs=[shape_records],
3149
  outputs=[split_source],
@@ -3156,7 +3015,7 @@ def build_dynamic_demo() -> gr.Blocks:
3156
  )
3157
  shape_settings.select(
3158
  fn=delete_shape_from_settings,
3159
- inputs=[shape_records, shape_settings, nozzle_spacing_table, nozzle_use_individual_spacing, last_shape_delete_at],
3160
  outputs=[stl_upload, *shape_sync_outputs, last_shape_delete_at],
3161
  ).then(
3162
  fn=lambda records: _dropdown_update(records),
@@ -3172,9 +3031,9 @@ def build_dynamic_demo() -> gr.Blocks:
3172
 
3173
  preview_inputs = [shape_records, selected_shape, shape_settings, model_opacity, scale_mode]
3174
  shape_settings.change(
3175
- fn=normalize_shape_settings_and_spacing,
3176
- inputs=[shape_records, shape_settings, scale_mode, nozzle_spacing_table, nozzle_use_individual_spacing],
3177
- outputs=[shape_records, shape_settings, nozzle_spacing_table],
3178
  queue=False,
3179
  ).then(
3180
  fn=_grid_spacing_table_update,
@@ -3207,7 +3066,7 @@ def build_dynamic_demo() -> gr.Blocks:
3207
 
3208
  generate_button.click(
3209
  fn=generate_dynamic_layer_stacks,
3210
- inputs=[shape_records, shape_settings, layer_height, scale_mode],
3211
  outputs=[shape_records, slicer_status, ref_layers],
3212
  ).then(
3213
  fn=lambda records: _dropdown_update(records),
@@ -3223,8 +3082,6 @@ def build_dynamic_demo() -> gr.Blocks:
3223
  shape_records,
3224
  split_source,
3225
  shape_settings,
3226
- nozzle_spacing_table,
3227
- nozzle_use_individual_spacing,
3228
  split_columns,
3229
  split_rows,
3230
  split_overlapping_layers,
@@ -3235,7 +3092,6 @@ def build_dynamic_demo() -> gr.Blocks:
3235
  outputs=[
3236
  shape_records,
3237
  shape_settings,
3238
- nozzle_spacing_table,
3239
  selected_shape,
3240
  gcode_downloads,
3241
  gcode_text_source,
@@ -3245,7 +3101,7 @@ def build_dynamic_demo() -> gr.Blocks:
3245
  ],
3246
  ).then(
3247
  fn=generate_dynamic_reference_stack,
3248
- inputs=[shape_records],
3249
  outputs=[ref_layers],
3250
  ).then(
3251
  fn=_grid_spacing_table_update,
@@ -3286,26 +3142,18 @@ def build_dynamic_demo() -> gr.Blocks:
3286
  )
3287
  gcode_text_source.change(fn=load_selected_gcode_text, inputs=[shape_records, gcode_text_source], outputs=[gcode_text])
3288
  refresh_gcode_text_button.click(fn=load_selected_gcode_text, inputs=[shape_records, gcode_text_source], outputs=[gcode_text])
3289
- nozzle_layout_mode.change(
3290
- fn=update_nozzle_spacing_mode,
3291
- inputs=[nozzle_layout_mode],
3292
- outputs=[nozzle_grid_group, nozzle_custom_group],
3293
- queue=False,
3294
- )
3295
  auto_align_split_parts_button.click(
3296
- fn=auto_align_split_parts_for_raster,
3297
  inputs=[
3298
  shape_records,
3299
  nozzle_grid_columns,
3300
  nozzle_grid_rows,
3301
  nozzle_grid_column_spacing,
3302
  nozzle_grid_row_spacing,
3303
- gcode_raster_pattern,
3304
  ],
3305
  outputs=[
3306
- nozzle_layout_mode,
3307
- nozzle_grid_group,
3308
- nozzle_custom_group,
3309
  nozzle_grid_column_spacing,
3310
  nozzle_grid_row_spacing,
3311
  nozzle_grid_use_individual_spacing,
@@ -3332,26 +3180,27 @@ def build_dynamic_demo() -> gr.Blocks:
3332
  nozzle_grid_row_spacing,
3333
  nozzle_grid_use_individual_spacing,
3334
  ):
3335
- grid_spacing_control.change(
 
 
 
 
 
3336
  fn=_grid_spacing_table_update,
3337
  inputs=grid_spacing_refresh_inputs,
3338
  outputs=[nozzle_grid_spacing_table],
3339
  queue=False,
3340
  )
3341
- nozzle_use_individual_spacing.change(fn=update_nozzle_spacing_table_mode, inputs=[shape_records, nozzle_spacing_table, nozzle_use_individual_spacing], outputs=[nozzle_spacing_table], queue=False)
3342
  nozzle_preview_button.click(
3343
  fn=render_dynamic_nozzle_spacing,
3344
  inputs=[
3345
  shape_records,
3346
- nozzle_layout_mode,
3347
  nozzle_grid_columns,
3348
  nozzle_grid_rows,
3349
  nozzle_grid_column_spacing,
3350
  nozzle_grid_row_spacing,
3351
  nozzle_grid_use_individual_spacing,
3352
  nozzle_grid_spacing_table,
3353
- nozzle_use_individual_spacing,
3354
- nozzle_spacing_table,
3355
  ],
3356
  outputs=[nozzle_spacing_plot, nozzle_spacing_status],
3357
  )
@@ -3398,15 +3247,12 @@ def build_dynamic_demo() -> gr.Blocks:
3398
  pp_travel_opacity,
3399
  pp_filament_width,
3400
  pp_travel_width,
3401
- nozzle_layout_mode,
3402
  nozzle_grid_columns,
3403
  nozzle_grid_rows,
3404
  nozzle_grid_column_spacing,
3405
  nozzle_grid_row_spacing,
3406
  nozzle_grid_use_individual_spacing,
3407
  nozzle_grid_spacing_table,
3408
- nozzle_use_individual_spacing,
3409
- nozzle_spacing_table,
3410
  ]
3411
  parallel_outputs = [parallel_plot, parallel_status, parallel_mode, parallel_anim_controls, pp_width_row, pp_export_group]
3412
  parallel_line_button.click(fn=render_dynamic_parallel_lines, inputs=parallel_render_inputs, outputs=parallel_outputs)
@@ -3419,15 +3265,12 @@ def build_dynamic_demo() -> gr.Blocks:
3419
  shape_records,
3420
  shape_settings,
3421
  pp_gif_travel_opacity,
3422
- nozzle_layout_mode,
3423
  nozzle_grid_columns,
3424
  nozzle_grid_rows,
3425
  nozzle_grid_column_spacing,
3426
  nozzle_grid_row_spacing,
3427
  nozzle_grid_use_individual_spacing,
3428
  nozzle_grid_spacing_table,
3429
- nozzle_use_individual_spacing,
3430
- nozzle_spacing_table,
3431
  pp_gif_duration,
3432
  pp_gif_fps,
3433
  pp_elev,
 
55
  SCALE_MODE_UNIFORM_FACTOR = "Keep Proportions"
56
  TARGET_DIMENSION_KEYS = ("target_x", "target_y", "target_z")
57
  FRONT_CAMERA = (90, 80, None)
 
 
58
  NOZZLE_LAYOUT_PRESETS = [
59
  "Custom",
60
  "One row",
 
66
  "2 x 5",
67
  "5 x 2",
68
  ]
 
 
69
  APP_CSS = """
70
  .gradio-container {
71
  font-size: 90%;
 
1157
  DEFAULT_PARALLEL_COLORS = ("#ff7f0e", "#1f77b4", "#2ca02c")
1158
 
1159
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1160
  def _group_parts_by_nozzle(parts: list[dict]) -> dict[int, list[dict]]:
1161
  grouped: dict[int, list[dict]] = {}
1162
  for part in parts:
 
1296
  return offsets, spacings
1297
 
1298
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1299
  def _format_shape_dimensions(parts: list[dict]) -> list[str]:
1300
  lines = ["**Shape dimensions from generated G-code:**"]
1301
  for part in sorted(parts, key=lambda item: item["idx"]):
 
1360
  "Nozzle",
1361
  "Port",
1362
  "Color",
1363
+ "Infill %",
1364
  "Contour Tracing",
1365
  "Delete",
1366
  ]
 
1375
  "number",
1376
  "number",
1377
  "str",
1378
+ "number",
1379
  "bool",
1380
  "str",
1381
  ]
 
 
 
 
 
 
1382
  ADVANCED_NOZZLE_SPACING_HEADERS = [
1383
  "From Nozzle",
1384
  "To Nozzle",
1385
  "X edge spacing (mm)",
1386
  "Y nozzle spacing (mm)",
1387
  ]
 
1388
 
1389
 
1390
  def _normalise_rows(table: Any) -> list[list[Any]]:
 
1523
  "nozzle": nozzle,
1524
  "port": previous.get("port", 1),
1525
  "color": previous.get("color", _default_color(index)),
1526
+ "infill": previous.get("infill", 100.0),
1527
  "contour_tracing": previous.get("contour_tracing", False),
1528
  "layer_stack": previous.get("layer_stack"),
1529
  "slice_params": previous.get("slice_params"),
 
1554
  _record_nozzle_number(record, int(record["idx"])),
1555
  record.get("port", 1),
1556
  record.get("color", _default_color(record["idx"])),
1557
+ _coerce_float(record.get("infill", 100.0), 100.0),
1558
  bool(record.get("contour_tracing", False)),
1559
  "Delete",
1560
  ]
 
1598
  nozzle_pos = 7 if has_nozzle_column else None
1599
  port_pos = 8 if has_nozzle_column else 7
1600
  color_pos = 9 if has_nozzle_column else 8
1601
+ infill_pos = 10 if has_nozzle_column else 9
1602
+ contour_pos = 11 if has_nozzle_column else 10
1603
  copy["valve"] = _coerce_int(copy.get("valve", 4), 4)
1604
  copy["nozzle"] = _coerce_int(
1605
  row[nozzle_pos] if nozzle_pos is not None else copy.get("nozzle", copy.get("idx", 1)),
 
1613
  copy["nozzle"] = _record_nozzle_number(copy)
1614
  if len(row) > color_pos and row[color_pos]:
1615
  copy["color"] = str(row[color_pos])
1616
+ try:
1617
+ copy["infill"] = max(
1618
+ 0.0,
1619
+ min(100.0, float(row[infill_pos])),
1620
+ )
1621
+ except (IndexError, TypeError, ValueError):
1622
+ copy["infill"] = _coerce_float(copy.get("infill", 100.0), 100.0)
1623
  try:
1624
  copy["contour_tracing"] = _coerce_bool(row[contour_pos], bool(copy.get("contour_tracing", False)))
1625
  except IndexError:
 
1694
  return pairs
1695
 
1696
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1697
  def _grid_spacing_rows(
1698
  records: list[dict],
1699
  columns: Any,
 
1745
  value=spacing_rows,
1746
  row_count=(len(spacing_rows), "fixed"),
1747
  column_count=(len(ADVANCED_NOZZLE_SPACING_HEADERS), "fixed"),
 
 
1748
  )
1749
 
1750
 
 
1771
  return False
1772
 
1773
 
1774
+ def _part_world_bounds(part: dict) -> tuple[tuple[float, float], tuple[float, float]] | None:
1775
+ """XY bounding box of a part's toolpath in the shape's own world frame.
1776
+
1777
+ Requires the `; PathOrigin` header the G-code generator writes; parts
1778
+ generated before that header exists return None.
1779
+ """
1780
+ parsed = part.get("parsed") or {}
1781
+ origin = parsed.get("path_origin")
1782
+ if origin is None:
1783
+ return None
1784
+ (x_min, y_min, _z_min), (x_max, y_max, _z_max) = parsed["bounds"]
1785
+ return (
1786
+ (x_min + origin[0], y_min + origin[1]),
1787
+ (x_max + origin[0], y_max + origin[1]),
1788
+ )
1789
+
1790
+
1791
+ def _nozzle_world_bounds(
1792
+ grouped: dict[int, list[dict]],
1793
+ ) -> dict[int, tuple[tuple[float, float], tuple[float, float]]]:
1794
+ world: dict[int, tuple[tuple[float, float], tuple[float, float]]] = {}
1795
+ for nozzle, parts in grouped.items():
1796
+ boxes = [_part_world_bounds(part) for part in parts]
1797
+ if not boxes or any(box is None for box in boxes):
1798
+ continue
1799
+ world[nozzle] = (
1800
+ (min(box[0][0] for box in boxes), min(box[0][1] for box in boxes)),
1801
+ (max(box[1][0] for box in boxes), max(box[1][1] for box in boxes)),
1802
+ )
1803
+ return world
1804
+
1805
+
1806
+ def _split_grid_shape(records: list[dict]) -> tuple[int, int] | None:
1807
+ """The split grid (columns, rows) when records hold exactly one split group."""
1808
+ groups = {
1809
+ record.get("split_group_id")
1810
+ for record in records
1811
+ if record.get("split_group_id")
1812
+ }
1813
+ if len(groups) != 1:
1814
+ return None
1815
+ group_id = next(iter(groups))
1816
+ for record in records:
1817
+ if record.get("split_group_id") == group_id:
1818
+ columns = _coerce_int(record.get("split_columns"), 0)
1819
+ rows = _coerce_int(record.get("split_rows"), 0)
1820
+ if columns >= 1 and rows >= 1:
1821
+ return columns, rows
1822
+ return None
1823
+
1824
+
1825
  def _auto_align_grid_spacing_rows(
1826
  records: list[dict],
1827
  columns: Any,
1828
  rows: Any,
1829
  column_spacing: Any,
1830
  row_spacing: Any,
1831
+ ) -> tuple[list[list[Any]], int, int, int, int]:
1832
+ """Exact per-pair gaps that reassemble split pieces in the parallel view.
1833
+
1834
+ Every generated G-code file records its PathOrigin: the world position of
1835
+ the relative toolpath's start. Anchoring each piece back into its world
1836
+ frame turns the required pair gaps into the actual world-frame gaps
1837
+ between the parts' toolpath bounding boxes — which automatically accounts
1838
+ for the raster pattern, filament width, travel buffers, reference-stack
1839
+ motion, and overlapping-layer splits. No hardcoded offsets.
1840
+ """
1841
  spacing_rows, column_count, row_count = _grid_spacing_rows(
1842
  records,
1843
  columns,
 
1845
  column_spacing,
1846
  row_spacing,
1847
  )
1848
+ parts, _messages = _parts_from_records(records)
1849
+ world = _nozzle_world_bounds(_group_parts_by_nozzle(parts))
1850
  records_by_nozzle = _records_by_nozzle(records)
1851
  ordered_nozzles = _ordered_nozzle_numbers(records)
1852
+
1853
  aligned_count = 0
1854
+ missing_count = 0
1855
  for index, (first, second) in enumerate(zip(ordered_nozzles, ordered_nozzles[1:])):
1856
  if not _split_pair_was_created_together(records_by_nozzle, first, second):
1857
  continue
1858
+
1859
+ second_column = (index + 1) % column_count
1860
+ if second_column == 0:
1861
+ # Row transition: `second` opens a new grid row. The layout places
1862
+ # it relative to the previous row's first nozzle (x) and the
1863
+ # previous row's lowest edge (y).
1864
+ previous_row = ordered_nozzles[index + 1 - column_count : index + 1]
1865
+ anchors = [second, *previous_row]
1866
+ else:
1867
+ row_first = ordered_nozzles[index + 1 - second_column]
1868
+ anchors = [first, second, row_first]
1869
+ if any(nozzle not in world for nozzle in anchors):
1870
+ missing_count += 1
1871
+ continue
1872
+
1873
+ (second_min_x, second_min_y), _second_max = world[second]
1874
+ if second_column == 0:
1875
+ gap_x = second_min_x - world[previous_row[0]][0][0]
1876
+ gap_y = second_min_y - max(world[nozzle][1][1] for nozzle in previous_row)
1877
+ else:
1878
+ gap_x = second_min_x - world[first][1][0]
1879
+ gap_y = second_min_y - world[row_first][0][1]
1880
+ spacing_rows[index][2] = round(gap_x, 4)
1881
+ spacing_rows[index][3] = round(gap_y, 4)
1882
  aligned_count += 1
1883
+ return spacing_rows, column_count, row_count, aligned_count, missing_count
1884
 
1885
 
1886
  def apply_nozzle_grid_spacing(
 
1910
  )
1911
 
1912
 
1913
+ def auto_align_split_parts(
 
 
 
 
 
 
1914
  records: list[dict] | None,
1915
  columns: Any,
1916
  rows: Any,
1917
  column_spacing: Any,
1918
  row_spacing: Any,
 
1919
  ) -> tuple:
1920
+ records = records or []
1921
+ grid_shape = _split_grid_shape(records)
1922
+ if grid_shape is not None:
1923
+ columns, rows = grid_shape
1924
+ (
1925
+ spacing_rows,
1926
+ column_count,
1927
+ row_count,
1928
+ aligned_count,
1929
+ missing_count,
1930
+ ) = _auto_align_grid_spacing_rows(records, columns, rows, column_spacing, row_spacing)
1931
+
1932
  if aligned_count <= 0:
1933
+ if missing_count > 0:
1934
+ status = (
1935
+ "Auto align needs the split pieces' generated G-code: press "
1936
+ "Generate G-Code first (files generated before this feature "
1937
+ "lack the PathOrigin header), then align again."
1938
+ )
1939
+ else:
1940
+ status = "No split-sibling nozzle connections found. Auto align was not applied."
1941
  return (
 
 
 
1942
  gr.update(),
1943
  gr.update(),
1944
  gr.update(),
1945
  gr.update(),
1946
+ gr.update(),
1947
+ gr.update(),
1948
+ status,
1949
  )
1950
 
1951
+ status = (
1952
+ f"Auto aligned {aligned_count} split nozzle connection(s) from the generated "
1953
+ f"G-code in a {column_count} x {row_count} grid. The exact per-connection "
1954
+ "gaps are in the Advanced Grid Spacing table."
1955
+ )
1956
+ if missing_count:
1957
+ status += (
1958
+ f" \n{missing_count} connection(s) skipped: regenerate G-code for those "
1959
+ "shapes to add the PathOrigin header, then align again."
1960
+ )
1961
  return (
1962
+ gr.update(value=column_count),
1963
+ gr.update(value=row_count),
 
1964
  gr.update(),
1965
  gr.update(),
1966
  gr.update(value=True),
 
1969
  value=spacing_rows,
1970
  row_count=(len(spacing_rows), "fixed"),
1971
  column_count=(len(ADVANCED_NOZZLE_SPACING_HEADERS), "fixed"),
 
 
 
 
 
 
1972
  ),
1973
+ status,
1974
  )
1975
 
1976
 
 
1991
  return gr.update(value=max(1, _coerce_int(columns, 1))), gr.update(value=max(1, _coerce_int(rows, 1)))
1992
 
1993
 
 
 
 
 
 
1994
  def update_lead_in_options_visibility(enabled: bool | None) -> dict[str, Any]:
1995
  return gr.update(visible=bool(enabled))
1996
 
 
2033
  files: Any,
2034
  records: list[dict] | None,
2035
  settings_table: Any | None = None,
 
 
2036
  ) -> tuple:
2037
  records = _apply_shape_settings(records or [], settings_table)
2038
  next_records = _records_from_files(files, records)
2039
  settings = _shape_settings_rows(next_records)
 
2040
  return (
2041
  next_records,
2042
  settings,
 
2043
  _dropdown_update(next_records),
2044
  _gcode_dropdown_update(next_records),
2045
  _gcode_dropdown_update(next_records, include_upload=True),
 
2051
  files: Any,
2052
  records: list[dict] | None,
2053
  settings_table: Any | None = None,
 
 
2054
  ) -> tuple:
2055
  records = _apply_shape_settings(records or [], settings_table)
2056
  paths = [str(SAMPLE_STL_DIR / filename) for filename in SAMPLE_STL_FILENAMES if (SAMPLE_STL_DIR / filename).exists()]
2057
  merged_paths = _append_file_paths(files, paths)
2058
  return (
2059
  gr.update(value=merged_paths),
2060
+ *sync_uploaded_shapes(merged_paths, records, None),
2061
  )
2062
 
2063
 
 
 
 
 
 
 
 
 
2064
  def _shape_delete_outputs(
2065
  records: list[dict],
 
 
2066
  last_delete_at: float | None,
2067
  upload_update: Any | None = None,
2068
  ) -> tuple:
 
2070
  upload_update if upload_update is not None else gr.update(),
2071
  records,
2072
  _shape_settings_rows(records),
 
2073
  _dropdown_update(records),
2074
  _gcode_dropdown_update(records),
2075
  _gcode_dropdown_update(records, include_upload=True),
 
2081
  def delete_shape_from_settings(
2082
  records: list[dict] | None,
2083
  settings_table: Any | None,
 
 
2084
  last_delete_at: float | None,
2085
  evt: gr.SelectData,
2086
  ) -> tuple:
 
2089
  selected = getattr(evt, "index", None)
2090
  current_records = _apply_shape_settings(records or [], settings_table)
2091
  if not isinstance(selected, (list, tuple)) or len(selected) < 2:
2092
+ return _shape_delete_outputs(current_records, last_delete_at)
2093
 
2094
  try:
2095
  row_index, column_index = int(selected[0]), int(selected[1])
2096
  except (TypeError, ValueError):
2097
+ return _shape_delete_outputs(current_records, last_delete_at)
2098
  delete_column_index = len(SHAPE_SETTINGS_HEADERS) - 1
2099
  if column_index != delete_column_index or row_index < 0 or row_index >= len(rows):
2100
+ return _shape_delete_outputs(current_records, last_delete_at)
2101
  if last_delete_at and now - float(last_delete_at) < DELETE_SHAPE_COOLDOWN_SECONDS:
2102
+ return _shape_delete_outputs(current_records, last_delete_at)
2103
 
2104
  try:
2105
  delete_idx = int(float(rows[row_index][0]))
 
2112
  upload_paths = [record.get("stl_path") for record in next_records if record.get("stl_path")]
2113
  return _shape_delete_outputs(
2114
  next_records,
 
 
2115
  now,
2116
  gr.update(value=upload_paths),
2117
  )
 
2191
  return normalized, _shape_settings_rows(normalized)
2192
 
2193
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2194
  def show_selected_model(
2195
  records: list[dict] | None,
2196
  selected: str | None,
 
2257
  settings_table: Any,
2258
  layer_height: float,
2259
  scale_mode: str | None,
2260
+ fil_width: float = 0.8,
2261
  progress: gr.Progress = gr.Progress(),
2262
  ) -> tuple:
2263
  records = _apply_shape_settings(records or [], settings_table)
 
2286
  )
2287
  except Exception as exc:
2288
  messages.append(f"Shape {record['idx']}: failed ({exc}).")
2289
+ ref_layers = generate_dynamic_reference_stack(records, fil_width)
2290
  if ref_layers is not None:
2291
  messages.append("Reference layers: updated automatically.")
2292
  else:
 
2294
  return records, "\n".join(messages), ref_layers
2295
 
2296
 
2297
+ def generate_dynamic_reference_stack(
2298
+ records: list[dict] | None,
2299
+ fil_width: float = 0.8,
2300
+ ) -> LayerStack | None:
2301
+ # Snapping the alignment to the fil grid keeps split pieces' scan-grid
2302
+ # phase intact under shared reference motion (exact one-fil seam pitch).
2303
+ return build_reference_stack(
2304
+ [record.get("layer_stack") for record in (records or [])],
2305
+ grid=float(fil_width) if fil_width else None,
2306
+ )
2307
 
2308
 
2309
  def split_selected_shape_for_grid(
2310
  records: list[dict] | None,
2311
  selected: str | None,
2312
  settings_table: Any | None,
 
 
2313
  columns: float,
2314
  rows: float,
2315
  overlapping_layers: bool,
 
2323
  return (
2324
  next_records,
2325
  _shape_settings_rows(next_records),
 
2326
  _dropdown_update(next_records, selected_value),
2327
  [record.get("gcode_path") for record in next_records if record.get("gcode_path")],
2328
  _gcode_dropdown_update(next_records),
 
2355
  rows=split_row_count,
2356
  overlapping_layers=bool(overlapping_layers),
2357
  overlap=float(fil_width) if overlapping_layers else 0.0,
2358
+ # Whole-fil cells (last piece absorbs the remainder): keeps the
2359
+ # required nozzle spacing uniform under shared reference motion.
2360
+ grid=float(fil_width),
2361
  )
2362
  except Exception as exc:
2363
  return _outputs(records, selected, f"Split failed: {exc}")
 
2467
  ) -> tuple:
2468
  records = _apply_shape_settings(records or [], settings_table)
2469
  messages: list[str] = []
2470
+ resliced = _ensure_records_sliced(records, layer_height, scale_mode, messages)
2471
+ if use_reference_motion:
2472
+ # Always rebuild with the CURRENT fil width: the reference stack's
2473
+ # alignment snap grid must match the fil the G-code is generated with.
2474
+ ref_layers = generate_dynamic_reference_stack(records, fil_width)
2475
+ elif resliced:
2476
+ ref_layers = generate_dynamic_reference_stack(records, fil_width)
2477
  contour_sources = _contour_tracing_sources(records)
2478
  if contour_sources:
2479
  enabled = ", ".join(f"Shape {source.owner_idx}" for source in contour_sources)
 
2502
  raster_pattern=raster_pattern,
2503
  contour_sources=contour_sources,
2504
  active_contour_owner=int(record.get("idx", 0)),
2505
+ infill=_coerce_float(record.get("infill", 100.0), 100.0) / 100.0,
2506
  lead_in_enabled=bool(lead_in_enabled),
2507
  lead_in_length=float(lead_in_length),
2508
  lead_in_clearance=float(lead_in_clearance),
 
2558
  return parts, messages
2559
 
2560
 
 
 
 
 
 
 
 
 
 
 
 
 
2561
  def render_dynamic_nozzle_spacing(
2562
  records: list[dict] | None,
 
2563
  columns: Any,
2564
  rows: Any,
2565
  column_spacing: Any,
2566
  row_spacing: Any,
2567
  use_grid_individual_spacing: bool,
2568
  grid_spacing_table: Any,
 
 
2569
  ) -> tuple[Any, str]:
2570
  parts, _messages = _parts_from_records(records)
2571
  if not parts:
2572
  return None, "No shape G-code available. Generate G-code first."
2573
+ offsets, spacings = _resolve_nozzle_grid_layout(
2574
  parts,
 
2575
  columns,
2576
  rows,
2577
  column_spacing,
2578
  row_spacing,
2579
  use_grid_individual_spacing,
2580
  grid_spacing_table,
 
 
2581
  )
2582
  return build_nozzle_spacing_figure(parts, offsets, spacings), _format_nozzle_spacing_status(parts, offsets, spacings)
2583
 
 
2648
  travel_opacity: float,
2649
  filament_width: float,
2650
  travel_width: float,
 
2651
  columns: Any,
2652
  rows: Any,
2653
  column_spacing: Any,
2654
  row_spacing: Any,
2655
  use_grid_individual_spacing: bool,
2656
  grid_spacing_table: Any,
 
 
2657
  tube: bool = True,
2658
  ) -> tuple[Any, str]:
2659
  records = _apply_shape_settings(records or [], settings_table)
2660
  parts, messages = _parts_from_records(records)
2661
  if not parts:
2662
  return None, "No shape G-code available. Generate G-code on the Generate G-Code tab first."
2663
+ offsets, spacings = _resolve_nozzle_grid_layout(
2664
  parts,
 
2665
  columns,
2666
  rows,
2667
  column_spacing,
2668
  row_spacing,
2669
  use_grid_individual_spacing,
2670
  grid_spacing_table,
 
 
2671
  )
2672
  figure = build_parallel_figure(
2673
  parts,
 
2701
  records: list[dict] | None,
2702
  settings_table: Any,
2703
  travel_opacity: float,
 
2704
  columns: Any,
2705
  rows: Any,
2706
  column_spacing: Any,
2707
  row_spacing: Any,
2708
  use_grid_individual_spacing: bool,
2709
  grid_spacing_table: Any,
 
 
2710
  duration: float,
2711
  fps: float,
2712
  elev: float,
 
2717
  parts, _messages = _parts_from_records(records)
2718
  if not parts:
2719
  return None
2720
+ offsets, _spacings = _resolve_nozzle_grid_layout(
2721
  parts,
 
2722
  columns,
2723
  rows,
2724
  column_spacing,
2725
  row_spacing,
2726
  use_grid_individual_spacing,
2727
  grid_spacing_table,
 
 
2728
  )
2729
 
2730
  def report(frame: int, total: int) -> None:
 
2857
  gcode_text = gr.Code(label="Selected G-Code", language=None, lines=18, max_lines=18, interactive=False, elem_classes=["gcode-view"])
2858
 
2859
  with gr.Accordion("Nozzle Spacing", open=False, elem_classes=["settings-accordion"]):
2860
+ with gr.Group():
 
 
 
 
 
2861
  with gr.Row():
2862
  nozzle_grid_preset = gr.Dropdown(
2863
  label="Common Layout",
 
2871
  nozzle_grid_row_spacing = gr.Number(label="Row Gap (Y, mm)", value=0.0, step=0.1)
2872
  with gr.Row():
2873
  auto_align_split_parts_button = gr.Button("Auto Align Split Parts", variant="secondary", size="sm")
2874
+ nozzle_grid_use_individual_spacing = gr.Checkbox(label="Advanced Grid Spacing", value=False)
2875
+ # Always visible: the "Advanced Grid Spacing" checkbox
2876
+ # controls whether these per-connection gaps are USED,
2877
+ # not whether the table shows. Toggling visibility from
2878
+ # events proved racy (the table wouldn't appear on the
2879
+ # first Auto Align click).
2880
  nozzle_grid_spacing_table = gr.Dataframe(
2881
  headers=ADVANCED_NOZZLE_SPACING_HEADERS,
2882
  value=[],
2883
  row_count=(0, "fixed"),
2884
  column_count=(len(ADVANCED_NOZZLE_SPACING_HEADERS), "fixed"),
2885
  interactive=True,
2886
+ label="Advanced Grid Spacing (used when the checkbox is on)",
 
2887
  elem_id="nozzle-grid-spacing-table",
2888
  )
 
 
 
 
 
 
 
 
 
 
 
2889
  nozzle_preview_button = gr.Button("Visualize Nozzle Spacing", variant="secondary", elem_id="visualize-nozzle-spacing-button")
2890
  with gr.Row():
2891
  with gr.Column(scale=3, min_width=420):
 
2979
  nozzle_grid_use_individual_spacing,
2980
  ]
2981
 
2982
+ shape_sync_outputs = [shape_records, shape_settings, selected_shape, gcode_text_source, gcode_source, gcode_downloads]
2983
+ stl_upload.change(fn=sync_uploaded_shapes, inputs=[stl_upload, shape_records, shape_settings], outputs=shape_sync_outputs).then(
2984
  fn=lambda records: _dropdown_update(records),
2985
  inputs=[shape_records],
2986
  outputs=[split_source],
 
2991
  outputs=[nozzle_grid_spacing_table],
2992
  queue=False,
2993
  )
2994
+ sync_uploads_button.click(fn=sync_uploaded_shapes, inputs=[stl_upload, shape_records, shape_settings], outputs=shape_sync_outputs).then(
2995
  fn=lambda records: _dropdown_update(records),
2996
  inputs=[shape_records],
2997
  outputs=[split_source],
 
3002
  outputs=[nozzle_grid_spacing_table],
3003
  queue=False,
3004
  )
3005
+ load_samples_button.click(fn=load_sample_shapes, inputs=[stl_upload, shape_records, shape_settings], outputs=[stl_upload, *shape_sync_outputs]).then(
3006
  fn=lambda records: _dropdown_update(records),
3007
  inputs=[shape_records],
3008
  outputs=[split_source],
 
3015
  )
3016
  shape_settings.select(
3017
  fn=delete_shape_from_settings,
3018
+ inputs=[shape_records, shape_settings, last_shape_delete_at],
3019
  outputs=[stl_upload, *shape_sync_outputs, last_shape_delete_at],
3020
  ).then(
3021
  fn=lambda records: _dropdown_update(records),
 
3031
 
3032
  preview_inputs = [shape_records, selected_shape, shape_settings, model_opacity, scale_mode]
3033
  shape_settings.change(
3034
+ fn=normalize_shape_dimensions_for_mode,
3035
+ inputs=[shape_records, shape_settings, scale_mode],
3036
+ outputs=[shape_records, shape_settings],
3037
  queue=False,
3038
  ).then(
3039
  fn=_grid_spacing_table_update,
 
3066
 
3067
  generate_button.click(
3068
  fn=generate_dynamic_layer_stacks,
3069
+ inputs=[shape_records, shape_settings, layer_height, scale_mode, fil_width],
3070
  outputs=[shape_records, slicer_status, ref_layers],
3071
  ).then(
3072
  fn=lambda records: _dropdown_update(records),
 
3082
  shape_records,
3083
  split_source,
3084
  shape_settings,
 
 
3085
  split_columns,
3086
  split_rows,
3087
  split_overlapping_layers,
 
3092
  outputs=[
3093
  shape_records,
3094
  shape_settings,
 
3095
  selected_shape,
3096
  gcode_downloads,
3097
  gcode_text_source,
 
3101
  ],
3102
  ).then(
3103
  fn=generate_dynamic_reference_stack,
3104
+ inputs=[shape_records, fil_width],
3105
  outputs=[ref_layers],
3106
  ).then(
3107
  fn=_grid_spacing_table_update,
 
3142
  )
3143
  gcode_text_source.change(fn=load_selected_gcode_text, inputs=[shape_records, gcode_text_source], outputs=[gcode_text])
3144
  refresh_gcode_text_button.click(fn=load_selected_gcode_text, inputs=[shape_records, gcode_text_source], outputs=[gcode_text])
 
 
 
 
 
 
3145
  auto_align_split_parts_button.click(
3146
+ fn=auto_align_split_parts,
3147
  inputs=[
3148
  shape_records,
3149
  nozzle_grid_columns,
3150
  nozzle_grid_rows,
3151
  nozzle_grid_column_spacing,
3152
  nozzle_grid_row_spacing,
 
3153
  ],
3154
  outputs=[
3155
+ nozzle_grid_columns,
3156
+ nozzle_grid_rows,
 
3157
  nozzle_grid_column_spacing,
3158
  nozzle_grid_row_spacing,
3159
  nozzle_grid_use_individual_spacing,
 
3180
  nozzle_grid_row_spacing,
3181
  nozzle_grid_use_individual_spacing,
3182
  ):
3183
+ # .input (user edits only), NOT .change: Auto Align sets these
3184
+ # controls programmatically and .change listeners would fire and
3185
+ # rebuild the spacing table, clobbering the aligned gaps it just
3186
+ # wrote. Flows that set them programmatically (preset dropdown,
3187
+ # Auto Align) update the table explicitly themselves.
3188
+ grid_spacing_control.input(
3189
  fn=_grid_spacing_table_update,
3190
  inputs=grid_spacing_refresh_inputs,
3191
  outputs=[nozzle_grid_spacing_table],
3192
  queue=False,
3193
  )
 
3194
  nozzle_preview_button.click(
3195
  fn=render_dynamic_nozzle_spacing,
3196
  inputs=[
3197
  shape_records,
 
3198
  nozzle_grid_columns,
3199
  nozzle_grid_rows,
3200
  nozzle_grid_column_spacing,
3201
  nozzle_grid_row_spacing,
3202
  nozzle_grid_use_individual_spacing,
3203
  nozzle_grid_spacing_table,
 
 
3204
  ],
3205
  outputs=[nozzle_spacing_plot, nozzle_spacing_status],
3206
  )
 
3247
  pp_travel_opacity,
3248
  pp_filament_width,
3249
  pp_travel_width,
 
3250
  nozzle_grid_columns,
3251
  nozzle_grid_rows,
3252
  nozzle_grid_column_spacing,
3253
  nozzle_grid_row_spacing,
3254
  nozzle_grid_use_individual_spacing,
3255
  nozzle_grid_spacing_table,
 
 
3256
  ]
3257
  parallel_outputs = [parallel_plot, parallel_status, parallel_mode, parallel_anim_controls, pp_width_row, pp_export_group]
3258
  parallel_line_button.click(fn=render_dynamic_parallel_lines, inputs=parallel_render_inputs, outputs=parallel_outputs)
 
3265
  shape_records,
3266
  shape_settings,
3267
  pp_gif_travel_opacity,
 
3268
  nozzle_grid_columns,
3269
  nozzle_grid_rows,
3270
  nozzle_grid_column_spacing,
3271
  nozzle_grid_row_spacing,
3272
  nozzle_grid_use_individual_spacing,
3273
  nozzle_grid_spacing_table,
 
 
3274
  pp_gif_duration,
3275
  pp_gif_fps,
3276
  pp_elev,
gcode_viewer.py CHANGED
@@ -21,6 +21,12 @@ _AXIS_RE = re.compile(r"([XYZxyz])\s*([-+]?(?:\d*\.\d+|\d+\.?)(?:e[-+]?\d+)?)")
21
  # Pneumatic valve toggle: WAGO_ValveCommands(<valve>, <0=close|1=open>). Some
22
  # generators emit every move as G1 and convey extrusion only through the valve.
23
  _VALVE_RE = re.compile(r"WAGO_ValveCommands\(\s*(\d+)\s*,\s*(\d+)\s*\)", re.IGNORECASE)
 
 
 
 
 
 
24
 
25
 
26
  def parse_gcode_path(gcode_text: str) -> dict:
@@ -143,6 +149,13 @@ def parse_gcode_path(gcode_text: str) -> dict:
143
  next_print_layer = z_to_layer[round(move["end"][2], 6)]
144
  move["layer"] = next_print_layer
145
 
 
 
 
 
 
 
 
146
  return {
147
  "print_segments": print_segments,
148
  "travel_segments": travel_segments,
@@ -150,6 +163,7 @@ def parse_gcode_path(gcode_text: str) -> dict:
150
  "layer_count": len(print_z),
151
  "bounds": bounds,
152
  "point_count": len(all_x),
 
153
  }
154
 
155
 
 
21
  # Pneumatic valve toggle: WAGO_ValveCommands(<valve>, <0=close|1=open>). Some
22
  # generators emit every move as G1 and convey extrusion only through the valve.
23
  _VALVE_RE = re.compile(r"WAGO_ValveCommands\(\s*(\d+)\s*,\s*(\d+)\s*\)", re.IGNORECASE)
24
+ # World anchor written by this app's generator: the absolute position, in the
25
+ # shape's own coordinate frame, that the relative toolpath starts from.
26
+ _PATH_ORIGIN_RE = re.compile(
27
+ r"^;\s*PathOrigin\s+X([-+]?[\d.]+(?:e[-+]?\d+)?)\s+Y([-+]?[\d.]+(?:e[-+]?\d+)?)",
28
+ re.MULTILINE,
29
+ )
30
 
31
 
32
  def parse_gcode_path(gcode_text: str) -> dict:
 
149
  next_print_layer = z_to_layer[round(move["end"][2], 6)]
150
  move["layer"] = next_print_layer
151
 
152
+ origin_match = _PATH_ORIGIN_RE.search(gcode_text)
153
+ path_origin = (
154
+ (float(origin_match.group(1)), float(origin_match.group(2)))
155
+ if origin_match
156
+ else None
157
+ )
158
+
159
  return {
160
  "print_segments": print_segments,
161
  "travel_segments": travel_segments,
 
163
  "layer_count": len(print_z),
164
  "bounds": bounds,
165
  "point_count": len(all_x),
166
+ "path_origin": path_origin,
167
  }
168
 
169
 
stl_slicer.py CHANGED
@@ -25,6 +25,15 @@ class LayerStack:
25
  `bounds` is the scaled mesh's axis-aligned bounding box. For grid-split
26
  pieces it is the piece's nominal cell box, which keeps reference-stack
27
  centring stable even when the clipped geometry shrinks.
 
 
 
 
 
 
 
 
 
28
  """
29
 
30
  layers: list[MultiPolygon]
@@ -32,6 +41,9 @@ class LayerStack:
32
  bounds: Bounds3D
33
  layer_height: float
34
  name: str = ""
 
 
 
35
 
36
 
37
  def load_mesh(stl_path: str | Path) -> trimesh.Trimesh:
 
25
  `bounds` is the scaled mesh's axis-aligned bounding box. For grid-split
26
  pieces it is the piece's nominal cell box, which keeps reference-stack
27
  centring stable even when the clipped geometry shrinks.
28
+
29
+ `scan_frame` is the XY box the raster scan grid is anchored to. Grid-split
30
+ pieces inherit the parent shape's frame so all pieces raster on one
31
+ continuous line grid and seams keep an exact one-fil-width pitch.
32
+
33
+ `align_center`/`align_grid` are stamped by `build_reference_stack` on the
34
+ combined reference stack: the common centre shapes were aligned to, and
35
+ the grid the alignment deltas were snapped to — so later alignment of a
36
+ shape against the reference reproduces exactly the same translation.
37
  """
38
 
39
  layers: list[MultiPolygon]
 
41
  bounds: Bounds3D
42
  layer_height: float
43
  name: str = ""
44
+ scan_frame: tuple[float, float, float, float] | None = None
45
+ align_center: tuple[float, float] | None = None
46
+ align_grid: float | None = None
47
 
48
 
49
  def load_mesh(stl_path: str | Path) -> trimesh.Trimesh:
tests/test_nozzle_spacing.py CHANGED
@@ -6,23 +6,21 @@ from app import (
6
  ADVANCED_NOZZLE_SPACING_HEADERS,
7
  SCALE_MODE_UNIFORM_FACTOR,
8
  SHAPE_SETTINGS_HEADERS,
9
- SIMPLE_NOZZLE_SPACING_HEADERS,
10
- _auto_align_split_offsets,
11
  _auto_align_grid_spacing_rows,
12
  _apply_shape_settings,
13
  _contour_tracing_sources,
14
  delete_shape_from_settings,
15
  _format_nozzle_spacing_status,
16
  _grid_spacing_rows,
 
17
  _records_from_files,
18
  _shape_settings_rows,
19
- _spacing_args_from_table,
20
- _spacing_table_update,
21
  normalize_shape_dimensions_for_mode,
22
  _resolve_nozzle_grid_layout,
23
- _resolve_nozzle_layout,
24
  )
25
  from vector_toolpath import (
 
 
26
  RASTER_PATTERN_SAME_DIRECTION,
27
  RASTER_PATTERN_Y_DIRECTION,
28
  )
@@ -41,145 +39,17 @@ def _part(
41
  }
42
 
43
 
44
- def test_calculated_nozzle_layout_uses_requested_part_gap() -> None:
45
- parts = [
46
- _part(1, ((0.0, 0.0, 0.0), (10.0, 20.0, 1.0))),
47
- _part(2, ((0.0, 0.0, 0.0), (5.0, 6.0, 1.0))),
48
- _part(3, ((-2.0, -1.0, 0.0), (2.0, 3.0, 1.0))),
49
- ]
50
-
51
- offsets, spacings = _resolve_nozzle_layout(
52
- parts,
53
- same_spacing=False,
54
- part_gap_12_x=2.5,
55
- part_gap_12_y=-1.0,
56
- part_gap_23_x=8.0,
57
- part_gap_23_y=4.0,
58
- )
59
-
60
- np.testing.assert_allclose(offsets[1], (0.0, 0.0))
61
- np.testing.assert_allclose(offsets[2], (12.5, -1.0))
62
- np.testing.assert_allclose(offsets[3], (27.5, 3.0))
63
- assert spacings[0] == {"from": 1, "to": 2, "dx": 12.5, "dy": -1.0}
64
- assert spacings[1] == {"from": 2, "to": 3, "dx": 15.0, "dy": 4.0}
65
-
66
-
67
- def test_calculated_nozzle_layout_allows_negative_x_overlap() -> None:
68
- parts = [
69
- _part(1, ((0.0, 0.0, 0.0), (10.0, 10.0, 1.0))),
70
- _part(2, ((0.0, 0.0, 0.0), (10.0, 10.0, 1.0))),
71
- ]
72
-
73
- offsets, spacings = _resolve_nozzle_layout(
74
- parts,
75
- same_spacing=True,
76
- part_gap_12_x=-2.0,
77
- part_gap_12_y=3.0,
78
- part_gap_23_x=0.0,
79
- part_gap_23_y=0.0,
80
- )
81
-
82
- np.testing.assert_allclose(offsets[1], (0.0, 0.0))
83
- np.testing.assert_allclose(offsets[2], (8.0, 3.0))
84
- assert spacings == [{"from": 1, "to": 2, "dx": 8.0, "dy": 3.0}]
85
-
86
-
87
- def test_nozzle_layout_groups_shapes_that_share_a_nozzle() -> None:
88
- parts = [
89
- _part(1, ((0.0, 0.0, 0.0), (10.0, 10.0, 1.0)), nozzle=1),
90
- _part(2, ((-1.0, -2.0, 0.0), (12.0, 4.0, 1.0)), nozzle=1),
91
- _part(3, ((0.0, 0.0, 0.0), (6.0, 5.0, 1.0)), nozzle=2),
92
- ]
93
-
94
- offsets, spacings = _resolve_nozzle_layout(
95
- parts,
96
- same_spacing=True,
97
- part_gap_12_x=5.0,
98
- part_gap_12_y=1.5,
99
- part_gap_23_x=0.0,
100
- part_gap_23_y=0.0,
101
- )
102
-
103
- assert sorted(offsets) == [1, 2]
104
- np.testing.assert_allclose(offsets[1], (0.0, 0.0))
105
- np.testing.assert_allclose(offsets[2], (17.0, 1.5))
106
- assert spacings == [{"from": 1, "to": 2, "dx": 17.0, "dy": 1.5}]
107
-
108
-
109
- def test_same_spacing_reuses_first_pair_values_for_second_pair() -> None:
110
- parts = [
111
- _part(1, ((0.0, 0.0, 0.0), (10.0, 10.0, 1.0))),
112
- _part(2, ((0.0, 0.0, 0.0), (4.0, 10.0, 1.0))),
113
- _part(3, ((0.0, 0.0, 0.0), (6.0, 10.0, 1.0))),
114
- ]
115
-
116
- offsets, spacings = _resolve_nozzle_layout(
117
- parts,
118
- same_spacing=True,
119
- part_gap_12_x=3.0,
120
- part_gap_12_y=2.0,
121
- part_gap_23_x=20.0,
122
- part_gap_23_y=10.0,
123
- )
124
-
125
- np.testing.assert_allclose(offsets[1], (0.0, 0.0))
126
- np.testing.assert_allclose(offsets[2], (13.0, 2.0))
127
- np.testing.assert_allclose(offsets[3], (20.0, 4.0))
128
- assert spacings[0] == {"from": 1, "to": 2, "dx": 13.0, "dy": 2.0}
129
- assert spacings[1] == {"from": 2, "to": 3, "dx": 7.0, "dy": 2.0}
130
-
131
-
132
- def test_individual_spacing_has_no_fixed_shape_limit() -> None:
133
- parts = [
134
- _part(1, ((0.0, 0.0, 0.0), (10.0, 10.0, 1.0))),
135
- _part(2, ((0.0, 0.0, 0.0), (4.0, 10.0, 1.0))),
136
- _part(3, ((0.0, 0.0, 0.0), (6.0, 10.0, 1.0))),
137
- _part(4, ((0.0, 0.0, 0.0), (2.0, 10.0, 1.0))),
138
- _part(5, ((0.0, 0.0, 0.0), (3.0, 10.0, 1.0))),
139
- _part(6, ((0.0, 0.0, 0.0), (4.0, 10.0, 1.0))),
140
- _part(7, ((0.0, 0.0, 0.0), (5.0, 10.0, 1.0))),
141
- ]
142
-
143
- offsets, spacings = _resolve_nozzle_layout(
144
- parts,
145
- False,
146
- 1.0,
147
- 0.0,
148
- 2.0,
149
- 3.0,
150
- 4.0,
151
- -1.0,
152
- 6.0,
153
- 2.0,
154
- 1.0,
155
- 1.0,
156
- -3.0,
157
- 0.5,
158
- )
159
-
160
- np.testing.assert_allclose(offsets[1], (0.0, 0.0))
161
- np.testing.assert_allclose(offsets[2], (11.0, 0.0))
162
- np.testing.assert_allclose(offsets[3], (17.0, 3.0))
163
- np.testing.assert_allclose(offsets[4], (27.0, 2.0))
164
- np.testing.assert_allclose(offsets[7], (40.0, 5.5))
165
- assert spacings[2] == {"from": 3, "to": 4, "dx": 10.0, "dy": -1.0}
166
- assert spacings[5] == {"from": 6, "to": 7, "dx": 1.0, "dy": 0.5}
167
-
168
-
169
  def test_spacing_status_lists_all_nozzle_pair_distances() -> None:
170
  parts = [
171
  _part(1, ((0.0, 0.0, 0.0), (10.0, 10.0, 1.0))),
172
  _part(2, ((0.0, 0.0, 0.0), (4.0, 10.0, 1.0))),
173
  _part(3, ((0.0, 0.0, 0.0), (6.0, 10.0, 1.0))),
174
  ]
175
- offsets, spacings = _resolve_nozzle_layout(
176
- parts,
177
- same_spacing=True,
178
- part_gap_12_x=3.0,
179
- part_gap_12_y=2.0,
180
- part_gap_23_x=20.0,
181
- part_gap_23_y=10.0,
182
- )
183
 
184
  status = _format_nozzle_spacing_status(parts, offsets, spacings)
185
 
@@ -255,6 +125,39 @@ def test_shape_settings_round_trip_contour_tracing_column() -> None:
255
  assert updated[0]["contour_tracing"] is True
256
 
257
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
258
  def test_contour_tracing_sources_use_sliced_layer_stacks() -> None:
259
  from shapely.geometry import MultiPolygon, box
260
 
@@ -300,105 +203,234 @@ def test_sample_reload_appends_next_nozzle_set() -> None:
300
  assert [record["nozzle"] for record in second_load] == [1, 2, 3, 4, 5, 6]
301
 
302
 
303
- def test_simple_spacing_table_uses_one_shared_spacing_row() -> None:
304
- records = [
305
- {"idx": 1, "name": "first"},
306
- {"idx": 2, "name": "second"},
307
- {"idx": 3, "name": "third"},
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
308
  ]
 
 
 
309
 
310
- update = _spacing_table_update(records, [["Shape 1", "Shape 2", 7.0, -1.5]], False)
311
- gap12x, gap12y, gap23x, gap23y, extra = _spacing_args_from_table(update["value"], False)
312
 
313
- assert update["headers"] == SIMPLE_NOZZLE_SPACING_HEADERS
314
- assert update["value"] == [["Same spacing", "All neighboring nozzles", 7.0, -1.5]]
315
- assert (gap12x, gap12y, gap23x, gap23y, extra) == (7.0, -1.5, 7.0, -1.5, [])
316
 
 
 
 
317
 
318
- def test_advanced_spacing_table_uses_named_nozzle_pairs() -> None:
319
- records = [
320
- {"idx": 1, "name": "first"},
321
- {"idx": 2, "name": "second"},
322
- {"idx": 3, "name": "third"},
323
- ]
324
 
325
- update = _spacing_table_update(
326
- records,
327
- [["Same spacing", "All neighboring shapes", 4.0, 0.5], ["ignored", "ignored", 8.0, 1.0]],
328
- True,
 
 
 
 
329
  )
330
- gap12x, gap12y, gap23x, gap23y, extra = _spacing_args_from_table(update["value"], True)
331
 
332
- assert update["headers"] == ADVANCED_NOZZLE_SPACING_HEADERS
333
- assert update["value"][0][:2] == ["Nozzle 1: Shape 1", "Nozzle 2: Shape 2"]
334
- assert update["value"][1][:2] == ["Nozzle 2: Shape 2", "Nozzle 3: Shape 3"]
335
- assert (gap12x, gap12y, gap23x, gap23y, extra) == (4.0, 0.5, 8.0, 1.0, [])
336
 
 
 
 
337
 
338
- def test_advanced_spacing_table_collapses_duplicate_nozzles() -> None:
339
- records = [
340
- {"idx": 1, "name": "first", "nozzle": 1},
341
- {"idx": 2, "name": "second", "nozzle": 1},
342
- {"idx": 3, "name": "third", "nozzle": 2},
343
- ]
344
 
345
- update = _spacing_table_update(records, [["ignored", "ignored", 9.0, 2.5]], True)
 
 
 
 
 
 
346
 
347
- assert update["headers"] == ADVANCED_NOZZLE_SPACING_HEADERS
348
- assert update["value"] == [["Nozzle 1: Shape 1, Shape 2", "Nozzle 2: Shape 3", 9.0, 2.5]]
 
349
 
 
 
 
350
 
351
- def test_auto_align_split_offsets_follow_raster_axis() -> None:
352
- assert _auto_align_split_offsets(RASTER_PATTERN_SAME_DIRECTION) == (-3.2, -0.8)
353
- assert _auto_align_split_offsets(RASTER_PATTERN_Y_DIRECTION) == (-0.8, -3.2)
354
 
 
 
355
 
356
- def test_auto_align_grid_spacing_only_targets_split_siblings() -> None:
357
- records = [
358
- {"idx": 1, "name": "first", "nozzle": 1, "split_group_id": "split-a", "split_index": 0},
359
- {"idx": 2, "name": "second", "nozzle": 2, "split_group_id": "split-a", "split_index": 1},
360
- {"idx": 3, "name": "third", "nozzle": 3},
361
- {"idx": 4, "name": "fourth", "nozzle": 4, "split_group_id": "split-a", "split_index": 2},
362
- ]
363
 
364
- rows, column_count, row_count, aligned_count = _auto_align_grid_spacing_rows(
365
- records,
366
- columns=2,
367
- rows=2,
368
- column_spacing=10.0,
369
- row_spacing=3.0,
370
- raster_pattern=RASTER_PATTERN_SAME_DIRECTION,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
371
  )
372
 
373
- assert column_count == 2
374
- assert row_count == 2
375
- assert aligned_count == 1
376
- assert rows == [
377
- ["Nozzle 1: Shape 1", "Nozzle 2: Shape 2", -3.2, 0.0],
378
- ["Nozzle 2: Shape 2", "Nozzle 3: Shape 3", 0.0, 3.0],
379
- ["Nozzle 3: Shape 3", "Nozzle 4: Shape 4", 10.0, 0.0],
380
- ]
 
 
 
 
 
 
 
 
 
 
 
381
 
 
 
 
382
 
383
- def test_auto_align_grid_spacing_handles_split_row_transition() -> None:
 
384
  records = [
385
  {"idx": 1, "name": "first", "nozzle": 1, "split_group_id": "split-a", "split_index": 0},
386
  {"idx": 2, "name": "second", "nozzle": 2, "split_group_id": "split-a", "split_index": 1},
387
- {"idx": 3, "name": "third", "nozzle": 3, "split_group_id": "split-a", "split_index": 2},
388
  ]
389
 
390
- rows, _column_count, _row_count, aligned_count = _auto_align_grid_spacing_rows(
391
- records,
392
- columns=2,
393
- rows=2,
394
- column_spacing=10.0,
395
- row_spacing=3.0,
396
- raster_pattern=RASTER_PATTERN_SAME_DIRECTION,
397
  )
398
 
399
- assert aligned_count == 2
400
- assert rows[0] == ["Nozzle 1: Shape 1", "Nozzle 2: Shape 2", -3.2, 0.0]
401
- assert rows[1] == ["Nozzle 2: Shape 2", "Nozzle 3: Shape 3", 0.0, -0.8]
402
 
403
 
404
  def test_auto_align_grid_spacing_skips_unsplit_records() -> None:
@@ -407,16 +439,12 @@ def test_auto_align_grid_spacing_skips_unsplit_records() -> None:
407
  {"idx": 2, "name": "second", "nozzle": 2},
408
  ]
409
 
410
- rows, _column_count, _row_count, aligned_count = _auto_align_grid_spacing_rows(
411
- records,
412
- columns=2,
413
- rows=1,
414
- column_spacing=10.0,
415
- row_spacing=3.0,
416
- raster_pattern=RASTER_PATTERN_SAME_DIRECTION,
417
  )
418
 
419
  assert aligned_count == 0
 
420
  assert rows == [["Nozzle 1: Shape 1", "Nozzle 2: Shape 2", 10.0, 0.0]]
421
 
422
 
@@ -530,7 +558,7 @@ def test_delete_shape_reindexes_without_losing_shape_data() -> None:
530
  {"idx": 3, "name": "last", "stl_path": "last.stl", "target_x": 30.0, "target_y": 31.0, "target_z": 32.0, "pressure": 35, "valve": 6, "port": 3, "color": "#333333"},
531
  ]
532
 
533
- outputs = delete_shape_from_settings(records, _shape_settings_rows(records), None, False, 0.0, Event())
534
  updated_records = outputs[1]
535
  updated_settings = outputs[2]
536
 
@@ -552,12 +580,10 @@ def test_delete_shape_cooldown_blocks_immediate_second_delete() -> None:
552
  {"idx": 3, "name": "last", "stl_path": "last.stl", "target_x": 30.0, "target_y": 31.0, "target_z": 32.0, "pressure": 35, "valve": 6, "port": 3, "color": "#333333"},
553
  ]
554
 
555
- first_outputs = delete_shape_from_settings(records, _shape_settings_rows(records), None, False, 0.0, Event())
556
  second_outputs = delete_shape_from_settings(
557
  first_outputs[1],
558
  first_outputs[2],
559
- None,
560
- False,
561
  first_outputs[-1],
562
  Event(),
563
  )
 
6
  ADVANCED_NOZZLE_SPACING_HEADERS,
7
  SCALE_MODE_UNIFORM_FACTOR,
8
  SHAPE_SETTINGS_HEADERS,
 
 
9
  _auto_align_grid_spacing_rows,
10
  _apply_shape_settings,
11
  _contour_tracing_sources,
12
  delete_shape_from_settings,
13
  _format_nozzle_spacing_status,
14
  _grid_spacing_rows,
15
+ _parts_from_records,
16
  _records_from_files,
17
  _shape_settings_rows,
 
 
18
  normalize_shape_dimensions_for_mode,
19
  _resolve_nozzle_grid_layout,
 
20
  )
21
  from vector_toolpath import (
22
+ RASTER_PATTERN_CIRCLE_SPIRAL,
23
+ RASTER_PATTERN_RECTANGULAR_SPIRAL,
24
  RASTER_PATTERN_SAME_DIRECTION,
25
  RASTER_PATTERN_Y_DIRECTION,
26
  )
 
39
  }
40
 
41
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
42
  def test_spacing_status_lists_all_nozzle_pair_distances() -> None:
43
  parts = [
44
  _part(1, ((0.0, 0.0, 0.0), (10.0, 10.0, 1.0))),
45
  _part(2, ((0.0, 0.0, 0.0), (4.0, 10.0, 1.0))),
46
  _part(3, ((0.0, 0.0, 0.0), (6.0, 10.0, 1.0))),
47
  ]
48
+ offsets = {1: (0.0, 0.0), 2: (13.0, 2.0), 3: (20.0, 4.0)}
49
+ spacings = [
50
+ {"from": 1, "to": 2, "dx": 13.0, "dy": 2.0},
51
+ {"from": 2, "to": 3, "dx": 7.0, "dy": 2.0},
52
+ ]
 
 
 
53
 
54
  status = _format_nozzle_spacing_status(parts, offsets, spacings)
55
 
 
125
  assert updated[0]["contour_tracing"] is True
126
 
127
 
128
+ def test_shape_settings_round_trip_infill_column() -> None:
129
+ records = [
130
+ {
131
+ "idx": 1,
132
+ "name": "circle",
133
+ "stl_path": "circle.stl",
134
+ "target_x": 10.0,
135
+ "target_y": 11.0,
136
+ "target_z": 12.0,
137
+ "pressure": 25.0,
138
+ "valve": 4,
139
+ "nozzle": 1,
140
+ "port": 1,
141
+ "color": "#111111",
142
+ "contour_tracing": False,
143
+ }
144
+ ]
145
+
146
+ rows = _shape_settings_rows(records)
147
+ infill_pos = SHAPE_SETTINGS_HEADERS.index("Infill %")
148
+ assert rows[0][infill_pos] == 100.0
149
+
150
+ rows[0][infill_pos] = 50
151
+ updated = _apply_shape_settings(records, rows)
152
+ assert updated[0]["infill"] == 50.0
153
+
154
+ # Out-of-range values clamp to 0..100.
155
+ rows[0][infill_pos] = 250
156
+ assert _apply_shape_settings(records, rows)[0]["infill"] == 100.0
157
+ rows[0][infill_pos] = -10
158
+ assert _apply_shape_settings(records, rows)[0]["infill"] == 0.0
159
+
160
+
161
  def test_contour_tracing_sources_use_sliced_layer_stacks() -> None:
162
  from shapely.geometry import MultiPolygon, box
163
 
 
203
  assert [record["nozzle"] for record in second_load] == [1, 2, 3, 4, 5, 6]
204
 
205
 
206
+ def _split_piece_records(
207
+ tmp_path,
208
+ columns: int = 2,
209
+ rows: int = 2,
210
+ use_reference_motion: bool = False,
211
+ overlapping_layers: bool = False,
212
+ raster_pattern: str | None = None,
213
+ ) -> list[dict]:
214
+ """Split a rectangle stack into grid pieces and generate real G-code."""
215
+ from shapely.geometry import MultiPolygon, box
216
+
217
+ from stl_slicer import LayerStack
218
+ from vector_gcode import generate_vector_gcode
219
+ from vector_toolpath import build_reference_stack, split_layer_stack_grid
220
+
221
+ layer = MultiPolygon([box(0.0, 0.0, 8.0, 6.0)])
222
+ stack = LayerStack(
223
+ layers=[layer, layer],
224
+ z_values=[0.5, 1.5],
225
+ bounds=((0.0, 0.0, 0.0), (8.0, 6.0, 2.0)),
226
+ layer_height=1.0,
227
+ name="source",
228
+ )
229
+ pieces = split_layer_stack_grid(
230
+ stack,
231
+ columns=columns,
232
+ rows=rows,
233
+ overlapping_layers=overlapping_layers,
234
+ overlap=1.0 if overlapping_layers else 0.0,
235
+ )
236
+ reference = build_reference_stack(pieces) if use_reference_motion else None
237
+
238
+ records: list[dict] = []
239
+ for index, piece in enumerate(pieces):
240
+ gcode_path = generate_vector_gcode(
241
+ piece,
242
+ shape_name=f"piece_{index}",
243
+ pressure=25,
244
+ valve=4 + index,
245
+ port=1,
246
+ fil_width=1.0,
247
+ motion=reference,
248
+ raster_pattern=raster_pattern,
249
+ output_dir=tmp_path / f"piece_{index}",
250
+ )
251
+ records.append(
252
+ {
253
+ "idx": index + 1,
254
+ "name": piece.name,
255
+ "nozzle": index + 1,
256
+ "color": "#000000",
257
+ "gcode_path": str(gcode_path),
258
+ "split_group_id": "split-a",
259
+ "split_index": index,
260
+ "split_row": index // columns + 1,
261
+ "split_col": index % columns + 1,
262
+ "split_rows": rows,
263
+ "split_columns": columns,
264
+ }
265
+ )
266
+ return records
267
+
268
+
269
+ def _assert_aligned_offsets_reassemble(records: list[dict], spacing_rows, columns, rows) -> None:
270
+ """Offsets from the aligned table must equal each piece's world anchor + const."""
271
+ parts, _messages = _parts_from_records(records)
272
+ offsets, _spacings = _resolve_nozzle_grid_layout(
273
+ parts,
274
+ columns,
275
+ rows,
276
+ column_spacing=5.0,
277
+ row_spacing=5.0,
278
+ use_individual_spacing=True,
279
+ spacing_table=spacing_rows,
280
+ )
281
+ anchors = {part["nozzle"]: part["parsed"]["path_origin"] for part in parts}
282
+ constants = [
283
+ (offsets[nozzle][0] - anchors[nozzle][0], offsets[nozzle][1] - anchors[nozzle][1])
284
+ for nozzle in sorted(offsets)
285
  ]
286
+ for constant in constants[1:]:
287
+ assert abs(constant[0] - constants[0][0]) < 5e-4
288
+ assert abs(constant[1] - constants[0][1]) < 5e-4
289
 
 
 
290
 
291
+ def test_auto_align_reassembles_grid_split_pieces(tmp_path) -> None:
292
+ records = _split_piece_records(tmp_path, columns=2, rows=2)
 
293
 
294
+ spacing_rows, column_count, row_count, aligned_count, missing_count = (
295
+ _auto_align_grid_spacing_rows(records, 2, 2, 5.0, 5.0)
296
+ )
297
 
298
+ assert (column_count, row_count) == (2, 2)
299
+ assert aligned_count == 3
300
+ assert missing_count == 0
301
+ _assert_aligned_offsets_reassemble(records, spacing_rows, 2, 2)
 
 
302
 
303
+
304
+ def test_auto_align_handles_reference_motion_and_overlap(tmp_path) -> None:
305
+ records = _split_piece_records(
306
+ tmp_path,
307
+ columns=2,
308
+ rows=2,
309
+ use_reference_motion=True,
310
+ overlapping_layers=True,
311
  )
 
312
 
313
+ spacing_rows, _cc, _rc, aligned_count, missing_count = _auto_align_grid_spacing_rows(
314
+ records, 2, 2, 5.0, 5.0
315
+ )
 
316
 
317
+ assert aligned_count == 3
318
+ assert missing_count == 0
319
+ _assert_aligned_offsets_reassemble(records, spacing_rows, 2, 2)
320
 
 
 
 
 
 
 
321
 
322
+ def test_auto_align_handles_y_raster_pattern(tmp_path) -> None:
323
+ records = _split_piece_records(
324
+ tmp_path,
325
+ columns=2,
326
+ rows=1,
327
+ raster_pattern=RASTER_PATTERN_Y_DIRECTION,
328
+ )
329
 
330
+ spacing_rows, _cc, _rc, aligned_count, missing_count = _auto_align_grid_spacing_rows(
331
+ records, 2, 1, 5.0, 5.0
332
+ )
333
 
334
+ assert aligned_count == 1
335
+ assert missing_count == 0
336
+ _assert_aligned_offsets_reassemble(records, spacing_rows, 2, 1)
337
 
 
 
 
338
 
339
+ def test_split_pieces_share_parent_scan_grid_so_column_gaps_equal_fil(tmp_path) -> None:
340
+ """User scenario: 4-column split + Y raster -> every aligned gap is one fil width.
341
 
342
+ The parent width (7.9) is deliberately not a multiple of the fil width or
343
+ the cell width, so per-piece centred grids would drift out of phase at
344
+ every seam. With the shared parent scan grid the assembled columns run at
345
+ an exact fil pitch, so the world gap between adjacent pieces' toolpath
346
+ boxes is exactly one fil width.
347
+ """
348
+ from shapely.geometry import MultiPolygon, box
349
 
350
+ from stl_slicer import LayerStack
351
+ from vector_gcode import generate_vector_gcode
352
+ from vector_toolpath import RASTER_PATTERN_Y_DIRECTION, split_layer_stack_grid
353
+
354
+ fil = 0.8
355
+ layer = MultiPolygon([box(0.0, 0.0, 7.9, 4.0)])
356
+ stack = LayerStack(
357
+ layers=[layer, layer],
358
+ z_values=[0.5, 1.5],
359
+ bounds=((0.0, 0.0, 0.0), (7.9, 4.0, 2.0)),
360
+ layer_height=1.0,
361
+ name="wide",
362
+ )
363
+ pieces = split_layer_stack_grid(stack, columns=4, rows=1)
364
+ records = []
365
+ for index, piece in enumerate(pieces):
366
+ gcode_path = generate_vector_gcode(
367
+ piece,
368
+ shape_name=f"col_{index}",
369
+ pressure=25,
370
+ valve=4 + index,
371
+ port=1,
372
+ fil_width=fil,
373
+ raster_pattern=RASTER_PATTERN_Y_DIRECTION,
374
+ output_dir=tmp_path / f"col_{index}",
375
+ )
376
+ records.append(
377
+ {
378
+ "idx": index + 1,
379
+ "name": piece.name,
380
+ "nozzle": index + 1,
381
+ "color": "#000000",
382
+ "gcode_path": str(gcode_path),
383
+ "split_group_id": "split-a",
384
+ "split_index": index,
385
+ "split_row": 1,
386
+ "split_col": index + 1,
387
+ "split_rows": 1,
388
+ "split_columns": 4,
389
+ }
390
+ )
391
+
392
+ spacing_rows, _cc, _rc, aligned_count, missing_count = _auto_align_grid_spacing_rows(
393
+ records, 4, 1, 5.0, 5.0
394
  )
395
 
396
+ assert aligned_count == 3
397
+ assert missing_count == 0
398
+ # Adjacent columns continue the parent grid: every gap is exactly one fil.
399
+ assert [row[2] for row in spacing_rows] == [fil, fil, fil]
400
+ _assert_aligned_offsets_reassemble(records, spacing_rows, 4, 1)
401
+
402
+
403
+ def test_auto_align_handles_spiral_patterns(tmp_path) -> None:
404
+ for pattern in (RASTER_PATTERN_RECTANGULAR_SPIRAL, RASTER_PATTERN_CIRCLE_SPIRAL):
405
+ records = _split_piece_records(
406
+ tmp_path / pattern.replace(" ", "_"),
407
+ columns=2,
408
+ rows=1,
409
+ raster_pattern=pattern,
410
+ )
411
+
412
+ spacing_rows, _cc, _rc, aligned_count, missing_count = (
413
+ _auto_align_grid_spacing_rows(records, 2, 1, 5.0, 5.0)
414
+ )
415
 
416
+ assert aligned_count == 1, pattern
417
+ assert missing_count == 0, pattern
418
+ _assert_aligned_offsets_reassemble(records, spacing_rows, 2, 1)
419
 
420
+
421
+ def test_auto_align_reports_missing_gcode_for_split_siblings(tmp_path) -> None:
422
  records = [
423
  {"idx": 1, "name": "first", "nozzle": 1, "split_group_id": "split-a", "split_index": 0},
424
  {"idx": 2, "name": "second", "nozzle": 2, "split_group_id": "split-a", "split_index": 1},
 
425
  ]
426
 
427
+ rows, _column_count, _row_count, aligned_count, missing_count = (
428
+ _auto_align_grid_spacing_rows(records, 2, 1, 10.0, 3.0)
 
 
 
 
 
429
  )
430
 
431
+ assert aligned_count == 0
432
+ assert missing_count == 1
433
+ assert rows == [["Nozzle 1: Shape 1", "Nozzle 2: Shape 2", 10.0, 0.0]]
434
 
435
 
436
  def test_auto_align_grid_spacing_skips_unsplit_records() -> None:
 
439
  {"idx": 2, "name": "second", "nozzle": 2},
440
  ]
441
 
442
+ rows, _column_count, _row_count, aligned_count, missing_count = (
443
+ _auto_align_grid_spacing_rows(records, 2, 1, 10.0, 3.0)
 
 
 
 
 
444
  )
445
 
446
  assert aligned_count == 0
447
+ assert missing_count == 0
448
  assert rows == [["Nozzle 1: Shape 1", "Nozzle 2: Shape 2", 10.0, 0.0]]
449
 
450
 
 
558
  {"idx": 3, "name": "last", "stl_path": "last.stl", "target_x": 30.0, "target_y": 31.0, "target_z": 32.0, "pressure": 35, "valve": 6, "port": 3, "color": "#333333"},
559
  ]
560
 
561
+ outputs = delete_shape_from_settings(records, _shape_settings_rows(records), 0.0, Event())
562
  updated_records = outputs[1]
563
  updated_settings = outputs[2]
564
 
 
580
  {"idx": 3, "name": "last", "stl_path": "last.stl", "target_x": 30.0, "target_y": 31.0, "target_z": 32.0, "pressure": 35, "valve": 6, "port": 3, "color": "#333333"},
581
  ]
582
 
583
+ first_outputs = delete_shape_from_settings(records, _shape_settings_rows(records), 0.0, Event())
584
  second_outputs = delete_shape_from_settings(
585
  first_outputs[1],
586
  first_outputs[2],
 
 
587
  first_outputs[-1],
588
  Event(),
589
  )
tests/test_vector_gcode.py CHANGED
@@ -192,11 +192,13 @@ def test_gcode_header_writes_presets_before_initial_aux_commands(tmp_path) -> No
192
  ]
193
 
194
  assert lines[0] == "G91"
195
- assert lines[1] == "{aux_command}WAGO_ValveCommands(7, 0)"
196
- assert lines[2] == "serialPort3.write(eval(setpress(25)))"
197
- assert lines[3] == "serialPort3.write(eval(togglepress()))"
198
- assert lines[4].startswith("{aux_command}WAGO_ValveCommands(")
 
199
  assert lines[5].startswith("{aux_command}WAGO_ValveCommands(")
 
200
 
201
 
202
  def test_gcode_lead_in_runs_once_before_first_layer(tmp_path) -> None:
@@ -515,6 +517,109 @@ def test_circle_spiral_raster_reverses_between_layers(tmp_path) -> None:
515
  assert end_z == 1.0
516
 
517
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
518
  def test_layer_contour_loops_follow_polygon_rings() -> None:
519
  hollow = MultiPolygon(
520
  [
@@ -753,6 +858,92 @@ def test_reference_motion_shares_path_and_gates_valve_per_shape(tmp_path) -> Non
753
  assert _print_length(small_moves) < _print_length(big_moves) / 2 + 4.0
754
 
755
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
756
  def test_build_reference_stack_unions_center_aligned_layers() -> None:
757
  first = _stack(box(0.0, 0.0, 2.0, 2.0), name="first")
758
  second = _stack(box(10.0, 10.0, 14.0, 14.0), name="second")
@@ -798,6 +989,76 @@ def test_split_layer_stack_grid_orders_rows_top_down() -> None:
798
  assert all(piece.layers[0].area == 4.0 for piece in pieces)
799
 
800
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
801
  def test_split_layer_stack_grid_overlap_alternates_between_layers() -> None:
802
  layer = box(0.0, 0.0, 4.0, 2.0)
803
  stack = _stack(layer, layer, name="interlock")
 
192
  ]
193
 
194
  assert lines[0] == "G91"
195
+ # World anchor of the relative toolpath (origin at sweep start (-1, 0.5)).
196
+ assert lines[1] == "; PathOrigin X-1.0 Y0.5"
197
+ assert lines[2] == "{aux_command}WAGO_ValveCommands(7, 0)"
198
+ assert lines[3] == "serialPort3.write(eval(setpress(25)))"
199
+ assert lines[4] == "serialPort3.write(eval(togglepress()))"
200
  assert lines[5].startswith("{aux_command}WAGO_ValveCommands(")
201
+ assert lines[6].startswith("{aux_command}WAGO_ValveCommands(")
202
 
203
 
204
  def test_gcode_lead_in_runs_once_before_first_layer(tmp_path) -> None:
 
517
  assert end_z == 1.0
518
 
519
 
520
+ def _total_length(moves: list[dict]) -> float:
521
+ return sum(math.dist(move["start"][:2], move["end"][:2]) for move in moves)
522
+
523
+
524
+ def _print_length(moves: list[dict]) -> float:
525
+ return sum(
526
+ math.dist(move["start"][:2], move["end"][:2])
527
+ for move in moves
528
+ if move["color"] == 255
529
+ )
530
+
531
+
532
+ def test_half_infill_skips_alternate_lines_but_keeps_the_same_path(tmp_path) -> None:
533
+ layer = box(0.0, 0.0, 4.0, 4.0)
534
+ stack = _stack(layer, layer)
535
+
536
+ def _generate(infill: float, label: str):
537
+ path = generate_vector_gcode(
538
+ stack,
539
+ shape_name=label,
540
+ pressure=25,
541
+ valve=7,
542
+ port=3,
543
+ fil_width=1.0,
544
+ layer_height=1.0,
545
+ infill=infill,
546
+ output_dir=tmp_path / label,
547
+ )
548
+ return _moves_with_colors(path.read_text())
549
+
550
+ full = _generate(1.0, "full")
551
+ half = _generate(0.5, "half")
552
+
553
+ # Identical motion: same final position and same total traversed length.
554
+ assert full[-1]["end"] == half[-1]["end"]
555
+ assert abs(_total_length(full) - _total_length(half)) < 1e-6
556
+
557
+ # Half the lines dispense: 2 of the 4 sweeps per layer print.
558
+ assert abs(_print_length(half) - _print_length(full) / 2) < 1e-6
559
+
560
+ # The printing sweeps sit on alternating scanlines (one fil apart x2).
561
+ half_print_rows = sorted({round(m["start"][1], 6) for m in half if m["color"] == 255 and m["start"][2] == 0.0})
562
+ assert len(half_print_rows) == 2
563
+ assert abs((half_print_rows[1] - half_print_rows[0]) - 2.0) < 1e-9
564
+
565
+
566
+ def test_infill_selection_is_shared_across_reference_motion(tmp_path) -> None:
567
+ small = _stack(box(0.0, 0.0, 2.0, 2.0), name="small")
568
+ big = _stack(box(0.0, 0.0, 4.0, 4.0), name="big")
569
+ reference = build_reference_stack([small, big])
570
+
571
+ def _generate(stack: LayerStack, infill: float, label: str):
572
+ path = generate_vector_gcode(
573
+ stack,
574
+ shape_name=label,
575
+ pressure=25,
576
+ valve=7,
577
+ port=3,
578
+ fil_width=1.0,
579
+ motion=reference,
580
+ infill=infill,
581
+ output_dir=tmp_path / label,
582
+ )
583
+ return _moves_with_colors(path.read_text())
584
+
585
+ sparse = _generate(small, 0.5, "sparse")
586
+ dense = _generate(big, 1.0, "dense")
587
+
588
+ # Different infill per shape, one shared motion path.
589
+ assert sparse[-1]["end"] == dense[-1]["end"]
590
+ assert abs(_total_length(sparse) - _total_length(dense)) < 1e-6
591
+ assert 0 < _print_length(sparse) < _print_length(dense)
592
+
593
+
594
+ def test_spiral_infill_skips_rings_and_keeps_the_path(tmp_path) -> None:
595
+ layer = box(0.0, 0.0, 6.0, 6.0)
596
+ stack = _stack(layer)
597
+
598
+ def _generate(pattern: str, infill: float, label: str):
599
+ path = generate_vector_gcode(
600
+ stack,
601
+ shape_name=label,
602
+ pressure=25,
603
+ valve=7,
604
+ port=3,
605
+ fil_width=1.0,
606
+ raster_pattern=pattern,
607
+ infill=infill,
608
+ output_dir=tmp_path / label,
609
+ )
610
+ return _moves_with_colors(path.read_text())
611
+
612
+ for pattern in (RASTER_PATTERN_RECTANGULAR_SPIRAL, RASTER_PATTERN_CIRCLE_SPIRAL):
613
+ full = _generate(pattern, 1.0, f"{pattern}-full".replace(" ", "_"))
614
+ half = _generate(pattern, 0.5, f"{pattern}-half".replace(" ", "_"))
615
+ # Identical path within the writer's micron-level delta rounding
616
+ # (segment split points differ, so the rounding accumulates
617
+ # differently by up to ~1 um over tens of thousands of moves).
618
+ assert math.dist(full[-1]["end"], half[-1]["end"]) < 1e-4, pattern
619
+ assert abs(_total_length(full) - _total_length(half)) < 1e-3, pattern
620
+ assert 0 < _print_length(half) < _print_length(full), pattern
621
+
622
+
623
  def test_layer_contour_loops_follow_polygon_rings() -> None:
624
  hollow = MultiPolygon(
625
  [
 
858
  assert _print_length(small_moves) < _print_length(big_moves) / 2 + 4.0
859
 
860
 
861
+ def test_reference_motion_contours_share_path_and_gate_valve_per_shape(tmp_path) -> None:
862
+ small = _stack(box(0.0, 0.0, 2.0, 2.0), name="small")
863
+ big = _stack(box(0.0, 0.0, 4.0, 4.0), name="big")
864
+ reference = build_reference_stack([small, big])
865
+ assert reference is not None
866
+ contour_sources = [
867
+ ContourSource(owner_idx=1, stack=small),
868
+ ContourSource(owner_idx=2, stack=big),
869
+ ]
870
+
871
+ def _generate(stack: LayerStack, owner: int, label: str):
872
+ path = generate_vector_gcode(
873
+ stack,
874
+ shape_name=label,
875
+ pressure=25,
876
+ valve=7,
877
+ port=3,
878
+ fil_width=1.0,
879
+ motion=reference,
880
+ contour_sources=contour_sources,
881
+ active_contour_owner=owner,
882
+ output_dir=tmp_path / label,
883
+ )
884
+ return _moves_with_colors(path.read_text())
885
+
886
+ small_moves = _generate(small, 1, "small")
887
+ big_moves = _generate(big, 2, "big")
888
+
889
+ # The motion including EVERY shape's contour tour is identical: same final
890
+ # position and same total traversed length for both heads.
891
+ assert small_moves[-1]["end"] == big_moves[-1]["end"]
892
+
893
+ def _total_length(moves: list[dict]) -> float:
894
+ return sum(math.dist(move["start"][:2], move["end"][:2]) for move in moves)
895
+
896
+ assert abs(_total_length(small_moves) - _total_length(big_moves)) < 1e-6
897
+
898
+ # In the shared frame (origin at the motion sweep start (-2, -0.5); big is
899
+ # re-centred to (-1,-1)..(3,3), small stays (0,0)..(2,2)):
900
+ small_corners = {(2.0, 0.5), (4.0, 0.5), (4.0, 2.5), (2.0, 2.5)}
901
+ big_corners = {(1.0, -0.5), (5.0, -0.5), (5.0, 3.5), (1.0, 3.5)}
902
+
903
+ def _endpoints(moves: list[dict], color: int) -> set[tuple[float, float]]:
904
+ return {
905
+ (round(move["end"][0], 6), round(move["end"][1], 6))
906
+ for move in moves
907
+ if move["color"] == color
908
+ }
909
+
910
+ # Each shape PRINTS its own outline and TRAVELS the other shape's outline.
911
+ assert small_corners <= _endpoints(small_moves, 255)
912
+ assert big_corners <= _endpoints(small_moves, 0)
913
+ assert big_corners <= _endpoints(big_moves, 255)
914
+ assert small_corners <= _endpoints(big_moves, 0)
915
+
916
+
917
+ def test_solo_contours_still_trace_only_own_shape(tmp_path) -> None:
918
+ small = _stack(box(0.0, 0.0, 2.0, 2.0), name="small")
919
+ big = _stack(box(0.0, 0.0, 4.0, 4.0), name="big")
920
+ contour_sources = [
921
+ ContourSource(owner_idx=1, stack=small),
922
+ ContourSource(owner_idx=2, stack=big),
923
+ ]
924
+
925
+ gcode_path = generate_vector_gcode(
926
+ small,
927
+ shape_name="solo",
928
+ pressure=25,
929
+ valve=7,
930
+ port=3,
931
+ fil_width=1.0,
932
+ contour_sources=contour_sources,
933
+ active_contour_owner=1,
934
+ output_dir=tmp_path,
935
+ )
936
+
937
+ moves = _moves_with_colors(gcode_path.read_text())
938
+ # Without reference motion the other shape's contour must NOT be traced:
939
+ # nothing ever moves outside the small shape's buffered footprint
940
+ # (origin at world (-1, 0.5), so relative x spans [0, 4], y [-0.5, 1.5];
941
+ # the big shape's contour would reach (5.0, 3.5)).
942
+ for move in moves:
943
+ assert -0.5 <= move["end"][0] <= 4.2
944
+ assert -1.0 <= move["end"][1] <= 2.0
945
+
946
+
947
  def test_build_reference_stack_unions_center_aligned_layers() -> None:
948
  first = _stack(box(0.0, 0.0, 2.0, 2.0), name="first")
949
  second = _stack(box(10.0, 10.0, 14.0, 14.0), name="second")
 
989
  assert all(piece.layers[0].area == 4.0 for piece in pieces)
990
 
991
 
992
+ def test_grid_split_pads_equal_whole_fil_cells() -> None:
993
+ # 20.0 / 4 = 5.0 mm cells, which is 6.25 fil widths — not representable.
994
+ # With `grid`, every cell rounds UP to 7 fils (5.6 mm) and the 2.4 mm
995
+ # leftover becomes blank margin split evenly outside the outer edges.
996
+ layer = box(0.0, 0.0, 20.0, 4.0)
997
+ stack = _stack(layer, name="wide")
998
+
999
+ pieces = split_layer_stack_grid(stack, columns=4, rows=1, grid=0.8)
1000
+
1001
+ widths = {round(piece.bounds[1][0] - piece.bounds[0][0], 6) for piece in pieces}
1002
+ assert widths == {5.6}
1003
+ # Padding is centred: 1.2 mm of blank space beyond each outer edge.
1004
+ assert round(pieces[0].bounds[0][0], 6) == -1.2
1005
+ assert round(pieces[-1].bounds[1][0], 6) == 21.2
1006
+ # No material is lost or duplicated by the padded cells.
1007
+ total_area = sum(piece.layers[0].area for piece in pieces)
1008
+ assert abs(total_area - layer.area) < 1e-9
1009
+
1010
+
1011
+ def test_grid_split_reference_deltas_are_uniform() -> None:
1012
+ from vector_toolpath import _centering_delta
1013
+
1014
+ layer = box(0.0, 0.0, 20.0, 4.0)
1015
+ stack = _stack(layer, name="wide")
1016
+ pieces = split_layer_stack_grid(stack, columns=4, rows=1, grid=0.8)
1017
+ reference = build_reference_stack(pieces, grid=0.8)
1018
+ assert reference is not None
1019
+
1020
+ deltas = [_centering_delta(piece, reference)[0] for piece in pieces]
1021
+ diffs = {round(a - b, 6) for a, b in zip(deltas, deltas[1:])}
1022
+ # Uniform spacing between every consecutive pair (one cell = 7 fils),
1023
+ # so the physical nozzle offsets are the same for every connection.
1024
+ assert diffs == {5.6}
1025
+
1026
+
1027
+ def test_split_overlap_seam_raster_distance_is_equal_on_both_sides() -> None:
1028
+ from vector_toolpath import _axis_raster_segments
1029
+
1030
+ # 7.5 mm is deliberately not a multiple of the 1 mm fil width, so raster
1031
+ # quantization leaves slack. The slack must be split evenly: both pieces'
1032
+ # lines sit the same distance from the (shifted) cut on every layer.
1033
+ layer = MultiPolygon([box(0.0, 0.0, 7.5, 4.0)])
1034
+ stack = LayerStack(
1035
+ layers=[layer, layer],
1036
+ z_values=[0.5, 1.5],
1037
+ bounds=((0.0, 0.0, 0.0), (7.5, 4.0, 2.0)),
1038
+ layer_height=1.0,
1039
+ name="seam",
1040
+ )
1041
+ left, right = split_layer_stack_grid(
1042
+ stack, columns=2, rows=1, overlapping_layers=True, overlap=0.5
1043
+ )
1044
+
1045
+ for layer_number in range(2):
1046
+ left_columns = sorted(
1047
+ {seg[0] for seg in _axis_raster_segments(
1048
+ left.layers[layer_number], left.layers[layer_number], 1.0, "Y"
1049
+ ) if seg[4] == 255}
1050
+ )
1051
+ right_columns = sorted(
1052
+ {seg[0] for seg in _axis_raster_segments(
1053
+ right.layers[layer_number], right.layers[layer_number], 1.0, "Y"
1054
+ ) if seg[4] == 255}
1055
+ )
1056
+ seam = left.layers[layer_number].bounds[2]
1057
+ left_distance = seam - left_columns[-1]
1058
+ right_distance = right_columns[0] - seam
1059
+ assert abs(left_distance - right_distance) < 1e-9
1060
+
1061
+
1062
  def test_split_layer_stack_grid_overlap_alternates_between_layers() -> None:
1063
  layer = box(0.0, 0.0, 4.0, 2.0)
1064
  stack = _stack(layer, layer, name="interlock")
vector_gcode.py CHANGED
@@ -22,8 +22,10 @@ from vector_toolpath import (
22
  RASTER_PATTERN_WOODPILE,
23
  RASTER_PATTERN_Y_DIRECTION,
24
  ContourSource,
 
25
  _lead_in_moves,
26
  _normalize_raster_pattern,
 
27
  align_stack_to,
28
  build_contour_layers,
29
  plan_layer_moves,
@@ -107,6 +109,7 @@ def write_gcode_file(
107
  increase_pressure_per_layer: float,
108
  pressure_ramp_enabled: bool,
109
  all_g1: bool,
 
110
  ) -> None:
111
  off_color = 0
112
  com_port = f"serialPort{port}"
@@ -120,6 +123,13 @@ def write_gcode_file(
120
 
121
  with open(gcode_path, "w") as f:
122
  f.write("G91\n")
 
 
 
 
 
 
 
123
  f.write(_valve_cmd(valve, 0))
124
  for line in setpress_lines:
125
  f.write(f"{line}\n")
@@ -186,6 +196,7 @@ def generate_vector_gcode(
186
  motion: LayerStack | None = None,
187
  contour_sources: list[ContourSource] | None = None,
188
  active_contour_owner: int | None = None,
 
189
  increase_pressure_per_layer: float = 0.1,
190
  pressure_ramp_enabled: bool = True,
191
  all_g1: bool = False,
@@ -229,7 +240,20 @@ def generate_vector_gcode(
229
  reference=contour_reference,
230
  )
231
 
232
- gcode_list = plan_layer_moves(
 
 
 
 
 
 
 
 
 
 
 
 
 
233
  motion_layers,
234
  valve_layers,
235
  fil_width,
@@ -237,8 +261,20 @@ def generate_vector_gcode(
237
  raster_pattern,
238
  contour_layers,
239
  active_contour_owner,
 
 
 
240
  )
241
 
 
 
 
 
 
 
 
 
 
242
  lead_in = _lead_in_moves(
243
  lead_in_enabled,
244
  lead_in_length,
@@ -267,5 +303,6 @@ def generate_vector_gcode(
267
  increase_pressure_per_layer=float(increase_pressure_per_layer),
268
  pressure_ramp_enabled=bool(pressure_ramp_enabled),
269
  all_g1=bool(all_g1),
 
270
  )
271
  return gcode_path
 
22
  RASTER_PATTERN_WOODPILE,
23
  RASTER_PATTERN_Y_DIRECTION,
24
  ContourSource,
25
+ _centering_delta,
26
  _lead_in_moves,
27
  _normalize_raster_pattern,
28
+ _scan_anchor,
29
  align_stack_to,
30
  build_contour_layers,
31
  plan_layer_moves,
 
109
  increase_pressure_per_layer: float,
110
  pressure_ramp_enabled: bool,
111
  all_g1: bool,
112
+ path_origin: tuple[float, float] | None = None,
113
  ) -> None:
114
  off_color = 0
115
  com_port = f"serialPort{port}"
 
123
 
124
  with open(gcode_path, "w") as f:
125
  f.write("G91\n")
126
+ if path_origin is not None:
127
+ # World anchor of the relative toolpath: absolute position (in the
128
+ # shape's own coordinate frame) of the point the moves start from.
129
+ # Lets tools place parallel parts so split pieces reassemble.
130
+ f.write(
131
+ f"; PathOrigin X{_coord(path_origin[0])} Y{_coord(path_origin[1])}\n"
132
+ )
133
  f.write(_valve_cmd(valve, 0))
134
  for line in setpress_lines:
135
  f.write(f"{line}\n")
 
196
  motion: LayerStack | None = None,
197
  contour_sources: list[ContourSource] | None = None,
198
  active_contour_owner: int | None = None,
199
+ infill: float = 1.0,
200
  increase_pressure_per_layer: float = 0.1,
201
  pressure_ramp_enabled: bool = True,
202
  all_g1: bool = False,
 
240
  reference=contour_reference,
241
  )
242
 
243
+ # Anchor the raster scan grid to the motion stack's frame (a split
244
+ # piece's frame is its parent shape's bounds) so lines stack across
245
+ # layers and stay on one continuous grid across split pieces.
246
+ frame_stack = motion if motion is not None else shape
247
+ if frame_stack.scan_frame is not None:
248
+ frame_x_min, frame_y_min, frame_x_max, frame_y_max = frame_stack.scan_frame
249
+ else:
250
+ (frame_x_min, frame_y_min, _fz), (frame_x_max, frame_y_max, _fz2) = frame_stack.bounds
251
+ scan_anchors = (
252
+ _scan_anchor(frame_x_min, frame_x_max, fil_width),
253
+ _scan_anchor(frame_y_min, frame_y_max, fil_width),
254
+ )
255
+
256
+ gcode_list, toolpath_origin = plan_layer_moves(
257
  motion_layers,
258
  valve_layers,
259
  fil_width,
 
261
  raster_pattern,
262
  contour_layers,
263
  active_contour_owner,
264
+ shared_motion=motion is not None,
265
+ scan_anchors=scan_anchors,
266
+ infill_fraction=max(0.0, min(1.0, float(infill))),
267
  )
268
 
269
+ # World anchor: the toolpath origin expressed in the shape's own frame.
270
+ # With reference motion the geometry was translated by the centering
271
+ # delta, so subtract it to get back to the shape's coordinates.
272
+ if motion is not None:
273
+ delta_x, delta_y = _centering_delta(shape, motion)
274
+ else:
275
+ delta_x = delta_y = 0.0
276
+ path_origin = (toolpath_origin[0] - delta_x, toolpath_origin[1] - delta_y)
277
+
278
  lead_in = _lead_in_moves(
279
  lead_in_enabled,
280
  lead_in_length,
 
303
  increase_pressure_per_layer=float(increase_pressure_per_layer),
304
  pressure_ramp_enabled=bool(pressure_ramp_enabled),
305
  all_g1=bool(all_g1),
306
+ path_origin=path_origin,
307
  )
308
  return gcode_path
vector_toolpath.py CHANGED
@@ -227,12 +227,15 @@ def _classify_polyline(
227
  points: list[tuple[float, float]],
228
  valve: MultiPolygon,
229
  eps: float = EPS,
 
230
  ) -> list[Seg]:
231
  """Split a motion polyline at valve-boundary crossings and color the pieces.
232
 
233
  Preserves path order (a whole-polyline shapely intersection would not).
234
  Pieces on the boundary count as printing (`covers`), matching the raster
235
- convention that boundary-grazing sweeps dispense.
 
 
236
  """
237
  segments: list[Seg] = []
238
  has_valve = valve is not None and not valve.is_empty
@@ -270,7 +273,10 @@ def _classify_polyline(
270
  if (t1 - t0) * seg_len <= eps:
271
  continue
272
  mid_x, mid_y = _point_along((t0 + t1) / 2.0, x0, y0, x1, y1)
273
- color = 255 if valve.covers(Point(mid_x, mid_y)) else 0
 
 
 
274
  start = _point_along(t0, x0, y0, x1, y1)
275
  end = _point_along(t1, x0, y0, x1, y1)
276
  _append_colored_segment(segments, *start, *end, color)
@@ -278,18 +284,68 @@ def _classify_polyline(
278
  return segments
279
 
280
 
281
- def _scan_coords(lo: float, hi: float, fil_width: float) -> list[float]:
282
- """Scanline positions at half-fil_width offsets; always at least one line."""
283
- if hi - lo < fil_width:
284
- return [(lo + hi) / 2.0]
285
 
286
- coords: list[float] = []
287
- value = lo + fil_width / 2.0
288
- limit = hi - fil_width / 2.0 + 1e-9
289
- while value <= limit:
290
- coords.append(value)
291
- value += fil_width
292
- return coords
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
293
 
294
 
295
  def _axis_raster_segments(
@@ -299,13 +355,18 @@ def _axis_raster_segments(
299
  axis: str,
300
  reverse_order: bool = False,
301
  start_forward: bool = True,
 
 
302
  ) -> list[Seg]:
303
  """Snake raster of `motion`, dispensing only inside `valve`.
304
 
305
  Axis "X" sweeps along X with rows stacked in Y; axis "Y" sweeps along Y
306
  with columns stacked in X. Each sweep spans from the first to the last
307
  motion chord (crossing interior gaps valve-off) and gets a fil_width
308
- valve-settle travel buffer before and after.
 
 
 
309
  """
310
  if motion is None or motion.is_empty:
311
  return []
@@ -316,10 +377,16 @@ def _axis_raster_segments(
316
  else:
317
  scan_lo, scan_hi = min_y, max_y
318
 
319
- coords = _scan_coords(scan_lo, scan_hi, fil_width)
320
  if reverse_order:
321
  coords = coords[::-1]
322
 
 
 
 
 
 
 
323
  segments: list[Seg] = []
324
  sweep_number = 0
325
  for coord in coords:
@@ -329,12 +396,17 @@ def _axis_raster_segments(
329
 
330
  sweep_lo = motion_runs[0][0]
331
  sweep_hi = motion_runs[-1][1]
332
- valve_runs = []
333
- for lo, hi in _chord_runs(valve, axis, coord):
334
- lo = max(lo, sweep_lo)
335
- hi = min(hi, sweep_hi)
336
- if hi - lo > EPS:
337
- valve_runs.append((lo, hi))
 
 
 
 
 
338
 
339
  def emit(a: float, b: float, color: int) -> None:
340
  if axis == "Y":
@@ -387,9 +459,13 @@ def _oriented_axis_raster_segments(
387
  current_x: float,
388
  current_y: float,
389
  prefer_default: bool = False,
 
 
390
  ) -> list[Seg]:
391
  """Pick the raster orientation whose start is nearest the current position."""
392
- default_segments = _axis_raster_segments(motion, valve, fil_width, axis)
 
 
393
  if prefer_default or not default_segments:
394
  return default_segments
395
 
@@ -403,6 +479,8 @@ def _oriented_axis_raster_segments(
403
  axis,
404
  reverse_order=reverse_order,
405
  start_forward=start_forward,
 
 
406
  )
407
  if segments and segments not in candidates:
408
  candidates.append(segments)
@@ -691,6 +769,29 @@ def _last_print_reference(output_list: list[dict]) -> tuple[float, float, float,
691
  return last_x, last_y, last_dx, last_dy
692
 
693
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
694
  def _rewind_trailing_travel(
695
  output_list: list[dict],
696
  current_x: float,
@@ -763,39 +864,55 @@ def _append_layer_contours(
763
  active_owner_idx: int | None,
764
  origin_x: float = 0.0,
765
  origin_y: float = 0.0,
 
766
  ) -> tuple[float, float]:
767
  # `origin_x/origin_y` is the world position of the move list's start:
768
  # _last_print_reference sums relative moves from zero, so its result must
769
  # be shifted back into the world frame the contours live in.
 
 
 
 
 
 
 
 
770
  if layer_number >= len(contour_layers):
771
  return current_x, current_y
772
 
773
  active_sources = [
774
  source
775
  for source in contour_layers[layer_number]
776
- if source.get("owner_idx") == active_owner_idx
777
  and any(len(contour) >= 2 for contour in source.get("contours", []))
778
  ]
779
  if not active_sources:
780
  return current_x, current_y
781
 
782
- current_x, current_y = _rewind_trailing_travel(
783
- output_list,
784
- current_x,
785
- current_y,
786
- )
 
787
 
788
  use_infill_reference = True
789
 
790
  for source in active_sources:
791
- color = 255
 
 
 
792
  for contour in source.get("contours", []):
793
  if len(contour) < 2:
794
  continue
795
  if use_infill_reference:
796
- nearest_x, nearest_y, approach_dx, approach_dy = _last_print_reference(
797
- output_list
 
 
798
  )
 
799
  nearest_x += origin_x
800
  nearest_y += origin_y
801
  else:
@@ -882,9 +999,25 @@ def plan_layer_moves(
882
  raster_pattern: str,
883
  contour_layers: list[list[dict]] | None = None,
884
  active_contour_owner: int | None = None,
885
- ) -> list[dict]:
886
- """Assemble per-layer segments into a relative move list for all patterns."""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
887
  raster_pattern = _normalize_raster_pattern(raster_pattern)
 
888
  gcode_list: list[dict] = []
889
  current_x = 0.0
890
  current_y = 0.0
@@ -902,16 +1035,58 @@ def plan_layer_moves(
902
  fil_width,
903
  reverse=layer_number % 2 == 1,
904
  )
905
- segments = _classify_polyline(points, valve)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
906
  elif raster_pattern == RASTER_PATTERN_RECTANGULAR_SPIRAL:
907
  points = _rectangular_spiral_polyline(
908
  motion.bounds,
909
  fil_width,
910
  reverse=layer_number % 2 == 1,
911
  )
912
- segments = _classify_polyline(points, valve)
 
 
 
 
 
 
 
 
 
 
 
 
 
913
  else:
914
  raster_axis = _raster_axis_for_pattern(raster_pattern, layer_number)
 
 
 
 
 
 
915
  segments = _oriented_axis_raster_segments(
916
  motion,
917
  valve,
@@ -920,6 +1095,8 @@ def plan_layer_moves(
920
  current_x,
921
  current_y,
922
  prefer_default=not raster_origin_initialized,
 
 
923
  )
924
 
925
  if not segments:
@@ -935,6 +1112,7 @@ def plan_layer_moves(
935
  active_contour_owner,
936
  origin_x,
937
  origin_y,
 
938
  )
939
  current_x, current_y = _append_relative_move(
940
  gcode_list,
@@ -1009,6 +1187,7 @@ def plan_layer_moves(
1009
  active_contour_owner,
1010
  origin_x,
1011
  origin_y,
 
1012
  )
1013
  current_x, current_y = _append_relative_move(
1014
  gcode_list,
@@ -1019,7 +1198,7 @@ def plan_layer_moves(
1019
  0,
1020
  )
1021
 
1022
- return gcode_list
1023
 
1024
 
1025
  def _stack_center(stack: LayerStack) -> tuple[float, float]:
@@ -1027,10 +1206,58 @@ def _stack_center(stack: LayerStack) -> tuple[float, float]:
1027
  return ((x_min + x_max) / 2.0, (y_min + y_max) / 2.0)
1028
 
1029
 
 
 
 
 
 
 
 
 
 
 
1030
  def _centering_delta(stack: LayerStack, reference: LayerStack) -> tuple[float, float]:
1031
- reference_x, reference_y = _stack_center(reference)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1032
  center_x, center_y = _stack_center(stack)
1033
- return reference_x - center_x, reference_y - center_y
 
 
 
1034
 
1035
 
1036
  def align_stack_to(
@@ -1053,12 +1280,21 @@ def align_stack_to(
1053
  return layers
1054
 
1055
 
1056
- def build_reference_stack(stacks: list[LayerStack | None]) -> LayerStack | None:
 
 
 
1057
  """Union all shapes into one shared motion stack, bbox-centres aligned.
1058
 
1059
  Vector analog of the old centered "black wins" TIFF merge: every stack is
1060
  translated so its XY bbox centre lands on the first stack's centre, then
1061
  each layer is the union of the translated layers.
 
 
 
 
 
 
1062
  """
1063
  valid = [stack for stack in stacks if stack is not None and stack.layers]
1064
  if not valid:
@@ -1066,15 +1302,22 @@ def build_reference_stack(stacks: list[LayerStack | None]) -> LayerStack | None:
1066
 
1067
  n_layers = max(len(stack.layers) for stack in valid)
1068
  reference_x, reference_y = _stack_center(valid[0])
 
 
 
 
 
 
 
 
 
1069
 
1070
  layer_parts: list[list[MultiPolygon]] = [[] for _ in range(n_layers)]
1071
  x_min = y_min = z_min = math.inf
1072
  x_max = y_max = z_max = -math.inf
1073
 
1074
  for stack in valid:
1075
- center_x, center_y = _stack_center(stack)
1076
- delta_x = reference_x - center_x
1077
- delta_y = reference_y - center_y
1078
 
1079
  (sx_min, sy_min, sz_min), (sx_max, sy_max, sz_max) = stack.bounds
1080
  x_min = min(x_min, sx_min + delta_x)
@@ -1103,26 +1346,54 @@ def build_reference_stack(stacks: list[LayerStack | None]) -> LayerStack | None:
1103
  break
1104
  z_values.append(z_value)
1105
 
 
 
1106
  return LayerStack(
1107
  layers=layers,
1108
  z_values=z_values,
1109
  bounds=((x_min, y_min, z_min), (x_max, y_max, z_max)),
1110
  layer_height=valid[0].layer_height,
1111
  name="reference",
 
 
 
1112
  )
1113
 
1114
 
1115
- def _split_boundaries(
1116
  lo: float,
1117
  hi: float,
1118
  count: int,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1119
  layer_index: int,
1120
  overlap: float,
1121
  ) -> list[float]:
1122
- """Cell boundaries for one axis; interior ones alternate by ±overlap per layer."""
1123
- edges = [lo + index * (hi - lo) / count for index in range(count + 1)]
1124
  if overlap <= 0.0 or count <= 1:
1125
- return edges
1126
 
1127
  adjusted = list(edges)
1128
  for boundary_index in range(1, count):
@@ -1144,13 +1415,16 @@ def split_layer_stack_grid(
1144
  rows: int,
1145
  overlapping_layers: bool = False,
1146
  overlap: float = 0.0,
 
1147
  ) -> list[LayerStack]:
1148
  """Split a sliced shape into a rows x columns grid of piece stacks.
1149
 
1150
  Pieces are returned row-major with row 1 the top strip (max-Y side),
1151
  matching the legacy image-grid ordering. With `overlapping_layers`, the
1152
  interior cut lines alternate by ±overlap between layers so neighbouring
1153
- pieces interlock. Piece `bounds` are the nominal (un-shifted) cell boxes.
 
 
1154
  """
1155
  columns = max(1, int(columns))
1156
  rows = max(1, int(rows))
@@ -1159,17 +1433,22 @@ def split_layer_stack_grid(
1159
  overlap_x = overlap if (overlapping_layers and columns > 1) else 0.0
1160
  overlap_y = overlap if (overlapping_layers and rows > 1) else 0.0
1161
 
1162
- base_x_edges = _split_boundaries(x_min, x_max, columns, 0, 0.0)
1163
- base_y_edges = _split_boundaries(y_min, y_max, rows, 0, 0.0)
1164
  layer_x_edges = [
1165
- _split_boundaries(x_min, x_max, columns, index, overlap_x)
1166
  for index in range(len(stack.layers))
1167
  ]
1168
  layer_y_edges = [
1169
- _split_boundaries(y_min, y_max, rows, index, overlap_y)
1170
  for index in range(len(stack.layers))
1171
  ]
1172
 
 
 
 
 
 
1173
  base_name = stack.name or "shape"
1174
  pieces: list[LayerStack] = []
1175
  for row_index in range(1, rows + 1):
@@ -1203,6 +1482,7 @@ def split_layer_stack_grid(
1203
  ),
1204
  layer_height=stack.layer_height,
1205
  name=f"{base_name}_r{row_index:02d}_c{col_index:02d}",
 
1206
  )
1207
  )
1208
 
 
227
  points: list[tuple[float, float]],
228
  valve: MultiPolygon,
229
  eps: float = EPS,
230
+ keep_point=None,
231
  ) -> list[Seg]:
232
  """Split a motion polyline at valve-boundary crossings and color the pieces.
233
 
234
  Preserves path order (a whole-polyline shapely intersection would not).
235
  Pieces on the boundary count as printing (`covers`), matching the raster
236
+ convention that boundary-grazing sweeps dispense. `keep_point(x, y)` can
237
+ additionally veto dispensing for a piece (evaluated at its midpoint) —
238
+ used for partial infill; the motion is unaffected.
239
  """
240
  segments: list[Seg] = []
241
  has_valve = valve is not None and not valve.is_empty
 
273
  if (t1 - t0) * seg_len <= eps:
274
  continue
275
  mid_x, mid_y = _point_along((t0 + t1) / 2.0, x0, y0, x1, y1)
276
+ dispensing = valve.covers(Point(mid_x, mid_y))
277
+ if dispensing and keep_point is not None:
278
+ dispensing = keep_point(mid_x, mid_y)
279
+ color = 255 if dispensing else 0
280
  start = _point_along(t0, x0, y0, x1, y1)
281
  end = _point_along(t1, x0, y0, x1, y1)
282
  _append_colored_segment(segments, *start, *end, color)
 
284
  return segments
285
 
286
 
287
+ def _infill_line_keep(fraction: float):
288
+ """Line-selection gate for partial infill; None means print every line.
 
 
289
 
290
+ Uses a Bresenham-style distribution so kept lines spread evenly: line k
291
+ dispenses iff floor((k+1)*f) > floor(k*f). At f=0.5 every other line
292
+ prints; at f=1 all do; at f=0 none. `k` is a global grid index, so the
293
+ same lines are selected across layers, across split pieces sharing a
294
+ parent frame, and across shapes sharing reference motion — the motion
295
+ path itself is never affected.
296
+ """
297
+ if fraction >= 1.0:
298
+ return None
299
+ fraction = max(0.0, float(fraction))
300
+
301
+ def keep(line_index: int) -> bool:
302
+ return math.floor((line_index + 1) * fraction) - math.floor(line_index * fraction) >= 1
303
+
304
+ return keep
305
+
306
+
307
+ def _scan_anchor(lo: float, hi: float, fil_width: float) -> float:
308
+ """First scanline position of the centred grid spanning [lo, hi].
309
+
310
+ The grid is centred: when the extent is not an exact multiple of
311
+ fil_width, the leftover slack is split evenly between both edges.
312
+ """
313
+ extent = hi - lo
314
+ if extent < fil_width:
315
+ return (lo + hi) / 2.0
316
+ count = int(math.floor(extent / fil_width + 1e-9))
317
+ slack = max(0.0, extent - count * fil_width)
318
+ return lo + slack / 2.0 + fil_width / 2.0
319
+
320
+
321
+ def _scan_coords(
322
+ lo: float,
323
+ hi: float,
324
+ fil_width: float,
325
+ anchor: float | None = None,
326
+ ) -> list[float]:
327
+ """Scanline positions in [lo, hi], fil_width apart; at least one line.
328
+
329
+ Without `anchor` the set is centred in [lo, hi] (always at least one
330
+ line). With `anchor` the lines lie on the global grid
331
+ {anchor + k*fil_width}: grid-split pieces anchored to their parent's
332
+ frame then raster on one continuous line grid, so the assembled seams
333
+ keep an exact one-fil-width pitch instead of drifting by each piece's own
334
+ quantization slack. The interval is half-open at `hi` so a line exactly
335
+ on a cut belongs to exactly one piece, and a sliver too thin to contain a
336
+ grid line gets none — the neighbouring grid line (at most one fil away)
337
+ covers it with filament width.
338
+ """
339
+ if anchor is None:
340
+ anchor = _scan_anchor(lo, hi, fil_width)
341
+ if hi - lo < fil_width:
342
+ return [anchor]
343
+ count = int(math.floor((hi - lo) / fil_width + 1e-9))
344
+ return [anchor + index * fil_width for index in range(count)]
345
+
346
+ k_lo = math.ceil((lo - anchor) / fil_width - 1e-9)
347
+ k_hi = math.floor((hi - anchor) / fil_width - 1e-9)
348
+ return [anchor + k * fil_width for k in range(int(k_lo), int(k_hi) + 1)]
349
 
350
 
351
  def _axis_raster_segments(
 
355
  axis: str,
356
  reverse_order: bool = False,
357
  start_forward: bool = True,
358
+ scan_anchor: float | None = None,
359
+ infill_keep=None,
360
  ) -> list[Seg]:
361
  """Snake raster of `motion`, dispensing only inside `valve`.
362
 
363
  Axis "X" sweeps along X with rows stacked in Y; axis "Y" sweeps along Y
364
  with columns stacked in X. Each sweep spans from the first to the last
365
  motion chord (crossing interior gaps valve-off) and gets a fil_width
366
+ valve-settle travel buffer before and after. `scan_anchor` pins the
367
+ scanlines to a global grid (see `_scan_coords`). `infill_keep(k)` gates
368
+ dispensing per grid line for partial infill: skipped lines are still
369
+ swept, just with the valve closed, so the motion never changes.
370
  """
371
  if motion is None or motion.is_empty:
372
  return []
 
377
  else:
378
  scan_lo, scan_hi = min_y, max_y
379
 
380
+ coords = _scan_coords(scan_lo, scan_hi, fil_width, anchor=scan_anchor)
381
  if reverse_order:
382
  coords = coords[::-1]
383
 
384
+ # Grid index base: orientation-independent, and shared across pieces when
385
+ # anchored to a common frame, so infill line selection lines up at seams.
386
+ index_base = scan_anchor if scan_anchor is not None else _scan_anchor(
387
+ scan_lo, scan_hi, fil_width
388
+ )
389
+
390
  segments: list[Seg] = []
391
  sweep_number = 0
392
  for coord in coords:
 
396
 
397
  sweep_lo = motion_runs[0][0]
398
  sweep_hi = motion_runs[-1][1]
399
+ if infill_keep is not None and not infill_keep(
400
+ int(round((coord - index_base) / fil_width))
401
+ ):
402
+ valve_runs: list[tuple[float, float]] = []
403
+ else:
404
+ valve_runs = []
405
+ for lo, hi in _chord_runs(valve, axis, coord):
406
+ lo = max(lo, sweep_lo)
407
+ hi = min(hi, sweep_hi)
408
+ if hi - lo > EPS:
409
+ valve_runs.append((lo, hi))
410
 
411
  def emit(a: float, b: float, color: int) -> None:
412
  if axis == "Y":
 
459
  current_x: float,
460
  current_y: float,
461
  prefer_default: bool = False,
462
+ scan_anchor: float | None = None,
463
+ infill_keep=None,
464
  ) -> list[Seg]:
465
  """Pick the raster orientation whose start is nearest the current position."""
466
+ default_segments = _axis_raster_segments(
467
+ motion, valve, fil_width, axis, scan_anchor=scan_anchor, infill_keep=infill_keep
468
+ )
469
  if prefer_default or not default_segments:
470
  return default_segments
471
 
 
479
  axis,
480
  reverse_order=reverse_order,
481
  start_forward=start_forward,
482
+ scan_anchor=scan_anchor,
483
+ infill_keep=infill_keep,
484
  )
485
  if segments and segments not in candidates:
486
  candidates.append(segments)
 
769
  return last_x, last_y, last_dx, last_dy
770
 
771
 
772
+ def _last_motion_reference(output_list: list[dict]) -> tuple[float, float, float, float]:
773
+ """Endpoint and direction of the last XY move, regardless of valve state.
774
+
775
+ Used instead of `_last_print_reference` when shapes share a reference
776
+ motion path: anchoring contours off the valve state would give every
777
+ shape a different contour tour, breaking the shared path.
778
+ """
779
+ x = y = 0.0
780
+ last_x = last_y = 0.0
781
+ last_dx = last_dy = 0.0
782
+ for move in output_list:
783
+ dx = float(move.get("X", 0.0))
784
+ dy = float(move.get("Y", 0.0))
785
+ x += dx
786
+ y += dy
787
+ if "Z" not in move and (dx != 0.0 or dy != 0.0):
788
+ last_x = x
789
+ last_y = y
790
+ last_dx = dx
791
+ last_dy = dy
792
+ return last_x, last_y, last_dx, last_dy
793
+
794
+
795
  def _rewind_trailing_travel(
796
  output_list: list[dict],
797
  current_x: float,
 
864
  active_owner_idx: int | None,
865
  origin_x: float = 0.0,
866
  origin_y: float = 0.0,
867
+ shared_motion: bool = False,
868
  ) -> tuple[float, float]:
869
  # `origin_x/origin_y` is the world position of the move list's start:
870
  # _last_print_reference sums relative moves from zero, so its result must
871
  # be shifted back into the world frame the contours live in.
872
+ #
873
+ # With `shared_motion` (reference-stack printing) every shape must trace
874
+ # EVERY source's contours in the same order so all parallel heads follow
875
+ # one identical path; the valve opens only on the active owner's contours.
876
+ # Anchoring then uses the last motion move instead of the last print, and
877
+ # the trailing-travel rewind is skipped — both depend on how the raster
878
+ # moves were split at valve boundaries, which differs per shape and would
879
+ # desynchronise the shared path.
880
  if layer_number >= len(contour_layers):
881
  return current_x, current_y
882
 
883
  active_sources = [
884
  source
885
  for source in contour_layers[layer_number]
886
+ if (shared_motion or source.get("owner_idx") == active_owner_idx)
887
  and any(len(contour) >= 2 for contour in source.get("contours", []))
888
  ]
889
  if not active_sources:
890
  return current_x, current_y
891
 
892
+ if not shared_motion:
893
+ current_x, current_y = _rewind_trailing_travel(
894
+ output_list,
895
+ current_x,
896
+ current_y,
897
+ )
898
 
899
  use_infill_reference = True
900
 
901
  for source in active_sources:
902
+ if shared_motion and source.get("owner_idx") != active_owner_idx:
903
+ color = 0
904
+ else:
905
+ color = 255
906
  for contour in source.get("contours", []):
907
  if len(contour) < 2:
908
  continue
909
  if use_infill_reference:
910
+ reference = (
911
+ _last_motion_reference(output_list)
912
+ if shared_motion
913
+ else _last_print_reference(output_list)
914
  )
915
+ nearest_x, nearest_y, approach_dx, approach_dy = reference
916
  nearest_x += origin_x
917
  nearest_y += origin_y
918
  else:
 
999
  raster_pattern: str,
1000
  contour_layers: list[list[dict]] | None = None,
1001
  active_contour_owner: int | None = None,
1002
+ shared_motion: bool = False,
1003
+ scan_anchors: tuple[float, float] | None = None,
1004
+ infill_fraction: float = 1.0,
1005
+ ) -> tuple[list[dict], tuple[float, float]]:
1006
+ """Assemble per-layer segments into a relative move list for all patterns.
1007
+
1008
+ `scan_anchors` = (x_anchor, y_anchor) pins the axis rasters' scanlines to
1009
+ a global grid so lines stack across layers and across split pieces.
1010
+
1011
+ `infill_fraction` < 1 skips dispensing on evenly-distributed lines (grid
1012
+ lines for axis rasters, rings/revolutions for spirals) while the motion
1013
+ path stays exactly the same as at 100% infill.
1014
+
1015
+ Returns the move list and the toolpath origin — the world position the
1016
+ relative moves start from (the first segment start of the first non-empty
1017
+ layer). Callers need it to map relative G-code back into world space.
1018
+ """
1019
  raster_pattern = _normalize_raster_pattern(raster_pattern)
1020
+ infill_keep = _infill_line_keep(infill_fraction)
1021
  gcode_list: list[dict] = []
1022
  current_x = 0.0
1023
  current_y = 0.0
 
1035
  fil_width,
1036
  reverse=layer_number % 2 == 1,
1037
  )
1038
+ keep_point = None
1039
+ if infill_keep is not None:
1040
+ # A spiral "line" is one revolution: revolution k lies at
1041
+ # radius outer - k*fil from the bbox-circumscribed circle.
1042
+ min_x, min_y, max_x, max_y = motion.bounds
1043
+ center_x = (min_x + max_x) / 2.0
1044
+ center_y = (min_y + max_y) / 2.0
1045
+ outer_radius = max(
1046
+ math.hypot(cx - center_x, cy - center_y)
1047
+ for cx, cy in (
1048
+ (min_x, min_y),
1049
+ (max_x, min_y),
1050
+ (max_x, max_y),
1051
+ (min_x, max_y),
1052
+ )
1053
+ )
1054
+
1055
+ def keep_point(x: float, y: float) -> bool:
1056
+ radius = math.hypot(x - center_x, y - center_y)
1057
+ return infill_keep(
1058
+ max(0, int(round((outer_radius - radius) / fil_width)))
1059
+ )
1060
+
1061
+ segments = _classify_polyline(points, valve, keep_point=keep_point)
1062
  elif raster_pattern == RASTER_PATTERN_RECTANGULAR_SPIRAL:
1063
  points = _rectangular_spiral_polyline(
1064
  motion.bounds,
1065
  fil_width,
1066
  reverse=layer_number % 2 == 1,
1067
  )
1068
+ keep_point = None
1069
+ if infill_keep is not None:
1070
+ # A spiral "line" is one ring: ring k sits k*fil inside the
1071
+ # half-fil-inset bounding box the spiral walks.
1072
+ min_x, min_y, max_x, max_y = motion.bounds
1073
+ half = fil_width / 2.0
1074
+ left, right = min_x + half, max_x - half
1075
+ bottom, top = min_y + half, max_y - half
1076
+
1077
+ def keep_point(x: float, y: float) -> bool:
1078
+ inset = min(x - left, right - x, y - bottom, top - y)
1079
+ return infill_keep(max(0, int(round(inset / fil_width))))
1080
+
1081
+ segments = _classify_polyline(points, valve, keep_point=keep_point)
1082
  else:
1083
  raster_axis = _raster_axis_for_pattern(raster_pattern, layer_number)
1084
+ if scan_anchors is None:
1085
+ axis_anchor = None
1086
+ else:
1087
+ # Axis "X" sweeps along X with rows stacked in Y, so its
1088
+ # scanlines use the Y anchor (and vice versa).
1089
+ axis_anchor = scan_anchors[0] if raster_axis == "Y" else scan_anchors[1]
1090
  segments = _oriented_axis_raster_segments(
1091
  motion,
1092
  valve,
 
1095
  current_x,
1096
  current_y,
1097
  prefer_default=not raster_origin_initialized,
1098
+ scan_anchor=axis_anchor,
1099
+ infill_keep=infill_keep,
1100
  )
1101
 
1102
  if not segments:
 
1112
  active_contour_owner,
1113
  origin_x,
1114
  origin_y,
1115
+ shared_motion,
1116
  )
1117
  current_x, current_y = _append_relative_move(
1118
  gcode_list,
 
1187
  active_contour_owner,
1188
  origin_x,
1189
  origin_y,
1190
+ shared_motion,
1191
  )
1192
  current_x, current_y = _append_relative_move(
1193
  gcode_list,
 
1198
  0,
1199
  )
1200
 
1201
+ return gcode_list, (origin_x, origin_y)
1202
 
1203
 
1204
  def _stack_center(stack: LayerStack) -> tuple[float, float]:
 
1206
  return ((x_min + x_max) / 2.0, (y_min + y_max) / 2.0)
1207
 
1208
 
1209
+ def _snap_to_grid(value: float, grid: float | None) -> float:
1210
+ if not grid or grid <= 0.0:
1211
+ return value
1212
+ # Half-up, not Python's banker's rounding: values that differ by exact
1213
+ # grid multiples must snap to results that differ by the same multiples
1214
+ # (banker's rounds exact halves toward even, breaking that translation
1215
+ # invariance and with it uniform split-piece spacing).
1216
+ return math.floor(value / grid + 0.5 + 1e-9) * grid
1217
+
1218
+
1219
  def _centering_delta(stack: LayerStack, reference: LayerStack) -> tuple[float, float]:
1220
+ """Translation that aligns `stack` into `reference`'s shared frame.
1221
+
1222
+ Uses the alignment target and snap grid stamped on the reference by
1223
+ `build_reference_stack`, so re-deriving the delta later reproduces the
1224
+ exact translation the reference was built with. Snapping the delta to the
1225
+ fil grid keeps every shape's world scan-grid phase intact, so split
1226
+ pieces printed with shared reference motion still tile at an exact
1227
+ one-fil-width line pitch.
1228
+
1229
+ Split siblings (stacks sharing the reference's scan frame) are aligned by
1230
+ their cell corner within that frame instead of their centre: with cells
1231
+ sized in whole grid multiples the deltas — and with them the required
1232
+ nozzle spacing — come out uniform across all pieces, whereas snapping the
1233
+ centres would wobble by up to one fil where the last cell's width (and so
1234
+ its centre phase) differs.
1235
+ """
1236
+ grid = reference.align_grid
1237
+ if (
1238
+ grid
1239
+ and stack.scan_frame is not None
1240
+ and stack.scan_frame == reference.scan_frame
1241
+ ):
1242
+ (stack_min_x, stack_min_y, _sz), _stack_max = (
1243
+ stack.bounds[0],
1244
+ stack.bounds[1],
1245
+ )
1246
+ frame_min_x, frame_min_y = stack.scan_frame[0], stack.scan_frame[1]
1247
+ return (
1248
+ _snap_to_grid(frame_min_x - stack_min_x, grid),
1249
+ _snap_to_grid(frame_min_y - stack_min_y, grid),
1250
+ )
1251
+
1252
+ if reference.align_center is not None:
1253
+ reference_x, reference_y = reference.align_center
1254
+ else:
1255
+ reference_x, reference_y = _stack_center(reference)
1256
  center_x, center_y = _stack_center(stack)
1257
+ return (
1258
+ _snap_to_grid(reference_x - center_x, grid),
1259
+ _snap_to_grid(reference_y - center_y, grid),
1260
+ )
1261
 
1262
 
1263
  def align_stack_to(
 
1280
  return layers
1281
 
1282
 
1283
+ def build_reference_stack(
1284
+ stacks: list[LayerStack | None],
1285
+ grid: float | None = None,
1286
+ ) -> LayerStack | None:
1287
  """Union all shapes into one shared motion stack, bbox-centres aligned.
1288
 
1289
  Vector analog of the old centered "black wins" TIFF merge: every stack is
1290
  translated so its XY bbox centre lands on the first stack's centre, then
1291
  each layer is the union of the translated layers.
1292
+
1293
+ `grid` (the fil width) snaps each translation to grid multiples so every
1294
+ shape's world scan-grid phase survives the alignment — required for split
1295
+ pieces to keep an exact one-fil line pitch under shared reference motion.
1296
+ The alignment target and grid are stamped on the result so later
1297
+ `_centering_delta` calls reproduce the exact same translations.
1298
  """
1299
  valid = [stack for stack in stacks if stack is not None and stack.layers]
1300
  if not valid:
 
1302
 
1303
  n_layers = max(len(stack.layers) for stack in valid)
1304
  reference_x, reference_y = _stack_center(valid[0])
1305
+ target = LayerStack(
1306
+ layers=[],
1307
+ z_values=[],
1308
+ bounds=((0.0, 0.0, 0.0), (0.0, 0.0, 0.0)),
1309
+ layer_height=valid[0].layer_height,
1310
+ scan_frame=valid[0].scan_frame,
1311
+ align_center=(reference_x, reference_y),
1312
+ align_grid=grid,
1313
+ )
1314
 
1315
  layer_parts: list[list[MultiPolygon]] = [[] for _ in range(n_layers)]
1316
  x_min = y_min = z_min = math.inf
1317
  x_max = y_max = z_max = -math.inf
1318
 
1319
  for stack in valid:
1320
+ delta_x, delta_y = _centering_delta(stack, target)
 
 
1321
 
1322
  (sx_min, sy_min, sz_min), (sx_max, sy_max, sz_max) = stack.bounds
1323
  x_min = min(x_min, sx_min + delta_x)
 
1346
  break
1347
  z_values.append(z_value)
1348
 
1349
+ # The first stack's delta is zero (snap(0) == 0), so its scan frame is
1350
+ # already in the shared frame and anchors the reference's scan grid.
1351
  return LayerStack(
1352
  layers=layers,
1353
  z_values=z_values,
1354
  bounds=((x_min, y_min, z_min), (x_max, y_max, z_max)),
1355
  layer_height=valid[0].layer_height,
1356
  name="reference",
1357
+ scan_frame=valid[0].scan_frame,
1358
+ align_center=(reference_x, reference_y),
1359
+ align_grid=grid,
1360
  )
1361
 
1362
 
1363
+ def _base_split_edges(
1364
  lo: float,
1365
  hi: float,
1366
  count: int,
1367
+ grid: float | None = None,
1368
+ ) -> list[float]:
1369
+ """Cell edges for one axis: equal cells, padded to whole grid multiples.
1370
+
1371
+ With `grid` (the fil width), every cell gets the SAME width — the extent
1372
+ divided by `count`, rounded UP to a whole number of grid units — and the
1373
+ leftover becomes blank margin split evenly outside the shape's outer
1374
+ edges. This is the vector analog of the old pixel splitter's padded
1375
+ canvas: identical piece sizes, so the required nozzle spacing is one cell
1376
+ everywhere, including under shared reference motion.
1377
+ """
1378
+ extent = hi - lo
1379
+ if grid and grid > 0.0 and count > 1 and extent > 0.0:
1380
+ units = max(1, math.ceil(extent / (count * grid) - 1e-9))
1381
+ cell = units * grid
1382
+ pad = cell * count - extent
1383
+ start = lo - pad / 2.0
1384
+ return [start + index * cell for index in range(count + 1)]
1385
+ return [lo + index * extent / count for index in range(count + 1)]
1386
+
1387
+
1388
+ def _shifted_split_edges(
1389
+ edges: list[float],
1390
  layer_index: int,
1391
  overlap: float,
1392
  ) -> list[float]:
1393
+ """Per-layer cell edges; interior ones alternate by ±overlap per layer."""
1394
+ count = len(edges) - 1
1395
  if overlap <= 0.0 or count <= 1:
1396
+ return list(edges)
1397
 
1398
  adjusted = list(edges)
1399
  for boundary_index in range(1, count):
 
1415
  rows: int,
1416
  overlapping_layers: bool = False,
1417
  overlap: float = 0.0,
1418
+ grid: float | None = None,
1419
  ) -> list[LayerStack]:
1420
  """Split a sliced shape into a rows x columns grid of piece stacks.
1421
 
1422
  Pieces are returned row-major with row 1 the top strip (max-Y side),
1423
  matching the legacy image-grid ordering. With `overlapping_layers`, the
1424
  interior cut lines alternate by ±overlap between layers so neighbouring
1425
+ pieces interlock. `grid` (the fil width) sizes the cells in whole grid
1426
+ multiples (see `_base_split_edges`). Piece `bounds` are the nominal
1427
+ (un-shifted) cell boxes.
1428
  """
1429
  columns = max(1, int(columns))
1430
  rows = max(1, int(rows))
 
1433
  overlap_x = overlap if (overlapping_layers and columns > 1) else 0.0
1434
  overlap_y = overlap if (overlapping_layers and rows > 1) else 0.0
1435
 
1436
+ base_x_edges = _base_split_edges(x_min, x_max, columns, grid)
1437
+ base_y_edges = _base_split_edges(y_min, y_max, rows, grid)
1438
  layer_x_edges = [
1439
+ _shifted_split_edges(base_x_edges, index, overlap_x)
1440
  for index in range(len(stack.layers))
1441
  ]
1442
  layer_y_edges = [
1443
+ _shifted_split_edges(base_y_edges, index, overlap_y)
1444
  for index in range(len(stack.layers))
1445
  ]
1446
 
1447
+ # Pieces inherit the parent's scan frame so they all raster on ONE
1448
+ # continuous line grid: the assembled seams then keep an exact
1449
+ # one-fil-width pitch instead of each piece re-centring its own lines.
1450
+ scan_frame = stack.scan_frame or (x_min, y_min, x_max, y_max)
1451
+
1452
  base_name = stack.name or "shape"
1453
  pieces: list[LayerStack] = []
1454
  for row_index in range(1, rows + 1):
 
1482
  ),
1483
  layer_height=stack.layer_height,
1484
  name=f"{base_name}_r{row_index:02d}_c{col_index:02d}",
1485
+ scan_frame=scan_frame,
1486
  )
1487
  )
1488