CyGuy8 Claude Fable 5 commited on
Commit
8f71a0b
·
1 Parent(s): 4b11c98

Add Download All zip, sliced-layer preview; always-on shared motion and G1 moves

Browse files

Generate G-Code tab:
- New "Download All (.zip)" button next to the individual G-code file
list; the zip is rebuilt wherever the file list refreshes (generate,
delete, split, sync) and hides when nothing is generated. Name-
colliding entries get a _2 suffix instead of overwriting
- G-code files are named <shape>_gcode.txt (SnakePath dropped), so
individual and zipped downloads carry the shape's name
- Shared reference motion is always on: the checkbox is gone and every
generation rebuilds the combined outline at the current fil width -
forgetting it silently produced non-parallel G-code
- Constant speed is always on: the all-G1 checkbox is gone; every move
is emitted as G1 and the valve commands mark print vs travel

Shapes & Slicing tab:
- Sliced Layer Preview next to the 3D viewer: a layer slider through
the selected shape's polygon outlines drawn in its print color.
Shapes sharing the nozzle (assemblies, split cell-mates) are drawn
together at the same Z, dimmer with a legend - multi-material
slicing and Flip Z can be checked before generating G-code. Refreshes
on selection, slicing, and slider release; fixed axes across layers

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

README.md CHANGED
@@ -47,11 +47,12 @@ Then open the local Gradio URL in your browser, upload STL files or load the bun
47
  - Slices each shape into per-layer vector outlines held in memory (no intermediate image files)
48
  - Shapes that share a **nozzle number** are treated automatically as one multi-material assembly: sliced on one shared Z grid and kept exactly where they were modeled, while shapes alone on their nozzle behave as ordinary independent parts
49
  - Automatically unions the sliced shapes into a combined reference layer set whenever shapes are sliced
 
50
  - Splits one sliced shape's geometry into an editable row/column grid for multi-nozzle printing of one large shape
51
  - Converts sliced layers into G-code files with pressure, valve, nozzle, port, and infill % settings per shape from the Shape Settings table
52
  - **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
53
  - Appends a shape outline contour after each enabled shape layer by tracing that layer's polygon boundary
54
- - Offers G-code generation options for raster pattern, **Use G1 for all moves** (no rapid travel command), and **Use combined reference outline for motion** (all shapes share one nozzle path; each dispenses only its own geometry)
55
  - 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
56
  - Calculates X/Y nozzle spacing from a grid layout (columns/rows plus gaps), with an optional per-connection Advanced Grid Spacing table, then visualizes the resulting nozzle layout
57
  - Previews selected generated G-code inline
@@ -81,7 +82,7 @@ For a multi-material object exported as separate STLs (one per material), give e
81
  - The group is aligned into the reference union as **one rigid unit**: each part keeps exactly the position it was modeled at relative to the others, so asymmetric assemblies line up the way they do in the 3D preview.
82
  - Shapes alone on their nozzle keep the normal behavior (centered onto the common reference), so regular shapes and multi-material assemblies can print together in one job.
83
  - **Contour Tracing** on assembly parts outlines only the assembly's true outer surface: edges where one material meets (or nearly meets, within half a bead — fit tolerances included) another material are internal interfaces and are skipped, exactly like the cut seams of grid-split pieces.
84
- - Combine with **Use combined reference outline for motion** so all heads share one synchronized path while each dispenses only its own part.
85
  - Nozzle renumbering in the table takes effect on the next slice or G-code generation — groups are re-detected automatically.
86
  - **Dimension edits scale the whole assembly**: changing a dimension of one group member applies the same scale factor (target ÷ original, per axis) to every shape on that nozzle, in both scaling modes — in Keep Proportions one edit rescales every member proportionally; in Independent X/Y/Z the edited axis's factor propagates to the group. Factors (not absolute values) keep differently-sized parts proportional to each other, and group members are scaled about the assembly's shared corner so the parts stay assembled at any size.
87
  - **Flip Z** (per-shape checkbox) prints a shape the other way up by mirroring it about its Z midplane. Checking it on any assembly member flips the **whole group** about the group's shared midplane, so the assembly stays together — useful when a multi-material model is authored with its display face down (e.g. the detail layer would otherwise print first instead of last).
@@ -103,13 +104,12 @@ The **Multi-Nozzle Split** accordion on the **Shapes & Slicing** tab can split o
103
  ### G-code Output
104
 
105
  - Generated G-code starts in relative coordinate mode (`G91`).
106
- - `G0` is travel and `G1` is print/feed.
107
- - The app generates print/feed moves where the tool path is inside a shape's layer polygons and travel moves elsewhere.
108
  - Generated files include pressure preset commands and WAGO valve commands based on the selected pressure, valve, and port; the nozzle number controls layout/spacing assignment.
109
  - Pressure increases by `0.1` psi per layer by default.
110
- - **Use G1 for all moves**: when enabled, every movement line is emitted as `G1` (no `G0` rapid travel); the WAGO valve still marks where material is dispensed. Applies to all shapes.
111
  - **Lead In**: enabled per shape via the **Lead In** column in Shape Settings; the Lead In Options accordion on the Generate G-Code tab sets the patch geometry. Prints a purge patch before layer 1, in a selectable direction (Left/Right/Up/Down) at the configured clearance from the start point. The return route exits the patch laterally and comes home through the clearance lane, so the primed nozzle never drags back across the wet purge lines. For grid-split pieces the clearance is automatically extended by the assembly's remaining extent along the purge axis (reported in the G-code status), so under shared reference motion every nozzle's purge patch lands clear of the whole assembled part instead of on a neighbor's print area. The **Lead In** column in Shape Settings controls dispensing per shape: an opted-out head still travels the shared patch (keeping parallel heads in sync) but keeps its valve shut, and skips the lead-in moves entirely when printing without shared motion.
112
- - **Use combined reference outline for motion**: when enabled, every shape's *motion* is taken from the combined reference layer union while each shape's *valve/dispensing* comes from its own layer polygons — so parallel print heads share one synchronized nozzle path and each deposits only its own geometry. The reference union is rebuilt automatically when shapes are sliced. Contour tracing stays synchronized too: every shape traces every traced shape's contour, opening its valve only on its own outline.
113
  - Generated files contain only machine commands (no metadata comments). The toolpath's world anchor — the position, in the shape's own frame, that the relative moves start from — is kept on the shape's session record and used to place parallel parts so split pieces reassemble.
