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

Split progress, Undo Delete, slice-on-demand preview, assembly rotation note; drop two repair buttons

Browse files

- Splitting reports live per-shape slicing progress (and Generate G-Code's
slicing phase now shows per-shape progress too).
- Undo Delete: every delete pushes a snapshot (settings, slices, and
G-code included; last 10) and one click restores the removed shape.
- Regenerate Preview slices the SELECTED shape on demand (plus its
nozzle-group assembly mates, which share one Z grid), so the
sliced-layer preview works before any G-code has been generated.
- Rotating an assembly member now notes that rotations turn about the
group's combined center - apply the same angle to every part to turn
the assembly as one unit.
- Removed the Sync Uploaded STLs and Assign Unique Valves buttons: the
uploader auto-syncs, and unique-valve assignment is automatic for new
shapes and split pieces (the assign_unique_valves logic stays, tested,
in case the button is ever wanted back).

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

Files changed (3) hide show
  1. README.md +4 -2
  2. app.py +145 -35
  3. tests/test_nozzle_spacing.py +61 -4
README.md CHANGED
@@ -54,7 +54,9 @@ Then open the local Gradio URL in your browser, upload STL files or load the bun
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
@@ -66,7 +68,7 @@ Then open the local Gradio URL in your browser, upload STL files or load the bun
66
  - Slicing is automatic: **Generate G-Code** slices every shape (fresh or stale) before writing G-code, and **Split Selected Shape into Grid Pieces** slices before splitting — "upload, then Generate G-Code" works in one click with no separate slice step
67
  - The generation status leads with the shared print path length and a time estimate at the Visualization tab's nozzle speed, and generation auto-renders the parallel view so switching to the Visualization tab always shows the current print
68
  - A **stale-G-code banner** appears above the Generate button whenever the table or any generation option changes after files were generated (each file carries a settings fingerprint), so outdated G-code is never downloaded or printed unnoticed
69
- - **Assign Unique Valves** resolves valve collisions in one click (first occurrence keeps its number; duplicates and unset valves move to the smallest unused), and **Undo Split** unwinds splits one at a time, newest first (each split pushes a snapshot onto an undo stack, kept for the last 10 splits)
70
  - Calculates X/Y nozzle spacing from a grid layout (columns/rows plus gaps) in the Visualization tab's Nozzle Spacing accordion, with an optional per-connection Advanced Grid Spacing table, then visualizes the resulting nozzle layout
71
  - Previews selected generated G-code inline
72
  - One Visualization tab that defaults to the parallel print of every generated shape (configured nozzle spacing, animated, with a server-side GIF export), and can switch to a single tool path from any generated shape or an uploaded G-code file
 
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 (per-shape slicing, reference building, then shape-by-shape generation); splitting reports the same per-shape slicing progress
58
+ - Deleting a shape can be undone: **Undo Delete** restores the removed shape with all its settings, slices, and G-code (last 10 deletes)
59
+ - **Regenerate Preview** slices the selected shape on demand (plus its assembly mates), so the sliced-layer preview works before any G-code has been generated
60
  - 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
61
  - Converts sliced layers into G-code files with pressure, valve, nozzle, port, and infill % settings per shape from the Shape Settings table
62
  - **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
 
68
  - Slicing is automatic: **Generate G-Code** slices every shape (fresh or stale) before writing G-code, and **Split Selected Shape into Grid Pieces** slices before splitting — "upload, then Generate G-Code" works in one click with no separate slice step
69
  - The generation status leads with the shared print path length and a time estimate at the Visualization tab's nozzle speed, and generation auto-renders the parallel view so switching to the Visualization tab always shows the current print
70
  - A **stale-G-code banner** appears above the Generate button whenever the table or any generation option changes after files were generated (each file carries a settings fingerprint), so outdated G-code is never downloaded or printed unnoticed
71
+ - **Undo Split** unwinds splits one at a time, newest first (each split pushes a snapshot onto an undo stack, kept for the last 10 splits)
72
  - Calculates X/Y nozzle spacing from a grid layout (columns/rows plus gaps) in the Visualization tab's Nozzle Spacing accordion, with an optional per-connection Advanced Grid Spacing table, then visualizes the resulting nozzle layout
73
  - Previews selected generated G-code inline
