Spaces:
Running
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>
- README.md +1 -0
- app.py +110 -14
- tests/test_nozzle_spacing.py +84 -0
- tests/test_vector_gcode.py +22 -0
- vector_gcode.py +11 -1
|
@@ -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.
|
|
@@ -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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 2908 |
-
|
| 2909 |
-
|
| 2910 |
-
|
| 2911 |
-
|
| 2912 |
-
|
| 2913 |
-
|
|
|
|
|
|
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 2950 |
-
# incumbents' shared pressure.
|
| 2951 |
value = incumbent_pressures[0]
|
| 2952 |
else:
|
| 2953 |
-
continue #
|
| 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"⚠️ 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.
|
|
@@ -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 |
|
|
@@ -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
|
|
@@ -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
|
|
|
|
|
|
|
| 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
|