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

Editable pressure variable in G-code files, with a fallback checkbox

Browse files

Pressure-owning files now start with a hand-editable variable line
({preset}{aux_command}globals().update(pressureN = X)) that every
pressure command references — the preset is setpress(pressureN) and each
ramp step is round(min(pressureN + k, 100), 1) — so a print's pressure
can be changed by editing one number in the file, no regeneration
needed. The line's shape is dictated by the TCode host: {aux_command}
keeps it off the motion timeline (routing is by substring), presets are
exec'd as one list literal so only expressions survive, and the name is
per-port because all files share one exec namespace.

An "Editable Pressure Variable" checkbox (on by default) falls back to
the plain numeric format in case a host cannot run the variable form;
it participates in the staleness banner and settings export/import.
Verified against a simulation of TCode.py's routing/exec pipeline,
including hand-edit rebasing and multi-port independence.

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

README.md CHANGED
@@ -123,9 +123,10 @@ The **Multi-Nozzle Split** accordion on the **Shapes & G-Code** tab can split on
123
  - Generated G-code starts in relative coordinate mode (`G91`).
124
  - The app opens the valve where the tool path is inside a shape's layer polygons and closes it elsewhere.
125
  - 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.
126
- - 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.
 
127
  - Pressure increases by `0.1` psi per layer by default.
128
- - Pressures are bounded to the regulator's range: table values clamp to 0–100 psi and snap to the tenths place (the serial protocol encodes `pressure × 10`), the per-layer ramp never climbs past 100 psi in the files, and the generation status warns when a ramp would hit the ceiling before the last layer.
129
  - 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.
130
  - **Lead In**: enabled per shape via the **Lead In** column in Shape Settings; the Lead In Options accordion sets the patch geometry. Prints a purge patch before layer 1: **Lead In Position** (Left/Right/Up/Down) picks which side of the shape it sits on, **Lead In Clearance** how far away, and **Lead In Line Direction** which way the purge strokes run — Auto points them at the shape (the historical behavior), or force Horizontal/Vertical (e.g. a patch below the shape with horizontal strokes). The purge always finishes at the patch corner nearest the toolpath start, whatever the line count's parity: strokes running across the approach print the farthest line first and step back toward the shape, and the zigzag starts from whichever end makes the last stroke land on the start corner — so the freshly primed nozzle travels only the short clearance lane to the print, never 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.
131
  - **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.
 
123
  - Generated G-code starts in relative coordinate mode (`G91`).
124
  - The app opens the valve where the tool path is inside a shape's layer polygons and closes it elsewhere.
125
  - 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.