74
  - One Visualization tab that defaults to the parallel print of every generated shape (configured nozzle spacing, animated, with a server-side GIF export), and can switch to a single tool path from any generated shape or an uploaded G-code file
app.py CHANGED
@@ -3033,13 +3033,14 @@ def _shape_select_noop() -> tuple:
3033
  lands late); echoing records/rows here raced the dimension normalizer's
3034
  write-back — the stale echo clobbered the recomputed proportions on the
3035
  first Keep Proportions edit and re-rendered the table mid-typing."""
3036
- return tuple(gr.skip() for _ in range(9))
3037
 
3038
 
3039
  def delete_shape_from_settings(
3040
  records: list[dict] | None,
3041
  settings_table: Any | None,
3042
  last_delete_at: float | None,
 
3043
  evt: gr.SelectData,
3044
  ) -> tuple:
3045
  now = time.monotonic()
@@ -3067,11 +3068,35 @@ def delete_shape_from_settings(
3067
  next_records = _reindex_shape_records([
3068
  record for record in current_records if int(record.get("idx", 0)) != delete_idx
3069
  ])
 
 
 
 
 
 
 
 
3070
  upload_paths = [record.get("stl_path") for record in next_records if record.get("stl_path")]
3071
- return _shape_delete_outputs(
3072
- next_records,
3073
- now,
3074
- gr.update(value=upload_paths),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3075
  )
3076
 
3077
 
@@ -3655,6 +3680,14 @@ def apply_shape_rotation(
3655
  )
3656
  else:
3657
  status = f"Rotation cleared for {name}; dimensions follow back to {dims} mm."
 
 
 
 
 
 
 
 
3658
  return records, _shape_settings_rows(records), status
3659
 
3660
 
@@ -3778,6 +3811,40 @@ def apply_all_shapes_z_rotation(
3778
  return apply_all_shapes_rotation(records, settings_table, 0.0, 0.0, angle)
3779
 
3780
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3781
  def apply_shape_z_rotation(
3782
  records: list[dict] | None,
3783
  selected: str | None,
@@ -3857,7 +3924,10 @@ def update_layer_preview(
3857
  if stack is None or not getattr(stack, "layers", None):
3858
  return (
3859
  gr.update(maximum=1, value=1),
3860
- _layer_preview_message("Generate G-Code (or split) to slice this shape and see its layer outlines."),
 
 
 
3861
  )
3862
 
3863
  layer_count = len(stack.layers)
@@ -4440,6 +4510,7 @@ def split_selected_shape_for_grid(
4440
  overlapping_rows: bool = False,
4441
  overlap_size: float | None = None,
4442
  row_overlap_direction: str | None = None,
 
4443
  ) -> tuple:
4444
  records = _apply_shape_settings(records or [], settings_table)
4445
  # Overlap depth for both interlock styles; blank/zero falls back to one
@@ -4453,7 +4524,17 @@ def split_selected_shape_for_grid(
4453
  # separate slicing step.
4454
  slice_messages: list[str] = []
4455
  if records:
4456
- _ensure_records_sliced(records, float(layer_height or 0.8), scale_mode, slice_messages)
 
 
 
 
 
 
 
 
 
 
4457
  # Undo stack: each split PUSHES its pre-split records (sliced), and Undo
4458
  # Split pops them one at a time (capped to the last 10 splits). Pushed
4459
  # only when a split actually happened — every failure path returns the
@@ -4602,10 +4683,13 @@ def _ensure_records_sliced(
4602
  layer_height: float,
4603
  scale_mode: str | None,
4604
  messages: list[str],
 
4605
  ) -> bool:
4606
- """Re-slice records whose layers are missing or stale for the current settings."""
 
 
4607
  plan_by_record = _group_z_levels_by_record(records, layer_height, scale_mode)
4608
- resliced = False
4609
  for record in records:
4610
  stl_path = record.get("stl_path")
4611
  if not stl_path:
@@ -4614,6 +4698,14 @@ def _ensure_records_sliced(
4614
  current = _slice_params_snapshot(record, layer_height, scale_mode, slice_plan)
4615
  if record.get("layer_stack") is not None and record.get("slice_params") == current:
4616
  continue
 
 
 
 
 
 
 
 
4617
  try:
4618
  stack = _slice_record(record, layer_height, scale_mode, None, slice_plan)
4619
  messages.append(
@@ -4622,8 +4714,7 @@ def _ensure_records_sliced(
4622
  except Exception as exc:
4623
  record["layer_stack"] = None
4624
  messages.append(f"Shape {record['idx']}: slicing failed ({exc}).")
4625
- resliced = True
4626
- return resliced
4627
 
4628
 
4629
  def _lead_in_assembly_extension(records: list[dict], direction: str | None) -> float:
@@ -5025,7 +5116,15 @@ def generate_dynamic_gcode(
5025
  records = _apply_shape_settings(records or [], settings_table)
5026
  messages: list[str] = []
5027
  progress(0.02, desc="Slicing shapes…")
5028
- _ensure_records_sliced(records, layer_height, scale_mode, messages)
 
 
 
 
 
 
 
 
5029
  # Shared reference motion is always on: every head traces the combined
5030
  # outline and dispenses only its own geometry. Rebuilt with the CURRENT
5031
  # fil width so the alignment snap grid matches this generation.
@@ -5491,6 +5590,7 @@ def build_dynamic_demo() -> gr.Blocks:
5491
  with gr.Blocks(title="ParallelPrint: STL to G-Code", css=APP_CSS, head=APP_HEAD + TOOLPATH_ANIM_HEAD + PARALLEL_ANIM_HEAD) as demo:
5492
  shape_records = gr.State([])
5493
  last_shape_delete_at = gr.State(0.0)
 
5494
  ref_layers = gr.State(None)
5495
 
5496
  with gr.Tab("Shapes & G-Code"):
@@ -5517,9 +5617,8 @@ def build_dynamic_demo() -> gr.Blocks:
5517
  label="Sample Set",
5518
  container=False,
5519
  )
5520
- sync_uploads_button = gr.Button("Sync Uploaded STLs", variant="secondary", size="sm")
5521
  reset_dimensions_button = gr.Button("Reset Dimensions", variant="secondary", size="sm")
5522
- assign_valves_button = gr.Button("Assign Unique Valves", variant="secondary", size="sm")
5523
  scale_mode = gr.Radio(
5524
  choices=[SCALE_MODE_TARGET_DIMENSIONS, SCALE_MODE_UNIFORM_FACTOR],
5525
  value=SCALE_MODE_TARGET_DIMENSIONS,
@@ -5907,17 +6006,6 @@ def build_dynamic_demo() -> gr.Blocks:
5907
  outputs=[nozzle_grid_spacing_table],
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,
5917
- inputs=grid_spacing_refresh_inputs,
5918
- outputs=[nozzle_grid_spacing_table],
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],
@@ -5943,8 +6031,8 @@ def build_dynamic_demo() -> gr.Blocks:
5943
  )
5944
  shape_settings.select(
5945
  fn=delete_shape_from_settings,
5946
- inputs=[shape_records, shape_settings, last_shape_delete_at],
5947
- outputs=[stl_upload, *shape_sync_outputs, last_shape_delete_at],
5948
  queue=False,
5949
  ).then(
5950
  fn=refresh_split_sources,
@@ -5957,6 +6045,21 @@ def build_dynamic_demo() -> gr.Blocks:
5957
  outputs=[nozzle_grid_spacing_table],
5958
  queue=False,
5959
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5960
 
5961
  preview_inputs = [shape_records, selected_shape, shape_settings, scale_mode]
5962
  # .change is the ONLY event the Dataframe emits for cell edits (it has
@@ -5976,14 +6079,27 @@ def build_dynamic_demo() -> gr.Blocks:
5976
  queue=False,
5977
  )
5978
  selected_shape.change(fn=show_selected_model, inputs=preview_inputs, outputs=[model_viewer, model_details])
5979
- refresh_preview_button.click(fn=show_selected_model, inputs=preview_inputs, outputs=[model_viewer, model_details])
5980
  # Sliced-layer preview. The slider uses .release only: the handler
5981
  # writes the slider back (clamped max/value), and a .change wiring
5982
  # would re-fire on that programmatic write.
5983
  layer_preview_inputs = [shape_records, selected_shape, shape_settings, layer_preview_slider]
5984
  layer_preview_outputs = [layer_preview_slider, layer_preview_plot]
5985
  selected_shape.change(fn=update_layer_preview, inputs=layer_preview_inputs, outputs=layer_preview_outputs)
5986
- refresh_preview_button.click(fn=update_layer_preview, inputs=layer_preview_inputs, outputs=layer_preview_outputs)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5987
  layer_preview_slider.release(fn=update_layer_preview, inputs=layer_preview_inputs, outputs=layer_preview_outputs)
5988
  scale_mode.change(
5989
  fn=normalize_shape_dimensions_for_mode,
@@ -6100,12 +6216,6 @@ def build_dynamic_demo() -> gr.Blocks:
6100
  outputs=[split_start_nozzle, split_start_valve],
6101
  queue=False,
6102
  )
6103
- assign_valves_button.click(
6104
- fn=assign_unique_valves,
6105
- inputs=[shape_records, shape_settings],
6106
- outputs=[shape_records, shape_settings],
6107
- queue=False,
6108
- )
6109
  export_settings_button.click(
6110
  fn=export_project_settings,
6111
  inputs=[
 
3033
  lands late); echoing records/rows here raced the dimension normalizer's
3034
  write-back — the stale echo clobbered the recomputed proportions on the
3035
  first Keep Proportions edit and re-rendered the table mid-typing."""
3036
+ return tuple(gr.skip() for _ in range(10))
3037
 
3038
 
3039
  def delete_shape_from_settings(
3040
  records: list[dict] | None,
3041
  settings_table: Any | None,
3042
  last_delete_at: float | None,
3043
+ undo_stack: list[list[dict]] | None,
3044
  evt: gr.SelectData,
3045
  ) -> tuple:
3046
  now = time.monotonic()
 
3068
  next_records = _reindex_shape_records([
3069
  record for record in current_records if int(record.get("idx", 0)) != delete_idx
3070
  ])
3071
+ if len(next_records) == len(current_records):
3072
+ return _shape_select_noop()
3073
+ # Undo stack: every delete pushes the pre-delete records (settings,
3074
+ # slices, and G-code included), capped to the last 10 deletes.
3075
+ next_undo_stack = [
3076
+ *(undo_stack or []),
3077
+ [dict(record) for record in current_records],
3078
+ ][-10:]
3079
  upload_paths = [record.get("stl_path") for record in next_records if record.get("stl_path")]
3080
+ return (
3081
+ *_shape_delete_outputs(
3082
+ next_records,
3083
+ now,
3084
+ gr.update(value=upload_paths),
3085
+ ),
3086
+ next_undo_stack,
3087
+ )
3088
+
3089
+
3090
+ def undo_last_delete(undo_stack: list[list[dict]] | None) -> tuple:
3091
+ """Restore the records from before the most recent delete."""
3092
+ stack = list(undo_stack or [])
3093
+ if not stack:
3094
+ return _shape_select_noop()
3095
+ restored = [dict(record) for record in stack.pop()]
3096
+ upload_paths = [record.get("stl_path") for record in restored if record.get("stl_path")]
3097
+ return (
3098
+ *_shape_delete_outputs(restored, None, gr.update(value=upload_paths)),
3099
+ stack,
3100
  )
3101
 
3102
 
 
3680
  )
3681
  else:
3682
  status = f"Rotation cleared for {name}; dimensions follow back to {dims} mm."
3683
+ nozzle = _record_nozzle_number(record, int(record.get("idx", pos + 1) or (pos + 1)))
3684
+ group_members = _multi_material_groups(records).get(nozzle)
3685
+ if group_members and len(group_members) > 1:
3686
+ status += (
3687
+ f" \n{name} is part of the nozzle {nozzle} multi-material assembly: "
3688
+ "rotations turn about the group's combined center, so apply the SAME "
3689
+ "angle to every part to turn the whole assembly as one unit."
3690
+ )
3691
  return records, _shape_settings_rows(records), status
3692
 
3693
 
 
3811
  return apply_all_shapes_rotation(records, settings_table, 0.0, 0.0, angle)
3812
 
3813
 
3814
+ def slice_selected_shape_for_preview(
3815
+ records: list[dict] | None,
3816
+ selected: str | None,
3817
+ settings_table: Any,
3818
+ layer_height: float,
3819
+ scale_mode: str | None,
3820
+ progress: gr.Progress = gr.Progress(),
3821
+ ) -> list[dict]:
3822
+ """Regenerate Preview slices the SELECTED shape on demand (plus its
3823
+ nozzle-group assembly mates, which must share one Z grid) — the layer
3824
+ preview works before any G-code has been generated."""
3825
+ records = _apply_shape_settings(records or [], settings_table)
3826
+ pos = _selected_record_index(records, selected)
3827
+ if pos < 0:
3828
+ return records
3829
+ record = records[pos]
3830
+ if not record.get("stl_path"):
3831
+ return records
3832
+ nozzle = _record_nozzle_number(record, int(record.get("idx", pos + 1) or (pos + 1)))
3833
+ group = _multi_material_groups(records).get(nozzle) or [record]
3834
+ messages: list[str] = []
3835
+ progress(0.1, desc="Slicing for preview…")
3836
+ _ensure_records_sliced(
3837
+ group,
3838
+ float(layer_height or 0.8),
3839
+ scale_mode,
3840
+ messages,
3841
+ progress_callback=lambda done, total, name: progress(
3842
+ 0.1 + 0.8 * done / max(1, total), desc=f"Slicing {name}…"
3843
+ ),
3844
+ )
3845
+ return records
3846
+
3847
+
3848
  def apply_shape_z_rotation(
3849
  records: list[dict] | None,
3850
  selected: str | None,
 
3924
  if stack is None or not getattr(stack, "layers", None):
3925
  return (
3926
  gr.update(maximum=1, value=1),
3927
+ _layer_preview_message(
3928
+ "Press Regenerate Preview to slice this shape and see its layer "
3929
+ "outlines. (Generate G-Code and splits slice automatically too.)"
3930
+ ),
3931
  )
3932
 
3933
  layer_count = len(stack.layers)
 
4510
  overlapping_rows: bool = False,
4511
  overlap_size: float | None = None,
4512
  row_overlap_direction: str | None = None,
4513
+ progress: gr.Progress = gr.Progress(),
4514
  ) -> tuple:
