CyGuy8 commited on
Commit
6b28240
·
1 Parent(s): 4f71a6a

Add multi-nozzle shape splitting workflow

Browse files
README.md CHANGED
@@ -48,8 +48,9 @@ Then open the local Gradio URL in your browser, upload STL files or load the bun
48
  - Encodes material as black (`0`) and empty space as white (`255`) in each TIFF slice
49
  - Lets you step through the slice stack in the browser
50
  - Exports a ZIP containing the generated TIFF images
51
- - Combines generated stacks into a reference TIFF stack
52
- - Converts generated TIFF ZIPs into G-code files with pressure, valve, and port settings per shape from the Shape Settings table
 
53
  - 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)
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
@@ -61,7 +62,7 @@ Then open the local Gradio URL in your browser, upload STL files or load the bun
61
 
62
  ### Reference TIFF Stack Alignment
63
 
64
- When you click **Generate Reference TIFF Stack**, the app combines available TIFF stacks layer-by-layer.
65
 
66
  - If source TIFFs have different dimensions, each layer is placed on a canvas using the largest width and height.
67
  - Layers are centered in X and Y before merging.
@@ -69,6 +70,14 @@ When you click **Generate Reference TIFF Stack**, the app combines available TIF
69
  - Alignment is centered image placement, not bottom-left anchoring.
70
  - If image-size differences are odd, centering may produce a one-pixel shift due to integer rounding.
71
 
 
 
 
 
 
 
 
 
72
  ### G-code XY Step Size
73
 
74
  - G-code generation uses the slicer's `Pixel Size/Fill Width` for XY step distance by passing `fil_width=pixel_size` into `generate_snake_path_gcode()`.
@@ -78,10 +87,10 @@ When you click **Generate Reference TIFF Stack**, the app combines available TIF
78
  - Generated G-code starts in relative coordinate mode (`G91`).
79
  - `G0` is travel and `G1` is print/feed.
80
  - The app generates print/feed moves from material pixels and travel moves between material regions.
81
- - Generated files include pressure preset commands and WAGO valve commands based on the selected pressure, valve, and port.
82
  - Pressure increases by `0.1` psi per layer by default.
83
  - **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.
84
- - **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. Requires generating the Reference TIFF Stack on the first tab first; shapes are skipped with a message if it is missing.
85
  - **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.
86
 
87
  ### Print vs Travel Classification
