CyGuy8 Claude Fable 5 commited on
Commit
a57b56b
·
1 Parent(s): c11d374

UI audit fixes: safe split defaults, spacing-table ghosts, rotation marker, split options in export

Browse files

- Split Starting Nozzle / Starting Valve default to the next numbers past
everything in use, refresh on every table change, and re-bump after
each split - a default split can no longer land pieces on another
shape's nozzle (silently forming a multi-material assembly) or reuse
its valves.
- The ghost-row pass now covers the Advanced Grid Spacing table, and the
rule is stronger for both tables: when any live (button-wrapped) rows
exist, every outer-copy row is a render leftover - which also removes
stale rows whose pair/shape no longer exists (e.g. the self-paired
"Nozzle 2 -> Nozzle 2" leftover).
- Rotated shapes are marked at the START of the STL cell ("↻90° name").
Display-only: reading the table back strips the marker, and the group
summaries ignore it.
- Save/Load Settings now includes the Multi-Nozzle Split geometry
options (columns, rows, both interlocks, row-overlap direction,
overlap depth). Starting nozzle/valve stay auto-computed.

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

Files changed (3) hide show
  1. README.md +3 -3
  2. app.py +134 -21
  3. tests/test_nozzle_spacing.py +50 -0
README.md CHANGED
@@ -51,11 +51,11 @@ Then open the local Gradio URL in your browser, upload STL files or load the bun
51
  - Port groups are marked too: shapes sharing a Port get a matching underline on their Pressure and Port cells and a summary line ("Port 1: A + B share one pressure regulator (25 psi)") — one regulator per serial port is why their pressures stay in sync
52
  - Automatically unions the sliced shapes into a combined reference layer set whenever shapes are sliced
53
  - Shows a sliced-layer preview in the Selected Shape Preview accordion (layer slider through the shape's polygon outlines, drawn in its print color; assembly parts sharing the nozzle are drawn together so multi-material slicing can be checked before generating G-code)
54
- - **Rotate a shape to print in any direction**: the Selected Shape Preview accordion has a Rotate (°) input that spins the selected shape on the bed (about Z, around its center) before slicing — the configured table dimensions follow the rotation (a spun 20 × 8 shape becomes 8 × 20, keeping any custom scaling), and the 3D/layer previews show the rotated shape. **Rotate All Shapes** applies the same angle to every uploaded shape at once (each carrying its own dimensions through the turn; split pieces are skipped). Assembly parts sharing a nozzle rotate about the group's combined center, so equal rotations turn the whole assembly as one unit. The rotation exports/imports with the other settings. (The engine also supports X/Y tilts, not yet exposed in the UI)
55
  - Bundled sample sets include a **Multi-Material Demo** (checkerboard cube, wrapped egg, space helmet — two STLs each) that loads with the parts of each model already grouped onto shared nozzles, forming three assemblies in one click
56
- - **Save / Load Settings**: exports every table setting plus the generation options as a small JSON keyed by STL filename; re-upload the same STLs later (or after a Space restart) and import to restore the whole setup — files in the export that aren't loaded yet are listed so they can be added. Split pieces are derived geometry and don't round-trip
57
  - Generate G-Code reports live progress (slicing, reference building, then shape-by-shape generation)
58
- - Splits one sliced shape's geometry into an editable row/column grid for multi-nozzle printing of one large shape
59
  - Converts sliced layers into G-code files with pressure, valve, nozzle, port, and infill % settings per shape from the Shape Settings table
60
  - **Pressure is a port property** (one pressure regulator per serial port): shapes sharing a Port always share one pressure — editing one shape's pressure updates every shape on that port, moving a shape onto a port adopts that port's pressure, and newly added shapes join at their port's existing pressure
61
  - **Infill %** per shape skips dispensing on evenly-distributed raster lines (rings for the circle spiral) — at 50% every other line prints. Parallel shapes with different infill still share one print path, and lines that **no** shape dispenses on are dropped from the motion entirely instead of being swept valve-off (every shape at 50% roughly halves the print path; with mixed infills a line survives if any shape prints it). The rectangular spiral keeps its full continuous walk; circle-spiral perimeter walls always stay
 
51
  - Port groups are marked too: shapes sharing a Port get a matching underline on their Pressure and Port cells and a summary line ("Port 1: A + B share one pressure regulator (25 psi)") — one regulator per serial port is why their pressures stay in sync
52
  - Automatically unions the sliced shapes into a combined reference layer set whenever shapes are sliced
53
  - Shows a sliced-layer preview in the Selected Shape Preview accordion (layer slider through the shape's polygon outlines, drawn in its print color; assembly parts sharing the nozzle are drawn together so multi-material slicing can be checked before generating G-code)
54
+ - **Rotate a shape to print in any direction**: the Selected Shape Preview accordion has a Rotate (°) input that spins the selected shape on the bed (about Z, around its center) before slicing — the configured table dimensions follow the rotation (a spun 20 × 8 shape becomes 8 × 20, keeping any custom scaling), and the 3D/layer previews show the rotated shape, and the table marks rotated shapes at the start of the STL column ("↻90° name"). **Rotate All Shapes** applies the same angle to every uploaded shape at once (each carrying its own dimensions through the turn; split pieces are skipped). Assembly parts sharing a nozzle rotate about the group's combined center, so equal rotations turn the whole assembly as one unit. The rotation exports/imports with the other settings. (The engine also supports X/Y tilts, not yet exposed in the UI)
55
  - Bundled sample sets include a **Multi-Material Demo** (checkerboard cube, wrapped egg, space helmet — two STLs each) that loads with the parts of each model already grouped onto shared nozzles, forming three assemblies in one click
56
+ - **Save / Load Settings**: exports every table setting plus the generation options (Multi-Nozzle Split geometry options included) as a small JSON keyed by STL filename; re-upload the same STLs later (or after a Space restart) and import to restore the whole setup — files in the export that aren't loaded yet are listed so they can be added. Split pieces are derived geometry and don't round-trip
57
  - Generate G-Code reports live progress (slicing, reference building, then shape-by-shape generation)
58
+ - Splits one sliced shape's geometry into an editable row/column grid for multi-nozzle printing of one large shape. The Starting Nozzle / Starting Valve defaults always sit past every number in use (and re-bump after each split), so a default split never lands pieces on another shape's nozzle or valve
59
  - Converts sliced layers into G-code files with pressure, valve, nozzle, port, and infill % settings per shape from the Shape Settings table
60
  - **Pressure is a port property** (one pressure regulator per serial port): shapes sharing a Port always share one pressure — editing one shape's pressure updates every shape on that port, moving a shape onto a port adopts that port's pressure, and newly added shapes join at their port's existing pressure
61
  - **Infill %** per shape skips dispensing on evenly-distributed raster lines (rings for the circle spiral) — at 50% every other line prints. Parallel shapes with different infill still share one print path, and lines that **no** shape dispenses on are dropped from the motion entirely instead of being swept valve-off (every shape at 50% roughly halves the print path; with mixed infills a line survives if any shape prints it). The rectangular spiral keeps its full continuous walk; circle-spiral perimeter walls always stay
app.py CHANGED
@@ -634,7 +634,23 @@ APP_HEAD = """
634
  return isNaN(value) ? null : Math.round(value);
635
  }
636
  function cellName(td) {
637
- return ((td && td.textContent) || '').replace(/\\u22ee/g, '').trim();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
638
  }
639
  function refresh() {
640
  var container = document.getElementById('shape-settings-table');
@@ -685,16 +701,19 @@ APP_HEAD = """
685
  }
686
  entry.copies.push({tr: tr, tds: tds, live: fromLive});
687
  });
688
- // Ghost pass: when a Shape number has a LIVE row, any stale copy of
689
- // it in the outer table is a render leftover — hide it; every other
690
- // recognized row is explicitly shown (it may have been a ghost in a
691
- // previous render). Unrecognized rows keep their current state.
 
692
  entries.forEach(function (entry) {
693
  entry.copies.forEach(function (copy) {
694
- var hide = entry.fromLive && !copy.live;
695
- copy.tr.style.display = hide ? 'none' : '';
696
  });
697
  });
 
 
 
698
  var byNozzle = {}, byValve = {}, byPort = {};