114
  - **Raster Pattern**: `X-direction raster` sweeps every layer back-and-forth in X. `Y-direction raster` rasters every layer in Y. `90° Woodpile raster` alternates the raster axis by layer, switching between X-direction and Y-direction sweeps. `45° Woodpile raster` rotates the sweep 45 degrees per layer, cycling 0, 45, 90, 135 degrees. `Rectangular Spiral raster` walks rectangular loops from the outside toward the center, then reverses from center to edge on the next layer; the loops live on one family anchored to the shape frame, so every layer (and every split sibling) walks the same rectangles and the walls stack — outer loops that cannot touch a layer's material are skipped instead of traveled. `Circle Spiral raster` prints concentric circles stepping inward by one line width per revolution — each revolution stays at a constant radius, so the walls are smooth true circles — then reverses outward on the next layer. The outermost revolution is a perimeter wall hugging the layer's material edge (half a bead inside its farthest boundary point), so the printed silhouette follows the shape smoothly; a matching inner wall hugs a central hole when there is one. The fill rings between the walls come from one global radii grid anchored at the shape frame's center, so interior rings stack exactly across layers, and rings that cannot touch a layer's material (or would overlap a wall bead) are skipped instead of traveled. Walls always dispense even under partial infill, like contour tracing; elsewhere the valve opens only where the path is inside material. Under shared reference motion every shape's own wall radius joins the one shared ring set (all heads travel all walls, each dispenses only its own), and rings that would graze a shape's boundary are suppressed for that shape — so each parallel shape keeps a smooth, complete outer circle regardless of its dimensions.
115
  - **Auto Align Split Parts**: in Nozzle Spacing, computes exact per-connection grid gaps from the split pieces' generated G-code (world toolpath anchors + toolpath bounds), sets the grid columns/rows from the split, and fills the Advanced Grid Spacing table. Works for every raster pattern, filament width, reference-motion setting, and overlapping-layer split. Requires the pieces' G-code to be generated first.
 
47
  - Slices each shape into per-layer vector outlines held in memory (no intermediate image files)
48
  - Shapes that share a **nozzle number** are treated automatically as one multi-material assembly: sliced on one shared Z grid and kept exactly where they were modeled, while shapes alone on their nozzle behave as ordinary independent parts
49
  - Automatically unions the sliced shapes into a combined reference layer set whenever shapes are sliced
50
+ - 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 and Flip Z can be checked before generating G-code)
51
  - Splits one sliced shape's geometry into an editable row/column grid for multi-nozzle printing of one large shape
52
  - Converts sliced layers into G-code files with pressure, valve, nozzle, port, and infill % settings per shape from the Shape Settings table
53
  - **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
54
  - Appends a shape outline contour after each enabled shape layer by tracing that layer's polygon boundary
55
+ - 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)
56
  - 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
57
  - Calculates X/Y nozzle spacing from a grid layout (columns/rows plus gaps), with an optional per-connection Advanced Grid Spacing table, then visualizes the resulting nozzle layout
58
  - Previews selected generated G-code inline
 
82
  - The group is aligned into the reference union as **one rigid unit**: each part keeps exactly the position it was modeled at relative to the others, so asymmetric assemblies line up the way they do in the 3D preview.
83
  - Shapes alone on their nozzle keep the normal behavior (centered onto the common reference), so regular shapes and multi-material assemblies can print together in one job.
84
  - **Contour Tracing** on assembly parts outlines only the assembly's true outer surface: edges where one material meets (or nearly meets, within half a bead — fit tolerances included) another material are internal interfaces and are skipped, exactly like the cut seams of grid-split pieces.
85
+ - Motion is always the combined reference outline, so all heads share one synchronized path while each dispenses only its own part.
86
  - Nozzle renumbering in the table takes effect on the next slice or G-code generation — groups are re-detected automatically.
87
  - **Dimension edits scale the whole assembly**: changing a dimension of one group member applies the same scale factor (target ÷ original, per axis) to every shape on that nozzle, in both scaling modes — in Keep Proportions one edit rescales every member proportionally; in Independent X/Y/Z the edited axis's factor propagates to the group. Factors (not absolute values) keep differently-sized parts proportional to each other, and group members are scaled about the assembly's shared corner so the parts stay assembled at any size.
88
  - **Flip Z** (per-shape checkbox) prints a shape the other way up by mirroring it about its Z midplane. Checking it on any assembly member flips the **whole group** about the group's shared midplane, so the assembly stays together — useful when a multi-material model is authored with its display face down (e.g. the detail layer would otherwise print first instead of last).
 
104
  ### G-code Output
105
 
106
  - Generated G-code starts in relative coordinate mode (`G91`).
107
+ - The app opens the valve where the tool path is inside a shape's layer polygons and closes it elsewhere.
 
108
  - Generated files include pressure preset commands and WAGO valve commands based on the selected pressure, valve, and port; the nozzle number controls layout/spacing assignment.
109
  - Pressure increases by `0.1` psi per layer by default.
110
+ - Every movement line is emitted as `G1` at one constant speed (no `G0` rapid travel); the WAGO valve commands mark where material is dispensed vs where the head just travels.
111
  - **Lead In**: enabled per shape via the **Lead In** column in Shape Settings; the Lead In Options accordion on the Generate G-Code tab sets the patch geometry. Prints a purge patch before layer 1, in a selectable direction (Left/Right/Up/Down) at the configured clearance from the start point. The return route exits the patch laterally and comes home through the clearance lane, so the primed nozzle never drags back across the wet purge lines. For grid-split pieces the clearance is automatically extended by the assembly's remaining extent along the purge axis (reported in the G-code status), so under shared reference motion every nozzle's purge patch lands clear of the whole assembled part instead of on a neighbor's print area. The **Lead In** column in Shape Settings controls dispensing per shape: an opted-out head still travels the shared patch (keeping parallel heads in sync) but keeps its valve shut, and skips the lead-in moves entirely when printing without shared motion.
112
+ - **Combined reference outline for motion** (always on): every shape's *motion* is taken from the combined reference layer union while each shape's *valve/dispensing* comes from its own layer polygons — so parallel print heads share one synchronized nozzle path and each deposits only its own geometry. The reference union is rebuilt automatically whenever shapes are sliced or G-code is generated. Contour tracing stays synchronized too: every shape traces every traced shape's contour, opening its valve only on its own outline.
113
  - Generated files contain only machine commands (no metadata comments). The toolpath's world anchor — the position, in the shape's own frame, that the relative moves start from — is kept on the shape's session record and used to place parallel parts so split pieces reassemble.
114
  - **Raster Pattern**: `X-direction raster` sweeps every layer back-and-forth in X. `Y-direction raster` rasters every layer in Y. `90° Woodpile raster` alternates the raster axis by layer, switching between X-direction and Y-direction sweeps. `45° Woodpile raster` rotates the sweep 45 degrees per layer, cycling 0, 45, 90, 135 degrees. `Rectangular Spiral raster` walks rectangular loops from the outside toward the center, then reverses from center to edge on the next layer; the loops live on one family anchored to the shape frame, so every layer (and every split sibling) walks the same rectangles and the walls stack — outer loops that cannot touch a layer's material are skipped instead of traveled. `Circle Spiral raster` prints concentric circles stepping inward by one line width per revolution — each revolution stays at a constant radius, so the walls are smooth true circles — then reverses outward on the next layer. The outermost revolution is a perimeter wall hugging the layer's material edge (half a bead inside its farthest boundary point), so the printed silhouette follows the shape smoothly; a matching inner wall hugs a central hole when there is one. The fill rings between the walls come from one global radii grid anchored at the shape frame's center, so interior rings stack exactly across layers, and rings that cannot touch a layer's material (or would overlap a wall bead) are skipped instead of traveled. Walls always dispense even under partial infill, like contour tracing; elsewhere the valve opens only where the path is inside material. Under shared reference motion every shape's own wall radius joins the one shared ring set (all heads travel all walls, each dispenses only its own), and rings that would graze a shape's boundary are suppressed for that shape — so each parallel shape keeps a smooth, complete outer circle regardless of its dimensions.
115
  - **Auto Align Split Parts**: in Nozzle Spacing, computes exact per-connection grid gaps from the split pieces' generated G-code (world toolpath anchors + toolpath bounds), sets the grid columns/rows from the split, and fills the Advanced Grid Spacing table. Works for every raster pattern, filament width, reference-motion setting, and overlapping-layer split. Requires the pieces' G-code to be generated first.
app.py CHANGED
@@ -4,6 +4,7 @@ import math
4
  import tempfile
5
  import time
6
  import warnings
 
7
  from pathlib import Path
8
  from typing import Any
9
 
@@ -2253,6 +2254,32 @@ def _gcode_dropdown_update(records: list[dict], selected: str | None = None, inc
2253
  return gr.update(choices=choices, value=value)
2254
 
2255
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2256
  def _merge_file_paths(*file_groups: Any) -> list[str]:
2257
  merged: list[str] = []
2258
  seen: set[str] = set()
@@ -2288,6 +2315,7 @@ def sync_uploaded_shapes(
2288
  _gcode_dropdown_update(next_records),
2289
  _gcode_dropdown_update(next_records, include_upload=True),
2290
  [record.get("gcode_path") for record in next_records if record.get("gcode_path")],
 
2291
  )
2292
 
2293
 
@@ -2322,6 +2350,7 @@ def _shape_delete_outputs(
2322
  _gcode_dropdown_update(records),
2323
  _gcode_dropdown_update(records, include_upload=True),
2324
  [record.get("gcode_path") for record in records if record.get("gcode_path")],
 
2325
  float(last_delete_at or 0.0),
2326
  )
2327
 
@@ -2332,7 +2361,7 @@ def _shape_select_noop() -> tuple:
2332
  lands late); echoing records/rows here raced the dimension normalizer's
2333
  write-back — the stale echo clobbered the recomputed proportions on the
2334
  first Keep Proportions edit and re-rendered the table mid-typing."""
2335
- return tuple(gr.skip() for _ in range(8))
2336
 
2337
 
2338
  def delete_shape_from_settings(
@@ -2727,6 +2756,130 @@ def show_selected_model(
2727
  )
2728
 
2729
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2730
  def _slice_params_snapshot(
2731
  record: dict,
2732
  layer_height: float,
@@ -3188,6 +3341,7 @@ def split_selected_shape_for_grid(
3188
  _gcode_dropdown_update(next_records, include_upload=True),
3189
  _dropdown_update(next_records, selected_value),
3190
  status,
 
3191
  )
3192
 
3193
  if not records:
@@ -3369,30 +3523,23 @@ def _lead_in_assembly_extension(records: list[dict], direction: str | None) -> f
3369
  def generate_dynamic_gcode(
3370
  records: list[dict] | None,
3371
  settings_table: Any,
3372
- all_g1: bool,
3373
- use_reference_motion: bool,
3374
  raster_pattern: str | None,
3375
  pressure_ramp_enabled: bool,
3376
  lead_in_length: float,
3377
  lead_in_clearance: float,
3378
  lead_in_lines: float,
3379
  lead_in_direction: str | None,
3380
- ref_layers: LayerStack | None,
3381
  layer_height: float,
3382
  fil_width: float,
3383
  scale_mode: str | None,
3384
  ) -> tuple:
3385
  records = _apply_shape_settings(records or [], settings_table)
3386
  messages: list[str] = []
3387
- resliced = _ensure_records_sliced(records, layer_height, scale_mode, messages)
3388
- if use_reference_motion or resliced:
3389
- # Always rebuild with the CURRENT fil width: the reference stack's
3390
- # alignment snap grid must match the fil the G-code is generated with.
3391
- ref_layers = generate_dynamic_reference_stack(records, fil_width)
3392
- else:
3393
- # Still refresh group frames + seam-free contours: nozzle numbers may
3394
- # have been edited in the table since the last slice.
3395
- _stamp_multi_material_frames(records, fil_width)
3396
  contour_sources = _contour_tracing_sources(records)
3397
  if contour_sources:
3398
  enabled = ", ".join(f"Shape {source.owner_idx}" for source in contour_sources)
@@ -3424,8 +3571,8 @@ def generate_dynamic_gcode(
3424
  if stack is None or not getattr(stack, "layers", None):
3425
  messages.append(f"Shape {record['idx']}: skipped (no sliced layers).")
3426
  continue
3427
- if use_reference_motion and ref_layers is None:
3428
- messages.append(f"Shape {record['idx']}: skipped (Reference motion selected, but no shapes are sliced).")
3429
  continue
3430
  shape_name = str(record.get("name") or stack.name or f"shape_{record['idx']}").replace(" ", "_")
3431
  try:
@@ -3438,8 +3585,11 @@ def generate_dynamic_gcode(
3438
  layer_height=float(layer_height),
3439
  fil_width=float(fil_width),
3440
  pressure_ramp_enabled=bool(pressure_ramp_enabled),
3441
- all_g1=bool(all_g1),
3442
- motion=ref_layers if use_reference_motion else None,
 
 
 
3443
  raster_pattern=raster_pattern,
3444
  contour_sources=contour_sources,
3445
  active_contour_owner=int(record.get("idx", 0)),
@@ -3450,7 +3600,7 @@ def generate_dynamic_gcode(
3450
  lead_in_lines=max(1, _coerce_int(lead_in_lines, 3)),
3451
  lead_in_direction=lead_in_direction or LEAD_IN_DIRECTION_LEFT,
3452
  lead_in_dispense=bool(record.get("lead_in", True)),
3453
- wall_sources=wall_sources if use_reference_motion else None,
3454
  origin_sink=(origin_sink := {}),
3455
  )
3456
  record["gcode_path"] = str(gcode_path)
@@ -3467,6 +3617,7 @@ def generate_dynamic_gcode(
3467
  "\n".join(messages),
3468
  _gcode_dropdown_update(records),
3469
  _gcode_dropdown_update(records, include_upload=True),
 
3470
  )
3471
 
3472
 
@@ -3803,6 +3954,15 @@ def build_dynamic_demo() -> gr.Blocks:
3803
  camera_position=FRONT_CAMERA,
3804
  height=360,
3805
  )
 
 
 
 
 
 
 
 
 
3806
  with gr.Column(scale=1, min_width=300):
3807
  model_details = gr.Markdown("No model loaded.")
3808
 
@@ -3813,13 +3973,9 @@ def build_dynamic_demo() -> gr.Blocks:
3813
  """
