CyGuy8 commited on
Commit
bfab64c
·
1 Parent(s): dc8e2be

Add nozzle spacing controls and visualization

Browse files
Files changed (4) hide show
  1. README.md +3 -2
  2. app.py +298 -35
  3. gcode_viewer.py +108 -14
  4. tests/test_nozzle_spacing.py +105 -0
README.md CHANGED
@@ -50,10 +50,11 @@ Then open the local Gradio URL in your browser, upload STL files or load the bun
50
  - Combines generated stacks into a reference TIFF stack
51
  - Converts generated TIFF ZIPs into G-code files with pressure, valve, and port settings per shape
52
  - Offers two G-code generation options: **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)
 
53
  - Previews each shape's generated G-code inline (text boxes under the downloads)
54
  - Visualizes generated or uploaded G-code tool paths, with the source selectable from Shape 1/2/3 or an uploaded file
55
  - Renders the tool path as a fast line plot or an animated 3D tube plot (play/pause, speed, scrub, frame-step, nozzle marker)
56
- - Plots all three shapes side by side (offset in X) and animates them printing in parallel, with a server-side GIF export of that animation
57
 
58
  ## Behavior and Implementation Notes
59
 
@@ -99,7 +100,7 @@ The G-code visualization tab renders the generated Shape 1/2/3 G-code or an uplo
99
 
100
  ### Parallel Printing Visualization
101
 
102
- The fourth tab plots all three shapes' G-code at once, offset along X so they do not overlap, 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.
103
 
104
  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).
105
 
 
50
  - Combines generated stacks into a reference TIFF stack
51
  - Converts generated TIFF ZIPs into G-code files with pressure, valve, and port settings per shape
52
  - Offers two G-code generation options: **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)
53
+ - Calculates X/Y nozzle spacing from shared or separate Shape 1->2 and Shape 2->3 spacing values, then visualizes the resulting nozzle layout
54
  - Previews each shape's generated G-code inline (text boxes under the downloads)
55
  - Visualizes generated or uploaded G-code tool paths, with the source selectable from Shape 1/2/3 or an uploaded file
56
  - Renders the tool path as a fast line plot or an animated 3D tube plot (play/pause, speed, scrub, frame-step, nozzle marker)
57
+ - Plots all three shapes using the configured nozzle spacing and animates them printing in parallel, with a server-side GIF export of that animation
58
 
59
  ## Behavior and Implementation Notes
60
 
 
100
 
101
  ### Parallel Printing Visualization
102
 
103
+ The fourth tab plots all three 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.
104
 
105
  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).
106
 
app.py CHANGED
@@ -22,6 +22,7 @@ from PIL import Image, ImageDraw, ImageFont
22
  import trimesh
23
 
24
  from gcode_viewer import (
 
25
  build_parallel_figure,
26
  build_parallel_gif,
27
  build_toolpath_figure,
@@ -91,20 +92,26 @@ APP_CSS = """
91
  }
92
 
93
  #load-sample-stls-button,
94
- #load-sample-stls-button button {
 
 
95
  background: #f97316 !important;
96
  border-color: #ea580c !important;
97
  color: #ffffff !important;
98
  }
99
 
100
  #load-sample-stls-button:hover,
101
- #load-sample-stls-button button:hover {
 
 
102
  background: #ea580c !important;
103
  border-color: #c2410c !important;
104
  }
105
 
106
  #load-sample-stls-button:focus-visible,
107
- #load-sample-stls-button button:focus-visible {
 
 
108
  box-shadow: 0 0 0 2px rgba(249, 115, 22, 0.35) !important;
109
  }
110
 
@@ -744,7 +751,8 @@ def _format_model_details(
744
  lines = [
745
  "### Model Details",
746
  f"- Source: `{source_name}`",
747
- f"- Extents: `{extents[0]:.3f} x {extents[1]:.3f} x {extents[2]:.3f}`",
 
748
  ]
749
 
750
  if scale_factors and not all(math.isclose(value, 1.0) for value in scale_factors):
@@ -1740,21 +1748,17 @@ PARALLEL_COLOR_CHOICES = [
1740
  ("Red", "#d62728"), ("Purple", "#9467bd"), ("Pink", "#e377c2"),
1741
  ("Teal", "#17becf"), ("Black", "#000000"),
1742
  ]
 
1743
 
1744
 
1745
- def render_parallel(
1746
  path1: str | None,
1747
  path2: str | None,
1748
  path3: str | None,
1749
  color1: str,
1750
  color2: str,
1751
  color3: str,
1752
- travel_opacity: float,
1753
- filament_width: float,
1754
- travel_width: float,
1755
- gap: float,
1756
- tube: bool = True,
1757
- ) -> tuple[Any, str]:
1758
  specs = [(1, path1, color1), (2, path2, color2), (3, path3, color3)]
1759
  parts: list[dict] = []
1760
  messages: list[str] = []
@@ -1774,17 +1778,188 @@ def render_parallel(
1774
  parts.append({"idx": idx, "color": color, "parsed": parsed})
1775
  messages.append(f"Shape {idx}: {parsed['point_count']} moves, {parsed.get('layer_count', 0)} layer(s).")
1776
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1777
  if not parts:
1778
  return None, "No shape G-code available. Generate G-code on the TIFF Slices to GCode tab first."
 
 
 
 
 
 
 
 
1779
 
1780
  figure = build_parallel_figure(
1781
  parts,
1782
- gap=float(gap),
1783
  filament_width=float(filament_width),
1784
  travel_width=float(travel_width),
1785
  travel_opacity=float(travel_opacity),
1786
  tube=tube,
1787
  )
 
1788
  return figure, " \n".join(messages)
1789
 
1790
 
@@ -1824,7 +1999,11 @@ def export_parallel_gif(
1824
  color2: str,
1825
  color3: str,
1826
  travel_opacity: float,
1827
- gap: float,
 
 
 
 
1828
  duration: float,
1829
  fps: float,
1830
  elev: float,
@@ -1836,20 +2015,18 @@ def export_parallel_gif(
1836
  CPU-only (Agg backend) — no WebGL or headless browser — so it works locally
1837
  and on Hugging Face. Each shape grows as colored lines on a shared timeline.
1838
  """
1839
- specs = [(path1, color1), (path2, color2), (path3, color3)]
1840
- parts: list[dict] = []
1841
- for idx, (path, color) in enumerate(specs, start=1):
1842
- if not path:
1843
- continue
1844
- try:
1845
- parsed = parse_gcode_path(Path(path).read_text())
1846
- except OSError:
1847
- continue
1848
- if parsed.get("point_count"):
1849
- parts.append({"idx": idx, "color": color, "parsed": parsed})
1850
 
1851
  if not parts:
1852
  return None
 
 
 
 
 
 
 
 
1853
 
1854
  def report(frame: int, total: int) -> None:
1855
  progress(frame / total, desc=f"Rendering frame {frame + 1}/{total}")
@@ -1858,7 +2035,7 @@ def export_parallel_gif(
1858
  result = build_parallel_gif(
1859
  parts,
1860
  out_path=out_path,
1861
- gap=float(gap),
1862
  duration=float(duration),
1863
  fps=int(fps),
1864
  travel_opacity=float(travel_opacity),
@@ -2104,9 +2281,9 @@ def build_demo() -> gr.Blocks:
2104
 
2105
  # --- Shared slicing controls ---
2106
  with gr.Row():
2107
- layer_height = gr.Number(label="Layer Height", value=0.8, minimum=0.0001, step=0.01)
2108
  pixel_size = gr.Number(
2109
- label="Pixel Size/Fill Width",
2110
  value=0.8,
2111
  minimum=0.0001,
2112
  step=0.01,
@@ -2504,6 +2681,7 @@ def build_demo() -> gr.Blocks:
2504
  ),
2505
  value=True,
2506
  )
 
2507
  gcode_button = gr.Button("Generate G-Code", variant="primary")
2508
 
2509
  with gr.Row():
@@ -2529,6 +2707,47 @@ def build_demo() -> gr.Blocks:
2529
 
2530
  gcode_status = gr.Markdown("")
2531
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2532
  gcode_button.click(
2533
  fn=run_all_tiff_to_gcode,
2534
  inputs=[
@@ -2588,6 +2807,27 @@ def build_demo() -> gr.Blocks:
2588
  return [];
2589
  }""",
2590
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2591
 
2592
  with gr.Tab("G-Code Visualization"):
2593
  gr.Markdown(
@@ -2862,10 +3102,6 @@ def build_demo() -> gr.Blocks:
2862
  label="Travel width (mm)", minimum=0.05, maximum=3.0,
2863
  value=0.2, step=0.05, min_width=150,
2864
  )
2865
- pp_gap = gr.Slider(
2866
- label="Gap between parts (mm)",
2867
- minimum=0.0, maximum=50.0, value=5.0, step=0.5,
2868
- )
2869
  parallel_status = gr.Markdown("")
2870
 
2871
  with gr.Group(visible=False) as pp_export_group:
@@ -2906,7 +3142,10 @@ def build_demo() -> gr.Blocks:
2906
  parallel_render_inputs = [
2907
  gcode_file_1, gcode_file_2, gcode_file_3,
2908
  pp_color_1, pp_color_2, pp_color_3,
2909
- pp_travel_opacity, pp_filament_width, pp_travel_width, pp_gap,
 
 
 
2910
  ]
2911
  parallel_outputs = [
2912
  parallel_plot, parallel_status, parallel_mode,
@@ -2922,13 +3161,33 @@ def build_demo() -> gr.Blocks:
2922
  inputs=parallel_render_inputs,
2923
  outputs=parallel_outputs,
2924
  )
2925
- # Width and gap changes rebuild the figure in the last-used mode.
2926
- for _slider in (pp_filament_width, pp_travel_width, pp_gap):
 
 
 
2927
  _slider.release(
2928
  fn=rerender_parallel_current_mode,
2929
  inputs=[parallel_mode] + parallel_render_inputs,
2930
  outputs=[parallel_plot, parallel_status],
2931
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2932
  # Color changes recolor a part's print/travel/nozzle traces client-side.
2933
  for _picker, _idx in ((pp_color_1, 1), (pp_color_2, 2), (pp_color_3, 3)):
2934
  _picker.change(
@@ -2974,7 +3233,11 @@ def build_demo() -> gr.Blocks:
2974
  inputs=[
2975
  gcode_file_1, gcode_file_2, gcode_file_3,
2976
  pp_color_1, pp_color_2, pp_color_3,
2977
- pp_gif_travel_opacity, pp_gap, pp_gif_duration, pp_gif_fps,
 
 
 
 
2978
  pp_elev, pp_azim,
2979
  ],
2980
  outputs=[pp_gif_file],
 
22
  import trimesh
23
 
24
  from gcode_viewer import (
25
+ build_nozzle_spacing_figure,
26
  build_parallel_figure,
27
  build_parallel_gif,
28
  build_toolpath_figure,
 
92
  }
93
 
94
  #load-sample-stls-button,
95
+ #load-sample-stls-button button,
96
+ #visualize-nozzle-spacing-button,
97
+ #visualize-nozzle-spacing-button button {
98
  background: #f97316 !important;
99
  border-color: #ea580c !important;
100
  color: #ffffff !important;
101
  }
102
 
103
  #load-sample-stls-button:hover,
104
+ #load-sample-stls-button button:hover,
105
+ #visualize-nozzle-spacing-button:hover,
106
+ #visualize-nozzle-spacing-button button:hover {
107
  background: #ea580c !important;
108
  border-color: #c2410c !important;
109
  }
110
 
111
  #load-sample-stls-button:focus-visible,
112
+ #load-sample-stls-button button:focus-visible,
113
+ #visualize-nozzle-spacing-button:focus-visible,
114
+ #visualize-nozzle-spacing-button button:focus-visible {
115
  box-shadow: 0 0 0 2px rgba(249, 115, 22, 0.35) !important;
116
  }
117
 
 
751
  lines = [
752
  "### Model Details",
753
  f"- Source: `{source_name}`",
754
+ f"- Dimensions (mm): `X {extents[0]:.3f}, Y {extents[1]:.3f}, Z {extents[2]:.3f}`",
755
+ f"- Footprint (mm): `X {extents[0]:.3f} x Y {extents[1]:.3f}`",
756
  ]
757
 
758
  if scale_factors and not all(math.isclose(value, 1.0) for value in scale_factors):
 
1748
  ("Red", "#d62728"), ("Purple", "#9467bd"), ("Pink", "#e377c2"),
1749
  ("Teal", "#17becf"), ("Black", "#000000"),
1750
  ]
1751
+ DEFAULT_PARALLEL_COLORS = ("#ff7f0e", "#1f77b4", "#2ca02c")
1752
 
1753
 
1754
+ def _load_parallel_parts(
1755
  path1: str | None,
1756
  path2: str | None,
1757
  path3: str | None,
1758
  color1: str,
1759
  color2: str,
1760
  color3: str,
1761
+ ) -> tuple[list[dict], list[str]]:
 
 
 
 
 
1762
  specs = [(1, path1, color1), (2, path2, color2), (3, path3, color3)]
1763
  parts: list[dict] = []
1764
  messages: list[str] = []
 
1778
  parts.append({"idx": idx, "color": color, "parsed": parsed})
1779
  messages.append(f"Shape {idx}: {parsed['point_count']} moves, {parsed.get('layer_count', 0)} layer(s).")
1780
 
1781
+ return parts, messages
1782
+
1783
+
1784
+ def _resolve_nozzle_layout(
1785
+ parts: list[dict],
1786
+ same_spacing: bool | None,
1787
+ part_gap_12_x: float | None,
1788
+ part_gap_12_y: float | None,
1789
+ part_gap_23_x: float | None,
1790
+ part_gap_23_y: float | None,
1791
+ ) -> tuple[dict[int, tuple[float, float]], list[dict]]:
1792
+ offsets: dict[int, tuple[float, float]] = {}
1793
+ spacings: list[dict] = []
1794
+ if not parts:
1795
+ return offsets, spacings
1796
+
1797
+ ordered = sorted(parts, key=lambda part: part["idx"])
1798
+ offsets[ordered[0]["idx"]] = (0.0, 0.0)
1799
+
1800
+ pair_spacing = {
1801
+ (1, 2): (float(part_gap_12_x or 0.0), float(part_gap_12_y or 0.0)),
1802
+ (2, 3): (
1803
+ float(part_gap_12_x or 0.0) if same_spacing else float(part_gap_23_x or 0.0),
1804
+ float(part_gap_12_y or 0.0) if same_spacing else float(part_gap_23_y or 0.0),
1805
+ ),
1806
+ }
1807
+
1808
+ def spacing_between(prev_idx: int, cur_idx: int) -> tuple[float, float]:
1809
+ if (prev_idx, cur_idx) in pair_spacing:
1810
+ return pair_spacing[(prev_idx, cur_idx)]
1811
+ if prev_idx == 1 and cur_idx == 3:
1812
+ x12, y12 = pair_spacing[(1, 2)]
1813
+ x23, y23 = pair_spacing[(2, 3)]
1814
+ return x12 + x23, y12 + y23
1815
+ return 0.0, 0.0
1816
+
1817
+ for previous, current in zip(ordered, ordered[1:]):
1818
+ prev_idx = previous["idx"]
1819
+ cur_idx = current["idx"]
1820
+ gap_x, y_step = spacing_between(prev_idx, cur_idx)
1821
+ prev_offset_x, prev_offset_y = offsets[prev_idx]
1822
+ (_, _, _), (prev_xmax, _prev_ymax, _) = previous["parsed"]["bounds"]
1823
+ (cur_xmin, _cur_ymin, _), (_, _, _) = current["parsed"]["bounds"]
1824
+ dx = (prev_xmax + prev_offset_x + gap_x) - cur_xmin
1825
+ dy = prev_offset_y + y_step
1826
+ offsets[cur_idx] = (dx, dy)
1827
+ spacings.append({
1828
+ "from": prev_idx,
1829
+ "to": cur_idx,
1830
+ "dx": dx - prev_offset_x,
1831
+ "dy": y_step,
1832
+ })
1833
+
1834
+ return offsets, spacings
1835
+
1836
+
1837
+ def _same_spacing_from_individual(use_individual_spacing: bool | None) -> bool:
1838
+ return not bool(use_individual_spacing)
1839
+
1840
+
1841
+ def update_individual_nozzle_spacing_visibility(
1842
+ use_individual_spacing: bool | None,
1843
+ ) -> dict[str, Any]:
1844
+ return gr.update(visible=bool(use_individual_spacing))
1845
+
1846
+
1847
+ def _format_shape_dimensions(parts: list[dict]) -> list[str]:
1848
+ lines = ["**Shape dimensions from generated G-code:**"]
1849
+ for part in sorted(parts, key=lambda item: item["idx"]):
1850
+ (xmin, ymin, zmin), (xmax, ymax, zmax) = part["parsed"]["bounds"]
1851
+ lines.append(
1852
+ f"Shape {part['idx']}: X {xmax - xmin:.2f} mm, "
1853
+ f"Y {ymax - ymin:.2f} mm, Z {zmax - zmin:.2f} mm."
1854
+ )
1855
+ return lines
1856
+
1857
+
1858
+ def _format_nozzle_spacing_status(
1859
+ parts: list[dict],
1860
+ offsets: dict[int, tuple[float, float]],
1861
+ spacings: list[dict],
1862
+ ) -> str:
1863
+ lines = _format_shape_dimensions(parts)
1864
+ ordered_nozzles = sorted(offsets)
1865
+ if ordered_nozzles:
1866
+ lines.append("**Nozzle coordinates:**")
1867
+ for idx in ordered_nozzles:
1868
+ x, y = offsets[idx]
1869
+ lines.append(f"Nozzle {idx}: X {x:.2f} mm, Y {y:.2f} mm.")
1870
+
1871
+ if not spacings:
1872
+ lines.append("Generate G-code for at least two shapes to calculate nozzle spacing.")
1873
+ return " \n".join(lines)
1874
+
1875
+ lines.append("**Nozzle-to-nozzle distances:**")
1876
+ for first_pos, first_idx in enumerate(ordered_nozzles):
1877
+ for second_idx in ordered_nozzles[first_pos + 1:]:
1878
+ x0, y0 = offsets[first_idx]
1879
+ x1, y1 = offsets[second_idx]
1880
+ dx = x1 - x0
1881
+ dy = y1 - y0
1882
+ distance = math.hypot(dx, dy)
1883
+ angle = math.degrees(math.atan2(dy, dx)) if distance else 0.0
1884
+ lines.append(
1885
+ f"Nozzle {first_idx} -> {second_idx}: "
1886
+ f"Delta X {dx:.2f} mm, Delta Y {dy:.2f} mm; "
1887
+ f"distance {distance:.2f} mm at {angle:.1f} deg."
1888
+ )
1889
+
1890
+ lines.append("**Adjacent spacing inputs:**")
1891
+ for spacing in spacings:
1892
+ lines.append(
1893
+ f"Nozzle {spacing['from']} -> {spacing['to']}: "
1894
+ f"X {spacing['dx']:.2f} mm, Y {spacing['dy']:.2f} mm."
1895
+ )
1896
+ return " \n".join(lines)
1897
+
1898
+
1899
+ def render_nozzle_spacing_preview(
1900
+ path1: str | None,
1901
+ path2: str | None,
1902
+ path3: str | None,
1903
+ use_individual_spacing: bool,
1904
+ part_gap_12_x: float,
1905
+ part_gap_12_y: float,
1906
+ part_gap_23_x: float,
1907
+ part_gap_23_y: float,
1908
+ ) -> tuple[Any, str]:
1909
+ parts, _messages = _load_parallel_parts(path1, path2, path3, *DEFAULT_PARALLEL_COLORS)
1910
+ if not parts:
1911
+ return None, "No shape G-code available. Generate G-code first."
1912
+ offsets, spacings = _resolve_nozzle_layout(
1913
+ parts,
1914
+ _same_spacing_from_individual(use_individual_spacing),
1915
+ part_gap_12_x,
1916
+ part_gap_12_y,
1917
+ part_gap_23_x,
1918
+ part_gap_23_y,
1919
+ )
1920
+ figure = build_nozzle_spacing_figure(parts, offsets, spacings)
1921
+ return figure, _format_nozzle_spacing_status(parts, offsets, spacings)
1922
+
1923
+
1924
+ def render_parallel(
1925
+ path1: str | None,
1926
+ path2: str | None,
1927
+ path3: str | None,
1928
+ color1: str,
1929
+ color2: str,
1930
+ color3: str,
1931
+ travel_opacity: float,
1932
+ filament_width: float,
1933
+ travel_width: float,
1934
+ use_individual_spacing: bool,
1935
+ part_gap_12_x: float,
1936
+ part_gap_12_y: float,
1937
+ part_gap_23_x: float,
1938
+ part_gap_23_y: float,
1939
+ tube: bool = True,
1940
+ ) -> tuple[Any, str]:
1941
+ parts, messages = _load_parallel_parts(path1, path2, path3, color1, color2, color3)
1942
+
1943
  if not parts:
1944
  return None, "No shape G-code available. Generate G-code on the TIFF Slices to GCode tab first."
1945
+ offsets, spacings = _resolve_nozzle_layout(
1946
+ parts,
1947
+ _same_spacing_from_individual(use_individual_spacing),
1948
+ part_gap_12_x,
1949
+ part_gap_12_y,
1950
+ part_gap_23_x,
1951
+ part_gap_23_y,
1952
+ )
1953
 
1954
  figure = build_parallel_figure(
1955
  parts,
1956
+ part_offsets=offsets,
1957
  filament_width=float(filament_width),
1958
  travel_width=float(travel_width),
1959
  travel_opacity=float(travel_opacity),
1960
  tube=tube,
1961
  )
1962
+ messages.append(_format_nozzle_spacing_status(parts, offsets, spacings))
1963
  return figure, " \n".join(messages)
1964
 
1965
 
 
1999
  color2: str,
2000
  color3: str,
2001
  travel_opacity: float,
2002
+ use_individual_spacing: bool,
2003
+ part_gap_12_x: float,
2004
+ part_gap_12_y: float,
2005
+ part_gap_23_x: float,
2006
+ part_gap_23_y: float,
2007
  duration: float,
2008
  fps: float,
2009
  elev: float,
 
2015
  CPU-only (Agg backend) — no WebGL or headless browser — so it works locally
2016
  and on Hugging Face. Each shape grows as colored lines on a shared timeline.
2017
  """
2018
+ parts, _messages = _load_parallel_parts(path1, path2, path3, color1, color2, color3)
 
 
 
 
 
 
 
 
 
 
2019
 
2020
  if not parts:
2021
  return None
2022
+ offsets, _spacings = _resolve_nozzle_layout(
2023
+ parts,
2024
+ _same_spacing_from_individual(use_individual_spacing),
2025
+ part_gap_12_x,
2026
+ part_gap_12_y,
2027
+ part_gap_23_x,
2028
+ part_gap_23_y,
2029
+ )
2030
 
2031
  def report(frame: int, total: int) -> None:
2032
  progress(frame / total, desc=f"Rendering frame {frame + 1}/{total}")
 
2035
  result = build_parallel_gif(
2036
  parts,
2037
  out_path=out_path,
2038
+ part_offsets=offsets,
2039
  duration=float(duration),
2040
  fps=int(fps),
2041
  travel_opacity=float(travel_opacity),
 
2281
 
2282
  # --- Shared slicing controls ---
2283
  with gr.Row():
2284
+ layer_height = gr.Number(label="Layer Height (mm)", value=0.8, minimum=0.0001, step=0.01)
2285
  pixel_size = gr.Number(
2286
+ label="Pixel Size/Fill Width (mm)",
2287
  value=0.8,
2288
  minimum=0.0001,
2289
  step=0.01,
 
2681
  ),
2682
  value=True,
2683
  )
2684
+
2685
  gcode_button = gr.Button("Generate G-Code", variant="primary")
2686
 
2687
  with gr.Row():
 
2707
 
2708
  gcode_status = gr.Markdown("")
2709
 
2710
+ with gr.Group():
2711
+ gr.Markdown("### Nozzle Spacing")
2712
+ nozzle_use_individual_spacing = gr.Checkbox(
2713
+ label="Use Individual Spacing",
2714
+ value=False,
2715
+ )
2716
+ with gr.Row():
2717
+ nozzle_part_gap_12_x = gr.Number(
2718
+ label="Shared / Shape 1 -> 2 X edge spacing (mm)",
2719
+ value=5.0,
2720
+ step=0.5,
2721
+ )
2722
+ nozzle_part_gap_12_y = gr.Number(
2723
+ label="Shared / Nozzle 1 -> 2 Y spacing (mm)",
2724
+ value=0.0,
2725
+ step=0.5,
2726
+ )
2727
+ with gr.Row(visible=False) as individual_nozzle_spacing_row:
2728
+ nozzle_part_gap_23_x = gr.Number(
2729
+ label="Shape 2 -> 3 X edge spacing (mm)",
2730
+ value=5.0,
2731
+ step=0.5,
2732
+ )
2733
+ nozzle_part_gap_23_y = gr.Number(
2734
+ label="Nozzle 2 -> 3 Y spacing (mm)",
2735
+ value=0.0,
2736
+ step=0.5,
2737
+ )
2738
+ nozzle_preview_button = gr.Button(
2739
+ "Visualize Nozzle Spacing",
2740
+ variant="secondary",
2741
+ elem_id="visualize-nozzle-spacing-button",
2742
+ )
2743
+ with gr.Row():
2744
+ with gr.Column(scale=3, min_width=420):
2745
+ nozzle_spacing_plot = gr.Plot(label="Nozzle Spacing")
2746
+ with gr.Column(scale=1, min_width=260):
2747
+ nozzle_spacing_status = gr.Markdown(
2748
+ "Generate G-code, then visualize nozzle spacing."
2749
+ )
2750
+
2751
  gcode_button.click(
2752
  fn=run_all_tiff_to_gcode,
2753
  inputs=[
 
2807
  return [];
2808
  }""",
2809
  )
2810
+ nozzle_spacing_inputs = [
2811
+ gcode_file_1,
2812
+ gcode_file_2,
2813
+ gcode_file_3,
2814
+ nozzle_use_individual_spacing,
2815
+ nozzle_part_gap_12_x,
2816
+ nozzle_part_gap_12_y,
2817
+ nozzle_part_gap_23_x,
2818
+ nozzle_part_gap_23_y,
2819
+ ]
2820
+ nozzle_use_individual_spacing.change(
2821
+ fn=update_individual_nozzle_spacing_visibility,
2822
+ inputs=[nozzle_use_individual_spacing],
2823
+ outputs=[individual_nozzle_spacing_row],
2824
+ queue=False,
2825
+ )
2826
+ nozzle_preview_button.click(
2827
+ fn=render_nozzle_spacing_preview,
2828
+ inputs=nozzle_spacing_inputs,
2829
+ outputs=[nozzle_spacing_plot, nozzle_spacing_status],
2830
+ )
2831
 
2832
  with gr.Tab("G-Code Visualization"):
2833
  gr.Markdown(
 
3102
  label="Travel width (mm)", minimum=0.05, maximum=3.0,
3103
  value=0.2, step=0.05, min_width=150,
3104
  )
 
 
 
 
3105
  parallel_status = gr.Markdown("")
3106
 
3107
  with gr.Group(visible=False) as pp_export_group:
 
3142
  parallel_render_inputs = [
3143
  gcode_file_1, gcode_file_2, gcode_file_3,
3144
  pp_color_1, pp_color_2, pp_color_3,
3145
+ pp_travel_opacity, pp_filament_width, pp_travel_width,
3146
+ nozzle_use_individual_spacing,
3147
+ nozzle_part_gap_12_x, nozzle_part_gap_12_y,
3148
+ nozzle_part_gap_23_x, nozzle_part_gap_23_y,
3149
  ]
3150
  parallel_outputs = [
3151
  parallel_plot, parallel_status, parallel_mode,
 
3161
  inputs=parallel_render_inputs,
3162
  outputs=parallel_outputs,
3163
  )
3164
+ # Width changes rebuild the figure in the last-used mode after the slider is released.
3165
+ for _slider in (
3166
+ pp_filament_width,
3167
+ pp_travel_width,
3168
+ ):
3169
  _slider.release(
3170
  fn=rerender_parallel_current_mode,
3171
  inputs=[parallel_mode] + parallel_render_inputs,
3172
  outputs=[parallel_plot, parallel_status],
3173
  )
3174
+ # Number inputs do not support release() in this Gradio build.
3175
+ nozzle_use_individual_spacing.change(
3176
+ fn=rerender_parallel_current_mode,
3177
+ inputs=[parallel_mode] + parallel_render_inputs,
3178
+ outputs=[parallel_plot, parallel_status],
3179
+ )
3180
+ for _number in (
3181
+ nozzle_part_gap_12_x,
3182
+ nozzle_part_gap_12_y,
3183
+ nozzle_part_gap_23_x,
3184
+ nozzle_part_gap_23_y,
3185
+ ):
3186
+ _number.change(
3187
+ fn=rerender_parallel_current_mode,
3188
+ inputs=[parallel_mode] + parallel_render_inputs,
3189
+ outputs=[parallel_plot, parallel_status],
3190
+ )
3191
  # Color changes recolor a part's print/travel/nozzle traces client-side.
3192
  for _picker, _idx in ((pp_color_1, 1), (pp_color_2, 2), (pp_color_3, 3)):
3193
  _picker.change(
 
3233
  inputs=[
3234
  gcode_file_1, gcode_file_2, gcode_file_3,
3235
  pp_color_1, pp_color_2, pp_color_3,
3236
+ pp_gif_travel_opacity,
3237
+ nozzle_use_individual_spacing,
3238
+ nozzle_part_gap_12_x, nozzle_part_gap_12_y,
3239
+ nozzle_part_gap_23_x, nozzle_part_gap_23_y,
3240
+ pp_gif_duration, pp_gif_fps,
3241
  pp_elev, pp_azim,
3242
  ],
3243
  outputs=[pp_gif_file],
gcode_viewer.py CHANGED
@@ -515,14 +515,15 @@ def build_toolpath_figure(
515
  def build_parallel_figure(
516
  parts: list[dict],
517
  gap: float = 5.0,
 
518
  filament_width: float = 0.8,
519
  travel_width: float = 0.2,
520
  travel_opacity: float = 0.2,
521
  print_opacity: float = 1.0,
522
  tube: bool = True,
523
  ) -> go.Figure:
524
- """Render several parsed shapes side by side, offset along X so they don't
525
- overlap. `tube` True draws filament tubes with a shared-time animation
526
  timeline; False draws fast thin scatter lines (no animation).
527
 
528
  `parts` is a list of {"idx": int, "color": str, "parsed": dict}. Each part's
@@ -548,8 +549,12 @@ def build_parallel_figure(
548
 
549
  (pxmin, pymin, pzmin), (pxmax, pymax, pzmax) = parsed["bounds"]
550
  width = pxmax - pxmin
551
- x_off = running_x - pxmin
552
- running_x += width + gap
 
 
 
 
553
 
554
  if tube:
555
  print_tube = _build_path_tube(moves, radius=max(filament_width, 0.05) / 2.0, kind="print")
@@ -557,13 +562,16 @@ def build_parallel_figure(
557
  path = _path_arrays(moves)
558
 
559
  px = [v + x_off for v in print_tube["x"]]
 
560
  tx = [v + x_off for v in travel_tube["x"]]
 
561
  path_x = [v + x_off for v in path["x"]]
 
562
 
563
  if travel_tube["i"]:
564
  fig.add_trace(
565
  go.Mesh3d(
566
- x=tx, y=travel_tube["y"], z=travel_tube["z"],
567
  i=travel_tube["i"], j=travel_tube["j"], k=travel_tube["k"],
568
  color=color, opacity=travel_opacity, name=f"Travel {idx}",
569
  showlegend=False, hoverinfo="skip",
@@ -573,7 +581,7 @@ def build_parallel_figure(
573
  if print_tube["i"]:
574
  fig.add_trace(
575
  go.Mesh3d(
576
- x=px, y=print_tube["y"], z=print_tube["z"],
577
  i=print_tube["i"], j=print_tube["j"], k=print_tube["k"],
578
  color=color, opacity=print_opacity, name=f"Shape {idx}",
579
  showlegend=True, hoverinfo="skip",
@@ -582,7 +590,7 @@ def build_parallel_figure(
582
  )
583
  fig.add_trace(
584
  go.Scatter3d(
585
- x=[path_x[-1]], y=[path["y"][-1]], z=[path["z"][-1]],
586
  mode="markers", name=f"Nozzle {idx}",
587
  marker=dict(size=4, color=color), showlegend=False, hoverinfo="skip",
588
  )
@@ -596,13 +604,15 @@ def build_parallel_figure(
596
  "nozzleName": f"Nozzle {idx}",
597
  "print_face_t": print_tube["face_t"],
598
  "travel_face_t": travel_tube["face_t"],
599
- "path_x": path_x, "path_y": path["y"], "path_z": path["z"], "path_t": path["t"],
600
  })
601
  else:
602
  t_xs, t_ys, t_zs = _segments_to_xyz(parsed["travel_segments"])
603
  p_xs, p_ys, p_zs = _segments_to_xyz(parsed["print_segments"])
604
  t_xs = [v + x_off if v is not None else None for v in t_xs]
605
  p_xs = [v + x_off if v is not None else None for v in p_xs]
 
 
606
  if t_xs:
607
  fig.add_trace(
608
  go.Scatter3d(
@@ -623,7 +633,7 @@ def build_parallel_figure(
623
  rendered = True
624
  n_parts += 1
625
  bx0 = min(bx0, pxmin + x_off); bx1 = max(bx1, pxmax + x_off)
626
- by0 = min(by0, pymin); by1 = max(by1, pymax)
627
  bz0 = min(bz0, pzmin); bz1 = max(bz1, pzmax)
628
 
629
  if not rendered:
@@ -650,10 +660,90 @@ def build_parallel_figure(
650
  return fig
651
 
652
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
653
  def build_parallel_gif(
654
  parts: list[dict],
655
  out_path: str | Path,
656
  gap: float = 5.0,
 
657
  duration: float = 6.0,
658
  fps: int = 10,
659
  travel_opacity: float = 0.15,
@@ -689,14 +779,18 @@ def build_parallel_gif(
689
  if not moves:
690
  continue
691
  (pxmin, pymin, pzmin), (pxmax, pymax, pzmax) = parsed["bounds"]
692
- x_off = running_x - pxmin
693
- running_x += (pxmax - pxmin) + gap
 
 
 
 
694
 
695
  cum = 0.0
696
  mlist: list[tuple] = []
697
  for m in moves:
698
- s = (m["start"][0] + x_off, m["start"][1], m["start"][2])
699
- e = (m["end"][0] + x_off, m["end"][1], m["end"][2])
700
  seg_len = math.dist(s, e)
701
  mlist.append((m["kind"], s, e, cum, cum + seg_len))
702
  cum += seg_len
@@ -707,7 +801,7 @@ def build_parallel_gif(
707
  })
708
 
709
  bx0 = min(bx0, pxmin + x_off); bx1 = max(bx1, pxmax + x_off)
710
- by0 = min(by0, pymin); by1 = max(by1, pymax)
711
  bz0 = min(bz0, pzmin); bz1 = max(bz1, pzmax)
712
 
713
  if not pdata or total_length <= 0:
 
515
  def build_parallel_figure(
516
  parts: list[dict],
517
  gap: float = 5.0,
518
+ part_offsets: dict[int, tuple[float, float]] | None = None,
519
  filament_width: float = 0.8,
520
  travel_width: float = 0.2,
521
  travel_opacity: float = 0.2,
522
  print_opacity: float = 1.0,
523
  tube: bool = True,
524
  ) -> go.Figure:
525
+ """Render several parsed shapes with optional explicit X/Y nozzle offsets.
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
 
549
 
550
  (pxmin, pymin, pzmin), (pxmax, pymax, pzmax) = parsed["bounds"]
551
  width = pxmax - pxmin
552
+ if part_offsets is None:
553
+ x_off = running_x - pxmin
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")
 
562
  path = _path_arrays(moves)
563
 
564
  px = [v + x_off for v in print_tube["x"]]
565
+ py = [v + y_off for v in print_tube["y"]]
566
  tx = [v + x_off for v in travel_tube["x"]]
567
+ ty = [v + y_off for v in travel_tube["y"]]
568
  path_x = [v + x_off for v in path["x"]]
569
+ path_y = [v + y_off for v in path["y"]]
570
 
571
  if travel_tube["i"]:
572
  fig.add_trace(
573
  go.Mesh3d(
574
+ x=tx, y=ty, z=travel_tube["z"],
575
  i=travel_tube["i"], j=travel_tube["j"], k=travel_tube["k"],
576
  color=color, opacity=travel_opacity, name=f"Travel {idx}",
577
  showlegend=False, hoverinfo="skip",
 
581
  if print_tube["i"]:
582
  fig.add_trace(
583
  go.Mesh3d(
584
+ x=px, y=py, z=print_tube["z"],
585
  i=print_tube["i"], j=print_tube["j"], k=print_tube["k"],
586
  color=color, opacity=print_opacity, name=f"Shape {idx}",
587
  showlegend=True, hoverinfo="skip",
 
590
  )
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
  )
 
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"],
608
  })
609
  else:
610
  t_xs, t_ys, t_zs = _segments_to_xyz(parsed["travel_segments"])
611
  p_xs, p_ys, p_zs = _segments_to_xyz(parsed["print_segments"])
612
  t_xs = [v + x_off if v is not None else None for v in t_xs]
613
  p_xs = [v + x_off if v is not None else None for v in p_xs]
614
+ t_ys = [v + y_off if v is not None else None for v in t_ys]
615
+ p_ys = [v + y_off if v is not None else None for v in p_ys]
616
  if t_xs:
617
  fig.add_trace(
618
  go.Scatter3d(
 
633
  rendered = True
634
  n_parts += 1
635
  bx0 = min(bx0, pxmin + x_off); bx1 = max(bx1, pxmax + x_off)
636
+ by0 = min(by0, pymin + y_off); by1 = max(by1, pymax + y_off)
637
  bz0 = min(bz0, pzmin); bz1 = max(bz1, pzmax)
638
 
639
  if not rendered:
 
660
  return fig
661
 
662
 
663
+ def build_nozzle_spacing_figure(
664
+ parts: list[dict],
665
+ part_offsets: dict[int, tuple[float, float]],
666
+ spacings: list[dict],
667
+ ) -> go.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(
681
+ go.Scatter(
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"]
707
+ end = spacing["to"]
708
+ x0, y0 = part_offsets.get(start, (0.0, 0.0))
709
+ x1, y1 = part_offsets.get(end, (0.0, 0.0))
710
+ fig.add_trace(
711
+ go.Scatter(
712
+ x=[x0, x1],
713
+ y=[y0, y1],
714
+ mode="lines+markers+text",
715
+ name=f"Nozzle {start}->{end}",
716
+ line=dict(color="#444444", width=2, dash="dash"),
717
+ marker=dict(color="#444444", size=6),
718
+ text=["", f"dx {spacing['dx']:.2f}, dy {spacing['dy']:.2f}"],
719
+ textposition="middle right",
720
+ )
721
+ )
722
+
723
+ if bx0 == float("inf"):
724
+ bx0 = by0 = -1.0
725
+ bx1 = by1 = 1.0
726
+
727
+ pad = max(bx1 - bx0, by1 - by0, 1.0) * 0.08
728
+ fig.update_layout(
729
+ height=420,
730
+ uirevision="nozzle-spacing",
731
+ xaxis_title="X (mm)",
732
+ yaxis_title="Y (mm)",
733
+ xaxis=dict(range=[bx0 - pad, bx1 + pad], scaleanchor="y", scaleratio=1),
734
+ yaxis=dict(range=[by0 - pad, by1 + pad]),
735
+ margin=dict(l=0, r=0, t=30, b=0),
736
+ legend=dict(orientation="h", yanchor="bottom", y=1.02, xanchor="left", x=0.0),
737
+ title="Nozzle spacing layout",
738
+ )
739
+ return fig
740
+
741
+
742
  def build_parallel_gif(
743
  parts: list[dict],
744
  out_path: str | Path,
745
  gap: float = 5.0,
746
+ part_offsets: dict[int, tuple[float, float]] | None = None,
747
  duration: float = 6.0,
748
  fps: int = 10,
749
  travel_opacity: float = 0.15,
 
779
  if not moves:
780
  continue
781
  (pxmin, pymin, pzmin), (pxmax, pymax, pzmax) = parsed["bounds"]
782
+ if part_offsets is None:
783
+ x_off = running_x - pxmin
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] = []
791
  for m in moves:
792
+ s = (m["start"][0] + x_off, m["start"][1] + y_off, m["start"][2])
793
+ e = (m["end"][0] + x_off, m["end"][1] + y_off, m["end"][2])
794
  seg_len = math.dist(s, e)
795
  mlist.append((m["kind"], s, e, cum, cum + seg_len))
796
  cum += seg_len
 
801
  })
802
 
803
  bx0 = min(bx0, pxmin + x_off); bx1 = max(bx1, pxmax + x_off)
804
+ by0 = min(by0, pymin + y_off); by1 = max(by1, pymax + y_off)
805
  bz0 = min(bz0, pzmin); bz1 = max(bz1, pzmax)
806
 
807
  if not pdata or total_length <= 0:
tests/test_nozzle_spacing.py ADDED
@@ -0,0 +1,105 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import numpy as np
4
+
5
+ from app import (
6
+ _format_nozzle_spacing_status,
7
+ _resolve_nozzle_layout,
8
+ )
9
+
10
+
11
+ def _part(idx: int, bounds: tuple[tuple[float, float, float], tuple[float, float, float]]) -> dict:
12
+ return {
13
+ "idx": idx,
14
+ "color": "#000000",
15
+ "parsed": {"bounds": bounds, "moves": [{"kind": "print"}], "point_count": 1},
16
+ }
17
+
18
+
19
+ def test_calculated_nozzle_layout_uses_requested_part_gap() -> None:
20
+ parts = [
21
+ _part(1, ((0.0, 0.0, 0.0), (10.0, 20.0, 1.0))),
22
+ _part(2, ((0.0, 0.0, 0.0), (5.0, 6.0, 1.0))),
23
+ _part(3, ((-2.0, -1.0, 0.0), (2.0, 3.0, 1.0))),
24
+ ]
25
+
26
+ offsets, spacings = _resolve_nozzle_layout(
27
+ parts,
28
+ same_spacing=False,
29
+ part_gap_12_x=2.5,
30
+ part_gap_12_y=-1.0,
31
+ part_gap_23_x=8.0,
32
+ part_gap_23_y=4.0,
33
+ )
34
+
35
+ np.testing.assert_allclose(offsets[1], (0.0, 0.0))
36
+ np.testing.assert_allclose(offsets[2], (12.5, -1.0))
37
+ np.testing.assert_allclose(offsets[3], (24.5, 3.0))
38
+ assert spacings[0] == {"from": 1, "to": 2, "dx": 12.5, "dy": -1.0}
39
+ assert spacings[1] == {"from": 2, "to": 3, "dx": 12.0, "dy": 4.0}
40
+
41
+
42
+ def test_calculated_nozzle_layout_allows_negative_x_overlap() -> None:
43
+ parts = [
44
+ _part(1, ((0.0, 0.0, 0.0), (10.0, 10.0, 1.0))),
45
+ _part(2, ((0.0, 0.0, 0.0), (10.0, 10.0, 1.0))),
46
+ ]
47
+
48
+ offsets, spacings = _resolve_nozzle_layout(
49
+ parts,
50
+ same_spacing=True,
51
+ part_gap_12_x=-2.0,
52
+ part_gap_12_y=3.0,
53
+ part_gap_23_x=0.0,
54
+ part_gap_23_y=0.0,
55
+ )
56
+
57
+ np.testing.assert_allclose(offsets[1], (0.0, 0.0))
58
+ np.testing.assert_allclose(offsets[2], (8.0, 3.0))
59
+ assert spacings == [{"from": 1, "to": 2, "dx": 8.0, "dy": 3.0}]
60
+
61
+
62
+ def test_same_spacing_reuses_first_pair_values_for_second_pair() -> None:
63
+ parts = [
64
+ _part(1, ((0.0, 0.0, 0.0), (10.0, 10.0, 1.0))),
65
+ _part(2, ((0.0, 0.0, 0.0), (4.0, 10.0, 1.0))),
66
+ _part(3, ((0.0, 0.0, 0.0), (6.0, 10.0, 1.0))),
67
+ ]
68
+
69
+ offsets, spacings = _resolve_nozzle_layout(
70
+ parts,
71
+ same_spacing=True,
72
+ part_gap_12_x=3.0,
73
+ part_gap_12_y=2.0,
74
+ part_gap_23_x=20.0,
75
+ part_gap_23_y=10.0,
76
+ )
77
+
78
+ np.testing.assert_allclose(offsets[1], (0.0, 0.0))
79
+ np.testing.assert_allclose(offsets[2], (13.0, 2.0))
80
+ np.testing.assert_allclose(offsets[3], (20.0, 4.0))
81
+ assert spacings[0] == {"from": 1, "to": 2, "dx": 13.0, "dy": 2.0}
82
+ assert spacings[1] == {"from": 2, "to": 3, "dx": 7.0, "dy": 2.0}
83
+
84
+
85
+ def test_spacing_status_lists_all_nozzle_pair_distances() -> None:
86
+ parts = [
87
+ _part(1, ((0.0, 0.0, 0.0), (10.0, 10.0, 1.0))),
88
+ _part(2, ((0.0, 0.0, 0.0), (4.0, 10.0, 1.0))),
89
+ _part(3, ((0.0, 0.0, 0.0), (6.0, 10.0, 1.0))),
90
+ ]
91
+ offsets, spacings = _resolve_nozzle_layout(
92
+ parts,
93
+ same_spacing=True,
94
+ part_gap_12_x=3.0,
95
+ part_gap_12_y=2.0,
96
+ part_gap_23_x=20.0,
97
+ part_gap_23_y=10.0,
98
+ )
99
+
100
+ status = _format_nozzle_spacing_status(parts, offsets, spacings)
101
+
102
+ assert "Nozzle 1: X 0.00 mm, Y 0.00 mm." in status
103
+ assert "Nozzle 1 -> 2: Delta X 13.00 mm, Delta Y 2.00 mm" in status
104
+ assert "Nozzle 1 -> 3: Delta X 20.00 mm, Delta Y 4.00 mm" in status
105
+ assert "Nozzle 2 -> 3: Delta X 7.00 mm, Delta Y 2.00 mm" in status