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