3814
  # Generate G-Code
3815
  Generate G-code for every sliced shape. Pressure, valve, nozzle, port, and color come from the Shape Settings table.
 
3816
  """
3817
  )
3818
- gcode_use_ref_motion = gr.Checkbox(
3819
- label="Use combined reference outline for motion (all shapes share one nozzle path; each dispenses only its own geometry).",
3820
- value=True,
3821
- )
3822
- gcode_all_g1 = gr.Checkbox(label="Move at one constant speed (no fast travel moves)", value=True)
3823
  gcode_pressure_ramp_enabled = gr.Checkbox(label="Increase Pressure Each Layer", value=True)
3824
  gcode_raster_pattern = gr.Dropdown(
3825
  label="Raster Pattern",
@@ -3840,7 +3996,15 @@ def build_dynamic_demo() -> gr.Blocks:
3840
  allow_custom_value=False,
3841
  )
3842
  gcode_button = gr.Button("Generate G-Code", variant="primary")
3843
- gcode_downloads = gr.File(label="Download G-Code Files", file_count="multiple", interactive=False, elem_classes=["gcode-download"])
 
 
 
 
 
 
 
 
3844
  gcode_status = gr.Markdown("")
3845
  with gr.Row():
3846
  gcode_text_source = gr.Dropdown(label="Preview G-Code", choices=[], value=None, allow_custom_value=False)
@@ -3970,7 +4134,7 @@ def build_dynamic_demo() -> gr.Blocks:
3970
  nozzle_grid_use_individual_spacing,
3971
  ]
3972
 
3973
- shape_sync_outputs = [shape_records, shape_settings, selected_shape, gcode_text_source, gcode_source, gcode_downloads]
3974
  stl_upload.change(fn=sync_uploaded_shapes, inputs=[stl_upload, shape_records, shape_settings], outputs=shape_sync_outputs).then(
3975
  fn=lambda records: _dropdown_update(records),
3976
  inputs=[shape_records],
@@ -4053,6 +4217,14 @@ def build_dynamic_demo() -> gr.Blocks:
4053
  selected_shape.change(fn=show_selected_model, inputs=preview_inputs, outputs=[model_viewer, model_details])
4054
  refresh_preview_button.click(fn=show_selected_model, inputs=preview_inputs, outputs=[model_viewer, model_details])
4055
  model_opacity.change(fn=show_selected_model, inputs=preview_inputs, outputs=[model_viewer, model_details])
 
 
 
 
 
 
 
 
4056
  scale_mode.change(
4057
  fn=normalize_shape_dimensions_for_mode,
4058
  inputs=[shape_records, shape_settings, scale_mode],
@@ -4077,6 +4249,10 @@ def build_dynamic_demo() -> gr.Blocks:
4077
  fn=generate_dynamic_layer_stacks,
4078
  inputs=[shape_records, shape_settings, layer_height, scale_mode, fil_width],
4079
  outputs=[shape_records, slicer_status, ref_layers],
 
 
 
 
4080
  ).then(
4081
  fn=lambda records: _dropdown_update(records),
4082
  inputs=[shape_records],
@@ -4115,6 +4291,7 @@ def build_dynamic_demo() -> gr.Blocks:
4115
  gcode_source,
4116
  split_source,
4117
  split_status,
 
4118
  ],
4119
  ).then(
4120
  fn=generate_dynamic_reference_stack,
@@ -4132,20 +4309,17 @@ def build_dynamic_demo() -> gr.Blocks:
4132
  inputs=[
4133
  shape_records,
4134
  shape_settings,
4135
- gcode_all_g1,
4136
- gcode_use_ref_motion,
4137
  gcode_raster_pattern,
4138
  gcode_pressure_ramp_enabled,
4139
  gcode_lead_in_length,
4140
  gcode_lead_in_clearance,
4141
  gcode_lead_in_lines,
4142
  gcode_lead_in_direction,
4143
- ref_layers,
4144
  layer_height,
4145
  fil_width,
4146
  scale_mode,
4147
  ],
4148
- outputs=[shape_records, ref_layers, gcode_downloads, gcode_status, gcode_text_source, gcode_source],
4149
  ).then(
4150
  fn=load_selected_gcode_text,
4151
  inputs=[shape_records, gcode_text_source],
 
4
  import tempfile
5
  import time
6
  import warnings
7
+ import zipfile
8
  from pathlib import Path
9
  from typing import Any
10
 
 
2254
  return gr.update(choices=choices, value=value)
2255
 
2256
 
2257
+ def _gcode_zip_update(records: list[dict] | None) -> dict[str, Any]:
2258
+ """Bundle every generated G-code file into one ZIP for the Download All
2259
+ button. Rebuilt wherever the individual download list is refreshed so the
2260
+ two can never disagree; the button hides when there is nothing to bundle."""
2261
+ paths = [
2262
+ record.get("gcode_path")
2263
+ for record in (records or [])
2264
+ if record.get("gcode_path") and Path(record["gcode_path"]).exists()
2265
+ ]
2266
+ if not paths:
2267
+ return gr.update(visible=False)
2268
+ zip_path = Path(tempfile.mkdtemp(prefix="gcode_zip_")) / "all_shapes_gcode.zip"
2269
+ used_names: set[str] = set()
2270
+ with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as bundle:
2271
+ for path in paths:
2272
+ arcname = Path(path).name
2273
+ stem, suffix = Path(path).stem, Path(path).suffix
2274
+ counter = 2
2275
+ while arcname in used_names:
2276
+ arcname = f"{stem}_{counter}{suffix}"
2277
+ counter += 1
2278
+ used_names.add(arcname)
2279
+ bundle.write(path, arcname=arcname)
2280
+ return gr.update(value=str(zip_path), visible=True)
2281
+
2282
+
2283
  def _merge_file_paths(*file_groups: Any) -> list[str]:
2284
  merged: list[str] = []
2285
  seen: set[str] = set()
 
2315
  _gcode_dropdown_update(next_records),
2316
  _gcode_dropdown_update(next_records, include_upload=True),
2317
  [record.get("gcode_path") for record in next_records if record.get("gcode_path")],
2318
+ _gcode_zip_update(next_records),
2319
  )
2320
 
2321
 
 
2350
  _gcode_dropdown_update(records),
2351
  _gcode_dropdown_update(records, include_upload=True),
2352
  [record.get("gcode_path") for record in records if record.get("gcode_path")],
2353
+ _gcode_zip_update(records),
2354
  float(last_delete_at or 0.0),
2355
  )
2356
 
 
2361
  lands late); echoing records/rows here raced the dimension normalizer's
2362
  write-back — the stale echo clobbered the recomputed proportions on the
2363
  first Keep Proportions edit and re-rendered the table mid-typing."""
2364
+ return tuple(gr.skip() for _ in range(9))
2365
 
2366
 
2367
  def delete_shape_from_settings(
 
2756
  )
2757
 
2758
 
2759
+ def _polygon_patch(polygon, **kwargs):
2760
+ """A filled matplotlib patch for a shapely Polygon, holes included."""
2761
+ from matplotlib.patches import PathPatch
2762
+ from matplotlib.path import Path as MplPath
2763
+
2764
+ verts: list[tuple[float, float]] = []
2765
+ codes: list[int] = []
2766
+ for ring in [polygon.exterior, *polygon.interiors]:
2767
+ coords = list(ring.coords)
2768
+ if len(coords) < 3:
2769
+ continue
2770
+ verts.extend(coords)
2771
+ codes.extend([MplPath.MOVETO] + [MplPath.LINETO] * (len(coords) - 2) + [MplPath.CLOSEPOLY])
2772
+ return PathPatch(MplPath(verts, codes), **kwargs)
2773
+
2774
+
2775
+ def _layer_preview_message(message: str):
2776
+ from matplotlib.figure import Figure
2777
+
2778
+ fig = Figure(figsize=(5.4, 5.0), dpi=100)
2779
+ ax = fig.add_subplot(111)
2780
+ ax.axis("off")
2781
+ ax.text(0.5, 0.5, message, ha="center", va="center", wrap=True, fontsize=11, color="#666666")
2782
+ return fig
2783
+
2784
+
2785
+ def update_layer_preview(
2786
+ records: list[dict] | None,
2787
+ selected: str | None,
2788
+ settings_table: Any,
2789
+ layer_value: float,
2790
+ ) -> tuple:
2791
+ """Slider + figure for the sliced-layer preview.
2792
+
2793
+ Draws the selected shape's layer polygons in its print color. Shapes on
2794
+ the same nozzle are parts of one assembly, so their polygons at the same
2795
+ Z are drawn too (dimmer) — this is where multi-material slicing and Flip
2796
+ Z can be checked before generating any G-code.
2797
+ """
2798
+ from matplotlib.figure import Figure
2799
+ from matplotlib.patches import Patch
2800
+
2801
+ records = _apply_shape_settings(list(records or []), settings_table)
2802
+ pos = _selected_record_index(records, selected)
2803
+ if pos < 0:
2804
+ return gr.update(maximum=1, value=1), _layer_preview_message("Load a shape to preview its sliced layers.")
2805
+ record = records[pos]
2806
+ stack = record.get("layer_stack")
2807
+ if stack is None or not getattr(stack, "layers", None):
2808
+ return (
2809
+ gr.update(maximum=1, value=1),
2810
+ _layer_preview_message("Slice Shapes to see this shape's layer outlines."),
2811
+ )
2812
+
2813
+ layer_count = len(stack.layers)
2814
+ layer_index = max(1, min(layer_count, _coerce_int(layer_value, 1)))
2815
+ z_value = stack.z_values[layer_index - 1]
2816
+ half_layer = float(stack.layer_height or 0.8) / 2.0
2817
+
2818
+ nozzle = _record_nozzle_number(record, int(record.get("idx", pos + 1) or (pos + 1)))
2819
+ drawn: list[tuple[dict, Any]] = [] # (record, MultiPolygon at ~z)
2820
+ for other_pos, other in enumerate(records):
2821
+ other_stack = other.get("layer_stack")
2822
+ if other_stack is None or not getattr(other_stack, "layers", None) or other_pos == pos:
2823
+ continue
2824
+ if _record_nozzle_number(other, int(other.get("idx", other_pos + 1) or (other_pos + 1))) != nozzle:
2825
+ continue
2826
+ z_values = other_stack.z_values
2827
+ nearest = min(range(len(z_values)), key=lambda k: abs(z_values[k] - z_value))
2828
+ if abs(z_values[nearest] - z_value) <= half_layer:
2829
+ drawn.append((other, other_stack.layers[nearest]))
2830
+ drawn.append((record, stack.layers[layer_index - 1])) # selected on top
2831
+
2832
+ fig = Figure(figsize=(5.4, 5.0), dpi=100)
2833
+ ax = fig.add_subplot(111)
2834
+ legend_handles = []
2835
+ has_material = False
2836
+ for member, layer in drawn:
2837
+ is_selected = member is record
2838
+ color = str(member.get("color") or _default_color(int(member.get("idx", 0) or 0)))
2839
+ for polygon in getattr(layer, "geoms", []):
2840
+ if polygon.is_empty:
2841
+ continue
2842
+ has_material = True
2843
+ ax.add_patch(
2844
+ _polygon_patch(
2845
+ polygon,
2846
+ facecolor=color,
2847
+ alpha=0.85 if is_selected else 0.45,
2848
+ edgecolor="#333333",
2849
+ linewidth=0.7,
2850
+ )
2851
+ )
2852
+ if len(drawn) > 1:
2853
+ legend_handles.append(
2854
+ Patch(facecolor=color, alpha=0.85 if is_selected else 0.45, label=str(member.get("name") or f"Shape {member.get('idx')}"))
2855
+ )
2856
+
2857
+ # Fixed frame across the whole stack (assembly bounds) so the view
2858
+ # doesn't jump between layers.
2859
+ xs: list[float] = []
2860
+ ys: list[float] = []
2861
+ for member, _layer in drawn:
2862
+ bounds = member["layer_stack"].bounds
2863
+ xs.extend((bounds[0][0], bounds[1][0]))
2864
+ ys.extend((bounds[0][1], bounds[1][1]))
2865
+ margin = max(1.0, 0.05 * max(max(xs) - min(xs), max(ys) - min(ys)))
2866
+ ax.set_xlim(min(xs) - margin, max(xs) + margin)
2867
+ ax.set_ylim(min(ys) - margin, max(ys) + margin)
2868
+ ax.set_aspect("equal")
2869
+ ax.grid(True, linewidth=0.3, alpha=0.4)
2870
+ ax.set_xlabel("X (mm)")
2871
+ ax.set_ylabel("Y (mm)")
2872
+ name = str(record.get("name") or f"Shape {record.get('idx')}")
2873
+ title = f"{name} — layer {layer_index}/{layer_count} (z = {z_value:.2f} mm)"
2874
+ if not has_material:
2875
+ title += " [empty layer]"
2876
+ ax.set_title(title, fontsize=10)
2877
+ if legend_handles:
2878
+ ax.legend(handles=legend_handles, fontsize=8, loc="upper right")
2879
+ fig.tight_layout()
2880
+ return gr.update(maximum=layer_count, value=layer_index), fig
2881
+
2882
+
2883
  def _slice_params_snapshot(
2884
  record: dict,
2885
  layer_height: float,
 
3341
  _gcode_dropdown_update(next_records, include_upload=True),
3342
  _dropdown_update(next_records, selected_value),
3343
  status,
3344
+ _gcode_zip_update(next_records),
3345
  )