126
+ - Pressure commands are written **once per port**: when shapes share a serial port, only the first shape's file carries the pressure variable, 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.
127
+ - **Editable pressure variable** (checkbox, on by default): each pressure-owning file starts with `{preset}{aux_command}globals().update(pressure3 = 25)` (the value from the Shape Settings table, the variable named after the file's serial port), and every pressure command references that variable — the preset is `setpress(pressure3)` and each ramp step is `setpress(round(min(pressure3 + 0.1, 100), 1))`. To print the same file at a different pressure, edit the one number on the first line; the preset and the entire ramp follow, no regeneration needed. The line's exact shape is dictated by the TCode print host: `{aux_command}` keeps it off the motion timeline (only lines containing `{aux_command}`/`serialPort` are routed as commands), and the host execs all `{preset}` commands as one list literal, so the line must be a pure expression — `globals().update(...)` is the expression form of the assignment. Naming the variable per port keeps multi-port prints independent, since the host runs every file in one shared namespace. Unchecking **Editable Pressure Variable** writes plain numeric pressure commands instead (the pre-variable format, e.g. `setpress(25)` with ramp values baked in), as a fallback in case a host cannot run the variable form.
128
  - Pressure increases by `0.1` psi per layer by default.
129
+ - Pressures are bounded to the regulator's range: table values clamp to 0–100 psi and snap to the tenths place (the serial protocol encodes `pressure × 10`), the ramp expressions clamp at 100 psi even for hand-edited variable values, and the generation status warns when a ramp would hit the ceiling before the last layer.
130
  - 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.
131
  - **Lead In**: enabled per shape via the **Lead In** column in Shape Settings; the Lead In Options accordion sets the patch geometry. Prints a purge patch before layer 1: **Lead In Position** (Left/Right/Up/Down) picks which side of the shape it sits on, **Lead In Clearance** how far away, and **Lead In Line Direction** which way the purge strokes run — Auto points them at the shape (the historical behavior), or force Horizontal/Vertical (e.g. a patch below the shape with horizontal strokes). The purge always finishes at the patch corner nearest the toolpath start, whatever the line count's parity: strokes running across the approach print the farthest line first and step back toward the shape, and the zigzag starts from whichever end makes the last stroke land on the start corner — so the freshly primed nozzle travels only the short clearance lane to the print, never 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.
132
  - **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.
app.py CHANGED
@@ -4806,6 +4806,7 @@ def _gcode_settings_snapshot(
4806
  sweep_buffer: float = 0.8,
4807
  lead_in_orientation: str | None = None,
4808
  contour_order: str | None = None,
 
4809
  ) -> dict:
4810
  """Fingerprint of every setting that shapes this record's G-code.
4811
 
@@ -4839,6 +4840,7 @@ def _gcode_settings_snapshot(
4839
  "scale_mode": _normalize_scale_mode(scale_mode),
4840
  "sweep_buffer": round(_coerce_float(sweep_buffer, 0.8), 6),
4841
  "contour_order": str(contour_order or CONTOUR_ORDER_LAST),
 
4842
  }
4843
 
4844
 
@@ -4863,6 +4865,7 @@ def check_gcode_staleness(
4863
  sweep_buffer: float = 0.8,
4864
  lead_in_orientation: str | None = None,
4865
  contour_order: str | None = None,
 
4866
  ) -> str:
4867
  """Warning banner text when generated G-code no longer matches the settings."""
4868
  records = _apply_shape_settings(records or [], settings_table)
@@ -4881,6 +4884,7 @@ def check_gcode_staleness(
4881
  sweep_buffer,
4882
  lead_in_orientation,
4883
  contour_order,
 
4884
  )
4885
  for record in records:
4886
  if not record.get("gcode_path"):
@@ -4927,6 +4931,7 @@ def export_project_settings(
4927
  lead_in_orientation: str | None,
4928
  nozzle_speed: Any,
4929
  contour_order: str | None = None,
 
4930
  split_columns: Any = None,
4931
  split_rows: Any = None,
4932
  split_overlapping_layers: Any = None,
@@ -4970,6 +4975,7 @@ def export_project_settings(
4970
  "lead_in_orientation": str(lead_in_orientation or LEAD_IN_LINE_AUTO),
4971
  "nozzle_speed": _coerce_float(nozzle_speed, 10.0),
4972
  "contour_order": str(contour_order or CONTOUR_ORDER_LAST),
 
4973
  "split_columns": max(1, _coerce_int(split_columns, 2)),
4974
  "split_rows": max(1, _coerce_int(split_rows, 1)),
4975
  "split_overlapping_layers": bool(split_overlapping_layers),
@@ -5001,7 +5007,7 @@ def import_project_settings(
5001
  """
5002
 
5003
  def _skip_options() -> tuple:
5004
- return tuple(gr.skip() for _ in range(19))
5005
 
5006
  paths = _uploaded_file_paths(settings_upload)
5007
  if not paths:
@@ -5064,6 +5070,7 @@ def import_project_settings(
5064
  option("scale_mode"),
5065
  option("nozzle_speed"),
5066
  option("contour_order"),
 
5067
  option("split_columns"),
5068
  option("split_rows"),
5069
  option("split_overlapping_layers"),
@@ -5111,6 +5118,7 @@ def generate_dynamic_gcode(
5111
  sweep_buffer: float = 0.8,
5112
  lead_in_orientation: str | None = None,
5113
  contour_order: str | None = None,
 
5114
  progress: gr.Progress = gr.Progress(),
5115
  ) -> tuple:
5116
  records = _apply_shape_settings(records or [], settings_table)
@@ -5217,6 +5225,7 @@ def generate_dynamic_gcode(
5217
  infill=_coerce_float(record.get("infill", 100.0), 100.0) / 100.0,
5218
  motion_infill_fractions=motion_infill_fractions,
5219
  emit_pressure_commands=owns_port_pressure,
 
5220
  # Same buffer for every shape: the shared motion must match.
5221
  sweep_buffer=max(0.0, _coerce_float(sweep_buffer, 0.8)),
5222
  lead_in_enabled=bool(lead_in_enabled),
@@ -5250,6 +5259,7 @@ def generate_dynamic_gcode(
5250
  sweep_buffer,
5251
  lead_in_orientation,
5252
  contour_order,
 
5253
  )
5254
  messages.append(f"Shape {record['idx']}: wrote `{gcode_path.name}`.")
5255
  except Exception as exc:
@@ -5799,6 +5809,16 @@ def build_dynamic_demo() -> gr.Blocks:
5799
  """
5800
  )
5801
  gcode_pressure_ramp_enabled = gr.Checkbox(label="Increase Pressure Each Layer", value=True)
 
 
 
 
 
 
 
 
 
 
5802
  with gr.Row(elem_id="gcode-raster-row"):
5803
  gcode_raster_pattern = gr.Dropdown(
5804
  label="Raster Pattern",
@@ -6234,6 +6254,7 @@ def build_dynamic_demo() -> gr.Blocks:
6234
  gcode_lead_in_orientation,
6235
  viz_nozzle_speed,
6236
  gcode_contour_order,
 
6237
  split_columns,
6238
  split_rows,
6239
  split_overlapping_layers,
@@ -6264,6 +6285,7 @@ def build_dynamic_demo() -> gr.Blocks:
6264
  scale_mode,
6265
  viz_nozzle_speed,
6266
  gcode_contour_order,
 
6267
  split_columns,
6268
  split_rows,
6269
  split_overlapping_layers,
@@ -6291,6 +6313,7 @@ def build_dynamic_demo() -> gr.Blocks:
6291
  gcode_sweep_buffer,
6292
  gcode_lead_in_orientation,
6293
  gcode_contour_order,
 
6294
  ]
6295
  shape_settings.change(
6296
  fn=check_gcode_staleness,
@@ -6311,6 +6334,7 @@ def build_dynamic_demo() -> gr.Blocks:
6311
  gcode_sweep_buffer,
6312
  gcode_lead_in_orientation,
6313
  gcode_contour_order,
 
6314
  ):
6315
  stale_control.change(
6316
  fn=check_gcode_staleness,
@@ -6400,6 +6424,7 @@ def build_dynamic_demo() -> gr.Blocks:
6400
  gcode_sweep_buffer,
6401
  gcode_lead_in_orientation,
6402
  gcode_contour_order,
 
6403
  ],
6404
  outputs=[shape_records, ref_layers, gcode_downloads, gcode_status, gcode_text_source, gcode_source, gcode_download_all],
6405
  ).then(
 
4806
  sweep_buffer: float = 0.8,
4807
  lead_in_orientation: str | None = None,
4808
  contour_order: str | None = None,
4809
+ pressure_variable: bool = True,
4810
  ) -> dict:
4811
  """Fingerprint of every setting that shapes this record's G-code.
4812
 
 
4840
  "scale_mode": _normalize_scale_mode(scale_mode),
4841
  "sweep_buffer": round(_coerce_float(sweep_buffer, 0.8), 6),
4842
  "contour_order": str(contour_order or CONTOUR_ORDER_LAST),
4843
+ "pressure_variable": bool(pressure_variable),
4844
  }
4845
 
4846
 
 
4865
  sweep_buffer: float = 0.8,
4866
  lead_in_orientation: str | None = None,
4867
  contour_order: str | None = None,
4868
+ pressure_variable: bool = True,
4869
  ) -> str:
4870
  """Warning banner text when generated G-code no longer matches the settings."""
4871
  records = _apply_shape_settings(records or [], settings_table)
 
4884
  sweep_buffer,
4885
  lead_in_orientation,
4886
  contour_order,
4887
+ pressure_variable,
4888
  )
