CyGuy8 commited on
Commit
017ca97
·
1 Parent(s): 0a99fd0

Add lead-in and advanced split alignment controls

Browse files
README.md CHANGED
@@ -52,6 +52,7 @@ Then open the local Gradio URL in your browser, upload STL files or load the bun
52
  - Automatically combines generated stacks into a reference TIFF stack when TIFF stacks are generated
53
  - Splits one generated TIFF stack into an editable row/column grid for multi-nozzle printing of one large shape
54
  - Converts generated TIFF ZIPs into G-code files with pressure, valve, nozzle, and port settings per shape from the Shape Settings table
 
55
  - Offers G-code generation options for raster pattern, **Use G1 for all moves** (no rapid travel command), and **Use Reference Stack for motion** (all shapes share one nozzle path; each dispenses only its own geometry)
56
  - Calculates X/Y nozzle spacing from an editable adjacent-pair spacing table, then visualizes the resulting nozzle layout
57
  - Previews selected generated G-code inline
@@ -75,7 +76,7 @@ When you click **Generate TIFF Stacks**, the app automatically combines availabl
75
 
76
  The **Multi-Nozzle Split** accordion on the **STL to TIFF Slicer** tab can split one generated shape stack into a grid of print-ready stacks. Choose a source shape that already has TIFF slices, set the number of columns and rows, choose the starting nozzle and valve numbers, then click **Split Selected Shape into Grid Pieces**.
77
 
78
- - Each slice is split into columns along X and rows along Y; leftover pixels are assigned to earlier columns/rows so no pixels are dropped.
79
  - The selected shape is replaced in Shape Settings by one generated record per grid cell, named by row and column.
80
  - Nozzle and valve numbers are assigned sequentially from the starting values, and the existing **TIFF Slices to GCode** tab can generate separate G-code for each piece.
81
 
@@ -93,6 +94,8 @@ The **Multi-Nozzle Split** accordion on the **STL to TIFF Slicer** tab can split
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 Reference Stack for motion**: when enabled, every shape's snake-path *motion* is taken from the combined Reference TIFF Stack while each shape's *valve/dispensing* comes from its own slices — so parallel print heads share one synchronized nozzle path and each deposits only its own geometry. The reference stack is generated automatically with TIFF stacks; shapes are skipped with a message if it is missing.
95
  - **Raster Pattern**: `X-direction raster` keeps the existing X-direction back-and-forth raster on every layer. `Y-direction raster` rasters every layer in Y. `Woodpile raster` alternates the raster axis by layer, switching between X-direction and Y-direction sweeps.
 
 
96
 
97
  ### Print vs Travel Classification
98
 
 
52
  - Automatically combines generated stacks into a reference TIFF stack when TIFF stacks are generated
53
  - Splits one generated TIFF stack into an editable row/column grid for multi-nozzle printing of one large shape
54
  - Converts generated TIFF ZIPs into G-code files with pressure, valve, nozzle, and port settings per shape from the Shape Settings table
55
+ - Appends a shape-optimized outer contour after each enabled shape layer by tracing that layer's active row envelope
56
  - Offers G-code generation options for raster pattern, **Use G1 for all moves** (no rapid travel command), and **Use Reference Stack for motion** (all shapes share one nozzle path; each dispenses only its own geometry)
57
  - Calculates X/Y nozzle spacing from an editable adjacent-pair spacing table, then visualizes the resulting nozzle layout
58
  - Previews selected generated G-code inline
 
76
 
77
  The **Multi-Nozzle Split** accordion on the **STL to TIFF Slicer** tab can split one generated shape stack into a grid of print-ready stacks. Choose a source shape that already has TIFF slices, set the number of columns and rows, choose the starting nozzle and valve numbers, then click **Split Selected Shape into Grid Pieces**.
78
 
79
+ - Each slice is padded with white pixels as needed, then split into equal-width columns and equal-height rows so every generated piece in the grid has a matching TIFF canvas.
80
  - The selected shape is replaced in Shape Settings by one generated record per grid cell, named by row and column.
81
  - Nozzle and valve numbers are assigned sequentially from the starting values, and the existing **TIFF Slices to GCode** tab can generate separate G-code for each piece.
82
 
 
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 Reference Stack for motion**: when enabled, every shape's snake-path *motion* is taken from the combined Reference TIFF Stack while each shape's *valve/dispensing* comes from its own slices — so parallel print heads share one synchronized nozzle path and each deposits only its own geometry. The reference stack is generated automatically with TIFF stacks; shapes are skipped with a message if it is missing.
96
  - **Raster Pattern**: `X-direction raster` keeps the existing X-direction back-and-forth raster on every layer. `Y-direction raster` rasters every layer in Y. `Woodpile raster` alternates the raster axis by layer, switching between X-direction and Y-direction sweeps.
97
+ - **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.
98
+ - **Contour Tracing**: enabled per row in Shape Settings. The app uses the shape-optimized row-envelope tracer, travels from the layer raster end to the nearest contour point, prints the contour, then returns to the raster endpoint before the next layer.
99
 
100
  ### Print vs Travel Classification
101
 
app.py CHANGED
@@ -38,8 +38,10 @@ from stl_slicer import (
38
  slice_stl_to_tiffs,
39
  )
40
  from tiff_to_gcode import (
41
- RASTER_PATTERN_CHOICES,
 
42
  RASTER_PATTERN_SAME_DIRECTION,
 
43
  generate_snake_path_gcode,
44
  )
45
 
@@ -67,6 +69,8 @@ NOZZLE_LAYOUT_PRESETS = [
67
  "2 x 5",
68
  "5 x 2",
69
  ]
 
 
70
  APP_CSS = """
71
  .gradio-container {
72
  font-size: 90%;
@@ -149,10 +153,15 @@ APP_CSS = """
149
  }
150
 
151
  #nozzle-spacing-table table tbody tr td:nth-child(-n+2),
 
152
  #nozzle-spacing-table table thead th,
 
153
  #nozzle-spacing-table [role="columnheader"],
 
154
  #nozzle-spacing-table [role="gridcell"]:nth-child(4n + 1),
155
- #nozzle-spacing-table [role="gridcell"]:nth-child(4n + 2) {
 
 
156
  background: rgba(243, 244, 246, 0.82) !important;
157
  color: var(--body-text-color-subdued) !important;
158
  pointer-events: none;
@@ -1499,12 +1508,22 @@ def _nozzle_group_bounds(grouped: dict[int, list[dict]], nozzle: int) -> tuple[t
1499
  )
1500
 
1501
 
 
 
 
 
 
 
 
 
1502
  def _resolve_nozzle_grid_layout(
1503
  parts: list[dict],
1504
  columns: Any,
1505
  rows: Any,
1506
  column_spacing: Any,
1507
  row_spacing: Any,
 
 
1508
  ) -> tuple[dict[int, tuple[float, float]], list[dict]]:
1509
  offsets: dict[int, tuple[float, float]] = {}
1510
  spacings: list[dict] = []
