CyGuy8 Claude Fable 5 commited on
Commit
83af5eb
·
1 Parent(s): 3f75aff

Pressure cap + tenths, recency port sync, ghost-row hiding, Enter commits cell edits

Browse files

- Clamp pressure to 0-100 psi (MAX_PRESSURE_PSI) and round to tenths in the
table, port sync, presets, and the per-layer ramp; warn when a ramp would
hit the ceiling mid-print.
- Port-pressure sync now follows the most recently edited member (edit
stamps), matching how Keep Proportions follows the latest dimension edit.
- Hide stale duplicate table rows left over by Gradio's double render so
group/port summaries never count ghosts.
- Fix Enter in a table cell not applying the edit: Gradio's Enter handler
reads the editor from its els registry, which the duplicate table copy
clobbers, so the typed value was dropped until a click outside. Intercept
Enter in capture phase and commit through the blur path instead, then
close the cell with a synthetic Escape.

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

Files changed (5) hide show
  1. README.md +1 -0
  2. app.py +110 -14
  3. tests/test_nozzle_spacing.py +84 -0
  4. tests/test_vector_gcode.py +22 -0
  5. vector_gcode.py +11 -1
README.md CHANGED
@@ -118,6 +118,7 @@ The **Multi-Nozzle Split** accordion on the **Shapes & G-Code** tab can split on
118
  - 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.
119
  - 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.
120
  - Pressure increases by `0.1` psi per layer by default.
 
121
  - 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.
122
  - **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). When strokes run across the approach, lines step further away from the shape. 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.
123
  - **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.
 
118
  - 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.
119
  - 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.
120
  - Pressure increases by `0.1` psi per layer by default.
121
+ - 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.
122
  - 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.
123
  - **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). When strokes run across the approach, lines step further away from the shape. 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.
124
  - **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
@@ -1,5 +1,6 @@
1
  from __future__ import annotations
2
 
 
3
  import json
4
  import math
5
  import tempfile
@@ -38,7 +39,7 @@ from stl_slicer import (
38
  scale_mesh,
39
  slice_stl_to_layers,
40
  )
41
- from vector_gcode import generate_vector_gcode
42
  from vector_toolpath import (
43
  LEAD_IN_DIRECTION_CHOICES,
44
  LEAD_IN_DIRECTION_LEFT,
@@ -532,6 +533,28 @@ APP_HEAD = """
532
  }
533
  });
534
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
535
  function suppressDeleteCellEditor(event) {
536
  var cell = event.target && event.target.closest ? event.target.closest('#shape-settings-table td:last-child, #shape-settings-table [role="gridcell"]:nth-child(12n)') : null;
537
  if (!cell) return;
@@ -556,6 +579,8 @@ APP_HEAD = """
556
  }
557
  function start() {
558
  enableUndoButtons();
 
 
559
  document.addEventListener('focusin', suppressDeleteCellEditor);
560
  document.addEventListener('pointerdown', isolateColorCell, true);
561
  document.addEventListener('mousedown', isolateColorCell, true);
@@ -622,6 +647,7 @@ APP_HEAD = """
622
  var entries = [];
623
  Array.prototype.slice.call(container.querySelectorAll('table tbody tr')).forEach(function (tr) {
624
  var tds = tr.querySelectorAll('td');
 
625
  for (var i = 0; i < tds.length; i++) {
626
  tds[i].style.background = '';
627
  if (i === 0 || i === PRESSURE_COL || i === PORT_COL) tds[i].style.boxShadow = '';
@@ -652,7 +678,17 @@ APP_HEAD = """
652
  entry.pressure = cellName(tds[PRESSURE_COL]);
653
  entry.fromLive = true;
654
  }
655
- entry.copies.push({tr: tr, tds: tds});
 
 
 
 
 
 
 
 
 
 
656
  });
657
  var byNozzle = {}, byValve = {}, byPort = {};