3346
 
3347
  if not records:
 
3523
  def generate_dynamic_gcode(
3524
  records: list[dict] | None,
3525
  settings_table: Any,
 
 
3526
  raster_pattern: str | None,
3527
  pressure_ramp_enabled: bool,
3528
  lead_in_length: float,
3529
  lead_in_clearance: float,
3530
  lead_in_lines: float,
3531
  lead_in_direction: str | None,
 
3532
  layer_height: float,
3533
  fil_width: float,
3534
  scale_mode: str | None,
3535
  ) -> tuple:
3536
  records = _apply_shape_settings(records or [], settings_table)
3537
  messages: list[str] = []
3538
+ _ensure_records_sliced(records, layer_height, scale_mode, messages)
3539
+ # Shared reference motion is always on: every head traces the combined
3540
+ # outline and dispenses only its own geometry. Rebuilt with the CURRENT
3541
+ # fil width so the alignment snap grid matches this generation.
3542
+ ref_layers = generate_dynamic_reference_stack(records, fil_width)
 
 
 
 
3543
  contour_sources = _contour_tracing_sources(records)
3544
  if contour_sources:
3545
  enabled = ", ".join(f"Shape {source.owner_idx}" for source in contour_sources)
 
3571
  if stack is None or not getattr(stack, "layers", None):
3572
  messages.append(f"Shape {record['idx']}: skipped (no sliced layers).")
3573
  continue
3574
+ if ref_layers is None:
3575
+ messages.append(f"Shape {record['idx']}: skipped (no combined reference outline; slice a shape first).")
3576
  continue
3577
  shape_name = str(record.get("name") or stack.name or f"shape_{record['idx']}").replace(" ", "_")
3578
  try:
 
3585
  layer_height=float(layer_height),
3586
  fil_width=float(fil_width),
3587
  pressure_ramp_enabled=bool(pressure_ramp_enabled),
3588
+ # Always one constant speed: every move is emitted as G1 (no
3589
+ # G0 rapid travel); the valve commands still mark print vs
3590
+ # travel.
3591
+ all_g1=True,
3592
+ motion=ref_layers,
3593
  raster_pattern=raster_pattern,
3594
  contour_sources=contour_sources,
3595
  active_contour_owner=int(record.get("idx", 0)),
 
3600
  lead_in_lines=max(1, _coerce_int(lead_in_lines, 3)),
3601
  lead_in_direction=lead_in_direction or LEAD_IN_DIRECTION_LEFT,
3602
  lead_in_dispense=bool(record.get("lead_in", True)),
3603
+ wall_sources=wall_sources,
3604
  origin_sink=(origin_sink := {}),
3605
  )
3606
  record["gcode_path"] = str(gcode_path)
 
3617
  "\n".join(messages),
3618
  _gcode_dropdown_update(records),
3619
  _gcode_dropdown_update(records, include_upload=True),
3620
+ _gcode_zip_update(records),
3621
  )
3622
 
3623
 
 
3954
  camera_position=FRONT_CAMERA,
3955
  height=360,
3956
  )
3957
+ with gr.Column(scale=2, min_width=380):
3958
+ layer_preview_plot = gr.Plot(label="Sliced Layer Preview")
3959
+ layer_preview_slider = gr.Slider(
3960
+ label="Layer",
3961
+ minimum=1,
3962
+ maximum=1,
3963
+ step=1,
3964
+ value=1,
3965
+ )
3966
  with gr.Column(scale=1, min_width=300):
