CyGuy8 Claude Fable 5 commited on
Commit
ab2d2fa
·
1 Parent(s): 67c2359

Stale-G-code banner, per-port pressure ownership, undo stack, auto-render

Browse files

G-code generation:
- Pressure commands are written ONCE PER PORT: the first shape on each
serial port owns the preset, toggle, per-layer ramp, and closing
toggle; port-mates' files carry no serial commands at all (valve
commands stay per shape). The print host compiles every file onto one
timeline, and duplicated toggles flipped the regulator on/off/on
- Dropped the legacy valve-100 init/close loop: no more
WAGO_ValveCommands(100, 0) or duplicate own-valve closes
- The generation status leads with the shared print path length and a
time estimate at the Visualization tab's nozzle speed

Guardrails:
- Stale-G-code banner above the Generate button: every generated file
carries a settings fingerprint, and any table or option change after
generation (or a shape added without a file) raises the warning until
regeneration; reverting the change clears it
- Fixed a latent re-sync bug the banner exposed: _records_from_files
dropped path_origin (breaking Auto Align after an upload re-sync) and
the new gcode_snapshot - both now carried through
- Assign Unique Valves button: first occurrence keeps its number,
duplicates and unset valves move to the smallest unused
- Undo Split unwinds MULTIPLE splits (a snapshot stack, newest first,
capped at 10) with remaining-count status

Visualization:
- Generation auto-renders the parallel line plot, and the Visualization
tab re-issues it on tab select (Gradio never mounts plots delivered to
a hidden tab); tube renders are never clobbered

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

Files changed (5) hide show
  1. README.md +4 -0
  2. app.py +333 -18
  3. tests/test_nozzle_spacing.py +149 -0
  4. tests/test_vector_gcode.py +37 -2
  5. vector_gcode.py +21 -11
README.md CHANGED
@@ -58,6 +58,9 @@ Then open the local Gradio URL in your browser, upload STL files or load the bun
58
  - Appends a shape outline contour after each enabled shape layer by tracing that layer's polygon boundary
59
  - 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)
60
  - 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
 
 
 
61
  - 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
62
  - Previews selected generated G-code inline
63
  - 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
@@ -109,6 +112,7 @@ The **Multi-Nozzle Split** accordion on the **Shapes & G-Code** tab can split on
109
  - Generated G-code starts in relative coordinate mode (`G91`).
110
  - The app opens the valve where the tool path is inside a shape's layer polygons and closes it elsewhere.
111
  - 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.
 
112
  - Pressure increases by `0.1` psi per layer by default.
113
  - 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.
114
  - **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.
 
58
  - Appends a shape outline contour after each enabled shape layer by tracing that layer's polygon boundary
59
  - 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)
60
  - 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
61
+ - 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
62
+ - 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
63
+ - **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)
64
  - 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
65
  - Previews selected generated G-code inline
66
  - 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
 
112
  - Generated G-code starts in relative coordinate mode (`G91`).
113
  - The app opens the valve where the tool path is inside a shape's layer polygons and closes it elsewhere.
114
  - 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.
115
+ - Pressure commands are written **once per port**: when shapes share a serial port, only the first shape's file carries the preset, toggle, and per-layer ramp — the print host compiles every file onto one timeline, and duplicated toggles would flip the regulator on/off/on at start. Valve commands stay per shape in every file.
116
  - Pressure increases by `0.1` psi per layer by default.
117
  - 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.
118
  - **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.
app.py CHANGED
@@ -282,6 +282,12 @@ APP_CSS = """
282
  color: var(--body-text-color-subdued);
283
  }
284
 
 
 
 
 
 
 
285
  /* Narrower columns: header labels ("Pressure (psi)", "Contour Tracing", ...)
286
  wrap to two lines instead of forcing single-line column widths. Wrapping
287
  happens ONLY at spaces — words never break mid-word (columns grow to fit
@@ -2013,6 +2019,11 @@ def _records_from_files(files: Any, previous_records: list[dict] | None = None)
2013
  "layer_stack": previous.get("layer_stack"),
2014
  "slice_params": previous.get("slice_params"),
2015
  "gcode_path": previous.get("gcode_path"),
 
 
 
 
 
2016
  })
2017
  return records
2018
 
@@ -3708,6 +3719,7 @@ def split_selected_shape_for_grid(
3708
  fil_width: float,
3709
  layer_height: float = 0.8,
3710
  scale_mode: str | None = None,
 
3711
  ) -> tuple:
3712
  records = _apply_shape_settings(records or [], settings_table)
3713
  # One-button flow: shapes are sliced (or re-sliced when their settings
@@ -3716,6 +3728,11 @@ def split_selected_shape_for_grid(
3716
  slice_messages: list[str] = []
3717
  if records:
3718
  _ensure_records_sliced(records, float(layer_height or 0.8), scale_mode, slice_messages)
 
 
 
 
 
3719
 
3720
  def _outputs(next_records: list[dict], selected_value: str | None, status: str) -> tuple:
3721
  if slice_messages:
@@ -3730,6 +3747,7 @@ def split_selected_shape_for_grid(
3730
  _dropdown_update(next_records, selected_value),
3731
  status,
3732
  _gcode_zip_update(next_records),
 
3733
  )
3734
 
3735
  if not records:
@@ -3908,6 +3926,160 @@ def _lead_in_assembly_extension(records: list[dict], direction: str | None) -> f
3908
  return extension
3909
 
3910
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3911
  def generate_dynamic_gcode(
3912
  records: list[dict] | None,
3913
  settings_table: Any,
@@ -3920,6 +4092,7 @@ def generate_dynamic_gcode(
3920
  layer_height: float,
3921
  fil_width: float,
3922
  scale_mode: str | None,
 
3923
  ) -> tuple:
3924
  records = _apply_shape_settings(records or [], settings_table)
3925
  messages: list[str] = []
@@ -3964,6 +4137,11 @@ def generate_dynamic_gcode(
3964
  f"Lead-in clearance extended by {lead_in_extension:.1f} mm so every "
3965
  "nozzle's purge patch lands clear of the split assembly."
3966
  )
 
 
 
 
 
3967
  for record in records:
3968
  stack = record.get("layer_stack")
3969
  if stack is None or not getattr(stack, "layers", None):
@@ -3973,6 +4151,8 @@ def generate_dynamic_gcode(
3973
  messages.append(f"Shape {record['idx']}: skipped (no combined reference outline; slice a shape first).")
3974
  continue
3975
  shape_name = str(record.get("name") or stack.name or f"shape_{record['idx']}").replace(" ", "_")
 
 
3976
  try:
3977
  gcode_path = generate_vector_gcode(
3978
  stack,
@@ -3993,6 +4173,7 @@ def generate_dynamic_gcode(
3993
  active_contour_owner=int(record.get("idx", 0)),
3994
  infill=_coerce_float(record.get("infill", 100.0), 100.0) / 100.0,
3995
  motion_infill_fractions=motion_infill_fractions,
 
3996
  lead_in_enabled=bool(lead_in_enabled),
3997
  lead_in_length=float(lead_in_length),
3998
  lead_in_clearance=effective_lead_in_clearance,
@@ -4002,17 +4183,48 @@ def generate_dynamic_gcode(
4002
  wall_sources=wall_sources,
4003
  origin_sink=(origin_sink := {}),
4004
  )
 
4005
  record["gcode_path"] = str(gcode_path)
4006
  # World anchor of the toolpath (kept OUT of the G-code file):
4007
  # Auto Align and the visualizations read it from the record.
4008
  record["path_origin"] = origin_sink.get("path_origin")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4009
  messages.append(f"Shape {record['idx']}: wrote `{gcode_path.name}`.")
4010
  except Exception as exc:
4011
  messages.append(f"Shape {record['idx']}: failed ({exc}).")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4012
  return (
4013
  records,
4014
  ref_layers,
4015
- [record.get("gcode_path") for record in records if record.get("gcode_path")],
4016
  "\n".join(messages),
4017
  _gcode_dropdown_update(records),
4018
  _gcode_dropdown_update(records, include_upload=True),
@@ -4333,6 +4545,7 @@ def build_dynamic_demo() -> gr.Blocks:
4333
  )
4334
  sync_uploads_button = gr.Button("Sync Uploaded STLs", variant="secondary", size="sm")
4335
  reset_dimensions_button = gr.Button("Reset Dimensions", variant="secondary", size="sm")
 
4336
  scale_mode = gr.Radio(
4337
  choices=[SCALE_MODE_TARGET_DIMENSIONS, SCALE_MODE_UNIFORM_FACTOR],
4338
  value=SCALE_MODE_TARGET_DIMENSIONS,
@@ -4406,8 +4619,11 @@ def build_dynamic_demo() -> gr.Blocks:
4406
  split_start_nozzle = gr.Number(label="Starting Nozzle", value=1, minimum=1, step=1)
4407
  split_start_valve = gr.Number(label="Starting Valve", value=4, minimum=1, step=1)
4408
  split_overlapping_layers = gr.Checkbox(label="Overlapping Layers", value=False)
4409
- split_button = gr.Button("Split Selected Shape into Grid Pieces", variant="primary")
 
 
4410
  split_status = gr.Markdown(SPLIT_STATUS_DEFAULT)
 
4411
 
4412
  with gr.Accordion("Selected Shape Preview", open=False, elem_classes=["settings-accordion"]):
4413
  with gr.Row():
@@ -4462,6 +4678,7 @@ def build_dynamic_demo() -> gr.Blocks:
4462
  value=LEAD_IN_DIRECTION_LEFT,
4463
  allow_custom_value=False,
4464
  )
 
4465
  gcode_button = gr.Button("Generate G-Code", variant="primary")
4466
  with gr.Row():
4467
  with gr.Column(scale=4):
@@ -4478,7 +4695,7 @@ def build_dynamic_demo() -> gr.Blocks:
4478
  refresh_gcode_text_button = gr.Button("Refresh G-Code Preview", variant="secondary", size="sm")
4479
  gcode_text = gr.Code(label="Selected G-Code", language=None, lines=18, max_lines=18, interactive=False, elem_classes=["gcode-view"])
4480
 
4481
- with gr.Tab("Visualization"):
4482
  gr.Markdown(
4483
  "### Print Visualization\n"
4484
  "Defaults to the parallel print of every generated shape, laid out with the "
@@ -4735,6 +4952,7 @@ def build_dynamic_demo() -> gr.Blocks:
4735
  fil_width,
4736
  layer_height,
4737
  scale_mode,
 
4738
  ],
4739
  outputs=[
4740
  shape_records,
@@ -4746,6 +4964,7 @@ def build_dynamic_demo() -> gr.Blocks:
4746
  split_source,
4747
  split_status,
4748
  gcode_download_all,
 
4749
  ],
4750
  ).then(
4751
  fn=generate_dynamic_reference_stack,
@@ -4761,6 +4980,100 @@ def build_dynamic_demo() -> gr.Blocks:
4761
  outputs=[nozzle_grid_spacing_table],
4762
  queue=False,
4763
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4764
 
4765
  gcode_button.click(
4766
  fn=generate_dynamic_gcode,
@@ -4776,6 +5089,7 @@ def build_dynamic_demo() -> gr.Blocks:
4776
  layer_height,
4777
  fil_width,
4778
  scale_mode,
 
4779
  ],
4780
  outputs=[shape_records, ref_layers, gcode_downloads, gcode_status, gcode_text_source, gcode_source, gcode_download_all],
4781
  ).then(
@@ -4793,6 +5107,17 @@ def build_dynamic_demo() -> gr.Blocks:
4793
  inputs=[shape_records],
4794
  outputs=[split_source],
4795
  queue=False,
 
 
 
 
 
 
 
 
 
 
 
4796
  )
4797
  gcode_text_source.change(fn=load_selected_gcode_text, inputs=[shape_records, gcode_text_source], outputs=[gcode_text])
4798
  refresh_gcode_text_button.click(fn=load_selected_gcode_text, inputs=[shape_records, gcode_text_source], outputs=[gcode_text])
@@ -4915,23 +5240,13 @@ def build_dynamic_demo() -> gr.Blocks:
4915
  queue=False,
4916
  )
4917
 
4918
- parallel_render_inputs = [
4919
- shape_records,
4920
- shape_settings,
4921
- pp_travel_opacity,
4922
- pp_filament_width,
4923
- pp_travel_width,
4924
- nozzle_grid_columns,
4925
- nozzle_grid_rows,
4926
- nozzle_grid_column_spacing,
4927
- nozzle_grid_row_spacing,
4928
- nozzle_grid_use_individual_spacing,
4929
- nozzle_grid_spacing_table,
4930
- viz_nozzle_speed,
4931
- ]
4932
- parallel_outputs = [parallel_plot, parallel_status, parallel_mode, parallel_anim_controls, pp_width_row, pp_export_group]
4933
  parallel_line_button.click(fn=render_dynamic_parallel_lines, inputs=parallel_render_inputs, outputs=parallel_outputs)
4934
  parallel_render_button.click(fn=render_dynamic_parallel_tubes, inputs=parallel_render_inputs, outputs=parallel_outputs)
 
 
 
 
 
4935
  pp_filament_width.release(fn=rerender_dynamic_parallel_current_mode, inputs=[parallel_mode] + parallel_render_inputs, outputs=[parallel_plot, parallel_status])
4936
  pp_travel_width.release(fn=rerender_dynamic_parallel_current_mode, inputs=[parallel_mode] + parallel_render_inputs, outputs=[parallel_plot, parallel_status])
4937
  pp_export_button.click(
 
282
  color: var(--body-text-color-subdued);
283
  }
284
 
285
+ /* Stale-G-code warning above the Generate button. */
286
+ #gcode-stale-banner p {
287
+ color: #b45309;
288
+ font-weight: 500;
289
+ }
290
+
291
  /* Narrower columns: header labels ("Pressure (psi)", "Contour Tracing", ...)
292
  wrap to two lines instead of forcing single-line column widths. Wrapping
293
  happens ONLY at spaces — words never break mid-word (columns grow to fit
 
2019
  "layer_stack": previous.get("layer_stack"),
2020
  "slice_params": previous.get("slice_params"),
2021
  "gcode_path": previous.get("gcode_path"),
2022
+ # Carried through a re-sync: the toolpath's world anchor (Auto
2023
+ # Align and the visualizations read it) and the generation
2024
+ # fingerprint (the stale-G-code banner compares against it).
2025
+ "path_origin": previous.get("path_origin"),
2026
+ "gcode_snapshot": previous.get("gcode_snapshot"),
2027
  })
2028
  return records
2029
 
 
3719
  fil_width: float,
3720
  layer_height: float = 0.8,
3721
  scale_mode: str | None = None,
3722
+ undo_stack: list[list[dict]] | None = None,
3723
  ) -> tuple:
3724
  records = _apply_shape_settings(records or [], settings_table)
3725
  # One-button flow: shapes are sliced (or re-sliced when their settings
 
3728
  slice_messages: list[str] = []
3729
  if records:
3730
  _ensure_records_sliced(records, float(layer_height or 0.8), scale_mode, slice_messages)
3731
+ # Undo stack: each split PUSHES its pre-split records (sliced), and Undo
3732
+ # Split pops them one at a time (capped to the last 10 splits). Pushed
3733
+ # only when a split actually happened — every failure path returns the
3734
+ # ORIGINAL `records` list object, detected by identity.
3735
+ next_undo_stack = [*(undo_stack or []), [dict(record) for record in records]][-10:]
3736
 
3737
  def _outputs(next_records: list[dict], selected_value: str | None, status: str) -> tuple:
3738
  if slice_messages:
 
3747
  _dropdown_update(next_records, selected_value),
3748
  status,
3749
  _gcode_zip_update(next_records),
3750
+ next_undo_stack if next_records is not records else gr.skip(),
3751
  )
3752
 
3753
  if not records:
 
3926
  return extension
3927
 
3928
 
3929
+ def refresh_parallel_on_tab_select(mode: str | None, *args: Any) -> tuple:
3930
+ """Re-send the parallel LINE plot when the Visualization tab is opened.
3931
+
3932
+ The generate chain renders the parallel view into the hidden tab, where
3933
+ Gradio's Plot component doesn't mount the figure — re-issuing it on tab
3934
+ select makes it appear. Tube renders are skipped: the user made those
3935
+ while ON the tab (already mounted), and they are expensive to redo.
3936
+ """
3937
+ if mode != "line":
3938
+ return gr.skip(), gr.skip()
3939
+ return render_dynamic_parallel(*args, tube=False)
3940
+
3941
+
3942
+ def undo_last_split(undo_stack: list[list[dict]] | None) -> tuple:
3943
+ """Restore the shapes as they were before the most recent split.
3944
+
3945
+ The undo slot is a STACK of pre-split snapshots (one per split, newest
3946
+ last), so repeated splits can be unwound one at a time."""
3947
+ stack = list(undo_stack or [])
3948
+ if not stack:
3949
+ return (
3950
+ *(gr.skip() for _ in range(7)),
3951
+ "Nothing to undo: no split has been made yet.",
3952
+ gr.skip(),
3953
+ gr.skip(),
3954
+ )
3955
+ snapshot = stack.pop()
3956
+ records = [dict(record) for record in snapshot]
3957
+ status = "Undid the last split: restored the shapes as they were before it."
3958
+ if stack:
3959
+ plural = "s" if len(stack) != 1 else ""
3960
+ status += f" ({len(stack)} earlier split{plural} can still be undone.)"
3961
+ return (
3962
+ records,
3963
+ _shape_settings_rows(records),
3964
+ _dropdown_update(records),
3965
+ [record.get("gcode_path") for record in records if record.get("gcode_path")],
3966
+ _gcode_dropdown_update(records),
3967
+ _gcode_dropdown_update(records, include_upload=True),
3968
+ _dropdown_update(records),
3969
+ status,
3970
+ _gcode_zip_update(records),
3971
+ stack or None,
3972
+ )
3973
+
3974
+
3975
+ def _gcode_settings_snapshot(
3976
+ record: dict,
3977
+ raster_pattern: str | None,
3978
+ pressure_ramp_enabled: bool,
3979
+ lead_in_length: float,
3980
+ lead_in_clearance: float,
3981
+ lead_in_lines: float,
3982
+ lead_in_direction: str | None,
3983
+ layer_height: float,
3984
+ fil_width: float,
3985
+ scale_mode: str | None,
3986
+ ) -> dict:
3987
+ """Fingerprint of every setting that shapes this record's G-code.
3988
+
3989
+ Stamped on the record at generation time; the staleness banner compares
3990
+ it against the CURRENT table/options so outdated files are never
3991
+ downloaded or printed unnoticed. Color is excluded (it never reaches
3992
+ the G-code)."""
3993
+ return {
3994
+ "targets": tuple(
3995
+ round(_coerce_float(record.get(key), 0.0), 6) for key in TARGET_DIMENSION_KEYS
3996
+ ),
3997
+ "pressure": round(_coerce_float(record.get("pressure"), 25.0), 6),
3998
+ "valve": _coerce_int(record.get("valve"), 4),
3999
+ "port": _coerce_int(record.get("port"), 1),
4000
+ "nozzle": _record_nozzle_number(record, int(record.get("idx", 1) or 1)),
4001
+ "infill": round(_coerce_float(record.get("infill"), 100.0), 6),
4002
+ "contour_tracing": bool(record.get("contour_tracing")),
4003
+ "lead_in": bool(record.get("lead_in")),
4004
+ "raster_pattern": str(raster_pattern or ""),
4005
+ "pressure_ramp": bool(pressure_ramp_enabled),
4006
+ "lead_in_params": (
4007
+ round(_coerce_float(lead_in_length, 5.0), 6),
4008
+ round(_coerce_float(lead_in_clearance, 5.0), 6),
4009
+ _coerce_int(lead_in_lines, 3),
4010
+ str(lead_in_direction or ""),
4011
+ ),
4012
+ "layer_height": round(_coerce_float(layer_height, 0.8), 6),
4013
+ "fil_width": round(_coerce_float(fil_width, 0.8), 6),
4014
+ "scale_mode": _normalize_scale_mode(scale_mode),
4015
+ }
4016
+
4017
+
4018
+ GCODE_STALE_MESSAGE = (
4019
+ "&#9888;&#65039; **Settings changed since this G-code was generated** — "
4020
+ "press Generate G-Code again before downloading, visualizing, or printing."
4021
+ )
4022
+
4023
+
4024
+ def check_gcode_staleness(
4025
+ records: list[dict] | None,
4026
+ settings_table: Any,
4027
+ raster_pattern: str | None,
4028
+ pressure_ramp_enabled: bool,
4029
+ lead_in_length: float,
4030
+ lead_in_clearance: float,
4031
+ lead_in_lines: float,
4032
+ lead_in_direction: str | None,
4033
+ layer_height: float,
4034
+ fil_width: float,
4035
+ scale_mode: str | None,
4036
+ ) -> str:
4037
+ """Warning banner text when generated G-code no longer matches the settings."""
4038
+ records = _apply_shape_settings(records or [], settings_table)
4039
+ if not any(record.get("gcode_path") for record in records):
4040
+ return "" # nothing generated yet: nothing can be stale
4041
+ snapshot_args = (
4042
+ raster_pattern,
4043
+ pressure_ramp_enabled,
4044
+ lead_in_length,
4045
+ lead_in_clearance,
4046
+ lead_in_lines,
4047
+ lead_in_direction,
4048
+ layer_height,
4049
+ fil_width,
4050
+ scale_mode,
4051
+ )
4052
+ for record in records:
4053
+ if not record.get("gcode_path"):
4054
+ # A shape added after the last generation has no file at all.
4055
+ return GCODE_STALE_MESSAGE
4056
+ if record.get("gcode_snapshot") != _gcode_settings_snapshot(record, *snapshot_args):
4057
+ return GCODE_STALE_MESSAGE
4058
+ return ""
4059
+
4060
+
4061
+ def assign_unique_valves(
4062
+ records: list[dict] | None, settings_table: Any
4063
+ ) -> tuple[list[dict], list[list[Any]]]:
4064
+ """Give every shape its own valve: first come keeps its number, later
4065
+ duplicates (and unset valves) move to the smallest unused number."""
4066
+ records = _apply_shape_settings(records or [], settings_table)
4067
+ updated = [dict(record) for record in records]
4068
+ claimed: set[int] = set()
4069
+ reassign: list[dict] = []
4070
+ for record in updated:
4071
+ valve = _coerce_int(record.get("valve"), 0)
4072
+ if valve > 0 and valve not in claimed:
4073
+ claimed.add(valve)
4074
+ else:
4075
+ reassign.append(record)
4076
+ for record in reassign:
4077
+ valve = _next_unused_valve(claimed)
4078
+ record["valve"] = valve
4079
+ claimed.add(valve)
4080
+ return updated, _shape_settings_rows(updated)
4081
+
4082
+
4083
  def generate_dynamic_gcode(
4084
  records: list[dict] | None,
4085
  settings_table: Any,
 
4092
  layer_height: float,
4093
  fil_width: float,
4094
  scale_mode: str | None,
4095
+ nozzle_speed: Any = None,
4096
  ) -> tuple:
4097
  records = _apply_shape_settings(records or [], settings_table)
4098
  messages: list[str] = []
 
4137
  f"Lead-in clearance extended by {lead_in_extension:.1f} mm so every "
4138
  "nozzle's purge patch lands clear of the split assembly."
4139
  )
4140
+ # The pressure regulator is a PORT device: when shapes share a serial
4141
+ # port, exactly ONE of their files owns the pressure commands (preset,
4142
+ # toggle, per-layer ramp) — the print host compiles every file onto one
4143
+ # timeline and duplicated toggles would flip the regulator on/off/on.
4144
+ ports_owned: set[int] = set()
4145
  for record in records:
4146
  stack = record.get("layer_stack")
4147
  if stack is None or not getattr(stack, "layers", None):
 
4151
  messages.append(f"Shape {record['idx']}: skipped (no combined reference outline; slice a shape first).")
4152
  continue
4153
  shape_name = str(record.get("name") or stack.name or f"shape_{record['idx']}").replace(" ", "_")
4154
+ port_number = _coerce_int(record.get("port"), 1)
4155
+ owns_port_pressure = port_number not in ports_owned
4156
  try:
4157
  gcode_path = generate_vector_gcode(
4158
  stack,
 
4173
  active_contour_owner=int(record.get("idx", 0)),
4174
  infill=_coerce_float(record.get("infill", 100.0), 100.0) / 100.0,
4175
  motion_infill_fractions=motion_infill_fractions,
4176
+ emit_pressure_commands=owns_port_pressure,
4177
  lead_in_enabled=bool(lead_in_enabled),
4178
  lead_in_length=float(lead_in_length),
4179
  lead_in_clearance=effective_lead_in_clearance,
 
4183
  wall_sources=wall_sources,
4184
  origin_sink=(origin_sink := {}),
4185
  )
4186
+ ports_owned.add(port_number)
4187
  record["gcode_path"] = str(gcode_path)
4188
  # World anchor of the toolpath (kept OUT of the G-code file):
4189
  # Auto Align and the visualizations read it from the record.
4190
  record["path_origin"] = origin_sink.get("path_origin")
4191
+ # Fingerprint the settings this file was generated with, so the
4192
+ # staleness banner can flag later edits.
4193
+ record["gcode_snapshot"] = _gcode_settings_snapshot(
4194
+ record,
4195
+ raster_pattern,
4196
+ pressure_ramp_enabled,
4197
+ lead_in_length,
4198
+ lead_in_clearance,
4199
+ lead_in_lines,
4200
+ lead_in_direction,
4201
+ layer_height,
4202
+ fil_width,
4203
+ scale_mode,
4204
+ )
4205
  messages.append(f"Shape {record['idx']}: wrote `{gcode_path.name}`.")
4206
  except Exception as exc:
4207
  messages.append(f"Shape {record['idx']}: failed ({exc}).")
4208
+
4209
+ generated_paths = [record.get("gcode_path") for record in records if record.get("gcode_path")]
4210
+ if generated_paths:
4211
+ # All heads share one motion path, so one file's length is the job's.
4212
+ try:
4213
+ shared_length = _parsed_path_length(parse_gcode_path(Path(generated_paths[0]).read_text()))
4214
+ except OSError:
4215
+ shared_length = 0.0
4216
+ speed = _coerce_float(nozzle_speed, 0.0) or 10.0
4217
+ estimate = _print_time_estimate(shared_length, speed)
4218
+ if estimate:
4219
+ messages.insert(
4220
+ 0,
4221
+ f"**Print path {shared_length:,.0f} mm — about {estimate} at {speed:g} mm/s** "
4222
+ "(constant speed; the Visualization tab's Nozzle Speed sets the rate).",
4223
+ )
4224
  return (
4225
  records,
4226
  ref_layers,
4227
+ generated_paths,
4228
  "\n".join(messages),
4229
  _gcode_dropdown_update(records),
4230
  _gcode_dropdown_update(records, include_upload=True),
 
4545
  )
4546
  sync_uploads_button = gr.Button("Sync Uploaded STLs", variant="secondary", size="sm")
4547
  reset_dimensions_button = gr.Button("Reset Dimensions", variant="secondary", size="sm")
4548
+ assign_valves_button = gr.Button("Assign Unique Valves", variant="secondary", size="sm")
4549
  scale_mode = gr.Radio(
4550
  choices=[SCALE_MODE_TARGET_DIMENSIONS, SCALE_MODE_UNIFORM_FACTOR],
4551
  value=SCALE_MODE_TARGET_DIMENSIONS,
 
4619
  split_start_nozzle = gr.Number(label="Starting Nozzle", value=1, minimum=1, step=1)
4620
  split_start_valve = gr.Number(label="Starting Valve", value=4, minimum=1, step=1)
4621
  split_overlapping_layers = gr.Checkbox(label="Overlapping Layers", value=False)
4622
+ with gr.Row():
4623
+ split_button = gr.Button("Split Selected Shape into Grid Pieces", variant="primary", scale=3)
4624
+ split_undo_button = gr.Button("Undo Split", variant="secondary", size="sm", scale=1, min_width=110)
4625
  split_status = gr.Markdown(SPLIT_STATUS_DEFAULT)
4626
+ split_undo = gr.State(None)
4627
 
4628
  with gr.Accordion("Selected Shape Preview", open=False, elem_classes=["settings-accordion"]):
4629
  with gr.Row():
 
4678
  value=LEAD_IN_DIRECTION_LEFT,
4679
  allow_custom_value=False,
4680
  )
4681
+ gcode_stale_banner = gr.Markdown("", elem_id="gcode-stale-banner")
4682
  gcode_button = gr.Button("Generate G-Code", variant="primary")
4683
  with gr.Row():
4684
  with gr.Column(scale=4):
 
4695
  refresh_gcode_text_button = gr.Button("Refresh G-Code Preview", variant="secondary", size="sm")
4696
  gcode_text = gr.Code(label="Selected G-Code", language=None, lines=18, max_lines=18, interactive=False, elem_classes=["gcode-view"])
4697
 
4698
+ with gr.Tab("Visualization") as viz_tab:
4699
  gr.Markdown(
4700
  "### Print Visualization\n"
4701
  "Defaults to the parallel print of every generated shape, laid out with the "
 
4952
  fil_width,
4953
  layer_height,
4954
  scale_mode,
4955
+ split_undo,
4956
  ],
4957
  outputs=[
4958
  shape_records,
 
4964
  split_source,
4965
  split_status,
4966
  gcode_download_all,
4967
+ split_undo,
4968
  ],
4969
  ).then(
4970
  fn=generate_dynamic_reference_stack,
 
4980
  outputs=[nozzle_grid_spacing_table],
4981
  queue=False,
4982
  )
4983
+ split_undo_button.click(
4984
+ fn=undo_last_split,
4985
+ inputs=[split_undo],
4986
+ outputs=[
4987
+ shape_records,
4988
+ shape_settings,
4989
+ selected_shape,
4990
+ gcode_downloads,
4991
+ gcode_text_source,
4992
+ gcode_source,
4993
+ split_source,
4994
+ split_status,
4995
+ gcode_download_all,
4996
+ split_undo,
4997
+ ],
4998
+ ).then(
4999
+ fn=generate_dynamic_reference_stack,
5000
+ inputs=[shape_records, fil_width],
5001
+ outputs=[ref_layers],
5002
+ ).then(
5003
+ fn=update_layer_preview,
5004
+ inputs=layer_preview_inputs,
5005
+ outputs=layer_preview_outputs,
5006
+ ).then(
5007
+ fn=_grid_spacing_table_update,
5008
+ inputs=grid_spacing_refresh_inputs,
5009
+ outputs=[nozzle_grid_spacing_table],
5010
+ queue=False,
5011
+ )
5012
+ assign_valves_button.click(
5013
+ fn=assign_unique_valves,
5014
+ inputs=[shape_records, shape_settings],
5015
+ outputs=[shape_records, shape_settings],
5016
+ queue=False,
5017
+ )
5018
+
5019
+ # Stale-G-code banner: re-checked on every table change and every
5020
+ # generation option change; generation itself re-stamps the
5021
+ # snapshots, so its chain clears the banner.
5022
+ stale_inputs = [
5023
+ shape_records,
5024
+ shape_settings,
5025
+ gcode_raster_pattern,
5026
+ gcode_pressure_ramp_enabled,
5027
+ gcode_lead_in_length,
5028
+ gcode_lead_in_clearance,
5029
+ gcode_lead_in_lines,
5030
+ gcode_lead_in_direction,
5031
+ layer_height,
5032
+ fil_width,
5033
+ scale_mode,
5034
+ ]
5035
+ shape_settings.change(
5036
+ fn=check_gcode_staleness,
5037
+ inputs=stale_inputs,
5038
+ outputs=[gcode_stale_banner],
5039
+ queue=False,
5040
+ )
5041
+ for stale_control in (
5042
+ gcode_raster_pattern,
5043
+ gcode_pressure_ramp_enabled,
5044
+ gcode_lead_in_length,
5045
+ gcode_lead_in_clearance,
5046
+ gcode_lead_in_lines,
5047
+ gcode_lead_in_direction,
5048
+ layer_height,
5049
+ fil_width,
5050
+ scale_mode,
5051
+ ):
5052
+ stale_control.change(
5053
+ fn=check_gcode_staleness,
5054
+ inputs=stale_inputs,
5055
+ outputs=[gcode_stale_banner],
5056
+ queue=False,
5057
+ )
5058
+
5059
+ # Defined before the generate chain so it can auto-render the
5060
+ # parallel view with fresh files (the same lists drive the
5061
+ # Visualization tab wiring further down).
5062
+ parallel_render_inputs = [
5063
+ shape_records,
5064
+ shape_settings,
5065
+ pp_travel_opacity,
5066
+ pp_filament_width,
5067
+ pp_travel_width,
5068
+ nozzle_grid_columns,
5069
+ nozzle_grid_rows,
5070
+ nozzle_grid_column_spacing,
5071
+ nozzle_grid_row_spacing,
5072
+ nozzle_grid_use_individual_spacing,
5073
+ nozzle_grid_spacing_table,
5074
+ viz_nozzle_speed,
5075
+ ]
5076
+ parallel_outputs = [parallel_plot, parallel_status, parallel_mode, parallel_anim_controls, pp_width_row, pp_export_group]
5077
 
5078
  gcode_button.click(
5079
  fn=generate_dynamic_gcode,
 
5089
  layer_height,
5090
  fil_width,
5091
  scale_mode,
5092
+ viz_nozzle_speed,
5093
  ],
5094
  outputs=[shape_records, ref_layers, gcode_downloads, gcode_status, gcode_text_source, gcode_source, gcode_download_all],
5095
  ).then(
 
5107
  inputs=[shape_records],
5108
  outputs=[split_source],
5109
  queue=False,
5110
+ ).then(
5111
+ fn=check_gcode_staleness,
5112
+ inputs=stale_inputs,
5113
+ outputs=[gcode_stale_banner],
5114
+ queue=False,
5115
+ ).then(
5116
+ # Fresh files: refresh the parallel view so the Visualization
5117
+ # tab always shows the current print.
5118
+ fn=render_dynamic_parallel_lines,
5119
+ inputs=parallel_render_inputs,
5120
+ outputs=parallel_outputs,
5121
  )
5122
  gcode_text_source.change(fn=load_selected_gcode_text, inputs=[shape_records, gcode_text_source], outputs=[gcode_text])
5123
  refresh_gcode_text_button.click(fn=load_selected_gcode_text, inputs=[shape_records, gcode_text_source], outputs=[gcode_text])
 
5240
  queue=False,
5241
  )
5242
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5243
  parallel_line_button.click(fn=render_dynamic_parallel_lines, inputs=parallel_render_inputs, outputs=parallel_outputs)
5244
  parallel_render_button.click(fn=render_dynamic_parallel_tubes, inputs=parallel_render_inputs, outputs=parallel_outputs)
5245
+ viz_tab.select(
5246
+ fn=refresh_parallel_on_tab_select,
5247
+ inputs=[parallel_mode] + parallel_render_inputs,
5248
+ outputs=[parallel_plot, parallel_status],
5249
+ )
5250
  pp_filament_width.release(fn=rerender_dynamic_parallel_current_mode, inputs=[parallel_mode] + parallel_render_inputs, outputs=[parallel_plot, parallel_status])
5251
  pp_travel_width.release(fn=rerender_dynamic_parallel_current_mode, inputs=[parallel_mode] + parallel_render_inputs, outputs=[parallel_plot, parallel_status])
5252
  pp_export_button.click(
tests/test_nozzle_spacing.py CHANGED
@@ -313,6 +313,155 @@ def test_sample_reload_appends_next_nozzle_set() -> None:
313
  assert [record["nozzle"] for record in second_load] == [1, 2, 3, 4, 5, 6]
314
 
315
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
316
  def test_print_time_estimate_from_path_length_and_speed() -> None:
317
  from app import _parsed_path_length, _print_time_estimate
318
 
 
313
  assert [record["nozzle"] for record in second_load] == [1, 2, 3, 4, 5, 6]
314
 
315
 
316
+ def test_assign_unique_valves_keeps_first_and_reassigns_duplicates() -> None:
317
+ from app import assign_unique_valves
318
+
319
+ records = [
320
+ _mm_member(1, 1, 10.0, 10.0, 10.0),
321
+ _mm_member(2, 2, 10.0, 10.0, 10.0),
322
+ _mm_member(3, 3, 10.0, 10.0, 10.0),
323
+ _mm_member(4, 4, 10.0, 10.0, 10.0),
324
+ ]
325
+ records[0]["valve"] = 4
326
+ records[1]["valve"] = 4 # duplicate: must move
327
+ records[2]["valve"] = 5 # unique: must stay
328
+ records[3]["valve"] = 0 # unset: must be assigned
329
+
330
+ updated, rows = assign_unique_valves(records, None)
331
+ valves = [record["valve"] for record in updated]
332
+ assert valves[0] == 4 # first occurrence keeps its number
333
+ assert valves[2] == 5
334
+ assert len(set(valves)) == 4
335
+ assert all(valve >= 4 for valve in valves)
336
+ valve_pos = SHAPE_SETTINGS_HEADERS.index("Valve")
337
+ assert [row[valve_pos] for row in rows] == valves
338
+
339
+
340
+ def test_undo_split_unwinds_multiple_splits() -> None:
341
+ from shapely.geometry import MultiPolygon, box
342
+
343
+ from app import _slice_params_snapshot, split_selected_shape_for_grid, undo_last_split
344
+ from stl_slicer import LayerStack
345
+
346
+ stack = LayerStack(
347
+ layers=[MultiPolygon([box(0.0, 0.0, 20.0, 10.0)])],
348
+ z_values=[0.4],
349
+ bounds=((0.0, 0.0, 0.0), (20.0, 10.0, 0.8)),
350
+ layer_height=0.8,
351
+ name="solo",
352
+ )
353
+ record = _mm_member(1, 1, 20.0, 10.0, 0.8)
354
+ record["name"] = "solo"
355
+ record["layer_stack"] = stack
356
+ record["slice_params"] = _slice_params_snapshot(record, 0.8, None, None)
357
+
358
+ # Split once, then split one of the pieces: the undo stack grows.
359
+ first = split_selected_shape_for_grid([record], None, None, 2, 1, False, 5, 9, 1.0)
360
+ pieces = first[0]
361
+ stack_one = first[-1]
362
+ assert len(pieces) == 2
363
+ assert isinstance(stack_one, list) and len(stack_one) == 1
364
+
365
+ second = split_selected_shape_for_grid(
366
+ pieces, None, None, 2, 1, False, 7, 11, 1.0, 0.8, None, stack_one
367
+ )
368
+ stack_two = second[-1]
369
+ assert len(stack_two) == 2
370
+
371
+ # Undo pops one split at a time, newest first.
372
+ undo_one = undo_last_split(stack_two)
373
+ assert [r["name"] for r in undo_one[0]] == [r["name"] for r in pieces]
374
+ assert "1 earlier split" in undo_one[7]
375
+ assert len(undo_one[-1]) == 1
376
+
377
+ undo_two = undo_last_split(undo_one[-1])
378
+ assert [r["name"] for r in undo_two[0]] == ["solo"]
379
+ assert undo_two[0][0]["layer_stack"] is stack
380
+ assert undo_two[-1] is None # fully unwound
381
+
382
+ # Nothing to undo: every data output is skipped.
383
+ empty = undo_last_split(None)
384
+ assert not isinstance(empty[0], list)
385
+
386
+
387
+ def test_generation_gives_pressure_ownership_to_one_shape_per_port() -> None:
388
+ from pathlib import Path
389
+
390
+ from shapely.geometry import MultiPolygon, box
391
+
392
+ from app import generate_dynamic_gcode
393
+ from stl_slicer import LayerStack
394
+
395
+ def _record(idx: int, port: int, valve: int) -> dict:
396
+ stack = LayerStack(
397
+ layers=[MultiPolygon([box(0.0, 0.0, 5.0, 5.0)])],
398
+ z_values=[0.4],
399
+ bounds=((0.0, 0.0, 0.0), (5.0, 5.0, 0.8)),
400
+ layer_height=0.8,
401
+ name=f"p{idx}",
402
+ )
403
+ return {
404
+ "idx": idx, "name": f"p{idx}", "stl_path": None,
405
+ "target_x": 5.0, "target_y": 5.0, "target_z": 0.8,
406
+ "pressure": 25.0, "valve": valve, "nozzle": idx, "port": port,
407
+ "color": "#111111", "infill": 100.0, "layer_stack": stack,
408
+ }
409
+
410
+ # Shapes 1+2 share port 1 (one regulator); shape 3 is alone on port 2.
411
+ records = [_record(1, 1, 4), _record(2, 1, 5), _record(3, 2, 6)]
412
+ out = generate_dynamic_gcode(
413
+ records, None, "X-direction raster", True, 5.0, 5.0, 3, "Left", 0.8, 0.8, None
414
+ )
415
+ texts = [Path(record["gcode_path"]).read_text() for record in out[0]]
416
+
417
+ assert "setpress" in texts[0] and "togglepress" in texts[0]
418
+ # The port-mate's file carries NO pressure commands at all.
419
+ assert "serialPort" not in texts[1]
420
+ # A shape on its own port owns its regulator.
421
+ assert "setpress" in texts[2] and "serialPort2" in texts[2]
422
+ # Valve control stays per shape in every file.
423
+ assert "WAGO_ValveCommands(5, 1)" in texts[1]
424
+
425
+
426
+ def test_gcode_staleness_banner_flags_edits_after_generation() -> None:
427
+ from app import (
428
+ GCODE_STALE_MESSAGE,
429
+ _gcode_settings_snapshot,
430
+ check_gcode_staleness,
431
+ )
432
+
433
+ settings_args = ("X-direction raster", True, 5.0, 5.0, 3, "Left", 0.8, 0.8, None)
434
+ record = _mm_member(1, 1, 20.0, 10.0, 5.0)
435
+ record["gcode_path"] = "fake_gcode.txt"
436
+ record["gcode_snapshot"] = _gcode_settings_snapshot(record, *settings_args)
437
+ records = [record]
438
+ rows = _shape_settings_rows(records)
439
+
440
+ # Untouched settings: no banner.
441
+ assert check_gcode_staleness(records, rows, *settings_args) == ""
442
+
443
+ # Editing a table cell (pressure) makes the file stale.
444
+ pressure_pos = SHAPE_SETTINGS_HEADERS.index("Pressure (psi)")
445
+ edited = _shape_settings_rows(records)
446
+ edited[0][pressure_pos] = 40.0
447
+ assert check_gcode_staleness(records, edited, *settings_args) == GCODE_STALE_MESSAGE
448
+
449
+ # Changing a generation option (raster pattern) is stale too.
450
+ changed = ("Circle Spiral raster", *settings_args[1:])
451
+ assert check_gcode_staleness(records, rows, *changed) == GCODE_STALE_MESSAGE
452
+
453
+ # A shape added after generation (no G-code yet) is stale.
454
+ with_new = [record, _mm_member(2, 2, 10.0, 10.0, 10.0)]
455
+ assert (
456
+ check_gcode_staleness(with_new, _shape_settings_rows(with_new), *settings_args)
457
+ == GCODE_STALE_MESSAGE
458
+ )
459
+
460
+ # Nothing generated at all: nothing can be stale.
461
+ fresh = [_mm_member(1, 1, 10.0, 10.0, 10.0)]
462
+ assert check_gcode_staleness(fresh, _shape_settings_rows(fresh), *settings_args) == ""
463
+
464
+
465
  def test_print_time_estimate_from_path_length_and_speed() -> None:
466
  from app import _parsed_path_length, _print_time_estimate
467
 
tests/test_vector_gcode.py CHANGED
@@ -217,8 +217,43 @@ def test_gcode_header_writes_presets_before_initial_aux_commands(tmp_path) -> No
217
  # {preset} marks pressure setup for the Aerotech host runtime.
218
  assert lines[2] == "{preset}serialPort3.write(eval(setpress(25)))"
219
  assert lines[3] == "{preset}serialPort3.write(eval(togglepress()))"
220
- assert lines[4].startswith("{aux_command}WAGO_ValveCommands(")
221
- assert lines[5].startswith("{aux_command}WAGO_ValveCommands(")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
222
 
223
 
224
  def test_gcode_lead_in_runs_once_before_first_layer(tmp_path) -> None:
 
217
  # {preset} marks pressure setup for the Aerotech host runtime.
218
  assert lines[2] == "{preset}serialPort3.write(eval(setpress(25)))"
219
  assert lines[3] == "{preset}serialPort3.write(eval(togglepress()))"
220
+ # The header ends there: no dummy valve-100 init, no duplicate close.
221
+ assert lines[4].startswith("G")
222
+ assert not any("WAGO_ValveCommands(100" in line for line in lines)
223
+
224
+
225
+ def test_port_sharing_files_emit_pressure_commands_once(tmp_path) -> None:
226
+ # The pressure regulator is a PORT device: a file generated WITHOUT
227
+ # pressure ownership carries no serial commands at all (no preset, no
228
+ # toggle, no per-layer ramp) — its valve commands are untouched.
229
+ layer = box(0.0, 0.0, 2.0, 2.0)
230
+ stack = _stack(layer, layer)
231
+
232
+ def _generate(emit: bool, label: str) -> str:
233
+ path = generate_vector_gcode(
234
+ stack,
235
+ shape_name=label,
236
+ pressure=25,
237
+ valve=7,
238
+ port=3,
239
+ fil_width=1.0,
240
+ layer_height=1.0,
241
+ pressure_ramp_enabled=True,
242
+ emit_pressure_commands=emit,
243
+ output_dir=tmp_path / label,
244
+ )
245
+ return path.read_text()
246
+
247
+ owner = _generate(True, "owner")
248
+ follower = _generate(False, "follower")
249
+
250
+ assert "serialPort3" in owner
251
+ assert _pressure_set_count(owner) >= 2 # preset + at least one ramp step
252
+ assert "serialPort3" not in follower
253
+ assert _pressure_set_count(follower) == 0
254
+ assert "togglepress" not in follower
255
+ # Valve control is per shape and unaffected.
256
+ assert follower.count("WAGO_ValveCommands(7, 1)") == owner.count("WAGO_ValveCommands(7, 1)")
257
 
258
 
259
  def test_gcode_lead_in_runs_once_before_first_layer(tmp_path) -> None:
vector_gcode.py CHANGED
@@ -117,7 +117,16 @@ def write_gcode_file(
117
  increase_pressure_per_layer: float,
118
  pressure_ramp_enabled: bool,
119
  all_g1: bool,
 
120
  ) -> None:
 
 
 
 
 
 
 
 
121
  off_color = 0
122
  com_port = f"serialPort{port}"
123
  color_dict: dict[int, int] = {0: 100, 255: valve}
@@ -131,12 +140,11 @@ def write_gcode_file(
131
  with open(gcode_path, "w") as f:
132
  f.write("G91\n")
133
  f.write(_valve_cmd(valve, 0))
134
- for line in setpress_lines:
135
- f.write(f"{line}\n")
136
- for line in pressure_on_lines:
137
- f.write(f"{line}\n")
138
- for color in color_dict:
139
- f.write(_valve_cmd(color_dict[color], 0))
140
 
141
  pressure_next: str | None = None
142
  for i, move in enumerate(gcode_list):
@@ -160,7 +168,7 @@ def write_gcode_file(
160
  f"{move_type} X{_coord(move['X'])} Y{_coord(move['Y'])} "
161
  f"Z{_coord(move['Z'])} ; Color {move['Color']}"
162
  )
163
- if pressure_ramp_enabled:
164
  pressure_cur += increase_pressure_per_layer
165
  pressure_next = _setpress_cmd(com_port, pressure_cur, start=False)
166
  else:
@@ -177,10 +185,10 @@ def write_gcode_file(
177
  f.write(f"{pressure_next}\n")
178
  pressure_next = None
179
 
180
- for color in color_dict:
181
- f.write(_valve_cmd(color_dict[color], 0))
182
- for line in pressure_off_lines:
183
- f.write(f"{line}\n")
184
 
185
 
186
  def generate_vector_gcode(
@@ -198,6 +206,7 @@ def generate_vector_gcode(
198
  active_contour_owner: int | None = None,
199
  infill: float = 1.0,
200
  motion_infill_fractions: list[float] | None = None,
 
201
  increase_pressure_per_layer: float = 0.1,
202
  pressure_ramp_enabled: bool = True,
203
  all_g1: bool = False,
@@ -376,5 +385,6 @@ def generate_vector_gcode(
376
  increase_pressure_per_layer=float(increase_pressure_per_layer),
377
  pressure_ramp_enabled=bool(pressure_ramp_enabled),
378
  all_g1=bool(all_g1),
 
379
  )
380
  return gcode_path
 
117
  increase_pressure_per_layer: float,
118
  pressure_ramp_enabled: bool,
119
  all_g1: bool,
120
+ emit_pressure_commands: bool = True,
121
  ) -> None:
122
+ """Write the move list as a G-code file.
123
+
124
+ `emit_pressure_commands` gates EVERY pressure command (preset, toggle,
125
+ per-layer ramp, closing toggle): the pressure regulator is a PORT
126
+ device, so when several shapes share a serial port only ONE of their
127
+ files may own it — the print host compiles all files onto one timeline,
128
+ and duplicated toggles would flip the regulator on/off/on at start.
129
+ """
130
  off_color = 0
131
  com_port = f"serialPort{port}"
132
  color_dict: dict[int, int] = {0: 100, 255: valve}
 
140
  with open(gcode_path, "w") as f:
141
  f.write("G91\n")
142
  f.write(_valve_cmd(valve, 0))
143
+ if emit_pressure_commands:
144
+ for line in setpress_lines:
145
+ f.write(f"{line}\n")
146
+ for line in pressure_on_lines:
147
+ f.write(f"{line}\n")
 
148
 
149
  pressure_next: str | None = None
150
  for i, move in enumerate(gcode_list):
 
168
  f"{move_type} X{_coord(move['X'])} Y{_coord(move['Y'])} "
169
  f"Z{_coord(move['Z'])} ; Color {move['Color']}"
170
  )
171
+ if pressure_ramp_enabled and emit_pressure_commands:
172
  pressure_cur += increase_pressure_per_layer
173
  pressure_next = _setpress_cmd(com_port, pressure_cur, start=False)
174
  else:
 
185
  f.write(f"{pressure_next}\n")
186
  pressure_next = None
187
 
188
+ f.write(_valve_cmd(valve, 0))
189
+ if emit_pressure_commands:
190
+ for line in pressure_off_lines:
191
+ f.write(f"{line}\n")
192
 
193
 
194
  def generate_vector_gcode(
 
206
  active_contour_owner: int | None = None,
207
  infill: float = 1.0,
208
  motion_infill_fractions: list[float] | None = None,
209
+ emit_pressure_commands: bool = True,
210
  increase_pressure_per_layer: float = 0.1,
211
  pressure_ramp_enabled: bool = True,
212
  all_g1: bool = False,
 
385
  increase_pressure_per_layer=float(increase_pressure_per_layer),
386
  pressure_ramp_enabled=bool(pressure_ramp_enabled),
387
  all_g1=bool(all_g1),
388
+ emit_pressure_commands=bool(emit_pressure_commands),
389
  )
390
  return gcode_path