4515
  records = _apply_shape_settings(records or [], settings_table)
4516
  # Overlap depth for both interlock styles; blank/zero falls back to one
 
4524
  # separate slicing step.
4525
  slice_messages: list[str] = []
4526
  if records:
4527
+ progress(0.02, desc="Checking slices…")
4528
+ _ensure_records_sliced(
4529
+ records,
4530
+ float(layer_height or 0.8),
4531
+ scale_mode,
4532
+ slice_messages,
4533
+ progress_callback=lambda done, total, name: progress(
4534
+ 0.05 + 0.7 * done / max(1, total), desc=f"Slicing {name}…"
4535
+ ),
4536
+ )
4537
+ progress(0.8, desc="Splitting into grid pieces…")
4538
  # Undo stack: each split PUSHES its pre-split records (sliced), and Undo
4539
  # Split pops them one at a time (capped to the last 10 splits). Pushed
4540
  # only when a split actually happened — every failure path returns the
 
4683
  layer_height: float,
4684
  scale_mode: str | None,
4685
  messages: list[str],
4686
+ progress_callback=None,
4687
  ) -> bool:
4688
+ """Re-slice records whose layers are missing or stale for the current
4689
+ settings. `progress_callback(done, total, name)` is called before each
4690
+ actual re-slice so callers can show per-shape progress."""
4691
  plan_by_record = _group_z_levels_by_record(records, layer_height, scale_mode)
4692
+ stale: list[tuple[dict, tuple | None]] = []
4693
  for record in records:
4694
  stl_path = record.get("stl_path")
4695
  if not stl_path:
 
4698
  current = _slice_params_snapshot(record, layer_height, scale_mode, slice_plan)
4699
  if record.get("layer_stack") is not None and record.get("slice_params") == current:
4700
  continue
4701
+ stale.append((record, slice_plan))
4702
+ for position, (record, slice_plan) in enumerate(stale):
4703
+ if progress_callback is not None:
4704
+ progress_callback(
4705
+ position,
4706
+ len(stale),
4707
+ str(record.get("name") or f"Shape {record.get('idx')}"),
4708
+ )
4709
  try:
4710
  stack = _slice_record(record, layer_height, scale_mode, None, slice_plan)