4889
  for record in records:
4890
  if not record.get("gcode_path"):
 
4931
  lead_in_orientation: str | None,
4932
  nozzle_speed: Any,
4933
  contour_order: str | None = None,
4934
+ pressure_variable: bool = True,
4935
  split_columns: Any = None,
4936
  split_rows: Any = None,
4937
  split_overlapping_layers: Any = None,
 
4975
  "lead_in_orientation": str(lead_in_orientation or LEAD_IN_LINE_AUTO),
4976
  "nozzle_speed": _coerce_float(nozzle_speed, 10.0),
4977
  "contour_order": str(contour_order or CONTOUR_ORDER_LAST),
4978
+ "pressure_variable": bool(pressure_variable),
4979
  "split_columns": max(1, _coerce_int(split_columns, 2)),
4980
  "split_rows": max(1, _coerce_int(split_rows, 1)),
4981
  "split_overlapping_layers": bool(split_overlapping_layers),
 
5007
  """
5008
 
5009
  def _skip_options() -> tuple:
5010
+ return tuple(gr.skip() for _ in range(20))
5011
 
5012
  paths = _uploaded_file_paths(settings_upload)
5013
  if not paths:
 
5070
  option("scale_mode"),
5071
  option("nozzle_speed"),
5072
  option("contour_order"),
5073
+ option("pressure_variable"),
5074
  option("split_columns"),
5075
  option("split_rows"),
5076
  option("split_overlapping_layers"),
 
5118
  sweep_buffer: float = 0.8,
5119
  lead_in_orientation: str | None = None,
5120
  contour_order: str | None = None,
5121
+ pressure_variable: bool = True,
5122
  progress: gr.Progress = gr.Progress(),
5123
  ) -> tuple:
5124
  records = _apply_shape_settings(records or [], settings_table)
 
5225
  infill=_coerce_float(record.get("infill", 100.0), 100.0) / 100.0,
5226
  motion_infill_fractions=motion_infill_fractions,
5227
  emit_pressure_commands=owns_port_pressure,
5228
+ pressure_variable=bool(pressure_variable),
5229
  # Same buffer for every shape: the shared motion must match.
5230
  sweep_buffer=max(0.0, _coerce_float(sweep_buffer, 0.8)),
5231
  lead_in_enabled=bool(lead_in_enabled),
 
5259
  sweep_buffer,
5260
  lead_in_orientation,
5261
  contour_order,
5262
+ pressure_variable,
5263
  )
5264
  messages.append(f"Shape {record['idx']}: wrote `{gcode_path.name}`.")
5265
  except Exception as exc:
 
5809
  """
5810
  )
5811
  gcode_pressure_ramp_enabled = gr.Checkbox(label="Increase Pressure Each Layer", value=True)
5812
+ gcode_pressure_variable = gr.Checkbox(
5813
+ label="Editable Pressure Variable",
5814
+ value=True,
5815
+ info=(
5816
+ "Start each pressure-owning file with a pressureN variable that every "
5817
+ "pressure command references, so the print pressure can be changed by "
5818
+ "editing one number in the file — no regeneration needed. Uncheck to "
5819
+ "write plain numeric pressure commands instead."
5820
+ ),
5821
+ )
5822
  with gr.Row(elem_id="gcode-raster-row"):
5823
  gcode_raster_pattern = gr.Dropdown(
5824
  label="Raster Pattern",
 
6254
  gcode_lead_in_orientation,
6255
  viz_nozzle_speed,
6256
  gcode_contour_order,
6257
+ gcode_pressure_variable,
6258
  split_columns,
6259
  split_rows,
6260
  split_overlapping_layers,
 
6285
  scale_mode,
6286
  viz_nozzle_speed,
6287
  gcode_contour_order,
6288
+ gcode_pressure_variable,
6289
  split_columns,
6290
  split_rows,
6291
  split_overlapping_layers,
 
6313
  gcode_sweep_buffer,
6314
  gcode_lead_in_orientation,
6315
  gcode_contour_order,
6316
+ gcode_pressure_variable,
6317
  ]
6318
  shape_settings.change(
6319
  fn=check_gcode_staleness,
 
6334
  gcode_sweep_buffer,
6335
  gcode_lead_in_orientation,
6336
  gcode_contour_order,
6337
+ gcode_pressure_variable,
6338
  ):
6339
  stale_control.change(
6340
  fn=check_gcode_staleness,
 
6424
  gcode_sweep_buffer,
6425
  gcode_lead_in_orientation,
6426
  gcode_contour_order,
6427
+ gcode_pressure_variable,
6428
  ],
6429
  outputs=[shape_records, ref_layers, gcode_downloads, gcode_status, gcode_text_source, gcode_source, gcode_download_all],
6430
  ).then(
tests/test_nozzle_spacing.py CHANGED
@@ -2011,6 +2011,7 @@ def test_project_settings_export_import_round_trip(tmp_path) -> None:
2011
  settings_path, status = export_project_settings(
2012
  records, None, 0.4, 0.4, None, "Circle Spiral raster", False, 1.2,
2013
  6.0, 7.0, 4, "Down", "Horizontal", 15.0,
 
2014
  split_columns=3, split_rows=2, split_overlapping_rows=True,
2015
  split_overlap=1.5,
2016
  )
@@ -2033,10 +2034,11 @@ def test_project_settings_export_import_round_trip(tmp_path) -> None:
2033
  assert option_updates[2]["value"] == 1.2
2034
  assert option_updates[8]["value"] == 0.4 # layer height
2035
  assert option_updates[11]["value"] == 15.0 # nozzle speed
2036
- assert option_updates[13]["value"] == 3 # split columns
2037
- assert option_updates[14]["value"] == 2 # split rows
2038
- assert option_updates[16]["value"] is True # overlapping rows
2039
- assert option_updates[18]["value"] == 1.5 # overlap mm
 
2040
 
2041
  # A file listed in the export but not loaded is reported.
2042
  partial = import_project_settings([settings_path], _records_from_files(["egg_inside.stl"], None), None)
 
2011
  settings_path, status = export_project_settings(
2012
  records, None, 0.4, 0.4, None, "Circle Spiral raster", False, 1.2,
2013
  6.0, 7.0, 4, "Down", "Horizontal", 15.0,
2014
+ pressure_variable=False,
2015
  split_columns=3, split_rows=2, split_overlapping_rows=True,
2016
  split_overlap=1.5,
2017
  )
 
2034
  assert option_updates[2]["value"] == 1.2
2035
  assert option_updates[8]["value"] == 0.4 # layer height
2036
  assert option_updates[11]["value"] == 15.0 # nozzle speed
2037
+ assert option_updates[13]["value"] is False # pressure variable off
2038
+ assert option_updates[14]["value"] == 3 # split columns
2039
+ assert option_updates[15]["value"] == 2 # split rows
2040
+ assert option_updates[17]["value"] is True # overlapping rows
2041
+ assert option_updates[19]["value"] == 1.5 # overlap mm
2042
 
2043
  # A file listed in the export but not loaded is reported.
2044
  partial = import_project_settings([settings_path], _records_from_files(["egg_inside.stl"], None), None)
tests/test_vector_gcode.py CHANGED
@@ -209,16 +209,23 @@ def test_gcode_header_writes_presets_before_initial_aux_commands(tmp_path) -> No
209
  if line.strip()
210
  ]
211
 
212
- assert lines[0] == "G91"
 
 
 
 
 
 
 
213
  # No metadata comments: the file starts straight with machine commands
214
  # (the toolpath's world anchor is returned via origin_sink instead).
215
  assert not any("PathOrigin" in line for line in lines)
216
- assert lines[1] == "{aux_command}WAGO_ValveCommands(7, 0)"
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
 
