CyGuy8 Claude Fable 5 commited on
Commit
6353562
·
1 Parent(s): 0e0e3fa

Sweep buffer input, lead-in line direction, readable pressure commands

Browse files

Generate 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>

Files changed (5) hide show
  1. README.md +2 -1
  2. app.py +58 -9
  3. tests/test_vector_gcode.py +75 -0
  4. vector_gcode.py +17 -32
  5. vector_toolpath.py +89 -26
README.md CHANGED
@@ -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 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.
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.
app.py CHANGED
@@ -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
- gcode_raster_pattern = gr.Dropdown(
4667
- label="Raster Pattern",
4668
- choices=list(RASTER_PATTERN_CHOICES),
4669
- value=RASTER_PATTERN_SAME_DIRECTION,
4670
- allow_custom_value=False,
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(label="Lead In Clearance (mm)", value=5.0, minimum=0.0, step=0.1)
 
 
 
 
 
 
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 Direction",
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(
tests/test_vector_gcode.py CHANGED
@@ -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
vector_gcode.py CHANGED
@@ -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
- # {preset} marks pressure setup for the Aerotech host runtime: presets
79
- # execute at the controller's START signal, before the initial toggles.
 
 
 
 
 
 
 
80
  if start:
81
  return f"\n\r{{preset}}{port}.write(eval(setpress({pressure:g})))"
82
- insert = ""
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
- insert = ""
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]
vector_toolpath.py CHANGED
@@ -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 fil_width
420
- valve-settle travel buffer before and after. `scan_anchor` pins the
 
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 - fil_width, sweep_lo, 0)
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 + fil_width, 0)
502
  else:
503
- emit(sweep_hi + fil_width, sweep_hi, 0)
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 - fil_width, 0)
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
- The patch sits `clearance` away from the toolpath start along the purge
1314
- direction and snakes `line_count` strokes of `length`, stepping one
1315
- `line_spacing` laterally between strokes. The return route exits the
1316
- patch one lateral step to the OUTSIDE and comes home through virgin
1317
- ground, so the freshly primed nozzle never drags back across the wet
 
 
 
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
- append_move(lead_clearance + lead_length, 0.0, off_color)
1350
- stroke = -1.0 # first stroke prints back toward the part
1351
- for pass_index in range(pass_count):
1352
- append_move(stroke * lead_length, 0.0, print_color)
1353
- stroke *= -1.0
1354
- if pass_index < pass_count - 1:
1355
- append_move(0.0, spacing, off_color)
1356
-
1357
- # Return: step one spacing outside the patch laterally, travel home
1358
- # through the clearance lane, then step back onto the start point.
1359
- if spacing > 0.0:
1360
- append_move(0.0, -(current_v + spacing), off_color)
1361
- append_move(-current_a, 0.0, off_color)
1362
- append_move(0.0, -current_v, off_color)
 
 
 
1363
  else:
1364
- append_move(-current_a, -current_v, off_color)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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: