CyGuy8 Claude Fable 5 commited on
Commit
c1a6699
·
1 Parent(s): 6041550

Merge slicing into one tab and one button: Generate G-Code slices, Split slices

Browse files

- The Shapes & Slicing and Generate G-Code tabs are one "Shapes & G-Code"
tab: upload, table, split and preview accordions, then the generation
options, button, downloads, and G-code preview in one flow (the app is
now two tabs: Shapes & G-Code, Visualization)
- The Slice Shapes button is gone. Generate G-Code slices every shape
from the current table settings before writing G-code, and Split
Selected Shape into Grid Pieces slices before splitting (same shared
Z-grid group plans), so the whole workflow is one button - two with a
split. The layer preview and split-source refreshes moved from the old
slice chain onto the generate and split chains
- Status texts that pointed at the removed button/tabs reworded; README
updated

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

Files changed (3) hide show
  1. README.md +5 -5
  2. app.py +45 -35
  3. tests/test_nozzle_spacing.py +6 -0
README.md CHANGED
@@ -33,7 +33,7 @@ uv run gradio app.py
33
 
34
  When `app.py` changes, Gradio will automatically rerun the file and refresh the demo.
35
 
36
- Then open the local Gradio URL in your browser, upload STL files or load the bundled samples, and slice the shapes.
37
 
38
  ## What the app does
39
 
@@ -54,7 +54,7 @@ Then open the local Gradio URL in your browser, upload STL files or load the bun
54
  - **Infill %** per shape skips dispensing on evenly-distributed raster lines (rings/revolutions for spiral patterns) — at 50% every other line prints — while the motion path stays exactly the same, so parallel shapes with different infill still share one print path
55
  - Appends a shape outline contour after each enabled shape layer by tracing that layer's polygon boundary
56
  - Offers a choice of raster pattern for G-code generation; all shapes always share one combined reference outline for motion (one nozzle path; each dispenses only its own geometry), and every move is emitted as `G1` at one constant speed (no `G0` rapid travel)
57
- - Re-slices shapes automatically during G-code generation when their slices are missing or stale, so "upload, then Generate G-Code" works in one click
58
  - 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
59
  - Previews selected generated G-code inline
60
  - 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
@@ -69,7 +69,7 @@ Slicing uses `trimesh` cross-sections composed into shapely polygons (`slice_stl
69
 
70
  ### Reference Layer Union
71
 
72
- When you click **Slice Shapes**, the app automatically unions the sliced shapes layer-by-layer into a combined reference layer set (used for shared-motion G-code).
73
 
74
  - Shapes are aligned by centering each shape's XY bounding box on a common center before the union.
75
  - Alignment is centered placement (in exact millimetres), not bottom-left anchoring.
@@ -89,11 +89,11 @@ For a multi-material object exported as separate STLs (one per material), give e
89
 
90
  ### Multi-Nozzle Split
91
 
92
- The **Multi-Nozzle Split** accordion on the **Shapes & Slicing** tab can split one sliced shape into a grid of print-ready piece stacks. Choose a source shape that has been sliced, set the number of columns and rows, choose the starting nozzle and valve numbers, then click **Split Selected Shape into Grid Pieces**.
93
 
94
  - Each layer's geometry is clipped against equal-size grid cells, so every piece keeps exact vector outlines.
95
  - The selected shape is replaced in Shape Settings by one generated record per grid cell, named by row and column.
96
- - Nozzle and valve numbers are assigned sequentially from the starting values, and the **Generate G-Code** tab can generate separate G-code for each piece.
97
  - **Overlapping Layers** alternates the interior cut lines by one filament width per layer so neighbouring pieces interlock.
98
  - **Multi-material assemblies split as one shape**: if the selected shape shares its nozzle with other shapes, the whole group is split together — every material is clipped by the same cell grid over the group's combined bounds. Pieces are emitted cell by cell: each cell's pieces share a nozzle (so every cell is itself a multi-material group, keeping the alignment and seam-free contour behavior), each piece gets its own valve, and cells where a material has no geometry are skipped. Auto Align works on the result like any other split.
99
 
 
33
 
34
  When `app.py` changes, Gradio will automatically rerun the file and refresh the demo.
35
 
36
+ Then open the local Gradio URL in your browser, upload STL files or load the bundled samples, and press **Generate G-Code**.
37
 
38
  ## What the app does
39
 
 
54
  - **Infill %** per shape skips dispensing on evenly-distributed raster lines (rings/revolutions for spiral patterns) — at 50% every other line prints — while the motion path stays exactly the same, so parallel shapes with different infill still share one print path
55
  - Appends a shape outline contour after each enabled shape layer by tracing that layer's polygon boundary
56
  - Offers a choice of raster pattern for G-code generation; all shapes always share one combined reference outline for motion (one nozzle path; each dispenses only its own geometry), and every move is emitted as `G1` at one constant speed (no `G0` rapid travel)
57
+ - 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
58
  - 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
59
  - Previews selected generated G-code inline
60
  - 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
 
69
 
70
  ### Reference Layer Union
71
 
72
+ Whenever shapes are sliced (automatically, during G-code generation or a split), the app unions the sliced shapes layer-by-layer into a combined reference layer set (used for shared-motion G-code).
73
 
74
  - Shapes are aligned by centering each shape's XY bounding box on a common center before the union.
75
  - Alignment is centered placement (in exact millimetres), not bottom-left anchoring.
 
89
 
90
  ### Multi-Nozzle Split
91
 
92
+ The **Multi-Nozzle Split** accordion on the **Shapes & G-Code** tab can split one shape into a grid of print-ready piece stacks. Choose a source shape, set the number of columns and rows, choose the starting nozzle and valve numbers, then click **Split Selected Shape into Grid Pieces** — the shapes are sliced automatically first if needed.
93
 
94
  - Each layer's geometry is clipped against equal-size grid cells, so every piece keeps exact vector outlines.
95
  - The selected shape is replaced in Shape Settings by one generated record per grid cell, named by row and column.
96
+ - Nozzle and valve numbers are assigned sequentially from the starting values, and **Generate G-Code** produces separate G-code for each piece.
97
  - **Overlapping Layers** alternates the interior cut lines by one filament width per layer so neighbouring pieces interlock.
98
  - **Multi-material assemblies split as one shape**: if the selected shape shares its nozzle with other shapes, the whole group is split together — every material is clipped by the same cell grid over the group's combined bounds. Pieces are emitted cell by cell: each cell's pieces share a nozzle (so every cell is itself a multi-material group, keeping the alignment and seam-free contour behavior), each piece gets its own valve, and cells where a material has no geometry are skipped. Auto Align works on the result like any other split.
99
 
app.py CHANGED
@@ -2947,7 +2947,7 @@ def update_layer_preview(
2947
  if stack is None or not getattr(stack, "layers", None):
2948
  return (
2949
  gr.update(maximum=1, value=1),
2950
- _layer_preview_message("Slice Shapes to see this shape's layer outlines."),
2951
  )
2952
 
2953
  layer_count = len(stack.layers)
@@ -3291,9 +3291,10 @@ def generate_dynamic_reference_stack(
3291
 
3292
 
3293
  SPLIT_STATUS_DEFAULT = (
3294
- "Slice a shape, then split it for multi-nozzle printing. Shapes that share "
3295
- "a nozzle number are one multi-material assembly: selecting any of them "
3296
- "splits the **whole group** together as one shape."
 
3297
  )
3298
 
3299
 
@@ -3353,8 +3354,8 @@ def _split_group_records(
3353
  return _outputs(
3354
  records,
3355
  selected,
3356
- f"Split failed: shape(s) on nozzle {group_nozzle} have no sliced layers yet - "
3357
- "press Slice Shapes first so the group shares one Z grid.",
3358
  )
3359
  stacks = [member["layer_stack"] for member in group_members]
3360
  layer_counts = {len(stack.layers) for stack in stacks}
@@ -3364,7 +3365,7 @@ def _split_group_records(
3364
  records,
3365
  selected,
3366
  f"Split failed: the shapes on nozzle {group_nozzle} were sliced on different "
3367
- "Z grids - press Slice Shapes to re-slice the group together.",
3368
  )
3369
 
3370
  # Stamp group frames + seam-free contours so pieces inherit contour
@@ -3468,10 +3469,20 @@ def split_selected_shape_for_grid(
3468
  starting_nozzle: float,
3469
  starting_valve: float,
3470
  fil_width: float,
 
 
3471
  ) -> tuple:
3472
  records = _apply_shape_settings(records or [], settings_table)
 
 
 
 
 
 
3473
 
3474
  def _outputs(next_records: list[dict], selected_value: str | None, status: str) -> tuple:
 
 
3475
  return (
3476
  next_records,
3477
  _shape_settings_rows(next_records),
@@ -3485,7 +3496,7 @@ def split_selected_shape_for_grid(
3485
  )
3486
 
3487
  if not records:
3488
- return _outputs(records, None, "Slice a shape before splitting it.")
3489
 
3490
  pos = _selected_record_index(records, selected)
3491
  if pos < 0:
@@ -3496,7 +3507,7 @@ def split_selected_shape_for_grid(
3496
  return _outputs(
3497
  records,
3498
  selected,
3499
- f"Split failed: Shape {source.get('idx', pos + 1)} has no sliced layers yet - press Slice Shapes first.",
3500
  )
3501
 
3502
  split_column_count = max(1, _coerce_int(columns, 2))
@@ -3781,7 +3792,7 @@ def _parts_from_records(records: list[dict] | None) -> tuple[list[dict], list[st
3781
  path = record.get("gcode_path")
3782
  idx = int(record.get("idx", len(parts) + 1))
3783
  if not path:
3784
- messages.append(f"Shape {idx}: no G-code (generate it on the Generate G-Code tab).")
3785
  continue
3786
  try:
3787
  parsed = parse_gcode_path(Path(path).read_text())
@@ -3907,7 +3918,7 @@ def render_dynamic_parallel(
3907
  records = _apply_shape_settings(records or [], settings_table)
3908
  parts, messages = _parts_from_records(records)
3909
  if not parts:
3910
- return None, "No shape G-code available. Generate G-code on the Generate G-Code tab first."
3911
  offsets, spacings = _resolve_nozzle_grid_layout(
3912
  parts,
3913
  columns,
@@ -3998,11 +4009,11 @@ def build_dynamic_demo() -> gr.Blocks:
3998
  last_shape_delete_at = gr.State(0.0)
3999
  ref_layers = gr.State(None)
4000
 
4001
- with gr.Tab("Shapes & Slicing"):
4002
  gr.Markdown(
4003
  """
4004
- # Shapes & Slicing
4005
- Upload any number of STL files, edit per-shape dimensions and print settings in the table, then slice each shape into per-layer outlines.
4006
  Pick each shape's plot color straight from the dropdown in the **Color** column.
4007
  """
4008
  )
@@ -4068,7 +4079,6 @@ def build_dynamic_demo() -> gr.Blocks:
4068
  with gr.Row():
4069
  layer_height = gr.Number(label="Layer Height (mm)", value=0.8, minimum=0.0001, step=0.01)
4070
  fil_width = gr.Number(label="Filament/Line Width (mm)", value=0.8, minimum=0.0001, step=0.01)
4071
- generate_button = gr.Button("Slice Shapes", variant="primary")
4072
 
4073
  with gr.Accordion("Multi-Nozzle Split", open=False, elem_classes=["settings-accordion"]):
4074
  with gr.Row():
@@ -4109,13 +4119,11 @@ def build_dynamic_demo() -> gr.Blocks:
4109
  with gr.Column(scale=1, min_width=300):
4110
  model_details = gr.Markdown("No model loaded.")
4111
 
4112
- slicer_status = gr.Markdown("")
4113
-
4114
- with gr.Tab("Generate G-Code"):
4115
  gr.Markdown(
4116
  """
4117
- # Generate G-Code
4118
- Generate G-code for every sliced shape. Pressure, valve, nozzle, port, and color come from the Shape Settings table.
 
4119
  All shapes share one combined nozzle path (each dispenses only its own geometry), so parallel heads always stay in sync.
4120
  """
4121
  )
@@ -4380,21 +4388,6 @@ def build_dynamic_demo() -> gr.Blocks:
4380
  outputs=[model_viewer, model_details],
4381
  )
4382
 
4383
- generate_button.click(
4384
- fn=generate_dynamic_layer_stacks,
4385
- inputs=[shape_records, shape_settings, layer_height, scale_mode, fil_width],
4386
- outputs=[shape_records, slicer_status, ref_layers],
4387
- ).then(
4388
- fn=update_layer_preview,
4389
- inputs=layer_preview_inputs,
4390
- outputs=layer_preview_outputs,
4391
- ).then(
4392
- fn=lambda records: _dropdown_update(records),
4393
- inputs=[shape_records],
4394
- outputs=[split_source],
4395
- queue=False,
4396
- )
4397
-
4398
  split_refresh_sources.click(fn=lambda records: _dropdown_update(records), inputs=[shape_records], outputs=[split_source], queue=False)
4399
  # .input (user selections only): programmatic dropdown refills after a
4400
  # split must not overwrite the split result message.
@@ -4416,6 +4409,8 @@ def build_dynamic_demo() -> gr.Blocks:
4416
  split_start_nozzle,
4417
  split_start_valve,
4418
  fil_width,
 
 
4419
  ],
4420
  outputs=[
4421
  shape_records,
@@ -4432,6 +4427,10 @@ def build_dynamic_demo() -> gr.Blocks:
4432
  fn=generate_dynamic_reference_stack,
4433
  inputs=[shape_records, fil_width],
4434
  outputs=[ref_layers],
 
 
 
 
4435
  ).then(
4436
  fn=_grid_spacing_table_update,
4437
  inputs=grid_spacing_refresh_inputs,
@@ -4459,6 +4458,17 @@ def build_dynamic_demo() -> gr.Blocks:
4459
  fn=load_selected_gcode_text,
4460
  inputs=[shape_records, gcode_text_source],
4461
  outputs=[gcode_text],
 
 
 
 
 
 
 
 
 
 
 
4462
  )
4463
  gcode_text_source.change(fn=load_selected_gcode_text, inputs=[shape_records, gcode_text_source], outputs=[gcode_text])
4464
  refresh_gcode_text_button.click(fn=load_selected_gcode_text, inputs=[shape_records, gcode_text_source], outputs=[gcode_text])
 
2947
  if stack is None or not getattr(stack, "layers", None):
2948
  return (
2949
  gr.update(maximum=1, value=1),
2950
+ _layer_preview_message("Generate G-Code (or split) to slice this shape and see its layer outlines."),
2951
  )
2952
 
2953
  layer_count = len(stack.layers)
 
3291
 
3292
 
3293
  SPLIT_STATUS_DEFAULT = (
3294
+ "Pick a shape to split for multi-nozzle printing - shapes are sliced "
3295
+ "automatically when you split. Shapes that share a nozzle number are one "
3296
+ "multi-material assembly: selecting any of them splits the **whole "
3297
+ "group** together as one shape."
3298
  )
3299
 
3300
 
 
3354
  return _outputs(
3355
  records,
3356
  selected,
3357
+ f"Split failed: shape(s) on nozzle {group_nozzle} could not be sliced - "
3358
+ "check their STLs.",
3359
  )
3360
  stacks = [member["layer_stack"] for member in group_members]
3361
  layer_counts = {len(stack.layers) for stack in stacks}
 
3365
  records,
3366
  selected,
3367
  f"Split failed: the shapes on nozzle {group_nozzle} were sliced on different "
3368
+ "Z grids - edit a dimension (or nozzle) so the group re-slices together.",
3369
  )
3370
 
3371
  # Stamp group frames + seam-free contours so pieces inherit contour
 
3469
  starting_nozzle: float,
3470
  starting_valve: float,
3471
  fil_width: float,
3472
+ layer_height: float = 0.8,
3473
+ scale_mode: str | None = None,
3474
  ) -> tuple:
3475
  records = _apply_shape_settings(records or [], settings_table)
3476
+ # One-button flow: shapes are sliced (or re-sliced when their settings
3477
+ # changed) straight from the table here, so splitting never needs a
3478
+ # separate slicing step.
3479
+ slice_messages: list[str] = []
3480
+ if records:
3481
+ _ensure_records_sliced(records, float(layer_height or 0.8), scale_mode, slice_messages)
3482
 
3483
  def _outputs(next_records: list[dict], selected_value: str | None, status: str) -> tuple:
3484
+ if slice_messages:
3485
+ status = " \n".join([*slice_messages, status])
3486
  return (
3487
  next_records,
3488
  _shape_settings_rows(next_records),
 
3496
  )
3497
 
3498
  if not records:
3499
+ return _outputs(records, None, "Add a shape before splitting it.")
3500
 
3501
  pos = _selected_record_index(records, selected)
3502
  if pos < 0:
 
3507
  return _outputs(
3508
  records,
3509
  selected,
3510
+ f"Split failed: Shape {source.get('idx', pos + 1)} could not be sliced - check its STL.",
3511
  )
3512
 
3513
  split_column_count = max(1, _coerce_int(columns, 2))
 
3792
  path = record.get("gcode_path")
3793
  idx = int(record.get("idx", len(parts) + 1))
3794
  if not path:
3795
+ messages.append(f"Shape {idx}: no G-code (press Generate G-Code on the Shapes & G-Code tab).")
3796
  continue
3797
  try:
3798
  parsed = parse_gcode_path(Path(path).read_text())
 
3918
  records = _apply_shape_settings(records or [], settings_table)
3919
  parts, messages = _parts_from_records(records)
3920
  if not parts:
3921
+ return None, "No shape G-code available. Press Generate G-Code on the Shapes & G-Code tab first."
3922
  offsets, spacings = _resolve_nozzle_grid_layout(
3923
  parts,
3924
  columns,
 
4009
  last_shape_delete_at = gr.State(0.0)
4010
  ref_layers = gr.State(None)
4011
 
4012
+ with gr.Tab("Shapes & G-Code"):
4013
  gr.Markdown(
4014
  """
4015
+ # Shapes & G-Code
4016
+ Upload any number of STL files, edit per-shape dimensions and print settings in the table, then press **Generate G-Code** below - every shape is sliced automatically as part of generation (and before a split).
4017
  Pick each shape's plot color straight from the dropdown in the **Color** column.
4018
  """
4019
  )
 
4079
  with gr.Row():
4080
  layer_height = gr.Number(label="Layer Height (mm)", value=0.8, minimum=0.0001, step=0.01)
4081
  fil_width = gr.Number(label="Filament/Line Width (mm)", value=0.8, minimum=0.0001, step=0.01)
 
4082
 
4083
  with gr.Accordion("Multi-Nozzle Split", open=False, elem_classes=["settings-accordion"]):
4084
  with gr.Row():
 
4119
  with gr.Column(scale=1, min_width=300):
4120
  model_details = gr.Markdown("No model loaded.")
4121
 
 
 
 
4122
  gr.Markdown(
4123
  """
4124
+ ---
4125
+ ### Generate G-Code
4126
+ One button slices every shape from the current table settings and writes its G-code. Pressure, valve, nozzle, port, and color come from the Shape Settings table.
4127
  All shapes share one combined nozzle path (each dispenses only its own geometry), so parallel heads always stay in sync.
4128
  """
4129
  )
 
4388
  outputs=[model_viewer, model_details],
4389
  )
4390
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4391
  split_refresh_sources.click(fn=lambda records: _dropdown_update(records), inputs=[shape_records], outputs=[split_source], queue=False)
4392
  # .input (user selections only): programmatic dropdown refills after a
4393
  # split must not overwrite the split result message.
 
4409
  split_start_nozzle,
4410
  split_start_valve,
4411
  fil_width,
4412
+ layer_height,
4413
+ scale_mode,
4414
  ],
4415
  outputs=[
4416
  shape_records,
 
4427
  fn=generate_dynamic_reference_stack,
4428
  inputs=[shape_records, fil_width],
4429
  outputs=[ref_layers],
4430
+ ).then(
4431
+ fn=update_layer_preview,
4432
+ inputs=layer_preview_inputs,
4433
+ outputs=layer_preview_outputs,
4434
  ).then(
4435
  fn=_grid_spacing_table_update,
4436
  inputs=grid_spacing_refresh_inputs,
 
4458
  fn=load_selected_gcode_text,
4459
  inputs=[shape_records, gcode_text_source],
4460
  outputs=[gcode_text],
4461
+ ).then(
4462
+ # Generation slices the shapes, so refresh everything that
4463
+ # depends on the sliced stacks (the Slice Shapes button is gone).
4464
+ fn=update_layer_preview,
4465
+ inputs=layer_preview_inputs,
4466
+ outputs=layer_preview_outputs,
4467
+ ).then(
4468
+ fn=lambda records: _dropdown_update(records),
4469
+ inputs=[shape_records],
4470
+ outputs=[split_source],
4471
+ queue=False,
4472
  )
4473
  gcode_text_source.change(fn=load_selected_gcode_text, inputs=[shape_records, gcode_text_source], outputs=[gcode_text])
4474
  refresh_gcode_text_button.click(fn=load_selected_gcode_text, inputs=[shape_records, gcode_text_source], outputs=[gcode_text])
tests/test_nozzle_spacing.py CHANGED
@@ -766,6 +766,12 @@ def test_group_split_splits_all_materials_on_one_shared_grid() -> None:
766
  _record(1, "bottom", _material(box(0.0, 0.0, 20.0, 5.0), "bottom")),
767
  _record(2, "top", _material(box(0.0, 5.0, 20.0, 10.0), "top")),
768
  ]
 
 
 
 
 
 
769
 
770
  outputs = split_selected_shape_for_grid(
771
  records,
 
766
  _record(1, "bottom", _material(box(0.0, 0.0, 20.0, 5.0), "bottom")),
767
  _record(2, "top", _material(box(0.0, 5.0, 20.0, 10.0), "top")),
768
  ]
769
+ # Mark the stacks as freshly sliced for the split's auto-slice step (the
770
+ # fixture stl paths don't exist, so a re-slice attempt would wipe them).
771
+ from app import _slice_params_snapshot
772
+
773
+ for record in records:
774
+ record["slice_params"] = _slice_params_snapshot(record, 0.8, None, None)
775
 
776
  outputs = split_selected_shape_for_grid(
777
  records,