658
  entries.forEach(function (entry) {
@@ -2041,6 +2077,7 @@ def _records_from_files(files: Any, previous_records: list[dict] | None = None)
2041
  if _coerce_int(record.get("port"), 1) == port
2042
  ]
2043
  pressure = port_mates[0].get("pressure", 25.0) if port_mates else 25.0
 
2044
  records.append({
2045
  "idx": index,
2046
  "name": name,
@@ -2055,6 +2092,9 @@ def _records_from_files(files: Any, previous_records: list[dict] | None = None)
2055
  "target_z": round(_coerce_float(previous.get("target_z"), default_z), 1),
2056
  "last_scaled_axis": previous.get("last_scaled_axis", "target_x"),
2057
  "pressure": pressure,
 
 
 
2058
  "valve": valve,
2059
  "nozzle": nozzle,
2060
  "port": previous.get("port", 1),
@@ -2156,6 +2196,11 @@ def _apply_shape_settings(records: list[dict], settings_table: Any) -> list[dict
2156
  copy[key] = float(row[pos])
2157
  except (IndexError, TypeError, ValueError):
2158
  copy[key] = copy.get(key, default)
 
 
 
 
 
2159
  has_nozzle_column = len(row) >= len(SHAPE_SETTINGS_HEADERS)
2160
  nozzle_pos = 7 if has_nozzle_column else None
2161
  port_pos = 8 if has_nozzle_column else 7
@@ -2862,7 +2907,11 @@ def _last_edited_pressures(records: list[dict] | None, settings_table: Any) -> d
2862
  if not previous or len(row) <= pressure_pos:
2863
  continue
2864
  old_pressure = _coerce_float(previous.get("pressure"), 25.0)
2865
- new_pressure = _coerce_float(row[pressure_pos], old_pressure)
 
 
 
 
2866
  if not math.isclose(new_pressure, old_pressure, rel_tol=0.0, abs_tol=1e-9):
2867
  edited[idx] = new_pressure
2868
  return edited
@@ -2896,6 +2945,9 @@ def _last_edited_ports(records: list[dict] | None, settings_table: Any) -> set[i
2896
  return changed
2897
 
2898
 
 
 
 
2899
  def _sync_port_pressures(
2900
  records: list[dict],
2901
  edited_pressures: dict[int, float],
@@ -2904,13 +2956,15 @@ def _sync_port_pressures(
2904
  """Sync pressures across shapes sharing a serial Port.
2905
 
2906
  Pressure is a PORT property — one regulator per serial port — so every
2907
- shape on a port must carry the same pressure. The source value must be
2908
- unambiguous: the pressure the user just edited (all edits agreeing), or
2909
- when a shape just JOINED the group by a port edit the incumbent
2910
- members' shared pressure, which the newcomer adopts. Groups already in
2911
- sync, or with no clear source (e.g. stale .change echoes that flag
2912
- conflicting members at once), are left untouched, so the event storm
2913
- converges instead of ping-ponging.
 
 
2914
  """
2915
  joined_idx = joined_idx or set()
2916
  by_port: dict[int, list[dict]] = {}
@@ -2935,9 +2989,22 @@ def _sync_port_pressures(
2935
  newcomers = [
2936
  member for member in members if int(member.get("idx", 0)) in joined_idx
2937
  ]
 
 
 
 
 
2938
  if len(edited_values) == 1:
2939
  value = next(iter(edited_values))
2940
- elif not edited_values and newcomers and incumbents:
 
 
 
 
 
 
 
 
2941
  incumbent_pressures = [
2942
  _coerce_float(member.get("pressure"), 25.0) for member in incumbents
2943
  ]
@@ -2946,11 +3013,11 @@ def _sync_port_pressures(
2946
  for p in incumbent_pressures[1:]
2947
  ):
2948
  continue
2949
- # A port edit pulled newcomers into the group: they adopt the
2950
- # incumbents' shared pressure.
2951
  value = incumbent_pressures[0]
2952
  else:
2953
- continue # ambiguous (stale echo): do not guess
2954
 
2955
  for member in members:
2956
  member["pressure"] = value
@@ -3075,6 +3142,8 @@ def normalize_shape_dimensions_for_mode(
3075
  record["last_scaled_axis"] = edited_axes[idx]
3076
  if record.get("stl_path"):
3077
  _round_targets_to_tenths(record)
 
 
3078
  normalized = _propagate_group_scale_factors(normalized, edited_axes, set(), joined_idx)
3079
  normalized = _sync_port_pressures(normalized, edited_pressures, port_joined_idx)
3080
  changed = any(
@@ -3171,6 +3240,10 @@ def normalize_shape_dimensions_for_mode(
3171
  normalized = _propagate_group_scale_factors(
3172
  normalized, edited_axes, recomputed_idx, joined_idx
3173
  )
 
 
 
 
3174
  normalized = _sync_port_pressures(normalized, edited_pressures, port_joined_idx)
3175
 
3176
  # Idempotence guard (breaks the .change write-back cascade): only write
@@ -4442,6 +4515,29 @@ def generate_dynamic_gcode(
4442
  except Exception as exc:
4443
  messages.append(f"Shape {record['idx']}: failed ({exc}).")
4444
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4445
  generated_paths = [record.get("gcode_path") for record in records if record.get("gcode_path")]
4446
  if generated_paths:
4447
  # All heads share one motion path, so one file's length is the job's.
 
1
  from __future__ import annotations
2
 
3
+ import itertools
4
  import json
5
  import math
6
  import tempfile
 
39
  scale_mesh,
40
  slice_stl_to_layers,
41
  )
42
+ from vector_gcode import MAX_PRESSURE_PSI, generate_vector_gcode
43
  from vector_toolpath import (
44
  LEAD_IN_DIRECTION_CHOICES,
45
  LEAD_IN_DIRECTION_LEFT,
 
533
  }
534
  });
535
  }
536
+ function commitCellEditOnEnter(event) {
537
+ // Gradio's own Enter handler loses the edit: it reads the editor via
538
+ // its els registry, which the duplicate table copy has clobbered, so
539
+ // the typed value is dropped and no change event reaches the server.
540
+ // The click-out path works because the editor's real blur event
541
+ // carries the textarea itself. So on Enter: suppress Gradio's
542
+ // handler and commit through the blur path, then send a synthetic
543
+ // Escape so the cell exits edit mode like a normal commit.
544
+ if (event.key !== 'Enter' || event.shiftKey) return;
545
+ var el = event.target;
546
+ if (!el || el.tagName !== 'TEXTAREA' || !el.closest) return;
547
+ if (!el.closest('#shape-settings-table, #nozzle-grid-spacing-table')) return;
548
+ if (el.closest('th')) return;
549
+ event.preventDefault();
550
+ event.stopImmediatePropagation();
551
+ el.blur();
552
+ el.dispatchEvent(new KeyboardEvent('keydown', {
553
+ key: 'Escape',
554
+ bubbles: true,
555
+ cancelable: true,
556
+ }));
557
+ }
558
  function suppressDeleteCellEditor(event) {
559
  var cell = event.target && event.target.closest ? event.target.closest('#shape-settings-table td:last-child, #shape-settings-table [role="gridcell"]:nth-child(12n)') : null;
560
  if (!cell) return;
 
579
  }
580
  function start() {
581
  enableUndoButtons();
582
+ // Capture phase: must beat the dataframe's own keydown handlers.
583
+ document.addEventListener('keydown', commitCellEditOnEnter, true);
584
  document.addEventListener('focusin', suppressDeleteCellEditor);
585
  document.addEventListener('pointerdown', isolateColorCell, true);
586
  document.addEventListener('mousedown', isolateColorCell, true);
 
647
  var entries = [];
648
  Array.prototype.slice.call(container.querySelectorAll('table tbody tr')).forEach(function (tr) {
649
  var tds = tr.querySelectorAll('td');
650
+ tr.style.display = '';
651
  for (var i = 0; i < tds.length; i++) {
652
  tds[i].style.background = '';
653
  if (i === 0 || i === PRESSURE_COL || i === PORT_COL) tds[i].style.boxShadow = '';
 
678
  entry.pressure = cellName(tds[PRESSURE_COL]);
679
  entry.fromLive = true;
680
  }
681
+ entry.copies.push({tr: tr, tds: tds, live: fromLive});
682
+ });
683
+ // Ghost pass: when a Shape number has a LIVE row, any stale copy of
684
+ // it in the outer table is a render leftover — hide it entirely.
685
+ // (Shape numbers with no live row are left alone: hiding on a guess
686
+ // could blank a legitimately rendered table.)
687
+ entries.forEach(function (entry) {
688
+ if (!entry.fromLive) return;
689
+ entry.copies.forEach(function (copy) {
690
+ if (!copy.live) copy.tr.style.display = 'none';
691
+ });
692
  });
693
  var byNozzle = {}, byValve = {}, byPort = {};
694
  entries.forEach(function (entry) {
 
2077
  if _coerce_int(record.get("port"), 1) == port
2078
  ]
2079
  pressure = port_mates[0].get("pressure", 25.0) if port_mates else 25.0
2080
+ pressure = round(min(max(_coerce_float(pressure, 25.0), 0.0), MAX_PRESSURE_PSI), 1)
2081
  records.append({
2082
  "idx": index,
2083
  "name": name,
 
2092
  "target_z": round(_coerce_float(previous.get("target_z"), default_z), 1),
2093
  "last_scaled_axis": previous.get("last_scaled_axis", "target_x"),
2094
  "pressure": pressure,
2095
+ # When this shape's pressure was last hand-edited (port-group
2096
+ # sync follows the most recent edit); survives re-syncs.
2097
+ "pressure_edited_at": previous.get("pressure_edited_at"),
2098
  "valve": valve,
2099
  "nozzle": nozzle,
2100
  "port": previous.get("port", 1),
 
2196
  copy[key] = float(row[pos])
2197
  except (IndexError, TypeError, ValueError):
2198
  copy[key] = copy.get(key, default)
2199
+ # The regulator range is 0..MAX_PRESSURE_PSI in tenths: values
2200
+ # outside it (or with more decimals) can't reach the hardware.
2201
+ copy["pressure"] = round(
2202
+ min(max(_coerce_float(copy.get("pressure"), 25.0), 0.0), MAX_PRESSURE_PSI), 1
2203
+ )
2204
  has_nozzle_column = len(row) >= len(SHAPE_SETTINGS_HEADERS)
2205
  nozzle_pos = 7 if has_nozzle_column else None
2206
  port_pos = 8 if has_nozzle_column else 7
 
2907
  if not previous or len(row) <= pressure_pos:
2908
  continue
2909
  old_pressure = _coerce_float(previous.get("pressure"), 25.0)
2910
+ # Clamp/round like _apply_shape_settings does, so the value that
2911
+ # propagates through a port group is the one the hardware can take.
2912
+ new_pressure = round(
2913
+ min(max(_coerce_float(row[pressure_pos], old_pressure), 0.0), MAX_PRESSURE_PSI), 1
2914
+ )
2915
  if not math.isclose(new_pressure, old_pressure, rel_tol=0.0, abs_tol=1e-9):
2916
  edited[idx] = new_pressure
2917
  return edited
 
2945
  return changed
2946
 
2947
 
2948
+ _PRESSURE_EDIT_SEQUENCE = itertools.count(1)
2949
+
2950
+
2951
  def _sync_port_pressures(
2952
  records: list[dict],
2953
  edited_pressures: dict[int, float],
 
2956
  """Sync pressures across shapes sharing a serial Port.
2957
 
2958
  Pressure is a PORT property — one regulator per serial port — so every
2959
+ shape on a port must carry the same pressure. The source value, in
2960
+ order of preference: the pressure the user just edited (all edits
2961
+ agreeing); otherwise the member whose pressure was edited MOST RECENTLY
2962
+ (each edit stamps `pressure_edited_at`, mirroring how Keep Proportions
2963
+ follows the most recently changed dimension) so when a port change
2964
+ merges shapes whose pressures were set at different times, the newest
2965
+ setting wins; otherwise, for stamp-less groups formed by a port edit,
2966
+ the incumbents' shared pressure. Conflicting simultaneous edits (stale
2967
+ .change echoes) are left untouched so the event storm converges.
2968
  """
2969
  joined_idx = joined_idx or set()
2970
  by_port: dict[int, list[dict]] = {}
 
2989
  newcomers = [
2990
  member for member in members if int(member.get("idx", 0)) in joined_idx
2991
  ]
2992
+ stamped = [
2993
+ member
2994
+ for member in members
2995
+ if _coerce_float(member.get("pressure_edited_at"), 0.0) > 0.0
2996
+ ]
2997
  if len(edited_values) == 1:
2998
  value = next(iter(edited_values))
2999
+ elif edited_values:
3000
+ continue # conflicting simultaneous edits (stale echo): do not guess
3001
+ elif stamped:
3002
+ # No edit in this event: follow the most recently edited member.
3003
+ source = max(
3004
+ stamped, key=lambda member: _coerce_float(member.get("pressure_edited_at"), 0.0)
3005
+ )
3006
+ value = _coerce_float(source.get("pressure"), 25.0)
3007
+ elif newcomers and incumbents:
3008
  incumbent_pressures = [
3009
  _coerce_float(member.get("pressure"), 25.0) for member in incumbents
3010
  ]
 
3013
  for p in incumbent_pressures[1:]
3014
  ):
3015
  continue
3016
+ # A port edit pulled newcomers into a never-edited group: they
3017
+ # adopt the incumbents' shared pressure.
3018
  value = incumbent_pressures[0]
3019
  else:
3020
+ continue # no clear source: do not guess
3021
 
3022
  for member in members:
3023
  member["pressure"] = value
 
3142
  record["last_scaled_axis"] = edited_axes[idx]
3143
  if record.get("stl_path"):
3144
  _round_targets_to_tenths(record)
3145
+ if idx in edited_pressures:
3146
+ record["pressure_edited_at"] = next(_PRESSURE_EDIT_SEQUENCE)
3147
  normalized = _propagate_group_scale_factors(normalized, edited_axes, set(), joined_idx)
3148
  normalized = _sync_port_pressures(normalized, edited_pressures, port_joined_idx)
3149
  changed = any(
 
3240
  normalized = _propagate_group_scale_factors(
3241
  normalized, edited_axes, recomputed_idx, joined_idx
3242
  )
3243
+ if edited_pressures:
3244
+ for record in normalized:
3245
+ if int(record.get("idx", 0)) in edited_pressures:
3246
+ record["pressure_edited_at"] = next(_PRESSURE_EDIT_SEQUENCE)
3247
  normalized = _sync_port_pressures(normalized, edited_pressures, port_joined_idx)
3248
 
3249
  # Idempotence guard (breaks the .change write-back cascade): only write
 
4515
  except Exception as exc:
4516
  messages.append(f"Shape {record['idx']}: failed ({exc}).")
4517
 
4518
+ # Pressure sanity: the regulator tops out at MAX_PRESSURE_PSI, and the
4519
+ # per-layer ramp climbs 0.1 psi per layer — warn when a print would hit
4520
+ # the ceiling (the files clamp there, so later layers hold the maximum).
4521
+ if pressure_ramp_enabled and ref_layers is not None and getattr(ref_layers, "layers", None):
4522
+ layer_count = len(ref_layers.layers)
4523
+ peak_base = max(
4524
+ (
4525
+ _coerce_float(record.get("pressure"), 25.0)
4526
+ for record in records
4527
+ if record.get("gcode_path")
4528
+ ),
4529
+ default=0.0,
4530
+ )
4531
+ peak = peak_base + 0.1 * max(0, layer_count - 1)
4532
+ if peak > MAX_PRESSURE_PSI + 1e-9:
4533
+ messages.insert(
4534
+ 0,
4535
+ f"&#9888;&#65039; The pressure ramp reaches the {MAX_PRESSURE_PSI:g} psi regulator "
4536
+ f"limit before the last layer (base {peak_base:g} psi + 0.1/layer x {layer_count} "
4537
+ "layers); the files hold the maximum from there on. Lower the base pressure or "
4538
+ "disable the ramp if that is not intended.",
4539
+ )
4540
+
4541
  generated_paths = [record.get("gcode_path") for record in records if record.get("gcode_path")]
4542
  if generated_paths:
4543
  # All heads share one motion path, so one file's length is the job's.
tests/test_nozzle_spacing.py CHANGED
@@ -1226,6 +1226,43 @@ def test_table_dimensions_display_and_store_to_the_tenths_place() -> None:
1226
  assert updated2[0][key] == round(updated2[0][key], 1)
1227
 
1228
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1229
  def test_pressure_edit_propagates_to_shapes_sharing_the_port() -> None:
1230
  from app import SCALE_MODE_TARGET_DIMENSIONS
1231
 
@@ -1259,6 +1296,53 @@ def test_pressure_edit_propagates_to_shapes_sharing_the_port() -> None:
1259
  assert updated2[1]["pressure"] == 32.0
1260
 
1261
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1262
  def test_moving_a_shape_onto_a_port_adopts_that_ports_pressure() -> None:
1263
  from app import SCALE_MODE_TARGET_DIMENSIONS
1264
 
 
1226
  assert updated2[0][key] == round(updated2[0][key], 1)
1227
 
1228
 
1229
+ def test_pressure_clamps_to_the_regulator_range_and_tenths() -> None:
1230
+ records = [
1231
+ _mm_member(1, 1, 10.0, 10.0, 10.0),
1232
+ _mm_member(2, 2, 10.0, 10.0, 10.0),
1233
+ _mm_member(3, 3, 10.0, 10.0, 10.0),
1234
+ ]
1235
+ records[1]["port"] = 2
1236
+ records[2]["port"] = 3
1237
+ rows = _shape_settings_rows(records)
1238
+ pressure_pos = SHAPE_SETTINGS_HEADERS.index("Pressure (psi)")
1239
+ rows[0][pressure_pos] = 150.0 # above the regulator limit
1240
+ rows[1][pressure_pos] = 25.678 # too many decimals
1241
+ rows[2][pressure_pos] = -5.0 # negative
1242
+
1243
+ updated = _apply_shape_settings(records, rows)
1244
+ assert [record["pressure"] for record in updated] == [100.0, 25.7, 0.0]
1245
+
1246
+
1247
+ def test_port_sync_propagates_the_clamped_rounded_pressure() -> None:
1248
+ from app import SCALE_MODE_TARGET_DIMENSIONS
1249
+
1250
+ # Both shapes on port 1; the edit is over-limit AND over-precise.
1251
+ records = [
1252
+ _mm_member(1, 1, 10.0, 10.0, 10.0),
1253
+ _mm_member(2, 2, 10.0, 10.0, 10.0),
1254
+ ]
1255
+ rows = _shape_settings_rows(records)
1256
+ pressure_pos = SHAPE_SETTINGS_HEADERS.index("Pressure (psi)")
1257
+ rows[0][pressure_pos] = 132.456
1258
+
1259
+ updated, _table = normalize_shape_dimensions_for_mode(
1260
+ records, rows, SCALE_MODE_TARGET_DIMENSIONS
1261
+ )
1262
+ assert updated[0]["pressure"] == 100.0
1263
+ assert updated[1]["pressure"] == 100.0 # the port-mate gets the CLAMPED value
1264
+
1265
+
1266
  def test_pressure_edit_propagates_to_shapes_sharing_the_port() -> None:
1267
  from app import SCALE_MODE_TARGET_DIMENSIONS
1268
 
 
1296
  assert updated2[1]["pressure"] == 32.0
1297
 
1298
 
1299
+ def test_port_merge_follows_the_most_recently_edited_pressure() -> None:
1300
+ from app import SCALE_MODE_TARGET_DIMENSIONS
1301
+
1302
+ # Shape 1 on port 1 (25 psi), shape 2 on port 2. The user edits shape
1303
+ # 2's pressure (stamping it as most recent), THEN moves it onto port 1:
1304
+ # the merged group follows the most recent edit — 40 psi wins, exactly
1305
+ # like Keep Proportions follows the most recently changed dimension.
1306
+ first = _mm_member(1, 1, 10.0, 10.0, 10.0)
1307
+ second = _mm_member(2, 2, 10.0, 10.0, 10.0)
1308
+ second["port"] = 2
1309
+ records = [first, second]
1310
+
1311
+ rows = _shape_settings_rows(records)
1312
+ pressure_pos = SHAPE_SETTINGS_HEADERS.index("Pressure (psi)")
1313
+ rows[1][pressure_pos] = 40.0
1314
+ records, _table = normalize_shape_dimensions_for_mode(
1315
+ records, rows, SCALE_MODE_TARGET_DIMENSIONS
1316
+ )
1317
+ assert records[1]["pressure"] == 40.0
1318
+ assert records[1].get("pressure_edited_at") # stamped as the latest edit
1319
+
1320
+ rows2 = _shape_settings_rows(records)
1321
+ port_pos = SHAPE_SETTINGS_HEADERS.index("Port")
1322
+ rows2[1][port_pos] = 1 # merge onto port 1
1323
+ merged, _table2 = normalize_shape_dimensions_for_mode(
1324
+ records, rows2, SCALE_MODE_TARGET_DIMENSIONS
1325
+ )
1326
+ assert merged[0]["pressure"] == 40.0 # the incumbent FOLLOWS the newer edit
1327
+ assert merged[1]["pressure"] == 40.0
1328
+
1329
+ # And the other way around: if the incumbent's pressure was edited more
1330
+ # recently, ITS value wins when the ports merge.
1331
+ third = _mm_member(3, 3, 10.0, 10.0, 10.0)
1332
+ fourth = _mm_member(4, 4, 10.0, 10.0, 10.0)
1333
+ fourth["port"] = 2
1334
+ fourth["pressure"] = 40.0
1335
+ group = [third, fourth]
1336
+ rows3 = _shape_settings_rows(group)
1337
+ rows3[0][pressure_pos] = 33.0 # edit the port-1 incumbent LAST
1338
+ group, _t = normalize_shape_dimensions_for_mode(group, rows3, SCALE_MODE_TARGET_DIMENSIONS)
1339
+ rows4 = _shape_settings_rows(group)
1340
+ rows4[1][port_pos] = 1
1341
+ merged2, _t2 = normalize_shape_dimensions_for_mode(group, rows4, SCALE_MODE_TARGET_DIMENSIONS)
1342
+ assert merged2[0]["pressure"] == 33.0
1343
+ assert merged2[1]["pressure"] == 33.0
1344
+
1345
+
1346
  def test_moving_a_shape_onto_a_port_adopts_that_ports_pressure() -> None:
1347
  from app import SCALE_MODE_TARGET_DIMENSIONS
1348
 
tests/test_vector_gcode.py CHANGED
@@ -297,6 +297,28 @@ def test_sweep_buffer_is_adjustable_and_can_be_disabled(tmp_path) -> None:
297
  assert none[0]["start"] == (0.0, 0.0, 0.0)
298
 
299
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
300
  def test_port_sharing_files_emit_pressure_commands_once(tmp_path) -> None:
301
  # The pressure regulator is a PORT device: a file generated WITHOUT
302
  # pressure ownership carries no serial commands at all (no preset, no
 
297
  assert none[0]["start"] == (0.0, 0.0, 0.0)
298
 
299
 
300
+ def test_pressure_ramp_clamps_at_the_regulator_limit(tmp_path) -> None:
301
+ import re
302
+
303
+ layer = box(0.0, 0.0, 2.0, 2.0)
304
+ gcode_path = generate_vector_gcode(
305
+ _stack(layer, layer, layer, layer), # 3 layer changes -> 3 ramp steps
306
+ shape_name="ramp_cap",
307
+ pressure=99.8,
308
+ valve=7,
309
+ port=3,
310
+ fil_width=1.0,
311
+ layer_height=1.0,
312
+ pressure_ramp_enabled=True,
313
+ output_dir=tmp_path,
314
+ )
315
+ values = [
316
+ float(match) for match in re.findall(r"setpress\(([\d.]+)\)", gcode_path.read_text())
317
+ ]
318
+ # Preset, then the ramp climbing 0.1/layer but never past 100 psi.
319
+ assert values == [99.8, 99.9, 100.0, 100.0]
320
+
321
+
322
  def test_port_sharing_files_emit_pressure_commands_once(tmp_path) -> None:
323
  # The pressure regulator is a PORT device: a file generated WITHOUT
324
  # pressure ownership carries no serial commands at all (no preset, no
vector_gcode.py CHANGED
@@ -32,9 +32,14 @@ from vector_toolpath import (
32
  plan_layer_moves,
33
  )
34
 
 
 
 
 
35
  __all__ = [
36
  "LEAD_IN_DIRECTION_CHOICES",
37
  "LEAD_IN_DIRECTION_LEFT",
 
38
  "RASTER_PATTERN_CHOICES",
39
  "RASTER_PATTERN_CIRCLE_SPIRAL",
40
  "RASTER_PATTERN_DIAGONAL_WOODPILE",
@@ -110,6 +115,9 @@ def write_gcode_file(
110
  com_port = f"serialPort{port}"
111
  color_dict: dict[int, int] = {0: 100, 255: valve}
112
 
 
 
 
113
  setpress_lines = [_setpress_cmd(com_port, pressure, start=True)]
114
  pressure_on_lines = [_toggle_cmd(com_port, start=True)]
115
  pressure_off_lines = [_toggle_cmd(com_port, start=False)]
@@ -148,7 +156,9 @@ def write_gcode_file(
148
  f"Z{_coord(move['Z'])} ; Color {move['Color']}"
149
  )
150
  if pressure_ramp_enabled and emit_pressure_commands:
151
- pressure_cur += increase_pressure_per_layer
 
 
152
  pressure_next = _setpress_cmd(com_port, pressure_cur, start=False)
153
  else:
154
  pressure_next = None
 
32
  plan_layer_moves,
33
  )
34
 
35
+ # The pressure box protocol ("08PS" + pressure*10 over serial) drives a
36
+ # regulator with a 0-100 psi range; values are encoded to the tenths place.
37
+ MAX_PRESSURE_PSI = 100.0
38
+
39
  __all__ = [
40
  "LEAD_IN_DIRECTION_CHOICES",
41
  "LEAD_IN_DIRECTION_LEFT",
42
+ "MAX_PRESSURE_PSI",
43
  "RASTER_PATTERN_CHOICES",
44
  "RASTER_PATTERN_CIRCLE_SPIRAL",
45
  "RASTER_PATTERN_DIAGONAL_WOODPILE",
 
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)]
 
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