@@ -102,7 +111,7 @@ The G-code visualization tab renders generated shape G-code or an uploaded `.txt
102
 
103
  ### Parallel Printing Visualization
104
 
105
- The fourth tab plots the generated shapes' G-code at once using the nozzle spacing configured on the TIFF-to-G-code tab, each in its own color. Like the visualization tab it has a fast **Line Plot** and an animated **Tube Plot**; the animation advances all parts on a shared cumulative-path-length timeline, so a shorter part finishes first.
106
 
107
  It can also **export the animation as a GIF**, rendered server-side with Matplotlib (the `Agg` CPU backend — no WebGL, no headless browser, and no `ffmpeg`, so it works locally and on Hugging Face). The GIF is line-style with faint grey travel and white, black-outlined nozzle markers drawn on top; controls cover duration, frames per second, elevation/azimuth viewing angle, and travel opacity (0 hides travel).
108
 
 
48
  - Encodes material as black (`0`) and empty space as white (`255`) in each TIFF slice
49
  - Lets you step through the slice stack in the browser
50
  - Exports a ZIP containing the generated TIFF images
51
+ - Automatically combines generated stacks into a reference TIFF stack when TIFF stacks are generated
52
+ - Splits one generated TIFF stack into an editable row/column grid for multi-nozzle printing of one large shape
53
+ - Converts generated TIFF ZIPs into G-code files with pressure, valve, nozzle, and port settings per shape from the Shape Settings table
54
  - 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)
55
  - Calculates X/Y nozzle spacing from an editable adjacent-pair spacing table, then visualizes the resulting nozzle layout
56
  - Previews selected generated G-code inline
 
62
 
63
  ### Reference TIFF Stack Alignment
64
 
65
+ When you click **Generate TIFF Stacks**, the app automatically combines available TIFF stacks layer-by-layer into the Reference TIFF Stack. The **Generate Reference TIFF Stack** button can still rebuild it manually from the current shape stacks.
66
 
67
  - If source TIFFs have different dimensions, each layer is placed on a canvas using the largest width and height.
68
  - Layers are centered in X and Y before merging.
 
70
  - Alignment is centered image placement, not bottom-left anchoring.
71
  - If image-size differences are odd, centering may produce a one-pixel shift due to integer rounding.
72
 
73
+ ### Single Shape Multi-Nozzle
74
+
75
+ The **Single Shape Multi-Nozzle** 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**.
76
+
77
+ - 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.
78
+ - The selected shape is replaced in Shape Settings by one generated record per grid cell, named by row and column.
79
+ - 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.
80
+
81
  ### G-code XY Step Size
82
 
83
  - G-code generation uses the slicer's `Pixel Size/Fill Width` for XY step distance by passing `fil_width=pixel_size` into `generate_snake_path_gcode()`.
 
87
  - Generated G-code starts in relative coordinate mode (`G91`).
88
  - `G0` is travel and `G1` is print/feed.
89
  - The app generates print/feed moves from material pixels and travel moves between material regions.
90
+ - 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.
91
  - Pressure increases by `0.1` psi per layer by default.
92
  - **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.
93
+ - **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.
94
  - **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.
95
 
96
  ### Print vs Travel Classification
 
111
 
112
  ### Parallel Printing Visualization
113
 
114
+ The fourth tab plots the generated shapes' G-code at once using the nozzle spacing configured on the TIFF-to-G-code tab, each in its own color. Shape Settings maps each STL to a nozzle number, so multiple shapes can share one nozzle offset while valves remain independent. Like the visualization tab it has a fast **Line Plot** and an animated **Tube Plot**; the animation advances all parts on a shared cumulative-path-length timeline, so a shorter part finishes first.
115
 
116
  It can also **export the animation as a GIF**, rendered server-side with Matplotlib (the `Agg` CPU backend — no WebGL, no headless browser, and no `ffmpeg`, so it works locally and on Hugging Face). The GIF is line-style with faint grey travel and white, black-outlined nozzle markers drawn on top; controls cover duration, frames per second, elevation/azimuth viewing angle, and travel opacity (0 hides travel).
117
 
app.py CHANGED
@@ -4,6 +4,7 @@ import tempfile
4
  import math
5
  import time
6
  import warnings
 
7
  from pathlib import Path
8
  from typing import Any
9
 
@@ -134,7 +135,7 @@ APP_CSS = """
134
  }
135
 
136
  #shape-settings-table table tbody tr td:last-child,
137
- #shape-settings-table [role="gridcell"]:nth-child(10n) {
138
  color: #ffffff !important;
139
  cursor: pointer;
140
  font-size: 0 !important;
@@ -143,7 +144,7 @@ APP_CSS = """
143
  }
144
 
145
  #shape-settings-table table tbody tr td:last-child::after,
146
- #shape-settings-table [role="gridcell"]:nth-child(10n)::after {
147
  content: "X";
148
  display: inline-flex;
149
  align-items: center;
@@ -159,9 +160,9 @@ APP_CSS = """
159
  }
160
 
161
  #shape-settings-table table tbody tr td:last-child input,
162
- #shape-settings-table [role="gridcell"]:nth-child(10n) input,
163
  #shape-settings-table table tbody tr td:last-child textarea,
164
- #shape-settings-table [role="gridcell"]:nth-child(10n) textarea {
165
  display: none !important;
166
  }
167
 
@@ -295,7 +296,7 @@ APP_HEAD = """
295
  });
296
  }
297
  function suppressDeleteCellEditor(event) {
298
- var cell = event.target && event.target.closest ? event.target.closest('#shape-settings-table td:last-child, #shape-settings-table [role="gridcell"]:nth-child(10n)') : null;
299
  if (!cell) return;
300
  setTimeout(function () {
301
  if (document.activeElement && cell.contains(document.activeElement)) {
@@ -1377,8 +1378,23 @@ def _resolve_nozzle_layout(
1377
  if not parts:
1378
  return offsets, spacings
1379
 
1380
- ordered = sorted(parts, key=lambda part: part["idx"])
1381
- offsets[ordered[0]["idx"]] = (0.0, 0.0)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1382
 
1383
  first_spacing = (float(part_gap_12_x or 0.0), float(part_gap_12_y or 0.0))
1384
  raw_pairs: list[tuple[float, float]] = [
@@ -1390,37 +1406,41 @@ def _resolve_nozzle_layout(
1390
  float(extra_pair_gaps[index] or 0.0),
1391
  float(extra_pair_gaps[index + 1] or 0.0) if index + 1 < len(extra_pair_gaps) else 0.0,
1392
  ))
1393
- max_pair_count = max((int(part.get("idx", 0)) for part in parts), default=1) - 1
1394
  while len(raw_pairs) < max_pair_count:
1395
  raw_pairs.append(first_spacing)
1396
  if same_spacing:
1397
  raw_pairs = [first_spacing for _ in raw_pairs]
1398
 
1399
  pair_spacing = {
1400
- (index + 1, index + 2): raw_pairs[index]
1401
- for index in range(len(raw_pairs))
1402
  }
1403
 
1404
  def spacing_between(prev_idx: int, cur_idx: int) -> tuple[float, float]:
1405
  if (prev_idx, cur_idx) in pair_spacing:
1406
  return pair_spacing[(prev_idx, cur_idx)]
1407
- if cur_idx <= prev_idx:
 
 
 
 
 
1408
  return 0.0, 0.0
1409
  total_x = 0.0
1410
  total_y = 0.0
1411
- for pair_idx in range(prev_idx, cur_idx):
1412
- step_x, step_y = pair_spacing.get((pair_idx, pair_idx + 1), first_spacing)
 
1413
  total_x += step_x
1414
  total_y += step_y
1415
  return total_x, total_y
1416
 
1417
- for previous, current in zip(ordered, ordered[1:]):
1418
- prev_idx = previous["idx"]
1419
- cur_idx = current["idx"]
1420
  gap_x, y_step = spacing_between(prev_idx, cur_idx)
1421
  prev_offset_x, prev_offset_y = offsets[prev_idx]
1422
- (_, _, _), (prev_xmax, _prev_ymax, _) = previous["parsed"]["bounds"]
1423
- (cur_xmin, _cur_ymin, _), (_, _, _) = current["parsed"]["bounds"]
1424
  dx = (prev_xmax + prev_offset_x + gap_x) - cur_xmin
1425
  dy = prev_offset_y + y_step
1426
  offsets[cur_idx] = (dx, dy)
@@ -1442,8 +1462,9 @@ def _format_shape_dimensions(parts: list[dict]) -> list[str]:
1442
  lines = ["**Shape dimensions from generated G-code:**"]
1443
  for part in sorted(parts, key=lambda item: item["idx"]):
1444
  (xmin, ymin, zmin), (xmax, ymax, zmax) = part["parsed"]["bounds"]
 
1445
  lines.append(
1446
- f"Shape {part['idx']}: X {xmax - xmin:.2f} mm, "
1447
  f"Y {ymax - ymin:.2f} mm, Z {zmax - zmin:.2f} mm."
1448
  )
1449
  return lines
@@ -1463,7 +1484,7 @@ def _format_nozzle_spacing_status(
1463
  lines.append(f"Nozzle {idx}: X {x:.2f} mm, Y {y:.2f} mm.")
1464
 
1465
  if not spacings:
1466
- lines.append("Generate G-code for at least two shapes to calculate nozzle spacing.")
1467
  return " \n".join(lines)
1468
 
1469
  lines.append("**Nozzle-to-nozzle distances:**")
@@ -1614,7 +1635,142 @@ def generate_reference_stack(
1614
  return ref_state, slider, label, preview
1615
 
1616
 
1617
- SHAPE_SETTINGS_HEADERS = ["Shape", "STL", "Target X (mm)", "Target Y (mm)", "Target Z (mm)", "Pressure (psi)", "Valve", "Port", "Color", "Delete"]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1618
  SIMPLE_NOZZLE_SPACING_HEADERS = [
1619
  "Spacing Mode",
1620
  "Applies To",
@@ -1622,8 +1778,8 @@ SIMPLE_NOZZLE_SPACING_HEADERS = [
1622
  "Y nozzle spacing (mm)",
1623
  ]
1624
  ADVANCED_NOZZLE_SPACING_HEADERS = [
1625
- "From Shape",
1626
- "To Shape",
1627
  "X edge spacing (mm)",
1628
  "Y nozzle spacing (mm)",
1629
  ]
@@ -1640,6 +1796,39 @@ def _normalise_rows(table: Any) -> list[list[Any]]:
1640
  return list(table or [])
1641
 
1642
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1643
  def _file_path_value(file_value: Any) -> str | None:
1644
  if not file_value:
1645
  return None
@@ -1710,8 +1899,10 @@ def _records_from_files(files: Any, previous_records: list[dict] | None = None)
1710
  "last_scaled_axis": previous.get("last_scaled_axis", "target_x"),
1711
  "pressure": previous.get("pressure", 25.0),
1712
  "valve": previous.get("valve", 4),
 
1713
  "port": previous.get("port", 1),
1714
  "color": previous.get("color", _default_color(index)),
 
1715
  "tiff_state": previous.get("tiff_state", _empty_state()),
1716
  "zip_path": previous.get("zip_path"),
1717
  "gcode_path": previous.get("gcode_path"),
@@ -1738,8 +1929,10 @@ def _shape_settings_rows(records: list[dict]) -> list[list[Any]]:
1738
  record.get("target_z", DEFAULT_TARGET_EXTENTS[2]),
1739
  record.get("pressure", 25.0),
1740
  record.get("valve", 4),
 
1741
  record.get("port", 1),
1742
  record.get("color", _default_color(record["idx"])),
 
1743
  "Delete",
1744
  ]
1745
  for record in records
@@ -1773,20 +1966,33 @@ def _apply_shape_settings(records: list[dict], settings_table: Any) -> list[dict
1773
  ("target_z", 4, DEFAULT_TARGET_EXTENTS[2]),
1774
  ("pressure", 5, 25.0),
1775
  ("valve", 6, 4),
1776
- ("port", 7, 1),
1777
  ):
1778
  try:
1779
  copy[key] = float(row[pos])
1780
  except (IndexError, TypeError, ValueError):
1781
  copy[key] = copy.get(key, default)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1782
  try:
1783
- copy["valve"] = int(float(copy["valve"]))
1784
- copy["port"] = int(float(copy["port"]))
1785
- except (TypeError, ValueError):
1786
- copy["valve"] = 4
1787
- copy["port"] = 1
1788
- if len(row) > 8 and row[8]:
1789
- copy["color"] = str(row[8])
1790
  updated.append(copy)
1791
  return updated
1792
 
@@ -1823,8 +2029,23 @@ def _last_edited_target_axes(records: list[dict] | None, settings_table: Any) ->
1823
  return edited_axes
1824
 
1825
 
1826
- def _shape_spacing_label(record: dict) -> str:
1827
- return f"Shape {record.get('idx', '?')}: {record.get('name') or 'Unnamed'}"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1828
 
1829
 
1830
  def _spacing_pairs_from_table(spacing_table: Any) -> list[tuple[float, float]]:
@@ -1849,18 +2070,19 @@ def _spacing_table_update(records: list[dict], existing_table: Any | None = None
1849
  if not use_individual_spacing:
1850
  return gr.update(
1851
  headers=SIMPLE_NOZZLE_SPACING_HEADERS,
1852
- value=[["Same spacing", "All neighboring shapes", first_pair[0], first_pair[1]]],
1853
  row_count=(1, "fixed"),
1854
  column_count=(len(SIMPLE_NOZZLE_SPACING_HEADERS), "fixed"),
1855
  label="Nozzle Spacing",
1856
  )
1857
 
1858
  rows: list[list[Any]] = []
1859
- for index, (first, second) in enumerate(zip(records, records[1:])):
 
1860
  gap_x, gap_y = pairs[index] if index < len(pairs) else first_pair
1861
  rows.append([
1862
- _shape_spacing_label(first),
1863
- _shape_spacing_label(second),
1864
  gap_x,
1865
  gap_y,
1866
  ])
@@ -2085,6 +2307,21 @@ def normalize_shape_dimensions_for_mode(
2085
  return normalized, _shape_settings_rows(normalized)
2086
 
2087
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2088
  def show_selected_model(
2089
  records: list[dict] | None,
2090
  selected: str | None,
@@ -2148,7 +2385,19 @@ def generate_dynamic_stacks(
2148
  ) -> tuple:
2149
  records = _apply_shape_settings(records or [], settings_table)
2150
  if not records:
2151
- return records, [], "Upload at least one STL first.", _dropdown_update(records), _reset_slider(), "No slice stack loaded yet.", None
 
 
 
 
 
 
 
 
 
 
 
 
2152
  total = len(records)
2153
  messages: list[str] = []
2154
  for pos, record in enumerate(records):
@@ -2182,6 +2431,11 @@ def generate_dynamic_stacks(
2182
  messages.append(f"Shape {record['idx']}: wrote `{stack.zip_path.name}`.")
2183
  except Exception as exc:
2184
  messages.append(f"Shape {record['idx']}: failed ({exc}).")
 
 
 
 
 
2185
  first_state = records[0].get("tiff_state") or _empty_state()
2186
  label, preview = _render_selected_slice(first_state, 0)
2187
  slider = gr.update(
@@ -2199,6 +2453,10 @@ def generate_dynamic_stacks(
2199
  slider,
2200
  label,
2201
  preview,
 
 
 
 
2202
  )
2203
 
2204
 
@@ -2207,6 +2465,187 @@ def generate_dynamic_reference_stack(records: list[dict] | None, progress: gr.Pr
2207
  return generate_reference_stack(*states, progress=progress)
2208
 
2209
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2210
  def generate_dynamic_gcode(
2211
  records: list[dict] | None,
2212
  settings_table: Any,
@@ -2219,7 +2658,11 @@ def generate_dynamic_gcode(
2219
  ) -> tuple:
2220
  records = _apply_shape_settings(records or [], settings_table)
2221
  motion_tiffs = (ref_state or {}).get("tiff_paths") if use_reference_motion else None
 
2222
  messages: list[str] = []
 
 
 
2223
  for record in records:
2224
  zip_path = record.get("zip_path")
2225
  if not zip_path:
@@ -2241,6 +2684,8 @@ def generate_dynamic_gcode(
2241
  all_g1=bool(all_g1),
2242
  motion_tiffs=motion_tiffs,
2243
  raster_pattern=raster_pattern,
 
 
2244
  )
2245
  record["gcode_path"] = str(gcode_path)
2246
  messages.append(f"Shape {record['idx']}: wrote `{gcode_path.name}`.")
@@ -2285,8 +2730,9 @@ def _parts_from_records(records: list[dict] | None) -> tuple[list[dict], list[st
2285
  if not parsed.get("point_count"):
2286
  messages.append(f"Shape {idx}: no G0/G1 moves found.")
2287
  continue
2288
- parts.append({"idx": idx, "color": record.get("color", _default_color(idx)), "parsed": parsed})
2289
- messages.append(f"Shape {idx}: {parsed['point_count']} moves, {parsed.get('layer_count', 0)} layer(s).")
 
2290
  return parts, messages
2291
 
2292
 
@@ -2480,6 +2926,7 @@ def build_dynamic_demo() -> gr.Blocks:
2480
  shape_records = gr.State([])
2481
  last_shape_delete_at = gr.State(0.0)
2482
  ref_state = gr.State(_empty_state())
 
2483
 
2484
  with gr.Tab("STL to TIFF Slicer"):
2485
  gr.Markdown(
@@ -2512,6 +2959,7 @@ def build_dynamic_demo() -> gr.Blocks:
2512
  value=[],
2513
  row_count=(0, "dynamic"),
2514
  column_count=(len(SHAPE_SETTINGS_HEADERS), "fixed"),
 
2515
  interactive=True,
2516
  label="Shape Settings",
2517
  elem_id="shape-settings-table",
@@ -2562,15 +3010,44 @@ def build_dynamic_demo() -> gr.Blocks:
2562
  ref_next_button = gr.Button("Next", scale=1, min_width=90, size="sm")
2563
  ref_slice_slider = gr.Slider(label="Slice Index", minimum=0, maximum=0, value=0, step=1, interactive=False)
2564
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2565
  with gr.Tab("TIFF Slices to GCode"):
2566
  gr.Markdown(
2567
  """
2568
  # TIFF Slices to GCode
2569
- Generate G-code for every shape with a TIFF stack. Pressure, valve, port, and color come from the Shape Settings table.
2570
  """
2571
  )
2572
  gcode_use_ref_motion = gr.Checkbox(
2573
- label="Use Reference Stack for motion (all shapes share one nozzle path; each dispenses only its own geometry). Generate the Reference TIFF Stack on the first tab first.",
2574
  value=True,
2575
  )
2576
  gcode_all_g1 = gr.Checkbox(label="Move at one constant speed (no fast travel moves)", value=True)
@@ -2593,7 +3070,7 @@ def build_dynamic_demo() -> gr.Blocks:
2593
  nozzle_use_individual_spacing = gr.Checkbox(label="Advanced Settings", value=False)
2594
  nozzle_spacing_table = gr.Dataframe(
2595
  headers=NOZZLE_SPACING_HEADERS,
2596
- value=[["Same spacing", "All neighboring shapes", 5.0, 0.0]],
2597
  row_count=(1, "fixed"),
2598
  column_count=(len(NOZZLE_SPACING_HEADERS), "fixed"),
2599
  interactive=True,
@@ -2684,20 +3161,40 @@ def build_dynamic_demo() -> gr.Blocks:
2684
  parallel_mode = gr.State("tube")
2685
 
2686
  shape_sync_outputs = [shape_records, shape_settings, nozzle_spacing_table, selected_shape, gcode_text_source, gcode_source, tiff_downloads, gcode_downloads]
2687
- 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)
2688
- 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)
2689
- 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])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2690
  shape_settings.select(
2691
  fn=delete_shape_from_settings,
2692
  inputs=[shape_records, shape_settings, nozzle_spacing_table, nozzle_use_individual_spacing, last_shape_delete_at],
2693
  outputs=[stl_upload, *shape_sync_outputs, last_shape_delete_at],
 
 
 
 
 
2694
  )
2695
 
2696
  preview_inputs = [shape_records, selected_shape, shape_settings, model_opacity, scale_mode]
2697
  shape_settings.change(
2698
- fn=normalize_shape_dimensions_for_mode,
2699
- inputs=[shape_records, shape_settings, scale_mode],
2700
- outputs=[shape_records, shape_settings],
2701
  queue=False,
2702
  )
2703
  selected_shape.change(fn=show_selected_model, inputs=preview_inputs, outputs=[model_viewer, model_details, slice_slider, slice_label, slice_preview])
@@ -2730,13 +3227,67 @@ def build_dynamic_demo() -> gr.Blocks:
2730
  generate_button.click(
2731
  fn=generate_dynamic_stacks,
2732
  inputs=[shape_records, shape_settings, layer_height, pixel_size, scale_mode],
2733
- outputs=[shape_records, tiff_downloads, slicer_status, selected_shape, slice_slider, slice_label, slice_preview],
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2734
  )
2735
  ref_generate_button.click(fn=generate_dynamic_reference_stack, inputs=[shape_records], outputs=[ref_state, ref_slice_slider, ref_slice_label, ref_slice_preview])
2736
  ref_slice_slider.release(fn=jump_to_slice, inputs=[ref_state, ref_slice_slider], outputs=[ref_slice_label, ref_slice_preview], queue=False)
2737
  ref_prev_button.click(fn=lambda sv, idx: shift_slice(sv, idx, -1), inputs=[ref_state, ref_slice_slider], outputs=[ref_slice_slider, ref_slice_label, ref_slice_preview], queue=False)
2738
  ref_next_button.click(fn=lambda sv, idx: shift_slice(sv, idx, 1), inputs=[ref_state, ref_slice_slider], outputs=[ref_slice_slider, ref_slice_label, ref_slice_preview], queue=False)
2739
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2740
  gcode_button.click(
2741
  fn=generate_dynamic_gcode,
2742
  inputs=[shape_records, shape_settings, gcode_all_g1, gcode_use_ref_motion, gcode_raster_pattern, ref_state, layer_height, pixel_size],
 
4
  import math
5
  import time
6
  import warnings
7
+ import zipfile
8
  from pathlib import Path
9
  from typing import Any
10
 
 
135
  }
136
 
137
  #shape-settings-table table tbody tr td:last-child,
138
+ #shape-settings-table [role="gridcell"]:nth-child(12n) {
139
  color: #ffffff !important;
140
  cursor: pointer;
141
  font-size: 0 !important;
 
144
  }
145
 
146
  #shape-settings-table table tbody tr td:last-child::after,
147
+ #shape-settings-table [role="gridcell"]:nth-child(12n)::after {
148
  content: "X";
149
  display: inline-flex;
150
  align-items: center;
 
160
  }
161
 
162
  #shape-settings-table table tbody tr td:last-child input,
163
+ #shape-settings-table [role="gridcell"]:nth-child(12n) input,
164
  #shape-settings-table table tbody tr td:last-child textarea,
165
+ #shape-settings-table [role="gridcell"]:nth-child(12n) textarea {
166
  display: none !important;
167
  }
168
 
 
296
  });
297
  }
298
  function suppressDeleteCellEditor(event) {
299
+ var cell = event.target && event.target.closest ? event.target.closest('#shape-settings-table td:last-child, #shape-settings-table [role="gridcell"]:nth-child(12n)') : null;
300
  if (!cell) return;
301
  setTimeout(function () {
302
  if (document.activeElement && cell.contains(document.activeElement)) {
 
1378
  if not parts:
1379
  return offsets, spacings
1380
 
1381
+ grouped: dict[int, list[dict]] = {}
1382
+ for part in parts:
1383
+ grouped.setdefault(_record_nozzle_number(part, int(part.get("idx", 1) or 1)), []).append(part)
1384
+ ordered_nozzles = sorted(grouped)
1385
+ offsets[ordered_nozzles[0]] = (0.0, 0.0)
1386
+
1387
+ def nozzle_bounds(nozzle: int) -> tuple[tuple[float, float, float], tuple[float, float, float]]:
1388
+ mins: list[tuple[float, float, float]] = []
1389
+ maxs: list[tuple[float, float, float]] = []
1390
+ for part in grouped[nozzle]:
1391
+ part_min, part_max = part["parsed"]["bounds"]
1392
+ mins.append(part_min)
1393
+ maxs.append(part_max)
1394
+ return (
1395
+ tuple(min(values) for values in zip(*mins)),
1396
+ tuple(max(values) for values in zip(*maxs)),
1397
+ )
1398
 
1399
  first_spacing = (float(part_gap_12_x or 0.0), float(part_gap_12_y or 0.0))
1400
  raw_pairs: list[tuple[float, float]] = [
 
1406
  float(extra_pair_gaps[index] or 0.0),
1407
  float(extra_pair_gaps[index + 1] or 0.0) if index + 1 < len(extra_pair_gaps) else 0.0,
1408
  ))
1409
+ max_pair_count = max(0, len(ordered_nozzles) - 1)
1410
  while len(raw_pairs) < max_pair_count:
1411
  raw_pairs.append(first_spacing)
1412
  if same_spacing:
1413
  raw_pairs = [first_spacing for _ in raw_pairs]
1414
 
1415
  pair_spacing = {
1416
+ (ordered_nozzles[index], ordered_nozzles[index + 1]): raw_pairs[index]
1417
+ for index in range(min(len(raw_pairs), max_pair_count))
1418
  }
1419
 
1420
  def spacing_between(prev_idx: int, cur_idx: int) -> tuple[float, float]:
1421
  if (prev_idx, cur_idx) in pair_spacing:
1422
  return pair_spacing[(prev_idx, cur_idx)]
1423
+ try:
1424
+ start_pos = ordered_nozzles.index(prev_idx)
1425
+ end_pos = ordered_nozzles.index(cur_idx)
1426
+ except ValueError:
1427
+ return 0.0, 0.0
1428
+ if end_pos <= start_pos:
1429
  return 0.0, 0.0
1430
  total_x = 0.0
1431
  total_y = 0.0
1432
+ for pair_pos in range(start_pos, end_pos):
1433
+ pair = (ordered_nozzles[pair_pos], ordered_nozzles[pair_pos + 1])
1434
+ step_x, step_y = pair_spacing.get(pair, first_spacing)
1435
  total_x += step_x
1436
  total_y += step_y
1437
  return total_x, total_y
1438
 
1439
+ for prev_idx, cur_idx in zip(ordered_nozzles, ordered_nozzles[1:]):
 
 
1440
  gap_x, y_step = spacing_between(prev_idx, cur_idx)
1441
  prev_offset_x, prev_offset_y = offsets[prev_idx]
1442
+ (_, _, _), (prev_xmax, _prev_ymax, _) = nozzle_bounds(prev_idx)
1443
+ (cur_xmin, _cur_ymin, _), (_, _, _) = nozzle_bounds(cur_idx)
1444
  dx = (prev_xmax + prev_offset_x + gap_x) - cur_xmin
1445
  dy = prev_offset_y + y_step
1446
  offsets[cur_idx] = (dx, dy)
 
1462
  lines = ["**Shape dimensions from generated G-code:**"]
1463
  for part in sorted(parts, key=lambda item: item["idx"]):
1464
  (xmin, ymin, zmin), (xmax, ymax, zmax) = part["parsed"]["bounds"]
1465
+ nozzle = _record_nozzle_number(part, int(part.get("idx", 1) or 1))
1466
  lines.append(
1467
+ f"Shape {part['idx']} (Nozzle {nozzle}): X {xmax - xmin:.2f} mm, "
1468
  f"Y {ymax - ymin:.2f} mm, Z {zmax - zmin:.2f} mm."
1469
  )
1470
  return lines
 
1484
  lines.append(f"Nozzle {idx}: X {x:.2f} mm, Y {y:.2f} mm.")
1485
 
1486
  if not spacings:
1487
+ lines.append("Generate G-code for at least two nozzles to calculate nozzle spacing.")
1488
  return " \n".join(lines)
1489
 
1490
  lines.append("**Nozzle-to-nozzle distances:**")
 
1635
  return ref_state, slider, label, preview
1636
 
1637
 
1638
+ def _zip_tiff_paths(tiff_paths: list[Path], zip_path: Path) -> None:
1639
+ with zipfile.ZipFile(zip_path, mode="w", compression=zipfile.ZIP_DEFLATED) as archive:
1640
+ for tiff_path in tiff_paths:
1641
+ archive.write(tiff_path, arcname=tiff_path.name)
1642
+
1643
+
1644
+ def _partition_length(length: int, count: int) -> list[tuple[int, int]]:
1645
+ base = length // count
1646
+ remainder = length % count
1647
+ spans: list[tuple[int, int]] = []
1648
+ start = 0
1649
+ for index in range(count):
1650
+ size = base + (1 if index < remainder else 0)
1651
+ end = start + size
1652
+ spans.append((start, end))
1653
+ start = end
1654
+ return spans
1655
+
1656
+
1657
+ def split_tiff_stack_grid(
1658
+ state: ViewerState,
1659
+ base_name: str = "split_shape",
1660
+ columns: float = 2,
1661
+ rows: float = 1,
1662
+ progress: gr.Progress = gr.Progress(),
1663
+ ) -> list[dict[str, Any]]:
1664
+ tiff_paths = [Path(path) for path in state.get("tiff_paths", [])]
1665
+ if not tiff_paths:
1666
+ raise ValueError("Generate a TIFF stack for the selected shape before splitting it.")
1667
+
1668
+ with Image.open(tiff_paths[0]) as first_image:
1669
+ width, height = first_image.size
1670
+ column_count = max(1, _coerce_int(columns, 2))
1671
+ row_count = max(1, _coerce_int(rows, 1))
1672
+ if column_count > width:
1673
+ raise ValueError(f"Cannot split {width}-pixel-wide TIFF slices into {column_count} columns.")
1674
+ if row_count > height:
1675
+ raise ValueError(f"Cannot split {height}-pixel-tall TIFF slices into {row_count} rows.")
1676
+
1677
+ safe_name = "".join(ch if ch.isalnum() or ch in {"-", "_"} else "_" for ch in base_name).strip("_") or "split_shape"
1678
+ output_dir = Path(tempfile.mkdtemp(prefix=f"{safe_name}_split_"))
1679
+ x_spans = _partition_length(width, column_count)
1680
+ y_spans = _partition_length(height, row_count)
1681
+ pieces: list[dict[str, Any]] = []
1682
+ for row_index, (y_start, y_end) in enumerate(y_spans, start=1):
1683
+ for col_index, (x_start, x_end) in enumerate(x_spans, start=1):
1684
+ piece_dir = output_dir / f"r{row_index:02d}_c{col_index:02d}_tiff_slices"
1685
+ piece_dir.mkdir(parents=True, exist_ok=True)
1686
+ pieces.append({
1687
+ "row": row_index,
1688
+ "col": col_index,
1689
+ "x_start": x_start,
1690
+ "x_end": x_end,
1691
+ "y_start": y_start,
1692
+ "y_end": y_end,
1693
+ "tiff_dir": piece_dir,
1694
+ "tiff_paths": [],
1695
+ })
1696
+
1697
+ for index, source_path in enumerate(tiff_paths):
1698
+ progress(index / max(len(tiff_paths), 1), desc=f"Splitting layer {index + 1}/{len(tiff_paths)}")
1699
+ with Image.open(source_path) as image:
1700
+ layer = image.convert("L")
1701
+ if layer.size != (width, height):
1702
+ raise ValueError("All TIFF slices must have the same dimensions to split the stack.")
1703
+
1704
+ for piece in pieces:
1705
+ piece_image = layer.crop((piece["x_start"], piece["y_start"], piece["x_end"], piece["y_end"]))
1706
+ piece_path = piece["tiff_dir"] / f"slice_{index:04d}.tif"
1707
+ piece_image.save(piece_path, compression="tiff_deflate")
1708
+ piece["tiff_paths"].append(piece_path)
1709
+
1710
+ pixel_size = float(state.get("pixel_size", 0.0) or 0.0)
1711
+ z_values = list(state.get("z_values", []))
1712
+ if len(z_values) < len(tiff_paths):
1713
+ z_values.extend([0.0] * (len(tiff_paths) - len(z_values)))
1714
+
1715
+ base_x_min = float(state.get("x_min", 0.0) or 0.0)
1716
+ base_y_min = float(state.get("y_min", 0.0) or 0.0)
1717
+ for piece in pieces:
1718
+ piece_width = piece["x_end"] - piece["x_start"]
1719
+ piece_height = piece["y_end"] - piece["y_start"]
1720
+ zip_path = output_dir / f"{safe_name}_r{piece['row']:02d}_c{piece['col']:02d}_tiff_slices.zip"
1721
+ _zip_tiff_paths(piece["tiff_paths"], zip_path)
1722
+ piece_state: ViewerState = {
1723
+ "tiff_paths": [str(path) for path in piece["tiff_paths"]],
1724
+ "z_values": z_values[: len(piece["tiff_paths"])],
1725
+ "pixel_size": pixel_size,
1726
+ "x_min": base_x_min + (piece["x_start"] * pixel_size),
1727
+ "y_min": base_y_min + ((height - piece["y_end"]) * pixel_size),
1728
+ "image_width": piece_width,
1729
+ "image_height": piece_height,
1730
+ "zip_path": str(zip_path),
1731
+ }
1732
+ piece["state"] = piece_state
1733
+ piece["zip_path"] = zip_path
1734
+ return pieces
1735
+
1736
+
1737
+ def split_tiff_stack_left_right(
1738
+ state: ViewerState,
1739
+ base_name: str = "split_shape",
1740
+ progress: gr.Progress = gr.Progress(),
1741
+ ) -> tuple[ViewerState, ViewerState, Path, Path]:
1742
+ pieces = split_tiff_stack_grid(state, base_name=base_name, columns=2, rows=1, progress=progress)
1743
+ return pieces[0]["state"], pieces[1]["state"], pieces[0]["zip_path"], pieces[1]["zip_path"]
1744
+
1745
+
1746
+ SHAPE_SETTINGS_HEADERS = [
1747
+ "Shape",
1748
+ "STL",
1749
+ "Target X (mm)",
1750
+ "Target Y (mm)",
1751
+ "Target Z (mm)",
1752
+ "Pressure (psi)",
1753
+ "Valve",
1754
+ "Nozzle",
1755
+ "Port",
1756
+ "Color",
1757
+ "Contour Tracing",
1758
+ "Delete",
1759
+ ]
1760
+ SHAPE_SETTINGS_DATATYPES = [
1761
+ "number",
1762
+ "str",
1763
+ "number",
1764
+ "number",
1765
+ "number",
1766
+ "number",
1767
+ "number",
1768
+ "number",
1769
+ "number",
1770
+ "str",
1771
+ "bool",
1772
+ "str",
1773
+ ]
1774
  SIMPLE_NOZZLE_SPACING_HEADERS = [
1775
  "Spacing Mode",
1776
  "Applies To",
 
1778
  "Y nozzle spacing (mm)",
1779
  ]
1780
  ADVANCED_NOZZLE_SPACING_HEADERS = [
1781
+ "From Nozzle",
1782
+ "To Nozzle",
1783
  "X edge spacing (mm)",
1784
  "Y nozzle spacing (mm)",
1785
  ]
 
1796
  return list(table or [])
1797
 
1798
 
1799
+ def _coerce_bool(value: Any, default: bool = False) -> bool:
1800
+ if isinstance(value, bool):
1801
+ return value
1802
+ if value is None:
1803
+ return default
1804
+ if isinstance(value, (int, float)):
1805
+ return bool(value)
1806
+ text = str(value).strip().lower()
1807
+ if not text:
1808
+ return default
1809
+ if text in {"1", "true", "yes", "y", "on", "checked"}:
1810
+ return True
1811
+ if text in {"0", "false", "no", "n", "off", "unchecked"}:
1812
+ return False
1813
+ return default
1814
+
1815
+
1816
+ def _coerce_int(value: Any, default: int) -> int:
1817
+ try:
1818
+ return int(float(value))
1819
+ except (TypeError, ValueError):
1820
+ try:
1821
+ return int(float(default))
1822
+ except (TypeError, ValueError):
1823
+ return 0
1824
+
1825
+
1826
+ def _record_nozzle_number(record: dict, fallback: int | None = None) -> int:
1827
+ default = fallback if fallback is not None else int(record.get("idx", 1) or 1)
1828
+ nozzle = _coerce_int(record.get("nozzle", default), default)
1829
+ return nozzle if nozzle > 0 else default
1830
+
1831
+
1832
  def _file_path_value(file_value: Any) -> str | None:
1833
  if not file_value:
1834
  return None
 
1899
  "last_scaled_axis": previous.get("last_scaled_axis", "target_x"),
1900
  "pressure": previous.get("pressure", 25.0),
1901
  "valve": previous.get("valve", 4),
1902
+ "nozzle": previous.get("nozzle", index),
1903
  "port": previous.get("port", 1),
1904
  "color": previous.get("color", _default_color(index)),
1905
+ "contour_tracing": previous.get("contour_tracing", False),
1906
  "tiff_state": previous.get("tiff_state", _empty_state()),
1907
  "zip_path": previous.get("zip_path"),
1908
  "gcode_path": previous.get("gcode_path"),
 
1929
  record.get("target_z", DEFAULT_TARGET_EXTENTS[2]),
1930
  record.get("pressure", 25.0),
1931
  record.get("valve", 4),
1932
+ _record_nozzle_number(record, int(record["idx"])),
1933
  record.get("port", 1),
1934
  record.get("color", _default_color(record["idx"])),
1935
+ bool(record.get("contour_tracing", False)),
1936
  "Delete",
1937
  ]
1938
  for record in records
 
1966
  ("target_z", 4, DEFAULT_TARGET_EXTENTS[2]),
1967
  ("pressure", 5, 25.0),
1968
  ("valve", 6, 4),
 
1969
  ):
1970
  try:
1971
  copy[key] = float(row[pos])
1972
  except (IndexError, TypeError, ValueError):
1973
  copy[key] = copy.get(key, default)
1974
+ has_nozzle_column = len(row) >= len(SHAPE_SETTINGS_HEADERS)
1975
+ nozzle_pos = 7 if has_nozzle_column else None
1976
+ port_pos = 8 if has_nozzle_column else 7
1977
+ color_pos = 9 if has_nozzle_column else 8
1978
+ contour_pos = 10 if has_nozzle_column else 9
1979
+ copy["valve"] = _coerce_int(copy.get("valve", 4), 4)
1980
+ copy["nozzle"] = _coerce_int(
1981
+ row[nozzle_pos] if nozzle_pos is not None else copy.get("nozzle", copy.get("idx", 1)),
1982
+ _record_nozzle_number(copy),
1983
+ )
1984
+ copy["port"] = _coerce_int(
1985
+ row[port_pos] if len(row) > port_pos else copy.get("port", 1),
1986
+ _coerce_int(copy.get("port", 1), 1),
1987
+ )
1988
+ if copy["nozzle"] <= 0:
1989
+ copy["nozzle"] = _record_nozzle_number(copy)
1990
+ if len(row) > color_pos and row[color_pos]:
1991
+ copy["color"] = str(row[color_pos])
1992
  try:
1993
+ copy["contour_tracing"] = _coerce_bool(row[contour_pos], bool(copy.get("contour_tracing", False)))
1994
+ except IndexError:
1995
+ copy["contour_tracing"] = bool(copy.get("contour_tracing", False))
 
 
 
 
1996
  updated.append(copy)
1997
  return updated
1998
 
 
2029
  return edited_axes
2030
 
2031
 
2032
+ def _nozzle_spacing_label(nozzle: int, records: list[dict]) -> str:
2033
+ shapes = [
2034
+ f"Shape {record.get('idx', '?')}"
2035
+ for record in records
2036
+ if _record_nozzle_number(record, int(record.get("idx", 1) or 1)) == nozzle
2037
+ ]
2038
+ if shapes:
2039
+ return f"Nozzle {nozzle}: {', '.join(shapes)}"
2040
+ return f"Nozzle {nozzle}"
2041
+
2042
+
2043
+ def _ordered_nozzle_numbers(records: list[dict]) -> list[int]:
2044
+ nozzles = {
2045
+ _record_nozzle_number(record, int(record.get("idx", position) or position))
2046
+ for position, record in enumerate(records, start=1)
2047
+ }
2048
+ return sorted(nozzles)
2049
 
2050
 
2051
  def _spacing_pairs_from_table(spacing_table: Any) -> list[tuple[float, float]]:
 
2070
  if not use_individual_spacing:
2071
  return gr.update(
2072
  headers=SIMPLE_NOZZLE_SPACING_HEADERS,
2073
+ value=[["Same spacing", "All neighboring nozzles", first_pair[0], first_pair[1]]],
2074
  row_count=(1, "fixed"),
2075
  column_count=(len(SIMPLE_NOZZLE_SPACING_HEADERS), "fixed"),
2076
  label="Nozzle Spacing",
2077
  )
2078
 
2079
  rows: list[list[Any]] = []
2080
+ ordered_nozzles = _ordered_nozzle_numbers(records)
2081
+ for index, (first, second) in enumerate(zip(ordered_nozzles, ordered_nozzles[1:])):
2082
  gap_x, gap_y = pairs[index] if index < len(pairs) else first_pair
2083
  rows.append([
2084
+ _nozzle_spacing_label(first, records),
2085
+ _nozzle_spacing_label(second, records),
2086
  gap_x,
2087
  gap_y,
2088
  ])
 
2307
  return normalized, _shape_settings_rows(normalized)
2308
 
2309
 
2310
+ def normalize_shape_settings_and_spacing(
2311
+ records: list[dict] | None,
2312
+ settings_table: Any | None,
2313
+ scale_mode: str | None,
2314
+ existing_spacing: Any | None,
2315
+ use_individual_spacing: bool | None,
2316
+ ) -> tuple:
2317
+ updated_records, updated_settings = normalize_shape_dimensions_for_mode(records, settings_table, scale_mode)
2318
+ return (
2319
+ updated_records,
2320
+ updated_settings,
2321
+ _spacing_table_update(updated_records, existing_spacing, use_individual_spacing),
2322
+ )
2323
+
2324
+
2325
  def show_selected_model(
2326
  records: list[dict] | None,
2327
  selected: str | None,
 
2385
  ) -> tuple:
2386
  records = _apply_shape_settings(records or [], settings_table)
2387
  if not records:
2388
+ return (
2389
+ records,
2390
+ [],
2391
+ "Upload at least one STL first.",
2392
+ _dropdown_update(records),
2393
+ _reset_slider(),
2394
+ "No slice stack loaded yet.",
2395
+ None,
2396
+ _empty_state(),
2397
+ _reset_slider(),
2398
+ "No reference stack generated yet.",
2399
+ None,
2400
+ )
2401
  total = len(records)
2402
  messages: list[str] = []
2403
  for pos, record in enumerate(records):
 
2431
  messages.append(f"Shape {record['idx']}: wrote `{stack.zip_path.name}`.")
2432
  except Exception as exc:
2433
  messages.append(f"Shape {record['idx']}: failed ({exc}).")
2434
+ ref_state, ref_slider, ref_label, ref_preview = generate_dynamic_reference_stack(records, progress=progress)
2435
+ if (ref_state or {}).get("tiff_paths"):
2436
+ messages.append("Reference TIFF Stack: updated automatically.")
2437
+ else:
2438
+ messages.append("Reference TIFF Stack: skipped (no generated shape slices available).")
2439
  first_state = records[0].get("tiff_state") or _empty_state()
2440
  label, preview = _render_selected_slice(first_state, 0)
2441
  slider = gr.update(
 
2453
  slider,
2454
  label,
2455
  preview,
2456
+ ref_state,
2457
+ ref_slider,
2458
+ ref_label,
2459
+ ref_preview,
2460
  )
2461
 
2462
 
 
2465
  return generate_reference_stack(*states, progress=progress)
2466
 
2467
 
2468
+ def _split_piece_choice(piece: dict[str, Any]) -> str:
2469
+ return f"R{piece['row']} C{piece['col']}"
2470
+
2471
+
2472
+ def _split_piece_dropdown_update(pieces: list[dict[str, Any]], selected: str | None = None) -> dict[str, Any]:
2473
+ choices = [_split_piece_choice(piece) for piece in pieces]
2474
+ value = selected if selected in choices else (choices[0] if choices else None)
2475
+ return gr.update(choices=choices, value=value)
2476
+
2477
+
2478
+ def _selected_split_piece(pieces: list[dict[str, Any]] | None, selected: str | None) -> dict[str, Any] | None:
2479
+ if not pieces:
2480
+ return None
2481
+ for piece in pieces:
2482
+ if _split_piece_choice(piece) == selected:
2483
+ return piece
2484
+ return pieces[0]
2485
+
2486
+
2487
+ def preview_selected_split_piece(pieces: list[dict[str, Any]] | None, selected: str | None) -> tuple:
2488
+ piece = _selected_split_piece(pieces, selected)
2489
+ if not piece:
2490
+ return _reset_slider(), "No split stack generated yet.", None
2491
+ state = piece.get("state") or _empty_state()
2492
+ label, preview = _render_selected_slice(state, 0)
2493
+ slider = gr.update(
2494
+ minimum=0,
2495
+ maximum=max(0, len(state.get("tiff_paths", [])) - 1),
2496
+ value=0,
2497
+ step=1,
2498
+ interactive=len(state.get("tiff_paths", [])) > 1,
2499
+ )
2500
+ return slider, label, preview
2501
+
2502
+
2503
+ def jump_to_selected_split_piece(pieces: list[dict[str, Any]] | None, selected: str | None, index: float) -> tuple:
2504
+ piece = _selected_split_piece(pieces, selected)
2505
+ if not piece:
2506
+ return "No split stack generated yet.", None
2507
+ return _render_selected_slice(piece.get("state") or _empty_state(), int(index))
2508
+
2509
+
2510
+ def shift_selected_split_piece(pieces: list[dict[str, Any]] | None, selected: str | None, index: float, delta: int) -> tuple:
2511
+ piece = _selected_split_piece(pieces, selected)
2512
+ if not piece:
2513
+ return gr.update(value=0), "No split stack generated yet.", None
2514
+ state = piece.get("state") or _empty_state()
2515
+ new_index, label, preview = shift_slice(state, index, delta)
2516
+ return gr.update(value=new_index), label, preview
2517
+
2518
+
2519
+ def split_selected_shape_for_grid(
2520
+ records: list[dict] | None,
2521
+ selected: str | None,
2522
+ settings_table: Any | None,
2523
+ existing_spacing: Any | None,
2524
+ use_individual_spacing: bool | None,
2525
+ columns: float,
2526
+ rows: float,
2527
+ starting_nozzle: float,
2528
+ starting_valve: float,
2529
+ progress: gr.Progress = gr.Progress(),
2530
+ ) -> tuple:
2531
+ records = _apply_shape_settings(records or [], settings_table)
2532
+ if not records:
2533
+ empty = _empty_state()
2534
+ return (
2535
+ records,
2536
+ _shape_settings_rows(records),
2537
+ _spacing_table_update(records, existing_spacing, use_individual_spacing),
2538
+ [],
2539
+ [],
2540
+ _gcode_dropdown_update(records),
2541
+ _gcode_dropdown_update(records, include_upload=True),
2542
+ _dropdown_update(records),
2543
+ [],
2544
+ [],
2545
+ _split_piece_dropdown_update([]),
2546
+ _reset_slider(),
2547
+ "No split stack generated yet.",
2548
+ None,
2549
+ "Generate TIFF stacks for a shape before splitting it.",
2550
+ )
2551
+
2552
+ pos = _selected_record_index(records, selected)
2553
+ if pos < 0:
2554
+ pos = 0
2555
+ source = records[pos]
2556
+ state = source.get("tiff_state") or _empty_state()
2557
+ try:
2558
+ pieces = split_tiff_stack_grid(
2559
+ state,
2560
+ base_name=str(source.get("name") or f"shape_{source.get('idx', pos + 1)}"),
2561
+ columns=columns,
2562
+ rows=rows,
2563
+ progress=progress,
2564
+ )
2565
+ except Exception as exc:
2566
+ empty = _empty_state()
2567
+ return (
2568
+ records,
2569
+ _shape_settings_rows(records),
2570
+ _spacing_table_update(records, existing_spacing, use_individual_spacing),
2571
+ [record.get("zip_path") for record in records if record.get("zip_path")],
2572
+ [record.get("gcode_path") for record in records if record.get("gcode_path")],
2573
+ _gcode_dropdown_update(records),
2574
+ _gcode_dropdown_update(records, include_upload=True),
2575
+ _dropdown_update(records, selected),
2576
+ [],
2577
+ [],
2578
+ _split_piece_dropdown_update([]),
2579
+ _reset_slider(),
2580
+ "No split stack generated yet.",
2581
+ None,
2582
+ f"Split failed: {exc}",
2583
+ )
2584
+
2585
+ base_name = str(source.get("name") or f"Shape {source.get('idx', pos + 1)}")
2586
+ first_nozzle = max(1, _coerce_int(starting_nozzle, 1))
2587
+ first_valve = max(1, _coerce_int(starting_valve, _coerce_int(source.get("valve", 4), 4)))
2588
+ split_records: list[dict] = []
2589
+ for index, piece in enumerate(pieces):
2590
+ piece_state = piece["state"]
2591
+ piece_width_mm = float(piece_state.get("image_width", 0) or 0) * float(piece_state.get("pixel_size", 0.0) or 0.0)
2592
+ piece_height_mm = float(piece_state.get("image_height", 0) or 0) * float(piece_state.get("pixel_size", 0.0) or 0.0)
2593
+ piece_record = dict(source)
2594
+ piece_record.update({
2595
+ "name": f"{base_name} - R{piece['row']}C{piece['col']}",
2596
+ "stl_path": None,
2597
+ "target_x": piece_width_mm or source.get("target_x", DEFAULT_TARGET_EXTENTS[0]),
2598
+ "target_y": piece_height_mm or source.get("target_y", DEFAULT_TARGET_EXTENTS[1]),
2599
+ "nozzle": first_nozzle + index,
2600
+ "valve": first_valve + index,
2601
+ "tiff_state": piece_state,
2602
+ "zip_path": str(piece["zip_path"]),
2603
+ "gcode_path": None,
2604
+ })
2605
+ split_records.append(piece_record)
2606
+ next_records = _reindex_shape_records([*records[:pos], *split_records, *records[pos + 1:]])
2607
+ slider, label, preview = preview_selected_split_piece(pieces, None)
2608
+ status = (
2609
+ f"Split Shape {source.get('idx', pos + 1)} into {len(pieces)} print-ready stacks "
2610
+ f"({max(1, _coerce_int(columns, 2))} columns x {max(1, _coerce_int(rows, 1))} rows). \n"
2611
+ f"Nozzles {first_nozzle}-{first_nozzle + len(pieces) - 1}; valves {first_valve}-{first_valve + len(pieces) - 1}."
2612
+ )
2613
+ return (
2614
+ next_records,
2615
+ _shape_settings_rows(next_records),
2616
+ _spacing_table_update(next_records, existing_spacing, use_individual_spacing),
2617
+ [record.get("zip_path") for record in next_records if record.get("zip_path")],
2618
+ [record.get("gcode_path") for record in next_records if record.get("gcode_path")],
2619
+ _gcode_dropdown_update(next_records),
2620
+ _gcode_dropdown_update(next_records, include_upload=True),
2621
+ _dropdown_update(next_records),
2622
+ [str(piece["zip_path"]) for piece in pieces],
2623
+ pieces,
2624
+ _split_piece_dropdown_update(pieces),
2625
+ slider,
2626
+ label,
2627
+ preview,
2628
+ status,
2629
+ )
2630
+
2631
+
2632
+ def _contour_tracing_sources(records: list[dict]) -> list[dict]:
2633
+ sources: list[dict] = []
2634
+ for record in records:
2635
+ if not record.get("contour_tracing"):
2636
+ continue
2637
+ state = record.get("tiff_state") or {}
2638
+ tiff_paths = state.get("tiff_paths") or []
2639
+ source = {
2640
+ "owner_idx": int(record.get("idx", len(sources) + 1)),
2641
+ "tiff_paths": list(tiff_paths),
2642
+ "zip_path": record.get("zip_path"),
2643
+ }
2644
+ if source["tiff_paths"] or source["zip_path"]:
2645
+ sources.append(source)
2646
+ return sources
2647
+
2648
+
2649
  def generate_dynamic_gcode(
2650
  records: list[dict] | None,
2651
  settings_table: Any,
 
2658
  ) -> tuple:
2659
  records = _apply_shape_settings(records or [], settings_table)
2660
  motion_tiffs = (ref_state or {}).get("tiff_paths") if use_reference_motion else None
2661
+ contour_sources = _contour_tracing_sources(records)
2662
  messages: list[str] = []
2663
+ if contour_sources:
2664
+ enabled = ", ".join(f"Shape {source['owner_idx']}" for source in contour_sources)
2665
+ messages.append(f"Contour tracing enabled for {enabled}.")
2666
  for record in records:
2667
  zip_path = record.get("zip_path")
2668
  if not zip_path:
 
2684
  all_g1=bool(all_g1),
2685
  motion_tiffs=motion_tiffs,
2686
  raster_pattern=raster_pattern,
2687
+ contour_tiff_sets=contour_sources,
2688
+ active_contour_owner=int(record.get("idx", 0)),
2689
  )
2690
  record["gcode_path"] = str(gcode_path)
2691
  messages.append(f"Shape {record['idx']}: wrote `{gcode_path.name}`.")
 
2730
  if not parsed.get("point_count"):
2731
  messages.append(f"Shape {idx}: no G0/G1 moves found.")
2732
  continue
2733
+ nozzle = _record_nozzle_number(record, idx)
2734
+ parts.append({"idx": idx, "nozzle": nozzle, "color": record.get("color", _default_color(idx)), "parsed": parsed})
2735
+ messages.append(f"Shape {idx} (Nozzle {nozzle}): {parsed['point_count']} moves, {parsed.get('layer_count', 0)} layer(s).")
2736
  return parts, messages
2737
 
2738
 
 
2926
  shape_records = gr.State([])
2927
  last_shape_delete_at = gr.State(0.0)
2928
  ref_state = gr.State(_empty_state())
2929
+ split_piece_states = gr.State([])
2930
 
2931
  with gr.Tab("STL to TIFF Slicer"):
2932
  gr.Markdown(
 
2959
  value=[],
2960
  row_count=(0, "dynamic"),
2961
  column_count=(len(SHAPE_SETTINGS_HEADERS), "fixed"),
2962
+ datatype=SHAPE_SETTINGS_DATATYPES,
2963
  interactive=True,
2964
  label="Shape Settings",
2965
  elem_id="shape-settings-table",
 
3010
  ref_next_button = gr.Button("Next", scale=1, min_width=90, size="sm")
3011
  ref_slice_slider = gr.Slider(label="Slice Index", minimum=0, maximum=0, value=0, step=1, interactive=False)
3012
 
3013
+ with gr.Tab("Single Shape Multi-Nozzle"):
3014
+ gr.Markdown(
3015
+ """
3016
+ # Single Shape Multi-Nozzle
3017
+ Split one generated TIFF stack into a row/column grid, then print each piece with a different nozzle.
3018
+ """
3019
+ )
3020
+ with gr.Row():
3021
+ split_source = gr.Dropdown(label="Source Shape", choices=[], value=None, allow_custom_value=False)
3022
+ split_refresh_sources = gr.Button("Refresh Source Shapes", variant="secondary", size="sm")
3023
+ with gr.Row():
3024
+ split_columns = gr.Number(label="Columns (X)", value=2, minimum=1, step=1)
3025
+ split_rows = gr.Number(label="Rows (Y)", value=1, minimum=1, step=1)
3026
+ split_start_nozzle = gr.Number(label="Starting Nozzle", value=1, minimum=1, step=1)
3027
+ split_start_valve = gr.Number(label="Starting Valve", value=4, minimum=1, step=1)
3028
+ split_button = gr.Button("Split Selected Shape into Grid Pieces", variant="primary")
3029
+ split_status = gr.Markdown("Generate a TIFF stack, then split it for multi-nozzle printing.")
3030
+ split_downloads = gr.File(label="Download Split TIFF ZIPs", file_count="multiple", interactive=False)
3031
+ with gr.Row():
3032
+ with gr.Column(scale=1, min_width=260):
3033
+ split_piece_source = gr.Dropdown(label="Preview Generated Piece", choices=[], value=None, allow_custom_value=False)
3034
+ with gr.Row():
3035
+ split_piece_prev = gr.Button("Prev", scale=1, min_width=90, size="sm")
3036
+ split_piece_next = gr.Button("Next", scale=1, min_width=90, size="sm")
3037
+ split_piece_slider = gr.Slider(label="Piece Slice Index", minimum=0, maximum=0, value=0, step=1, interactive=False)
3038
+ split_piece_label = gr.Markdown("No split stack generated yet.")
3039
+ with gr.Column(scale=2, min_width=420):
3040
+ split_piece_preview = gr.Image(label="Generated Piece Preview", type="pil", image_mode="RGB", height=330)
3041
+
3042
  with gr.Tab("TIFF Slices to GCode"):
3043
  gr.Markdown(
3044
  """
3045
  # TIFF Slices to GCode
3046
+ Generate G-code for every shape with a TIFF stack. Pressure, valve, nozzle, port, and color come from the Shape Settings table.
3047
  """
3048
  )
3049
  gcode_use_ref_motion = gr.Checkbox(
3050
+ label="Use Reference Stack for motion (all shapes share one nozzle path; each dispenses only its own geometry).",
3051
  value=True,
3052
  )
3053
  gcode_all_g1 = gr.Checkbox(label="Move at one constant speed (no fast travel moves)", value=True)
 
3070
  nozzle_use_individual_spacing = gr.Checkbox(label="Advanced Settings", value=False)
3071
  nozzle_spacing_table = gr.Dataframe(
3072
  headers=NOZZLE_SPACING_HEADERS,
3073
+ value=[["Same spacing", "All neighboring nozzles", 5.0, 0.0]],
3074
  row_count=(1, "fixed"),
3075
  column_count=(len(NOZZLE_SPACING_HEADERS), "fixed"),
3076
  interactive=True,
 
3161
  parallel_mode = gr.State("tube")
3162
 
3163
  shape_sync_outputs = [shape_records, shape_settings, nozzle_spacing_table, selected_shape, gcode_text_source, gcode_source, tiff_downloads, gcode_downloads]
3164
+ 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(
3165
+ fn=lambda records: _dropdown_update(records),
3166
+ inputs=[shape_records],
3167
+ outputs=[split_source],
3168
+ queue=False,
3169
+ )
3170
+ 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(
3171
+ fn=lambda records: _dropdown_update(records),
3172
+ inputs=[shape_records],
3173
+ outputs=[split_source],
3174
+ queue=False,
3175
+ )
3176
+ 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(
3177
+ fn=lambda records: _dropdown_update(records),
3178
+ inputs=[shape_records],
3179
+ outputs=[split_source],
3180
+ queue=False,
3181
+ )
3182
  shape_settings.select(
3183
  fn=delete_shape_from_settings,
3184
  inputs=[shape_records, shape_settings, nozzle_spacing_table, nozzle_use_individual_spacing, last_shape_delete_at],
3185
  outputs=[stl_upload, *shape_sync_outputs, last_shape_delete_at],
3186
+ ).then(
3187
+ fn=lambda records: _dropdown_update(records),
3188
+ inputs=[shape_records],
3189
+ outputs=[split_source],
3190
+ queue=False,
3191
  )
3192
 
3193
  preview_inputs = [shape_records, selected_shape, shape_settings, model_opacity, scale_mode]
3194
  shape_settings.change(
3195
+ fn=normalize_shape_settings_and_spacing,
3196
+ inputs=[shape_records, shape_settings, scale_mode, nozzle_spacing_table, nozzle_use_individual_spacing],
3197
+ outputs=[shape_records, shape_settings, nozzle_spacing_table],
3198
  queue=False,
3199
  )
3200
  selected_shape.change(fn=show_selected_model, inputs=preview_inputs, outputs=[model_viewer, model_details, slice_slider, slice_label, slice_preview])
 
3227
  generate_button.click(
3228
  fn=generate_dynamic_stacks,
3229
  inputs=[shape_records, shape_settings, layer_height, pixel_size, scale_mode],
3230
+ outputs=[
3231
+ shape_records,
3232
+ tiff_downloads,
3233
+ slicer_status,
3234
+ selected_shape,
3235
+ slice_slider,
3236
+ slice_label,
3237
+ slice_preview,
3238
+ ref_state,
3239
+ ref_slice_slider,
3240
+ ref_slice_label,
3241
+ ref_slice_preview,
3242
+ ],
3243
+ ).then(
3244
+ fn=lambda records: _dropdown_update(records),
3245
+ inputs=[shape_records],
3246
+ outputs=[split_source],
3247
+ queue=False,
3248
  )
3249
  ref_generate_button.click(fn=generate_dynamic_reference_stack, inputs=[shape_records], outputs=[ref_state, ref_slice_slider, ref_slice_label, ref_slice_preview])
3250
  ref_slice_slider.release(fn=jump_to_slice, inputs=[ref_state, ref_slice_slider], outputs=[ref_slice_label, ref_slice_preview], queue=False)
3251
  ref_prev_button.click(fn=lambda sv, idx: shift_slice(sv, idx, -1), inputs=[ref_state, ref_slice_slider], outputs=[ref_slice_slider, ref_slice_label, ref_slice_preview], queue=False)
3252
  ref_next_button.click(fn=lambda sv, idx: shift_slice(sv, idx, 1), inputs=[ref_state, ref_slice_slider], outputs=[ref_slice_slider, ref_slice_label, ref_slice_preview], queue=False)
3253
 
3254
+ split_refresh_sources.click(fn=lambda records: _dropdown_update(records), inputs=[shape_records], outputs=[split_source], queue=False)
3255
+ split_button.click(
3256
+ fn=split_selected_shape_for_grid,
3257
+ inputs=[
3258
+ shape_records,
3259
+ split_source,
3260
+ shape_settings,
3261
+ nozzle_spacing_table,
3262
+ nozzle_use_individual_spacing,
3263
+ split_columns,
3264
+ split_rows,
3265
+ split_start_nozzle,
3266
+ split_start_valve,
3267
+ ],
3268
+ outputs=[
3269
+ shape_records,
3270
+ shape_settings,
3271
+ nozzle_spacing_table,
3272
+ tiff_downloads,
3273
+ gcode_downloads,
3274
+ gcode_text_source,
3275
+ gcode_source,
3276
+ split_source,
3277
+ split_downloads,
3278
+ split_piece_states,
3279
+ split_piece_source,
3280
+ split_piece_slider,
3281
+ split_piece_label,
3282
+ split_piece_preview,
3283
+ split_status,
3284
+ ],
3285
+ )
3286
+ 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)
3287
+ 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)
3288
+ split_piece_prev.click(fn=lambda pieces, selected, idx: shift_selected_split_piece(pieces, selected, idx, -1), inputs=[split_piece_states, split_piece_source, split_piece_slider], outputs=[split_piece_slider, split_piece_label, split_piece_preview], queue=False)
3289
+ split_piece_next.click(fn=lambda pieces, selected, idx: shift_selected_split_piece(pieces, selected, idx, 1), inputs=[split_piece_states, split_piece_source, split_piece_slider], outputs=[split_piece_slider, split_piece_label, split_piece_preview], queue=False)
3290
+
3291
  gcode_button.click(
3292
  fn=generate_dynamic_gcode,
3293
  inputs=[shape_records, shape_settings, gcode_all_g1, gcode_use_ref_motion, gcode_raster_pattern, ref_state, layer_height, pixel_size],
gcode_viewer.py CHANGED
@@ -381,6 +381,14 @@ def _segments_to_xyz(
381
  return xs, ys, zs
382
 
383
 
 
 
 
 
 
 
 
 
384
  def build_toolpath_figure(
385
  parsed: dict,
386
  travel_opacity: float = 0.2,
@@ -526,9 +534,9 @@ def build_parallel_figure(
526
  `tube` True draws filament tubes with a shared-time animation
527
  timeline; False draws fast thin scatter lines (no animation).
528
 
529
- `parts` is a list of {"idx": int, "color": str, "parsed": dict}. Each part's
530
- print and travel traces (and, in tube mode, a nozzle marker) are named by idx
531
- so the client-side animation/recolor can address them.
532
  """
533
  fig = go.Figure()
534
  anim_parts: list[dict] = []
@@ -541,6 +549,7 @@ def build_parallel_figure(
541
  running_x = 0.0
542
  for part in parts:
543
  idx = part["idx"]
 
544
  color = part["color"]
545
  parsed = part["parsed"]
546
  moves = parsed.get("moves") or []
@@ -554,7 +563,8 @@ def build_parallel_figure(
554
  y_off = 0.0
555
  running_x += width + gap
556
  else:
557
- x_off, y_off = part_offsets.get(idx, (0.0, 0.0))
 
558
 
559
  if tube:
560
  print_tube = _build_path_tube(moves, radius=max(filament_width, 0.05) / 2.0, kind="print")
@@ -591,7 +601,7 @@ def build_parallel_figure(
591
  fig.add_trace(
592
  go.Scatter3d(
593
  x=[path_x[-1]], y=[path_y[-1]], z=[path["z"][-1]],
594
- mode="markers", name=f"Nozzle {idx}",
595
  marker=dict(size=4, color=color), showlegend=False, hoverinfo="skip",
596
  )
597
  )
@@ -601,7 +611,7 @@ def build_parallel_figure(
601
  anim_parts.append({
602
  "printName": f"Shape {idx}",
603
  "travelName": f"Travel {idx}",
604
- "nozzleName": f"Nozzle {idx}",
605
  "print_face_t": print_tube["face_t"],
606
  "travel_face_t": travel_tube["face_t"],
607
  "path_x": path_x, "path_y": path_y, "path_z": path["z"], "path_t": path["t"],
@@ -668,13 +678,16 @@ def build_nozzle_spacing_figure(
668
  fig = go.Figure()
669
  bx0 = by0 = float("inf")
670
  bx1 = by1 = float("-inf")
 
671
 
672
  for part in parts:
673
  idx = part["idx"]
 
674
  color = part["color"]
 
675
  parsed = part["parsed"]
676
  (pxmin, pymin, _), (pxmax, pymax, _) = parsed["bounds"]
677
- x_off, y_off = part_offsets.get(idx, (0.0, 0.0))
678
  xs = [pxmin + x_off, pxmax + x_off, pxmax + x_off, pxmin + x_off, pxmin + x_off]
679
  ys = [pymin + y_off, pymin + y_off, pymax + y_off, pymax + y_off, pymin + y_off]
680
  fig.add_trace(
@@ -682,25 +695,31 @@ def build_nozzle_spacing_figure(
682
  x=xs,
683
  y=ys,
684
  mode="lines",
685
- name=f"Shape {idx} bounds",
686
  line=dict(color=color, width=2),
687
  )
688
  )
 
 
 
 
 
 
689
  fig.add_trace(
690
  go.Scatter(
691
  x=[x_off],
692
  y=[y_off],
693
  mode="markers+text",
694
- name=f"Nozzle {idx}",
695
- marker=dict(color=color, size=12),
696
- text=[f"N{idx}"],
697
  textposition="top center",
698
  )
699
  )
700
- bx0 = min(bx0, min(xs), x_off)
701
- bx1 = max(bx1, max(xs), x_off)
702
- by0 = min(by0, min(ys), y_off)
703
- by1 = max(by1, max(ys), y_off)
704
 
705
  for spacing in spacings:
706
  start = spacing["from"]
@@ -759,7 +778,7 @@ def build_parallel_gif(
759
 
760
  Each part's toolpath is drawn as growing colored lines (print solid, travel
761
  faint), three parts in parallel on a shared cumulative-length time axis.
762
- `parts` is a list of {"idx": int, "color": str, "parsed": dict}.
763
  """
764
  import matplotlib
765
  matplotlib.use("Agg")
@@ -784,7 +803,8 @@ def build_parallel_gif(
784
  y_off = 0.0
785
  running_x += (pxmax - pxmin) + gap
786
  else:
787
- x_off, y_off = part_offsets.get(part["idx"], (0.0, 0.0))
 
788
 
789
  cum = 0.0
790
  mlist: list[tuple] = []
 
381
  return xs, ys, zs
382
 
383
 
384
+ def _part_nozzle(part: dict) -> int:
385
+ try:
386
+ nozzle = int(float(part.get("nozzle", part.get("idx", 1))))
387
+ except (TypeError, ValueError):
388
+ nozzle = int(part.get("idx", 1) or 1)
389
+ return nozzle if nozzle > 0 else int(part.get("idx", 1) or 1)
390
+
391
+
392
  def build_toolpath_figure(
393
  parsed: dict,
394
  travel_opacity: float = 0.2,
 
534
  `tube` True draws filament tubes with a shared-time animation
535
  timeline; False draws fast thin scatter lines (no animation).
536
 
537
+ `parts` is a list of {"idx": int, "nozzle": int, "color": str, "parsed": dict}.
538
+ Each part's print and travel traces (and, in tube mode, a nozzle marker) are
539
+ named by idx so the client-side animation/recolor can address them.
540
  """
541
  fig = go.Figure()
542
  anim_parts: list[dict] = []
 
549
  running_x = 0.0
550
  for part in parts:
551
  idx = part["idx"]
552
+ nozzle = _part_nozzle(part)
553
  color = part["color"]
554
  parsed = part["parsed"]
555
  moves = parsed.get("moves") or []
 
563
  y_off = 0.0
564
  running_x += width + gap
565
  else:
566
+ x_off, y_off = part_offsets.get(nozzle, part_offsets.get(idx, (0.0, 0.0)))
567
+ nozzle_trace_name = f"Nozzle {nozzle} (Shape {idx})"
568
 
569
  if tube:
570
  print_tube = _build_path_tube(moves, radius=max(filament_width, 0.05) / 2.0, kind="print")
 
601
  fig.add_trace(
602
  go.Scatter3d(
603
  x=[path_x[-1]], y=[path_y[-1]], z=[path["z"][-1]],
604
+ mode="markers", name=nozzle_trace_name,
605
  marker=dict(size=4, color=color), showlegend=False, hoverinfo="skip",
606
  )
607
  )
 
611
  anim_parts.append({
612
  "printName": f"Shape {idx}",
613
  "travelName": f"Travel {idx}",
614
+ "nozzleName": nozzle_trace_name,
615
  "print_face_t": print_tube["face_t"],
616
  "travel_face_t": travel_tube["face_t"],
617
  "path_x": path_x, "path_y": path_y, "path_z": path["z"], "path_t": path["t"],
 
678
  fig = go.Figure()
679
  bx0 = by0 = float("inf")
680
  bx1 = by1 = float("-inf")
681
+ nozzle_colors: dict[int, str] = {}
682
 
683
  for part in parts:
684
  idx = part["idx"]
685
+ nozzle = _part_nozzle(part)
686
  color = part["color"]
687
+ nozzle_colors.setdefault(nozzle, color)
688
  parsed = part["parsed"]
689
  (pxmin, pymin, _), (pxmax, pymax, _) = parsed["bounds"]
690
+ x_off, y_off = part_offsets.get(nozzle, part_offsets.get(idx, (0.0, 0.0)))
691
  xs = [pxmin + x_off, pxmax + x_off, pxmax + x_off, pxmin + x_off, pxmin + x_off]
692
  ys = [pymin + y_off, pymin + y_off, pymax + y_off, pymax + y_off, pymin + y_off]
693
  fig.add_trace(
 
695
  x=xs,
696
  y=ys,
697
  mode="lines",
698
+ name=f"Shape {idx} bounds (N{nozzle})",
699
  line=dict(color=color, width=2),
700
  )
701
  )
702
+ bx0 = min(bx0, min(xs), x_off)
703
+ bx1 = max(bx1, max(xs), x_off)
704
+ by0 = min(by0, min(ys), y_off)
705
+ by1 = max(by1, max(ys), y_off)
706
+
707
+ for nozzle, (x_off, y_off) in sorted(part_offsets.items()):
708
  fig.add_trace(
709
  go.Scatter(
710
  x=[x_off],
711
  y=[y_off],
712
  mode="markers+text",
713
+ name=f"Nozzle {nozzle}",
714
+ marker=dict(color=nozzle_colors.get(nozzle, "#444444"), size=12),
715
+ text=[f"N{nozzle}"],
716
  textposition="top center",
717
  )
718
  )
719
+ bx0 = min(bx0, x_off)
720
+ bx1 = max(bx1, x_off)
721
+ by0 = min(by0, y_off)
722
+ by1 = max(by1, y_off)
723
 
724
  for spacing in spacings:
725
  start = spacing["from"]
 
778
 
779
  Each part's toolpath is drawn as growing colored lines (print solid, travel
780
  faint), three parts in parallel on a shared cumulative-length time axis.
781
+ `parts` is a list of {"idx": int, "nozzle": int, "color": str, "parsed": dict}.
782
  """
783
  import matplotlib
784
  matplotlib.use("Agg")
 
803
  y_off = 0.0
804
  running_x += (pxmax - pxmin) + gap
805
  else:
806
+ nozzle = _part_nozzle(part)
807
+ x_off, y_off = part_offsets.get(nozzle, part_offsets.get(part["idx"], (0.0, 0.0)))
808
 
809
  cum = 0.0
810
  mlist: list[tuple] = []
tests/test_app_scaling.py CHANGED
@@ -2,10 +2,14 @@ from __future__ import annotations
2
 
3
  import numpy as np
4
  import trimesh
 
5
 
6
  from app import (
7
  SCALE_MODE_TARGET_DIMENSIONS,
8
  SCALE_MODE_UNIFORM_FACTOR,
 
 
 
9
  _resolve_mesh_scale_factors,
10
  _uniform_target_extents_from_anchor,
11
  )
@@ -53,3 +57,84 @@ def test_uniform_target_extents_update_from_changed_side() -> None:
53
  )
54
 
55
  np.testing.assert_allclose(target_extents, (6.0, 12.0, 24.0))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2
 
3
  import numpy as np
4
  import trimesh
5
+ from PIL import Image
6
 
7
  from app import (
8
  SCALE_MODE_TARGET_DIMENSIONS,
9
  SCALE_MODE_UNIFORM_FACTOR,
10
+ generate_dynamic_stacks,
11
+ split_tiff_stack_grid,
12
+ split_tiff_stack_left_right,
13
  _resolve_mesh_scale_factors,
14
  _uniform_target_extents_from_anchor,
15
  )
 
57
  )
58
 
59
  np.testing.assert_allclose(target_extents, (6.0, 12.0, 24.0))
60
+
61
+
62
+ def test_generate_dynamic_stacks_empty_input_resets_reference_stack() -> None:
63
+ outputs = generate_dynamic_stacks([], [], 0.8, 0.8, SCALE_MODE_TARGET_DIMENSIONS)
64
+
65
+ assert len(outputs) == 11
66
+ assert outputs[0] == []
67
+ assert outputs[2] == "Upload at least one STL first."
68
+ assert outputs[7]["tiff_paths"] == []
69
+ assert outputs[9] == "No reference stack generated yet."
70
+
71
+
72
+ def test_split_tiff_stack_left_right_preserves_pixels_and_metadata(tmp_path) -> None:
73
+ pixels = np.array(
74
+ [
75
+ [0, 255, 0, 255, 0],
76
+ [255, 0, 255, 0, 255],
77
+ ],
78
+ dtype=np.uint8,
79
+ )
80
+ tiff_path = tmp_path / "slice_0000.tif"
81
+ Image.fromarray(pixels, mode="L").save(tiff_path)
82
+ state = {
83
+ "tiff_paths": [str(tiff_path)],
84
+ "z_values": [1.25],
85
+ "pixel_size": 0.5,
86
+ "x_min": 10.0,
87
+ "y_min": -2.0,
88
+ "image_width": 5,
89
+ "image_height": 2,
90
+ }
91
+
92
+ left_state, right_state, left_zip, right_zip = split_tiff_stack_left_right(state, "wide-part")
93
+
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]
101
+ assert right_state["z_values"] == [1.25]
102
+
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:
110
+ pixels = np.arange(20, dtype=np.uint8).reshape((4, 5))
111
+ tiff_path = tmp_path / "slice_0000.tif"
112
+ Image.fromarray(pixels, mode="L").save(tiff_path)
113
+ state = {
114
+ "tiff_paths": [str(tiff_path)],
115
+ "z_values": [2.0],
116
+ "pixel_size": 0.25,
117
+ "x_min": 4.0,
118
+ "y_min": 10.0,
119
+ "image_width": 5,
120
+ "image_height": 4,
121
+ }
122
+
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:
140
+ np.testing.assert_array_equal(np.asarray(image), expected_pixels)
tests/test_nozzle_spacing.py CHANGED
@@ -5,7 +5,9 @@ import numpy as np
5
  from app import (
6
  ADVANCED_NOZZLE_SPACING_HEADERS,
7
  SCALE_MODE_UNIFORM_FACTOR,
 
8
  SIMPLE_NOZZLE_SPACING_HEADERS,
 
9
  delete_shape_from_settings,
10
  _format_nozzle_spacing_status,
11
  _shape_settings_rows,
@@ -16,9 +18,14 @@ from app import (
16
  )
17
 
18
 
19
- def _part(idx: int, bounds: tuple[tuple[float, float, float], tuple[float, float, float]]) -> dict:
 
 
 
 
20
  return {
21
  "idx": idx,
 
22
  "color": "#000000",
23
  "parsed": {"bounds": bounds, "moves": [{"kind": "print"}], "point_count": 1},
24
  }
@@ -42,9 +49,9 @@ def test_calculated_nozzle_layout_uses_requested_part_gap() -> None:
42
 
43
  np.testing.assert_allclose(offsets[1], (0.0, 0.0))
44
  np.testing.assert_allclose(offsets[2], (12.5, -1.0))
45
- np.testing.assert_allclose(offsets[3], (24.5, 3.0))
46
  assert spacings[0] == {"from": 1, "to": 2, "dx": 12.5, "dy": -1.0}
47
- assert spacings[1] == {"from": 2, "to": 3, "dx": 12.0, "dy": 4.0}
48
 
49
 
50
  def test_calculated_nozzle_layout_allows_negative_x_overlap() -> None:
@@ -67,6 +74,28 @@ def test_calculated_nozzle_layout_allows_negative_x_overlap() -> None:
67
  assert spacings == [{"from": 1, "to": 2, "dx": 8.0, "dy": 3.0}]
68
 
69
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
70
  def test_same_spacing_reuses_first_pair_values_for_second_pair() -> None:
71
  parts = [
72
  _part(1, ((0.0, 0.0, 0.0), (10.0, 10.0, 1.0))),
@@ -182,6 +211,40 @@ def test_keep_proportions_uses_most_recently_edited_dimension() -> None:
182
  assert updated_settings[0][2:5] == [25.0, 50.0, 12.5]
183
 
184
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
185
  def test_simple_spacing_table_uses_one_shared_spacing_row() -> None:
186
  records = [
187
  {"idx": 1, "name": "first"},
@@ -193,11 +256,11 @@ def test_simple_spacing_table_uses_one_shared_spacing_row() -> None:
193
  gap12x, gap12y, gap23x, gap23y, extra = _spacing_args_from_table(update["value"], False)
194
 
195
  assert update["headers"] == SIMPLE_NOZZLE_SPACING_HEADERS
196
- assert update["value"] == [["Same spacing", "All neighboring shapes", 7.0, -1.5]]
197
  assert (gap12x, gap12y, gap23x, gap23y, extra) == (7.0, -1.5, 7.0, -1.5, [])
198
 
199
 
200
- def test_advanced_spacing_table_uses_named_shape_pairs() -> None:
201
  records = [
202
  {"idx": 1, "name": "first"},
203
  {"idx": 2, "name": "second"},
@@ -212,14 +275,27 @@ def test_advanced_spacing_table_uses_named_shape_pairs() -> None:
212
  gap12x, gap12y, gap23x, gap23y, extra = _spacing_args_from_table(update["value"], True)
213
 
214
  assert update["headers"] == ADVANCED_NOZZLE_SPACING_HEADERS
215
- assert update["value"][0][:2] == ["Shape 1: first", "Shape 2: second"]
216
- assert update["value"][1][:2] == ["Shape 2: second", "Shape 3: third"]
217
  assert (gap12x, gap12y, gap23x, gap23y, extra) == (4.0, 0.5, 8.0, 1.0, [])
218
 
219
 
 
 
 
 
 
 
 
 
 
 
 
 
 
220
  def test_delete_shape_reindexes_without_losing_shape_data() -> None:
221
  class Event:
222
- index = (1, 9)
223
 
224
  records = [
225
  {"idx": 1, "name": "first", "stl_path": "first.stl", "target_x": 10.0, "target_y": 11.0, "target_z": 12.0, "pressure": 25, "valve": 4, "port": 1, "color": "#111111"},
@@ -241,7 +317,7 @@ def test_delete_shape_reindexes_without_losing_shape_data() -> None:
241
 
242
  def test_delete_shape_cooldown_blocks_immediate_second_delete() -> None:
243
  class Event:
244
- index = (1, 9)
245
 
246
  records = [
247
  {"idx": 1, "name": "first", "stl_path": "first.stl", "target_x": 10.0, "target_y": 11.0, "target_z": 12.0, "pressure": 25, "valve": 4, "port": 1, "color": "#111111"},
 
5
  from app import (
6
  ADVANCED_NOZZLE_SPACING_HEADERS,
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
  _shape_settings_rows,
 
18
  )
19
 
20
 
21
+ def _part(
22
+ idx: int,
23
+ bounds: tuple[tuple[float, float, float], tuple[float, float, float]],
24
+ nozzle: int | None = None,
25
+ ) -> dict:
26
  return {
27
  "idx": idx,
28
+ "nozzle": nozzle if nozzle is not None else idx,
29
  "color": "#000000",
30
  "parsed": {"bounds": bounds, "moves": [{"kind": "print"}], "point_count": 1},
31
  }
 
49
 
50
  np.testing.assert_allclose(offsets[1], (0.0, 0.0))
51
  np.testing.assert_allclose(offsets[2], (12.5, -1.0))
52
+ np.testing.assert_allclose(offsets[3], (27.5, 3.0))
53
  assert spacings[0] == {"from": 1, "to": 2, "dx": 12.5, "dy": -1.0}
54
+ assert spacings[1] == {"from": 2, "to": 3, "dx": 15.0, "dy": 4.0}
55
 
56
 
57
  def test_calculated_nozzle_layout_allows_negative_x_overlap() -> None:
 
74
  assert spacings == [{"from": 1, "to": 2, "dx": 8.0, "dy": 3.0}]
75
 
76
 
77
+ def test_nozzle_layout_groups_shapes_that_share_a_nozzle() -> None:
78
+ parts = [
79
+ _part(1, ((0.0, 0.0, 0.0), (10.0, 10.0, 1.0)), nozzle=1),
80
+ _part(2, ((-1.0, -2.0, 0.0), (12.0, 4.0, 1.0)), nozzle=1),
81
+ _part(3, ((0.0, 0.0, 0.0), (6.0, 5.0, 1.0)), nozzle=2),
82
+ ]
83
+
84
+ offsets, spacings = _resolve_nozzle_layout(
85
+ parts,
86
+ same_spacing=True,
87
+ part_gap_12_x=5.0,
88
+ part_gap_12_y=1.5,
89
+ part_gap_23_x=0.0,
90
+ part_gap_23_y=0.0,
91
+ )
92
+
93
+ assert sorted(offsets) == [1, 2]
94
+ np.testing.assert_allclose(offsets[1], (0.0, 0.0))
95
+ np.testing.assert_allclose(offsets[2], (17.0, 1.5))
96
+ assert spacings == [{"from": 1, "to": 2, "dx": 17.0, "dy": 1.5}]
97
+
98
+
99
  def test_same_spacing_reuses_first_pair_values_for_second_pair() -> None:
100
  parts = [
101
  _part(1, ((0.0, 0.0, 0.0), (10.0, 10.0, 1.0))),
 
211
  assert updated_settings[0][2:5] == [25.0, 50.0, 12.5]
212
 
213
 
214
+ def test_shape_settings_round_trip_contour_tracing_column() -> None:
215
+ records = [
216
+ {
217
+ "idx": 1,
218
+ "name": "circle",
219
+ "stl_path": "circle.stl",
220
+ "target_x": 10.0,
221
+ "target_y": 11.0,
222
+ "target_z": 12.0,
223
+ "pressure": 25.0,
224
+ "valve": 4,
225
+ "nozzle": 1,
226
+ "port": 1,
227
+ "color": "#111111",
228
+ "contour_tracing": False,
229
+ }
230
+ ]
231
+
232
+ rows = _shape_settings_rows(records)
233
+ assert SHAPE_SETTINGS_HEADERS[6:9] == ["Valve", "Nozzle", "Port"]
234
+ assert SHAPE_SETTINGS_HEADERS[-2:] == ["Contour Tracing", "Delete"]
235
+ assert rows[0][6:9] == [4, 1, 1]
236
+ assert rows[0][-2:] == [False, "Delete"]
237
+
238
+ rows[0][7] = 3
239
+ rows[0][8] = 2
240
+ rows[0][-2] = True
241
+ updated = _apply_shape_settings(records, rows)
242
+
243
+ assert updated[0]["nozzle"] == 3
244
+ assert updated[0]["port"] == 2
245
+ assert updated[0]["contour_tracing"] is True
246
+
247
+
248
  def test_simple_spacing_table_uses_one_shared_spacing_row() -> None:
249
  records = [
250
  {"idx": 1, "name": "first"},
 
256
  gap12x, gap12y, gap23x, gap23y, extra = _spacing_args_from_table(update["value"], False)
257
 
258
  assert update["headers"] == SIMPLE_NOZZLE_SPACING_HEADERS
259
+ assert update["value"] == [["Same spacing", "All neighboring nozzles", 7.0, -1.5]]
260
  assert (gap12x, gap12y, gap23x, gap23y, extra) == (7.0, -1.5, 7.0, -1.5, [])
261
 
262
 
263
+ def test_advanced_spacing_table_uses_named_nozzle_pairs() -> None:
264
  records = [
265
  {"idx": 1, "name": "first"},
266
  {"idx": 2, "name": "second"},
 
275
  gap12x, gap12y, gap23x, gap23y, extra = _spacing_args_from_table(update["value"], True)
276
 
277
  assert update["headers"] == ADVANCED_NOZZLE_SPACING_HEADERS
278
+ assert update["value"][0][:2] == ["Nozzle 1: Shape 1", "Nozzle 2: Shape 2"]
279
+ assert update["value"][1][:2] == ["Nozzle 2: Shape 2", "Nozzle 3: Shape 3"]
280
  assert (gap12x, gap12y, gap23x, gap23y, extra) == (4.0, 0.5, 8.0, 1.0, [])
281
 
282
 
283
+ def test_advanced_spacing_table_collapses_duplicate_nozzles() -> None:
284
+ records = [
285
+ {"idx": 1, "name": "first", "nozzle": 1},
286
+ {"idx": 2, "name": "second", "nozzle": 1},
287
+ {"idx": 3, "name": "third", "nozzle": 2},
288
+ ]
289
+
290
+ update = _spacing_table_update(records, [["ignored", "ignored", 9.0, 2.5]], True)
291
+
292
+ assert update["headers"] == ADVANCED_NOZZLE_SPACING_HEADERS
293
+ assert update["value"] == [["Nozzle 1: Shape 1, Shape 2", "Nozzle 2: Shape 3", 9.0, 2.5]]
294
+
295
+
296
  def test_delete_shape_reindexes_without_losing_shape_data() -> None:
297
  class Event:
298
+ index = (1, len(SHAPE_SETTINGS_HEADERS) - 1)
299
 
300
  records = [
301
  {"idx": 1, "name": "first", "stl_path": "first.stl", "target_x": 10.0, "target_y": 11.0, "target_z": 12.0, "pressure": 25, "valve": 4, "port": 1, "color": "#111111"},
 
317
 
318
  def test_delete_shape_cooldown_blocks_immediate_second_delete() -> None:
319
  class Event:
320
+ index = (1, len(SHAPE_SETTINGS_HEADERS) - 1)
321
 
322
  records = [
323
  {"idx": 1, "name": "first", "stl_path": "first.stl", "target_x": 10.0, "target_y": 11.0, "target_z": 12.0, "pressure": 25, "valve": 4, "port": 1, "color": "#111111"},
tests/test_tiff_to_gcode.py CHANGED
@@ -2,15 +2,413 @@ from __future__ import annotations
2
 
3
  import zipfile
4
 
 
5
  from PIL import Image
6
 
7
  from tiff_to_gcode import (
 
8
  RASTER_PATTERN_WOODPILE,
9
  RASTER_PATTERN_Y_DIRECTION,
 
 
10
  generate_snake_path_gcode,
11
  )
12
 
13
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
14
  def test_gcode_header_writes_presets_before_initial_aux_commands(tmp_path) -> None:
15
  tiff_path = tmp_path / "slice_0000.tif"
16
  Image.new("L", (1, 1), 0).save(tiff_path)
@@ -166,3 +564,86 @@ def test_y_direction_raster_prints_each_layer_along_y_axis(tmp_path) -> None:
166
  assert max(x_positions) <= 3.0
167
  assert min(y_positions) >= 0.0
168
  assert max(y_positions) <= 2.0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2
 
3
  import zipfile
4
 
5
+ 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
 
17
 
18
+ def _move_signature(gcode_text: str) -> list[tuple[float | None, float | None, float | None]]:
19
+ signature: list[tuple[float | None, float | None, float | None]] = []
20
+ for line in gcode_text.splitlines():
21
+ if not line.startswith(("G0", "G1")):
22
+ continue
23
+ axes: dict[str, float] = {}
24
+ for token in line.split():
25
+ if token[:1] in {"X", "Y", "Z"}:
26
+ axes[token[0]] = float(token[1:])
27
+ signature.append((axes.get("X"), axes.get("Y"), axes.get("Z")))
28
+ return signature
29
+
30
+
31
+ def _move_endpoints_for_color(gcode_text: str, color: int) -> list[tuple[float, float]]:
32
+ x = y = 0.0
33
+ endpoints: list[tuple[float, float]] = []
34
+ for line in gcode_text.splitlines():
35
+ if not line.startswith(("G0", "G1")):
36
+ continue
37
+ start = (x, y)
38
+ for token in line.split():
39
+ if token.startswith("X"):
40
+ x += float(token[1:])
41
+ if token.startswith("Y"):
42
+ y += float(token[1:])
43
+ if f"; Color {color}" in line:
44
+ endpoints.extend([start, (x, y)])
45
+ return endpoints
46
+
47
+
48
+ def _moves_with_colors(gcode_text: str) -> list[dict]:
49
+ x = y = z = 0.0
50
+ moves: list[dict] = []
51
+ for line in gcode_text.splitlines():
52
+ if not line.startswith(("G0", "G1")):
53
+ continue
54
+ start = (x, y, z)
55
+ for token in line.split():
56
+ if token.startswith("X"):
57
+ x += float(token[1:])
58
+ if token.startswith("Y"):
59
+ y += float(token[1:])
60
+ if token.startswith("Z"):
61
+ z += float(token[1:])
62
+ color = None
63
+ if "; Color " in line:
64
+ color = int(line.rsplit("; Color ", 1)[1])
65
+ moves.append({"start": start, "end": (x, y, z), "color": color})
66
+ return moves
67
+
68
+
69
+ def test_trace_mask_contours_uses_tiff_pixel_border_edges() -> None:
70
+ contours = _trace_mask_contours(
71
+ np.array(
72
+ [
73
+ [True, True],
74
+ [True, True],
75
+ ],
76
+ dtype=bool,
77
+ ),
78
+ pixel_size=1.0,
79
+ )
80
+
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)
87
+ raster_image.putpixel((4, 3), 0)
88
+ raster_image.save(raster_tiff)
89
+ raster_zip = tmp_path / "raster_slices.zip"
90
+ with zipfile.ZipFile(raster_zip, mode="w") as archive:
91
+ archive.write(raster_tiff, arcname=raster_tiff.name)
92
+
93
+ contour_tiff = tmp_path / "contour_slice_0000.tif"
94
+ contour_image = Image.new("L", (7, 6), 255)
95
+ contour_image.putpixel((4, 3), 0)
96
+ contour_image.save(contour_tiff)
97
+
98
+ gcode_path = generate_snake_path_gcode(
99
+ raster_zip,
100
+ shape_name="aligned_contour",
101
+ pressure=25,
102
+ valve=7,
103
+ port=3,
104
+ fil_width=1.0,
105
+ all_g1=True,
106
+ contour_tiff_sets=[{"owner_idx": 1, "tiff_paths": [str(contour_tiff)]}],
107
+ active_contour_owner=1,
108
+ )
109
+
110
+ points = _move_endpoints_for_color(gcode_path.read_text(), 255)
111
+ xs = [point[0] for point in points]
112
+ ys = [point[1] for point in points]
113
+
114
+ assert (min(xs), max(xs)) == (1.0, 2.0)
115
+ assert (min(ys), max(ys)) == (-0.5, 0.5)
116
+
117
+
118
+ def test_contour_tracing_travels_to_nearest_border_after_infill(tmp_path) -> None:
119
+ tiff_path = tmp_path / "slice_0000.tif"
120
+ image = Image.new("L", (7, 6), 255)
121
+ image.putpixel((4, 3), 0)
122
+ image.save(tiff_path)
123
+ zip_path = tmp_path / "slices.zip"
124
+ with zipfile.ZipFile(zip_path, mode="w") as archive:
125
+ archive.write(tiff_path, arcname=tiff_path.name)
126
+
127
+ gcode_path = generate_snake_path_gcode(
128
+ zip_path,
129
+ shape_name="nearest_border_contour",
130
+ pressure=25,
131
+ valve=7,
132
+ port=3,
133
+ fil_width=1.0,
134
+ all_g1=True,
135
+ contour_tiff_sets=[{"owner_idx": 1, "tiff_paths": [str(tiff_path)]}],
136
+ active_contour_owner=1,
137
+ )
138
+
139
+ moves = _moves_with_colors(gcode_path.read_text())
140
+ assert moves[1] == {
141
+ "start": (1.0, 0.0, 0.0),
142
+ "end": (2.0, 0.0, 0.0),
143
+ "color": 255,
144
+ }
145
+ assert moves[2]["color"] == 255
146
+ assert moves[2]["start"] == (2.0, 0.0, 0.0)
147
+
148
+
149
+ def test_contour_tracing_anchors_to_expanding_raster_frame(tmp_path) -> None:
150
+ tiff_path = tmp_path / "slice_0000.tif"
151
+ image = Image.new("L", (7, 5), 255)
152
+ for col in [3]:
153
+ image.putpixel((col, 0), 0)
154
+ for col in range(2, 5):
155
+ image.putpixel((col, 1), 0)
156
+ for col in range(1, 6):
157
+ image.putpixel((col, 2), 0)
158
+ image.save(tiff_path)
159
+
160
+ zip_path = tmp_path / "slices.zip"
161
+ with zipfile.ZipFile(zip_path, mode="w") as archive:
162
+ archive.write(tiff_path, arcname=tiff_path.name)
163
+
164
+ gcode_path = generate_snake_path_gcode(
165
+ zip_path,
166
+ shape_name="expanding_contour",
167
+ pressure=25,
168
+ valve=7,
169
+ port=3,
170
+ fil_width=1.0,
171
+ all_g1=True,
172
+ contour_tiff_sets=[{"owner_idx": 1, "tiff_paths": [str(tiff_path)]}],
173
+ active_contour_owner=1,
174
+ )
175
+
176
+ print_moves = [
177
+ move for move in _moves_with_colors(gcode_path.read_text()) if move["color"] == 255
178
+ ]
179
+ contour_points = [
180
+ point
181
+ for move in print_moves[3:]
182
+ for point in (move["start"], move["end"])
183
+ ]
184
+ xs = [point[0] for point in contour_points]
185
+ ys = [point[1] for point in contour_points]
186
+
187
+ assert print_moves[:3] == [
188
+ {"start": (1.0, 0.0, 0.0), "end": (2.0, 0.0, 0.0), "color": 255},
189
+ {"start": (3.0, 1.0, 0.0), "end": (0.0, 1.0, 0.0), "color": 255},
190
+ {"start": (-1.0, 2.0, 0.0), "end": (4.0, 2.0, 0.0), "color": 255},
191
+ ]
192
+ assert print_moves[3]["start"] == (4.0, 2.0, 0.0)
193
+ assert (min(xs), max(xs)) == (-1.0, 4.0)
194
+ assert (min(ys), max(ys)) == (-0.5, 2.5)
195
+
196
+
197
+ def test_contour_tracing_uses_shifted_layer_raster_frame(tmp_path) -> None:
198
+ tiff_paths = []
199
+ for index, pixel in enumerate([(4, 3), (5, 4)]):
200
+ tiff_path = tmp_path / f"slice_{index:04d}.tif"
201
+ image = Image.new("L", (8, 7), 255)
202
+ image.putpixel(pixel, 0)
203
+ image.save(tiff_path)
204
+ tiff_paths.append(tiff_path)
205
+
206
+ zip_path = tmp_path / "slices.zip"
207
+ with zipfile.ZipFile(zip_path, mode="w") as archive:
208
+ for tiff_path in tiff_paths:
209
+ archive.write(tiff_path, arcname=tiff_path.name)
210
+
211
+ gcode_path = generate_snake_path_gcode(
212
+ zip_path,
213
+ shape_name="shifted_layer_contour",
214
+ pressure=25,
215
+ valve=7,
216
+ port=3,
217
+ fil_width=1.0,
218
+ layer_height=1.0,
219
+ all_g1=True,
220
+ contour_tiff_sets=[{"owner_idx": 1, "tiff_paths": [str(p) for p in tiff_paths]}],
221
+ active_contour_owner=1,
222
+ )
223
+
224
+ layer_one_prints = [
225
+ move
226
+ for move in _moves_with_colors(gcode_path.read_text())
227
+ if move["color"] == 255
228
+ and move["start"][2] == 1.0
229
+ and move["end"][2] == 1.0
230
+ ]
231
+
232
+ assert layer_one_prints[0] == {
233
+ "start": (3.0, 1.0, 1.0),
234
+ "end": (2.0, 1.0, 1.0),
235
+ "color": 255,
236
+ }
237
+ contour_points = [
238
+ point
239
+ for move in layer_one_prints[1:]
240
+ for point in (move["start"], move["end"])
241
+ ]
242
+ xs = [point[0] for point in contour_points]
243
+ ys = [point[1] for point in contour_points]
244
+
245
+ assert layer_one_prints[1]["start"] == (2.0, 1.0, 1.0)
246
+ assert layer_one_prints[1]["end"] == (2.0, 0.5, 1.0)
247
+ assert (min(xs), max(xs)) == (2.0, 3.0)
248
+ assert (min(ys), max(ys)) == (0.5, 1.5)
249
+
250
+
251
+ def test_contour_tracing_mirrors_odd_layer_y_frame(tmp_path) -> None:
252
+ tiff_paths = []
253
+ for index in range(2):
254
+ tiff_path = tmp_path / f"slice_{index:04d}.tif"
255
+ image = Image.new("L", (8, 7), 255)
256
+ image.putpixel((4, 3), 0)
257
+ image.putpixel((4, 4), 0)
258
+ image.putpixel((5, 4), 0)
259
+ image.save(tiff_path)
260
+ tiff_paths.append(tiff_path)
261
+
262
+ zip_path = tmp_path / "slices.zip"
263
+ with zipfile.ZipFile(zip_path, mode="w") as archive:
264
+ for tiff_path in tiff_paths:
265
+ archive.write(tiff_path, arcname=tiff_path.name)
266
+
267
+ gcode_path = generate_snake_path_gcode(
268
+ zip_path,
269
+ shape_name="odd_layer_y_contour",
270
+ pressure=25,
271
+ valve=7,
272
+ port=3,
273
+ fil_width=1.0,
274
+ layer_height=1.0,
275
+ all_g1=True,
276
+ contour_tiff_sets=[{"owner_idx": 1, "tiff_paths": [str(p) for p in tiff_paths]}],
277
+ active_contour_owner=1,
278
+ )
279
+
280
+ layer_one_prints = [
281
+ move
282
+ for move in _moves_with_colors(gcode_path.read_text())
283
+ if move["color"] == 255
284
+ and move["start"][2] == 1.0
285
+ and move["end"][2] == 1.0
286
+ ]
287
+ contour_points = [
288
+ point
289
+ for move in layer_one_prints[2:]
290
+ for point in (move["start"], move["end"])
291
+ ]
292
+ xs = [point[0] for point in contour_points]
293
+ ys = [point[1] for point in contour_points]
294
+
295
+ assert layer_one_prints[:2] == [
296
+ {"start": (1.0, 1.0, 1.0), "end": (3.0, 1.0, 1.0), "color": 255},
297
+ {"start": (2.0, 0.0, 1.0), "end": (1.0, 0.0, 1.0), "color": 255},
298
+ ]
299
+ assert layer_one_prints[2]["start"] == (1.0, 0.0, 1.0)
300
+ assert layer_one_prints[2]["end"] == (1.0, -0.5, 1.0)
301
+ assert (min(xs), max(xs)) == (1.0, 3.0)
302
+ assert (min(ys), max(ys)) == (-0.5, 1.5)
303
+
304
+
305
+ def test_contour_tracing_closes_loop_and_restores_raster_endpoint(tmp_path) -> None:
306
+ tiff_paths = []
307
+ for index in range(4):
308
+ tiff_path = tmp_path / f"slice_{index:04d}.tif"
309
+ image = Image.new("L", (8, 8), 255)
310
+ image.putpixel((4, 2), 0)
311
+ image.putpixel((4, 3), 0)
312
+ image.putpixel((5, 4), 0)
313
+ image.save(tiff_path)
314
+ tiff_paths.append(tiff_path)
315
+
316
+ zip_path = tmp_path / "slices.zip"
317
+ with zipfile.ZipFile(zip_path, mode="w") as archive:
318
+ for tiff_path in tiff_paths:
319
+ archive.write(tiff_path, arcname=tiff_path.name)
320
+
321
+ gcode_path = generate_snake_path_gcode(
322
+ zip_path,
323
+ shape_name="odd_layer_last_infill_anchor",
324
+ pressure=25,
325
+ valve=7,
326
+ port=3,
327
+ fil_width=1.0,
328
+ layer_height=1.0,
329
+ all_g1=True,
330
+ contour_tiff_sets=[{"owner_idx": 1, "tiff_paths": [str(p) for p in tiff_paths]}],
331
+ active_contour_owner=1,
332
+ )
333
+
334
+ all_moves = _moves_with_colors(gcode_path.read_text())
335
+ for layer_z in (1.0, 3.0):
336
+ layer_moves = [
337
+ move
338
+ for move in all_moves
339
+ if move["start"][2] == layer_z
340
+ and move["end"][2] == layer_z
341
+ ]
342
+ layer_prints = [
343
+ move
344
+ for move in layer_moves
345
+ if move["color"] == 255
346
+ ]
347
+
348
+ assert layer_prints[:3] == [
349
+ {"start": (3.0, 2.0, layer_z), "end": (2.0, 2.0, layer_z), "color": 255},
350
+ {"start": (1.0, 1.0, layer_z), "end": (2.0, 1.0, layer_z), "color": 255},
351
+ {"start": (2.0, 0.0, layer_z), "end": (1.0, 0.0, layer_z), "color": 255},
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)
370
+ motion_img[3, 4] = 255
371
+ motion_img[4, 4] = 255
372
+ motion_img[4, 5] = 255
373
+ for index in range(2):
374
+ tiff_path = tmp_path / f"l_shape_{index:04d}.tif"
375
+ image = Image.new("L", (8, 7), 255)
376
+ image.putpixel((4, 3), 0)
377
+ image.putpixel((4, 4), 0)
378
+ image.putpixel((5, 4), 0)
379
+ image.save(tiff_path)
380
+ tiff_paths.append(str(tiff_path))
381
+
382
+ contour_layers = _build_contour_layers(
383
+ [{"owner_idx": 1, "tiff_paths": tiff_paths}],
384
+ [motion_img, motion_img],
385
+ pixel_size=1.0,
386
+ invert=True,
387
+ off_color=0,
388
+ work_dir=tmp_path,
389
+ raster_pattern=RASTER_PATTERN_SAME_DIRECTION,
390
+ )
391
+
392
+ assert contour_layers[0][0]["contours"][0] == [
393
+ (1.0, -0.5),
394
+ (2.0, -0.5),
395
+ (2.0, 0.5),
396
+ (3.0, 0.5),
397
+ (3.0, 1.5),
398
+ (1.0, 1.5),
399
+ (1.0, -0.5),
400
+ ]
401
+ assert contour_layers[1][0]["contours"][0] == [
402
+ (1.0, -0.5),
403
+ (3.0, -0.5),
404
+ (3.0, 0.5),
405
+ (2.0, 0.5),
406
+ (2.0, 1.5),
407
+ (1.0, 1.5),
408
+ (1.0, -0.5),
409
+ ]
410
+
411
+
412
  def test_gcode_header_writes_presets_before_initial_aux_commands(tmp_path) -> None:
413
  tiff_path = tmp_path / "slice_0000.tif"
414
  Image.new("L", (1, 1), 0).save(tiff_path)
 
564
  assert max(x_positions) <= 3.0
565
  assert min(y_positions) >= 0.0
566
  assert max(y_positions) <= 2.0
567
+
568
+
569
+ def test_contour_tracing_skips_inactive_nozzle_outline(tmp_path) -> None:
570
+ blank_tiff = tmp_path / "blank_slice_0000.tif"
571
+ Image.new("L", (1, 1), 255).save(blank_tiff)
572
+ blank_zip = tmp_path / "blank_slices.zip"
573
+ with zipfile.ZipFile(blank_zip, mode="w") as archive:
574
+ archive.write(blank_tiff, arcname=blank_tiff.name)
575
+
576
+ contour_tiff = tmp_path / "contour_slice_0000.tif"
577
+ Image.new("L", (1, 1), 0).save(contour_tiff)
578
+ contour_sources = [{"owner_idx": 1, "tiff_paths": [str(contour_tiff)]}]
579
+
580
+ active_path = generate_snake_path_gcode(
581
+ blank_zip,
582
+ shape_name="active_contour",
583
+ pressure=25,
584
+ valve=7,
585
+ port=3,
586
+ all_g1=True,
587
+ contour_tiff_sets=contour_sources,
588
+ active_contour_owner=1,
589
+ )
590
+ inactive_path = generate_snake_path_gcode(
591
+ blank_zip,
592
+ shape_name="inactive_contour",
593
+ pressure=25,
594
+ valve=7,
595
+ port=3,
596
+ all_g1=True,
597
+ contour_tiff_sets=contour_sources,
598
+ active_contour_owner=2,
599
+ )
600
+
601
+ active_text = active_path.read_text()
602
+ inactive_text = inactive_path.read_text()
603
+
604
+ assert _move_signature(active_text)
605
+ assert _move_signature(inactive_text) == []
606
+ assert any(
607
+ line.startswith("G1") and "; Color 255" in line
608
+ for line in active_text.splitlines()
609
+ )
610
+ assert not any("; Color 255" in line for line in inactive_text.splitlines())
611
+
612
+
613
+ def test_inactive_contour_tracing_preserves_original_raster_moves(tmp_path) -> None:
614
+ tiff_paths = []
615
+ for index in range(2):
616
+ tiff_path = tmp_path / f"slice_{index:04d}.tif"
617
+ image = Image.new("L", (4, 3), 255)
618
+ image.putpixel((1, 1), 0)
619
+ image.putpixel((2, 1), 0)
620
+ image.save(tiff_path)
621
+ tiff_paths.append(tiff_path)
622
+
623
+ zip_path = tmp_path / "slices.zip"
624
+ with zipfile.ZipFile(zip_path, mode="w") as archive:
625
+ for tiff_path in tiff_paths:
626
+ archive.write(tiff_path, arcname=tiff_path.name)
627
+
628
+ original_path = generate_snake_path_gcode(
629
+ zip_path,
630
+ shape_name="original_raster",
631
+ pressure=25,
632
+ valve=7,
633
+ port=3,
634
+ all_g1=True,
635
+ )
636
+ inactive_path = generate_snake_path_gcode(
637
+ zip_path,
638
+ shape_name="inactive_contour_raster",
639
+ pressure=25,
640
+ valve=7,
641
+ port=3,
642
+ all_g1=True,
643
+ contour_tiff_sets=[{"owner_idx": 2, "tiff_paths": [str(p) for p in tiff_paths]}],
644
+ active_contour_owner=1,
645
+ )
646
+
647
+ assert _move_signature(inactive_path.read_text()) == _move_signature(
648
+ original_path.read_text()
649
+ )
tiff_to_gcode.py CHANGED
@@ -3,6 +3,7 @@ from __future__ import annotations
3
  import os
4
  import tempfile
5
  import zipfile
 
6
  from codecs import encode
7
  from pathlib import Path
8
  from textwrap import wrap
@@ -20,7 +21,6 @@ RASTER_PATTERN_CHOICES = (
20
  RASTER_PATTERN_WOODPILE,
21
  )
22
 
23
-
24
  def _normalize_raster_pattern(pattern: str | None) -> str:
25
  if pattern == RASTER_PATTERN_WOODPILE:
26
  return RASTER_PATTERN_WOODPILE
@@ -197,6 +197,115 @@ def _center_on_canvas(
197
  return out
198
 
199
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
200
  def _append_relative_move(
201
  output_list: list[dict],
202
  current_x: float,
@@ -217,6 +326,396 @@ def _append_relative_move(
217
  return target_x, target_y
218
 
219
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
220
  def _woodpile_layer_segments(
221
  path_img: np.ndarray,
222
  color_img: np.ndarray,
@@ -320,10 +819,13 @@ def _build_footprint_raster_gcode_list(
320
  pixel_size: float,
321
  layer_height: float,
322
  raster_pattern: str,
 
 
323
  ) -> list[dict]:
324
  gcode_list: list[dict] = []
325
  current_x = 0.0
326
  current_y = 0.0
 
327
 
328
  for layer_number, (path_img, color_img) in enumerate(zip(path_ref_list, color_ref_list)):
329
  raster_axis = _raster_axis_for_pattern(raster_pattern, layer_number)
@@ -331,6 +833,23 @@ def _build_footprint_raster_gcode_list(
331
  if not segments:
332
  if layer_number > 0:
333
  gcode_list.append({"X": 0.0, "Y": 0.0, "Z": layer_height, "Color": 0})
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
334
  continue
335
 
336
  first_x, first_y = segments[0][0], segments[0][1]
@@ -372,6 +891,24 @@ def _build_footprint_raster_gcode_list(
372
  color,
373
  )
374
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
375
  return gcode_list
376
 
377
 
@@ -388,6 +925,8 @@ def generate_snake_path_gcode(
388
  all_g1: bool = False,
389
  motion_tiffs: list[str] | None = None,
390
  raster_pattern: str | None = RASTER_PATTERN_SAME_DIRECTION,
 
 
391
  ) -> Path:
392
  zip_path = Path(zip_path)
393
  if not zip_path.exists():
@@ -435,6 +974,24 @@ def generate_snake_path_gcode(
435
  path_ref_list = [im.copy() for im in shape_imgs]
436
  color_ref_list = [im.copy() for im in shape_imgs]
437
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
438
  setpress_lines = [_setpress_cmd(com_port, pressure, start=True)]
439
  pressure_on_lines = [_toggle_cmd(com_port, start=True)]
440
  pressure_off_lines = [_toggle_cmd(com_port, start=False)]
@@ -446,9 +1003,13 @@ def generate_snake_path_gcode(
446
  fil_width,
447
  layer_height,
448
  raster_pattern,
 
 
449
  )
450
  else:
451
  gcode_list: list[dict] = []
 
 
452
  dist_sign_long = 1
453
  current_offsets_x: list[int] = []
454
  use_flip_y = False
@@ -504,6 +1065,8 @@ def generate_snake_path_gcode(
504
  gcode_list.append(
505
  {"X": shift_y, "Y": shift_x, "Z": layer_height, "Color": 0}
506
  )
 
 
507
 
508
  for row in current_image_ref:
509
  if all(p == off_color for p in row):
@@ -521,6 +1084,10 @@ def generate_snake_path_gcode(
521
 
522
  if layers == 0:
523
  direction = -1
 
 
 
 
524
  direction = _gcode_layer(
525
  ref_for_path,
526
  current_image,
@@ -529,6 +1096,31 @@ def generate_snake_path_gcode(
529
  direction,
530
  layers,
531
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
532
 
533
  gcode_path = work_dir / f"{shape_name}_SnakePath_gcode.txt"
534
  pressure_cur = float(pressure)
 
3
  import os
4
  import tempfile
5
  import zipfile
6
+ from collections import defaultdict
7
  from codecs import encode
8
  from pathlib import Path
9
  from textwrap import wrap
 
21
  RASTER_PATTERN_WOODPILE,
22
  )
23
 
 
24
  def _normalize_raster_pattern(pattern: str | None) -> str:
25
  if pattern == RASTER_PATTERN_WOODPILE:
26
  return RASTER_PATTERN_WOODPILE
 
197
  return out
198
 
199
 
200
+ def _simplify_closed_contour(
201
+ points: list[tuple[int, int]],
202
+ ) -> list[tuple[int, int]]:
203
+ if len(points) < 4:
204
+ return points
205
+ if points[0] != points[-1]:
206
+ points = [*points, points[0]]
207
+
208
+ ring = points[:-1]
209
+ simplified: list[tuple[int, int]] = []
210
+ for idx, point in enumerate(ring):
211
+ prev_point = ring[idx - 1]
212
+ next_point = ring[(idx + 1) % len(ring)]
213
+ dx1 = point[0] - prev_point[0]
214
+ dy1 = point[1] - prev_point[1]
215
+ dx2 = next_point[0] - point[0]
216
+ dy2 = next_point[1] - point[1]
217
+ if dx1 * dy2 == dy1 * dx2:
218
+ continue
219
+ simplified.append(point)
220
+
221
+ if len(simplified) < 3:
222
+ simplified = ring
223
+ simplified.append(simplified[0])
224
+ return simplified
225
+
226
+
227
+ def _contour_area2(points: list[tuple[float, float]]) -> float:
228
+ return sum(
229
+ x0 * y1 - x1 * y0
230
+ for (x0, y0), (x1, y1) in zip(points, points[1:])
231
+ )
232
+
233
+
234
+ def _contour_sort_key(points: list[tuple[float, float]]) -> tuple[float, float, float]:
235
+ xs = [point[0] for point in points]
236
+ ys = [point[1] for point in points]
237
+ return (-abs(_contour_area2(points)), min(ys), min(xs))
238
+
239
+
240
+ def _trace_mask_contours(
241
+ mask: np.ndarray,
242
+ pixel_size: float,
243
+ x_offset_px: float = 0.0,
244
+ y_offset_px: float = 0.0,
245
+ ) -> list[list[tuple[float, float]]]:
246
+ if not np.any(mask):
247
+ return []
248
+
249
+ mask = mask.astype(bool)
250
+ segments: list[tuple[tuple[int, int], tuple[int, int]]] = []
251
+ height, width = mask.shape
252
+
253
+ def is_on(row: int, col: int) -> bool:
254
+ return 0 <= row < height and 0 <= col < width and bool(mask[row, col])
255
+
256
+ for row in range(height):
257
+ for col in range(width):
258
+ if not mask[row, col]:
259
+ continue
260
+ if not is_on(row - 1, col):
261
+ segments.append(((col, row), (col + 1, row)))
262
+ if not is_on(row, col + 1):
263
+ segments.append(((col + 1, row), (col + 1, row + 1)))
264
+ if not is_on(row + 1, col):
265
+ segments.append(((col + 1, row + 1), (col, row + 1)))
266
+ if not is_on(row, col - 1):
267
+ segments.append(((col, row + 1), (col, row)))
268
+
269
+ outgoing: dict[tuple[int, int], list[tuple[int, int]]] = defaultdict(list)
270
+ for start, end in segments:
271
+ outgoing[start].append(end)
272
+
273
+ contours: list[list[tuple[float, float]]] = []
274
+ remaining = set(segments)
275
+ while remaining:
276
+ start, end = min(remaining)
277
+ remaining.remove((start, end))
278
+ contour = [start, end]
279
+ current = end
280
+
281
+ while current != start:
282
+ next_point = next(
283
+ (
284
+ candidate
285
+ for candidate in outgoing.get(current, [])
286
+ if (current, candidate) in remaining
287
+ ),
288
+ None,
289
+ )
290
+ if next_point is None:
291
+ break
292
+ remaining.remove((current, next_point))
293
+ current = next_point
294
+ contour.append(current)
295
+
296
+ if len(contour) > 3 and contour[-1] == contour[0]:
297
+ simplified = _simplify_closed_contour(contour)
298
+ contours.append(
299
+ [
300
+ ((x + x_offset_px) * pixel_size, (y + y_offset_px) * pixel_size)
301
+ for x, y in simplified
302
+ ]
303
+ )
304
+
305
+ contours.sort(key=_contour_sort_key)
306
+ return contours
307
+
308
+
309
  def _append_relative_move(
310
  output_list: list[dict],
311
  current_x: float,
 
326
  return target_x, target_y
327
 
328
 
329
+ def _point_distance_sq(
330
+ ax: float,
331
+ ay: float,
332
+ bx: float,
333
+ by: float,
334
+ ) -> float:
335
+ return (ax - bx) ** 2 + (ay - by) ** 2
336
+
337
+
338
+ def _closest_point_on_segment(
339
+ px: float,
340
+ py: float,
341
+ ax: float,
342
+ ay: float,
343
+ bx: float,
344
+ by: float,
345
+ ) -> tuple[float, float, float]:
346
+ dx = bx - ax
347
+ dy = by - ay
348
+ length_sq = dx * dx + dy * dy
349
+ if length_sq == 0:
350
+ return ax, ay, 0.0
351
+ t = ((px - ax) * dx + (py - ay) * dy) / length_sq
352
+ t = max(0.0, min(1.0, t))
353
+ return ax + t * dx, ay + t * dy, t
354
+
355
+
356
+ def _rotate_closed_contour_to_nearest_border(
357
+ contour: list[tuple[float, float]],
358
+ current_x: float,
359
+ current_y: float,
360
+ approach_dx: float = 0.0,
361
+ approach_dy: float = 0.0,
362
+ ) -> list[tuple[float, float]]:
363
+ if len(contour) < 3:
364
+ return contour
365
+
366
+ ring = contour[:-1] if contour[0] == contour[-1] else contour
367
+ if len(ring) < 2:
368
+ return contour
369
+
370
+ best_idx = 0
371
+ best_t = 0.0
372
+ best_point = ring[0]
373
+ best_dist = float("inf")
374
+ for idx, (ax, ay) in enumerate(ring):
375
+ bx, by = ring[(idx + 1) % len(ring)]
376
+ point_x, point_y, t = _closest_point_on_segment(
377
+ current_x,
378
+ current_y,
379
+ ax,
380
+ ay,
381
+ bx,
382
+ by,
383
+ )
384
+ distance = _point_distance_sq(current_x, current_y, point_x, point_y)
385
+ if distance < best_dist:
386
+ best_dist = distance
387
+ best_idx = idx
388
+ best_t = t
389
+ best_point = (point_x, point_y)
390
+
391
+ eps = 1e-9
392
+ def choose_direction(
393
+ forward: list[tuple[float, float]],
394
+ reverse: list[tuple[float, float]],
395
+ ) -> list[tuple[float, float]]:
396
+ if (
397
+ len(forward) < 2
398
+ or len(reverse) < 2
399
+ or (approach_dx == 0 and approach_dy == 0)
400
+ ):
401
+ return forward
402
+
403
+ def score(candidate: list[tuple[float, float]]) -> float:
404
+ tx = candidate[1][0] - candidate[0][0]
405
+ ty = candidate[1][1] - candidate[0][1]
406
+ # Positive when the shape interior, opposite the approach vector,
407
+ # is to the left of the contour's first move.
408
+ return (ty * approach_dx) - (tx * approach_dy)
409
+
410
+ return reverse if score(reverse) > score(forward) else forward
411
+
412
+ if best_t <= eps:
413
+ forward = ring[best_idx:] + ring[:best_idx] + [ring[best_idx]]
414
+ reverse_ring = list(reversed(ring))
415
+ reverse_idx = reverse_ring.index(ring[best_idx])
416
+ reverse = (
417
+ reverse_ring[reverse_idx:]
418
+ + reverse_ring[:reverse_idx]
419
+ + [reverse_ring[reverse_idx]]
420
+ )
421
+ return choose_direction(forward, reverse)
422
+
423
+ next_idx = (best_idx + 1) % len(ring)
424
+ if best_t >= 1.0 - eps:
425
+ forward = ring[next_idx:] + ring[:next_idx] + [ring[next_idx]]
426
+ reverse_ring = list(reversed(ring))
427
+ reverse_idx = reverse_ring.index(ring[next_idx])
428
+ reverse = (
429
+ reverse_ring[reverse_idx:]
430
+ + reverse_ring[:reverse_idx]
431
+ + [reverse_ring[reverse_idx]]
432
+ )
433
+ return choose_direction(forward, reverse)
434
+
435
+ forward = [best_point]
436
+ for step in range(1, len(ring) + 1):
437
+ forward.append(ring[(best_idx + step) % len(ring)])
438
+ forward.append(best_point)
439
+
440
+ reverse = [best_point]
441
+ for step in range(0, len(ring)):
442
+ reverse.append(ring[(best_idx - step) % len(ring)])
443
+ reverse.append(best_point)
444
+ return choose_direction(forward, reverse)
445
+
446
+
447
+ def _last_print_reference(output_list: list[dict]) -> tuple[float, float, float, float]:
448
+ x = y = 0.0
449
+ last_x = last_y = 0.0
450
+ last_dx = last_dy = 0.0
451
+ for move in output_list:
452
+ dx = float(move.get("X", 0.0))
453
+ dy = float(move.get("Y", 0.0))
454
+ x += dx
455
+ y += dy
456
+ if move.get("Color") == 255 and "Z" not in move:
457
+ last_x = x
458
+ last_y = y
459
+ last_dx = dx
460
+ last_dy = dy
461
+ return last_x, last_y, last_dx, last_dy
462
+
463
+
464
+ def _rewind_trailing_travel(
465
+ output_list: list[dict],
466
+ current_x: float,
467
+ current_y: float,
468
+ ) -> tuple[float, float]:
469
+ if not output_list:
470
+ return current_x, current_y
471
+
472
+ last_move = output_list[-1]
473
+ if last_move.get("Color") != 0 or "Z" in last_move:
474
+ return current_x, current_y
475
+
476
+ has_layer_print = any(
477
+ move.get("Color") == 255 and "Z" not in move
478
+ for move in reversed(output_list[:-1])
479
+ )
480
+ if not has_layer_print:
481
+ return current_x, current_y
482
+
483
+ output_list.pop()
484
+ return (
485
+ current_x - float(last_move.get("X", 0.0)),
486
+ current_y - float(last_move.get("Y", 0.0)),
487
+ )
488
+
489
+
490
+ def _contour_source_paths(source: dict, extract_root: Path, source_pos: int) -> list[Path]:
491
+ paths = source.get("tiff_paths") or source.get("paths") or []
492
+ if paths:
493
+ return sorted((Path(path) for path in paths), key=lambda path: _sort_key(path.name))
494
+
495
+ zip_path = source.get("zip_path")
496
+ if not zip_path:
497
+ return []
498
+ extract_dir = extract_root / f"source_{source_pos:03d}"
499
+ extract_dir.mkdir(parents=True, exist_ok=True)
500
+ return _extract_zip_tiffs(Path(zip_path), extract_dir)
501
+
502
+
503
+ def _active_pixel_bounds(mask: np.ndarray) -> tuple[int, int, int, int] | None:
504
+ rows, cols = np.where(mask)
505
+ if len(rows) == 0:
506
+ return None
507
+ return int(rows.min()), int(rows.max()), int(cols.min()), int(cols.max())
508
+
509
+
510
+ def _first_raster_row_start(mask: np.ndarray) -> tuple[int, int] | None:
511
+ rows = np.where(mask.any(axis=1))[0]
512
+ if len(rows) == 0:
513
+ return None
514
+ row = int(rows[0])
515
+ cols = np.where(mask[row])[0]
516
+ if len(cols) == 0:
517
+ return None
518
+ return row, int(cols[0])
519
+
520
+
521
+ 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)
528
+ if first_row_start is None:
529
+ first_row_start = _first_raster_row_start(contour_mask)
530
+ if first_row_start is None:
531
+ return 0.0, 0.0
532
+ first_row, first_col = first_row_start
533
+ # The legacy X-raster infill path anchors every row to the first
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(
542
+ contour_tiff_sets: list[dict] | None,
543
+ path_ref_list: list[np.ndarray],
544
+ pixel_size: float,
545
+ invert: bool,
546
+ off_color: int,
547
+ work_dir: Path,
548
+ raster_pattern: str,
549
+ layer_start_directions: list[int] | None = None,
550
+ ) -> list[list[dict]]:
551
+ contour_layers: list[list[dict]] = [[] for _ in path_ref_list]
552
+ if not contour_tiff_sets:
553
+ return contour_layers
554
+
555
+ extract_root = work_dir / "contour_sources"
556
+ for source_pos, source in enumerate(contour_tiff_sets):
557
+ try:
558
+ owner_idx = int(source.get("owner_idx", source.get("idx", source_pos + 1)))
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):
565
+ continue
566
+ canvas_h, canvas_w = path_img.shape[:2]
567
+ contour_img = _load_grayscale(source_paths[layer_number], invert=invert)
568
+ if contour_img.shape[:2] != (canvas_h, canvas_w):
569
+ contour_img = _center_on_canvas(
570
+ contour_img,
571
+ canvas_h,
572
+ canvas_w,
573
+ fill=off_color,
574
+ )
575
+ contour_path_img = path_img
576
+ if raster_pattern == RASTER_PATTERN_SAME_DIRECTION and layer_number % 2 == 1:
577
+ contour_path_img = np.flipud(contour_path_img)
578
+ contour_img = np.flipud(contour_img)
579
+ if (
580
+ raster_pattern == RASTER_PATTERN_SAME_DIRECTION
581
+ and layer_start_directions is not None
582
+ and layer_number < len(layer_start_directions)
583
+ and layer_start_directions[layer_number] > 0
584
+ ):
585
+ contour_path_img = np.fliplr(contour_path_img)
586
+ contour_img = np.fliplr(contour_img)
587
+
588
+ contour_mask = contour_img > off_color
589
+ x_offset_px, y_offset_px = _contour_pixel_offsets(
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
606
+
607
+
608
+ def _same_direction_layer_start_directions(
609
+ path_ref_list: list[np.ndarray],
610
+ off_color: int,
611
+ ) -> list[int]:
612
+ directions: list[int] = []
613
+ direction = -1
614
+ for path_img in path_ref_list:
615
+ directions.append(direction)
616
+ nonblank_rows = int(np.count_nonzero((path_img > off_color).any(axis=1)))
617
+ if nonblank_rows % 2 == 1:
618
+ direction *= -1
619
+ return directions
620
+
621
+
622
+ def _append_layer_contours(
623
+ output_list: list[dict],
624
+ current_x: float,
625
+ current_y: float,
626
+ contour_layers: list[list[dict]],
627
+ layer_number: int,
628
+ active_owner_idx: int | None,
629
+ origin_x: float = 0.0,
630
+ origin_y: float = 0.0,
631
+ x_scale: float = 1.0,
632
+ y_scale: float = 1.0,
633
+ ) -> tuple[float, float]:
634
+ if layer_number >= len(contour_layers):
635
+ return current_x, current_y
636
+
637
+ active_sources = [
638
+ source
639
+ for source in contour_layers[layer_number]
640
+ if source.get("owner_idx") == active_owner_idx
641
+ and any(len(contour) >= 2 for contour in source.get("contours", []))
642
+ ]
643
+ if not active_sources:
644
+ return current_x, current_y
645
+
646
+ current_x, current_y = _rewind_trailing_travel(
647
+ output_list,
648
+ current_x,
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:
657
+ color = 255
658
+ for contour in source.get("contours", []):
659
+ if len(contour) < 2:
660
+ continue
661
+ contour = [
662
+ (origin_x + (x_scale * x), origin_y + (y_scale * y))
663
+ for x, y in contour
664
+ ]
665
+ if use_infill_reference:
666
+ nearest_x, nearest_y, approach_dx, approach_dy = _last_print_reference(
667
+ output_list
668
+ )
669
+ else:
670
+ nearest_x, nearest_y = current_x, current_y
671
+ approach_dx, approach_dy = 0.0, 0.0
672
+ contour = _rotate_closed_contour_to_nearest_border(
673
+ contour,
674
+ nearest_x,
675
+ nearest_y,
676
+ approach_dx,
677
+ approach_dy,
678
+ )
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,
687
+ current_x,
688
+ current_y,
689
+ start_x,
690
+ start_y,
691
+ 0,
692
+ )
693
+ for target_x, target_y in contour[1:]:
694
+ current_x, current_y = _append_relative_move(
695
+ output_list,
696
+ current_x,
697
+ current_y,
698
+ target_x,
699
+ target_y,
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
+
719
  def _woodpile_layer_segments(
720
  path_img: np.ndarray,
721
  color_img: np.ndarray,
 
819
  pixel_size: float,
820
  layer_height: float,
821
  raster_pattern: str,
822
+ contour_layers: list[list[dict]] | None = None,
823
+ active_contour_owner: int | None = None,
824
  ) -> list[dict]:
825
  gcode_list: list[dict] = []
826
  current_x = 0.0
827
  current_y = 0.0
828
+ contour_layers = contour_layers or []
829
 
830
  for layer_number, (path_img, color_img) in enumerate(zip(path_ref_list, color_ref_list)):
831
  raster_axis = _raster_axis_for_pattern(raster_pattern, layer_number)
 
833
  if not segments:
834
  if layer_number > 0:
835
  gcode_list.append({"X": 0.0, "Y": 0.0, "Z": layer_height, "Color": 0})
836
+ layer_end_x, layer_end_y = current_x, current_y
837
+ current_x, current_y = _append_layer_contours(
838
+ gcode_list,
839
+ current_x,
840
+ current_y,
841
+ contour_layers,
842
+ layer_number,
843
+ active_contour_owner,
844
+ )
845
+ current_x, current_y = _append_relative_move(
846
+ gcode_list,
847
+ current_x,
848
+ current_y,
849
+ layer_end_x,
850
+ layer_end_y,
851
+ 0,
852
+ )
853
  continue
854
 
855
  first_x, first_y = segments[0][0], segments[0][1]
 
891
  color,
892
  )
893
 
894
+ layer_end_x, layer_end_y = current_x, current_y
895
+ current_x, current_y = _append_layer_contours(
896
+ gcode_list,
897
+ current_x,
898
+ current_y,
899
+ contour_layers,
900
+ layer_number,
901
+ active_contour_owner,
902
+ )
903
+ current_x, current_y = _append_relative_move(
904
+ gcode_list,
905
+ current_x,
906
+ current_y,
907
+ layer_end_x,
908
+ layer_end_y,
909
+ 0,
910
+ )
911
+
912
  return gcode_list
913
 
914
 
 
925
  all_g1: bool = False,
926
  motion_tiffs: list[str] | None = None,
927
  raster_pattern: str | None = RASTER_PATTERN_SAME_DIRECTION,
928
+ contour_tiff_sets: list[dict] | None = None,
929
+ active_contour_owner: int | None = None,
930
  ) -> Path:
931
  zip_path = Path(zip_path)
932
  if not zip_path.exists():
 
974
  path_ref_list = [im.copy() for im in shape_imgs]
975
  color_ref_list = [im.copy() for im in shape_imgs]
976
 
977
+ layer_start_directions = None
978
+ if raster_pattern == RASTER_PATTERN_SAME_DIRECTION:
979
+ layer_start_directions = _same_direction_layer_start_directions(
980
+ path_ref_list,
981
+ off_color,
982
+ )
983
+
984
+ contour_layers = _build_contour_layers(
985
+ contour_tiff_sets,
986
+ path_ref_list,
987
+ fil_width,
988
+ invert,
989
+ off_color,
990
+ work_dir,
991
+ raster_pattern,
992
+ layer_start_directions,
993
+ )
994
+
995
  setpress_lines = [_setpress_cmd(com_port, pressure, start=True)]
996
  pressure_on_lines = [_toggle_cmd(com_port, start=True)]
997
  pressure_off_lines = [_toggle_cmd(com_port, start=False)]
 
1003
  fil_width,
1004
  layer_height,
1005
  raster_pattern,
1006
+ contour_layers,
1007
+ active_contour_owner,
1008
  )
1009
  else:
1010
  gcode_list: list[dict] = []
1011
+ current_x = 0.0
1012
+ current_y = 0.0
1013
  dist_sign_long = 1
1014
  current_offsets_x: list[int] = []
1015
  use_flip_y = False
 
1065
  gcode_list.append(
1066
  {"X": shift_y, "Y": shift_x, "Z": layer_height, "Color": 0}
1067
  )
1068
+ current_x += shift_y
1069
+ current_y += shift_x
1070
 
1071
  for row in current_image_ref:
1072
  if all(p == off_color for p in row):
 
1084
 
1085
  if layers == 0:
1086
  direction = -1
1087
+ layer_start = len(gcode_list)
1088
+ layer_origin_x, layer_origin_y = current_x, current_y
1089
+ layer_x_scale = 1.0 if direction < 0 else -1.0
1090
+ layer_y_scale = -1.0 if layers % 2 == 1 else 1.0
1091
  direction = _gcode_layer(
1092
  ref_for_path,
1093
  current_image,
 
1096
  direction,
1097
  layers,
1098
  )
1099
+ for move in gcode_list[layer_start:]:
1100
+ current_x += float(move.get("X", 0.0))
1101
+ current_y += float(move.get("Y", 0.0))
1102
+
1103
+ layer_end_x, layer_end_y = current_x, current_y
1104
+ current_x, current_y = _append_layer_contours(
1105
+ gcode_list,
1106
+ current_x,
1107
+ current_y,
1108
+ contour_layers,
1109
+ layers,
1110
+ active_contour_owner,
1111
+ layer_origin_x,
1112
+ layer_origin_y,
1113
+ layer_x_scale,
1114
+ layer_y_scale,
1115
+ )
1116
+ current_x, current_y = _append_relative_move(
1117
+ gcode_list,
1118
+ current_x,
1119
+ current_y,
1120
+ layer_end_x,
1121
+ layer_end_y,
1122
+ off_color,
1123
+ )
1124
 
1125
  gcode_path = work_dir / f"{shape_name}_SnakePath_gcode.txt"
1126
  pressure_cur = float(pressure)