3967
  model_details = gr.Markdown("No model loaded.")
3968
 
 
3973
  """
3974
  # Generate G-Code
3975
  Generate G-code for every sliced shape. Pressure, valve, nozzle, port, and color come from the Shape Settings table.
3976
+ All shapes share one combined nozzle path (each dispenses only its own geometry), so parallel heads always stay in sync.
3977
  """
3978
  )
 
 
 
 
 
3979
  gcode_pressure_ramp_enabled = gr.Checkbox(label="Increase Pressure Each Layer", value=True)
3980
  gcode_raster_pattern = gr.Dropdown(
3981
  label="Raster Pattern",
 
3996
  allow_custom_value=False,
3997
  )
3998
  gcode_button = gr.Button("Generate G-Code", variant="primary")
3999
+ with gr.Row():
4000
+ with gr.Column(scale=4):
4001
+ gcode_downloads = gr.File(label="Download G-Code Files", file_count="multiple", interactive=False, elem_classes=["gcode-download"])
4002
+ with gr.Column(scale=1, min_width=200):
4003
+ gcode_download_all = gr.DownloadButton(
4004
+ "Download All (.zip)",
4005
+ variant="secondary",
4006
+ visible=False,
4007
+ )
4008
  gcode_status = gr.Markdown("")
4009
  with gr.Row():
4010
  gcode_text_source = gr.Dropdown(label="Preview G-Code", choices=[], value=None, allow_custom_value=False)
 
4134
  nozzle_grid_use_individual_spacing,
4135
  ]
4136
 
4137
+ shape_sync_outputs = [shape_records, shape_settings, selected_shape, gcode_text_source, gcode_source, gcode_downloads, gcode_download_all]
4138
  stl_upload.change(fn=sync_uploaded_shapes, inputs=[stl_upload, shape_records, shape_settings], outputs=shape_sync_outputs).then(
4139
  fn=lambda records: _dropdown_update(records),
4140
  inputs=[shape_records],
 
4217
  selected_shape.change(fn=show_selected_model, inputs=preview_inputs, outputs=[model_viewer, model_details])
4218
  refresh_preview_button.click(fn=show_selected_model, inputs=preview_inputs, outputs=[model_viewer, model_details])
4219
  model_opacity.change(fn=show_selected_model, inputs=preview_inputs, outputs=[model_viewer, model_details])
4220
+ # Sliced-layer preview. The slider uses .release only: the handler
4221
+ # writes the slider back (clamped max/value), and a .change wiring
4222
+ # would re-fire on that programmatic write.
4223
+ layer_preview_inputs = [shape_records, selected_shape, shape_settings, layer_preview_slider]
4224
+ layer_preview_outputs = [layer_preview_slider, layer_preview_plot]
4225
+ selected_shape.change(fn=update_layer_preview, inputs=layer_preview_inputs, outputs=layer_preview_outputs)
4226
+ refresh_preview_button.click(fn=update_layer_preview, inputs=layer_preview_inputs, outputs=layer_preview_outputs)
4227
+ layer_preview_slider.release(fn=update_layer_preview, inputs=layer_preview_inputs, outputs=layer_preview_outputs)
4228
  scale_mode.change(
4229
  fn=normalize_shape_dimensions_for_mode,
4230
  inputs=[shape_records, shape_settings, scale_mode],
 
4249
  fn=generate_dynamic_layer_stacks,
4250
  inputs=[shape_records, shape_settings, layer_height, scale_mode, fil_width],
4251
  outputs=[shape_records, slicer_status, ref_layers],
4252
+ ).then(
4253
+ fn=update_layer_preview,
4254
+ inputs=layer_preview_inputs,
4255
+ outputs=layer_preview_outputs,
4256
  ).then(
4257
  fn=lambda records: _dropdown_update(records),
4258
  inputs=[shape_records],
 
4291
  gcode_source,
4292
  split_source,
4293
  split_status,
4294
+ gcode_download_all,
4295
  ],
4296
  ).then(
4297
  fn=generate_dynamic_reference_stack,
 
4309
  inputs=[
4310
  shape_records,
4311
  shape_settings,
 
 
4312
  gcode_raster_pattern,
4313
  gcode_pressure_ramp_enabled,
4314
  gcode_lead_in_length,
4315
  gcode_lead_in_clearance,
4316
  gcode_lead_in_lines,
4317
  gcode_lead_in_direction,
 
4318
  layer_height,
4319
  fil_width,
4320
  scale_mode,
4321
  ],
4322
+ outputs=[shape_records, ref_layers, gcode_downloads, gcode_status, gcode_text_source, gcode_source, gcode_download_all],
4323
  ).then(
4324
  fn=load_selected_gcode_text,
4325
  inputs=[shape_records, gcode_text_source],
tests/test_nozzle_spacing.py CHANGED
@@ -723,7 +723,7 @@ def test_non_delete_cell_selection_touches_nothing() -> None:
723
 
724
  outputs = delete_shape_from_settings(records, _shape_settings_rows(records), 0.0, Event())
725
 
726
- assert len(outputs) == 8
727
  assert all(not isinstance(value, list) for value in outputs)
728
  assert not isinstance(outputs[1], list) # records State untouched
729
 
@@ -1101,6 +1101,84 @@ def test_apply_bulk_bool_selection_sets_a_whole_column() -> None:
1101
  assert refused[0].get("color") == cleared[0].get("color")
1102
 
1103
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1104
  def test_load_sample_shapes_respects_the_selected_set() -> None:
1105
  from app import DEFAULT_SAMPLE_STL_SET, SAMPLE_STL_SETS, load_sample_shapes
1106
 
 
723
 
724
  outputs = delete_shape_from_settings(records, _shape_settings_rows(records), 0.0, Event())
725
 
726
+ assert len(outputs) == 9
727
  assert all(not isinstance(value, list) for value in outputs)
728
  assert not isinstance(outputs[1], list) # records State untouched
729
 
 
1101
  assert refused[0].get("color") == cleared[0].get("color")
1102
 
1103
 
1104
+ def test_download_all_zip_bundles_gcode_files_by_shape_name(tmp_path) -> None:
1105
+ import zipfile
1106
+
1107
+ from app import _gcode_zip_update
1108
+
1109
+ first = tmp_path / "circle_gcode.txt"
1110
+ first.write_text("G91\n")
1111
+ other_dir = tmp_path / "other"
1112
+ other_dir.mkdir()
1113
+ duplicate = other_dir / "circle_gcode.txt"
1114
+ duplicate.write_text("G91\n")
1115
+ square = tmp_path / "square_gcode.txt"
1116
+ square.write_text("G91\n")
1117
+
1118
+ update = _gcode_zip_update(
1119
+ [
1120
+ {"idx": 1, "gcode_path": str(first)},
1121
+ {"idx": 2, "gcode_path": str(duplicate)},
1122
+ {"idx": 3, "gcode_path": str(square)},
1123
+ {"idx": 4, "gcode_path": None},
1124
+ ]
1125
+ )
1126
+ assert update["visible"] is True
1127
+ with zipfile.ZipFile(update["value"]) as bundle:
1128
+ assert sorted(bundle.namelist()) == [
1129
+ "circle_gcode.txt",
1130
+ "circle_gcode_2.txt",
1131
+ "square_gcode.txt",
1132
+ ]
1133
+
1134
+ # Nothing generated yet: the button hides instead of offering an empty zip.
1135
+ hidden = _gcode_zip_update([{"idx": 1, "gcode_path": None}])
1136
+ assert hidden["visible"] is False
1137
+
1138
+
1139
+ def test_layer_preview_draws_the_selection_and_its_nozzle_group() -> None:
1140
+ from shapely.geometry import MultiPolygon, box
1141
+
1142
+ from app import update_layer_preview
1143
+ from stl_slicer import LayerStack
1144
+
1145
+ def _preview_stack(name: str, poly) -> LayerStack:
1146
+ return LayerStack(
1147
+ layers=[MultiPolygon([poly]), MultiPolygon([poly])],
1148
+ z_values=[0.4, 1.2],
1149
+ bounds=((0.0, 0.0, 0.0), (10.0, 10.0, 1.6)),
1150
+ layer_height=0.8,
1151
+ name=name,
1152
+ )
1153
+
1154
+ records = [
1155
+ {"idx": 1, "name": "base", "stl_path": "base.stl", "nozzle": 1, "color": "#ff0000",
1156
+ "layer_stack": _preview_stack("base", box(0.0, 0.0, 10.0, 10.0))},
1157
+ {"idx": 2, "name": "stripe", "stl_path": "stripe.stl", "nozzle": 1, "color": "#0000ff",
1158
+ "layer_stack": _preview_stack("stripe", box(2.0, 2.0, 8.0, 8.0))},
1159
+ {"idx": 3, "name": "solo", "stl_path": "solo.stl", "nozzle": 2, "color": "#00ff00",
1160
+ "layer_stack": _preview_stack("solo", box(0.0, 0.0, 5.0, 5.0))},
1161
+ ]
1162
+
1163
+ # Selected shape plus its same-nozzle assembly sibling are both drawn.
1164
+ slider, fig = update_layer_preview(records, "1: base", None, 2)
1165
+ assert slider["maximum"] == 2
1166
+ assert slider["value"] == 2
1167
+ assert len(fig.axes[0].patches) == 2
1168
+
1169
+ # A shape alone on its nozzle draws only itself; out-of-range slider
1170
+ # values clamp to the layer count.
1171
+ slider_solo, fig_solo = update_layer_preview(records, "3: solo", None, 99)
1172
+ assert slider_solo["value"] == 2
1173
+ assert len(fig_solo.axes[0].patches) == 1
1174
+
1175
+ # Unsliced shape: slider resets, figure carries the hint (no patches).
1176
+ unsliced = [{"idx": 1, "name": "raw", "stl_path": "raw.stl", "nozzle": 1, "color": "#ff0000", "layer_stack": None}]
1177
+ slider_reset, fig_hint = update_layer_preview(unsliced, "1: raw", None, 5)
1178
+ assert slider_reset["maximum"] == 1
1179
+ assert not fig_hint.axes[0].patches
1180
+
1181
+
1182
  def test_load_sample_shapes_respects_the_selected_set() -> None:
1183
  from app import DEFAULT_SAMPLE_STL_SET, SAMPLE_STL_SETS, load_sample_shapes
1184
 
tests/test_vector_gcode.py CHANGED
@@ -176,6 +176,21 @@ def test_slanted_shape_gcode_round_trips_through_the_viewer(tmp_path) -> None:
176
  assert -1.0 <= y <= 21.0
177
 
178
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
179
  def test_gcode_header_writes_presets_before_initial_aux_commands(tmp_path) -> None:
180
  gcode_path = generate_vector_gcode(
181
  _stack(box(0.0, 0.0, 1.0, 1.0)),
 
176
  assert -1.0 <= y <= 21.0
177
 
178
 
179
+ def test_gcode_file_is_named_after_the_shape(tmp_path) -> None:
180
+ gcode_path = generate_vector_gcode(
181
+ _stack(box(0.0, 0.0, 1.0, 1.0)),
182
+ shape_name="Simple_Circle",
183
+ pressure=25,
184
+ valve=7,
185
+ port=3,
186
+ fil_width=1.0,
187
+ origin_sink=(origin_sink := {}),
188
+ output_dir=tmp_path,
189
+ )
190
+
191
+ assert gcode_path.name == "Simple_Circle_gcode.txt"
192
+
193
+
194
  def test_gcode_header_writes_presets_before_initial_aux_commands(tmp_path) -> None:
195
  gcode_path = generate_vector_gcode(
196
  _stack(box(0.0, 0.0, 1.0, 1.0)),
vector_gcode.py CHANGED
@@ -353,7 +353,7 @@ def generate_vector_gcode(
353
  output_dir = Path(output_dir)
354
  output_dir.mkdir(parents=True, exist_ok=True)
355
 
356
- gcode_path = output_dir / f"{shape_name}_SnakePath_gcode.txt"
357
  write_gcode_file(
358
  gcode_path,
359
  gcode_list,
 
353
  output_dir = Path(output_dir)
354
  output_dir.mkdir(parents=True, exist_ok=True)
355
 
356
+ gcode_path = output_dir / f"{shape_name}_gcode.txt"
357
  write_gcode_file(
358
  gcode_path,
359
  gcode_list,