4711
  messages.append(
 
4714
  except Exception as exc:
4715
  record["layer_stack"] = None
4716
  messages.append(f"Shape {record['idx']}: slicing failed ({exc}).")
4717
+ return bool(stale)
 
4718
 
4719
 
4720
  def _lead_in_assembly_extension(records: list[dict], direction: str | None) -> float:
 
5116
  records = _apply_shape_settings(records or [], settings_table)
5117
  messages: list[str] = []
5118
  progress(0.02, desc="Slicing shapes…")
5119
+ _ensure_records_sliced(
5120
+ records,
5121
+ layer_height,
5122
+ scale_mode,
5123
+ messages,
5124
+ progress_callback=lambda done, total, name: progress(
5125
+ 0.02 + 0.09 * done / max(1, total), desc=f"Slicing {name}…"
5126
+ ),
5127
+ )
5128
  # Shared reference motion is always on: every head traces the combined
5129
  # outline and dispenses only its own geometry. Rebuilt with the CURRENT
5130
  # fil width so the alignment snap grid matches this generation.
 
5590
  with gr.Blocks(title="ParallelPrint: STL to G-Code", css=APP_CSS, head=APP_HEAD + TOOLPATH_ANIM_HEAD + PARALLEL_ANIM_HEAD) as demo:
5591
  shape_records = gr.State([])
5592
  last_shape_delete_at = gr.State(0.0)
5593
+ delete_undo = gr.State(None)
5594
  ref_layers = gr.State(None)
5595
 
5596
  with gr.Tab("Shapes & G-Code"):
 
5617
  label="Sample Set",
5618
  container=False,
5619
  )
 
5620
  reset_dimensions_button = gr.Button("Reset Dimensions", variant="secondary", size="sm")
5621
+ undo_delete_button = gr.Button("Undo Delete", variant="secondary", size="sm")
5622
  scale_mode = gr.Radio(
5623
  choices=[SCALE_MODE_TARGET_DIMENSIONS, SCALE_MODE_UNIFORM_FACTOR],
5624
  value=SCALE_MODE_TARGET_DIMENSIONS,
 
6006
  outputs=[nozzle_grid_spacing_table],
6007
  queue=False,
6008
  )
 
 
 
 
 
 
 
 
 
 
 
6009
  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(
6010
  fn=refresh_split_sources,
6011
  inputs=[shape_records],
 
6031
  )
6032
  shape_settings.select(
6033
  fn=delete_shape_from_settings,
6034
+ inputs=[shape_records, shape_settings, last_shape_delete_at, delete_undo],
6035
+ outputs=[stl_upload, *shape_sync_outputs, last_shape_delete_at, delete_undo],
6036
  queue=False,
6037
  ).then(
6038
  fn=refresh_split_sources,
 
6045
  outputs=[nozzle_grid_spacing_table],
6046
  queue=False,
6047
  )
6048
+ undo_delete_button.click(
6049
+ fn=undo_last_delete,
6050
+ inputs=[delete_undo],
6051
+ outputs=[stl_upload, *shape_sync_outputs, last_shape_delete_at, delete_undo],
6052
+ ).then(
6053
+ fn=refresh_split_sources,
6054
+ inputs=[shape_records],
6055
+ outputs=[split_source, split_start_nozzle, split_start_valve],
6056
+ queue=False,
6057
+ ).then(
6058
+ fn=_grid_spacing_table_update,
6059
+ inputs=grid_spacing_refresh_inputs,
6060
+ outputs=[nozzle_grid_spacing_table],
6061
+ queue=False,
6062
+ )
6063
 
6064
  preview_inputs = [shape_records, selected_shape, shape_settings, scale_mode]
6065
  # .change is the ONLY event the Dataframe emits for cell edits (it has
 
6079
  queue=False,
6080
  )
6081
  selected_shape.change(fn=show_selected_model, inputs=preview_inputs, outputs=[model_viewer, model_details])
 
6082
  # Sliced-layer preview. The slider uses .release only: the handler
6083
  # writes the slider back (clamped max/value), and a .change wiring
6084
  # would re-fire on that programmatic write.
6085
  layer_preview_inputs = [shape_records, selected_shape, shape_settings, layer_preview_slider]
6086
  layer_preview_outputs = [layer_preview_slider, layer_preview_plot]
6087
  selected_shape.change(fn=update_layer_preview, inputs=layer_preview_inputs, outputs=layer_preview_outputs)
6088
+ # Regenerate Preview slices the selected shape on demand first, so
6089
+ # the layer preview works before any G-code has been generated.
6090
+ refresh_preview_button.click(
6091
+ fn=slice_selected_shape_for_preview,
6092
+ inputs=[shape_records, selected_shape, shape_settings, layer_height, scale_mode],
6093
+ outputs=[shape_records],
6094
+ ).then(
6095
+ fn=show_selected_model,
6096
+ inputs=preview_inputs,
6097
+ outputs=[model_viewer, model_details],
6098
+ ).then(
6099
+ fn=update_layer_preview,
6100
+ inputs=layer_preview_inputs,
6101
+ outputs=layer_preview_outputs,
6102
+ )
6103
  layer_preview_slider.release(fn=update_layer_preview, inputs=layer_preview_inputs, outputs=layer_preview_outputs)
6104
  scale_mode.change(
6105
  fn=normalize_shape_dimensions_for_mode,
 
6216
  outputs=[split_start_nozzle, split_start_valve],
6217
  queue=False,
6218
  )
 
 
 
 
 
 
6219
  export_settings_button.click(
6220
  fn=export_project_settings,
6221
  inputs=[
tests/test_nozzle_spacing.py CHANGED
@@ -975,7 +975,7 @@ def test_delete_shape_reindexes_without_losing_shape_data() -> None:
975
  {"idx": 3, "name": "last", "stl_path": "last.stl", "target_x": 30.0, "target_y": 31.0, "target_z": 32.0, "pressure": 35, "valve": 6, "port": 3, "color": "#333333"},
976
  ]
977
 
978
- outputs = delete_shape_from_settings(records, _shape_settings_rows(records), 0.0, Event())
979
  updated_records = outputs[1]
980
  updated_settings = outputs[2]
981
 
@@ -986,6 +986,21 @@ def test_delete_shape_reindexes_without_losing_shape_data() -> None:
986
  assert updated_settings[1][1] == "last"
987
  assert updated_settings[1][2:5] == [30.0, 31.0, 32.0]
988
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
989
 
990
  def test_delete_shape_cooldown_blocks_immediate_second_delete() -> None:
991
  class Event:
@@ -997,12 +1012,13 @@ def test_delete_shape_cooldown_blocks_immediate_second_delete() -> None:
997
  {"idx": 3, "name": "last", "stl_path": "last.stl", "target_x": 30.0, "target_y": 31.0, "target_z": 32.0, "pressure": 35, "valve": 6, "port": 3, "color": "#333333"},
998
  ]
999
 
1000
- first_outputs = delete_shape_from_settings(records, _shape_settings_rows(records), 0.0, Event())
1001
  assert [record["name"] for record in first_outputs[1]] == ["first", "last"]
1002
 
1003
  second_outputs = delete_shape_from_settings(
1004
  first_outputs[1],
1005
  first_outputs[2],
 
1006
  first_outputs[-1],
1007
  Event(),
1008
  )
@@ -1022,9 +1038,9 @@ def test_non_delete_cell_selection_touches_nothing() -> None:
1022
  {"idx": 1, "name": "first", "stl_path": "first.stl", "target_x": 10.0, "target_y": 11.0, "target_z": 12.0, "pressure": 25, "valve": 4, "port": 1, "color": "#111111"},
1023
  ]
1024
 
1025
- outputs = delete_shape_from_settings(records, _shape_settings_rows(records), 0.0, Event())
1026
 
1027
- assert len(outputs) == 9
1028
  assert all(not isinstance(value, list) for value in outputs)
1029
  assert not isinstance(outputs[1], list) # records State untouched
1030
 
@@ -1904,6 +1920,47 @@ def test_rotation_marker_shows_in_table_and_strips_on_read() -> 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
 
 
975
  {"idx": 3, "name": "last", "stl_path": "last.stl", "target_x": 30.0, "target_y": 31.0, "target_z": 32.0, "pressure": 35, "valve": 6, "port": 3, "color": "#333333"},
976
  ]
977
 
978
+ outputs = delete_shape_from_settings(records, _shape_settings_rows(records), 0.0, None, Event())
979
  updated_records = outputs[1]
980
  updated_settings = outputs[2]
981
 
 
986
  assert updated_settings[1][1] == "last"
987
  assert updated_settings[1][2:5] == [30.0, 31.0, 32.0]
988
 
