CyGuy8 commited on
Commit
2bb6898
·
1 Parent(s): 6b28240

Improve UI and add overlapping interlock

Browse files
Files changed (4) hide show
  1. README.md +3 -2
  2. app.py +502 -115
  3. tests/test_app_scaling.py +39 -0
  4. tests/test_nozzle_spacing.py +63 -0
README.md CHANGED
@@ -43,6 +43,7 @@ Then open the local Gradio URL in your browser, upload STL files or load the bun
43
  - Shows an interactive selected-shape 3D viewer for rotating each model
44
  - Shows model extents, face count, vertex count, and watertight status
45
  - Scales loaded STLs from editable target X/Y/Z dimensions in the Shape Settings table; new rows default to the STL's original dimensions, **Reset Dimensions** restores them, and **Keep Proportions** updates the other target sides from the edited side
 
46
  - Lets you choose layer height and XY pixel size
47
  - Produces one `.tif` image per slice
48
  - Encodes material as black (`0`) and empty space as white (`255`) in each TIFF slice
@@ -70,9 +71,9 @@ When you click **Generate TIFF Stacks**, the app automatically combines availabl
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.
 
43
  - Shows an interactive selected-shape 3D viewer for rotating each model
44
  - Shows model extents, face count, vertex count, and watertight status
45
  - Scales loaded STLs from editable target X/Y/Z dimensions in the Shape Settings table; new rows default to the STL's original dimensions, **Reset Dimensions** restores them, and **Keep Proportions** updates the other target sides from the edited side
46
+ - Keeps optional shape, TIFF, reference-stack, and nozzle-spacing previews in closed accordions to reduce clutter
47
  - Lets you choose layer height and XY pixel size
48
  - Produces one `.tif` image per slice
49
  - Encodes material as black (`0`) and empty space as white (`255`) in each TIFF slice
 
71
  - Alignment is centered image placement, not bottom-left anchoring.
72
  - If image-size differences are odd, centering may produce a one-pixel shift due to integer rounding.
73
 
74
+ ### Multi-Nozzle Split
75
 
76
+ The **Multi-Nozzle Split** accordion on the **STL to TIFF Slicer** tab can split one generated shape stack into a grid of print-ready stacks. Choose a source shape that already has TIFF slices, set the number of columns and rows, choose the starting nozzle and valve numbers, then click **Split Selected Shape into Grid Pieces**.
77
 
78
  - Each slice is split into columns along X and rows along Y; leftover pixels are assigned to earlier columns/rows so no pixels are dropped.
79
  - The selected shape is replaced in Shape Settings by one generated record per grid cell, named by row and column.
app.py CHANGED
@@ -38,7 +38,7 @@ from stl_slicer import (
38
  slice_stl_to_tiffs,
39
  )
40
  from tiff_to_gcode import (
41
- RASTER_PATTERN_CHOICES,
42
  RASTER_PATTERN_SAME_DIRECTION,
43
  generate_snake_path_gcode,
44
  )
@@ -54,6 +54,19 @@ SCALE_MODE_TARGET_DIMENSIONS = "Independent X/Y/Z"
54
  SCALE_MODE_UNIFORM_FACTOR = "Keep Proportions"
55
  TARGET_DIMENSION_KEYS = ("target_x", "target_y", "target_z")
56
  FRONT_CAMERA = (90, 80, None)
 
 
 
 
 
 
 
 
 
 
 
 
 
57
  APP_CSS = """
58
  .gradio-container {
59
  font-size: 90%;
@@ -99,6 +112,18 @@ APP_CSS = """
99
  opacity: 1 !important;
100
  }
101
 
 
 
 
 
 
 
 
 
 
 
 
 
102
  #load-sample-stls-button,
103
  #load-sample-stls-button button,
104
  #visualize-nozzle-spacing-button,