699
  entries.forEach(function (entry) {
700
  if (entry.nozzle !== null) (byNozzle[entry.nozzle] = byNozzle[entry.nozzle] || []).push(entry);
@@ -2196,11 +2215,61 @@ def _round_targets_to_tenths(record: dict) -> dict:
2196
  return record
2197
 
2198
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2199
  def _shape_settings_rows(records: list[dict]) -> list[list[Any]]:
2200
  return [
2201
  [
2202
  record["idx"],
2203
- record["name"],
2204
  round(_coerce_float(record.get("target_x"), DEFAULT_TARGET_EXTENTS[0]), 1),
2205
  round(_coerce_float(record.get("target_y"), DEFAULT_TARGET_EXTENTS[1]), 1),
2206
  round(_coerce_float(record.get("target_z"), DEFAULT_TARGET_EXTENTS[2]), 1),
@@ -2233,7 +2302,7 @@ def _apply_shape_settings(records: list[dict], settings_table: Any) -> list[dict
2233
  copy = dict(record)
2234
  row = by_idx.get(int(copy.get("idx", 0)))
2235
  if row:
2236
- copy["name"] = str(row[1] or copy["name"])
2237
  if "original_x" not in copy or "original_y" not in copy or "original_z" not in copy:
2238
  original_x, original_y, original_z = _default_target_extents_for_stl(str(copy.get("stl_path", "")))
2239
  copy.setdefault("original_x", original_x)
@@ -4767,6 +4836,12 @@ def export_project_settings(
4767
  lead_in_orientation: str | None,
4768
  nozzle_speed: Any,
4769
  contour_order: str | None = None,
 
 
 
 
 
 
4770
  ) -> tuple[str | None, str]:
4771
  """Write the session's settings to a small JSON file, keyed by STL name.
4772
 
@@ -4804,6 +4879,14 @@ def export_project_settings(
4804
  "lead_in_orientation": str(lead_in_orientation or LEAD_IN_LINE_AUTO),
4805
  "nozzle_speed": _coerce_float(nozzle_speed, 10.0),
4806
  "contour_order": str(contour_order or CONTOUR_ORDER_LAST),
 
 
 
 
 
 
 
 
4807
  },
4808
  }
4809
  settings_path = Path(tempfile.mkdtemp(prefix="pp_settings_")) / "parallelprint_settings.json"
@@ -4827,7 +4910,7 @@ def import_project_settings(
4827
  """
4828
 
4829
  def _skip_options() -> tuple:
4830
- return tuple(gr.skip() for _ in range(13))
4831
 
4832
  paths = _uploaded_file_paths(settings_upload)
4833
  if not paths:
@@ -4890,6 +4973,12 @@ def import_project_settings(
4890
  option("scale_mode"),
4891
  option("nozzle_speed"),
4892
  option("contour_order"),
 
 
 
 
 
 
4893
  )
4894
 
4895
 
@@ -5808,9 +5897,9 @@ def build_dynamic_demo() -> gr.Blocks:
5808
 
5809
  shape_sync_outputs = [shape_records, shape_settings, selected_shape, gcode_text_source, gcode_source, gcode_downloads, gcode_download_all]
5810
  stl_upload.change(fn=sync_uploaded_shapes, inputs=[stl_upload, shape_records, shape_settings], outputs=shape_sync_outputs).then(
5811
- fn=lambda records: _dropdown_update(records),
5812
  inputs=[shape_records],
5813
- outputs=[split_source],
5814
  queue=False,
5815
  ).then(
5816
  fn=_grid_spacing_table_update,
@@ -5819,9 +5908,9 @@ def build_dynamic_demo() -> gr.Blocks:
5819
  queue=False,
5820
  )
5821
  sync_uploads_button.click(fn=sync_uploaded_shapes, inputs=[stl_upload, shape_records, shape_settings], outputs=shape_sync_outputs).then(
5822
- fn=lambda records: _dropdown_update(records),
5823
  inputs=[shape_records],
5824
- outputs=[split_source],
5825
  queue=False,
5826
  ).then(
5827
  fn=_grid_spacing_table_update,
@@ -5830,9 +5919,9 @@ def build_dynamic_demo() -> gr.Blocks:
5830
  queue=False,
5831
  )
5832
  load_samples_button.click(fn=load_sample_shapes, inputs=[stl_upload, shape_records, shape_settings, sample_set_selector], outputs=[stl_upload, *shape_sync_outputs]).then(
5833
- fn=lambda records: _dropdown_update(records),
5834
  inputs=[shape_records],
5835
- outputs=[split_source],
5836
  queue=False,
5837
  ).then(
5838
  fn=_grid_spacing_table_update,
@@ -5858,9 +5947,9 @@ def build_dynamic_demo() -> gr.Blocks:
5858
  outputs=[stl_upload, *shape_sync_outputs, last_shape_delete_at],
5859
  queue=False,
5860
  ).then(
5861
- fn=lambda records: _dropdown_update(records),
5862
  inputs=[shape_records],
5863
- outputs=[split_source],
5864
  queue=False,
5865
  ).then(
5866
  fn=_grid_spacing_table_update,
@@ -5916,7 +6005,7 @@ def build_dynamic_demo() -> gr.Blocks:
5916
  outputs=[model_viewer, model_details],
5917
  )
5918
 
5919
- split_refresh_sources.click(fn=lambda records: _dropdown_update(records), inputs=[shape_records], outputs=[split_source], queue=False)
5920
  # .input (user selections only): programmatic dropdown refills after a
5921
  # split must not overwrite the split result message.
5922
  split_source.input(
@@ -5969,6 +6058,13 @@ def build_dynamic_demo() -> gr.Blocks:
5969
  inputs=grid_spacing_refresh_inputs,
5970
  outputs=[nozzle_grid_spacing_table],
5971
  queue=False,
 
 
 
 
 
 
 
5972
  )
5973
  split_undo_button.click(
5974
  fn=undo_last_split,
@@ -5998,6 +6094,11 @@ def build_dynamic_demo() -> gr.Blocks:
5998
  inputs=grid_spacing_refresh_inputs,
5999
  outputs=[nozzle_grid_spacing_table],
6000
  queue=False,
 
 
 
 
 
6001
  )
6002
  assign_valves_button.click(
6003
  fn=assign_unique_valves,
@@ -6023,6 +6124,12 @@ def build_dynamic_demo() -> gr.Blocks:
6023
  gcode_lead_in_orientation,
6024
  viz_nozzle_speed,
6025
  gcode_contour_order,
 
 
 
 
 
 
6026
  ],
6027
  outputs=[settings_export_file, settings_status],
6028
  queue=False,
@@ -6047,6 +6154,12 @@ def build_dynamic_demo() -> gr.Blocks:
6047
  scale_mode,
6048
  viz_nozzle_speed,
6049
  gcode_contour_order,
 
 
 
 
 
 
6050
  ],
6051
  )
6052
 
@@ -6190,9 +6303,9 @@ def build_dynamic_demo() -> gr.Blocks:
6190
  inputs=layer_preview_inputs,
6191
  outputs=layer_preview_outputs,
6192
  ).then(
6193
- fn=lambda records: _dropdown_update(records),
6194
  inputs=[shape_records],
6195
- outputs=[split_source],
6196
  queue=False,
6197
  ).then(
6198
  fn=check_gcode_staleness,
 
634
  return isNaN(value) ? null : Math.round(value);
635
  }
636
  function cellName(td) {
637
+ // Strip the cell-menu glyph and the display-only leading rotation
638
+ // marker ("↻90° name").
639
+ return ((td && td.textContent) || '').replace(/\\u22ee/g, '').trim().replace(/^\\u21bb\\S*\\s*/, '');
640
+ }
641
+ function hideStaleCopies(containerId) {
642
+ // Gradio renders each dataframe TWICE (a button-wrapped live copy
643
+ // plus an outer copy that can hold stale leftover rows). When ANY
644
+ // live rows exist, every outer row is a render leftover — hide it.
645
+ var container = document.getElementById(containerId);
646
+ if (!container) return;
647
+ var rows = Array.prototype.slice.call(container.querySelectorAll('table tbody tr'));
648
+ var anyLive = rows.some(function (tr) { return tr.querySelector('td') && tr.closest('button'); });
649
+ rows.forEach(function (tr) {
650
+ if (!tr.querySelector('td')) return;
651
+ var live = !!tr.closest('button');
652
+ tr.style.display = (anyLive && !live) ? 'none' : '';
653
+ });
654
  }
655
  function refresh() {
656
  var container = document.getElementById('shape-settings-table');
 
701
  }
702
  entry.copies.push({tr: tr, tds: tds, live: fromLive});
703
  });
704
+ // Ghost pass: when the table has ANY live (button-wrapped) rows,
705
+ // every outer copy is a render leftover — including stale rows
706
+ // whose Shape number no longer exists. Only when NO live rows
707
+ // rendered at all does the outer table stand on its own.
708
+ var anyLive = entries.some(function (entry) { return entry.fromLive; });
709
  entries.forEach(function (entry) {
710
  entry.copies.forEach(function (copy) {
711
+ copy.tr.style.display = (anyLive && !copy.live) ? 'none' : '';
 
712
  });
713
  });
714
+ // The Advanced Grid Spacing table has the same double render (and
715
+ // showed stale self-paired rows); same rule, no styling needed.
716
+ hideStaleCopies('nozzle-grid-spacing-table');
717
  var byNozzle = {}, byValve = {}, byPort = {};
718
  entries.forEach(function (entry) {
719
  if (entry.nozzle !== null) (byNozzle[entry.nozzle] = byNozzle[entry.nozzle] || []).push(entry);
 
2215
  return record
2216
 
2217
 
2218
+ def _display_shape_name(record: dict) -> str:
2219
+ """The STL cell text: a leading rotation marker (a single compact token,
2220
+ e.g. "↻90°") in front of the name, so a rotated shape is recognizable at
2221
+ a glance straight from the table."""
2222
+ name = str(record.get("name") or "")
2223
+ rotation = _record_rotation(record)
2224
+ if not any(rotation):
2225
+ return name
2226
+ if rotation[0] or rotation[1]:
2227
+ marker = f"↻X{rotation[0]:g}°Y{rotation[1]:g}°Z{rotation[2]:g}°"
2228
+ else:
2229
+ marker = f"↻{rotation[2]:g}°"
2230
+ return f"{marker} {name}"
2231
+
2232
+
2233
+ def _strip_rotation_marker(name: str) -> str:
2234
+ """Remove the display-only leading rotation marker before storing a name."""
2235
+ text = name.strip()
2236
+ if text.startswith("↻"):
2237
+ parts = text.split(None, 1)
2238
+ if len(parts) == 2:
2239
+ return parts[1]
2240
+ return name
2241
+
2242
+
2243
+ def _split_numbering_defaults(records: list[dict] | None) -> tuple[dict, dict]:
2244
+ """Collision-free Starting Nozzle / Starting Valve for the split panel.
2245
+
2246
+ Splitting assigns a RUN of sequential numbers from the starting values,
2247
+ so the defaults sit past every number in use — otherwise a default
2248
+ split silently lands pieces on other shapes' nozzles (accidentally
2249
+ forming multi-material assemblies) and reuses their valves.
2250
+ """
2251
+ records = records or []
2252
+ nozzles = [
2253
+ _record_nozzle_number(record, int(record.get("idx", position) or position))
2254
+ for position, record in enumerate(records, start=1)
2255
+ ]
2256
+ valves = [_coerce_int(record.get("valve"), 0) for record in records]
2257
+ next_nozzle = (max(nozzles) + 1) if nozzles else 1
2258
+ next_valve = max([valve for valve in valves if valve > 0] + [3]) + 1
2259
+ return gr.update(value=next_nozzle), gr.update(value=next_valve)
2260
+
2261
+
2262
+ def refresh_split_sources(records: list[dict] | None) -> tuple:
2263
+ """Split panel refresh: source dropdown + collision-free numbering."""
2264
+ nozzle_update, valve_update = _split_numbering_defaults(records)
2265
+ return _dropdown_update(records), nozzle_update, valve_update
2266
+
2267
+
2268
  def _shape_settings_rows(records: list[dict]) -> list[list[Any]]:
2269
  return [
2270
  [
2271
  record["idx"],
2272
+ _display_shape_name(record),
2273
  round(_coerce_float(record.get("target_x"), DEFAULT_TARGET_EXTENTS[0]), 1),
2274
  round(_coerce_float(record.get("target_y"), DEFAULT_TARGET_EXTENTS[1]), 1),
2275
  round(_coerce_float(record.get("target_z"), DEFAULT_TARGET_EXTENTS[2]), 1),
 
2302
  copy = dict(record)
2303
  row = by_idx.get(int(copy.get("idx", 0)))
2304
  if row:
2305
+ copy["name"] = _strip_rotation_marker(str(row[1] or copy["name"]))
2306
  if "original_x" not in copy or "original_y" not in copy or "original_z" not in copy:
2307
  original_x, original_y, original_z = _default_target_extents_for_stl(str(copy.get("stl_path", "")))
2308
  copy.setdefault("original_x", original_x)
 
4836
  lead_in_orientation: str | None,
4837
  nozzle_speed: Any,
4838
  contour_order: str | None = None,
4839
+ split_columns: Any = None,
4840
+ split_rows: Any = None,
4841
+ split_overlapping_layers: Any = None,
4842
+ split_overlapping_rows: Any = None,
4843
+ split_row_overlap_direction: str | None = None,
4844
+ split_overlap: Any = None,
4845
  ) -> tuple[str | None, str]:
4846
  """Write the session's settings to a small JSON file, keyed by STL name.
4847
 
 
4879
  "lead_in_orientation": str(lead_in_orientation or LEAD_IN_LINE_AUTO),
4880
  "nozzle_speed": _coerce_float(nozzle_speed, 10.0),
4881
  "contour_order": str(contour_order or CONTOUR_ORDER_LAST),
4882
+ "split_columns": max(1, _coerce_int(split_columns, 2)),
4883
+ "split_rows": max(1, _coerce_int(split_rows, 1)),
4884
+ "split_overlapping_layers": bool(split_overlapping_layers),
4885
+ "split_overlapping_rows": bool(split_overlapping_rows),
4886
+ "split_row_overlap_direction": str(
4887
+ split_row_overlap_direction or ROW_OVERLAP_AXES_CHOICES[0]
4888
+ ),
4889
+ "split_overlap": _coerce_float(split_overlap, 0.8),
4890
  },
4891
  }
4892
  settings_path = Path(tempfile.mkdtemp(prefix="pp_settings_")) / "parallelprint_settings.json"
 
4910
  """
4911
 
4912
  def _skip_options() -> tuple:
4913
+ return tuple(gr.skip() for _ in range(19))
4914
 
4915
  paths = _uploaded_file_paths(settings_upload)
4916
  if not paths:
 
4973
  option("scale_mode"),
4974
  option("nozzle_speed"),
4975
  option("contour_order"),
4976
+ option("split_columns"),
4977
+ option("split_rows"),
4978
+ option("split_overlapping_layers"),
4979
+ option("split_overlapping_rows"),
4980
+ option("split_row_overlap_direction"),
4981
+ option("split_overlap"),
4982
  )
4983
 
4984
 
 
5897
 
5898
  shape_sync_outputs = [shape_records, shape_settings, selected_shape, gcode_text_source, gcode_source, gcode_downloads, gcode_download_all]
5899
  stl_upload.change(fn=sync_uploaded_shapes, inputs=[stl_upload, shape_records, shape_settings], outputs=shape_sync_outputs).then(
5900
+ fn=refresh_split_sources,
5901
  inputs=[shape_records],
5902
+ outputs=[split_source, split_start_nozzle, split_start_valve],
5903
  queue=False,
5904
  ).then(
5905
  fn=_grid_spacing_table_update,
 
5908
  queue=False,
5909
  )
5910
  sync_uploads_button.click(fn=sync_uploaded_shapes, inputs=[stl_upload, shape_records, shape_settings], outputs=shape_sync_outputs).then(
5911
+ fn=refresh_split_sources,
5912
  inputs=[shape_records],
5913
+ outputs=[split_source, split_start_nozzle, split_start_valve],
5914
  queue=False,
5915
  ).then(
5916
  fn=_grid_spacing_table_update,
 
5919
  queue=False,
5920
  )
5921
  load_samples_button.click(fn=load_sample_shapes, inputs=[stl_upload, shape_records, shape_settings, sample_set_selector], outputs=[stl_upload, *shape_sync_outputs]).then(
5922
+ fn=refresh_split_sources,
5923
  inputs=[shape_records],
5924
+ outputs=[split_source, split_start_nozzle, split_start_valve],
5925
  queue=False,
5926
  ).then(
5927
  fn=_grid_spacing_table_update,
 
5947
  outputs=[stl_upload, *shape_sync_outputs, last_shape_delete_at],
5948
  queue=False,
5949
  ).then(
5950
+ fn=refresh_split_sources,
5951
  inputs=[shape_records],
5952
+ outputs=[split_source, split_start_nozzle, split_start_valve],
5953
  queue=False,
5954
  ).then(
5955
  fn=_grid_spacing_table_update,
 
6005
  outputs=[model_viewer, model_details],
6006
  )
6007
 
6008
+ split_refresh_sources.click(fn=refresh_split_sources, inputs=[shape_records], outputs=[split_source, split_start_nozzle, split_start_valve], queue=False)
6009
  # .input (user selections only): programmatic dropdown refills after a
6010
  # split must not overwrite the split result message.
6011
  split_source.input(
 
6058
  inputs=grid_spacing_refresh_inputs,
6059
  outputs=[nozzle_grid_spacing_table],
6060
  queue=False,
6061
+ ).then(
6062
+ # Fresh pieces claimed a run of nozzles/valves: bump the split
6063
+ # defaults past them so a SECOND split cannot collide either.
6064
+ fn=lambda records: _split_numbering_defaults(records),
6065
+ inputs=[shape_records],
6066
+ outputs=[split_start_nozzle, split_start_valve],
6067
+ queue=False,
6068
  )
6069
  split_undo_button.click(
6070
  fn=undo_last_split,
 
6094
  inputs=grid_spacing_refresh_inputs,
6095
  outputs=[nozzle_grid_spacing_table],
6096
  queue=False,
6097
+ ).then(
6098
+ fn=lambda records: _split_numbering_defaults(records),
6099
+ inputs=[shape_records],
6100
+ outputs=[split_start_nozzle, split_start_valve],
6101
+ queue=False,
6102
  )
6103
  assign_valves_button.click(
6104
  fn=assign_unique_valves,
 
6124
  gcode_lead_in_orientation,
6125
  viz_nozzle_speed,
6126
  gcode_contour_order,
6127
+ split_columns,
6128
+ split_rows,
6129
+ split_overlapping_layers,
6130
+ split_overlapping_rows,
6131
+ split_row_overlap_direction,
6132
+ split_overlap,
6133
  ],
6134
  outputs=[settings_export_file, settings_status],
6135
  queue=False,
 
6154
  scale_mode,
6155
  viz_nozzle_speed,
6156
  gcode_contour_order,
6157
+ split_columns,
6158
+ split_rows,
6159
+ split_overlapping_layers,
6160
+ split_overlapping_rows,
6161
+ split_row_overlap_direction,
6162
+ split_overlap,
6163
  ],
6164
  )
6165
 
 
6303
  inputs=layer_preview_inputs,
6304
  outputs=layer_preview_outputs,
6305
  ).then(
6306
+ fn=refresh_split_sources,
6307
  inputs=[shape_records],
6308
+ outputs=[split_source, split_start_nozzle, split_start_valve],
6309
  queue=False,
6310
  ).then(
6311
  fn=check_gcode_staleness,
tests/test_nozzle_spacing.py CHANGED
@@ -1860,6 +1860,50 @@ def test_apply_shape_rotation_updates_dims_and_reslices(tmp_path) -> None:
1860
  assert (respun[0]["original_x"], respun[0]["original_y"], respun[0]["original_z"]) == (4.0, 10.0, 2.0)
1861
 
1862
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1863
  def test_rotate_all_shapes_at_once(tmp_path) -> None:
1864
  import trimesh
1865
 
@@ -1910,6 +1954,8 @@ def test_project_settings_export_import_round_trip(tmp_path) -> None:
1910
  settings_path, status = export_project_settings(
1911
  records, None, 0.4, 0.4, None, "Circle Spiral raster", False, 1.2,
1912
  6.0, 7.0, 4, "Down", "Horizontal", 15.0,
 
 
1913
  )
1914
  assert settings_path and "2 shape(s)" in status
1915
 
@@ -1930,6 +1976,10 @@ def test_project_settings_export_import_round_trip(tmp_path) -> None:
1930
  assert option_updates[2]["value"] == 1.2
1931
  assert option_updates[8]["value"] == 0.4 # layer height
1932
  assert option_updates[11]["value"] == 15.0 # nozzle speed
 
 
 
 
1933
 
1934
  # A file listed in the export but not loaded is reported.
1935
  partial = import_project_settings([settings_path], _records_from_files(["egg_inside.stl"], None), None)
 
1860
  assert (respun[0]["original_x"], respun[0]["original_y"], respun[0]["original_z"]) == (4.0, 10.0, 2.0)
1861
 
1862
 
1863
+ def test_split_numbering_defaults_avoid_collisions() -> None:
1864
+ from app import _split_numbering_defaults
1865
+
1866
+ # Shapes on nozzles 5/2/3 with valves 4/9: the split defaults must sit
1867
+ # PAST every number in use (splits assign sequential runs), otherwise a
1868
+ # default split lands pieces on other shapes' nozzles - silently forming
1869
+ # multi-material assemblies - and reuses their valves.
1870
+ records = [
1871
+ {"idx": 1, "name": "a", "nozzle": 5, "valve": 4},
1872
+ {"idx": 2, "name": "b", "nozzle": 2, "valve": 9},
1873
+ {"idx": 3, "name": "c", "nozzle": 3, "valve": 0},
1874
+ ]
1875
+ nozzle_update, valve_update = _split_numbering_defaults(records)
1876
+ assert nozzle_update["value"] == 6
1877
+ assert valve_update["value"] == 10
1878
+
1879
+ # Empty table: the historical defaults.
1880
+ nozzle_update, valve_update = _split_numbering_defaults([])
1881
+ assert nozzle_update["value"] == 1
1882
+ assert valve_update["value"] == 4
1883
+
1884
+
1885
+ def test_rotation_marker_shows_in_table_and_strips_on_read() -> None:
1886
+ from app import _apply_shape_settings, _display_shape_name
1887
+
1888
+ record = {
1889
+ "idx": 1, "name": "bar", "stl_path": "bar.stl",
1890
+ "original_x": 10.0, "original_y": 4.0, "original_z": 2.0,
1891
+ "target_x": 10.0, "target_y": 4.0, "target_z": 2.0,
1892
+ "pressure": 25.0, "valve": 4, "nozzle": 1, "port": 1,
1893
+ "color": "#111111", "rotation": (0.0, 0.0, 90.0),
1894
+ }
1895
+ # The STL cell carries a visible LEADING rotation marker...
1896
+ assert _display_shape_name(record) == "↻90° bar"
1897
+ rows = _shape_settings_rows([record])
1898
+ assert rows[0][1] == "↻90° bar"
1899
+ # ...which is display-only: reading the table back stores the clean name.
1900
+ updated = _apply_shape_settings([record], rows)
1901
+ assert updated[0]["name"] == "bar"
1902
+ # Unrotated shapes show the plain name.
1903
+ record["rotation"] = None
1904
+ assert _display_shape_name(record) == "bar"
1905
+
1906
+
1907
  def test_rotate_all_shapes_at_once(tmp_path) -> None:
1908
  import trimesh
1909
 
 
1954
  settings_path, status = export_project_settings(
1955
  records, None, 0.4, 0.4, None, "Circle Spiral raster", False, 1.2,
1956
  6.0, 7.0, 4, "Down", "Horizontal", 15.0,
1957
+ split_columns=3, split_rows=2, split_overlapping_rows=True,
1958
+ split_overlap=1.5,
1959
  )
1960
  assert settings_path and "2 shape(s)" in status
1961
 
 
1976
  assert option_updates[2]["value"] == 1.2
1977
  assert option_updates[8]["value"] == 0.4 # layer height
1978
  assert option_updates[11]["value"] == 15.0 # nozzle speed
1979
+ assert option_updates[13]["value"] == 3 # split columns
1980
+ assert option_updates[14]["value"] == 2 # split rows
1981
+ assert option_updates[16]["value"] is True # overlapping rows
1982
+ assert option_updates[18]["value"] == 1.5 # overlap mm
1983
 
1984
  # A file listed in the export but not loaded is reported.
1985
  partial = import_project_settings([settings_path], _records_from_files(["egg_inside.stl"], None), None)