@@ -304,6 +311,36 @@ def test_sweep_buffer_is_adjustable_and_can_be_disabled(tmp_path) -> None:
304
  assert none[0]["start"] == (0.0, 0.0, 0.0)
305
 
306
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
307
  def test_pressure_ramp_clamps_at_the_regulator_limit(tmp_path) -> None:
308
  import re
309
 
@@ -319,11 +356,18 @@ def test_pressure_ramp_clamps_at_the_regulator_limit(tmp_path) -> None:
319
  pressure_ramp_enabled=True,
320
  output_dir=tmp_path,
321
  )
322
- values = [
323
- float(match) for match in re.findall(r"setpress\(([\d.]+)\)", gcode_path.read_text())
324
- ]
 
 
 
325
  # Preset, then the ramp climbing 0.1/layer but never past 100 psi.
326
- assert values == [99.8, 99.9, 100.0, 100.0]
 
 
 
 
327
 
328
 
329
  def test_port_sharing_files_emit_pressure_commands_once(tmp_path) -> None:
@@ -356,10 +400,75 @@ def test_port_sharing_files_emit_pressure_commands_once(tmp_path) -> None:
356
  assert "serialPort3" not in follower
357
  assert _pressure_set_count(follower) == 0
358
  assert "togglepress" not in follower
 
 
 
359
  # Valve control is per shape and unaffected.
360
  assert follower.count("WAGO_ValveCommands(7, 1)") == owner.count("WAGO_ValveCommands(7, 1)")
361
 
362
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
363
  def test_gcode_lead_in_runs_once_before_first_layer(tmp_path) -> None:
364
  gcode_path = generate_vector_gcode(
365
  _stack(box(0.0, 0.0, 0.5, 0.5), box(0.0, 0.0, 0.5, 0.5)),
 
209
  if line.strip()
210
  ]
211
 
212
+ # The hand-editable pressure variable leads the file; every pressure
213
+ # command below references it, so editing this one number retunes the
214
+ # whole print without regenerating. The odd shape is host-dictated:
215
+ # {aux_command} routes the line off the motion timeline, and the host
216
+ # execs presets as one list literal, so the line must be an expression
217
+ # (a bare `pressure3 = 25` assignment would be a syntax error there).
218
+ assert lines[0] == "{preset}{aux_command}globals().update(pressure3 = 25)"
219
+ assert lines[1] == "G91"
220
  # No metadata comments: the file starts straight with machine commands
221
  # (the toolpath's world anchor is returned via origin_sink instead).
222
  assert not any("PathOrigin" in line for line in lines)
223
+ assert lines[2] == "{aux_command}WAGO_ValveCommands(7, 0)"
224
  # {preset} marks pressure setup for the Aerotech host runtime.
225
+ assert lines[3] == "{preset}serialPort3.write(eval(setpress(pressure3)))"
226
+ assert lines[4] == "{preset}serialPort3.write(eval(togglepress()))"
227
  # The header ends there: no dummy valve-100 init, no duplicate close.
228
+ assert lines[5].startswith("G")
229
  assert not any("WAGO_ValveCommands(100" in line for line in lines)
230
 
231
 
 
311
  assert none[0]["start"] == (0.0, 0.0, 0.0)
312
 
313
 
314
+ def _pressure_values(gcode_text: str, override: float | None = None) -> list[float]:
315
+ """Evaluate every setpress(...) expression the way the print host does.
316
+
317
+ The TCode host routes lines containing {aux_command}/serialPort as
318
+ commands, strips the markers, execs the {preset} commands as ONE list
319
+ literal in its module namespace, and execs ramp commands individually
320
+ in that same namespace. This mimics exactly that: the file's own
321
+ variable line defines `pressureN`, then each setpress argument is
322
+ evaluated in the shared namespace. `override` simulates the user
323
+ hand-editing the variable's value before printing.
324
+ """
325
+ namespace: dict = {}
326
+ values = []
327
+ for line in gcode_text.splitlines():
328
+ line = line.strip()
329
+ if "{aux_command}" not in line and "serialPort" not in line:
330
+ continue # the host would parse this as a motion line
331
+ command = line.replace("{aux_command}", "").replace("{preset}", "")
332
+ if "globals().update(" in command:
333
+ expr = command.split("globals().update(", 1)[1].rsplit(")", 1)[0]
334
+ name, value = (part.strip() for part in expr.split("="))
335
+ namespace[name] = float(value) if override is None else override
336
+ elif "setpress(" in command:
337
+ # The command ends `...setpress(EXPR)))` — closing setpress,
338
+ # eval, and write; the expression itself may contain parens.
339
+ expr = command.split("setpress(", 1)[1].rsplit(")))", 1)[0]
340
+ values.append(float(eval(expr, namespace)))
341
+ return values
342
+
343
+
344
  def test_pressure_ramp_clamps_at_the_regulator_limit(tmp_path) -> None:
345
  import re
346
 
 
356
  pressure_ramp_enabled=True,
357
  output_dir=tmp_path,
358
  )
359
+ text = gcode_path.read_text()
360
+
361
+ # The file ships with the configured pressure as its variable default.
362
+ default = re.search(r"globals\(\)\.update\(pressure3 = ([\d.]+)\)", text)
363
+ assert default is not None and float(default.group(1)) == 99.8
364
+
365
  # Preset, then the ramp climbing 0.1/layer but never past 100 psi.
366
+ assert _pressure_values(text) == [99.8, 99.9, 100.0, 100.0]
367
+
368
+ # Hand-editing the variable rebases the ENTIRE ramp — the point of the
369
+ # feature: no regeneration needed to print the same file at 50 psi.
370
+ assert _pressure_values(text, override=50.0) == [50.0, 50.1, 50.2, 50.3]
371
 
372
 
373
  def test_port_sharing_files_emit_pressure_commands_once(tmp_path) -> None:
 
400
  assert "serialPort3" not in follower
401
  assert _pressure_set_count(follower) == 0
402
  assert "togglepress" not in follower
403
+ # The pressure variable lives only in the port-owning file too.
404
+ assert "globals().update(pressure3 = 25)" in owner
405
+ assert "globals().update" not in follower
406
  # Valve control is per shape and unaffected.
407
  assert follower.count("WAGO_ValveCommands(7, 1)") == owner.count("WAGO_ValveCommands(7, 1)")
408
 
409
 
410
+ def test_pressure_variable_is_namespaced_per_port(tmp_path) -> None:
411
+ # The print host execs every file's commands in ONE shared namespace:
412
+ # if two port-owning files used the same variable name, each file's
413
+ # ramp would read whichever file defined it LAST. Naming the variable
414
+ # by port keeps each regulator on its own knob.
415
+ layer = box(0.0, 0.0, 2.0, 2.0)
416
+
417
+ def _generate(port: int) -> str:
418
+ path = generate_vector_gcode(
419
+ _stack(layer, layer),
420
+ shape_name=f"port{port}",
421
+ pressure=25,
422
+ valve=7,
423
+ port=port,
424
+ fil_width=1.0,
425
+ layer_height=1.0,
426
+ pressure_ramp_enabled=True,
427
+ output_dir=tmp_path / f"port{port}",
428
+ )
429
+ return path.read_text()
430
+
431
+ on_port2 = _generate(2)
432
+ on_port3 = _generate(3)
433
+
434
+ assert "globals().update(pressure2 = 25)" in on_port2
435
+ assert "pressure3" not in on_port2
436
+ assert "globals().update(pressure3 = 25)" in on_port3
437
+ assert "pressure2" not in on_port3
438
+ # Every setpress in each file references its own port's variable.
439
+ assert "setpress(pressure2)" in on_port2
440
+ assert "pressure2 + 0.1" in on_port2
441
+ assert "setpress(pressure3)" in on_port3
442
+ assert "pressure3 + 0.1" in on_port3
443
+
444
+
445
+ def test_pressure_variable_can_be_disabled(tmp_path) -> None:
446
+ # The "Editable Pressure Variable" checkbox off: files fall back to the
447
+ # plain numeric format (pre-variable), in case the host chokes on the
448
+ # variable form.
449
+ layer = box(0.0, 0.0, 2.0, 2.0)
450
+ gcode_path = generate_vector_gcode(
451
+ _stack(layer, layer, layer),
452
+ shape_name="no_variable",
453
+ pressure=25,
454
+ valve=7,
455
+ port=3,
456
+ fil_width=1.0,
457
+ layer_height=1.0,
458
+ pressure_ramp_enabled=True,
459
+ pressure_variable=False,
460
+ output_dir=tmp_path,
461
+ )
462
+ text = gcode_path.read_text()
463
+
464
+ assert "globals().update" not in text
465
+ assert "pressure3" not in text
466
+ assert text.startswith("G91\n")
467
+ assert "{preset}serialPort3.write(eval(setpress(25)))" in text
468
+ # Ramp values are baked in, clamped and rounded at generation time.
469
+ assert _pressure_values(text) == [25.0, 25.1, 25.2]
470
+
471
+
472
  def test_gcode_lead_in_runs_once_before_first_layer(tmp_path) -> None:
473
  gcode_path = generate_vector_gcode(
474
  _stack(box(0.0, 0.0, 0.5, 0.5), box(0.0, 0.0, 0.5, 0.5)),
vector_gcode.py CHANGED
@@ -53,19 +53,73 @@ __all__ = [
53
  ]
54
 
55
 
56
- def _setpress_cmd(port: str, pressure: float, start: bool) -> str:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
57
  """Pressure preset in the readable host dialect.
58
 
59
  Every pressure command uses the same `eval(setpress(...))` form (the
60
- print host defines `setpress`, which builds the serial byte string), so
61
- the values are human-readable throughout the file. `start` adds the
62
- {preset} marker: the host executes presets at the controller's START
63
- signal, before the initial toggles; ramp commands mid-file run at their
64
- scheduled times instead.
 
 
65
  """
66
  if start:
67
- return f"\n\r{{preset}}{port}.write(eval(setpress({pressure:g})))"
68
- return f"\n\r{port}.write(eval(setpress({pressure:g})))"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
69
 
70
 
71
  def _toggle_cmd(port: str, start: bool) -> str:
@@ -102,29 +156,43 @@ def write_gcode_file(
102
  pressure_ramp_enabled: bool,
103
  all_g1: bool,
104
  emit_pressure_commands: bool = True,
 
105
  ) -> None:
106
  """Write the move list as a G-code file.
107
 
108
- `emit_pressure_commands` gates EVERY pressure command (preset, toggle,
109
- per-layer ramp, closing toggle): the pressure regulator is a PORT
 
110
  device, so when several shapes share a serial port only ONE of their
111
  files may own it — the print host compiles all files onto one timeline,
112
  and duplicated toggles would flip the regulator on/off/on at start.
 
 
 
 
 
113
  """
114
  off_color = 0
115
  com_port = f"serialPort{port}"
116
  color_dict: dict[int, int] = {0: 100, 255: valve}
117
 
118
  # The regulator tops out at MAX_PRESSURE_PSI and resolves tenths: clamp
119
- # the preset AND the per-layer ramp so no command asks the impossible.
 
120
  pressure = round(min(max(float(pressure), 0.0), MAX_PRESSURE_PSI), 1)
121
- setpress_lines = [_setpress_cmd(com_port, pressure, start=True)]
 
 
 
122
  pressure_on_lines = [_toggle_cmd(com_port, start=True)]
123
  pressure_off_lines = [_toggle_cmd(com_port, start=False)]
124
 
 
125
  pressure_cur = float(pressure)
126
 
127
  with open(gcode_path, "w") as f:
 
 
128
  f.write("G91\n")
129
  f.write(_valve_cmd(valve, 0))
130
  if emit_pressure_commands:
@@ -156,10 +224,22 @@ def write_gcode_file(
156
  f"Z{_coord(move['Z'])} ; Color {move['Color']}"
157
  )
158
  if pressure_ramp_enabled and emit_pressure_commands:
159
- pressure_cur = round(
160
- min(pressure_cur + increase_pressure_per_layer, MAX_PRESSURE_PSI), 1
 
 
 
 
 
 
 
 
 
 
 
 
 
161
  )
162
- pressure_next = _setpress_cmd(com_port, pressure_cur, start=False)
163
  else:
164
  pressure_next = None
165
  else:
@@ -197,6 +277,7 @@ def generate_vector_gcode(
197
  infill: float = 1.0,
198
  motion_infill_fractions: list[float] | None = None,
199
  emit_pressure_commands: bool = True,
 
200
  sweep_buffer: float | None = None,
201
  increase_pressure_per_layer: float = 0.1,
202
  pressure_ramp_enabled: bool = True,
@@ -385,5 +466,6 @@ def generate_vector_gcode(
385
  pressure_ramp_enabled=bool(pressure_ramp_enabled),
386
  all_g1=bool(all_g1),
387
  emit_pressure_commands=bool(emit_pressure_commands),
 
388
  )
389
  return gcode_path
 
53
  ]
54
 
55
 
56
+ def _pressure_variable_name(port: int) -> str:
57
+ """The hand-editable pressure variable, one per serial port.
58
+
59
+ The name carries the port number because the print host execs every
60
+ file's commands in ONE shared namespace: with several port-owning files
61
+ in a print, a single shared name would leave every ramp reading the
62
+ LAST file's base pressure. Pressure is a port property, so `pressureN`
63
+ is also the natural granularity.
64
+ """
65
+ return f"pressure{port}"
66
+
67
+
68
+ def _pressure_variable_cmd(port: int, pressure: float) -> str:
69
+ """The one hand-editable knob: `pressureN = X` on the file's first line.
70
+
71
+ The exact form is dictated by how the TCode print host consumes files:
72
+
73
+ - Only lines containing `{aux_command}` or `serialPort` are routed as
74
+ commands; anything else is parsed as a G-code move. The `{aux_command}`
75
+ marker (stripped by the host) is what keeps this line off the motion
76
+ timeline.
77
+ - The host joins all `{preset}` commands into ONE list literal and
78
+ exec()s it at the controller's START signal, so the line must be a
79
+ pure EXPRESSION — a bare `pressure = X` assignment would be a syntax
80
+ error there. `globals().update(pressureN = X)` is an expression that
81
+ still reads like the assignment, and it lands the name in the host's
82
+ module globals where the setpress preset (same exec, evaluated next)
83
+ and the mid-file ramp commands (exec'd individually) can see it.
84
+
85
+ Editing this single number retunes every pressure command in the file —
86
+ no need to regenerate the G-code just to print at a different pressure.
87
+ """
88
+ return (
89
+ f"{{preset}}{{aux_command}}globals().update("
90
+ f"{_pressure_variable_name(port)} = {pressure:g})"
91
+ )
92
+
93
+
94
+ def _setpress_cmd(port: str, expression: str, start: bool) -> str:
95
  """Pressure preset in the readable host dialect.
96
 
97
  Every pressure command uses the same `eval(setpress(...))` form (the
98
+ print host defines `setpress`, which builds the serial byte string).
99
+ `expression` is evaluated by the host — it references the `pressureN`
100
+ variable declared at the top of the file rather than a baked-in number,
101
+ so hand-editing that variable retunes the preset and the whole ramp.
102
+ `start` adds the {preset} marker: the host executes presets at the
103
+ controller's START signal, before the initial toggles; ramp commands
104
+ mid-file run at their scheduled times instead.
105
  """
106
  if start:
107
+ return f"\n\r{{preset}}{port}.write(eval(setpress({expression})))"
108
+ return f"\n\r{port}.write(eval(setpress({expression})))"
109
+
110
+
111
+ def _ramp_expression(port: int, offset: float) -> str:
112
+ """Ramp step as an expression of the top-of-file `pressureN` variable.
113
+
114
+ The regulator tops out at MAX_PRESSURE_PSI and resolves tenths, so the
115
+ clamp and rounding travel inside the expression — they must hold for
116
+ whatever value the user hand-edits the variable to, which rules out
117
+ pre-computing them here.
118
+ """
119
+ return (
120
+ f"round(min({_pressure_variable_name(port)} + {offset:g}, "
121
+ f"{MAX_PRESSURE_PSI:g}), 1)"
122
+ )
123
 
124
 
125
  def _toggle_cmd(port: str, start: bool) -> str:
 
156
  pressure_ramp_enabled: bool,
157
  all_g1: bool,
158
  emit_pressure_commands: bool = True,
159
+ pressure_variable: bool = True,
160
  ) -> None:
161
  """Write the move list as a G-code file.
162
 
163
+ `emit_pressure_commands` gates EVERY pressure command (the top-of-file
164
+ `pressure` variable, preset, toggle, per-layer ramp, closing toggle):
165
+ the pressure regulator is a PORT
166
  device, so when several shapes share a serial port only ONE of their
167
  files may own it — the print host compiles all files onto one timeline,
168
  and duplicated toggles would flip the regulator on/off/on at start.
169
+
170
+ `pressure_variable` picks the pressure command style: True writes the
171
+ hand-editable `pressureN` variable on the first line with every
172
+ setpress referencing it; False writes plain numeric setpress commands
173
+ (the pre-variable format) with the ramp values baked in.
174
  """
175
  off_color = 0
176
  com_port = f"serialPort{port}"
177
  color_dict: dict[int, int] = {0: 100, 255: valve}
178
 
179
  # The regulator tops out at MAX_PRESSURE_PSI and resolves tenths: clamp
180
+ # the default the file ships with; the ramp expressions carry their own
181
+ # clamp so hand-edited values stay legal too.
182
  pressure = round(min(max(float(pressure), 0.0), MAX_PRESSURE_PSI), 1)
183
+ preset_expression = (
184
+ _pressure_variable_name(port) if pressure_variable else f"{pressure:g}"
185
+ )
186
+ setpress_lines = [_setpress_cmd(com_port, preset_expression, start=True)]
187
  pressure_on_lines = [_toggle_cmd(com_port, start=True)]
188
  pressure_off_lines = [_toggle_cmd(com_port, start=False)]
189
 
190
+ ramp_layer = 0
191
  pressure_cur = float(pressure)
192
 
193
  with open(gcode_path, "w") as f:
194
+ if emit_pressure_commands and pressure_variable:
195
+ f.write(f"{_pressure_variable_cmd(port, pressure)}\n")
196
  f.write("G91\n")
197
  f.write(_valve_cmd(valve, 0))
198
  if emit_pressure_commands:
 
224
  f"Z{_coord(move['Z'])} ; Color {move['Color']}"
225
  )
226
  if pressure_ramp_enabled and emit_pressure_commands:
227
+ if pressure_variable:
228
+ ramp_layer += 1
229
+ offset = round(increase_pressure_per_layer * ramp_layer, 6)
230
+ ramp_expression = _ramp_expression(port, offset)
231
+ else:
232
+ pressure_cur = round(
233
+ min(
234
+ pressure_cur + increase_pressure_per_layer,
235
+ MAX_PRESSURE_PSI,
236
+ ),
237
+ 1,
238
+ )
239
+ ramp_expression = f"{pressure_cur:g}"
240
+ pressure_next = _setpress_cmd(
241
+ com_port, ramp_expression, start=False
242
  )
 
243
  else:
244
  pressure_next = None
245
  else:
 
277
  infill: float = 1.0,
278
  motion_infill_fractions: list[float] | None = None,
279
  emit_pressure_commands: bool = True,
280
+ pressure_variable: bool = True,
281
  sweep_buffer: float | None = None,
282
  increase_pressure_per_layer: float = 0.1,
283
  pressure_ramp_enabled: bool = True,
 
466
  pressure_ramp_enabled=bool(pressure_ramp_enabled),
467
  all_g1=bool(all_g1),
468
  emit_pressure_commands=bool(emit_pressure_commands),
469
+ pressure_variable=bool(pressure_variable),
470
  )
471
  return gcode_path