@@ -1516,14 +1535,8 @@ def _resolve_nozzle_grid_layout(
1516
  column_count = max(1, _coerce_int(columns, 1))
1517
  requested_rows = max(1, _coerce_int(rows, 1))
1518
  row_count = max(requested_rows, math.ceil(len(ordered_nozzles) / column_count))
1519
- try:
1520
- x_gap = float(column_spacing)
1521
- except (TypeError, ValueError):
1522
- x_gap = 0.0
1523
- try:
1524
- y_gap = float(row_spacing)
1525
- except (TypeError, ValueError):
1526
- y_gap = 0.0
1527
 
1528
  placements = {
1529
  nozzle: (index % column_count, index // column_count)
@@ -1539,6 +1552,53 @@ def _resolve_nozzle_grid_layout(
1539
  column_widths[column] = max(column_widths[column], xmax - xmin)
1540
  row_heights[row] = max(row_heights[row], ymax - ymin)
1541
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1542
  column_positions: list[float] = []
1543
  x_pos = 0.0
1544
  for width in column_widths:
@@ -1574,11 +1634,21 @@ def _resolve_layout_from_spacing_controls(
1574
  rows: Any,
1575
  column_spacing: Any,
1576
  row_spacing: Any,
 
 
1577
  use_individual_spacing: bool,
1578
  spacing_table: Any,
1579
  ) -> tuple[dict[int, tuple[float, float]], list[dict]]:
1580
  if layout_mode != NOZZLE_LAYOUT_PAIR_TABLE:
1581
- return _resolve_nozzle_grid_layout(parts, columns, rows, column_spacing, row_spacing)
 
 
 
 
 
 
 
 
1582
  gap12x, gap12y, gap23x, gap23y, extra = _spacing_args_from_table(spacing_table, use_individual_spacing)
1583
  return _resolve_nozzle_layout(
1584
  parts,
@@ -1791,6 +1861,25 @@ def _partition_length(length: int, count: int) -> list[tuple[int, int]]:
1791
  return spans
1792
 
1793
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1794
  def _layer_split_spans(length: int, count: int, layer_index: int, overlap_pixels: int) -> list[tuple[int, int]]:
1795
  base_spans = _partition_length(length, count)
1796
  if overlap_pixels <= 0 or count <= 1:
@@ -1806,14 +1895,6 @@ def _layer_split_spans(length: int, count: int, layer_index: int, overlap_pixels
1806
  return [(adjusted[index], adjusted[index + 1]) for index in range(count)]
1807
 
1808
 
1809
- def _overlap_canvas_span(start: int, end: int, length: int, position: int, count: int, overlap_pixels: int) -> tuple[int, int]:
1810
- if overlap_pixels <= 0:
1811
- return start, end
1812
- canvas_start = max(0, start - overlap_pixels) if position > 1 else start
1813
- canvas_end = min(length, end + overlap_pixels) if position < count else end
1814
- return canvas_start, canvas_end
1815
-
1816
-
1817
  def split_tiff_stack_grid(
1818
  state: ViewerState,
1819
  base_name: str = "split_shape",
@@ -1837,9 +1918,24 @@ def split_tiff_stack_grid(
1837
 
1838
  safe_name = "".join(ch if ch.isalnum() or ch in {"-", "_"} else "_" for ch in base_name).strip("_") or "split_shape"
1839
  output_dir = Path(tempfile.mkdtemp(prefix=f"{safe_name}_split_"))
1840
- x_spans = _partition_length(width, column_count)
1841
- y_spans = _partition_length(height, row_count)
 
 
1842
  overlap_pixels = 1 if overlapping_layers else 0
 
 
 
 
 
 
 
 
 
 
 
 
 
1843
  pieces: list[dict[str, Any]] = []
1844
  for row_index, (y_start, y_end) in enumerate(y_spans, start=1):
1845
  for col_index, (x_start, x_end) in enumerate(x_spans, start=1):
@@ -1863,53 +1959,82 @@ def split_tiff_stack_grid(
1863
  if layer.size != (width, height):
1864
  raise ValueError("All TIFF slices must have the same dimensions to split the stack.")
1865
 
1866
- layer_x_spans = _layer_split_spans(width, column_count, index, overlap_pixels)
1867
- layer_y_spans = _layer_split_spans(height, row_count, index, overlap_pixels)
1868
- for piece in pieces:
1869
- canvas_x_start, canvas_x_end = _overlap_canvas_span(
1870
- piece["x_start"], piece["x_end"], width, piece["col"], column_count, overlap_pixels
 
 
 
 
1871
  )
1872
- canvas_y_start, canvas_y_end = _overlap_canvas_span(
1873
- piece["y_start"], piece["y_end"], height, piece["row"], row_count, overlap_pixels
 
 
 
 
 
 
1874
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1875
  x_start, x_end = layer_x_spans[piece["col"] - 1]
1876
  y_start, y_end = layer_y_spans[piece["row"] - 1]
1877
  if overlapping_layers:
1878
  piece_image = Image.new("L", (canvas_x_end - canvas_x_start, canvas_y_end - canvas_y_start), 255)
1879
  piece_image.paste(
1880
- layer.crop((x_start, y_start, x_end, y_end)),
1881
  (x_start - canvas_x_start, y_start - canvas_y_start),
1882
  )
1883
  else:
1884
- piece_image = layer.crop((x_start, y_start, x_end, y_end))
1885
  piece_path = piece["tiff_dir"] / f"slice_{index:04d}.tif"
1886
  piece_image.save(piece_path, compression="tiff_deflate")
1887
  piece["tiff_paths"].append(piece_path)
1888
 
1889
- pixel_size = float(state.get("pixel_size", 0.0) or 0.0)
1890
  z_values = list(state.get("z_values", []))
1891
  if len(z_values) < len(tiff_paths):
1892
  z_values.extend([0.0] * (len(tiff_paths) - len(z_values)))
1893
 
1894
- base_x_min = float(state.get("x_min", 0.0) or 0.0)
1895
- base_y_min = float(state.get("y_min", 0.0) or 0.0)
1896
  for piece in pieces:
1897
- canvas_x_start, canvas_x_end = _overlap_canvas_span(
1898
- piece["x_start"], piece["x_end"], width, piece["col"], column_count, overlap_pixels
1899
- )
1900
- canvas_y_start, canvas_y_end = _overlap_canvas_span(
1901
- piece["y_start"], piece["y_end"], height, piece["row"], row_count, overlap_pixels
1902
- )
1903
- piece_width = canvas_x_end - canvas_x_start if overlapping_layers else piece["x_end"] - piece["x_start"]
1904
- piece_height = canvas_y_end - canvas_y_start if overlapping_layers else piece["y_end"] - piece["y_start"]
 
 
 
 
 
 
 
1905
  zip_path = output_dir / f"{safe_name}_r{piece['row']:02d}_c{piece['col']:02d}_tiff_slices.zip"
1906
  _zip_tiff_paths(piece["tiff_paths"], zip_path)
1907
  piece_state: ViewerState = {
1908
  "tiff_paths": [str(path) for path in piece["tiff_paths"]],
1909
  "z_values": z_values[: len(piece["tiff_paths"])],
1910
  "pixel_size": pixel_size,
1911
- "x_min": base_x_min + ((canvas_x_start if overlapping_layers else piece["x_start"]) * pixel_size),
1912
- "y_min": base_y_min + ((height - (canvas_y_end if overlapping_layers else piece["y_end"])) * pixel_size),
1913
  "image_width": piece_width,
1914
  "image_height": piece_height,
1915
  "zip_path": str(zip_path),
@@ -2009,6 +2134,13 @@ def _coerce_int(value: Any, default: int) -> int:
2009
  return 0
2010
 
2011
 
 
 
 
 
 
 
 
2012
  def _record_nozzle_number(record: dict, fallback: int | None = None) -> int:
2013
  default = fallback if fallback is not None else int(record.get("idx", 1) or 1)
2014
  nozzle = _coerce_int(record.get("nozzle", default), default)
@@ -2300,29 +2432,20 @@ def _grid_spacing_rows(
2300
  rows: Any,
2301
  column_spacing: Any,
2302
  row_spacing: Any,
 
2303
  ) -> tuple[list[list[Any]], int, int]:
2304
  ordered_nozzles = _ordered_nozzle_numbers(records)
2305
  column_count = max(1, _coerce_int(columns, 2))
2306
- row_count = max(1, _coerce_int(rows, 1))
2307
- try:
2308
- x_spacing = float(column_spacing)
2309
- except (TypeError, ValueError):
2310
- x_spacing = 5.0
2311
- try:
2312
- y_spacing = float(row_spacing)
2313
- except (TypeError, ValueError):
2314
- y_spacing = 5.0
2315
 
2316
  spacing_rows: list[list[Any]] = []
2317
  for index, (first, second) in enumerate(zip(ordered_nozzles, ordered_nozzles[1:])):
2318
- current_col = index % column_count
2319
- next_col = (index + 1) % column_count
2320
- if next_col > current_col:
2321
- gap_x = x_spacing
2322
- gap_y = 0.0
2323
- else:
2324
- gap_x = -x_spacing * (column_count - 1)
2325
- gap_y = y_spacing
2326
  spacing_rows.append([
2327
  _nozzle_spacing_label(first, records),
2328
  _nozzle_spacing_label(second, records),
@@ -2332,6 +2455,85 @@ def _grid_spacing_rows(
2332
  return spacing_rows, column_count, row_count
2333
 
2334
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2335
  def apply_nozzle_grid_spacing(
2336
  records: list[dict] | None,
2337
  columns: Any,
@@ -2359,6 +2561,64 @@ def apply_nozzle_grid_spacing(
2359
  )
2360
 
2361
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2362
  def update_nozzle_grid_preset(
2363
  preset: str | None,
2364
  records: list[dict] | None,
@@ -2880,6 +3140,9 @@ def split_selected_shape_for_grid(
2880
  base_name = str(source.get("name") or f"Shape {source.get('idx', pos + 1)}")
2881
  first_nozzle = max(1, _coerce_int(starting_nozzle, 1))
2882
  first_valve = max(1, _coerce_int(starting_valve, _coerce_int(source.get("valve", 4), 4)))
 
 
 
2883
  split_records: list[dict] = []
2884
  for index, piece in enumerate(pieces):
2885
  piece_state = piece["state"]
@@ -2893,6 +3156,12 @@ def split_selected_shape_for_grid(
2893
  "target_y": piece_height_mm or source.get("target_y", DEFAULT_TARGET_EXTENTS[1]),
2894
  "nozzle": first_nozzle + index,
2895
  "valve": first_valve + index,
 
 
 
 
 
 
2896
  "tiff_state": piece_state,
2897
  "zip_path": str(piece["zip_path"]),
2898
  "gcode_path": None,
@@ -2935,6 +3204,7 @@ def _contour_tracing_sources(records: list[dict]) -> list[dict]:
2935
  tiff_paths = state.get("tiff_paths") or []
2936
  source = {
2937
  "owner_idx": int(record.get("idx", len(sources) + 1)),
 
2938
  "tiff_paths": list(tiff_paths),
2939
  "zip_path": record.get("zip_path"),
2940
  }
@@ -2949,6 +3219,10 @@ def generate_dynamic_gcode(
2949
  all_g1: bool,
2950
  use_reference_motion: bool,
2951
  raster_pattern: str | None,
 
 
 
 
2952
  ref_state: ViewerState | None,
2953
  layer_height: float,
2954
  pixel_size: float,
@@ -2959,7 +3233,7 @@ def generate_dynamic_gcode(
2959
  messages: list[str] = []
2960
  if contour_sources:
2961
  enabled = ", ".join(f"Shape {source['owner_idx']}" for source in contour_sources)
2962
- messages.append(f"Contour tracing enabled for {enabled}.")
2963
  for record in records:
2964
  zip_path = record.get("zip_path")
2965
  if not zip_path:
@@ -2983,6 +3257,10 @@ def generate_dynamic_gcode(
2983
  raster_pattern=raster_pattern,
2984
  contour_tiff_sets=contour_sources,
2985
  active_contour_owner=int(record.get("idx", 0)),
 
 
 
 
2986
  )
2987
  record["gcode_path"] = str(gcode_path)
2988
  messages.append(f"Shape {record['idx']}: wrote `{gcode_path.name}`.")
@@ -3052,6 +3330,8 @@ def render_dynamic_nozzle_spacing(
3052
  rows: Any,
3053
  column_spacing: Any,
3054
  row_spacing: Any,
 
 
3055
  use_individual_spacing: bool,
3056
  spacing_table: Any,
3057
  ) -> tuple[Any, str]:
@@ -3065,6 +3345,8 @@ def render_dynamic_nozzle_spacing(
3065
  rows,
3066
  column_spacing,
3067
  row_spacing,
 
 
3068
  use_individual_spacing,
3069
  spacing_table,
3070
  )
@@ -3142,6 +3424,8 @@ def render_dynamic_parallel(
3142
  rows: Any,
3143
  column_spacing: Any,
3144
  row_spacing: Any,
 
 
3145
  use_individual_spacing: bool,
3146
  spacing_table: Any,
3147
  tube: bool = True,
@@ -3157,6 +3441,8 @@ def render_dynamic_parallel(
3157
  rows,
3158
  column_spacing,
3159
  row_spacing,
 
 
3160
  use_individual_spacing,
3161
  spacing_table,
3162
  )
@@ -3197,6 +3483,8 @@ def export_dynamic_parallel_gif(
3197
  rows: Any,
3198
  column_spacing: Any,
3199
  row_spacing: Any,
 
 
3200
  use_individual_spacing: bool,
3201
  spacing_table: Any,
3202
  duration: float,
@@ -3216,6 +3504,8 @@ def export_dynamic_parallel_gif(
3216
  rows,
3217
  column_spacing,
3218
  row_spacing,
 
 
3219
  use_individual_spacing,
3220
  spacing_table,
3221
  )
@@ -3369,6 +3659,11 @@ def build_dynamic_demo() -> gr.Blocks:
3369
  value=RASTER_PATTERN_SAME_DIRECTION,
3370
  allow_custom_value=False,
3371
  )
 
 
 
 
 
3372
  gcode_button = gr.Button("Generate G-Code", variant="primary")
3373
  gcode_downloads = gr.File(label="Download G-Code Files", file_count="multiple", interactive=False, elem_classes=["gcode-download"])
3374
  gcode_status = gr.Markdown("")
@@ -3395,6 +3690,19 @@ def build_dynamic_demo() -> gr.Blocks:
3395
  nozzle_grid_rows = gr.Number(label="Grid Rows", value=2, minimum=1, step=1)
3396
  nozzle_grid_column_spacing = gr.Number(label="Column Gap (X, mm)", value=0.0, step=0.1)
3397
  nozzle_grid_row_spacing = gr.Number(label="Row Gap (Y, mm)", value=0.0, step=0.1)
 
 
 
 
 
 
 
 
 
 
 
 
 
3398
  with gr.Group(visible=False) as nozzle_custom_group:
3399
  nozzle_use_individual_spacing = gr.Checkbox(label="Use Different Values for Each Nozzle Connection", value=False)
3400
  nozzle_spacing_table = gr.Dataframe(
@@ -3489,24 +3797,49 @@ def build_dynamic_demo() -> gr.Blocks:
3489
 
3490
  parallel_mode = gr.State("tube")
3491
 
 
 
 
 
 
 
 
 
 
 
3492
  shape_sync_outputs = [shape_records, shape_settings, nozzle_spacing_table, selected_shape, gcode_text_source, gcode_source, tiff_downloads, gcode_downloads]
3493
  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(
3494
  fn=lambda records: _dropdown_update(records),
3495
  inputs=[shape_records],
3496
  outputs=[split_source],
3497
  queue=False,
 
 
 
 
 
3498
  )
3499
  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(
3500
  fn=lambda records: _dropdown_update(records),
3501
  inputs=[shape_records],
3502
  outputs=[split_source],
3503
  queue=False,
 
 
 
 
 
3504
  )
3505
  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(
3506
  fn=lambda records: _dropdown_update(records),
3507
  inputs=[shape_records],
3508
  outputs=[split_source],
3509
  queue=False,
 
 
 
 
 
3510
  )
3511
  shape_settings.select(
3512
  fn=delete_shape_from_settings,
@@ -3517,6 +3850,11 @@ def build_dynamic_demo() -> gr.Blocks:
3517
  inputs=[shape_records],
3518
  outputs=[split_source],
3519
  queue=False,
 
 
 
 
 
3520
  )
3521
 
3522
  preview_inputs = [shape_records, selected_shape, shape_settings, model_opacity, scale_mode]
@@ -3525,6 +3863,11 @@ def build_dynamic_demo() -> gr.Blocks:
3525
  inputs=[shape_records, shape_settings, scale_mode, nozzle_spacing_table, nozzle_use_individual_spacing],
3526
  outputs=[shape_records, shape_settings, nozzle_spacing_table],
3527
  queue=False,
 
 
 
 
 
3528
  )
3529
  selected_shape.change(fn=show_selected_model, inputs=preview_inputs, outputs=[model_viewer, model_details, slice_slider, slice_label, slice_preview])
3530
  refresh_preview_button.click(fn=show_selected_model, inputs=preview_inputs, outputs=[model_viewer, model_details, slice_slider, slice_label, slice_preview])
@@ -3616,6 +3959,11 @@ def build_dynamic_demo() -> gr.Blocks:
3616
  fn=generate_dynamic_reference_stack,
3617
  inputs=[shape_records],
3618
  outputs=[ref_state, ref_slice_slider, ref_slice_label, ref_slice_preview],
 
 
 
 
 
3619
  )
3620
  split_piece_source.change(fn=preview_selected_split_piece, inputs=[split_piece_states, split_piece_source], outputs=[split_piece_slider, split_piece_label, split_piece_preview], queue=False)
3621
  split_piece_slider.release(fn=jump_to_selected_split_piece, inputs=[split_piece_states, split_piece_source, split_piece_slider], outputs=[split_piece_label, split_piece_preview], queue=False)
@@ -3624,7 +3972,20 @@ def build_dynamic_demo() -> gr.Blocks:
3624
 
3625
  gcode_button.click(
3626
  fn=generate_dynamic_gcode,
3627
- inputs=[shape_records, shape_settings, gcode_all_g1, gcode_use_ref_motion, gcode_raster_pattern, ref_state, layer_height, pixel_size],
 
 
 
 
 
 
 
 
 
 
 
 
 
3628
  outputs=[shape_records, gcode_downloads, gcode_status, gcode_text_source, gcode_source],
3629
  )
3630
  gcode_text_source.change(fn=load_selected_gcode_text, inputs=[shape_records, gcode_text_source], outputs=[gcode_text])
@@ -3635,12 +3996,52 @@ def build_dynamic_demo() -> gr.Blocks:
3635
  outputs=[nozzle_grid_group, nozzle_custom_group],
3636
  queue=False,
3637
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3638
  nozzle_grid_preset.change(
3639
  fn=update_nozzle_grid_preset,
3640
  inputs=[nozzle_grid_preset, shape_records, nozzle_grid_columns, nozzle_grid_rows],
3641
  outputs=[nozzle_grid_columns, nozzle_grid_rows],
3642
  queue=False,
 
 
 
 
 
3643
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
3644
  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)
3645
  nozzle_preview_button.click(
3646
  fn=render_dynamic_nozzle_spacing,
@@ -3651,6 +4052,8 @@ def build_dynamic_demo() -> gr.Blocks:
3651
  nozzle_grid_rows,
3652
  nozzle_grid_column_spacing,
3653
  nozzle_grid_row_spacing,
 
 
3654
  nozzle_use_individual_spacing,
3655
  nozzle_spacing_table,
3656
  ],
@@ -3704,6 +4107,8 @@ def build_dynamic_demo() -> gr.Blocks:
3704
  nozzle_grid_rows,
3705
  nozzle_grid_column_spacing,
3706
  nozzle_grid_row_spacing,
 
 
3707
  nozzle_use_individual_spacing,
3708
  nozzle_spacing_table,
3709
  ]
@@ -3723,6 +4128,8 @@ def build_dynamic_demo() -> gr.Blocks:
3723
  nozzle_grid_rows,
3724
  nozzle_grid_column_spacing,
3725
  nozzle_grid_row_spacing,
 
 
3726
  nozzle_use_individual_spacing,
3727
  nozzle_spacing_table,
3728
  pp_gif_duration,
 
38
  slice_stl_to_tiffs,
39
  )
40
  from tiff_to_gcode import (
41
+ CONTOUR_MODE_ROW_ENVELOPE,
42
+ RASTER_PATTERN_CHOICES,
43
  RASTER_PATTERN_SAME_DIRECTION,
44
+ RASTER_PATTERN_Y_DIRECTION,
45
  generate_snake_path_gcode,
46
  )
47
 
 
69
  "2 x 5",
70
  "5 x 2",
71
  ]
72
+ AUTO_ALIGN_X_RASTER_OFFSETS = (-3.2, -0.8)
73
+ AUTO_ALIGN_Y_RASTER_OFFSETS = (-0.8, -3.2)
74
  APP_CSS = """
75
  .gradio-container {
76
  font-size: 90%;
 
153
  }
154
 
155
  #nozzle-spacing-table table tbody tr td:nth-child(-n+2),
156
+ #nozzle-grid-spacing-table table tbody tr td:nth-child(-n+2),
157
  #nozzle-spacing-table table thead th,
158
+ #nozzle-grid-spacing-table table thead th,
159
  #nozzle-spacing-table [role="columnheader"],
160
+ #nozzle-grid-spacing-table [role="columnheader"],
161
  #nozzle-spacing-table [role="gridcell"]:nth-child(4n + 1),
162
+ #nozzle-spacing-table [role="gridcell"]:nth-child(4n + 2),
163
+ #nozzle-grid-spacing-table [role="gridcell"]:nth-child(4n + 1),
164
+ #nozzle-grid-spacing-table [role="gridcell"]:nth-child(4n + 2) {
165
  background: rgba(243, 244, 246, 0.82) !important;
166
  color: var(--body-text-color-subdued) !important;
167
  pointer-events: none;
 
1508
  )
1509
 
1510
 
1511
+ def _grid_default_gap_for_pair(pair_index: int, column_count: int, x_gap: float, y_gap: float) -> tuple[float, float]:
1512
+ current_col = pair_index % column_count
1513
+ next_col = (pair_index + 1) % column_count
1514
+ if next_col > current_col:
1515
+ return x_gap, 0.0
1516
+ return 0.0, y_gap
1517
+
1518
+
1519
  def _resolve_nozzle_grid_layout(
1520
  parts: list[dict],
1521
  columns: Any,
1522
  rows: Any,
1523
  column_spacing: Any,
1524
  row_spacing: Any,
1525
+ use_individual_spacing: bool | None = False,
1526
+ spacing_table: Any | None = None,
1527
  ) -> tuple[dict[int, tuple[float, float]], list[dict]]:
1528
  offsets: dict[int, tuple[float, float]] = {}
1529
  spacings: list[dict] = []
 
1535
  column_count = max(1, _coerce_int(columns, 1))
1536
  requested_rows = max(1, _coerce_int(rows, 1))
1537
  row_count = max(requested_rows, math.ceil(len(ordered_nozzles) / column_count))
1538
+ x_gap = _coerce_float(column_spacing, 0.0)
1539
+ y_gap = _coerce_float(row_spacing, 0.0)
 
 
 
 
 
 
1540
 
1541
  placements = {
1542
  nozzle: (index % column_count, index // column_count)
 
1552
  column_widths[column] = max(column_widths[column], xmax - xmin)
1553
  row_heights[row] = max(row_heights[row], ymax - ymin)
1554
 
1555
+ if use_individual_spacing:
1556
+ gap_pairs = _spacing_pairs_from_table(spacing_table)
1557
+
1558
+ def pair_gap(pair_index: int) -> tuple[float, float]:
1559
+ if pair_index < len(gap_pairs):
1560
+ return gap_pairs[pair_index]
1561
+ return _grid_default_gap_for_pair(pair_index, column_count, x_gap, y_gap)
1562
+
1563
+ row_start_x = 0.0
1564
+ row_min_y = 0.0
1565
+ for row in range(row_count):
1566
+ row_start_index = row * column_count
1567
+ row_nozzles = ordered_nozzles[row_start_index:row_start_index + column_count]
1568
+ if not row_nozzles:
1569
+ break
1570
+ for col, nozzle in enumerate(row_nozzles):
1571
+ (xmin, ymin, _), _ = bounds_by_nozzle[nozzle]
1572
+ if col == 0:
1573
+ target_x = row_start_x
1574
+ target_y = row_min_y
1575
+ else:
1576
+ prev = row_nozzles[col - 1]
1577
+ gap_x, gap_y = pair_gap(row_start_index + col - 1)
1578
+ prev_offset_x, _prev_offset_y = offsets[prev]
1579
+ (_, _, _), (prev_xmax, _prev_ymax, _) = bounds_by_nozzle[prev]
1580
+ target_x = prev_offset_x + prev_xmax + gap_x
1581
+ target_y = row_min_y + gap_y
1582
+ offsets[nozzle] = (target_x - xmin, target_y - ymin)
1583
+
1584
+ row_bottom = max(offsets[nozzle][1] + bounds_by_nozzle[nozzle][1][1] for nozzle in row_nozzles)
1585
+ next_row_start_index = row_start_index + len(row_nozzles)
1586
+ if next_row_start_index < len(ordered_nozzles):
1587
+ row_shift_x, row_gap = pair_gap(next_row_start_index - 1)
1588
+ row_start_x += row_shift_x
1589
+ row_min_y = row_bottom + row_gap
1590
+
1591
+ for first, second in zip(ordered_nozzles, ordered_nozzles[1:]):
1592
+ first_x, first_y = offsets[first]
1593
+ second_x, second_y = offsets[second]
1594
+ spacings.append({
1595
+ "from": first,
1596
+ "to": second,
1597
+ "dx": second_x - first_x,
1598
+ "dy": second_y - first_y,
1599
+ })
1600
+ return offsets, spacings
1601
+
1602
  column_positions: list[float] = []
1603
  x_pos = 0.0
1604
  for width in column_widths:
 
1634
  rows: Any,
1635
  column_spacing: Any,
1636
  row_spacing: Any,
1637
+ use_grid_individual_spacing: bool | None,
1638
+ grid_spacing_table: Any,
1639
  use_individual_spacing: bool,
1640
  spacing_table: Any,
1641
  ) -> tuple[dict[int, tuple[float, float]], list[dict]]:
1642
  if layout_mode != NOZZLE_LAYOUT_PAIR_TABLE:
1643
+ return _resolve_nozzle_grid_layout(
1644
+ parts,
1645
+ columns,
1646
+ rows,
1647
+ column_spacing,
1648
+ row_spacing,
1649
+ use_grid_individual_spacing,
1650
+ grid_spacing_table,
1651
+ )
1652
  gap12x, gap12y, gap23x, gap23y, extra = _spacing_args_from_table(spacing_table, use_individual_spacing)
1653
  return _resolve_nozzle_layout(
1654
  parts,
 
1861
  return spans
1862
 
1863
 
1864
+ def _padded_grid_axis(length: int, count: int) -> dict[str, Any]:
1865
+ cell_size = max(1, math.ceil(length / count))
1866
+ padded_length = cell_size * count
1867
+ total_pad = padded_length - length
1868
+ leading_pad = total_pad // 2
1869
+ trailing_pad = total_pad - leading_pad
1870
+ spans = [
1871
+ (index * cell_size, (index + 1) * cell_size)
1872
+ for index in range(count)
1873
+ ]
1874
+ return {
1875
+ "cell_size": cell_size,
1876
+ "padded_length": padded_length,
1877
+ "leading_pad": leading_pad,
1878
+ "trailing_pad": trailing_pad,
1879
+ "spans": spans,
1880
+ }
1881
+
1882
+
1883
  def _layer_split_spans(length: int, count: int, layer_index: int, overlap_pixels: int) -> list[tuple[int, int]]:
1884
  base_spans = _partition_length(length, count)
1885
  if overlap_pixels <= 0 or count <= 1:
 
1895
  return [(adjusted[index], adjusted[index + 1]) for index in range(count)]
1896
 
1897
 
 
 
 
 
 
 
 
 
1898
  def split_tiff_stack_grid(
1899
  state: ViewerState,
1900
  base_name: str = "split_shape",
 
1918
 
1919
  safe_name = "".join(ch if ch.isalnum() or ch in {"-", "_"} else "_" for ch in base_name).strip("_") or "split_shape"
1920
  output_dir = Path(tempfile.mkdtemp(prefix=f"{safe_name}_split_"))
1921
+ x_axis = _padded_grid_axis(width, column_count)
1922
+ y_axis = _padded_grid_axis(height, row_count)
1923
+ x_spans = x_axis["spans"]
1924
+ y_spans = y_axis["spans"]
1925
  overlap_pixels = 1 if overlapping_layers else 0
1926
+ overlap_x = overlap_pixels if column_count > 1 else 0
1927
+ overlap_y = overlap_pixels if row_count > 1 else 0
1928
+ padded_width = int(x_axis["padded_length"])
1929
+ padded_height = int(y_axis["padded_length"])
1930
+ working_width = padded_width + (2 * overlap_x)
1931
+ working_height = padded_height + (2 * overlap_y)
1932
+ source_x = int(x_axis["leading_pad"]) + overlap_x
1933
+ source_y = int(y_axis["leading_pad"]) + overlap_y
1934
+ working_x_min = base_x_min = float(state.get("x_min", 0.0) or 0.0)
1935
+ working_y_min = base_y_min = float(state.get("y_min", 0.0) or 0.0)
1936
+ pixel_size = float(state.get("pixel_size", 0.0) or 0.0)
1937
+ working_x_min = base_x_min - (source_x * pixel_size)
1938
+ working_y_min = base_y_min - ((int(y_axis["trailing_pad"]) + overlap_y) * pixel_size)
1939
  pieces: list[dict[str, Any]] = []
1940
  for row_index, (y_start, y_end) in enumerate(y_spans, start=1):
1941
  for col_index, (x_start, x_end) in enumerate(x_spans, start=1):
 
1959
  if layer.size != (width, height):
1960
  raise ValueError("All TIFF slices must have the same dimensions to split the stack.")
1961
 
1962
+ padded_layer = Image.new("L", (working_width, working_height), 255)
1963
+ padded_layer.paste(layer, (source_x, source_y))
1964
+ layer_x_spans = [
1965
+ (x_start + overlap_x, x_end + overlap_x)
1966
+ for x_start, x_end in _layer_split_spans(
1967
+ padded_width,
1968
+ column_count,
1969
+ index,
1970
+ overlap_x,
1971
  )
1972
+ ]
1973
+ layer_y_spans = [
1974
+ (y_start + overlap_y, y_end + overlap_y)
1975
+ for y_start, y_end in _layer_split_spans(
1976
+ padded_height,
1977
+ row_count,
1978
+ index,
1979
+ overlap_y,
1980
  )
1981
+ ]
1982
+ for piece in pieces:
1983
+ canvas_x_start = piece["x_start"]
1984
+ canvas_x_end = piece["x_end"]
1985
+ canvas_y_start = piece["y_start"]
1986
+ canvas_y_end = piece["y_end"]
1987
+ if overlapping_layers:
1988
+ canvas_x_start -= overlap_x
1989
+ canvas_x_end += overlap_x
1990
+ canvas_y_start -= overlap_y
1991
+ canvas_y_end += overlap_y
1992
+ canvas_x_start += overlap_x
1993
+ canvas_x_end += overlap_x
1994
+ canvas_y_start += overlap_y
1995
+ canvas_y_end += overlap_y
1996
  x_start, x_end = layer_x_spans[piece["col"] - 1]
1997
  y_start, y_end = layer_y_spans[piece["row"] - 1]
1998
  if overlapping_layers:
1999
  piece_image = Image.new("L", (canvas_x_end - canvas_x_start, canvas_y_end - canvas_y_start), 255)
2000
  piece_image.paste(
2001
+ padded_layer.crop((x_start, y_start, x_end, y_end)),
2002
  (x_start - canvas_x_start, y_start - canvas_y_start),
2003
  )
2004
  else:
2005
+ piece_image = padded_layer.crop((x_start, y_start, x_end, y_end))
2006
  piece_path = piece["tiff_dir"] / f"slice_{index:04d}.tif"
2007
  piece_image.save(piece_path, compression="tiff_deflate")
2008
  piece["tiff_paths"].append(piece_path)
2009
 
 
2010
  z_values = list(state.get("z_values", []))
2011
  if len(z_values) < len(tiff_paths):
2012
  z_values.extend([0.0] * (len(tiff_paths) - len(z_values)))
2013
 
 
 
2014
  for piece in pieces:
2015
+ canvas_x_start = piece["x_start"]
2016
+ canvas_x_end = piece["x_end"]
2017
+ canvas_y_start = piece["y_start"]
2018
+ canvas_y_end = piece["y_end"]
2019
+ if overlapping_layers:
2020
+ canvas_x_start -= overlap_x
2021
+ canvas_x_end += overlap_x
2022
+ canvas_y_start -= overlap_y
2023
+ canvas_y_end += overlap_y
2024
+ canvas_x_start += overlap_x
2025
+ canvas_x_end += overlap_x
2026
+ canvas_y_start += overlap_y
2027
+ canvas_y_end += overlap_y
2028
+ piece_width = canvas_x_end - canvas_x_start
2029
+ piece_height = canvas_y_end - canvas_y_start
2030
  zip_path = output_dir / f"{safe_name}_r{piece['row']:02d}_c{piece['col']:02d}_tiff_slices.zip"
2031
  _zip_tiff_paths(piece["tiff_paths"], zip_path)
2032
  piece_state: ViewerState = {
2033
  "tiff_paths": [str(path) for path in piece["tiff_paths"]],
2034
  "z_values": z_values[: len(piece["tiff_paths"])],
2035
  "pixel_size": pixel_size,
2036
+ "x_min": working_x_min + (canvas_x_start * pixel_size),
2037
+ "y_min": working_y_min + ((working_height - canvas_y_end) * pixel_size),
2038
  "image_width": piece_width,
2039
  "image_height": piece_height,
2040
  "zip_path": str(zip_path),
 
2134
  return 0
2135
 
2136
 
2137
+ def _coerce_float(value: Any, default: float) -> float:
2138
+ try:
2139
+ return float(value)
2140
+ except (TypeError, ValueError):
2141
+ return float(default)
2142
+
2143
+
2144
  def _record_nozzle_number(record: dict, fallback: int | None = None) -> int:
2145
  default = fallback if fallback is not None else int(record.get("idx", 1) or 1)
2146
  nozzle = _coerce_int(record.get("nozzle", default), default)
 
2432
  rows: Any,
2433
  column_spacing: Any,
2434
  row_spacing: Any,
2435
+ existing_table: Any | None = None,
2436
  ) -> tuple[list[list[Any]], int, int]:
2437
  ordered_nozzles = _ordered_nozzle_numbers(records)
2438
  column_count = max(1, _coerce_int(columns, 2))
2439
+ requested_rows = max(1, _coerce_int(rows, 1))
2440
+ row_count = max(requested_rows, math.ceil(len(ordered_nozzles) / column_count) if ordered_nozzles else requested_rows)
2441
+ x_spacing = _coerce_float(column_spacing, 5.0)
2442
+ y_spacing = _coerce_float(row_spacing, 5.0)
2443
+ existing_pairs = _spacing_pairs_from_table(existing_table)
 
 
 
 
2444
 
2445
  spacing_rows: list[list[Any]] = []
2446
  for index, (first, second) in enumerate(zip(ordered_nozzles, ordered_nozzles[1:])):
2447
+ default_gap_x, default_gap_y = _grid_default_gap_for_pair(index, column_count, x_spacing, y_spacing)
2448
+ gap_x, gap_y = existing_pairs[index] if index < len(existing_pairs) else (default_gap_x, default_gap_y)
 
 
 
 
 
 
2449
  spacing_rows.append([
2450
  _nozzle_spacing_label(first, records),
2451
  _nozzle_spacing_label(second, records),
 
2455
  return spacing_rows, column_count, row_count
2456
 
2457
 
2458
+ def _grid_spacing_table_update(
2459
+ records: list[dict] | None,
2460
+ columns: Any,
2461
+ rows: Any,
2462
+ column_spacing: Any,
2463
+ row_spacing: Any,
2464
+ existing_table: Any | None = None,
2465
+ use_individual_grid_spacing: bool | None = False,
2466
+ ) -> dict[str, Any]:
2467
+ spacing_rows, _column_count, _row_count = _grid_spacing_rows(
2468
+ records or [],
2469
+ columns,
2470
+ rows,
2471
+ column_spacing,
2472
+ row_spacing,
2473
+ existing_table if use_individual_grid_spacing else None,
2474
+ )
2475
+ return gr.update(
2476
+ headers=ADVANCED_NOZZLE_SPACING_HEADERS,
2477
+ value=spacing_rows,
2478
+ row_count=(len(spacing_rows), "fixed"),
2479
+ column_count=(len(ADVANCED_NOZZLE_SPACING_HEADERS), "fixed"),
2480
+ label="Advanced Grid Spacing",
2481
+ visible=bool(use_individual_grid_spacing),
2482
+ )
2483
+
2484
+
2485
+ def _records_by_nozzle(records: list[dict]) -> dict[int, list[dict]]:
2486
+ grouped: dict[int, list[dict]] = {}
2487
+ for index, record in enumerate(records, start=1):
2488
+ grouped.setdefault(_record_nozzle_number(record, int(record.get("idx", index) or index)), []).append(record)
2489
+ return grouped
2490
+
2491
+
2492
+ def _split_pair_was_created_together(records_by_nozzle: dict[int, list[dict]], first: int, second: int) -> bool:
2493
+ for first_record in records_by_nozzle.get(first, []):
2494
+ first_group = first_record.get("split_group_id")
2495
+ if not first_group:
2496
+ continue
2497
+ first_index = _coerce_int(first_record.get("split_index"), -1)
2498
+ if first_index < 0:
2499
+ continue
2500
+ for second_record in records_by_nozzle.get(second, []):
2501
+ if second_record.get("split_group_id") != first_group:
2502
+ continue
2503
+ if _coerce_int(second_record.get("split_index"), -1) == first_index + 1:
2504
+ return True
2505
+ return False
2506
+
2507
+
2508
+ def _auto_align_grid_spacing_rows(
2509
+ records: list[dict],
2510
+ columns: Any,
2511
+ rows: Any,
2512
+ column_spacing: Any,
2513
+ row_spacing: Any,
2514
+ raster_pattern: str | None,
2515
+ ) -> tuple[list[list[Any]], int, int, int]:
2516
+ spacing_rows, column_count, row_count = _grid_spacing_rows(
2517
+ records,
2518
+ columns,
2519
+ rows,
2520
+ column_spacing,
2521
+ row_spacing,
2522
+ )
2523
+ auto_x_gap, auto_y_gap = _auto_align_split_offsets(raster_pattern)
2524
+ records_by_nozzle = _records_by_nozzle(records)
2525
+ ordered_nozzles = _ordered_nozzle_numbers(records)
2526
+ aligned_count = 0
2527
+ for index, (first, second) in enumerate(zip(ordered_nozzles, ordered_nozzles[1:])):
2528
+ if not _split_pair_was_created_together(records_by_nozzle, first, second):
2529
+ continue
2530
+ gap_x, gap_y = _grid_default_gap_for_pair(index, column_count, auto_x_gap, auto_y_gap)
2531
+ spacing_rows[index][2] = gap_x
2532
+ spacing_rows[index][3] = gap_y
2533
+ aligned_count += 1
2534
+ return spacing_rows, column_count, row_count, aligned_count
2535
+
2536
+
2537
  def apply_nozzle_grid_spacing(
2538
  records: list[dict] | None,
2539
  columns: Any,
 
2561
  )
2562
 
2563
 
2564
+ def _auto_align_split_offsets(raster_pattern: str | None) -> tuple[float, float]:
2565
+ if raster_pattern == RASTER_PATTERN_Y_DIRECTION:
2566
+ return AUTO_ALIGN_Y_RASTER_OFFSETS
2567
+ return AUTO_ALIGN_X_RASTER_OFFSETS
2568
+
2569
+
2570
+ def auto_align_split_parts_for_raster(
2571
+ records: list[dict] | None,
2572
+ columns: Any,
2573
+ rows: Any,
2574
+ column_spacing: Any,
2575
+ row_spacing: Any,
2576
+ raster_pattern: str | None,
2577
+ ) -> tuple:
2578
+ x_gap, y_gap = _auto_align_split_offsets(raster_pattern)
2579
+ raster_label = raster_pattern or RASTER_PATTERN_SAME_DIRECTION
2580
+ spacing_rows, column_count, row_count, aligned_count = _auto_align_grid_spacing_rows(
2581
+ records or [],
2582
+ columns,
2583
+ rows,
2584
+ column_spacing,
2585
+ row_spacing,
2586
+ raster_pattern,
2587
+ )
2588
+ if aligned_count <= 0:
2589
+ return (
2590
+ gr.update(value=NOZZLE_LAYOUT_GRID),
2591
+ gr.update(visible=True),
2592
+ gr.update(visible=False),
2593
+ gr.update(),
2594
+ gr.update(),
2595
+ gr.update(),
2596
+ gr.update(),
2597
+ "No split-sibling nozzle connections found. Auto align was not applied.",
2598
+ )
2599
+
2600
+ return (
2601
+ gr.update(value=NOZZLE_LAYOUT_GRID),
2602
+ gr.update(visible=True),
2603
+ gr.update(visible=False),
2604
+ gr.update(),
2605
+ gr.update(),
2606
+ gr.update(value=True),
2607
+ gr.update(
2608
+ headers=ADVANCED_NOZZLE_SPACING_HEADERS,
2609
+ value=spacing_rows,
2610
+ row_count=(len(spacing_rows), "fixed"),
2611
+ column_count=(len(ADVANCED_NOZZLE_SPACING_HEADERS), "fixed"),
2612
+ label="Advanced Grid Spacing",
2613
+ visible=True,
2614
+ ),
2615
+ (
2616
+ f"Auto aligned {aligned_count} split nozzle connection(s) for {raster_label} "
2617
+ f"in a {column_count} x {row_count} grid: Column Gap X {x_gap:.2f} mm, Row Gap Y {y_gap:.2f} mm."
2618
+ ),
2619
+ )
2620
+
2621
+
2622
  def update_nozzle_grid_preset(
2623
  preset: str | None,
2624
  records: list[dict] | None,
 
3140
  base_name = str(source.get("name") or f"Shape {source.get('idx', pos + 1)}")
3141
  first_nozzle = max(1, _coerce_int(starting_nozzle, 1))
3142
  first_valve = max(1, _coerce_int(starting_valve, _coerce_int(source.get("valve", 4), 4)))
3143
+ split_column_count = max(1, _coerce_int(columns, 2))
3144
+ split_row_count = max(1, _coerce_int(rows, 1))
3145
+ split_group_id = f"split-{int(time.time() * 1_000_000)}-{source.get('idx', pos + 1)}"
3146
  split_records: list[dict] = []
3147
  for index, piece in enumerate(pieces):
3148
  piece_state = piece["state"]
 
3156
  "target_y": piece_height_mm or source.get("target_y", DEFAULT_TARGET_EXTENTS[1]),
3157
  "nozzle": first_nozzle + index,
3158
  "valve": first_valve + index,
3159
+ "split_group_id": split_group_id,
3160
+ "split_index": index,
3161
+ "split_row": int(piece["row"]),
3162
+ "split_col": int(piece["col"]),
3163
+ "split_rows": split_row_count,
3164
+ "split_columns": split_column_count,
3165
  "tiff_state": piece_state,
3166
  "zip_path": str(piece["zip_path"]),
3167
  "gcode_path": None,
 
3204
  tiff_paths = state.get("tiff_paths") or []
3205
  source = {
3206
  "owner_idx": int(record.get("idx", len(sources) + 1)),
3207
+ "contour_mode": CONTOUR_MODE_ROW_ENVELOPE,
3208
  "tiff_paths": list(tiff_paths),
3209
  "zip_path": record.get("zip_path"),
3210
  }
 
3219
  all_g1: bool,
3220
  use_reference_motion: bool,
3221
  raster_pattern: str | None,
3222
+ lead_in_enabled: bool,
3223
+ lead_in_length: float,
3224
+ lead_in_clearance: float,
3225
+ lead_in_lines: float,
3226
  ref_state: ViewerState | None,
3227
  layer_height: float,
3228
  pixel_size: float,
 
3233
  messages: list[str] = []
3234
  if contour_sources:
3235
  enabled = ", ".join(f"Shape {source['owner_idx']}" for source in contour_sources)
3236
+ messages.append(f"Shape-optimized contour tracing enabled for {enabled}.")
3237
  for record in records:
3238
  zip_path = record.get("zip_path")
3239
  if not zip_path:
 
3257
  raster_pattern=raster_pattern,
3258
  contour_tiff_sets=contour_sources,
3259
  active_contour_owner=int(record.get("idx", 0)),
3260
+ lead_in_enabled=bool(lead_in_enabled),
3261
+ lead_in_length=float(lead_in_length),
3262
+ lead_in_clearance=float(lead_in_clearance),
3263
+ lead_in_lines=max(1, _coerce_int(lead_in_lines, 3)),
3264
  )
3265
  record["gcode_path"] = str(gcode_path)
3266
  messages.append(f"Shape {record['idx']}: wrote `{gcode_path.name}`.")
 
3330
  rows: Any,
3331
  column_spacing: Any,
3332
  row_spacing: Any,
3333
+ use_grid_individual_spacing: bool,
3334
+ grid_spacing_table: Any,
3335
  use_individual_spacing: bool,
3336
  spacing_table: Any,
3337
  ) -> tuple[Any, str]:
 
3345
  rows,
3346
  column_spacing,
3347
  row_spacing,
3348
+ use_grid_individual_spacing,
3349
+ grid_spacing_table,
3350
  use_individual_spacing,
3351
  spacing_table,
3352
  )
 
3424
  rows: Any,
3425
  column_spacing: Any,
3426
  row_spacing: Any,
3427
+ use_grid_individual_spacing: bool,
3428
+ grid_spacing_table: Any,
3429
  use_individual_spacing: bool,
3430
  spacing_table: Any,
3431
  tube: bool = True,
 
3441
  rows,
3442
  column_spacing,
3443
  row_spacing,
3444
+ use_grid_individual_spacing,
3445
+ grid_spacing_table,
3446
  use_individual_spacing,
3447
  spacing_table,
3448
  )
 
3483
  rows: Any,
3484
  column_spacing: Any,
3485
  row_spacing: Any,
3486
+ use_grid_individual_spacing: bool,
3487
+ grid_spacing_table: Any,
3488
  use_individual_spacing: bool,
3489
  spacing_table: Any,
3490
  duration: float,
 
3504
  rows,
3505
  column_spacing,
3506
  row_spacing,
3507
+ use_grid_individual_spacing,
3508
+ grid_spacing_table,
3509
  use_individual_spacing,
3510
  spacing_table,
3511
  )
 
3659
  value=RASTER_PATTERN_SAME_DIRECTION,
3660
  allow_custom_value=False,
3661
  )
3662
+ gcode_lead_in_enabled = gr.Checkbox(label="Lead In", value=False)
3663
+ with gr.Row():
3664
+ gcode_lead_in_length = gr.Number(label="Lead In Length (mm)", value=5.0, minimum=0.1, step=0.1)
3665
+ gcode_lead_in_clearance = gr.Number(label="Lead In Clearance (mm)", value=5.0, minimum=0.0, step=0.1)
3666
+ gcode_lead_in_lines = gr.Number(label="Lead In Raster Lines", value=3, minimum=1, step=1)
3667
  gcode_button = gr.Button("Generate G-Code", variant="primary")
3668
  gcode_downloads = gr.File(label="Download G-Code Files", file_count="multiple", interactive=False, elem_classes=["gcode-download"])
3669
  gcode_status = gr.Markdown("")
 
3690
  nozzle_grid_rows = gr.Number(label="Grid Rows", value=2, minimum=1, step=1)
3691
  nozzle_grid_column_spacing = gr.Number(label="Column Gap (X, mm)", value=0.0, step=0.1)
3692
  nozzle_grid_row_spacing = gr.Number(label="Row Gap (Y, mm)", value=0.0, step=0.1)
3693
+ with gr.Row():
3694
+ auto_align_split_parts_button = gr.Button("Auto Align Split Parts", variant="secondary", size="sm")
3695
+ nozzle_grid_use_individual_spacing = gr.Checkbox(label="Use Different Grid Connection Gaps", value=False)
3696
+ nozzle_grid_spacing_table = gr.Dataframe(
3697
+ headers=ADVANCED_NOZZLE_SPACING_HEADERS,
3698
+ value=[],
3699
+ row_count=(0, "fixed"),
3700
+ column_count=(len(ADVANCED_NOZZLE_SPACING_HEADERS), "fixed"),
3701
+ interactive=True,
3702
+ label="Advanced Grid Spacing",
3703
+ visible=False,
3704
+ elem_id="nozzle-grid-spacing-table",
3705
+ )
3706
  with gr.Group(visible=False) as nozzle_custom_group:
3707
  nozzle_use_individual_spacing = gr.Checkbox(label="Use Different Values for Each Nozzle Connection", value=False)
3708
  nozzle_spacing_table = gr.Dataframe(
 
3797
 
3798
  parallel_mode = gr.State("tube")
3799
 
3800
+ grid_spacing_refresh_inputs = [
3801
+ shape_records,
3802
+ nozzle_grid_columns,
3803
+ nozzle_grid_rows,
3804
+ nozzle_grid_column_spacing,
3805
+ nozzle_grid_row_spacing,
3806
+ nozzle_grid_spacing_table,
3807
+ nozzle_grid_use_individual_spacing,
3808
+ ]
3809
+
3810
  shape_sync_outputs = [shape_records, shape_settings, nozzle_spacing_table, selected_shape, gcode_text_source, gcode_source, tiff_downloads, gcode_downloads]
3811
  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(
3812
  fn=lambda records: _dropdown_update(records),
3813
  inputs=[shape_records],
3814
  outputs=[split_source],
3815
  queue=False,
3816
+ ).then(
3817
+ fn=_grid_spacing_table_update,
3818
+ inputs=grid_spacing_refresh_inputs,
3819
+ outputs=[nozzle_grid_spacing_table],
3820
+ queue=False,
3821
  )
3822
  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(
3823
  fn=lambda records: _dropdown_update(records),
3824
  inputs=[shape_records],
3825
  outputs=[split_source],
3826
  queue=False,
3827
+ ).then(
3828
+ fn=_grid_spacing_table_update,
3829
+ inputs=grid_spacing_refresh_inputs,
3830
+ outputs=[nozzle_grid_spacing_table],
3831
+ queue=False,
3832
  )
3833
  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(
3834
  fn=lambda records: _dropdown_update(records),
3835
  inputs=[shape_records],
3836
  outputs=[split_source],
3837
  queue=False,
3838
+ ).then(
3839
+ fn=_grid_spacing_table_update,
3840
+ inputs=grid_spacing_refresh_inputs,
3841
+ outputs=[nozzle_grid_spacing_table],
3842
+ queue=False,
3843
  )
3844
  shape_settings.select(
3845
  fn=delete_shape_from_settings,
 
3850
  inputs=[shape_records],
3851
  outputs=[split_source],
3852
  queue=False,
3853
+ ).then(
3854
+ fn=_grid_spacing_table_update,
3855
+ inputs=grid_spacing_refresh_inputs,
3856
+ outputs=[nozzle_grid_spacing_table],
3857
+ queue=False,
3858
  )
3859
 
3860
  preview_inputs = [shape_records, selected_shape, shape_settings, model_opacity, scale_mode]
 
3863
  inputs=[shape_records, shape_settings, scale_mode, nozzle_spacing_table, nozzle_use_individual_spacing],
3864
  outputs=[shape_records, shape_settings, nozzle_spacing_table],
3865
  queue=False,
3866
+ ).then(
3867
+ fn=_grid_spacing_table_update,
3868
+ inputs=grid_spacing_refresh_inputs,
3869
+ outputs=[nozzle_grid_spacing_table],
3870
+ queue=False,
3871
  )
3872
  selected_shape.change(fn=show_selected_model, inputs=preview_inputs, outputs=[model_viewer, model_details, slice_slider, slice_label, slice_preview])
3873
  refresh_preview_button.click(fn=show_selected_model, inputs=preview_inputs, outputs=[model_viewer, model_details, slice_slider, slice_label, slice_preview])
 
3959
  fn=generate_dynamic_reference_stack,
3960
  inputs=[shape_records],
3961
  outputs=[ref_state, ref_slice_slider, ref_slice_label, ref_slice_preview],
3962
+ ).then(
3963
+ fn=_grid_spacing_table_update,
3964
+ inputs=grid_spacing_refresh_inputs,
3965
+ outputs=[nozzle_grid_spacing_table],
3966
+ queue=False,
3967
  )
3968
  split_piece_source.change(fn=preview_selected_split_piece, inputs=[split_piece_states, split_piece_source], outputs=[split_piece_slider, split_piece_label, split_piece_preview], queue=False)
3969
  split_piece_slider.release(fn=jump_to_selected_split_piece, inputs=[split_piece_states, split_piece_source, split_piece_slider], outputs=[split_piece_label, split_piece_preview], queue=False)
 
3972
 
3973
  gcode_button.click(
3974
  fn=generate_dynamic_gcode,
3975
+ inputs=[
3976
+ shape_records,
3977
+ shape_settings,
3978
+ gcode_all_g1,
3979
+ gcode_use_ref_motion,
3980
+ gcode_raster_pattern,
3981
+ gcode_lead_in_enabled,
3982
+ gcode_lead_in_length,
3983
+ gcode_lead_in_clearance,
3984
+ gcode_lead_in_lines,
3985
+ ref_state,
3986
+ layer_height,
3987
+ pixel_size,
3988
+ ],
3989
  outputs=[shape_records, gcode_downloads, gcode_status, gcode_text_source, gcode_source],
3990
  )
3991
  gcode_text_source.change(fn=load_selected_gcode_text, inputs=[shape_records, gcode_text_source], outputs=[gcode_text])
 
3996
  outputs=[nozzle_grid_group, nozzle_custom_group],
3997
  queue=False,
3998
  )
3999
+ auto_align_split_parts_button.click(
4000
+ fn=auto_align_split_parts_for_raster,
4001
+ inputs=[
4002
+ shape_records,
4003
+ nozzle_grid_columns,
4004
+ nozzle_grid_rows,
4005
+ nozzle_grid_column_spacing,
4006
+ nozzle_grid_row_spacing,
4007
+ gcode_raster_pattern,
4008
+ ],
4009
+ outputs=[
4010
+ nozzle_layout_mode,
4011
+ nozzle_grid_group,
4012
+ nozzle_custom_group,
4013
+ nozzle_grid_column_spacing,
4014
+ nozzle_grid_row_spacing,
4015
+ nozzle_grid_use_individual_spacing,
4016
+ nozzle_grid_spacing_table,
4017
+ nozzle_spacing_status,
4018
+ ],
4019
+ queue=False,
4020
+ )
4021
  nozzle_grid_preset.change(
4022
  fn=update_nozzle_grid_preset,
4023
  inputs=[nozzle_grid_preset, shape_records, nozzle_grid_columns, nozzle_grid_rows],
4024
  outputs=[nozzle_grid_columns, nozzle_grid_rows],
4025
  queue=False,
4026
+ ).then(
4027
+ fn=_grid_spacing_table_update,
4028
+ inputs=grid_spacing_refresh_inputs,
4029
+ outputs=[nozzle_grid_spacing_table],
4030
+ queue=False,
4031
  )
4032
+ for grid_spacing_control in (
4033
+ nozzle_grid_columns,
4034
+ nozzle_grid_rows,
4035
+ nozzle_grid_column_spacing,
4036
+ nozzle_grid_row_spacing,
4037
+ nozzle_grid_use_individual_spacing,
4038
+ ):
4039
+ grid_spacing_control.change(
4040
+ fn=_grid_spacing_table_update,
4041
+ inputs=grid_spacing_refresh_inputs,
4042
+ outputs=[nozzle_grid_spacing_table],
4043
+ queue=False,
4044
+ )
4045
  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)
4046
  nozzle_preview_button.click(
4047
  fn=render_dynamic_nozzle_spacing,
 
4052
  nozzle_grid_rows,
4053
  nozzle_grid_column_spacing,
4054
  nozzle_grid_row_spacing,
4055
+ nozzle_grid_use_individual_spacing,
4056
+ nozzle_grid_spacing_table,
4057
  nozzle_use_individual_spacing,
4058
  nozzle_spacing_table,
4059
  ],
 
4107
  nozzle_grid_rows,
4108
  nozzle_grid_column_spacing,
4109
  nozzle_grid_row_spacing,
4110
+ nozzle_grid_use_individual_spacing,
4111
+ nozzle_grid_spacing_table,
4112
  nozzle_use_individual_spacing,
4113
  nozzle_spacing_table,
4114
  ]
 
4128
  nozzle_grid_rows,
4129
  nozzle_grid_column_spacing,
4130
  nozzle_grid_row_spacing,
4131
+ nozzle_grid_use_individual_spacing,
4132
+ nozzle_grid_spacing_table,
4133
  nozzle_use_individual_spacing,
4134
  nozzle_spacing_table,
4135
  pp_gif_duration,
tests/test_app_scaling.py CHANGED
@@ -94,7 +94,7 @@ def test_split_tiff_stack_left_right_preserves_pixels_and_metadata(tmp_path) ->
94
  assert left_zip.exists()
95
  assert right_zip.exists()
96
  assert left_state["image_width"] == 3
97
- assert right_state["image_width"] == 2
98
  assert left_state["x_min"] == 10.0
99
  assert right_state["x_min"] == 11.5
100
  assert left_state["z_values"] == [1.25]
@@ -103,7 +103,9 @@ def test_split_tiff_stack_left_right_preserves_pixels_and_metadata(tmp_path) ->
103
  with Image.open(left_state["tiff_paths"][0]) as left_image:
104
  np.testing.assert_array_equal(np.asarray(left_image), pixels[:, :3])
105
  with Image.open(right_state["tiff_paths"][0]) as right_image:
106
- np.testing.assert_array_equal(np.asarray(right_image), pixels[:, 3:])
 
 
107
 
108
 
109
  def test_split_tiff_stack_grid_preserves_pixels_and_offsets(tmp_path) -> None:
@@ -123,17 +125,55 @@ def test_split_tiff_stack_grid_preserves_pixels_and_offsets(tmp_path) -> None:
123
  pieces = split_tiff_stack_grid(state, "grid-part", columns=2, rows=2)
124
 
125
  assert [(piece["row"], piece["col"]) for piece in pieces] == [(1, 1), (1, 2), (2, 1), (2, 2)]
126
- assert [piece["state"]["image_width"] for piece in pieces] == [3, 2, 3, 2]
127
  assert [piece["state"]["image_height"] for piece in pieces] == [2, 2, 2, 2]
128
  assert [piece["state"]["x_min"] for piece in pieces] == [4.0, 4.75, 4.0, 4.75]
129
  assert [piece["state"]["y_min"] for piece in pieces] == [10.5, 10.5, 10.0, 10.0]
130
  assert all(piece["zip_path"].exists() for piece in pieces)
131
 
 
 
 
 
132
  expected = [
133
  pixels[:2, :3],
134
- pixels[:2, 3:],
135
  pixels[2:, :3],
136
- pixels[2:, 3:],
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
137
  ]
138
  for piece, expected_pixels in zip(pieces, expected):
139
  with Image.open(piece["state"]["tiff_paths"][0]) as image:
@@ -159,21 +199,23 @@ def test_split_tiff_stack_grid_overlapping_layers_keep_small_alignment_margin(tm
159
 
160
  left, right = split_tiff_stack_grid(state, "overlap-part", columns=2, rows=1, overlapping_layers=True)
161
 
162
- assert left["state"]["image_width"] == 4
163
- assert right["state"]["image_width"] == 4
164
- assert left["state"]["x_min"] == 10.0
165
  assert right["state"]["x_min"] == 11.0
166
  with Image.open(left["state"]["tiff_paths"][0]) as left_layer0:
167
- expected = np.zeros((2, 4), dtype=np.uint8)
 
168
  np.testing.assert_array_equal(np.asarray(left_layer0), expected)
169
  with Image.open(right["state"]["tiff_paths"][0]) as right_layer0:
170
- expected = np.full((2, 4), 255, dtype=np.uint8)
171
- expected[:, 2:] = 0
172
  np.testing.assert_array_equal(np.asarray(right_layer0), expected)
173
  with Image.open(left["state"]["tiff_paths"][1]) as left_layer1:
174
- expected = np.full((2, 4), 255, dtype=np.uint8)
175
- expected[:, :2] = 0
176
  np.testing.assert_array_equal(np.asarray(left_layer1), expected)
177
  with Image.open(right["state"]["tiff_paths"][1]) as right_layer1:
178
- expected = np.zeros((2, 4), dtype=np.uint8)
 
179
  np.testing.assert_array_equal(np.asarray(right_layer1), expected)
 
94
  assert left_zip.exists()
95
  assert right_zip.exists()
96
  assert left_state["image_width"] == 3
97
+ assert right_state["image_width"] == 3
98
  assert left_state["x_min"] == 10.0
99
  assert right_state["x_min"] == 11.5
100
  assert left_state["z_values"] == [1.25]
 
103
  with Image.open(left_state["tiff_paths"][0]) as left_image:
104
  np.testing.assert_array_equal(np.asarray(left_image), pixels[:, :3])
105
  with Image.open(right_state["tiff_paths"][0]) as right_image:
106
+ expected = np.full((2, 3), 255, dtype=np.uint8)
107
+ expected[:, :2] = pixels[:, 3:]
108
+ np.testing.assert_array_equal(np.asarray(right_image), expected)
109
 
110
 
111
  def test_split_tiff_stack_grid_preserves_pixels_and_offsets(tmp_path) -> None:
 
125
  pieces = split_tiff_stack_grid(state, "grid-part", columns=2, rows=2)
126
 
127
  assert [(piece["row"], piece["col"]) for piece in pieces] == [(1, 1), (1, 2), (2, 1), (2, 2)]
128
+ assert [piece["state"]["image_width"] for piece in pieces] == [3, 3, 3, 3]
129
  assert [piece["state"]["image_height"] for piece in pieces] == [2, 2, 2, 2]
130
  assert [piece["state"]["x_min"] for piece in pieces] == [4.0, 4.75, 4.0, 4.75]
131
  assert [piece["state"]["y_min"] for piece in pieces] == [10.5, 10.5, 10.0, 10.0]
132
  assert all(piece["zip_path"].exists() for piece in pieces)
133
 
134
+ right_top = np.full((2, 3), 255, dtype=np.uint8)
135
+ right_top[:, :2] = pixels[:2, 3:]
136
+ right_bottom = np.full((2, 3), 255, dtype=np.uint8)
137
+ right_bottom[:, :2] = pixels[2:, 3:]
138
  expected = [
139
  pixels[:2, :3],
140
+ right_top,
141
  pixels[2:, :3],
142
+ right_bottom,
143
+ ]
144
+ for piece, expected_pixels in zip(pieces, expected):
145
+ with Image.open(piece["state"]["tiff_paths"][0]) as image:
146
+ np.testing.assert_array_equal(np.asarray(image), expected_pixels)
147
+
148
+
149
+ def test_split_tiff_stack_grid_pads_more_than_three_columns_to_equal_widths(tmp_path) -> None:
150
+ pixels = np.arange(20, dtype=np.uint8).reshape((2, 10))
151
+ tiff_path = tmp_path / "slice_0000.tif"
152
+ Image.fromarray(pixels, mode="L").save(tiff_path)
153
+ state = {
154
+ "tiff_paths": [str(tiff_path)],
155
+ "z_values": [0.0],
156
+ "pixel_size": 1.0,
157
+ "x_min": 100.0,
158
+ "y_min": 0.0,
159
+ "image_width": 10,
160
+ "image_height": 2,
161
+ }
162
+
163
+ pieces = split_tiff_stack_grid(state, "four-way", columns=4, rows=1)
164
+
165
+ assert [piece["state"]["image_width"] for piece in pieces] == [3, 3, 3, 3]
166
+ assert [piece["state"]["x_min"] for piece in pieces] == [99.0, 102.0, 105.0, 108.0]
167
+
168
+ first_expected = np.full((2, 3), 255, dtype=np.uint8)
169
+ first_expected[:, 1:] = pixels[:, :2]
170
+ last_expected = np.full((2, 3), 255, dtype=np.uint8)
171
+ last_expected[:, :2] = pixels[:, 8:]
172
+ expected = [
173
+ first_expected,
174
+ pixels[:, 2:5],
175
+ pixels[:, 5:8],
176
+ last_expected,
177
  ]
178
  for piece, expected_pixels in zip(pieces, expected):
179
  with Image.open(piece["state"]["tiff_paths"][0]) as image:
 
199
 
200
  left, right = split_tiff_stack_grid(state, "overlap-part", columns=2, rows=1, overlapping_layers=True)
201
 
202
+ assert left["state"]["image_width"] == 5
203
+ assert right["state"]["image_width"] == 5
204
+ assert left["state"]["x_min"] == 9.5
205
  assert right["state"]["x_min"] == 11.0
206
  with Image.open(left["state"]["tiff_paths"][0]) as left_layer0:
207
+ expected = np.full((2, 5), 255, dtype=np.uint8)
208
+ expected[:, 1:] = 0
209
  np.testing.assert_array_equal(np.asarray(left_layer0), expected)
210
  with Image.open(right["state"]["tiff_paths"][0]) as right_layer0:
211
+ expected = np.full((2, 5), 255, dtype=np.uint8)
212
+ expected[:, 2:4] = 0
213
  np.testing.assert_array_equal(np.asarray(right_layer0), expected)
214
  with Image.open(left["state"]["tiff_paths"][1]) as left_layer1:
215
+ expected = np.full((2, 5), 255, dtype=np.uint8)
216
+ expected[:, 1:3] = 0
217
  np.testing.assert_array_equal(np.asarray(left_layer1), expected)
218
  with Image.open(right["state"]["tiff_paths"][1]) as right_layer1:
219
+ expected = np.full((2, 5), 255, dtype=np.uint8)
220
+ expected[:, :4] = 0
221
  np.testing.assert_array_equal(np.asarray(right_layer1), expected)
tests/test_nozzle_spacing.py CHANGED
@@ -7,7 +7,10 @@ from app import (
7
  SCALE_MODE_UNIFORM_FACTOR,
8
  SHAPE_SETTINGS_HEADERS,
9
  SIMPLE_NOZZLE_SPACING_HEADERS,
 
 
10
  _apply_shape_settings,
 
11
  delete_shape_from_settings,
12
  _format_nozzle_spacing_status,
13
  _grid_spacing_rows,
@@ -19,6 +22,11 @@ from app import (
19
  _resolve_nozzle_grid_layout,
20
  _resolve_nozzle_layout,
21
  )
 
 
 
 
 
22
 
23
 
24
  def _part(
@@ -248,6 +256,27 @@ def test_shape_settings_round_trip_contour_tracing_column() -> None:
248
  assert updated[0]["contour_tracing"] is True
249
 
250
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
251
  def test_repeated_sample_path_gets_next_unused_nozzle() -> None:
252
  records = _records_from_files(
253
  ["sample.stl", "sample.stl"],
@@ -316,6 +345,78 @@ def test_advanced_spacing_table_collapses_duplicate_nozzles() -> None:
316
  assert update["value"] == [["Nozzle 1: Shape 1, Shape 2", "Nozzle 2: Shape 3", 9.0, 2.5]]
317
 
318
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
319
  def test_grid_spacing_rows_follow_row_major_pattern() -> None:
320
  records = [
321
  {"idx": 1, "name": "first", "nozzle": 1},
@@ -330,7 +431,34 @@ def test_grid_spacing_rows_follow_row_major_pattern() -> None:
330
  assert row_count == 2
331
  assert rows == [
332
  ["Nozzle 1: Shape 1", "Nozzle 2: Shape 2", 10.0, 0.0],
333
- ["Nozzle 2: Shape 2", "Nozzle 3: Shape 3", -10.0, 3.0],
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
334
  ["Nozzle 3: Shape 3", "Nozzle 4: Shape 4", 10.0, 0.0],
335
  ]
336
 
@@ -356,6 +484,39 @@ def test_nozzle_grid_layout_places_nozzles_by_rows_and_columns() -> None:
356
  ]
357
 
358
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
359
  def test_delete_shape_reindexes_without_losing_shape_data() -> None:
360
  class Event:
361
  index = (1, len(SHAPE_SETTINGS_HEADERS) - 1)
 
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,
 
22
  _resolve_nozzle_grid_layout,
23
  _resolve_nozzle_layout,
24
  )
25
+ from tiff_to_gcode import (
26
+ CONTOUR_MODE_ROW_ENVELOPE,
27
+ RASTER_PATTERN_SAME_DIRECTION,
28
+ RASTER_PATTERN_Y_DIRECTION,
29
+ )
30
 
31
 
32
  def _part(
 
256
  assert updated[0]["contour_tracing"] is True
257
 
258
 
259
+ def test_contour_tracing_sources_request_shape_optimized_mode() -> None:
260
+ sources = _contour_tracing_sources(
261
+ [
262
+ {
263
+ "idx": 2,
264
+ "contour_tracing": True,
265
+ "tiff_state": {"tiff_paths": ["slice_0000.tif"]},
266
+ }
267
+ ]
268
+ )
269
+
270
+ assert sources == [
271
+ {
272
+ "owner_idx": 2,
273
+ "contour_mode": CONTOUR_MODE_ROW_ENVELOPE,
274
+ "tiff_paths": ["slice_0000.tif"],
275
+ "zip_path": None,
276
+ }
277
+ ]
278
+
279
+
280
  def test_repeated_sample_path_gets_next_unused_nozzle() -> None:
281
  records = _records_from_files(
282
  ["sample.stl", "sample.stl"],
 
345
  assert update["value"] == [["Nozzle 1: Shape 1, Shape 2", "Nozzle 2: Shape 3", 9.0, 2.5]]
346
 
347
 
348
+ def test_auto_align_split_offsets_follow_raster_axis() -> None:
349
+ assert _auto_align_split_offsets(RASTER_PATTERN_SAME_DIRECTION) == (-3.2, -0.8)
350
+ assert _auto_align_split_offsets(RASTER_PATTERN_Y_DIRECTION) == (-0.8, -3.2)
351
+
352
+
353
+ def test_auto_align_grid_spacing_only_targets_split_siblings() -> None:
354
+ records = [
355
+ {"idx": 1, "name": "first", "nozzle": 1, "split_group_id": "split-a", "split_index": 0},
356
+ {"idx": 2, "name": "second", "nozzle": 2, "split_group_id": "split-a", "split_index": 1},
357
+ {"idx": 3, "name": "third", "nozzle": 3},
358
+ {"idx": 4, "name": "fourth", "nozzle": 4, "split_group_id": "split-a", "split_index": 2},
359
+ ]
360
+
361
+ rows, column_count, row_count, aligned_count = _auto_align_grid_spacing_rows(
362
+ records,
363
+ columns=2,
364
+ rows=2,
365
+ column_spacing=10.0,
366
+ row_spacing=3.0,
367
+ raster_pattern=RASTER_PATTERN_SAME_DIRECTION,
368
+ )
369
+
370
+ assert column_count == 2
371
+ assert row_count == 2
372
+ assert aligned_count == 1
373
+ assert rows == [
374
+ ["Nozzle 1: Shape 1", "Nozzle 2: Shape 2", -3.2, 0.0],
375
+ ["Nozzle 2: Shape 2", "Nozzle 3: Shape 3", 0.0, 3.0],
376
+ ["Nozzle 3: Shape 3", "Nozzle 4: Shape 4", 10.0, 0.0],
377
+ ]
378
+
379
+
380
+ def test_auto_align_grid_spacing_handles_split_row_transition() -> None:
381
+ records = [
382
+ {"idx": 1, "name": "first", "nozzle": 1, "split_group_id": "split-a", "split_index": 0},
383
+ {"idx": 2, "name": "second", "nozzle": 2, "split_group_id": "split-a", "split_index": 1},
384
+ {"idx": 3, "name": "third", "nozzle": 3, "split_group_id": "split-a", "split_index": 2},
385
+ ]
386
+
387
+ rows, _column_count, _row_count, aligned_count = _auto_align_grid_spacing_rows(
388
+ records,
389
+ columns=2,
390
+ rows=2,
391
+ column_spacing=10.0,
392
+ row_spacing=3.0,
393
+ raster_pattern=RASTER_PATTERN_SAME_DIRECTION,
394
+ )
395
+
396
+ assert aligned_count == 2
397
+ assert rows[0] == ["Nozzle 1: Shape 1", "Nozzle 2: Shape 2", -3.2, 0.0]
398
+ assert rows[1] == ["Nozzle 2: Shape 2", "Nozzle 3: Shape 3", 0.0, -0.8]
399
+
400
+
401
+ def test_auto_align_grid_spacing_skips_unsplit_records() -> None:
402
+ records = [
403
+ {"idx": 1, "name": "first", "nozzle": 1},
404
+ {"idx": 2, "name": "second", "nozzle": 2},
405
+ ]
406
+
407
+ rows, _column_count, _row_count, aligned_count = _auto_align_grid_spacing_rows(
408
+ records,
409
+ columns=2,
410
+ rows=1,
411
+ column_spacing=10.0,
412
+ row_spacing=3.0,
413
+ raster_pattern=RASTER_PATTERN_SAME_DIRECTION,
414
+ )
415
+
416
+ assert aligned_count == 0
417
+ assert rows == [["Nozzle 1: Shape 1", "Nozzle 2: Shape 2", 10.0, 0.0]]
418
+
419
+
420
  def test_grid_spacing_rows_follow_row_major_pattern() -> None:
421
  records = [
422
  {"idx": 1, "name": "first", "nozzle": 1},
 
431
  assert row_count == 2
432
  assert rows == [
433
  ["Nozzle 1: Shape 1", "Nozzle 2: Shape 2", 10.0, 0.0],
434
+ ["Nozzle 2: Shape 2", "Nozzle 3: Shape 3", 0.0, 3.0],
435
+ ["Nozzle 3: Shape 3", "Nozzle 4: Shape 4", 10.0, 0.0],
436
+ ]
437
+
438
+
439
+ def test_grid_spacing_rows_preserve_existing_advanced_values() -> None:
440
+ records = [
441
+ {"idx": 1, "name": "first", "nozzle": 1},
442
+ {"idx": 2, "name": "second", "nozzle": 2},
443
+ {"idx": 3, "name": "third", "nozzle": 3},
444
+ {"idx": 4, "name": "fourth", "nozzle": 4},
445
+ ]
446
+
447
+ rows, _column_count, _row_count = _grid_spacing_rows(
448
+ records,
449
+ columns=2,
450
+ rows=2,
451
+ column_spacing=10.0,
452
+ row_spacing=3.0,
453
+ existing_table=[
454
+ ["Nozzle 1", "Nozzle 2", 1.5, 0.25],
455
+ ["Nozzle 2", "Nozzle 3", 2.0, 4.5],
456
+ ],
457
+ )
458
+
459
+ assert rows == [
460
+ ["Nozzle 1: Shape 1", "Nozzle 2: Shape 2", 1.5, 0.25],
461
+ ["Nozzle 2: Shape 2", "Nozzle 3: Shape 3", 2.0, 4.5],
462
  ["Nozzle 3: Shape 3", "Nozzle 4: Shape 4", 10.0, 0.0],
463
  ]
464
 
 
484
  ]
485
 
486
 
487
+ def test_advanced_nozzle_grid_layout_uses_per_connection_gaps() -> None:
488
+ parts = [
489
+ _part(1, ((0.0, 0.0, 0.0), (10.0, 20.0, 1.0))),
490
+ _part(2, ((0.0, 0.0, 0.0), (10.0, 20.0, 1.0))),
491
+ _part(3, ((0.0, 0.0, 0.0), (10.0, 20.0, 1.0))),
492
+ _part(4, ((0.0, 0.0, 0.0), (10.0, 20.0, 1.0))),
493
+ ]
494
+
495
+ offsets, spacings = _resolve_nozzle_grid_layout(
496
+ parts,
497
+ columns=2,
498
+ rows=2,
499
+ column_spacing=2.0,
500
+ row_spacing=3.0,
501
+ use_individual_spacing=True,
502
+ spacing_table=[
503
+ ["Nozzle 1", "Nozzle 2", 2.0, 0.0],
504
+ ["Nozzle 2", "Nozzle 3", 4.0, 3.0],
505
+ ["Nozzle 3", "Nozzle 4", 6.0, 0.0],
506
+ ],
507
+ )
508
+
509
+ np.testing.assert_allclose(offsets[1], (0.0, 0.0))
510
+ np.testing.assert_allclose(offsets[2], (12.0, 0.0))
511
+ np.testing.assert_allclose(offsets[3], (4.0, 23.0))
512
+ np.testing.assert_allclose(offsets[4], (20.0, 23.0))
513
+ assert spacings == [
514
+ {"from": 1, "to": 2, "dx": 12.0, "dy": 0.0},
515
+ {"from": 2, "to": 3, "dx": -8.0, "dy": 23.0},
516
+ {"from": 3, "to": 4, "dx": 16.0, "dy": 0.0},
517
+ ]
518
+
519
+
520
  def test_delete_shape_reindexes_without_losing_shape_data() -> None:
521
  class Event:
522
  index = (1, len(SHAPE_SETTINGS_HEADERS) - 1)
tests/test_tiff_to_gcode.py CHANGED
@@ -6,11 +6,14 @@ import numpy as np
6
  from PIL import Image
7
 
8
  from tiff_to_gcode import (
 
9
  RASTER_PATTERN_SAME_DIRECTION,
10
  RASTER_PATTERN_WOODPILE,
11
  RASTER_PATTERN_Y_DIRECTION,
12
  _build_contour_layers,
 
13
  _trace_mask_contours,
 
14
  generate_snake_path_gcode,
15
  )
16
 
@@ -81,6 +84,76 @@ def test_trace_mask_contours_uses_tiff_pixel_border_edges() -> None:
81
  assert contours == [[(0.0, 0.0), (2.0, 0.0), (2.0, 2.0), (0.0, 2.0), (0.0, 0.0)]]
82
 
83
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
84
  def test_contour_tracing_aligns_default_raster_border_pixel_frame(tmp_path) -> None:
85
  raster_tiff = tmp_path / "raster_slice_0000.tif"
86
  raster_image = Image.new("L", (7, 6), 255)
@@ -352,18 +425,47 @@ def test_contour_tracing_closes_loop_and_restores_raster_endpoint(tmp_path) -> N
352
  ]
353
  contour_start = layer_prints[2]["end"]
354
  assert layer_prints[3]["start"] == contour_start
355
- assert layer_prints[-1]["end"] == contour_start
356
 
357
  last_print_index = max(
358
  idx for idx, move in enumerate(layer_moves) if move["color"] == 255
359
  )
360
  assert layer_moves[last_print_index + 1] == {
361
- "start": contour_start,
362
  "end": (0.0, 0.0, layer_z),
363
  "color": 0,
364
  }
365
 
366
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
367
  def test_contour_tracing_follows_default_raster_layer_flip(tmp_path) -> None:
368
  tiff_paths = []
369
  motion_img = np.zeros((7, 8), dtype=np.uint8)
@@ -432,10 +534,55 @@ def test_gcode_header_writes_presets_before_initial_aux_commands(tmp_path) -> No
432
  ]
433
 
434
  assert lines[0] == "G91"
435
- assert lines[1].startswith("{preset}serialPort3.write(")
436
  assert lines[2].startswith("{preset}serialPort3.write(")
437
- assert lines[3].startswith("{aux_command}WAGO_ValveCommands(")
438
  assert lines[4].startswith("{aux_command}WAGO_ValveCommands(")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
439
 
440
 
441
  def test_gcode_uses_g1_for_print_and_g0_for_travel(tmp_path) -> None:
 
6
  from PIL import Image
7
 
8
  from tiff_to_gcode import (
9
+ CONTOUR_MODE_ROW_ENVELOPE,
10
  RASTER_PATTERN_SAME_DIRECTION,
11
  RASTER_PATTERN_WOODPILE,
12
  RASTER_PATTERN_Y_DIRECTION,
13
  _build_contour_layers,
14
+ _append_layer_contours,
15
  _trace_mask_contours,
16
+ _trace_row_envelope_contours,
17
  generate_snake_path_gcode,
18
  )
19
 
 
84
  assert contours == [[(0.0, 0.0), (2.0, 0.0), (2.0, 2.0), (0.0, 2.0), (0.0, 0.0)]]
85
 
86
 
87
+ def test_trace_row_envelope_contours_follows_left_right_row_extents() -> None:
88
+ contours = _trace_row_envelope_contours(
89
+ np.array(
90
+ [
91
+ [False, True, False],
92
+ [True, True, True],
93
+ ],
94
+ dtype=bool,
95
+ ),
96
+ pixel_size=1.0,
97
+ )
98
+
99
+ assert contours == [
100
+ [
101
+ (0.0, 0.0),
102
+ (-1.0, 1.0),
103
+ (-1.0, 1.5),
104
+ (0.0, 1.5),
105
+ (1.0, 1.5),
106
+ (2.0, 1.5),
107
+ (2.0, 1.0),
108
+ (1.0, 0.0),
109
+ (1.0, -0.5),
110
+ (0.0, -0.5),
111
+ ]
112
+ ]
113
+
114
+
115
+ def test_build_contour_layers_can_use_shape_optimized_row_envelope(tmp_path) -> None:
116
+ contour_img = np.array(
117
+ [
118
+ [0, 255, 0],
119
+ [255, 255, 255],
120
+ ],
121
+ dtype=np.uint8,
122
+ )
123
+ contour_tiff = tmp_path / "contour_slice_0000.tif"
124
+ Image.fromarray(contour_img).save(contour_tiff)
125
+
126
+ contour_layers = _build_contour_layers(
127
+ [
128
+ {
129
+ "owner_idx": 1,
130
+ "contour_mode": CONTOUR_MODE_ROW_ENVELOPE,
131
+ "tiff_paths": [str(contour_tiff)],
132
+ }
133
+ ],
134
+ [contour_img],
135
+ pixel_size=1.0,
136
+ invert=False,
137
+ off_color=0,
138
+ work_dir=tmp_path,
139
+ raster_pattern=RASTER_PATTERN_SAME_DIRECTION,
140
+ )
141
+
142
+ assert contour_layers[0][0]["contour_mode"] == CONTOUR_MODE_ROW_ENVELOPE
143
+ assert contour_layers[0][0]["contours"][0] == [
144
+ (1.0, 0.0),
145
+ (0.0, 1.0),
146
+ (0.0, 1.5),
147
+ (1.0, 1.5),
148
+ (2.0, 1.5),
149
+ (3.0, 1.5),
150
+ (3.0, 1.0),
151
+ (2.0, 0.0),
152
+ (2.0, -0.5),
153
+ (1.0, -0.5),
154
+ ]
155
+
156
+
157
  def test_contour_tracing_aligns_default_raster_border_pixel_frame(tmp_path) -> None:
158
  raster_tiff = tmp_path / "raster_slice_0000.tif"
159
  raster_image = Image.new("L", (7, 6), 255)
 
425
  ]
426
  contour_start = layer_prints[2]["end"]
427
  assert layer_prints[3]["start"] == contour_start
428
+ last_contour_start = layer_prints[-1]["end"]
429
 
430
  last_print_index = max(
431
  idx for idx, move in enumerate(layer_moves) if move["color"] == 255
432
  )
433
  assert layer_moves[last_print_index + 1] == {
434
+ "start": last_contour_start,
435
  "end": (0.0, 0.0, layer_z),
436
  "color": 0,
437
  }
438
 
439
 
440
+ def test_contour_tracing_keeps_hollow_rings_separate() -> None:
441
+ output = [{"X": 0.0, "Y": 0.0, "Color": 255}]
442
+ contour_layers = [
443
+ [
444
+ {
445
+ "owner_idx": 1,
446
+ "contours": [
447
+ [(0.0, 0.0), (4.0, 0.0), (4.0, 4.0), (0.0, 4.0), (0.0, 0.0)],
448
+ [(1.0, 1.0), (2.0, 1.0), (2.0, 2.0), (1.0, 2.0), (1.0, 1.0)],
449
+ ],
450
+ }
451
+ ]
452
+ ]
453
+
454
+ current_x, current_y = _append_layer_contours(
455
+ output,
456
+ 0.0,
457
+ 0.0,
458
+ contour_layers,
459
+ layer_number=0,
460
+ active_owner_idx=1,
461
+ )
462
+
463
+ contour_print_moves = [move for move in output[1:] if move["Color"] == 255]
464
+
465
+ assert len(contour_print_moves) == 8
466
+ assert (current_x, current_y) == (1.0, 1.0)
467
+
468
+
469
  def test_contour_tracing_follows_default_raster_layer_flip(tmp_path) -> None:
470
  tiff_paths = []
471
  motion_img = np.zeros((7, 8), dtype=np.uint8)
 
534
  ]
535
 
536
  assert lines[0] == "G91"
537
+ assert lines[1] == "{aux_command}WAGO_ValveCommands(7, 0)"
538
  assert lines[2].startswith("{preset}serialPort3.write(")
539
+ assert lines[3].startswith("{preset}serialPort3.write(")
540
  assert lines[4].startswith("{aux_command}WAGO_ValveCommands(")
541
+ assert lines[5].startswith("{aux_command}WAGO_ValveCommands(")
542
+
543
+
544
+ def test_gcode_lead_in_runs_once_before_first_layer(tmp_path) -> None:
545
+ tiff_paths = []
546
+ for index in range(2):
547
+ tiff_path = tmp_path / f"slice_{index:04d}.tif"
548
+ Image.new("L", (1, 1), 0).save(tiff_path)
549
+ tiff_paths.append(tiff_path)
550
+
551
+ zip_path = tmp_path / "slices.zip"
552
+ with zipfile.ZipFile(zip_path, mode="w") as archive:
553
+ for tiff_path in tiff_paths:
554
+ archive.write(tiff_path, arcname=tiff_path.name)
555
+
556
+ gcode_path = generate_snake_path_gcode(
557
+ zip_path,
558
+ shape_name="lead_in",
559
+ pressure=25,
560
+ valve=7,
561
+ port=3,
562
+ fil_width=0.5,
563
+ layer_height=1.0,
564
+ lead_in_enabled=True,
565
+ lead_in_length=3.0,
566
+ lead_in_clearance=4.0,
567
+ lead_in_lines=3,
568
+ )
569
+
570
+ moves = _moves_with_colors(gcode_path.read_text())
571
+
572
+ assert moves[:7] == [
573
+ {"start": (0.0, 0.0, 0.0), "end": (-7.0, 0.0, 0.0), "color": 0},
574
+ {"start": (-7.0, 0.0, 0.0), "end": (-4.0, 0.0, 0.0), "color": 255},
575
+ {"start": (-4.0, 0.0, 0.0), "end": (-4.0, 0.5, 0.0), "color": 0},
576
+ {"start": (-4.0, 0.5, 0.0), "end": (-7.0, 0.5, 0.0), "color": 255},
577
+ {"start": (-7.0, 0.5, 0.0), "end": (-7.0, 1.0, 0.0), "color": 0},
578
+ {"start": (-7.0, 1.0, 0.0), "end": (-4.0, 1.0, 0.0), "color": 255},
579
+ {"start": (-4.0, 1.0, 0.0), "end": (0.0, 0.0, 0.0), "color": 0},
580
+ ]
581
+ assert all(move["end"][2] == 0.0 for move in moves[:7])
582
+
583
+ first_z_index = next(index for index, move in enumerate(moves) if move["end"][2] > 0.0)
584
+ assert first_z_index > 7
585
+ assert not any(move["start"][0] < -3.0 or move["end"][0] < -3.0 for move in moves[first_z_index:])
586
 
587
 
588
  def test_gcode_uses_g1_for_print_and_g0_for_travel(tmp_path) -> None:
tiff_to_gcode.py CHANGED
@@ -20,6 +20,12 @@ RASTER_PATTERN_CHOICES = (
20
  RASTER_PATTERN_Y_DIRECTION,
21
  RASTER_PATTERN_WOODPILE,
22
  )
 
 
 
 
 
 
23
 
24
  def _normalize_raster_pattern(pattern: str | None) -> str:
25
  if pattern == RASTER_PATTERN_WOODPILE:
@@ -29,6 +35,12 @@ def _normalize_raster_pattern(pattern: str | None) -> str:
29
  return RASTER_PATTERN_SAME_DIRECTION
30
 
31
 
 
 
 
 
 
 
32
  def _setpress(pressure: float) -> str:
33
  pressure_str = str(int(pressure * 10)).zfill(4)
34
  command_bytes = bytes("08PS " + pressure_str, "utf-8")
@@ -160,6 +172,47 @@ def _sort_key(filename: str) -> int:
160
  return int(digits) if digits else 2**31
161
 
162
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
163
  def _extract_zip_tiffs(zip_path: Path, dest: Path) -> list[Path]:
164
  with zipfile.ZipFile(zip_path) as archive:
165
  archive.extractall(dest)
@@ -306,6 +359,51 @@ def _trace_mask_contours(
306
  return contours
307
 
308
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
309
  def _append_relative_move(
310
  output_list: list[dict],
311
  current_x: float,
@@ -522,6 +620,7 @@ def _contour_pixel_offsets(
522
  raster_pattern: str,
523
  motion_mask: np.ndarray,
524
  contour_mask: np.ndarray,
 
525
  ) -> tuple[float, float]:
526
  if raster_pattern == RASTER_PATTERN_SAME_DIRECTION:
527
  first_row_start = _first_raster_row_start(motion_mask)
@@ -534,8 +633,14 @@ def _contour_pixel_offsets(
534
  # rastered row's starting pixel. Wider lower rows can extend left of
535
  # that point, so using the layer-wide min column shifts pyramid-like
536
  # contours away from the infill.
537
- return 1.0 - first_col, -0.5 - first_row
538
- return 0.0, 0.0
 
 
 
 
 
 
539
 
540
 
541
  def _build_contour_layers(
@@ -559,6 +664,9 @@ def _build_contour_layers(
559
  except (TypeError, ValueError):
560
  owner_idx = source_pos + 1
561
 
 
 
 
562
  source_paths = _contour_source_paths(source, extract_root, source_pos)
563
  for layer_number, path_img in enumerate(path_ref_list):
564
  if layer_number >= len(source_paths):
@@ -590,16 +698,29 @@ def _build_contour_layers(
590
  raster_pattern,
591
  contour_path_img > off_color,
592
  contour_mask,
 
593
  )
594
- contours = _trace_mask_contours(
595
- contour_mask,
596
- pixel_size,
597
- x_offset_px=x_offset_px,
598
- y_offset_px=y_offset_px,
599
- )
 
 
 
 
 
 
 
 
600
  if contours:
601
  contour_layers[layer_number].append(
602
- {"owner_idx": owner_idx, "contours": contours}
 
 
 
 
603
  )
604
 
605
  return contour_layers
@@ -649,8 +770,6 @@ def _append_layer_contours(
649
  current_y,
650
  )
651
 
652
- first_start_x: float | None = None
653
- first_start_y: float | None = None
654
  use_infill_reference = True
655
 
656
  for source in active_sources:
@@ -679,8 +798,6 @@ def _append_layer_contours(
679
  if contour[0] != contour[-1]:
680
  contour = [*contour, contour[0]]
681
  start_x, start_y = contour[0]
682
- if first_start_x is None:
683
- first_start_x, first_start_y = start_x, start_y
684
  use_infill_reference = False
685
  current_x, current_y = _append_relative_move(
686
  output_list,
@@ -700,19 +817,6 @@ def _append_layer_contours(
700
  color,
701
  )
702
 
703
- if first_start_x is not None and (
704
- abs(current_x - first_start_x) > 1e-9
705
- or abs(current_y - (first_start_y or 0.0)) > 1e-9
706
- ):
707
- current_x, current_y = _append_relative_move(
708
- output_list,
709
- current_x,
710
- current_y,
711
- first_start_x,
712
- first_start_y,
713
- 255,
714
- )
715
-
716
  return current_x, current_y
717
 
718
 
@@ -951,6 +1055,10 @@ def generate_snake_path_gcode(
951
  raster_pattern: str | None = RASTER_PATTERN_SAME_DIRECTION,
952
  contour_tiff_sets: list[dict] | None = None,
953
  active_contour_owner: int | None = None,
 
 
 
 
954
  ) -> Path:
955
  zip_path = Path(zip_path)
956
  if not zip_path.exists():
@@ -1146,11 +1254,24 @@ def generate_snake_path_gcode(
1146
  off_color,
1147
  )
1148
 
 
 
 
 
 
 
 
 
 
 
 
 
1149
  gcode_path = work_dir / f"{shape_name}_SnakePath_gcode.txt"
1150
  pressure_cur = float(pressure)
1151
 
1152
  with open(gcode_path, "w") as f:
1153
  f.write("G91\n")
 
1154
  for line in setpress_lines:
1155
  f.write(f"{line}\n")
1156
  for line in pressure_on_lines:
 
20
  RASTER_PATTERN_Y_DIRECTION,
21
  RASTER_PATTERN_WOODPILE,
22
  )
23
+ CONTOUR_MODE_EXACT = "Exact pixel border"
24
+ CONTOUR_MODE_ROW_ENVELOPE = "Shape-optimized row envelope"
25
+ CONTOUR_MODE_CHOICES = (
26
+ CONTOUR_MODE_EXACT,
27
+ CONTOUR_MODE_ROW_ENVELOPE,
28
+ )
29
 
30
  def _normalize_raster_pattern(pattern: str | None) -> str:
31
  if pattern == RASTER_PATTERN_WOODPILE:
 
35
  return RASTER_PATTERN_SAME_DIRECTION
36
 
37
 
38
+ def _normalize_contour_mode(mode: str | None) -> str:
39
+ if mode == CONTOUR_MODE_ROW_ENVELOPE:
40
+ return CONTOUR_MODE_ROW_ENVELOPE
41
+ return CONTOUR_MODE_EXACT
42
+
43
+
44
  def _setpress(pressure: float) -> str:
45
  pressure_str = str(int(pressure * 10)).zfill(4)
46
  command_bytes = bytes("08PS " + pressure_str, "utf-8")
 
172
  return int(digits) if digits else 2**31
173
 
174
 
175
+ def _lead_in_moves(
176
+ enabled: bool,
177
+ length: float,
178
+ clearance: float,
179
+ line_count: int,
180
+ line_spacing: float,
181
+ print_color: int,
182
+ off_color: int,
183
+ ) -> list[dict]:
184
+ if not enabled:
185
+ return []
186
+ lead_length = max(0.0, float(length))
187
+ if lead_length <= 0.0:
188
+ return []
189
+ lead_clearance = max(0.0, float(clearance))
190
+ pass_count = max(1, int(line_count))
191
+ spacing = max(0.0, float(line_spacing))
192
+
193
+ moves: list[dict] = []
194
+ current_x = 0.0
195
+ current_y = 0.0
196
+
197
+ def append_move(dx: float, dy: float, color: int) -> None:
198
+ nonlocal current_x, current_y
199
+ if dx == 0.0 and dy == 0.0:
200
+ return
201
+ moves.append({"X": dx, "Y": dy, "Color": color})
202
+ current_x += dx
203
+ current_y += dy
204
+
205
+ append_move(-(lead_clearance + lead_length), 0.0, off_color)
206
+ direction = 1.0
207
+ for pass_index in range(pass_count):
208
+ append_move(direction * lead_length, 0.0, print_color)
209
+ direction *= -1.0
210
+ if pass_index < pass_count - 1:
211
+ append_move(0.0, spacing, off_color)
212
+ append_move(-current_x, -current_y, off_color)
213
+ return moves
214
+
215
+
216
  def _extract_zip_tiffs(zip_path: Path, dest: Path) -> list[Path]:
217
  with zipfile.ZipFile(zip_path) as archive:
218
  archive.extractall(dest)
 
359
  return contours
360
 
361
 
362
+ def _trace_row_envelope_contours(
363
+ mask: np.ndarray,
364
+ pixel_size: float,
365
+ x_offset_px: float = 0.0,
366
+ y_offset_px: float = 0.0,
367
+ ) -> list[list[tuple[float, float]]]:
368
+ """Trace one outside contour using each active row's left/right extent."""
369
+ if not np.any(mask):
370
+ return []
371
+
372
+ mask = mask.astype(bool)
373
+ first = np.where(mask.any(axis=1), mask.argmax(axis=1), -1)
374
+ last = np.where(
375
+ mask.any(axis=1),
376
+ mask.shape[1] - 1 - np.fliplr(mask).argmax(axis=1),
377
+ -1,
378
+ )
379
+ rows = np.where(first != -1)[0]
380
+ if len(rows) == 0:
381
+ return []
382
+
383
+ points: list[tuple[float, float]] = []
384
+
385
+ for row in rows:
386
+ points.append((float(first[row] - 1), float(row)))
387
+
388
+ bottom = int(rows[-1])
389
+ for col in range(int(first[bottom]) - 1, int(last[bottom]) + 1):
390
+ points.append((float(col), float(bottom) + 0.5))
391
+
392
+ for row in rows[::-1]:
393
+ points.append((float(last[row]), float(row)))
394
+
395
+ top = int(rows[0])
396
+ for col in range(int(last[top]), int(first[top]) - 2, -1):
397
+ points.append((float(col), float(top) - 0.5))
398
+
399
+ return [
400
+ [
401
+ ((x + x_offset_px) * pixel_size, (y + y_offset_px) * pixel_size)
402
+ for x, y in points
403
+ ]
404
+ ]
405
+
406
+
407
  def _append_relative_move(
408
  output_list: list[dict],
409
  current_x: float,
 
620
  raster_pattern: str,
621
  motion_mask: np.ndarray,
622
  contour_mask: np.ndarray,
623
+ contour_mode: str = CONTOUR_MODE_EXACT,
624
  ) -> tuple[float, float]:
625
  if raster_pattern == RASTER_PATTERN_SAME_DIRECTION:
626
  first_row_start = _first_raster_row_start(motion_mask)
 
633
  # rastered row's starting pixel. Wider lower rows can extend left of
634
  # that point, so using the layer-wide min column shifts pyramid-like
635
  # contours away from the infill.
636
+ x_offset, y_offset = 1.0 - first_col, -0.5 - first_row
637
+ else:
638
+ x_offset, y_offset = 0.0, 0.0
639
+
640
+ if contour_mode == CONTOUR_MODE_ROW_ENVELOPE:
641
+ x_offset += 1.0
642
+ y_offset += 0.5
643
+ return x_offset, y_offset
644
 
645
 
646
  def _build_contour_layers(
 
664
  except (TypeError, ValueError):
665
  owner_idx = source_pos + 1
666
 
667
+ contour_mode = _normalize_contour_mode(
668
+ source.get("contour_mode") or source.get("mode")
669
+ )
670
  source_paths = _contour_source_paths(source, extract_root, source_pos)
671
  for layer_number, path_img in enumerate(path_ref_list):
672
  if layer_number >= len(source_paths):
 
698
  raster_pattern,
699
  contour_path_img > off_color,
700
  contour_mask,
701
+ contour_mode,
702
  )
703
+ if contour_mode == CONTOUR_MODE_ROW_ENVELOPE:
704
+ contours = _trace_row_envelope_contours(
705
+ contour_mask,
706
+ pixel_size,
707
+ x_offset_px=x_offset_px,
708
+ y_offset_px=y_offset_px,
709
+ )
710
+ else:
711
+ contours = _trace_mask_contours(
712
+ contour_mask,
713
+ pixel_size,
714
+ x_offset_px=x_offset_px,
715
+ y_offset_px=y_offset_px,
716
+ )
717
  if contours:
718
  contour_layers[layer_number].append(
719
+ {
720
+ "owner_idx": owner_idx,
721
+ "contour_mode": contour_mode,
722
+ "contours": contours,
723
+ }
724
  )
725
 
726
  return contour_layers
 
770
  current_y,
771
  )
772
 
 
 
773
  use_infill_reference = True
774
 
775
  for source in active_sources:
 
798
  if contour[0] != contour[-1]:
799
  contour = [*contour, contour[0]]
800
  start_x, start_y = contour[0]
 
 
801
  use_infill_reference = False
802
  current_x, current_y = _append_relative_move(
803
  output_list,
 
817
  color,
818
  )
819
 
 
 
 
 
 
 
 
 
 
 
 
 
 
820
  return current_x, current_y
821
 
822
 
 
1055
  raster_pattern: str | None = RASTER_PATTERN_SAME_DIRECTION,
1056
  contour_tiff_sets: list[dict] | None = None,
1057
  active_contour_owner: int | None = None,
1058
+ lead_in_enabled: bool = False,
1059
+ lead_in_length: float = 5.0,
1060
+ lead_in_clearance: float = 5.0,
1061
+ lead_in_lines: int = 3,
1062
  ) -> Path:
1063
  zip_path = Path(zip_path)
1064
  if not zip_path.exists():
 
1254
  off_color,
1255
  )
1256
 
1257
+ lead_in = _lead_in_moves(
1258
+ lead_in_enabled,
1259
+ lead_in_length,
1260
+ lead_in_clearance,
1261
+ lead_in_lines,
1262
+ fil_width,
1263
+ 255,
1264
+ off_color,
1265
+ )
1266
+ if lead_in:
1267
+ gcode_list = [*lead_in, *gcode_list]
1268
+
1269
  gcode_path = work_dir / f"{shape_name}_SnakePath_gcode.txt"
1270
  pressure_cur = float(pressure)
1271
 
1272
  with open(gcode_path, "w") as f:
1273
  f.write("G91\n")
1274
+ f.write(_valve_cmd(valve, 0))
1275
  for line in setpress_lines:
1276
  f.write(f"{line}\n")
1277
  for line in pressure_on_lines: