Spaces:
Running
Sweep buffer input, lead-in line direction, readable pressure commands
Browse filesGenerate G-Code options:
- Sweep Buffer (mm) input next to the raster pattern: the valve-settle
travel before/after each raster line (was hardcoded to one fil_width;
0 disables it). Applies to the axis rasters and both woodpiles; the
same value drives every shape so the shared motion stays in sync
- Lead In Line Direction dropdown (Auto/Horizontal/Vertical) decouples
the purge strokes' direction from the patch position: e.g. a patch
BELOW the shape can now print HORIZONTAL strokes. Auto keeps the
historical toward-the-shape behavior byte-for-byte. Across-oriented
lines step further away from the shape, and the return route still
exits past the wet patch and comes home on virgin ground.
"Lead In Direction" renamed to "Lead In Position"; clearance/length
inputs gained hint text (clearance IS the distance from the shape)
- Both new options join the stale-G-code fingerprint
G-code output:
- Every pressure command now uses the readable eval(setpress(...)) /
eval(togglepress()) dialect - previously the per-layer ramp and the
closing toggle were precomputed raw byte strings. The host execs both
forms identically (setpress builds the same serial bytes), so wire
traffic is unchanged; the hex/checksum builders became dead code and
were removed
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- README.md +2 -1
- app.py +58 -9
- tests/test_vector_gcode.py +75 -0
- vector_gcode.py +17 -32
- vector_toolpath.py +89 -26
|
@@ -57,6 +57,7 @@ Then open the local Gradio URL in your browser, upload STL files or load the bun
|
|
| 57 |
- **Infill %** per shape skips dispensing on evenly-distributed raster lines (rings for the circle spiral) — at 50% every other line prints. Parallel shapes with different infill still share one print path, and lines that **no** shape dispenses on are dropped from the motion entirely instead of being swept valve-off (every shape at 50% roughly halves the print path; with mixed infills a line survives if any shape prints it). The rectangular spiral keeps its full continuous walk; circle-spiral perimeter walls always stay
|
| 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
|
|
@@ -115,7 +116,7 @@ The **Multi-Nozzle Split** accordion on the **Shapes & G-Code** tab can split on
|
|
| 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
|
| 119 |
- **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.
|
| 120 |
- Generated files contain only machine commands (no metadata comments). The toolpath's world anchor — the position, in the shape's own frame, that the relative moves start from — is kept on the shape's session record and used to place parallel parts so split pieces reassemble.
|
| 121 |
- **Raster Pattern**: `X-direction raster` sweeps every layer back-and-forth in X. `Y-direction raster` rasters every layer in Y. `90° Woodpile raster` alternates the raster axis by layer, switching between X-direction and Y-direction sweeps. `45° Woodpile raster` rotates the sweep 45 degrees per layer, cycling 0, 45, 90, 135 degrees. `Rectangular Spiral raster` walks rectangular loops from the outside toward the center, then reverses from center to edge on the next layer; the loops live on one family anchored to the shape frame, so every layer (and every split sibling) walks the same rectangles and the walls stack — outer loops that cannot touch a layer's material are skipped instead of traveled. `Circle Spiral raster` prints concentric circles stepping inward by one line width per revolution — each revolution stays at a constant radius, so the walls are smooth true circles — then reverses outward on the next layer. The outermost revolution is a perimeter wall hugging the layer's material edge (half a bead inside its farthest boundary point), so the printed silhouette follows the shape smoothly; a matching inner wall hugs a central hole when there is one. The fill rings between the walls come from one global radii grid anchored at the shape frame's center, so interior rings stack exactly across layers, and rings that cannot touch a layer's material (or would overlap a wall bead) are skipped instead of traveled. Walls always dispense even under partial infill, like contour tracing; elsewhere the valve opens only where the path is inside material. Under shared reference motion every shape's own wall radius joins the one shared ring set (all heads travel all walls, each dispenses only its own), and rings that would graze a shape's boundary are suppressed for that shape — so each parallel shape keeps a smooth, complete outer circle regardless of its dimensions.
|
|
|
|
| 57 |
- **Infill %** per shape skips dispensing on evenly-distributed raster lines (rings for the circle spiral) — at 50% every other line prints. Parallel shapes with different infill still share one print path, and lines that **no** shape dispenses on are dropped from the motion entirely instead of being swept valve-off (every shape at 50% roughly halves the print path; with mixed infills a line survives if any shape prints it). The rectangular spiral keeps its full continuous walk; circle-spiral perimeter walls always stay
|
| 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 |
+
- **Sweep Buffer (mm)** sets the valve-settle travel before and after each raster line (default 0.8, 0 disables it); it applies to the axis rasters and both woodpiles, and the same value drives every shape so the shared motion stays in sync
|
| 61 |
- 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
|
| 62 |
- 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
|
| 63 |
- 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
|
|
|
|
| 116 |
- 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.
|
| 117 |
- Pressure increases by `0.1` psi per layer by default.
|
| 118 |
- 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.
|
| 119 |
+
- **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.
|
| 120 |
- **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.
|
| 121 |
- Generated files contain only machine commands (no metadata comments). The toolpath's world anchor — the position, in the shape's own frame, that the relative moves start from — is kept on the shape's session record and used to place parallel parts so split pieces reassemble.
|
| 122 |
- **Raster Pattern**: `X-direction raster` sweeps every layer back-and-forth in X. `Y-direction raster` rasters every layer in Y. `90° Woodpile raster` alternates the raster axis by layer, switching between X-direction and Y-direction sweeps. `45° Woodpile raster` rotates the sweep 45 degrees per layer, cycling 0, 45, 90, 135 degrees. `Rectangular Spiral raster` walks rectangular loops from the outside toward the center, then reverses from center to edge on the next layer; the loops live on one family anchored to the shape frame, so every layer (and every split sibling) walks the same rectangles and the walls stack — outer loops that cannot touch a layer's material are skipped instead of traveled. `Circle Spiral raster` prints concentric circles stepping inward by one line width per revolution — each revolution stays at a constant radius, so the walls are smooth true circles — then reverses outward on the next layer. The outermost revolution is a perimeter wall hugging the layer's material edge (half a bead inside its farthest boundary point), so the printed silhouette follows the shape smoothly; a matching inner wall hugs a central hole when there is one. The fill rings between the walls come from one global radii grid anchored at the shape frame's center, so interior rings stack exactly across layers, and rings that cannot touch a layer's material (or would overlap a wall bead) are skipped instead of traveled. Walls always dispense even under partial infill, like contour tracing; elsewhere the valve opens only where the path is inside material. Under shared reference motion every shape's own wall radius joins the one shared ring set (all heads travel all walls, each dispenses only its own), and rings that would graze a shape's boundary are suppressed for that shape — so each parallel shape keeps a smooth, complete outer circle regardless of its dimensions.
|
|
@@ -41,6 +41,8 @@ from vector_gcode import generate_vector_gcode
|
|
| 41 |
from vector_toolpath import (
|
| 42 |
LEAD_IN_DIRECTION_CHOICES,
|
| 43 |
LEAD_IN_DIRECTION_LEFT,
|
|
|
|
|
|
|
| 44 |
RASTER_PATTERN_CHOICES,
|
| 45 |
RASTER_PATTERN_SAME_DIRECTION,
|
| 46 |
RASTER_PATTERN_Y_DIRECTION,
|
|
@@ -3983,6 +3985,8 @@ def _gcode_settings_snapshot(
|
|
| 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 |
|
|
@@ -4008,10 +4012,12 @@ def _gcode_settings_snapshot(
|
|
| 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 |
|
|
@@ -4033,6 +4039,8 @@ def check_gcode_staleness(
|
|
| 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)
|
|
@@ -4048,6 +4056,8 @@ def check_gcode_staleness(
|
|
| 4048 |
layer_height,
|
| 4049 |
fil_width,
|
| 4050 |
scale_mode,
|
|
|
|
|
|
|
| 4051 |
)
|
| 4052 |
for record in records:
|
| 4053 |
if not record.get("gcode_path"):
|
|
@@ -4093,6 +4103,8 @@ def generate_dynamic_gcode(
|
|
| 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] = []
|
|
@@ -4174,11 +4186,14 @@ def generate_dynamic_gcode(
|
|
| 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,
|
| 4180 |
lead_in_lines=max(1, _coerce_int(lead_in_lines, 3)),
|
| 4181 |
lead_in_direction=lead_in_direction or LEAD_IN_DIRECTION_LEFT,
|
|
|
|
| 4182 |
lead_in_dispense=bool(record.get("lead_in", True)),
|
| 4183 |
wall_sources=wall_sources,
|
| 4184 |
origin_sink=(origin_sink := {}),
|
|
@@ -4201,6 +4216,8 @@ def generate_dynamic_gcode(
|
|
| 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:
|
|
@@ -4663,23 +4680,49 @@ def build_dynamic_demo() -> gr.Blocks:
|
|
| 4663 |
"""
|
| 4664 |
)
|
| 4665 |
gcode_pressure_ramp_enabled = gr.Checkbox(label="Increase Pressure Each Layer", value=True)
|
| 4666 |
-
|
| 4667 |
-
|
| 4668 |
-
|
| 4669 |
-
|
| 4670 |
-
|
| 4671 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4672 |
with gr.Accordion("Lead In Options", open=False, elem_classes=["settings-accordion"]):
|
| 4673 |
gr.Markdown("Applies to shapes with **Lead In** checked in the Shape Settings table.")
|
| 4674 |
with gr.Row():
|
| 4675 |
-
gcode_lead_in_length = gr.Number(label="Lead In Length (mm)", value=5.0, minimum=0.1, step=0.1)
|
| 4676 |
-
gcode_lead_in_clearance = gr.Number(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4677 |
gcode_lead_in_lines = gr.Number(label="Lead In Raster Lines", value=3, minimum=1, step=1)
|
|
|
|
| 4678 |
gcode_lead_in_direction = gr.Dropdown(
|
| 4679 |
-
label="Lead In
|
| 4680 |
choices=list(LEAD_IN_DIRECTION_CHOICES),
|
| 4681 |
value=LEAD_IN_DIRECTION_LEFT,
|
| 4682 |
allow_custom_value=False,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4683 |
)
|
| 4684 |
gcode_stale_banner = gr.Markdown("", elem_id="gcode-stale-banner")
|
| 4685 |
gcode_button = gr.Button("Generate G-Code", variant="primary")
|
|
@@ -5034,6 +5077,8 @@ def build_dynamic_demo() -> gr.Blocks:
|
|
| 5034 |
layer_height,
|
| 5035 |
fil_width,
|
| 5036 |
scale_mode,
|
|
|
|
|
|
|
| 5037 |
]
|
| 5038 |
shape_settings.change(
|
| 5039 |
fn=check_gcode_staleness,
|
|
@@ -5051,6 +5096,8 @@ def build_dynamic_demo() -> gr.Blocks:
|
|
| 5051 |
layer_height,
|
| 5052 |
fil_width,
|
| 5053 |
scale_mode,
|
|
|
|
|
|
|
| 5054 |
):
|
| 5055 |
stale_control.change(
|
| 5056 |
fn=check_gcode_staleness,
|
|
@@ -5093,6 +5140,8 @@ def build_dynamic_demo() -> gr.Blocks:
|
|
| 5093 |
fil_width,
|
| 5094 |
scale_mode,
|
| 5095 |
viz_nozzle_speed,
|
|
|
|
|
|
|
| 5096 |
],
|
| 5097 |
outputs=[shape_records, ref_layers, gcode_downloads, gcode_status, gcode_text_source, gcode_source, gcode_download_all],
|
| 5098 |
).then(
|
|
|
|
| 41 |
from vector_toolpath import (
|
| 42 |
LEAD_IN_DIRECTION_CHOICES,
|
| 43 |
LEAD_IN_DIRECTION_LEFT,
|
| 44 |
+
LEAD_IN_LINE_AUTO,
|
| 45 |
+
LEAD_IN_LINE_CHOICES,
|
| 46 |
RASTER_PATTERN_CHOICES,
|
| 47 |
RASTER_PATTERN_SAME_DIRECTION,
|
| 48 |
RASTER_PATTERN_Y_DIRECTION,
|
|
|
|
| 3985 |
layer_height: float,
|
| 3986 |
fil_width: float,
|
| 3987 |
scale_mode: str | None,
|
| 3988 |
+
sweep_buffer: float = 0.8,
|
| 3989 |
+
lead_in_orientation: str | None = None,
|
| 3990 |
) -> dict:
|
| 3991 |
"""Fingerprint of every setting that shapes this record's G-code.
|
| 3992 |
|
|
|
|
| 4012 |
round(_coerce_float(lead_in_clearance, 5.0), 6),
|
| 4013 |
_coerce_int(lead_in_lines, 3),
|
| 4014 |
str(lead_in_direction or ""),
|
| 4015 |
+
str(lead_in_orientation or LEAD_IN_LINE_AUTO),
|
| 4016 |
),
|
| 4017 |
"layer_height": round(_coerce_float(layer_height, 0.8), 6),
|
| 4018 |
"fil_width": round(_coerce_float(fil_width, 0.8), 6),
|
| 4019 |
"scale_mode": _normalize_scale_mode(scale_mode),
|
| 4020 |
+
"sweep_buffer": round(_coerce_float(sweep_buffer, 0.8), 6),
|
| 4021 |
}
|
| 4022 |
|
| 4023 |
|
|
|
|
| 4039 |
layer_height: float,
|
| 4040 |
fil_width: float,
|
| 4041 |
scale_mode: str | None,
|
| 4042 |
+
sweep_buffer: float = 0.8,
|
| 4043 |
+
lead_in_orientation: str | None = None,
|
| 4044 |
) -> str:
|
| 4045 |
"""Warning banner text when generated G-code no longer matches the settings."""
|
| 4046 |
records = _apply_shape_settings(records or [], settings_table)
|
|
|
|
| 4056 |
layer_height,
|
| 4057 |
fil_width,
|
| 4058 |
scale_mode,
|
| 4059 |
+
sweep_buffer,
|
| 4060 |
+
lead_in_orientation,
|
| 4061 |
)
|
| 4062 |
for record in records:
|
| 4063 |
if not record.get("gcode_path"):
|
|
|
|
| 4103 |
fil_width: float,
|
| 4104 |
scale_mode: str | None,
|
| 4105 |
nozzle_speed: Any = None,
|
| 4106 |
+
sweep_buffer: float = 0.8,
|
| 4107 |
+
lead_in_orientation: str | None = None,
|
| 4108 |
) -> tuple:
|
| 4109 |
records = _apply_shape_settings(records or [], settings_table)
|
| 4110 |
messages: list[str] = []
|
|
|
|
| 4186 |
infill=_coerce_float(record.get("infill", 100.0), 100.0) / 100.0,
|
| 4187 |
motion_infill_fractions=motion_infill_fractions,
|
| 4188 |
emit_pressure_commands=owns_port_pressure,
|
| 4189 |
+
# Same buffer for every shape: the shared motion must match.
|
| 4190 |
+
sweep_buffer=max(0.0, _coerce_float(sweep_buffer, 0.8)),
|
| 4191 |
lead_in_enabled=bool(lead_in_enabled),
|
| 4192 |
lead_in_length=float(lead_in_length),
|
| 4193 |
lead_in_clearance=effective_lead_in_clearance,
|
| 4194 |
lead_in_lines=max(1, _coerce_int(lead_in_lines, 3)),
|
| 4195 |
lead_in_direction=lead_in_direction or LEAD_IN_DIRECTION_LEFT,
|
| 4196 |
+
lead_in_orientation=lead_in_orientation,
|
| 4197 |
lead_in_dispense=bool(record.get("lead_in", True)),
|
| 4198 |
wall_sources=wall_sources,
|
| 4199 |
origin_sink=(origin_sink := {}),
|
|
|
|
| 4216 |
layer_height,
|
| 4217 |
fil_width,
|
| 4218 |
scale_mode,
|
| 4219 |
+
sweep_buffer,
|
| 4220 |
+
lead_in_orientation,
|
| 4221 |
)
|
| 4222 |
messages.append(f"Shape {record['idx']}: wrote `{gcode_path.name}`.")
|
| 4223 |
except Exception as exc:
|
|
|
|
| 4680 |
"""
|
| 4681 |
)
|
| 4682 |
gcode_pressure_ramp_enabled = gr.Checkbox(label="Increase Pressure Each Layer", value=True)
|
| 4683 |
+
with gr.Row():
|
| 4684 |
+
gcode_raster_pattern = gr.Dropdown(
|
| 4685 |
+
label="Raster Pattern",
|
| 4686 |
+
choices=list(RASTER_PATTERN_CHOICES),
|
| 4687 |
+
value=RASTER_PATTERN_SAME_DIRECTION,
|
| 4688 |
+
allow_custom_value=False,
|
| 4689 |
+
scale=3,
|
| 4690 |
+
)
|
| 4691 |
+
gcode_sweep_buffer = gr.Number(
|
| 4692 |
+
label="Sweep Buffer (mm)",
|
| 4693 |
+
value=0.8,
|
| 4694 |
+
minimum=0.0,
|
| 4695 |
+
step=0.1,
|
| 4696 |
+
scale=1,
|
| 4697 |
+
min_width=170,
|
| 4698 |
+
info="Valve-settle travel before/after each raster line (0 = none).",
|
| 4699 |
+
)
|
| 4700 |
with gr.Accordion("Lead In Options", open=False, elem_classes=["settings-accordion"]):
|
| 4701 |
gr.Markdown("Applies to shapes with **Lead In** checked in the Shape Settings table.")
|
| 4702 |
with gr.Row():
|
| 4703 |
+
gcode_lead_in_length = gr.Number(label="Lead In Length (mm)", value=5.0, minimum=0.1, step=0.1, info="Length of each purge stroke.")
|
| 4704 |
+
gcode_lead_in_clearance = gr.Number(
|
| 4705 |
+
label="Lead In Clearance (mm)",
|
| 4706 |
+
value=5.0,
|
| 4707 |
+
minimum=0.0,
|
| 4708 |
+
step=0.1,
|
| 4709 |
+
info="Distance between the shape and the purge patch.",
|
| 4710 |
+
)
|
| 4711 |
gcode_lead_in_lines = gr.Number(label="Lead In Raster Lines", value=3, minimum=1, step=1)
|
| 4712 |
+
with gr.Row():
|
| 4713 |
gcode_lead_in_direction = gr.Dropdown(
|
| 4714 |
+
label="Lead In Position",
|
| 4715 |
choices=list(LEAD_IN_DIRECTION_CHOICES),
|
| 4716 |
value=LEAD_IN_DIRECTION_LEFT,
|
| 4717 |
allow_custom_value=False,
|
| 4718 |
+
info="Which side of the shape the purge patch sits on.",
|
| 4719 |
+
)
|
| 4720 |
+
gcode_lead_in_orientation = gr.Dropdown(
|
| 4721 |
+
label="Lead In Line Direction",
|
| 4722 |
+
choices=list(LEAD_IN_LINE_CHOICES),
|
| 4723 |
+
value=LEAD_IN_LINE_AUTO,
|
| 4724 |
+
allow_custom_value=False,
|
| 4725 |
+
info="Which way the purge strokes run within the patch.",
|
| 4726 |
)
|
| 4727 |
gcode_stale_banner = gr.Markdown("", elem_id="gcode-stale-banner")
|
| 4728 |
gcode_button = gr.Button("Generate G-Code", variant="primary")
|
|
|
|
| 5077 |
layer_height,
|
| 5078 |
fil_width,
|
| 5079 |
scale_mode,
|
| 5080 |
+
gcode_sweep_buffer,
|
| 5081 |
+
gcode_lead_in_orientation,
|
| 5082 |
]
|
| 5083 |
shape_settings.change(
|
| 5084 |
fn=check_gcode_staleness,
|
|
|
|
| 5096 |
layer_height,
|
| 5097 |
fil_width,
|
| 5098 |
scale_mode,
|
| 5099 |
+
gcode_sweep_buffer,
|
| 5100 |
+
gcode_lead_in_orientation,
|
| 5101 |
):
|
| 5102 |
stale_control.change(
|
| 5103 |
fn=check_gcode_staleness,
|
|
|
|
| 5140 |
fil_width,
|
| 5141 |
scale_mode,
|
| 5142 |
viz_nozzle_speed,
|
| 5143 |
+
gcode_sweep_buffer,
|
| 5144 |
+
gcode_lead_in_orientation,
|
| 5145 |
],
|
| 5146 |
outputs=[shape_records, ref_layers, gcode_downloads, gcode_status, gcode_text_source, gcode_source, gcode_download_all],
|
| 5147 |
).then(
|
|
@@ -222,6 +222,81 @@ def test_gcode_header_writes_presets_before_initial_aux_commands(tmp_path) -> No
|
|
| 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
|
|
|
|
| 222 |
assert not any("WAGO_ValveCommands(100" in line for line in lines)
|
| 223 |
|
| 224 |
|
| 225 |
+
def test_lead_in_line_direction_can_run_across_the_approach(tmp_path) -> None:
|
| 226 |
+
from vector_toolpath import LEAD_IN_DIRECTION_DOWN, LEAD_IN_LINE_HORIZONTAL
|
| 227 |
+
|
| 228 |
+
gcode_path = generate_vector_gcode(
|
| 229 |
+
_stack(box(0.0, 0.0, 0.5, 0.5)),
|
| 230 |
+
shape_name="lead_below_horizontal",
|
| 231 |
+
pressure=25,
|
| 232 |
+
valve=7,
|
| 233 |
+
port=3,
|
| 234 |
+
fil_width=0.5,
|
| 235 |
+
lead_in_enabled=True,
|
| 236 |
+
lead_in_length=3.0,
|
| 237 |
+
lead_in_clearance=4.0,
|
| 238 |
+
lead_in_lines=3,
|
| 239 |
+
lead_in_direction=LEAD_IN_DIRECTION_DOWN,
|
| 240 |
+
lead_in_orientation=LEAD_IN_LINE_HORIZONTAL,
|
| 241 |
+
output_dir=tmp_path,
|
| 242 |
+
)
|
| 243 |
+
moves = _moves_with_colors(gcode_path.read_text())
|
| 244 |
+
|
| 245 |
+
# The purge patch sits BELOW the shape with HORIZONTAL strokes: the
|
| 246 |
+
# first travel drops straight to y = -clearance, and every purge stroke
|
| 247 |
+
# runs in X only, stepping half a fil further down between strokes.
|
| 248 |
+
assert moves[0]["end"] == (0.0, -4.0, 0.0)
|
| 249 |
+
home_index = next(i for i, m in enumerate(moves[1:], start=1) if m["end"] == (0.0, 0.0, 0.0))
|
| 250 |
+
lead_prints = [m for m in moves[:home_index] if m["color"] == 255]
|
| 251 |
+
assert len(lead_prints) == 3
|
| 252 |
+
assert all(abs(m["end"][1] - m["start"][1]) < 1e-9 for m in lead_prints) # horizontal
|
| 253 |
+
assert all(abs(abs(m["end"][0] - m["start"][0]) - 3.0) < 1e-9 for m in lead_prints)
|
| 254 |
+
stroke_rows = sorted(m["start"][1] for m in lead_prints)
|
| 255 |
+
assert stroke_rows == [-5.0, -4.5, -4.0] # stepping AWAY from the shape
|
| 256 |
+
|
| 257 |
+
# The return route comes home without re-crossing a wet stroke row:
|
| 258 |
+
# travels between strokes' rows happen only beyond the patch or on the
|
| 259 |
+
# clean lane outside the stroke span (x < 0).
|
| 260 |
+
for travel in [m for m in moves[1:home_index] if m["color"] == 0]:
|
| 261 |
+
y0, y1 = travel["start"][1], travel["end"][1]
|
| 262 |
+
x0, x1 = travel["start"][0], travel["end"][0]
|
| 263 |
+
if abs(y1 - y0) < 1e-9 and abs(x1 - x0) > 1e-9 and y0 in stroke_rows:
|
| 264 |
+
# Horizontal travel on a wet row must be a stroke, not a travel.
|
| 265 |
+
raise AssertionError(f"travel drags along wet row y={y0}")
|
| 266 |
+
|
| 267 |
+
|
| 268 |
+
def test_sweep_buffer_is_adjustable_and_can_be_disabled(tmp_path) -> None:
|
| 269 |
+
layer = box(0.0, 0.0, 4.0, 1.0)
|
| 270 |
+
stack = _stack(layer)
|
| 271 |
+
|
| 272 |
+
def _generate(label: str, **kwargs):
|
| 273 |
+
path = generate_vector_gcode(
|
| 274 |
+
stack,
|
| 275 |
+
shape_name=label,
|
| 276 |
+
pressure=25,
|
| 277 |
+
valve=7,
|
| 278 |
+
port=3,
|
| 279 |
+
fil_width=1.0,
|
| 280 |
+
output_dir=tmp_path / label,
|
| 281 |
+
**kwargs,
|
| 282 |
+
)
|
| 283 |
+
return _moves_with_colors(path.read_text())
|
| 284 |
+
|
| 285 |
+
# Default: the valve-settle approach travel is one fil_width long.
|
| 286 |
+
default = _generate("default")
|
| 287 |
+
assert default[0]["color"] == 0
|
| 288 |
+
assert abs(math.dist(default[0]["start"], default[0]["end"]) - 1.0) < 1e-9
|
| 289 |
+
|
| 290 |
+
# Custom buffer: the approach stretches to the requested length.
|
| 291 |
+
long_buffer = _generate("long", sweep_buffer=2.5)
|
| 292 |
+
assert abs(math.dist(long_buffer[0]["start"], long_buffer[0]["end"]) - 2.5) < 1e-9
|
| 293 |
+
|
| 294 |
+
# Zero buffer: no approach travel — the sweep starts on the material.
|
| 295 |
+
none = _generate("none", sweep_buffer=0.0)
|
| 296 |
+
assert none[0]["color"] == 255
|
| 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
|
|
@@ -9,9 +9,7 @@ pressure ramp.
|
|
| 9 |
from __future__ import annotations
|
| 10 |
|
| 11 |
import tempfile
|
| 12 |
-
from codecs import encode
|
| 13 |
from pathlib import Path
|
| 14 |
-
from textwrap import wrap
|
| 15 |
|
| 16 |
from stl_slicer import LayerStack
|
| 17 |
from vector_toolpath import (
|
|
@@ -50,44 +48,25 @@ __all__ = [
|
|
| 50 |
]
|
| 51 |
|
| 52 |
|
| 53 |
-
def _setpress(pressure: float) -> str:
|
| 54 |
-
pressure_str = str(int(pressure * 10)).zfill(4)
|
| 55 |
-
command_bytes = bytes("08PS " + pressure_str, "utf-8")
|
| 56 |
-
hex_command = encode(command_bytes, "hex").decode("utf-8")
|
| 57 |
-
format_command = "\\x" + "\\x".join(
|
| 58 |
-
hex_command[i : i + 2] for i in range(0, len(hex_command), 2)
|
| 59 |
-
)
|
| 60 |
-
|
| 61 |
-
hex_pairs = wrap(hex_command, 2)
|
| 62 |
-
decimal_sum = sum(int(pair, 16) for pair in hex_pairs)
|
| 63 |
-
checksum_bin = bin(decimal_sum % 256)[2:].zfill(8)
|
| 64 |
-
inverted = int("".join("1" if c == "0" else "0" for c in checksum_bin), 2) + 1
|
| 65 |
-
checksum_hex = hex(inverted)[2:].upper()
|
| 66 |
-
format_checksum = "\\x" + "\\x".join(
|
| 67 |
-
checksum_hex[i : i + 2] for i in range(0, len(checksum_hex), 2)
|
| 68 |
-
)
|
| 69 |
-
|
| 70 |
-
return "b'" + "\\x05\\x02" + format_command + format_checksum + "\\x03" + "'"
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
def _togglepress() -> str:
|
| 74 |
-
return "b'\\x05\\x02\\x30\\x34\\x44\\x49\\x20\\x20\\x43\\x46\\x03'"
|
| 75 |
-
|
| 76 |
-
|
| 77 |
def _setpress_cmd(port: str, pressure: float, start: bool) -> str:
|
| 78 |
-
|
| 79 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 80 |
if start:
|
| 81 |
return f"\n\r{{preset}}{port}.write(eval(setpress({pressure:g})))"
|
| 82 |
-
|
| 83 |
-
return f"\n\r{insert}{port}.write({_setpress(pressure)})"
|
| 84 |
|
| 85 |
|
| 86 |
def _toggle_cmd(port: str, start: bool) -> str:
|
| 87 |
if start:
|
| 88 |
return f"\n\r{{preset}}{port}.write(eval(togglepress()))"
|
| 89 |
-
|
| 90 |
-
return f"\n\r{insert}{port}.write({_togglepress()})"
|
| 91 |
|
| 92 |
|
| 93 |
def _valve_cmd(valve: int, command: int) -> str:
|
|
@@ -207,6 +186,7 @@ def generate_vector_gcode(
|
|
| 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,
|
|
@@ -215,6 +195,7 @@ def generate_vector_gcode(
|
|
| 215 |
lead_in_clearance: float = 5.0,
|
| 216 |
lead_in_lines: int = 3,
|
| 217 |
lead_in_direction: str = LEAD_IN_DIRECTION_LEFT,
|
|
|
|
| 218 |
lead_in_dispense: bool = True,
|
| 219 |
wall_sources: list[LayerStack] | None = None,
|
| 220 |
origin_sink: dict | None = None,
|
|
@@ -337,6 +318,9 @@ def generate_vector_gcode(
|
|
| 337 |
if motion_infill_fractions is not None
|
| 338 |
else None
|
| 339 |
),
|
|
|
|
|
|
|
|
|
|
| 340 |
)
|
| 341 |
|
| 342 |
# World anchor: the toolpath origin expressed in the shape's own frame.
|
|
@@ -365,6 +349,7 @@ def generate_vector_gcode(
|
|
| 365 |
255 if lead_in_dispense else 0,
|
| 366 |
0,
|
| 367 |
direction=lead_in_direction,
|
|
|
|
| 368 |
)
|
| 369 |
if lead_in:
|
| 370 |
gcode_list = [*lead_in, *gcode_list]
|
|
|
|
| 9 |
from __future__ import annotations
|
| 10 |
|
| 11 |
import tempfile
|
|
|
|
| 12 |
from pathlib import Path
|
|
|
|
| 13 |
|
| 14 |
from stl_slicer import LayerStack
|
| 15 |
from vector_toolpath import (
|
|
|
|
| 48 |
]
|
| 49 |
|
| 50 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 51 |
def _setpress_cmd(port: str, pressure: float, start: bool) -> str:
|
| 52 |
+
"""Pressure preset in the readable host dialect.
|
| 53 |
+
|
| 54 |
+
Every pressure command uses the same `eval(setpress(...))` form (the
|
| 55 |
+
print host defines `setpress`, which builds the serial byte string), so
|
| 56 |
+
the values are human-readable throughout the file. `start` adds the
|
| 57 |
+
{preset} marker: the host executes presets at the controller's START
|
| 58 |
+
signal, before the initial toggles; ramp commands mid-file run at their
|
| 59 |
+
scheduled times instead.
|
| 60 |
+
"""
|
| 61 |
if start:
|
| 62 |
return f"\n\r{{preset}}{port}.write(eval(setpress({pressure:g})))"
|
| 63 |
+
return f"\n\r{port}.write(eval(setpress({pressure:g})))"
|
|
|
|
| 64 |
|
| 65 |
|
| 66 |
def _toggle_cmd(port: str, start: bool) -> str:
|
| 67 |
if start:
|
| 68 |
return f"\n\r{{preset}}{port}.write(eval(togglepress()))"
|
| 69 |
+
return f"\n\r{port}.write(eval(togglepress()))"
|
|
|
|
| 70 |
|
| 71 |
|
| 72 |
def _valve_cmd(valve: int, command: int) -> str:
|
|
|
|
| 186 |
infill: float = 1.0,
|
| 187 |
motion_infill_fractions: list[float] | None = None,
|
| 188 |
emit_pressure_commands: bool = True,
|
| 189 |
+
sweep_buffer: float | None = None,
|
| 190 |
increase_pressure_per_layer: float = 0.1,
|
| 191 |
pressure_ramp_enabled: bool = True,
|
| 192 |
all_g1: bool = False,
|
|
|
|
| 195 |
lead_in_clearance: float = 5.0,
|
| 196 |
lead_in_lines: int = 3,
|
| 197 |
lead_in_direction: str = LEAD_IN_DIRECTION_LEFT,
|
| 198 |
+
lead_in_orientation: str | None = None,
|
| 199 |
lead_in_dispense: bool = True,
|
| 200 |
wall_sources: list[LayerStack] | None = None,
|
| 201 |
origin_sink: dict | None = None,
|
|
|
|
| 318 |
if motion_infill_fractions is not None
|
| 319 |
else None
|
| 320 |
),
|
| 321 |
+
# Valve-settle travel before/after each raster sweep; one fil_width
|
| 322 |
+
# when not given. Pass the SAME value for every shape sharing motion.
|
| 323 |
+
sweep_buffer=sweep_buffer,
|
| 324 |
)
|
| 325 |
|
| 326 |
# World anchor: the toolpath origin expressed in the shape's own frame.
|
|
|
|
| 349 |
255 if lead_in_dispense else 0,
|
| 350 |
0,
|
| 351 |
direction=lead_in_direction,
|
| 352 |
+
orientation=lead_in_orientation,
|
| 353 |
)
|
| 354 |
if lead_in:
|
| 355 |
gcode_list = [*lead_in, *gcode_list]
|
|
@@ -411,13 +411,15 @@ def _axis_raster_segments(
|
|
| 411 |
scan_anchor: float | None = None,
|
| 412 |
infill_keep=None,
|
| 413 |
motion_keep=None,
|
|
|
|
| 414 |
) -> list[Seg]:
|
| 415 |
"""Snake raster of `motion`, dispensing only inside `valve`.
|
| 416 |
|
| 417 |
Axis "X" sweeps along X with rows stacked in Y; axis "Y" sweeps along Y
|
| 418 |
with columns stacked in X. Each sweep spans from the first to the last
|
| 419 |
-
motion chord (crossing interior gaps valve-off) and gets a
|
| 420 |
-
|
|
|
|
| 421 |
scanlines to a global grid (see `_scan_coords`). `infill_keep(k)` gates
|
| 422 |
dispensing per grid line for partial infill: skipped lines are still
|
| 423 |
swept with the valve closed. `motion_keep(k)` drops a grid line from the
|
|
@@ -427,6 +429,7 @@ def _axis_raster_segments(
|
|
| 427 |
if motion is None or motion.is_empty:
|
| 428 |
return []
|
| 429 |
|
|
|
|
| 430 |
min_x, min_y, max_x, max_y = motion.bounds
|
| 431 |
if axis == "Y":
|
| 432 |
scan_lo, scan_hi = min_x, max_x
|
|
@@ -485,7 +488,7 @@ def _axis_raster_segments(
|
|
| 485 |
sweep_number += 1
|
| 486 |
|
| 487 |
if forward:
|
| 488 |
-
emit(sweep_lo -
|
| 489 |
current = sweep_lo
|
| 490 |
for lo, hi in valve_runs:
|
| 491 |
if lo - current > EPS:
|
|
@@ -498,9 +501,9 @@ def _axis_raster_segments(
|
|
| 498 |
if sweep_hi - current > EPS:
|
| 499 |
emit(current, sweep_hi, 0)
|
| 500 |
current = sweep_hi
|
| 501 |
-
emit(current, current +
|
| 502 |
else:
|
| 503 |
-
emit(sweep_hi +
|
| 504 |
current = sweep_hi
|
| 505 |
for lo, hi in reversed(valve_runs):
|
| 506 |
if current - hi > EPS:
|
|
@@ -513,7 +516,7 @@ def _axis_raster_segments(
|
|
| 513 |
if current - sweep_lo > EPS:
|
| 514 |
emit(current, sweep_lo, 0)
|
| 515 |
current = sweep_lo
|
| 516 |
-
emit(current, current -
|
| 517 |
|
| 518 |
return segments
|
| 519 |
|
|
@@ -529,6 +532,7 @@ def _oriented_axis_raster_segments(
|
|
| 529 |
scan_anchor: float | None = None,
|
| 530 |
infill_keep=None,
|
| 531 |
motion_keep=None,
|
|
|
|
| 532 |
) -> list[Seg]:
|
| 533 |
"""Pick the raster orientation whose start is nearest the current position."""
|
| 534 |
default_segments = _axis_raster_segments(
|
|
@@ -539,6 +543,7 @@ def _oriented_axis_raster_segments(
|
|
| 539 |
scan_anchor=scan_anchor,
|
| 540 |
infill_keep=infill_keep,
|
| 541 |
motion_keep=motion_keep,
|
|
|
|
| 542 |
)
|
| 543 |
if prefer_default or not default_segments:
|
| 544 |
return default_segments
|
|
@@ -556,6 +561,7 @@ def _oriented_axis_raster_segments(
|
|
| 556 |
scan_anchor=scan_anchor,
|
| 557 |
infill_keep=infill_keep,
|
| 558 |
motion_keep=motion_keep,
|
|
|
|
| 559 |
)
|
| 560 |
if segments and segments not in candidates:
|
| 561 |
candidates.append(segments)
|
|
@@ -582,6 +588,7 @@ def _rotated_raster_segments(
|
|
| 582 |
frame: tuple[float, float, float, float],
|
| 583 |
infill_keep=None,
|
| 584 |
motion_keep=None,
|
|
|
|
| 585 |
) -> list[Seg]:
|
| 586 |
"""Snake raster at an arbitrary angle, reusing the axis raster machinery.
|
| 587 |
|
|
@@ -626,6 +633,7 @@ def _rotated_raster_segments(
|
|
| 626 |
scan_anchor=anchor,
|
| 627 |
infill_keep=infill_keep,
|
| 628 |
motion_keep=motion_keep,
|
|
|
|
| 629 |
)
|
| 630 |
|
| 631 |
theta_back = math.radians(angle_degrees)
|
|
@@ -1298,6 +1306,25 @@ def _normalize_lead_in_direction(direction: str | None) -> str:
|
|
| 1298 |
return LEAD_IN_DIRECTION_LEFT
|
| 1299 |
|
| 1300 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1301 |
def _lead_in_moves(
|
| 1302 |
enabled: bool,
|
| 1303 |
length: float,
|
|
@@ -1307,14 +1334,18 @@ def _lead_in_moves(
|
|
| 1307 |
print_color: int,
|
| 1308 |
off_color: int,
|
| 1309 |
direction: str | None = LEAD_IN_DIRECTION_LEFT,
|
|
|
|
| 1310 |
) -> list[dict]:
|
| 1311 |
"""Purge patch printed before layer 1, in the chosen direction.
|
| 1312 |
|
| 1313 |
-
|
| 1314 |
-
|
| 1315 |
-
|
| 1316 |
-
|
| 1317 |
-
|
|
|
|
|
|
|
|
|
|
| 1318 |
purge lines.
|
| 1319 |
"""
|
| 1320 |
if not enabled:
|
|
@@ -1326,6 +1357,13 @@ def _lead_in_moves(
|
|
| 1326 |
pass_count = max(1, int(line_count))
|
| 1327 |
spacing = max(0.0, float(line_spacing))
|
| 1328 |
away, lateral = _LEAD_IN_AXES[_normalize_lead_in_direction(direction)]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1329 |
|
| 1330 |
moves: list[dict] = []
|
| 1331 |
# Patch-local frame: `a` runs along the away axis, `v` along the lateral.
|
|
@@ -1346,22 +1384,44 @@ def _lead_in_moves(
|
|
| 1346 |
current_a += delta_a
|
| 1347 |
current_v += delta_v
|
| 1348 |
|
| 1349 |
-
|
| 1350 |
-
|
| 1351 |
-
|
| 1352 |
-
|
| 1353 |
-
|
| 1354 |
-
|
| 1355 |
-
|
| 1356 |
-
|
| 1357 |
-
|
| 1358 |
-
|
| 1359 |
-
|
| 1360 |
-
|
| 1361 |
-
|
| 1362 |
-
|
|
|
|
|
|
|
|
|
|
| 1363 |
else:
|
| 1364 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1365 |
return moves
|
| 1366 |
|
| 1367 |
|
|
@@ -1379,6 +1439,7 @@ def plan_layer_moves(
|
|
| 1379 |
extra_wall_radii: list[list[float]] | None = None,
|
| 1380 |
ring_center: tuple[float, float] | None = None,
|
| 1381 |
motion_infill_fractions: list[float] | None = None,
|
|
|
|
| 1382 |
) -> tuple[list[dict], tuple[float, float]]:
|
| 1383 |
"""Assemble per-layer segments into a relative move list for all patterns.
|
| 1384 |
|
|
@@ -1548,6 +1609,7 @@ def plan_layer_moves(
|
|
| 1548 |
frame=frame,
|
| 1549 |
infill_keep=infill_keep,
|
| 1550 |
motion_keep=motion_keep,
|
|
|
|
| 1551 |
)
|
| 1552 |
else:
|
| 1553 |
raster_axis = _raster_axis_for_pattern(raster_pattern, layer_number)
|
|
@@ -1565,6 +1627,7 @@ def plan_layer_moves(
|
|
| 1565 |
scan_anchor=axis_anchor,
|
| 1566 |
infill_keep=infill_keep,
|
| 1567 |
motion_keep=motion_keep,
|
|
|
|
| 1568 |
)
|
| 1569 |
|
| 1570 |
if not segments:
|
|
|
|
| 411 |
scan_anchor: float | None = None,
|
| 412 |
infill_keep=None,
|
| 413 |
motion_keep=None,
|
| 414 |
+
sweep_buffer: float | None = None,
|
| 415 |
) -> list[Seg]:
|
| 416 |
"""Snake raster of `motion`, dispensing only inside `valve`.
|
| 417 |
|
| 418 |
Axis "X" sweeps along X with rows stacked in Y; axis "Y" sweeps along Y
|
| 419 |
with columns stacked in X. Each sweep spans from the first to the last
|
| 420 |
+
motion chord (crossing interior gaps valve-off) and gets a valve-settle
|
| 421 |
+
travel buffer before and after (`sweep_buffer`, one fil_width when not
|
| 422 |
+
given; 0 disables it). `scan_anchor` pins the
|
| 423 |
scanlines to a global grid (see `_scan_coords`). `infill_keep(k)` gates
|
| 424 |
dispensing per grid line for partial infill: skipped lines are still
|
| 425 |
swept with the valve closed. `motion_keep(k)` drops a grid line from the
|
|
|
|
| 429 |
if motion is None or motion.is_empty:
|
| 430 |
return []
|
| 431 |
|
| 432 |
+
buffer_length = fil_width if sweep_buffer is None else max(0.0, float(sweep_buffer))
|
| 433 |
min_x, min_y, max_x, max_y = motion.bounds
|
| 434 |
if axis == "Y":
|
| 435 |
scan_lo, scan_hi = min_x, max_x
|
|
|
|
| 488 |
sweep_number += 1
|
| 489 |
|
| 490 |
if forward:
|
| 491 |
+
emit(sweep_lo - buffer_length, sweep_lo, 0)
|
| 492 |
current = sweep_lo
|
| 493 |
for lo, hi in valve_runs:
|
| 494 |
if lo - current > EPS:
|
|
|
|
| 501 |
if sweep_hi - current > EPS:
|
| 502 |
emit(current, sweep_hi, 0)
|
| 503 |
current = sweep_hi
|
| 504 |
+
emit(current, current + buffer_length, 0)
|
| 505 |
else:
|
| 506 |
+
emit(sweep_hi + buffer_length, sweep_hi, 0)
|
| 507 |
current = sweep_hi
|
| 508 |
for lo, hi in reversed(valve_runs):
|
| 509 |
if current - hi > EPS:
|
|
|
|
| 516 |
if current - sweep_lo > EPS:
|
| 517 |
emit(current, sweep_lo, 0)
|
| 518 |
current = sweep_lo
|
| 519 |
+
emit(current, current - buffer_length, 0)
|
| 520 |
|
| 521 |
return segments
|
| 522 |
|
|
|
|
| 532 |
scan_anchor: float | None = None,
|
| 533 |
infill_keep=None,
|
| 534 |
motion_keep=None,
|
| 535 |
+
sweep_buffer: float | None = None,
|
| 536 |
) -> list[Seg]:
|
| 537 |
"""Pick the raster orientation whose start is nearest the current position."""
|
| 538 |
default_segments = _axis_raster_segments(
|
|
|
|
| 543 |
scan_anchor=scan_anchor,
|
| 544 |
infill_keep=infill_keep,
|
| 545 |
motion_keep=motion_keep,
|
| 546 |
+
sweep_buffer=sweep_buffer,
|
| 547 |
)
|
| 548 |
if prefer_default or not default_segments:
|
| 549 |
return default_segments
|
|
|
|
| 561 |
scan_anchor=scan_anchor,
|
| 562 |
infill_keep=infill_keep,
|
| 563 |
motion_keep=motion_keep,
|
| 564 |
+
sweep_buffer=sweep_buffer,
|
| 565 |
)
|
| 566 |
if segments and segments not in candidates:
|
| 567 |
candidates.append(segments)
|
|
|
|
| 588 |
frame: tuple[float, float, float, float],
|
| 589 |
infill_keep=None,
|
| 590 |
motion_keep=None,
|
| 591 |
+
sweep_buffer: float | None = None,
|
| 592 |
) -> list[Seg]:
|
| 593 |
"""Snake raster at an arbitrary angle, reusing the axis raster machinery.
|
| 594 |
|
|
|
|
| 633 |
scan_anchor=anchor,
|
| 634 |
infill_keep=infill_keep,
|
| 635 |
motion_keep=motion_keep,
|
| 636 |
+
sweep_buffer=sweep_buffer,
|
| 637 |
)
|
| 638 |
|
| 639 |
theta_back = math.radians(angle_degrees)
|
|
|
|
| 1306 |
return LEAD_IN_DIRECTION_LEFT
|
| 1307 |
|
| 1308 |
|
| 1309 |
+
# Purge-stroke orientation: "Auto" keeps the strokes pointing at the shape
|
| 1310 |
+
# (the historical behavior — horizontal for Left/Right patches, vertical for
|
| 1311 |
+
# Up/Down); Horizontal/Vertical force an absolute stroke direction.
|
| 1312 |
+
LEAD_IN_LINE_AUTO = "Auto (toward the shape)"
|
| 1313 |
+
LEAD_IN_LINE_HORIZONTAL = "Horizontal"
|
| 1314 |
+
LEAD_IN_LINE_VERTICAL = "Vertical"
|
| 1315 |
+
LEAD_IN_LINE_CHOICES = (
|
| 1316 |
+
LEAD_IN_LINE_AUTO,
|
| 1317 |
+
LEAD_IN_LINE_HORIZONTAL,
|
| 1318 |
+
LEAD_IN_LINE_VERTICAL,
|
| 1319 |
+
)
|
| 1320 |
+
|
| 1321 |
+
|
| 1322 |
+
def _normalize_lead_in_orientation(orientation: str | None) -> str:
|
| 1323 |
+
if orientation in LEAD_IN_LINE_CHOICES:
|
| 1324 |
+
return orientation
|
| 1325 |
+
return LEAD_IN_LINE_AUTO
|
| 1326 |
+
|
| 1327 |
+
|
| 1328 |
def _lead_in_moves(
|
| 1329 |
enabled: bool,
|
| 1330 |
length: float,
|
|
|
|
| 1334 |
print_color: int,
|
| 1335 |
off_color: int,
|
| 1336 |
direction: str | None = LEAD_IN_DIRECTION_LEFT,
|
| 1337 |
+
orientation: str | None = None,
|
| 1338 |
) -> list[dict]:
|
| 1339 |
"""Purge patch printed before layer 1, in the chosen direction.
|
| 1340 |
|
| 1341 |
+
`direction` places the patch (Left/Right/Up/Down of the toolpath start,
|
| 1342 |
+
`clearance` away). `orientation` sets which way the purge strokes RUN:
|
| 1343 |
+
Auto points them at the shape (horizontal for Left/Right patches,
|
| 1344 |
+
vertical for Up/Down), Horizontal/Vertical force an absolute direction.
|
| 1345 |
+
When strokes run along the approach, lines step laterally; when they run
|
| 1346 |
+
across it, lines step further AWAY from the shape — either way the
|
| 1347 |
+
return route exits one step outside the wet patch and comes home through
|
| 1348 |
+
virgin ground, so the freshly primed nozzle never drags back across the
|
| 1349 |
purge lines.
|
| 1350 |
"""
|
| 1351 |
if not enabled:
|
|
|
|
| 1357 |
pass_count = max(1, int(line_count))
|
| 1358 |
spacing = max(0.0, float(line_spacing))
|
| 1359 |
away, lateral = _LEAD_IN_AXES[_normalize_lead_in_direction(direction)]
|
| 1360 |
+
orientation = _normalize_lead_in_orientation(orientation)
|
| 1361 |
+
if orientation == LEAD_IN_LINE_HORIZONTAL:
|
| 1362 |
+
strokes_along_away = abs(away[0]) > 0.0
|
| 1363 |
+
elif orientation == LEAD_IN_LINE_VERTICAL:
|
| 1364 |
+
strokes_along_away = abs(away[1]) > 0.0
|
| 1365 |
+
else:
|
| 1366 |
+
strokes_along_away = True
|
| 1367 |
|
| 1368 |
moves: list[dict] = []
|
| 1369 |
# Patch-local frame: `a` runs along the away axis, `v` along the lateral.
|
|
|
|
| 1384 |
current_a += delta_a
|
| 1385 |
current_v += delta_v
|
| 1386 |
|
| 1387 |
+
if strokes_along_away:
|
| 1388 |
+
append_move(lead_clearance + lead_length, 0.0, off_color)
|
| 1389 |
+
stroke = -1.0 # first stroke prints back toward the part
|
| 1390 |
+
for pass_index in range(pass_count):
|
| 1391 |
+
append_move(stroke * lead_length, 0.0, print_color)
|
| 1392 |
+
stroke *= -1.0
|
| 1393 |
+
if pass_index < pass_count - 1:
|
| 1394 |
+
append_move(0.0, spacing, off_color)
|
| 1395 |
+
|
| 1396 |
+
# Return: step one spacing outside the patch laterally, travel home
|
| 1397 |
+
# through the clearance lane, then step back onto the start point.
|
| 1398 |
+
if spacing > 0.0:
|
| 1399 |
+
append_move(0.0, -(current_v + spacing), off_color)
|
| 1400 |
+
append_move(-current_a, 0.0, off_color)
|
| 1401 |
+
append_move(0.0, -current_v, off_color)
|
| 1402 |
+
else:
|
| 1403 |
+
append_move(-current_a, -current_v, off_color)
|
| 1404 |
else:
|
| 1405 |
+
# Strokes run ACROSS the approach: lines step further away from the
|
| 1406 |
+
# shape, so the patch grows outward from `clearance`.
|
| 1407 |
+
append_move(lead_clearance, 0.0, off_color)
|
| 1408 |
+
stroke = 1.0
|
| 1409 |
+
for pass_index in range(pass_count):
|
| 1410 |
+
append_move(0.0, stroke * lead_length, print_color)
|
| 1411 |
+
stroke *= -1.0
|
| 1412 |
+
if pass_index < pass_count - 1:
|
| 1413 |
+
append_move(spacing, 0.0, off_color)
|
| 1414 |
+
|
| 1415 |
+
# Return: step one spacing PAST the outermost wet line, cross to the
|
| 1416 |
+
# clean lane one spacing outside the stroke span, come home along
|
| 1417 |
+
# it, then step onto the start point.
|
| 1418 |
+
if spacing > 0.0:
|
| 1419 |
+
append_move(spacing, 0.0, off_color)
|
| 1420 |
+
append_move(0.0, -(current_v + spacing), off_color)
|
| 1421 |
+
append_move(-current_a, 0.0, off_color)
|
| 1422 |
+
append_move(0.0, -current_v, off_color)
|
| 1423 |
+
else:
|
| 1424 |
+
append_move(-current_a, -current_v, off_color)
|
| 1425 |
return moves
|
| 1426 |
|
| 1427 |
|
|
|
|
| 1439 |
extra_wall_radii: list[list[float]] | None = None,
|
| 1440 |
ring_center: tuple[float, float] | None = None,
|
| 1441 |
motion_infill_fractions: list[float] | None = None,
|
| 1442 |
+
sweep_buffer: float | None = None,
|
| 1443 |
) -> tuple[list[dict], tuple[float, float]]:
|
| 1444 |
"""Assemble per-layer segments into a relative move list for all patterns.
|
| 1445 |
|
|
|
|
| 1609 |
frame=frame,
|
| 1610 |
infill_keep=infill_keep,
|
| 1611 |
motion_keep=motion_keep,
|
| 1612 |
+
sweep_buffer=sweep_buffer,
|
| 1613 |
)
|
| 1614 |
else:
|
| 1615 |
raster_axis = _raster_axis_for_pattern(raster_pattern, layer_number)
|
|
|
|
| 1627 |
scan_anchor=axis_anchor,
|
| 1628 |
infill_keep=infill_keep,
|
| 1629 |
motion_keep=motion_keep,
|
| 1630 |
+
sweep_buffer=sweep_buffer,
|
| 1631 |
)
|
| 1632 |
|
| 1633 |
if not segments:
|