@@ -1454,6 +1479,118 @@ def _resolve_nozzle_layout(
1454
  return offsets, spacings
1455
 
1456
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1457
  def _same_spacing_from_individual(use_individual_spacing: bool | None) -> bool:
1458
  return not bool(use_individual_spacing)
1459
 
@@ -1654,11 +1791,35 @@ def _partition_length(length: int, count: int) -> list[tuple[int, int]]:
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", [])]
@@ -1678,6 +1839,7 @@ def split_tiff_stack_grid(
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):
@@ -1701,8 +1863,25 @@ def split_tiff_stack_grid(
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)
@@ -1715,19 +1894,26 @@ def split_tiff_stack_grid(
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
@@ -1879,13 +2065,26 @@ def _selected_record_index(records: list[dict], selected: str | None) -> int:
1879
  return 0
1880
 
1881
 
 
 
 
 
 
 
 
1882
  def _records_from_files(files: Any, previous_records: list[dict] | None = None) -> list[dict]:
1883
- previous_by_path = {record.get("stl_path"): record for record in (previous_records or [])}
 
 
 
1884
  records: list[dict] = []
1885
  for index, path in enumerate(_uploaded_file_paths(files), start=1):
1886
- previous = previous_by_path.get(path, {})
 
1887
  name = previous.get("name") or Path(path).stem or f"Shape {index}"
1888
  default_x, default_y, default_z = _default_target_extents_for_stl(path)
 
 
1889
  records.append({
1890
  "idx": index,
1891
  "name": name,
@@ -1899,7 +2098,7 @@ def _records_from_files(files: Any, previous_records: list[dict] | None = 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),
@@ -2095,6 +2294,93 @@ def _spacing_table_update(records: list[dict], existing_table: Any | None = None
2095
  )
2096
 
2097
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2098
  def _dropdown_update(records: list[dict], selected: str | None = None) -> dict[str, Any]:
2099
  choices = [_shape_choice(record) for record in records]
2100
  value = selected if selected in choices else (choices[0] if choices else None)
@@ -2122,6 +2408,13 @@ def _merge_file_paths(*file_groups: Any) -> list[str]:
2122
  return merged
2123
 
2124
 
 
 
 
 
 
 
 
2125
  def sync_uploaded_shapes(
2126
  files: Any,
2127
  records: list[dict] | None,
@@ -2154,7 +2447,7 @@ def load_sample_shapes(
2154
  ) -> tuple:
2155
  records = _apply_shape_settings(records or [], settings_table)
2156
  paths = [str(SAMPLE_STL_DIR / filename) for filename in SAMPLE_STL_FILENAMES if (SAMPLE_STL_DIR / filename).exists()]
2157
- merged_paths = _merge_file_paths(files, paths)
2158
  return (
2159
  gr.update(value=merged_paths),
2160
  *sync_uploaded_shapes(merged_paths, records, None, existing_spacing, use_individual_spacing),
@@ -2524,6 +2817,7 @@ def split_selected_shape_for_grid(
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(),
@@ -2560,6 +2854,7 @@ def split_selected_shape_for_grid(
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:
@@ -2610,6 +2905,8 @@ def split_selected_shape_for_grid(
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),
@@ -2748,19 +3045,28 @@ def _spacing_args_from_table(spacing_table: Any, use_individual_spacing: bool |
2748
  return pairs[0][0], pairs[0][1], pairs[1][0], pairs[1][1], extra
2749
 
2750
 
2751
- def render_dynamic_nozzle_spacing(records: list[dict] | None, use_individual_spacing: bool, spacing_table: Any) -> tuple[Any, str]:
 
 
 
 
 
 
 
 
 
2752
  parts, _messages = _parts_from_records(records)
2753
  if not parts:
2754
  return None, "No shape G-code available. Generate G-code first."
2755
- gap12x, gap12y, gap23x, gap23y, extra = _spacing_args_from_table(spacing_table, use_individual_spacing)
2756
- offsets, spacings = _resolve_nozzle_layout(
2757
  parts,
2758
- _same_spacing_from_individual(use_individual_spacing),
2759
- gap12x,
2760
- gap12y,
2761
- gap23x,
2762
- gap23y,
2763
- *extra,
 
2764
  )
2765
  return build_nozzle_spacing_figure(parts, offsets, spacings), _format_nozzle_spacing_status(parts, offsets, spacings)
2766
 
@@ -2831,6 +3137,11 @@ def render_dynamic_parallel(
2831
  travel_opacity: float,
2832
  filament_width: float,
2833
  travel_width: float,
 
 
 
 
 
2834
  use_individual_spacing: bool,
2835
  spacing_table: Any,
2836
  tube: bool = True,
@@ -2839,15 +3150,15 @@ def render_dynamic_parallel(
2839
  parts, messages = _parts_from_records(records)
2840
  if not parts:
2841
  return None, "No shape G-code available. Generate G-code on the TIFF Slices to GCode tab first."
2842
- gap12x, gap12y, gap23x, gap23y, extra = _spacing_args_from_table(spacing_table, use_individual_spacing)
2843
- offsets, spacings = _resolve_nozzle_layout(
2844
  parts,
2845
- _same_spacing_from_individual(use_individual_spacing),
2846
- gap12x,
2847
- gap12y,
2848
- gap23x,
2849
- gap23y,
2850
- *extra,
 
2851
  )
2852
  figure = build_parallel_figure(
2853
  parts,
@@ -2881,6 +3192,11 @@ def export_dynamic_parallel_gif(
2881
  records: list[dict] | None,
2882
  settings_table: Any,
2883
  travel_opacity: float,
 
 
 
 
 
2884
  use_individual_spacing: bool,
2885
  spacing_table: Any,
2886
  duration: float,
@@ -2893,15 +3209,15 @@ def export_dynamic_parallel_gif(
2893
  parts, _messages = _parts_from_records(records)
2894
  if not parts:
2895
  return None
2896
- gap12x, gap12y, gap23x, gap23y, extra = _spacing_args_from_table(spacing_table, use_individual_spacing)
2897
- offsets, _spacings = _resolve_nozzle_layout(
2898
  parts,
2899
- _same_spacing_from_individual(use_individual_spacing),
2900
- gap12x,
2901
- gap12y,
2902
- gap23x,
2903
- gap23y,
2904
- *extra,
 
2905
  )
2906
 
2907
  def report(frame: int, total: int) -> None:
@@ -2969,75 +3285,71 @@ def build_dynamic_demo() -> gr.Blocks:
2969
  pixel_size = gr.Number(label="Pixel Size/Fill Width (mm)", value=0.8, minimum=0.0001, step=0.01)
2970
  generate_button = gr.Button("Generate TIFF Stacks", variant="primary")
2971
 
2972
- with gr.Row():
2973
- selected_shape = gr.Dropdown(label="Preview Shape", choices=[], value=None, allow_custom_value=False)
2974
- refresh_preview_button = gr.Button("Regenerate Preview", variant="secondary", size="sm")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2975
 
2976
- with gr.Row():
2977
- with gr.Column(scale=2, min_width=420):
2978
- model_viewer = gr.Model3D(
2979
- label="Selected 3D Viewer",
2980
- display_mode="solid",
2981
- clear_color=(0.94, 0.95, 0.97, 1.0),
2982
- camera_position=FRONT_CAMERA,
2983
- height=360,
2984
- )
2985
- with gr.Column(scale=1, min_width=300):
2986
- model_details = gr.Markdown("No model loaded.")
2987
 
2988
- with gr.Row():
2989
- with gr.Column(scale=2, min_width=420):
2990
- slice_preview = gr.Image(label="Selected Slice Preview", type="pil", image_mode="RGB", height=320)
2991
- with gr.Column(scale=1, min_width=300):
2992
- slice_label = gr.Markdown("No slice stack loaded yet.")
2993
- with gr.Row():
2994
- prev_button = gr.Button("Prev", scale=1, min_width=90, size="sm")
2995
- next_button = gr.Button("Next", scale=1, min_width=90, size="sm")
2996
- slice_slider = gr.Slider(label="Slice Index", minimum=0, maximum=0, value=0, step=1, interactive=False)
2997
- tiff_downloads = gr.File(label="Download TIFF ZIPs", file_count="multiple", interactive=False)
2998
- slicer_status = gr.Markdown("")
2999
-
3000
- gr.Markdown("---")
3001
- gr.Markdown("### Reference TIFF Stack")
3002
- with gr.Row():
3003
- with gr.Column(scale=1, min_width=200):
3004
- ref_generate_button = gr.Button("Generate Reference TIFF Stack", variant="primary")
3005
- with gr.Column(scale=3, min_width=250):
3006
- ref_slice_label = gr.Markdown("No reference stack generated yet.")
3007
- ref_slice_preview = gr.Image(label="Reference Slice Preview", type="pil", image_mode="RGB", height=270)
3008
- with gr.Row():
3009
- ref_prev_button = gr.Button("Prev", scale=1, min_width=90, size="sm")
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(
@@ -3065,18 +3377,35 @@ def build_dynamic_demo() -> gr.Blocks:
3065
  refresh_gcode_text_button = gr.Button("Refresh G-Code Preview", variant="secondary", size="sm")
3066
  gcode_text = gr.Code(label="Selected G-Code", language=None, lines=18, max_lines=18, interactive=False, elem_classes=["gcode-view"])
3067
 
3068
- with gr.Group():
3069
- gr.Markdown("### Nozzle Spacing")
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,
3077
- label="Nozzle Spacing",
3078
- elem_id="nozzle-spacing-table",
3079
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3080
  nozzle_preview_button = gr.Button("Visualize Nozzle Spacing", variant="secondary", elem_id="visualize-nozzle-spacing-button")
3081
  with gr.Row():
3082
  with gr.Column(scale=3, min_width=420):
@@ -3262,6 +3591,7 @@ def build_dynamic_demo() -> gr.Blocks:
3262
  nozzle_use_individual_spacing,
3263
  split_columns,
3264
  split_rows,
 
3265
  split_start_nozzle,
3266
  split_start_valve,
3267
  ],
@@ -3282,6 +3612,10 @@ def build_dynamic_demo() -> gr.Blocks:
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)
@@ -3295,8 +3629,33 @@ def build_dynamic_demo() -> gr.Blocks:
3295
  )
3296
  gcode_text_source.change(fn=load_selected_gcode_text, inputs=[shape_records, gcode_text_source], outputs=[gcode_text])
3297
  refresh_gcode_text_button.click(fn=load_selected_gcode_text, inputs=[shape_records, gcode_text_source], outputs=[gcode_text])
 
 
 
 
 
 
 
 
 
 
 
 
3298
  nozzle_use_individual_spacing.change(fn=update_nozzle_spacing_table_mode, inputs=[shape_records, nozzle_spacing_table, nozzle_use_individual_spacing], outputs=[nozzle_spacing_table], queue=False)
3299
- nozzle_preview_button.click(fn=render_dynamic_nozzle_spacing, inputs=[shape_records, nozzle_use_individual_spacing, nozzle_spacing_table], outputs=[nozzle_spacing_plot, nozzle_spacing_status])
 
 
 
 
 
 
 
 
 
 
 
 
 
3300
 
3301
  gcode_source.change(
3302
  fn=None,
@@ -3334,7 +3693,20 @@ def build_dynamic_demo() -> gr.Blocks:
3334
 
3335
  layer_height.change(fn=sync_width_sliders, inputs=[layer_height], outputs=[print_width_slider, travel_width_slider], queue=False)
3336
 
3337
- parallel_render_inputs = [shape_records, shape_settings, pp_travel_opacity, pp_filament_width, pp_travel_width, nozzle_use_individual_spacing, nozzle_spacing_table]
 
 
 
 
 
 
 
 
 
 
 
 
 
3338
  parallel_outputs = [parallel_plot, parallel_status, parallel_mode, parallel_anim_controls, pp_width_row, pp_export_group]
3339
  parallel_line_button.click(fn=render_dynamic_parallel_lines, inputs=parallel_render_inputs, outputs=parallel_outputs)
3340
  parallel_render_button.click(fn=render_dynamic_parallel_tubes, inputs=parallel_render_inputs, outputs=parallel_outputs)
@@ -3342,7 +3714,22 @@ def build_dynamic_demo() -> gr.Blocks:
3342
  pp_travel_width.release(fn=rerender_dynamic_parallel_current_mode, inputs=[parallel_mode] + parallel_render_inputs, outputs=[parallel_plot, parallel_status])
3343
  pp_export_button.click(
3344
  fn=export_dynamic_parallel_gif,
3345
- inputs=[shape_records, shape_settings, pp_gif_travel_opacity, nozzle_use_individual_spacing, nozzle_spacing_table, pp_gif_duration, pp_gif_fps, pp_elev, pp_azim],
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3346
  outputs=[pp_gif_file],
3347
  )
3348
 
 
38
  slice_stl_to_tiffs,
39
  )
40
  from tiff_to_gcode import (
41
+ RASTER_PATTERN_CHOICES,
42
  RASTER_PATTERN_SAME_DIRECTION,
43
  generate_snake_path_gcode,
44
  )
 
54
  SCALE_MODE_UNIFORM_FACTOR = "Keep Proportions"
55
  TARGET_DIMENSION_KEYS = ("target_x", "target_y", "target_z")
56
  FRONT_CAMERA = (90, 80, None)
57
+ NOZZLE_LAYOUT_GRID = "Grid Layout"
58
+ NOZZLE_LAYOUT_PAIR_TABLE = "Custom Spacing"
59
+ NOZZLE_LAYOUT_PRESETS = [
60
+ "Custom",
61
+ "One row",
62
+ "One column",
63
+ "2 x 1",
64
+ "1 x 2",
65
+ "2 x 2",
66
+ "3 x 3",
67
+ "2 x 5",
68
+ "5 x 2",
69
+ ]
70
  APP_CSS = """
71
  .gradio-container {
72
  font-size: 90%;
 
112
  opacity: 1 !important;
113
  }
114
 
115
+ .settings-accordion summary,
116
+ .settings-accordion button[aria-expanded],
117
+ .settings-accordion .label-wrap span {
118
+ font-size: 1.05rem !important;
119
+ font-weight: 700 !important;
120
+ }
121
+
122
+ .settings-accordion summary {
123
+ padding-top: 0.55rem !important;
124
+ padding-bottom: 0.55rem !important;
125
+ }
126
+
127
  #load-sample-stls-button,
128
  #load-sample-stls-button button,
129
  #visualize-nozzle-spacing-button,
 
1479
  return offsets, spacings
1480
 
1481
 
1482
+ def _group_parts_by_nozzle(parts: list[dict]) -> dict[int, list[dict]]:
1483
+ grouped: dict[int, list[dict]] = {}
1484
+ for part in parts:
1485
+ grouped.setdefault(_record_nozzle_number(part, int(part.get("idx", 1) or 1)), []).append(part)
1486
+ return grouped
1487
+
1488
+
1489
+ def _nozzle_group_bounds(grouped: dict[int, list[dict]], nozzle: int) -> tuple[tuple[float, float, float], tuple[float, float, float]]:
1490
+ mins: list[tuple[float, float, float]] = []
1491
+ maxs: list[tuple[float, float, float]] = []
1492
+ for part in grouped[nozzle]:
1493
+ part_min, part_max = part["parsed"]["bounds"]
1494
+ mins.append(part_min)
1495
+ maxs.append(part_max)
1496
+ return (
1497
+ tuple(min(values) for values in zip(*mins)),
1498
+ tuple(max(values) for values in zip(*maxs)),
1499
+ )
1500
+
1501
+
1502
+ def _resolve_nozzle_grid_layout(
1503
+ parts: list[dict],
1504
+ columns: Any,
1505
+ rows: Any,
1506
+ column_spacing: Any,
1507
+ row_spacing: Any,
1508
+ ) -> tuple[dict[int, tuple[float, float]], list[dict]]:
1509
+ offsets: dict[int, tuple[float, float]] = {}
1510
+ spacings: list[dict] = []
1511
+ if not parts:
1512
+ return offsets, spacings
1513
+
1514
+ grouped = _group_parts_by_nozzle(parts)
1515
+ ordered_nozzles = sorted(grouped)
1516
+ column_count = max(1, _coerce_int(columns, 1))
1517
+ requested_rows = max(1, _coerce_int(rows, 1))
1518
+ row_count = max(requested_rows, math.ceil(len(ordered_nozzles) / column_count))
1519
+ try:
1520
+ x_gap = float(column_spacing)
1521
+ except (TypeError, ValueError):
1522
+ x_gap = 0.0
1523
+ try:
1524
+ y_gap = float(row_spacing)
1525
+ except (TypeError, ValueError):
1526
+ y_gap = 0.0
1527
+
1528
+ placements = {
1529
+ nozzle: (index % column_count, index // column_count)
1530
+ for index, nozzle in enumerate(ordered_nozzles)
1531
+ }
1532
+ column_widths = [0.0 for _ in range(column_count)]
1533
+ row_heights = [0.0 for _ in range(row_count)]
1534
+ bounds_by_nozzle: dict[int, tuple[tuple[float, float, float], tuple[float, float, float]]] = {}
1535
+ for nozzle, (column, row) in placements.items():
1536
+ bounds = _nozzle_group_bounds(grouped, nozzle)
1537
+ bounds_by_nozzle[nozzle] = bounds
1538
+ (xmin, ymin, _), (xmax, ymax, _) = bounds
1539
+ column_widths[column] = max(column_widths[column], xmax - xmin)
1540
+ row_heights[row] = max(row_heights[row], ymax - ymin)
1541
+
1542
+ column_positions: list[float] = []
1543
+ x_pos = 0.0
1544
+ for width in column_widths:
1545
+ column_positions.append(x_pos)
1546
+ x_pos += width + x_gap
1547
+
1548
+ row_positions: list[float] = []
1549
+ y_pos = 0.0
1550
+ for height in row_heights:
1551
+ row_positions.append(y_pos)
1552
+ y_pos += height + y_gap
1553
+
1554
+ for nozzle, (column, row) in placements.items():
1555
+ (xmin, ymin, _), _ = bounds_by_nozzle[nozzle]
1556
+ offsets[nozzle] = (column_positions[column] - xmin, row_positions[row] - ymin)
1557
+
1558
+ for first, second in zip(ordered_nozzles, ordered_nozzles[1:]):
1559
+ first_x, first_y = offsets[first]
1560
+ second_x, second_y = offsets[second]
1561
+ spacings.append({
1562
+ "from": first,
1563
+ "to": second,
1564
+ "dx": second_x - first_x,
1565
+ "dy": second_y - first_y,
1566
+ })
1567
+ return offsets, spacings
1568
+
1569
+
1570
+ def _resolve_layout_from_spacing_controls(
1571
+ parts: list[dict],
1572
+ layout_mode: str | None,
1573
+ columns: Any,
1574
+ rows: Any,
1575
+ column_spacing: Any,
1576
+ row_spacing: Any,
1577
+ use_individual_spacing: bool,
1578
+ spacing_table: Any,
1579
+ ) -> tuple[dict[int, tuple[float, float]], list[dict]]:
1580
+ if layout_mode != NOZZLE_LAYOUT_PAIR_TABLE:
1581
+ return _resolve_nozzle_grid_layout(parts, columns, rows, column_spacing, row_spacing)
1582
+ gap12x, gap12y, gap23x, gap23y, extra = _spacing_args_from_table(spacing_table, use_individual_spacing)
1583
+ return _resolve_nozzle_layout(
1584
+ parts,
1585
+ _same_spacing_from_individual(use_individual_spacing),
1586
+ gap12x,
1587
+ gap12y,
1588
+ gap23x,
1589
+ gap23y,
1590
+ *extra,
1591
+ )
1592
+
1593
+
1594
  def _same_spacing_from_individual(use_individual_spacing: bool | None) -> bool:
1595
  return not bool(use_individual_spacing)
1596
 
 
1791
  return spans
1792
 
1793
 
1794
+ def _layer_split_spans(length: int, count: int, layer_index: int, overlap_pixels: int) -> list[tuple[int, int]]:
1795
+ base_spans = _partition_length(length, count)
1796
+ if overlap_pixels <= 0 or count <= 1:
1797
+ return base_spans
1798
+
1799
+ boundaries = [base_spans[0][0], *[end for _start, end in base_spans]]
1800
+ adjusted = list(boundaries)
1801
+ for boundary_index in range(1, len(boundaries) - 1):
1802
+ direction = 1 if (layer_index + boundary_index) % 2 == 1 else -1
1803
+ lower = adjusted[boundary_index - 1] + 1
1804
+ upper = boundaries[boundary_index + 1] - 1
1805
+ adjusted[boundary_index] = max(lower, min(upper, boundaries[boundary_index] + direction * overlap_pixels))
1806
+ return [(adjusted[index], adjusted[index + 1]) for index in range(count)]
1807
+
1808
+
1809
+ def _overlap_canvas_span(start: int, end: int, length: int, position: int, count: int, overlap_pixels: int) -> tuple[int, int]:
1810
+ if overlap_pixels <= 0:
1811
+ return start, end
1812
+ canvas_start = max(0, start - overlap_pixels) if position > 1 else start
1813
+ canvas_end = min(length, end + overlap_pixels) if position < count else end
1814
+ return canvas_start, canvas_end
1815
+
1816
+
1817
  def split_tiff_stack_grid(
1818
  state: ViewerState,
1819
  base_name: str = "split_shape",
1820
  columns: float = 2,
1821
  rows: float = 1,
1822
+ overlapping_layers: bool | None = False,
1823
  progress: gr.Progress = gr.Progress(),
1824
  ) -> list[dict[str, Any]]:
1825
  tiff_paths = [Path(path) for path in state.get("tiff_paths", [])]
 
1839
  output_dir = Path(tempfile.mkdtemp(prefix=f"{safe_name}_split_"))
1840
  x_spans = _partition_length(width, column_count)
1841
  y_spans = _partition_length(height, row_count)
1842
+ overlap_pixels = 1 if overlapping_layers else 0
1843
  pieces: list[dict[str, Any]] = []
1844
  for row_index, (y_start, y_end) in enumerate(y_spans, start=1):
1845
  for col_index, (x_start, x_end) in enumerate(x_spans, start=1):
 
1863
  if layer.size != (width, height):
1864
  raise ValueError("All TIFF slices must have the same dimensions to split the stack.")
1865
 
1866
+ layer_x_spans = _layer_split_spans(width, column_count, index, overlap_pixels)
1867
+ layer_y_spans = _layer_split_spans(height, row_count, index, overlap_pixels)
1868
  for piece in pieces:
1869
+ canvas_x_start, canvas_x_end = _overlap_canvas_span(
1870
+ piece["x_start"], piece["x_end"], width, piece["col"], column_count, overlap_pixels
1871
+ )
1872
+ canvas_y_start, canvas_y_end = _overlap_canvas_span(
1873
+ piece["y_start"], piece["y_end"], height, piece["row"], row_count, overlap_pixels
1874
+ )
1875
+ x_start, x_end = layer_x_spans[piece["col"] - 1]
1876
+ y_start, y_end = layer_y_spans[piece["row"] - 1]
1877
+ if overlapping_layers:
1878
+ piece_image = Image.new("L", (canvas_x_end - canvas_x_start, canvas_y_end - canvas_y_start), 255)
1879
+ piece_image.paste(
1880
+ layer.crop((x_start, y_start, x_end, y_end)),
1881
+ (x_start - canvas_x_start, y_start - canvas_y_start),
1882
+ )
1883
+ else:
1884
+ piece_image = layer.crop((x_start, y_start, x_end, y_end))
1885
  piece_path = piece["tiff_dir"] / f"slice_{index:04d}.tif"
1886
  piece_image.save(piece_path, compression="tiff_deflate")
1887
  piece["tiff_paths"].append(piece_path)
 
1894
  base_x_min = float(state.get("x_min", 0.0) or 0.0)
1895
  base_y_min = float(state.get("y_min", 0.0) or 0.0)
1896
  for piece in pieces:
1897
+ canvas_x_start, canvas_x_end = _overlap_canvas_span(
1898
+ piece["x_start"], piece["x_end"], width, piece["col"], column_count, overlap_pixels
1899
+ )
1900
+ canvas_y_start, canvas_y_end = _overlap_canvas_span(
1901
+ piece["y_start"], piece["y_end"], height, piece["row"], row_count, overlap_pixels
1902
+ )
1903
+ piece_width = canvas_x_end - canvas_x_start if overlapping_layers else piece["x_end"] - piece["x_start"]
1904
+ piece_height = canvas_y_end - canvas_y_start if overlapping_layers else piece["y_end"] - piece["y_start"]
1905
  zip_path = output_dir / f"{safe_name}_r{piece['row']:02d}_c{piece['col']:02d}_tiff_slices.zip"
1906
  _zip_tiff_paths(piece["tiff_paths"], zip_path)
1907
  piece_state: ViewerState = {
1908
  "tiff_paths": [str(path) for path in piece["tiff_paths"]],
1909
  "z_values": z_values[: len(piece["tiff_paths"])],
1910
  "pixel_size": pixel_size,
1911
+ "x_min": base_x_min + ((canvas_x_start if overlapping_layers else piece["x_start"]) * pixel_size),
1912
+ "y_min": base_y_min + ((height - (canvas_y_end if overlapping_layers else piece["y_end"])) * pixel_size),
1913
  "image_width": piece_width,
1914
  "image_height": piece_height,
1915
  "zip_path": str(zip_path),
1916
+ "overlapping_layers": bool(overlapping_layers),
1917
  }
1918
  piece["state"] = piece_state
1919
  piece["zip_path"] = zip_path
 
2065
  return 0
2066
 
2067
 
2068
+ def _next_unused_nozzle(used_nozzles: set[int]) -> int:
2069
+ nozzle = 1
2070
+ while nozzle in used_nozzles:
2071
+ nozzle += 1
2072
+ return nozzle
2073
+
2074
+
2075
  def _records_from_files(files: Any, previous_records: list[dict] | None = None) -> list[dict]:
2076
+ previous_by_path: dict[str | None, list[dict]] = {}
2077
+ for record in previous_records or []:
2078
+ previous_by_path.setdefault(record.get("stl_path"), []).append(record)
2079
+ used_nozzles: set[int] = set()
2080
  records: list[dict] = []
2081
  for index, path in enumerate(_uploaded_file_paths(files), start=1):
2082
+ previous_queue = previous_by_path.get(path) or []
2083
+ previous = previous_queue.pop(0) if previous_queue else {}
2084
  name = previous.get("name") or Path(path).stem or f"Shape {index}"
2085
  default_x, default_y, default_z = _default_target_extents_for_stl(path)
2086
+ nozzle = _record_nozzle_number(previous, index) if previous else _next_unused_nozzle(used_nozzles)
2087
+ used_nozzles.add(nozzle)
2088
  records.append({
2089
  "idx": index,
2090
  "name": name,
 
2098
  "last_scaled_axis": previous.get("last_scaled_axis", "target_x"),
2099
  "pressure": previous.get("pressure", 25.0),
2100
  "valve": previous.get("valve", 4),
2101
+ "nozzle": nozzle,
2102
  "port": previous.get("port", 1),
2103
  "color": previous.get("color", _default_color(index)),
2104
  "contour_tracing": previous.get("contour_tracing", False),
 
2294
  )
2295
 
2296
 
2297
+ def _grid_spacing_rows(
2298
+ records: list[dict],
2299
+ columns: Any,
2300
+ rows: Any,
2301
+ column_spacing: Any,
2302
+ row_spacing: Any,
2303
+ ) -> tuple[list[list[Any]], int, int]:
2304
+ ordered_nozzles = _ordered_nozzle_numbers(records)
2305
+ column_count = max(1, _coerce_int(columns, 2))
2306
+ row_count = max(1, _coerce_int(rows, 1))
2307
+ try:
2308
+ x_spacing = float(column_spacing)
2309
+ except (TypeError, ValueError):
2310
+ x_spacing = 5.0
2311
+ try:
2312
+ y_spacing = float(row_spacing)
2313
+ except (TypeError, ValueError):
2314
+ y_spacing = 5.0
2315
+
2316
+ spacing_rows: list[list[Any]] = []
2317
+ for index, (first, second) in enumerate(zip(ordered_nozzles, ordered_nozzles[1:])):
2318
+ current_col = index % column_count
2319
+ next_col = (index + 1) % column_count
2320
+ if next_col > current_col:
2321
+ gap_x = x_spacing
2322
+ gap_y = 0.0
2323
+ else:
2324
+ gap_x = -x_spacing * (column_count - 1)
2325
+ gap_y = y_spacing
2326
+ spacing_rows.append([
2327
+ _nozzle_spacing_label(first, records),
2328
+ _nozzle_spacing_label(second, records),
2329
+ gap_x,
2330
+ gap_y,
2331
+ ])
2332
+ return spacing_rows, column_count, row_count
2333
+
2334
+
2335
+ def apply_nozzle_grid_spacing(
2336
+ records: list[dict] | None,
2337
+ columns: Any,
2338
+ rows: Any,
2339
+ column_spacing: Any,
2340
+ row_spacing: Any,
2341
+ ) -> tuple[dict[str, Any], dict[str, Any], str]:
2342
+ records = records or []
2343
+ spacing_rows, column_count, row_count = _grid_spacing_rows(records, columns, rows, column_spacing, row_spacing)
2344
+ nozzle_count = len(_ordered_nozzle_numbers(records))
2345
+ capacity = column_count * row_count
2346
+ status = f"Applied {column_count} x {row_count} grid spacing to {max(nozzle_count - 1, 0)} nozzle pair(s)."
2347
+ if nozzle_count > capacity:
2348
+ status += f" {nozzle_count} nozzles exceed {capacity} grid slots, so spacing continues row by row."
2349
+ return (
2350
+ gr.update(value=True),
2351
+ gr.update(
2352
+ headers=ADVANCED_NOZZLE_SPACING_HEADERS,
2353
+ value=spacing_rows,
2354
+ row_count=(len(spacing_rows), "fixed"),
2355
+ column_count=(len(ADVANCED_NOZZLE_SPACING_HEADERS), "fixed"),
2356
+ label="Advanced Nozzle Spacing",
2357
+ ),
2358
+ status,
2359
+ )
2360
+
2361
+
2362
+ def update_nozzle_grid_preset(
2363
+ preset: str | None,
2364
+ records: list[dict] | None,
2365
+ columns: Any,
2366
+ rows: Any,
2367
+ ) -> tuple[dict[str, Any], dict[str, Any]]:
2368
+ nozzle_count = max(1, len(_ordered_nozzle_numbers(records or [])))
2369
+ if preset == "One row":
2370
+ return gr.update(value=nozzle_count), gr.update(value=1)
2371
+ if preset == "One column":
2372
+ return gr.update(value=1), gr.update(value=nozzle_count)
2373
+ if preset and " x " in preset:
2374
+ left, right = preset.split(" x ", 1)
2375
+ return gr.update(value=max(1, _coerce_int(left, 1))), gr.update(value=max(1, _coerce_int(right, 1)))
2376
+ return gr.update(value=max(1, _coerce_int(columns, 1))), gr.update(value=max(1, _coerce_int(rows, 1)))
2377
+
2378
+
2379
+ def update_nozzle_spacing_mode(layout_mode: str | None) -> tuple[dict[str, Any], dict[str, Any]]:
2380
+ custom_selected = layout_mode == NOZZLE_LAYOUT_PAIR_TABLE
2381
+ return gr.update(visible=not custom_selected), gr.update(visible=custom_selected)
2382
+
2383
+
2384
  def _dropdown_update(records: list[dict], selected: str | None = None) -> dict[str, Any]:
2385
  choices = [_shape_choice(record) for record in records]
2386
  value = selected if selected in choices else (choices[0] if choices else None)
 
2408
  return merged
2409
 
2410
 
2411
+ def _append_file_paths(*file_groups: Any) -> list[str]:
2412
+ paths: list[str] = []
2413
+ for group in file_groups:
2414
+ paths.extend(_uploaded_file_paths(group))
2415
+ return paths
2416
+
2417
+
2418
  def sync_uploaded_shapes(
2419
  files: Any,
2420
  records: list[dict] | None,
 
2447
  ) -> tuple:
2448
  records = _apply_shape_settings(records or [], settings_table)
2449
  paths = [str(SAMPLE_STL_DIR / filename) for filename in SAMPLE_STL_FILENAMES if (SAMPLE_STL_DIR / filename).exists()]
2450
+ merged_paths = _append_file_paths(files, paths)
2451
  return (
2452
  gr.update(value=merged_paths),
2453
  *sync_uploaded_shapes(merged_paths, records, None, existing_spacing, use_individual_spacing),
 
2817
  use_individual_spacing: bool | None,
2818
  columns: float,
2819
  rows: float,
2820
+ overlapping_layers: bool,
2821
  starting_nozzle: float,
2822
  starting_valve: float,
2823
  progress: gr.Progress = gr.Progress(),
 
2854
  base_name=str(source.get("name") or f"shape_{source.get('idx', pos + 1)}"),
2855
  columns=columns,
2856
  rows=rows,
2857
+ overlapping_layers=overlapping_layers,
2858
  progress=progress,
2859
  )
2860
  except Exception as exc:
 
2905
  f"({max(1, _coerce_int(columns, 2))} columns x {max(1, _coerce_int(rows, 1))} rows). \n"
2906
  f"Nozzles {first_nozzle}-{first_nozzle + len(pieces) - 1}; valves {first_valve}-{first_valve + len(pieces) - 1}."
2907
  )
2908
+ if overlapping_layers:
2909
+ status += " \nOverlapping Layers is enabled: split boundaries alternate by 1 pixel per layer with small blank margins for alignment."
2910
  return (
2911
  next_records,
2912
  _shape_settings_rows(next_records),
 
3045
  return pairs[0][0], pairs[0][1], pairs[1][0], pairs[1][1], extra
3046
 
3047
 
3048
+ def render_dynamic_nozzle_spacing(
3049
+ records: list[dict] | None,
3050
+ layout_mode: str | None,
3051
+ columns: Any,
3052
+ rows: Any,
3053
+ column_spacing: Any,
3054
+ row_spacing: Any,
3055
+ use_individual_spacing: bool,
3056
+ spacing_table: Any,
3057
+ ) -> tuple[Any, str]:
3058
  parts, _messages = _parts_from_records(records)
3059
  if not parts:
3060
  return None, "No shape G-code available. Generate G-code first."
3061
+ offsets, spacings = _resolve_layout_from_spacing_controls(
 
3062
  parts,
3063
+ layout_mode,
3064
+ columns,
3065
+ rows,
3066
+ column_spacing,
3067
+ row_spacing,
3068
+ use_individual_spacing,
3069
+ spacing_table,
3070
  )
3071
  return build_nozzle_spacing_figure(parts, offsets, spacings), _format_nozzle_spacing_status(parts, offsets, spacings)
3072
 
 
3137
  travel_opacity: float,
3138
  filament_width: float,
3139
  travel_width: float,
3140
+ layout_mode: str | None,
3141
+ columns: Any,
3142
+ rows: Any,
3143
+ column_spacing: Any,
3144
+ row_spacing: Any,
3145
  use_individual_spacing: bool,
3146
  spacing_table: Any,
3147
  tube: bool = True,
 
3150
  parts, messages = _parts_from_records(records)
3151
  if not parts:
3152
  return None, "No shape G-code available. Generate G-code on the TIFF Slices to GCode tab first."
3153
+ offsets, spacings = _resolve_layout_from_spacing_controls(
 
3154
  parts,
3155
+ layout_mode,
3156
+ columns,
3157
+ rows,
3158
+ column_spacing,
3159
+ row_spacing,
3160
+ use_individual_spacing,
3161
+ spacing_table,
3162
  )
3163
  figure = build_parallel_figure(
3164
  parts,
 
3192
  records: list[dict] | None,
3193
  settings_table: Any,
3194
  travel_opacity: float,
3195
+ layout_mode: str | None,
3196
+ columns: Any,
3197
+ rows: Any,
3198
+ column_spacing: Any,
3199
+ row_spacing: Any,
3200
  use_individual_spacing: bool,
3201
  spacing_table: Any,
3202
  duration: float,
 
3209
  parts, _messages = _parts_from_records(records)
3210
  if not parts:
3211
  return None
3212
+ offsets, _spacings = _resolve_layout_from_spacing_controls(
 
3213
  parts,
3214
+ layout_mode,
3215
+ columns,
3216
+ rows,
3217
+ column_spacing,
3218
+ row_spacing,
3219
+ use_individual_spacing,
3220
+ spacing_table,
3221
  )
3222
 
3223
  def report(frame: int, total: int) -> None:
 
3285
  pixel_size = gr.Number(label="Pixel Size/Fill Width (mm)", value=0.8, minimum=0.0001, step=0.01)
3286
  generate_button = gr.Button("Generate TIFF Stacks", variant="primary")
3287
 
3288
+ with gr.Accordion("Multi-Nozzle Split", open=False, elem_classes=["settings-accordion"]):
3289
+ with gr.Row():
3290
+ split_source = gr.Dropdown(label="Source Shape", choices=[], value=None, allow_custom_value=False)
3291
+ split_refresh_sources = gr.Button("Refresh Source Shapes", variant="secondary", size="sm")
3292
+ with gr.Row():
3293
+ split_columns = gr.Number(label="Columns (X)", value=2, minimum=1, step=1)
3294
+ split_rows = gr.Number(label="Rows (Y)", value=1, minimum=1, step=1)
3295
+ split_start_nozzle = gr.Number(label="Starting Nozzle", value=1, minimum=1, step=1)
3296
+ split_start_valve = gr.Number(label="Starting Valve", value=4, minimum=1, step=1)
3297
+ split_overlapping_layers = gr.Checkbox(label="Overlapping Layers", value=False)
3298
+ split_button = gr.Button("Split Selected Shape into Grid Pieces", variant="primary")
3299
+ split_status = gr.Markdown("Generate a TIFF stack, then split it for multi-nozzle printing.")
3300
+ split_downloads = gr.File(label="Download Split TIFF ZIPs", file_count="multiple", interactive=False)
3301
+ with gr.Row():
3302
+ with gr.Column(scale=1, min_width=260):
3303
+ split_piece_source = gr.Dropdown(label="Preview Generated Piece", choices=[], value=None, allow_custom_value=False)
3304
+ with gr.Row():
3305
+ split_piece_prev = gr.Button("Prev", scale=1, min_width=90, size="sm")
3306
+ split_piece_next = gr.Button("Next", scale=1, min_width=90, size="sm")
3307
+ split_piece_slider = gr.Slider(label="Piece Slice Index", minimum=0, maximum=0, value=0, step=1, interactive=False)
3308
+ split_piece_label = gr.Markdown("No split stack generated yet.")
3309
+ with gr.Column(scale=2, min_width=420):
3310
+ split_piece_preview = gr.Image(label="Generated Piece Preview", type="pil", image_mode="RGB", height=330)
3311
+
3312
+ with gr.Accordion("Selected Shape Preview", open=False, elem_classes=["settings-accordion"]):
3313
+ with gr.Row():
3314
+ selected_shape = gr.Dropdown(label="Preview Shape", choices=[], value=None, allow_custom_value=False)
3315
+ refresh_preview_button = gr.Button("Regenerate Preview", variant="secondary", size="sm")
3316
 
3317
+ with gr.Row():
3318
+ with gr.Column(scale=2, min_width=420):
3319
+ model_viewer = gr.Model3D(
3320
+ label="Selected 3D Viewer",
3321
+ display_mode="solid",
3322
+ clear_color=(0.94, 0.95, 0.97, 1.0),
3323
+ camera_position=FRONT_CAMERA,
3324
+ height=360,
3325
+ )
3326
+ with gr.Column(scale=1, min_width=300):
3327
+ model_details = gr.Markdown("No model loaded.")
3328
 
3329
+ with gr.Row():
3330
+ with gr.Column(scale=2, min_width=420):
3331
+ slice_preview = gr.Image(label="Selected Slice Preview", type="pil", image_mode="RGB", height=320)
3332
+ with gr.Column(scale=1, min_width=300):
3333
+ slice_label = gr.Markdown("No slice stack loaded yet.")
3334
+ with gr.Row():
3335
+ prev_button = gr.Button("Prev", scale=1, min_width=90, size="sm")
3336
+ next_button = gr.Button("Next", scale=1, min_width=90, size="sm")
3337
+ slice_slider = gr.Slider(label="Slice Index", minimum=0, maximum=0, value=0, step=1, interactive=False)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3338
 
3339
+ tiff_downloads = gr.File(label="Download TIFF ZIPs", file_count="multiple", interactive=False)
3340
+ slicer_status = gr.Markdown("")
3341
+
3342
+ with gr.Accordion("Reference TIFF Stack Preview", open=False, elem_classes=["settings-accordion"]):
3343
+ with gr.Row():
3344
+ with gr.Column(scale=1, min_width=200):
3345
+ ref_generate_button = gr.Button("Generate Reference TIFF Stack", variant="primary")
3346
+ with gr.Column(scale=3, min_width=250):
3347
+ ref_slice_label = gr.Markdown("No reference stack generated yet.")
3348
+ ref_slice_preview = gr.Image(label="Reference Slice Preview", type="pil", image_mode="RGB", height=270)
3349
+ with gr.Row():
3350
+ ref_prev_button = gr.Button("Prev", scale=1, min_width=90, size="sm")
3351
+ ref_next_button = gr.Button("Next", scale=1, min_width=90, size="sm")
3352
+ ref_slice_slider = gr.Slider(label="Slice Index", minimum=0, maximum=0, value=0, step=1, interactive=False)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3353
 
3354
  with gr.Tab("TIFF Slices to GCode"):
3355
  gr.Markdown(
 
3377
  refresh_gcode_text_button = gr.Button("Refresh G-Code Preview", variant="secondary", size="sm")
3378
  gcode_text = gr.Code(label="Selected G-Code", language=None, lines=18, max_lines=18, interactive=False, elem_classes=["gcode-view"])
3379
 
3380
+ with gr.Accordion("Nozzle Spacing", open=False, elem_classes=["settings-accordion"]):
3381
+ nozzle_layout_mode = gr.Radio(
3382
+ label="Spacing Mode",
3383
+ choices=[NOZZLE_LAYOUT_GRID, NOZZLE_LAYOUT_PAIR_TABLE],
3384
+ value=NOZZLE_LAYOUT_GRID,
 
 
 
 
 
 
3385
  )
3386
+ with gr.Group(visible=True) as nozzle_grid_group:
3387
+ with gr.Row():
3388
+ nozzle_grid_preset = gr.Dropdown(
3389
+ label="Common Layout",
3390
+ choices=NOZZLE_LAYOUT_PRESETS,
3391
+ value="Custom",
3392
+ allow_custom_value=False,
3393
+ )
3394
+ nozzle_grid_columns = gr.Number(label="Grid Columns", value=2, minimum=1, step=1)
3395
+ nozzle_grid_rows = gr.Number(label="Grid Rows", value=2, minimum=1, step=1)
3396
+ nozzle_grid_column_spacing = gr.Number(label="Column Gap (X, mm)", value=0.0, step=0.1)
3397
+ nozzle_grid_row_spacing = gr.Number(label="Row Gap (Y, mm)", value=0.0, step=0.1)
3398
+ with gr.Group(visible=False) as nozzle_custom_group:
3399
+ nozzle_use_individual_spacing = gr.Checkbox(label="Use Different Values for Each Nozzle Connection", value=False)
3400
+ nozzle_spacing_table = gr.Dataframe(
3401
+ headers=NOZZLE_SPACING_HEADERS,
3402
+ value=[["Same spacing", "All neighboring nozzles", 5.0, 0.0]],
3403
+ row_count=(1, "fixed"),
3404
+ column_count=(len(NOZZLE_SPACING_HEADERS), "fixed"),
3405
+ interactive=True,
3406
+ label="Custom Spacing",
3407
+ elem_id="nozzle-spacing-table",
3408
+ )
3409
  nozzle_preview_button = gr.Button("Visualize Nozzle Spacing", variant="secondary", elem_id="visualize-nozzle-spacing-button")
3410
  with gr.Row():
3411
  with gr.Column(scale=3, min_width=420):
 
3591
  nozzle_use_individual_spacing,
3592
  split_columns,
3593
  split_rows,
3594
+ split_overlapping_layers,
3595
  split_start_nozzle,
3596
  split_start_valve,
3597
  ],
 
3612
  split_piece_preview,
3613
  split_status,
3614
  ],
3615
+ ).then(
3616
+ fn=generate_dynamic_reference_stack,
3617
+ inputs=[shape_records],
3618
+ outputs=[ref_state, ref_slice_slider, ref_slice_label, ref_slice_preview],
3619
  )
3620
  split_piece_source.change(fn=preview_selected_split_piece, inputs=[split_piece_states, split_piece_source], outputs=[split_piece_slider, split_piece_label, split_piece_preview], queue=False)
3621
  split_piece_slider.release(fn=jump_to_selected_split_piece, inputs=[split_piece_states, split_piece_source, split_piece_slider], outputs=[split_piece_label, split_piece_preview], queue=False)
 
3629
  )
3630
  gcode_text_source.change(fn=load_selected_gcode_text, inputs=[shape_records, gcode_text_source], outputs=[gcode_text])
3631
  refresh_gcode_text_button.click(fn=load_selected_gcode_text, inputs=[shape_records, gcode_text_source], outputs=[gcode_text])
3632
+ nozzle_layout_mode.change(
3633
+ fn=update_nozzle_spacing_mode,
3634
+ inputs=[nozzle_layout_mode],
3635
+ outputs=[nozzle_grid_group, nozzle_custom_group],
3636
+ queue=False,
3637
+ )
3638
+ nozzle_grid_preset.change(
3639
+ fn=update_nozzle_grid_preset,
3640
+ inputs=[nozzle_grid_preset, shape_records, nozzle_grid_columns, nozzle_grid_rows],
3641
+ outputs=[nozzle_grid_columns, nozzle_grid_rows],
3642
+ queue=False,
3643
+ )
3644
  nozzle_use_individual_spacing.change(fn=update_nozzle_spacing_table_mode, inputs=[shape_records, nozzle_spacing_table, nozzle_use_individual_spacing], outputs=[nozzle_spacing_table], queue=False)
3645
+ nozzle_preview_button.click(
3646
+ fn=render_dynamic_nozzle_spacing,
3647
+ inputs=[
3648
+ shape_records,
3649
+ nozzle_layout_mode,
3650
+ nozzle_grid_columns,
3651
+ nozzle_grid_rows,
3652
+ nozzle_grid_column_spacing,
3653
+ nozzle_grid_row_spacing,
3654
+ nozzle_use_individual_spacing,
3655
+ nozzle_spacing_table,
3656
+ ],
3657
+ outputs=[nozzle_spacing_plot, nozzle_spacing_status],
3658
+ )
3659
 
3660
  gcode_source.change(
3661
  fn=None,
 
3693
 
3694
  layer_height.change(fn=sync_width_sliders, inputs=[layer_height], outputs=[print_width_slider, travel_width_slider], queue=False)
3695
 
3696
+ parallel_render_inputs = [
3697
+ shape_records,
3698
+ shape_settings,
3699
+ pp_travel_opacity,
3700
+ pp_filament_width,
3701
+ pp_travel_width,
3702
+ nozzle_layout_mode,
3703
+ nozzle_grid_columns,
3704
+ nozzle_grid_rows,
3705
+ nozzle_grid_column_spacing,
3706
+ nozzle_grid_row_spacing,
3707
+ nozzle_use_individual_spacing,
3708
+ nozzle_spacing_table,
3709
+ ]
3710
  parallel_outputs = [parallel_plot, parallel_status, parallel_mode, parallel_anim_controls, pp_width_row, pp_export_group]
3711
  parallel_line_button.click(fn=render_dynamic_parallel_lines, inputs=parallel_render_inputs, outputs=parallel_outputs)
3712
  parallel_render_button.click(fn=render_dynamic_parallel_tubes, inputs=parallel_render_inputs, outputs=parallel_outputs)
 
3714
  pp_travel_width.release(fn=rerender_dynamic_parallel_current_mode, inputs=[parallel_mode] + parallel_render_inputs, outputs=[parallel_plot, parallel_status])
3715
  pp_export_button.click(
3716
  fn=export_dynamic_parallel_gif,
3717
+ inputs=[
3718
+ shape_records,
3719
+ shape_settings,
3720
+ pp_gif_travel_opacity,
3721
+ nozzle_layout_mode,
3722
+ nozzle_grid_columns,
3723
+ nozzle_grid_rows,
3724
+ nozzle_grid_column_spacing,
3725
+ nozzle_grid_row_spacing,
3726
+ nozzle_use_individual_spacing,
3727
+ nozzle_spacing_table,
3728
+ pp_gif_duration,
3729
+ pp_gif_fps,
3730
+ pp_elev,
3731
+ pp_azim,
3732
+ ],
3733
  outputs=[pp_gif_file],
3734
  )
3735
 
tests/test_app_scaling.py CHANGED
@@ -138,3 +138,42 @@ def test_split_tiff_stack_grid_preserves_pixels_and_offsets(tmp_path) -> None:
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)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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)
141
+
142
+
143
+ def test_split_tiff_stack_grid_overlapping_layers_keep_small_alignment_margin(tmp_path) -> None:
144
+ layer0 = np.zeros((2, 6), dtype=np.uint8)
145
+ layer1 = np.zeros((2, 6), dtype=np.uint8)
146
+ layer0_path = tmp_path / "slice_0000.tif"
147
+ layer1_path = tmp_path / "slice_0001.tif"
148
+ Image.fromarray(layer0, mode="L").save(layer0_path)
149
+ Image.fromarray(layer1, mode="L").save(layer1_path)
150
+ state = {
151
+ "tiff_paths": [str(layer0_path), str(layer1_path)],
152
+ "z_values": [0.0, 1.0],
153
+ "pixel_size": 0.5,
154
+ "x_min": 10.0,
155
+ "y_min": -2.0,
156
+ "image_width": 6,
157
+ "image_height": 2,
158
+ }
159
+
160
+ left, right = split_tiff_stack_grid(state, "overlap-part", columns=2, rows=1, overlapping_layers=True)
161
+
162
+ assert left["state"]["image_width"] == 4
163
+ assert right["state"]["image_width"] == 4
164
+ assert left["state"]["x_min"] == 10.0
165
+ assert right["state"]["x_min"] == 11.0
166
+ with Image.open(left["state"]["tiff_paths"][0]) as left_layer0:
167
+ expected = np.zeros((2, 4), dtype=np.uint8)
168
+ np.testing.assert_array_equal(np.asarray(left_layer0), expected)
169
+ with Image.open(right["state"]["tiff_paths"][0]) as right_layer0:
170
+ expected = np.full((2, 4), 255, dtype=np.uint8)
171
+ expected[:, 2:] = 0
172
+ np.testing.assert_array_equal(np.asarray(right_layer0), expected)
173
+ with Image.open(left["state"]["tiff_paths"][1]) as left_layer1:
174
+ expected = np.full((2, 4), 255, dtype=np.uint8)
175
+ expected[:, :2] = 0
176
+ np.testing.assert_array_equal(np.asarray(left_layer1), expected)
177
+ with Image.open(right["state"]["tiff_paths"][1]) as right_layer1:
178
+ expected = np.zeros((2, 4), dtype=np.uint8)
179
+ np.testing.assert_array_equal(np.asarray(right_layer1), expected)
tests/test_nozzle_spacing.py CHANGED
@@ -10,10 +10,13 @@ from app import (
10
  _apply_shape_settings,
11
  delete_shape_from_settings,
12
  _format_nozzle_spacing_status,
 
 
13
  _shape_settings_rows,
14
  _spacing_args_from_table,
15
  _spacing_table_update,
16
  normalize_shape_dimensions_for_mode,
 
17
  _resolve_nozzle_layout,
18
  )
19
 
@@ -245,6 +248,26 @@ def test_shape_settings_round_trip_contour_tracing_column() -> None:
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"},
@@ -293,6 +316,46 @@ def test_advanced_spacing_table_collapses_duplicate_nozzles() -> None:
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)
 
10
  _apply_shape_settings,
11
  delete_shape_from_settings,
12
  _format_nozzle_spacing_status,
13
+ _grid_spacing_rows,
14
+ _records_from_files,
15
  _shape_settings_rows,
16
  _spacing_args_from_table,
17
  _spacing_table_update,
18
  normalize_shape_dimensions_for_mode,
19
+ _resolve_nozzle_grid_layout,
20
  _resolve_nozzle_layout,
21
  )
22
 
 
248
  assert updated[0]["contour_tracing"] is True
249
 
250
 
251
+ def test_repeated_sample_path_gets_next_unused_nozzle() -> None:
252
+ records = _records_from_files(
253
+ ["sample.stl", "sample.stl"],
254
+ previous_records=[{"idx": 1, "name": "sample", "stl_path": "sample.stl", "nozzle": 1}],
255
+ )
256
+
257
+ assert [record["nozzle"] for record in records] == [1, 2]
258
+
259
+
260
+ def test_sample_reload_appends_next_nozzle_set() -> None:
261
+ sample_paths = ["sample_a.stl", "sample_b.stl", "sample_c.stl"]
262
+ first_load = _records_from_files(sample_paths)
263
+ first_resync = _records_from_files(sample_paths, first_load)
264
+ second_load = _records_from_files([*sample_paths, *sample_paths], first_load)
265
+
266
+ assert [record["nozzle"] for record in first_load] == [1, 2, 3]
267
+ assert [record["nozzle"] for record in first_resync] == [1, 2, 3]
268
+ assert [record["nozzle"] for record in second_load] == [1, 2, 3, 4, 5, 6]
269
+
270
+
271
  def test_simple_spacing_table_uses_one_shared_spacing_row() -> None:
272
  records = [
273
  {"idx": 1, "name": "first"},
 
316
  assert update["value"] == [["Nozzle 1: Shape 1, Shape 2", "Nozzle 2: Shape 3", 9.0, 2.5]]
317
 
318
 
319
+ def test_grid_spacing_rows_follow_row_major_pattern() -> None:
320
+ records = [
321
+ {"idx": 1, "name": "first", "nozzle": 1},
322
+ {"idx": 2, "name": "second", "nozzle": 2},
323
+ {"idx": 3, "name": "third", "nozzle": 3},
324
+ {"idx": 4, "name": "fourth", "nozzle": 4},
325
+ ]
326
+
327
+ rows, column_count, row_count = _grid_spacing_rows(records, columns=2, rows=2, column_spacing=10.0, row_spacing=3.0)
328
+
329
+ assert column_count == 2
330
+ assert row_count == 2
331
+ assert rows == [
332
+ ["Nozzle 1: Shape 1", "Nozzle 2: Shape 2", 10.0, 0.0],
333
+ ["Nozzle 2: Shape 2", "Nozzle 3: Shape 3", -10.0, 3.0],
334
+ ["Nozzle 3: Shape 3", "Nozzle 4: Shape 4", 10.0, 0.0],
335
+ ]
336
+
337
+
338
+ def test_nozzle_grid_layout_places_nozzles_by_rows_and_columns() -> None:
339
+ parts = [
340
+ _part(1, ((0.0, 0.0, 0.0), (10.0, 20.0, 1.0))),
341
+ _part(2, ((0.0, 0.0, 0.0), (10.0, 20.0, 1.0))),
342
+ _part(3, ((0.0, 0.0, 0.0), (10.0, 20.0, 1.0))),
343
+ _part(4, ((0.0, 0.0, 0.0), (10.0, 20.0, 1.0))),
344
+ ]
345
+
346
+ offsets, spacings = _resolve_nozzle_grid_layout(parts, columns=2, rows=2, column_spacing=2.0, row_spacing=3.0)
347
+
348
+ np.testing.assert_allclose(offsets[1], (0.0, 0.0))
349
+ np.testing.assert_allclose(offsets[2], (12.0, 0.0))
350
+ np.testing.assert_allclose(offsets[3], (0.0, 23.0))
351
+ np.testing.assert_allclose(offsets[4], (12.0, 23.0))
352
+ assert spacings == [
353
+ {"from": 1, "to": 2, "dx": 12.0, "dy": 0.0},
354
+ {"from": 2, "to": 3, "dx": -12.0, "dy": 23.0},
355
+ {"from": 3, "to": 4, "dx": 12.0, "dy": 0.0},
356
+ ]
357
+
358
+
359
  def test_delete_shape_reindexes_without_losing_shape_data() -> None:
360
  class Event:
361
  index = (1, len(SHAPE_SETTINGS_HEADERS) - 1)