989
+ # The delete pushed an undo snapshot; Undo Delete restores everything.
990
+ from app import undo_last_delete
991
+
992
+ undo_stack = outputs[-1]
993
+ assert isinstance(undo_stack, list) and len(undo_stack) == 1
994
+ restored_outputs = undo_last_delete(undo_stack)
995
+ restored = restored_outputs[1]
996
+ assert [record["name"] for record in restored] == ["first", "middle", "last"]
997
+ assert restored[1]["pressure"] == 30
998
+ assert restored_outputs[-1] == [] # stack popped
999
+
1000
+ # Undo with an empty stack touches nothing.
1001
+ empty = undo_last_delete([])
1002
+ assert not isinstance(empty[1], list)
1003
+
1004
 
1005
  def test_delete_shape_cooldown_blocks_immediate_second_delete() -> None:
1006
  class Event:
 
1012
  {"idx": 3, "name": "last", "stl_path": "last.stl", "target_x": 30.0, "target_y": 31.0, "target_z": 32.0, "pressure": 35, "valve": 6, "port": 3, "color": "#333333"},
1013
  ]
1014
 
1015
+ first_outputs = delete_shape_from_settings(records, _shape_settings_rows(records), 0.0, None, Event())
1016
  assert [record["name"] for record in first_outputs[1]] == ["first", "last"]
1017
 
1018
  second_outputs = delete_shape_from_settings(
1019
  first_outputs[1],
1020
  first_outputs[2],
1021
+ first_outputs[-2], # last_delete_at (the undo stack is now last)
1022
  first_outputs[-1],
1023
  Event(),
1024
  )
 
1038
  {"idx": 1, "name": "first", "stl_path": "first.stl", "target_x": 10.0, "target_y": 11.0, "target_z": 12.0, "pressure": 25, "valve": 4, "port": 1, "color": "#111111"},
1039
  ]
1040
 
1041
+ outputs = delete_shape_from_settings(records, _shape_settings_rows(records), 0.0, None, Event())
1042
 
1043
+ assert len(outputs) == 10
1044
  assert all(not isinstance(value, list) for value in outputs)
1045
  assert not isinstance(outputs[1], list) # records State untouched
1046
 
 
1920
  assert _display_shape_name(record) == "bar"
1921
 
1922
 
1923
+ def test_regenerate_preview_slices_the_selected_shape_on_demand(tmp_path) -> None:
1924
+ import trimesh
1925
+
1926
+ from app import slice_selected_shape_for_preview
1927
+
1928
+ trimesh.creation.box(extents=(4.0, 4.0, 2.0)).export(tmp_path / "cube.stl")
1929
+ records = _records_from_files([str(tmp_path / "cube.stl")], None)
1930
+ assert records[0].get("layer_stack") is None
1931
+
1932
+ updated = slice_selected_shape_for_preview(records, None, None, 1.0, None)
1933
+ stack = updated[0].get("layer_stack")
1934
+ assert stack is not None and len(stack.layers) == 2
1935
+
1936
+ # Split pieces (no STL) are a no-op.
1937
+ piece = [{"idx": 1, "name": "piece", "stl_path": None, "split_group_id": "s"}]
1938
+ assert slice_selected_shape_for_preview(piece, None, None, 1.0, None) == piece
1939
+
1940
+
1941
+ def test_rotation_status_notes_assembly_membership(tmp_path) -> None:
1942
+ import trimesh
1943
+
1944
+ from app import apply_shape_z_rotation
1945
+
1946
+ for name in ("inner", "outer"):
1947
+ trimesh.creation.box(extents=(4.0, 4.0, 2.0)).export(tmp_path / f"{name}.stl")
1948
+ records = _records_from_files(
1949
+ [str(tmp_path / "inner.stl"), str(tmp_path / "outer.stl")], None
1950
+ )
1951
+ # Same nozzle -> a multi-material assembly.
1952
+ records[0]["nozzle"] = records[1]["nozzle"] = 1
1953
+
1954
+ _updated, _rows, status = apply_shape_z_rotation(records, None, None, 90)
1955
+ assert "multi-material assembly" in status
1956
+ assert "SAME angle" in status
1957
+
1958
+ # A solo shape gets no assembly note.
1959
+ solo = _records_from_files([str(tmp_path / "inner.stl")], None)
1960
+ _updated, _rows, solo_status = apply_shape_z_rotation(solo, None, None, 90)
1961
+ assert "assembly" not in solo_status
1962
+
1963
+
1964
  def test_rotate_all_shapes_at_once(tmp_path) -> None:
1965
  import trimesh
1966