CyGuy8 Claude Fable 5 commited on
Commit
b8e790e
·
1 Parent(s): 859a75d

Step circle spiral by whole rings and make lead-in per shape

Browse files

- Circle spiral prints concentric circles: each revolution holds a constant
radius (smooth walls) and steps inward by exactly one fil width; the step
segments and the final dive to the centre travel with the valve shut,
including step pieces clipped by the material boundary at the edges
- Lead-in gains a direction option (Left/Right/Up/Down) and a return route
that exits the patch laterally and comes home through the clearance lane,
never dragging the primed nozzle across the wet purge lines
- For grid splits the lead-in clearance auto-extends by the assembly's
remaining extent along the purge axis, so under shared reference motion
every nozzle's purge patch lands clear of the whole assembled part
- Lead-in is now controlled solely by a per-shape "Lead In" column in Shape
Settings (opt-in): unchecked heads traverse the shared patch valve-off to
stay in sync, or skip it entirely when printing without shared motion;
the global checkbox is replaced by an always-available options accordion

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

README.md CHANGED
@@ -92,9 +92,10 @@ The **Multi-Nozzle Split** accordion on the **Shapes & Slicing** tab can split o
92
  - Generated files include pressure preset commands and WAGO valve commands based on the selected pressure, valve, and port; the nozzle number controls layout/spacing assignment.
93
  - Pressure increases by `0.1` psi per layer by default.
94
  - **Use G1 for all moves**: when enabled, every movement line is emitted as `G1` (no `G0` rapid travel); the WAGO valve still marks where material is dispensed. Applies to all shapes.
 
95
  - **Use combined reference outline for motion**: when enabled, 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 when shapes are sliced. Contour tracing stays synchronized too: every shape traces every traced shape's contour, opening its valve only on its own outline.
96
  - Every generated file starts with a `; PathOrigin X.. Y..` comment: the world position (in the shape's own frame) that the relative toolpath starts from. Tools use it to place parallel parts so split pieces reassemble.
97
- - **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 each layer from the outer layer bounds toward the center, then reverses from center to edge on the next layer. `Circle Spiral raster` uses a shrinking circular spiral from the layer bounds toward the center, then reverses outward on the next layer. Spiral motion covers the layer bounds; the valve opens only where the path is inside material.
98
  - **Auto Align Split Parts**: in Nozzle Spacing, computes exact per-connection grid gaps from the split pieces' generated G-code (`PathOrigin` anchors + toolpath bounds), sets the grid columns/rows from the split, and fills the Advanced Grid Spacing table. Works for every raster pattern, filament width, reference-motion setting, and overlapping-layer split. Requires the pieces' G-code to be generated first.
99
  - **Contour Tracing**: enabled per row in Shape Settings. The app traces the layer polygon's boundary rings (holes traced separately), travels from the layer raster end to the nearest contour point, prints the contour, then returns to the raster endpoint before the next layer. For grid-split pieces only the parent shape's true outer surface is traced — the cut seams between sibling pieces are excluded (open arcs are printed end-to-end without closing the loop; fully interior pieces get no contour).
100
 
 
92
  - Generated files include pressure preset commands and WAGO valve commands based on the selected pressure, valve, and port; the nozzle number controls layout/spacing assignment.
93
  - Pressure increases by `0.1` psi per layer by default.
94
  - **Use G1 for all moves**: when enabled, every movement line is emitted as `G1` (no `G0` rapid travel); the WAGO valve still marks where material is dispensed. Applies to all shapes.
95
+ - **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.
96
  - **Use combined reference outline for motion**: when enabled, 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 when shapes are sliced. Contour tracing stays synchronized too: every shape traces every traced shape's contour, opening its valve only on its own outline.
97
  - Every generated file starts with a `; PathOrigin X.. Y..` comment: the world position (in the shape's own frame) that the relative toolpath starts from. Tools use it to place parallel parts so split pieces reassemble.
98
+ - **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 each layer from the outer layer bounds toward the center, then reverses from center to edge on the next layer. `Circle Spiral raster` prints concentric circles from the layer bounds toward the center — each revolution stays at a constant radius and steps inward by one line width between revolutions, so the walls are smooth true circles — then reverses outward on the next layer. Spiral motion covers the layer bounds; the valve opens only where the path is inside material.
99
  - **Auto Align Split Parts**: in Nozzle Spacing, computes exact per-connection grid gaps from the split pieces' generated G-code (`PathOrigin` anchors + toolpath bounds), sets the grid columns/rows from the split, and fills the Advanced Grid Spacing table. Works for every raster pattern, filament width, reference-motion setting, and overlapping-layer split. Requires the pieces' G-code to be generated first.
100
  - **Contour Tracing**: enabled per row in Shape Settings. The app traces the layer polygon's boundary rings (holes traced separately), travels from the layer raster end to the nearest contour point, prints the contour, then returns to the raster endpoint before the next layer. For grid-split pieces only the parent shape's true outer surface is traced — the cut seams between sibling pieces are excluded (open arcs are printed end-to-end without closing the loop; fully interior pieces get no contour).
101
 
app.py CHANGED
@@ -37,6 +37,8 @@ from stl_slicer import (
37
  )
38
  from vector_gcode import generate_vector_gcode
39
  from vector_toolpath import (
 
 
40
  RASTER_PATTERN_CHOICES,
41
  RASTER_PATTERN_SAME_DIRECTION,
42
  RASTER_PATTERN_Y_DIRECTION,
@@ -1362,6 +1364,7 @@ SHAPE_SETTINGS_HEADERS = [
1362
  "Color",
1363
  "Infill %",
1364
  "Contour Tracing",
 
1365
  "Delete",
1366
  ]
1367
  SHAPE_SETTINGS_DATATYPES = [
@@ -1377,6 +1380,7 @@ SHAPE_SETTINGS_DATATYPES = [
1377
  "str",
1378
  "number",
1379
  "bool",
 
1380
  "str",
1381
  ]
1382
  ADVANCED_NOZZLE_SPACING_HEADERS = [
@@ -1525,6 +1529,7 @@ def _records_from_files(files: Any, previous_records: list[dict] | None = None)
1525
  "color": previous.get("color", _default_color(index)),
1526
  "infill": previous.get("infill", 100.0),
1527
  "contour_tracing": previous.get("contour_tracing", False),
 
1528
  "layer_stack": previous.get("layer_stack"),
1529
  "slice_params": previous.get("slice_params"),
1530
  "gcode_path": previous.get("gcode_path"),
@@ -1556,6 +1561,7 @@ def _shape_settings_rows(records: list[dict]) -> list[list[Any]]:
1556
  record.get("color", _default_color(record["idx"])),
1557
  _coerce_float(record.get("infill", 100.0), 100.0),
1558
  bool(record.get("contour_tracing", False)),
 
1559
  "Delete",
1560
  ]
1561
  for record in records
@@ -1624,6 +1630,11 @@ def _apply_shape_settings(records: list[dict], settings_table: Any) -> list[dict
1624
  copy["contour_tracing"] = _coerce_bool(row[contour_pos], bool(copy.get("contour_tracing", False)))
1625
  except IndexError:
1626
  copy["contour_tracing"] = bool(copy.get("contour_tracing", False))
 
 
 
 
 
1627
  updated.append(copy)
1628
  return updated
1629
 
@@ -1991,10 +2002,6 @@ def update_nozzle_grid_preset(
1991
  return gr.update(value=max(1, _coerce_int(columns, 1))), gr.update(value=max(1, _coerce_int(rows, 1)))
1992
 
1993
 
1994
- def update_lead_in_options_visibility(enabled: bool | None) -> dict[str, Any]:
1995
- return gr.update(visible=bool(enabled))
1996
-
1997
-
1998
  def _dropdown_update(records: list[dict], selected: str | None = None) -> dict[str, Any]:
1999
  choices = [_shape_choice(record) for record in records]
2000
  value = selected if selected in choices else (choices[0] if choices else None)
@@ -2449,6 +2456,35 @@ def _ensure_records_sliced(
2449
  return resliced
2450
 
2451
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2452
  def generate_dynamic_gcode(
2453
  records: list[dict] | None,
2454
  settings_table: Any,
@@ -2456,10 +2492,10 @@ def generate_dynamic_gcode(
2456
  use_reference_motion: bool,
2457
  raster_pattern: str | None,
2458
  pressure_ramp_enabled: bool,
2459
- lead_in_enabled: bool,
2460
  lead_in_length: float,
2461
  lead_in_clearance: float,
2462
  lead_in_lines: float,
 
2463
  ref_layers: LayerStack | None,
2464
  layer_height: float,
2465
  fil_width: float,
@@ -2478,6 +2514,19 @@ def generate_dynamic_gcode(
2478
  if contour_sources:
2479
  enabled = ", ".join(f"Shape {source.owner_idx}" for source in contour_sources)
2480
  messages.append(f"Contour tracing enabled for {enabled}.")
 
 
 
 
 
 
 
 
 
 
 
 
 
2481
  for record in records:
2482
  stack = record.get("layer_stack")
2483
  if stack is None or not getattr(stack, "layers", None):
@@ -2505,8 +2554,10 @@ def generate_dynamic_gcode(
2505
  infill=_coerce_float(record.get("infill", 100.0), 100.0) / 100.0,
2506
  lead_in_enabled=bool(lead_in_enabled),
2507
  lead_in_length=float(lead_in_length),
2508
- lead_in_clearance=float(lead_in_clearance),
2509
  lead_in_lines=max(1, _coerce_int(lead_in_lines, 3)),
 
 
2510
  )
2511
  record["gcode_path"] = str(gcode_path)
2512
  messages.append(f"Shape {record['idx']}: wrote `{gcode_path.name}`.")
@@ -2842,12 +2893,18 @@ def build_dynamic_demo() -> gr.Blocks:
2842
  value=RASTER_PATTERN_SAME_DIRECTION,
2843
  allow_custom_value=False,
2844
  )
2845
- gcode_lead_in_enabled = gr.Checkbox(label="Lead In", value=False)
2846
- with gr.Group(visible=False) as gcode_lead_in_options_group:
2847
  with gr.Row():
2848
  gcode_lead_in_length = gr.Number(label="Lead In Length (mm)", value=5.0, minimum=0.1, step=0.1)
2849
  gcode_lead_in_clearance = gr.Number(label="Lead In Clearance (mm)", value=5.0, minimum=0.0, step=0.1)
2850
  gcode_lead_in_lines = gr.Number(label="Lead In Raster Lines", value=3, minimum=1, step=1)
 
 
 
 
 
 
2851
  gcode_button = gr.Button("Generate G-Code", variant="primary")
2852
  gcode_downloads = gr.File(label="Download G-Code Files", file_count="multiple", interactive=False, elem_classes=["gcode-download"])
2853
  gcode_status = gr.Markdown("")
@@ -3119,10 +3176,10 @@ def build_dynamic_demo() -> gr.Blocks:
3119
  gcode_use_ref_motion,
3120
  gcode_raster_pattern,
3121
  gcode_pressure_ramp_enabled,
3122
- gcode_lead_in_enabled,
3123
  gcode_lead_in_length,
3124
  gcode_lead_in_clearance,
3125
  gcode_lead_in_lines,
 
3126
  ref_layers,
3127
  layer_height,
3128
  fil_width,
@@ -3134,12 +3191,6 @@ def build_dynamic_demo() -> gr.Blocks:
3134
  inputs=[shape_records, gcode_text_source],
3135
  outputs=[gcode_text],
3136
  )
3137
- gcode_lead_in_enabled.change(
3138
- fn=update_lead_in_options_visibility,
3139
- inputs=[gcode_lead_in_enabled],
3140
- outputs=[gcode_lead_in_options_group],
3141
- queue=False,
3142
- )
3143
  gcode_text_source.change(fn=load_selected_gcode_text, inputs=[shape_records, gcode_text_source], outputs=[gcode_text])
3144
  refresh_gcode_text_button.click(fn=load_selected_gcode_text, inputs=[shape_records, gcode_text_source], outputs=[gcode_text])
3145
  auto_align_split_parts_button.click(
 
37
  )
38
  from vector_gcode import generate_vector_gcode
39
  from vector_toolpath import (
40
+ LEAD_IN_DIRECTION_CHOICES,
41
+ LEAD_IN_DIRECTION_LEFT,
42
  RASTER_PATTERN_CHOICES,
43
  RASTER_PATTERN_SAME_DIRECTION,
44
  RASTER_PATTERN_Y_DIRECTION,
 
1364
  "Color",
1365
  "Infill %",
1366
  "Contour Tracing",
1367
+ "Lead In",
1368
  "Delete",
1369
  ]
1370
  SHAPE_SETTINGS_DATATYPES = [
 
1380
  "str",
1381
  "number",
1382
  "bool",
1383
+ "bool",
1384
  "str",
1385
  ]
1386
  ADVANCED_NOZZLE_SPACING_HEADERS = [
 
1529
  "color": previous.get("color", _default_color(index)),
1530
  "infill": previous.get("infill", 100.0),
1531
  "contour_tracing": previous.get("contour_tracing", False),
1532
+ "lead_in": previous.get("lead_in", False),
1533
  "layer_stack": previous.get("layer_stack"),
1534
  "slice_params": previous.get("slice_params"),
1535
  "gcode_path": previous.get("gcode_path"),
 
1561
  record.get("color", _default_color(record["idx"])),
1562
  _coerce_float(record.get("infill", 100.0), 100.0),
1563
  bool(record.get("contour_tracing", False)),
1564
+ bool(record.get("lead_in", False)),
1565
  "Delete",
1566
  ]
1567
  for record in records
 
1630
  copy["contour_tracing"] = _coerce_bool(row[contour_pos], bool(copy.get("contour_tracing", False)))
1631
  except IndexError:
1632
  copy["contour_tracing"] = bool(copy.get("contour_tracing", False))
1633
+ lead_in_pos = contour_pos + 1
1634
+ try:
1635
+ copy["lead_in"] = _coerce_bool(row[lead_in_pos], bool(copy.get("lead_in", False)))
1636
+ except IndexError:
1637
+ copy["lead_in"] = bool(copy.get("lead_in", False))
1638
  updated.append(copy)
1639
  return updated
1640
 
 
2002
  return gr.update(value=max(1, _coerce_int(columns, 1))), gr.update(value=max(1, _coerce_int(rows, 1)))
2003
 
2004
 
 
 
 
 
2005
  def _dropdown_update(records: list[dict], selected: str | None = None) -> dict[str, Any]:
2006
  choices = [_shape_choice(record) for record in records]
2007
  value = selected if selected in choices else (choices[0] if choices else None)
 
2456
  return resliced
2457
 
2458
 
2459
+ def _lead_in_assembly_extension(records: list[dict], direction: str | None) -> float:
2460
+ """Extra lead-in clearance so split pieces purge clear of the assembly.
2461
+
2462
+ Under shared reference motion every head executes the same lead-in
2463
+ offset, so head k's purge patch lands one cell over from head k-1's -
2464
+ right on a sibling's print area - unless the clearance exceeds the
2465
+ assembly's remaining extent along the purge axis. The split cells are
2466
+ equal sized, so that extent is (count - 1) * cell for the deepest piece.
2467
+ Returned as one batch-wide value (the max over all split pieces) so
2468
+ every shape's lead-in stays identical and the shared motion stays in
2469
+ sync.
2470
+ """
2471
+ axis = 0 if (direction or LEAD_IN_DIRECTION_LEFT) in ("Left", "Right") else 1
2472
+ extension = 0.0
2473
+ for record in records:
2474
+ if not record.get("split_group_id"):
2475
+ continue
2476
+ stack = record.get("layer_stack")
2477
+ if stack is None:
2478
+ continue
2479
+ (min_x, min_y, _z_min), (max_x, max_y, _z_max) = stack.bounds
2480
+ cell = (max_x - min_x) if axis == 0 else (max_y - min_y)
2481
+ count = _coerce_int(
2482
+ record.get("split_columns" if axis == 0 else "split_rows"), 1
2483
+ )
2484
+ extension = max(extension, cell * max(0, count - 1))
2485
+ return extension
2486
+
2487
+
2488
  def generate_dynamic_gcode(
2489
  records: list[dict] | None,
2490
  settings_table: Any,
 
2492
  use_reference_motion: bool,
2493
  raster_pattern: str | None,
2494
  pressure_ramp_enabled: bool,
 
2495
  lead_in_length: float,
2496
  lead_in_clearance: float,
2497
  lead_in_lines: float,
2498
+ lead_in_direction: str | None,
2499
  ref_layers: LayerStack | None,
2500
  layer_height: float,
2501
  fil_width: float,
 
2514
  if contour_sources:
2515
  enabled = ", ".join(f"Shape {source.owner_idx}" for source in contour_sources)
2516
  messages.append(f"Contour tracing enabled for {enabled}.")
2517
+ # Lead-in is driven entirely by the per-shape "Lead In" column: the
2518
+ # purge motion exists whenever any shape dispenses it (all heads must
2519
+ # share the motion), and each shape's own flag gates its valve.
2520
+ lead_in_enabled = any(record.get("lead_in") for record in records)
2521
+ effective_lead_in_clearance = float(lead_in_clearance or 0.0)
2522
+ if lead_in_enabled:
2523
+ lead_in_extension = _lead_in_assembly_extension(records, lead_in_direction)
2524
+ if lead_in_extension > 0.0:
2525
+ effective_lead_in_clearance += lead_in_extension
2526
+ messages.append(
2527
+ f"Lead-in clearance extended by {lead_in_extension:.1f} mm so every "
2528
+ "nozzle's purge patch lands clear of the split assembly."
2529
+ )
2530
  for record in records:
2531
  stack = record.get("layer_stack")
2532
  if stack is None or not getattr(stack, "layers", None):
 
2554
  infill=_coerce_float(record.get("infill", 100.0), 100.0) / 100.0,
2555
  lead_in_enabled=bool(lead_in_enabled),
2556
  lead_in_length=float(lead_in_length),
2557
+ lead_in_clearance=effective_lead_in_clearance,
2558
  lead_in_lines=max(1, _coerce_int(lead_in_lines, 3)),
2559
+ lead_in_direction=lead_in_direction or LEAD_IN_DIRECTION_LEFT,
2560
+ lead_in_dispense=bool(record.get("lead_in", True)),
2561
  )
2562
  record["gcode_path"] = str(gcode_path)
2563
  messages.append(f"Shape {record['idx']}: wrote `{gcode_path.name}`.")
 
2893
  value=RASTER_PATTERN_SAME_DIRECTION,
2894
  allow_custom_value=False,
2895
  )
2896
+ with gr.Accordion("Lead In Options", open=False, elem_classes=["settings-accordion"]):
2897
+ gr.Markdown("Applies to shapes with **Lead In** checked in the Shape Settings table.")
2898
  with gr.Row():
2899
  gcode_lead_in_length = gr.Number(label="Lead In Length (mm)", value=5.0, minimum=0.1, step=0.1)
2900
  gcode_lead_in_clearance = gr.Number(label="Lead In Clearance (mm)", value=5.0, minimum=0.0, step=0.1)
2901
  gcode_lead_in_lines = gr.Number(label="Lead In Raster Lines", value=3, minimum=1, step=1)
2902
+ gcode_lead_in_direction = gr.Dropdown(
2903
+ label="Lead In Direction",
2904
+ choices=list(LEAD_IN_DIRECTION_CHOICES),
2905
+ value=LEAD_IN_DIRECTION_LEFT,
2906
+ allow_custom_value=False,
2907
+ )
2908
  gcode_button = gr.Button("Generate G-Code", variant="primary")
2909
  gcode_downloads = gr.File(label="Download G-Code Files", file_count="multiple", interactive=False, elem_classes=["gcode-download"])
2910
  gcode_status = gr.Markdown("")
 
3176
  gcode_use_ref_motion,
3177
  gcode_raster_pattern,
3178
  gcode_pressure_ramp_enabled,
 
3179
  gcode_lead_in_length,
3180
  gcode_lead_in_clearance,
3181
  gcode_lead_in_lines,
3182
+ gcode_lead_in_direction,
3183
  ref_layers,
3184
  layer_height,
3185
  fil_width,
 
3191
  inputs=[shape_records, gcode_text_source],
3192
  outputs=[gcode_text],
3193
  )
 
 
 
 
 
 
3194
  gcode_text_source.change(fn=load_selected_gcode_text, inputs=[shape_records, gcode_text_source], outputs=[gcode_text])
3195
  refresh_gcode_text_button.click(fn=load_selected_gcode_text, inputs=[shape_records, gcode_text_source], outputs=[gcode_text])
3196
  auto_align_split_parts_button.click(
tests/test_nozzle_spacing.py CHANGED
@@ -111,18 +111,24 @@ def test_shape_settings_round_trip_contour_tracing_column() -> None:
111
 
112
  rows = _shape_settings_rows(records)
113
  assert SHAPE_SETTINGS_HEADERS[6:9] == ["Valve", "Nozzle", "Port"]
114
- assert SHAPE_SETTINGS_HEADERS[-2:] == ["Contour Tracing", "Delete"]
 
 
115
  assert rows[0][6:9] == [4, 1, 1]
116
- assert rows[0][-2:] == [False, "Delete"]
 
 
117
 
118
  rows[0][7] = 3
119
  rows[0][8] = 2
120
- rows[0][-2] = True
 
121
  updated = _apply_shape_settings(records, rows)
122
 
123
  assert updated[0]["nozzle"] == 3
124
  assert updated[0]["port"] == 2
125
  assert updated[0]["contour_tracing"] is True
 
126
 
127
 
128
  def test_shape_settings_round_trip_infill_column() -> None:
@@ -158,6 +164,45 @@ def test_shape_settings_round_trip_infill_column() -> None:
158
  assert _apply_shape_settings(records, rows)[0]["infill"] == 0.0
159
 
160
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
161
  def test_contour_tracing_sources_use_sliced_layer_stacks() -> None:
162
  from shapely.geometry import MultiPolygon, box
163
 
 
111
 
112
  rows = _shape_settings_rows(records)
113
  assert SHAPE_SETTINGS_HEADERS[6:9] == ["Valve", "Nozzle", "Port"]
114
+ assert SHAPE_SETTINGS_HEADERS[-3:] == ["Contour Tracing", "Lead In", "Delete"]
115
+ contour_pos = SHAPE_SETTINGS_HEADERS.index("Contour Tracing")
116
+ lead_in_pos = SHAPE_SETTINGS_HEADERS.index("Lead In")
117
  assert rows[0][6:9] == [4, 1, 1]
118
+ assert rows[0][contour_pos] is False
119
+ assert rows[0][lead_in_pos] is False # lead-in is opt-in per shape
120
+ assert rows[0][-1] == "Delete"
121
 
122
  rows[0][7] = 3
123
  rows[0][8] = 2
124
+ rows[0][contour_pos] = True
125
+ rows[0][lead_in_pos] = True
126
  updated = _apply_shape_settings(records, rows)
127
 
128
  assert updated[0]["nozzle"] == 3
129
  assert updated[0]["port"] == 2
130
  assert updated[0]["contour_tracing"] is True
131
+ assert updated[0]["lead_in"] is True
132
 
133
 
134
  def test_shape_settings_round_trip_infill_column() -> None:
 
164
  assert _apply_shape_settings(records, rows)[0]["infill"] == 0.0
165
 
166
 
167
+ def test_lead_in_assembly_extension_covers_the_split_extent() -> None:
168
+ from shapely.geometry import MultiPolygon, box
169
+
170
+ from app import _lead_in_assembly_extension
171
+ from stl_slicer import LayerStack
172
+ from vector_toolpath import split_layer_stack_grid
173
+
174
+ layer = MultiPolygon([box(0.0, 0.0, 9.0, 4.0)])
175
+ stack = LayerStack(
176
+ layers=[layer],
177
+ z_values=[0.5],
178
+ bounds=((0.0, 0.0, 0.0), (9.0, 4.0, 1.0)),
179
+ layer_height=1.0,
180
+ name="bar",
181
+ )
182
+ pieces = split_layer_stack_grid(stack, 3, 1, grid=1.0)
183
+ records = [
184
+ {
185
+ "idx": index + 1,
186
+ "layer_stack": piece,
187
+ "split_group_id": "g",
188
+ "split_columns": 3,
189
+ "split_rows": 1,
190
+ }
191
+ for index, piece in enumerate(pieces)
192
+ ]
193
+
194
+ # Purging along the split axis must clear (count-1) cells; there is only
195
+ # one row, so the perpendicular directions need no extension.
196
+ cell_width = pieces[0].bounds[1][0] - pieces[0].bounds[0][0]
197
+ assert _lead_in_assembly_extension(records, "Left") == cell_width * 2
198
+ assert _lead_in_assembly_extension(records, "Right") == cell_width * 2
199
+ assert _lead_in_assembly_extension(records, "Up") == 0.0
200
+ assert _lead_in_assembly_extension(records, "Down") == 0.0
201
+
202
+ # Whole (unsplit) shapes never extend.
203
+ assert _lead_in_assembly_extension([{"idx": 1}], "Left") == 0.0
204
+
205
+
206
  def test_contour_tracing_sources_use_sliced_layer_stacks() -> None:
207
  from shapely.geometry import MultiPolygon, box
208
 
tests/test_vector_gcode.py CHANGED
@@ -219,25 +219,136 @@ def test_gcode_lead_in_runs_once_before_first_layer(tmp_path) -> None:
219
 
220
  moves = _moves_with_colors(gcode_path.read_text())
221
 
222
- assert moves[:7] == [
223
  {"start": (0.0, 0.0, 0.0), "end": (-7.0, 0.0, 0.0), "color": 0},
224
  {"start": (-7.0, 0.0, 0.0), "end": (-4.0, 0.0, 0.0), "color": 255},
225
  {"start": (-4.0, 0.0, 0.0), "end": (-4.0, 0.5, 0.0), "color": 0},
226
  {"start": (-4.0, 0.5, 0.0), "end": (-7.0, 0.5, 0.0), "color": 255},
227
  {"start": (-7.0, 0.5, 0.0), "end": (-7.0, 1.0, 0.0), "color": 0},
228
  {"start": (-7.0, 1.0, 0.0), "end": (-4.0, 1.0, 0.0), "color": 255},
229
- {"start": (-4.0, 1.0, 0.0), "end": (0.0, 0.0, 0.0), "color": 0},
 
 
 
 
 
230
  ]
231
- assert all(move["end"][2] == 0.0 for move in moves[:7])
232
 
233
  first_z_index = next(index for index, move in enumerate(moves) if move["end"][2] > 0.0)
234
- assert first_z_index > 7
235
  assert not any(
236
  move["start"][0] < -3.0 or move["end"][0] < -3.0
237
  for move in moves[first_z_index:]
238
  )
239
 
240
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
241
  def test_gcode_pressure_ramp_can_be_disabled(tmp_path) -> None:
242
  stack = _stack(box(0.0, 0.0, 1.0, 1.0), box(0.0, 0.0, 1.0, 1.0))
243
 
@@ -561,6 +672,56 @@ def test_circle_spiral_points_decrease_radius_to_center() -> None:
561
  )
562
 
563
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
564
  def test_circle_spiral_raster_reverses_between_layers(tmp_path) -> None:
565
  layer = box(0.0, 0.0, 5.0, 5.0)
566
  gcode_path = generate_vector_gcode(
 
219
 
220
  moves = _moves_with_colors(gcode_path.read_text())
221
 
222
+ assert moves[:9] == [
223
  {"start": (0.0, 0.0, 0.0), "end": (-7.0, 0.0, 0.0), "color": 0},
224
  {"start": (-7.0, 0.0, 0.0), "end": (-4.0, 0.0, 0.0), "color": 255},
225
  {"start": (-4.0, 0.0, 0.0), "end": (-4.0, 0.5, 0.0), "color": 0},
226
  {"start": (-4.0, 0.5, 0.0), "end": (-7.0, 0.5, 0.0), "color": 255},
227
  {"start": (-7.0, 0.5, 0.0), "end": (-7.0, 1.0, 0.0), "color": 0},
228
  {"start": (-7.0, 1.0, 0.0), "end": (-4.0, 1.0, 0.0), "color": 255},
229
+ # Return route: exit the patch one spacing to the outside, travel
230
+ # home through the clearance lane, then step onto the start point —
231
+ # never dragging the primed nozzle back across the purge lines.
232
+ {"start": (-4.0, 1.0, 0.0), "end": (-4.0, -0.5, 0.0), "color": 0},
233
+ {"start": (-4.0, -0.5, 0.0), "end": (0.0, -0.5, 0.0), "color": 0},
234
+ {"start": (0.0, -0.5, 0.0), "end": (0.0, 0.0, 0.0), "color": 0},
235
  ]
236
+ assert all(move["end"][2] == 0.0 for move in moves[:9])
237
 
238
  first_z_index = next(index for index, move in enumerate(moves) if move["end"][2] > 0.0)
239
+ assert first_z_index > 9
240
  assert not any(
241
  move["start"][0] < -3.0 or move["end"][0] < -3.0
242
  for move in moves[first_z_index:]
243
  )
244
 
245
 
246
+ def test_gcode_lead_in_direction_points_the_purge_patch(tmp_path) -> None:
247
+ from vector_toolpath import LEAD_IN_DIRECTION_UP
248
+
249
+ gcode_path = generate_vector_gcode(
250
+ _stack(box(0.0, 0.0, 0.5, 0.5)),
251
+ shape_name="lead_in_up",
252
+ pressure=25,
253
+ valve=7,
254
+ port=3,
255
+ fil_width=0.5,
256
+ lead_in_enabled=True,
257
+ lead_in_length=3.0,
258
+ lead_in_clearance=4.0,
259
+ lead_in_lines=2,
260
+ lead_in_direction=LEAD_IN_DIRECTION_UP,
261
+ output_dir=tmp_path,
262
+ )
263
+
264
+ moves = _moves_with_colors(gcode_path.read_text())
265
+ # Patch is ABOVE the start: first travel goes +7 in Y, purge strokes are
266
+ # vertical and sit in y in [4, 7]; only the half-fil-wide return lane
267
+ # dips to the negative lateral side.
268
+ assert moves[0]["end"] == (0.0, 7.0, 0.0)
269
+ lead_prints = [m for m in moves[:6] if m["color"] == 255]
270
+ assert lead_prints
271
+ assert all(abs(m["end"][0] - m["start"][0]) < 1e-9 for m in lead_prints)
272
+ assert all(
273
+ 3.9 <= min(m["start"][1], m["end"][1]) and max(m["start"][1], m["end"][1]) <= 7.1
274
+ for m in lead_prints
275
+ )
276
+ assert all(m["end"][0] >= -0.5 - 1e-9 for m in moves[:9])
277
+
278
+
279
+ def test_lead_in_opt_out_travels_shared_patch_but_skips_it_solo(tmp_path) -> None:
280
+ small = _stack(box(0.0, 0.0, 2.0, 2.0), name="small")
281
+ big = _stack(box(0.0, 0.0, 4.0, 4.0), name="big")
282
+ reference = build_reference_stack([small, big])
283
+
284
+ def _generate(stack: LayerStack, dispense: bool, motion, label: str):
285
+ path = generate_vector_gcode(
286
+ stack,
287
+ shape_name=label,
288
+ pressure=25,
289
+ valve=7,
290
+ port=3,
291
+ fil_width=1.0,
292
+ motion=motion,
293
+ lead_in_enabled=True,
294
+ lead_in_length=3.0,
295
+ lead_in_clearance=4.0,
296
+ lead_in_lines=3,
297
+ lead_in_dispense=dispense,
298
+ output_dir=tmp_path / label,
299
+ )
300
+ return _moves_with_colors(path.read_text())
301
+
302
+ # Shared motion: the opted-out head traverses the identical patch with
303
+ # the valve shut; totals and endpoints match the dispensing head exactly.
304
+ priming = _generate(big, True, reference, "priming")
305
+ passive = _generate(small, False, reference, "passive")
306
+ assert priming[-1]["end"] == passive[-1]["end"]
307
+ assert abs(_total_length(priming) - _total_length(passive)) < 1e-6
308
+ assert any(m["color"] == 255 for m in priming[:6])
309
+ assert all(m["color"] == 0 for m in passive[:9])
310
+
311
+ # Solo (no shared motion): the opted-out shape skips the lead-in
312
+ # entirely — its first move is the raster approach, not the purge travel.
313
+ solo = _generate(small, False, None, "solo")
314
+ with_lead = _generate(small, True, None, "with_lead")
315
+ assert len(solo) < len(with_lead)
316
+ assert solo[0]["end"] != (-7.0, 0.0, 0.0)
317
+ assert with_lead[0]["end"] == (-7.0, 0.0, 0.0)
318
+
319
+
320
+ def test_gcode_lead_in_return_never_crosses_the_purge_lines(tmp_path) -> None:
321
+ for lines in (1, 2, 3, 4):
322
+ gcode_path = generate_vector_gcode(
323
+ _stack(box(0.0, 0.0, 0.5, 0.5)),
324
+ shape_name=f"lead_return_{lines}",
325
+ pressure=25,
326
+ valve=7,
327
+ port=3,
328
+ fil_width=0.5,
329
+ lead_in_enabled=True,
330
+ lead_in_length=3.0,
331
+ lead_in_clearance=4.0,
332
+ lead_in_lines=lines,
333
+ output_dir=tmp_path / str(lines),
334
+ )
335
+ moves = _moves_with_colors(gcode_path.read_text())
336
+ lead_end = next(i for i, m in enumerate(moves) if m["end"] == (0.0, 0.0, 0.0))
337
+ prints = [m for m in moves[: lead_end + 1] if m["color"] == 255]
338
+ travels = [m for m in moves[: lead_end + 1] if m["color"] == 0]
339
+ # No travel move's interior crosses a printed purge line: every
340
+ # printed line sits on a lane y = k*0.5, and travels only run along
341
+ # x = const (lane changes at line ends) or at y = -0.5 / y <= 0.
342
+ for travel in travels[1:]:
343
+ y0, y1 = travel["start"][1], travel["end"][1]
344
+ x0, x1 = travel["start"][0], travel["end"][0]
345
+ if abs(y1 - y0) < 1e-9 and abs(x1 - x0) > 1e-9:
346
+ # Horizontal travel: must be outside the printed lanes.
347
+ assert y0 < -1e-9 or not any(
348
+ abs(p["start"][1] - y0) < 1e-9 for p in prints
349
+ ), (lines, travel)
350
+
351
+
352
  def test_gcode_pressure_ramp_can_be_disabled(tmp_path) -> None:
353
  stack = _stack(box(0.0, 0.0, 1.0, 1.0), box(0.0, 0.0, 1.0, 1.0))
354
 
 
672
  )
673
 
674
 
675
+ def test_circle_spiral_steps_radius_by_whole_pitches() -> None:
676
+ # Each revolution is a true circle at a constant radius; the radius drops
677
+ # by exactly one pitch in a single radial jump between revolutions.
678
+ points = _circle_spiral_points(2.0, 3.0, outer_radius=4.0, pitch=0.8)
679
+ radii = [math.hypot(x - 2.0, y - 3.0) for x, y in points]
680
+
681
+ distinct = sorted({round(radius, 6) for radius in radii})
682
+ assert distinct == [0.0, 0.8, 1.6, 2.4, 3.2, 4.0]
683
+
684
+ ring_transitions = sum(
685
+ 1
686
+ for previous, current in zip(radii, radii[1:])
687
+ if abs(current - previous) > 1e-9
688
+ )
689
+ assert ring_transitions == 5
690
+
691
+
692
+ def test_circle_spiral_ring_steps_travel_with_valve_shut(tmp_path) -> None:
693
+ from gcode_viewer import parse_gcode_path
694
+ from vector_toolpath import RASTER_PATTERN_CIRCLE_SPIRAL
695
+
696
+ layer = box(0.0, 0.0, 10.0, 10.0)
697
+ gcode_path = generate_vector_gcode(
698
+ _stack(layer, layer),
699
+ shape_name="ring_steps",
700
+ pressure=25,
701
+ valve=7,
702
+ port=3,
703
+ fil_width=0.8,
704
+ layer_height=1.0,
705
+ raster_pattern=RASTER_PATTERN_CIRCLE_SPIRAL,
706
+ output_dir=tmp_path,
707
+ )
708
+
709
+ parsed = parse_gcode_path(gcode_path.read_text())
710
+ origin_x, origin_y = parsed["path_origin"]
711
+ center_x = center_y = 5.0
712
+
713
+ # Print moves stay on a constant-radius ring (within chord flattening);
714
+ # the inward steps between rings — including pieces clipped by the
715
+ # material boundary at the edges — are always valve-off travel.
716
+ worst = 0.0
717
+ for segment in parsed["print_segments"]:
718
+ for a, b in zip(segment, segment[1:]):
719
+ radius_a = math.hypot(a[0] + origin_x - center_x, a[1] + origin_y - center_y)
720
+ radius_b = math.hypot(b[0] + origin_x - center_x, b[1] + origin_y - center_y)
721
+ worst = max(worst, abs(radius_b - radius_a))
722
+ assert worst < 0.11
723
+
724
+
725
  def test_circle_spiral_raster_reverses_between_layers(tmp_path) -> None:
726
  layer = box(0.0, 0.0, 5.0, 5.0)
727
  gcode_path = generate_vector_gcode(
vector_gcode.py CHANGED
@@ -15,6 +15,8 @@ from textwrap import wrap
15
 
16
  from stl_slicer import LayerStack
17
  from vector_toolpath import (
 
 
18
  RASTER_PATTERN_CHOICES,
19
  RASTER_PATTERN_CIRCLE_SPIRAL,
20
  RASTER_PATTERN_DIAGONAL_WOODPILE,
@@ -32,6 +34,8 @@ from vector_toolpath import (
32
  )
33
 
34
  __all__ = [
 
 
35
  "RASTER_PATTERN_CHOICES",
36
  "RASTER_PATTERN_CIRCLE_SPIRAL",
37
  "RASTER_PATTERN_DIAGONAL_WOODPILE",
@@ -205,6 +209,8 @@ def generate_vector_gcode(
205
  lead_in_length: float = 5.0,
206
  lead_in_clearance: float = 5.0,
207
  lead_in_lines: int = 3,
 
 
208
  output_dir: str | Path | None = None,
209
  ) -> Path:
210
  """Generate G-code for one sliced shape.
@@ -274,14 +280,18 @@ def generate_vector_gcode(
274
  delta_x = delta_y = 0.0
275
  path_origin = (toolpath_origin[0] - delta_x, toolpath_origin[1] - delta_y)
276
 
 
 
 
277
  lead_in = _lead_in_moves(
278
- lead_in_enabled,
279
  lead_in_length,
280
  lead_in_clearance,
281
  lead_in_lines,
282
  fil_width,
283
- 255,
284
  0,
 
285
  )
286
  if lead_in:
287
  gcode_list = [*lead_in, *gcode_list]
 
15
 
16
  from stl_slicer import LayerStack
17
  from vector_toolpath import (
18
+ LEAD_IN_DIRECTION_CHOICES,
19
+ LEAD_IN_DIRECTION_LEFT,
20
  RASTER_PATTERN_CHOICES,
21
  RASTER_PATTERN_CIRCLE_SPIRAL,
22
  RASTER_PATTERN_DIAGONAL_WOODPILE,
 
34
  )
35
 
36
  __all__ = [
37
+ "LEAD_IN_DIRECTION_CHOICES",
38
+ "LEAD_IN_DIRECTION_LEFT",
39
  "RASTER_PATTERN_CHOICES",
40
  "RASTER_PATTERN_CIRCLE_SPIRAL",
41
  "RASTER_PATTERN_DIAGONAL_WOODPILE",
 
209
  lead_in_length: float = 5.0,
210
  lead_in_clearance: float = 5.0,
211
  lead_in_lines: int = 3,
212
+ lead_in_direction: str = LEAD_IN_DIRECTION_LEFT,
213
+ lead_in_dispense: bool = True,
214
  output_dir: str | Path | None = None,
215
  ) -> Path:
216
  """Generate G-code for one sliced shape.
 
280
  delta_x = delta_y = 0.0
281
  path_origin = (toolpath_origin[0] - delta_x, toolpath_origin[1] - delta_y)
282
 
283
+ # A shape that opts out of the lead-in still TRAVELS the purge patch when
284
+ # motion is shared (all heads must move identically) but keeps its valve
285
+ # shut; printing solo, it skips the lead-in moves entirely.
286
  lead_in = _lead_in_moves(
287
+ lead_in_enabled and (lead_in_dispense or motion is not None),
288
  lead_in_length,
289
  lead_in_clearance,
290
  lead_in_lines,
291
  fil_width,
292
+ 255 if lead_in_dispense else 0,
293
  0,
294
+ direction=lead_in_direction,
295
  )
296
  if lead_in:
297
  gcode_list = [*lead_in, *gcode_list]
vector_toolpath.py CHANGED
@@ -246,6 +246,7 @@ def _classify_polyline(
246
  valve: MultiPolygon,
247
  eps: float = EPS,
248
  keep_point=None,
 
249
  ) -> list[Seg]:
250
  """Split a motion polyline at valve-boundary crossings and color the pieces.
251
 
@@ -253,7 +254,10 @@ def _classify_polyline(
253
  Pieces on the boundary count as printing (`covers`), matching the raster
254
  convention that boundary-grazing sweeps dispense. `keep_point(x, y)` can
255
  additionally veto dispensing for a piece (evaluated at its midpoint) —
256
- used for partial infill; the motion is unaffected.
 
 
 
257
  """
258
  segments: list[Seg] = []
259
  has_valve = valve is not None and not valve.is_empty
@@ -268,7 +272,9 @@ def _classify_polyline(
268
  if seg_len <= eps:
269
  continue
270
 
271
- if not has_valve:
 
 
272
  _append_colored_segment(segments, x0, y0, x1, y1, 0)
273
  continue
274
 
@@ -670,28 +676,35 @@ def _circle_spiral_points(
670
  outer_radius: float,
671
  pitch: float,
672
  ) -> list[tuple[float, float]]:
 
 
 
 
 
 
 
 
673
  if outer_radius <= 0.0:
674
  return [(center_x, center_y)]
675
 
676
  pitch = max(float(pitch), 1e-9)
677
- sample_spacing = pitch
678
- theta_max = (outer_radius / pitch) * 2.0 * math.pi
679
- theta = 0.0
680
- points = [(center_x + outer_radius, center_y)]
681
-
682
- while theta < theta_max:
683
- radius = max(outer_radius - (pitch * theta / (2.0 * math.pi)), 0.0)
684
- d_theta = min(math.pi / 10.0, sample_spacing / max(radius, pitch))
685
- theta = min(theta + d_theta, theta_max)
686
- radius = max(outer_radius - (pitch * theta / (2.0 * math.pi)), 0.0)
687
- points.append(
688
- (
689
- center_x + (radius * math.cos(theta)),
690
- center_y + (radius * math.sin(theta)),
691
  )
692
- )
693
 
694
- if points[-1] != (center_x, center_y):
695
  points.append((center_x, center_y))
696
  return points
697
 
@@ -1074,6 +1087,31 @@ def _append_layer_contours(
1074
  return current_x, current_y
1075
 
1076
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1077
  def _lead_in_moves(
1078
  enabled: bool,
1079
  length: float,
@@ -1082,7 +1120,17 @@ def _lead_in_moves(
1082
  line_spacing: float,
1083
  print_color: int,
1084
  off_color: int,
 
1085
  ) -> list[dict]:
 
 
 
 
 
 
 
 
 
1086
  if not enabled:
1087
  return []
1088
  lead_length = max(0.0, float(length))
@@ -1091,27 +1139,43 @@ def _lead_in_moves(
1091
  lead_clearance = max(0.0, float(clearance))
1092
  pass_count = max(1, int(line_count))
1093
  spacing = max(0.0, float(line_spacing))
 
1094
 
1095
  moves: list[dict] = []
1096
- current_x = 0.0
1097
- current_y = 0.0
 
1098
 
1099
- def append_move(dx: float, dy: float, color: int) -> None:
1100
- nonlocal current_x, current_y
1101
- if dx == 0.0 and dy == 0.0:
1102
  return
1103
- moves.append({"X": dx, "Y": dy, "Color": color})
1104
- current_x += dx
1105
- current_y += dy
 
 
 
 
 
 
1106
 
1107
- append_move(-(lead_clearance + lead_length), 0.0, off_color)
1108
- direction = 1.0
1109
  for pass_index in range(pass_count):
1110
- append_move(direction * lead_length, 0.0, print_color)
1111
- direction *= -1.0
1112
  if pass_index < pass_count - 1:
1113
  append_move(0.0, spacing, off_color)
1114
- append_move(-current_x, -current_y, off_color)
 
 
 
 
 
 
 
 
1115
  return moves
1116
 
1117
 
@@ -1165,30 +1229,36 @@ def plan_layer_moves(
1165
  fil_width,
1166
  reverse=layer_number % 2 == 1,
1167
  )
1168
- keep_point = None
1169
- if infill_keep is not None:
1170
- # A spiral "line" is one revolution: revolution k lies at
1171
- # radius outer - k*fil from the bbox-circumscribed circle.
1172
- min_x, min_y, max_x, max_y = motion.bounds
1173
- center_x = (min_x + max_x) / 2.0
1174
- center_y = (min_y + max_y) / 2.0
1175
- outer_radius = max(
1176
- math.hypot(cx - center_x, cy - center_y)
1177
- for cx, cy in (
1178
- (min_x, min_y),
1179
- (max_x, min_y),
1180
- (max_x, max_y),
1181
- (min_x, max_y),
1182
- )
 
1183
  )
 
1184
 
1185
- def keep_point(x: float, y: float) -> bool:
1186
- radius = math.hypot(x - center_x, y - center_y)
1187
- return infill_keep(
1188
- max(0, int(round((outer_radius - radius) / fil_width)))
1189
- )
1190
-
1191
- segments = _classify_polyline(points, valve, keep_point=keep_point)
 
 
 
 
1192
  elif raster_pattern == RASTER_PATTERN_RECTANGULAR_SPIRAL:
1193
  points = _rectangular_spiral_polyline(
1194
  motion.bounds,
 
246
  valve: MultiPolygon,
247
  eps: float = EPS,
248
  keep_point=None,
249
+ keep_segment=None,
250
  ) -> list[Seg]:
251
  """Split a motion polyline at valve-boundary crossings and color the pieces.
252
 
 
254
  Pieces on the boundary count as printing (`covers`), matching the raster
255
  convention that boundary-grazing sweeps dispense. `keep_point(x, y)` can
256
  additionally veto dispensing for a piece (evaluated at its midpoint) —
257
+ used for partial infill. `keep_segment(x0, y0, x1, y1)` vetoes a whole
258
+ source segment BEFORE it is split, so a vetoed transition move stays
259
+ valve-off even where the material boundary cuts through it. The motion
260
+ is unaffected by either gate.
261
  """
262
  segments: list[Seg] = []
263
  has_valve = valve is not None and not valve.is_empty
 
272
  if seg_len <= eps:
273
  continue
274
 
275
+ if not has_valve or (
276
+ keep_segment is not None and not keep_segment(x0, y0, x1, y1)
277
+ ):
278
  _append_colored_segment(segments, x0, y0, x1, y1, 0)
279
  continue
280
 
 
676
  outer_radius: float,
677
  pitch: float,
678
  ) -> list[tuple[float, float]]:
679
+ """Concentric-ring "spiral": full circles stepping inward by one pitch.
680
+
681
+ Each revolution stays at a CONSTANT radius (so the printed walls are true
682
+ smooth circles) and the radius then drops by exactly one pitch in a short
683
+ radial jump before the next revolution, ending at the centre. Ring k thus
684
+ sits exactly at radius outer - k*pitch, which is also what the partial
685
+ infill revolution index assumes.
686
+ """
687
  if outer_radius <= 0.0:
688
  return [(center_x, center_y)]
689
 
690
  pitch = max(float(pitch), 1e-9)
691
+ points: list[tuple[float, float]] = []
692
+ radius = outer_radius
693
+ while radius > 1e-9:
694
+ # Sample roughly one pitch of arc length per step, at least 20/ring.
695
+ d_theta = min(math.pi / 10.0, pitch / max(radius, pitch))
696
+ steps = max(8, int(math.ceil((2.0 * math.pi) / d_theta)))
697
+ for index in range(steps + 1):
698
+ theta = (2.0 * math.pi) * index / steps
699
+ points.append(
700
+ (
701
+ center_x + (radius * math.cos(theta)),
702
+ center_y + (radius * math.sin(theta)),
703
+ )
 
704
  )
705
+ radius -= pitch
706
 
707
+ if not points or points[-1] != (center_x, center_y):
708
  points.append((center_x, center_y))
709
  return points
710
 
 
1087
  return current_x, current_y
1088
 
1089
 
1090
+ LEAD_IN_DIRECTION_LEFT = "Left"
1091
+ LEAD_IN_DIRECTION_RIGHT = "Right"
1092
+ LEAD_IN_DIRECTION_UP = "Up"
1093
+ LEAD_IN_DIRECTION_DOWN = "Down"
1094
+ LEAD_IN_DIRECTION_CHOICES = (
1095
+ LEAD_IN_DIRECTION_LEFT,
1096
+ LEAD_IN_DIRECTION_RIGHT,
1097
+ LEAD_IN_DIRECTION_UP,
1098
+ LEAD_IN_DIRECTION_DOWN,
1099
+ )
1100
+ # Away-from-the-part axis and the lateral line-stepping axis per direction.
1101
+ _LEAD_IN_AXES = {
1102
+ LEAD_IN_DIRECTION_LEFT: ((-1.0, 0.0), (0.0, 1.0)),
1103
+ LEAD_IN_DIRECTION_RIGHT: ((1.0, 0.0), (0.0, 1.0)),
1104
+ LEAD_IN_DIRECTION_UP: ((0.0, 1.0), (1.0, 0.0)),
1105
+ LEAD_IN_DIRECTION_DOWN: ((0.0, -1.0), (1.0, 0.0)),
1106
+ }
1107
+
1108
+
1109
+ def _normalize_lead_in_direction(direction: str | None) -> str:
1110
+ if direction in _LEAD_IN_AXES:
1111
+ return direction
1112
+ return LEAD_IN_DIRECTION_LEFT
1113
+
1114
+
1115
  def _lead_in_moves(
1116
  enabled: bool,
1117
  length: float,
 
1120
  line_spacing: float,
1121
  print_color: int,
1122
  off_color: int,
1123
+ direction: str | None = LEAD_IN_DIRECTION_LEFT,
1124
  ) -> list[dict]:
1125
+ """Purge patch printed before layer 1, in the chosen direction.
1126
+
1127
+ The patch sits `clearance` away from the toolpath start along the purge
1128
+ direction and snakes `line_count` strokes of `length`, stepping one
1129
+ `line_spacing` laterally between strokes. The return route exits the
1130
+ patch one lateral step to the OUTSIDE and comes home through virgin
1131
+ ground, so the freshly primed nozzle never drags back across the wet
1132
+ purge lines.
1133
+ """
1134
  if not enabled:
1135
  return []
1136
  lead_length = max(0.0, float(length))
 
1139
  lead_clearance = max(0.0, float(clearance))
1140
  pass_count = max(1, int(line_count))
1141
  spacing = max(0.0, float(line_spacing))
1142
+ away, lateral = _LEAD_IN_AXES[_normalize_lead_in_direction(direction)]
1143
 
1144
  moves: list[dict] = []
1145
+ # Patch-local frame: `a` runs along the away axis, `v` along the lateral.
1146
+ current_a = 0.0
1147
+ current_v = 0.0
1148
 
1149
+ def append_move(delta_a: float, delta_v: float, color: int) -> None:
1150
+ nonlocal current_a, current_v
1151
+ if delta_a == 0.0 and delta_v == 0.0:
1152
  return
1153
+ moves.append(
1154
+ {
1155
+ "X": delta_a * away[0] + delta_v * lateral[0],
1156
+ "Y": delta_a * away[1] + delta_v * lateral[1],
1157
+ "Color": color,
1158
+ }
1159
+ )
1160
+ current_a += delta_a
1161
+ current_v += delta_v
1162
 
1163
+ append_move(lead_clearance + lead_length, 0.0, off_color)
1164
+ stroke = -1.0 # first stroke prints back toward the part
1165
  for pass_index in range(pass_count):
1166
+ append_move(stroke * lead_length, 0.0, print_color)
1167
+ stroke *= -1.0
1168
  if pass_index < pass_count - 1:
1169
  append_move(0.0, spacing, off_color)
1170
+
1171
+ # Return: step one spacing outside the patch laterally, travel home
1172
+ # through the clearance lane, then step back onto the start point.
1173
+ if spacing > 0.0:
1174
+ append_move(0.0, -(current_v + spacing), off_color)
1175
+ append_move(-current_a, 0.0, off_color)
1176
+ append_move(0.0, -current_v, off_color)
1177
+ else:
1178
+ append_move(-current_a, -current_v, off_color)
1179
  return moves
1180
 
1181
 
 
1229
  fil_width,
1230
  reverse=layer_number % 2 == 1,
1231
  )
1232
+ # Rings sit exactly at radius outer - k*fil. Dispense only ON a
1233
+ # ring: the radial step between revolutions (and the final dive
1234
+ # to the centre) travels with the valve shut otherwise every
1235
+ # step would extrude a radial seam, worst on the outer wall.
1236
+ # Vetoing whole source segments by their radial change also kills
1237
+ # the step pieces the material boundary would otherwise split off.
1238
+ min_x, min_y, max_x, max_y = motion.bounds
1239
+ center_x = (min_x + max_x) / 2.0
1240
+ center_y = (min_y + max_y) / 2.0
1241
+ outer_radius = max(
1242
+ math.hypot(cx - center_x, cy - center_y)
1243
+ for cx, cy in (
1244
+ (min_x, min_y),
1245
+ (max_x, min_y),
1246
+ (max_x, max_y),
1247
+ (min_x, max_y),
1248
  )
1249
+ )
1250
 
1251
+ def keep_segment(x0: float, y0: float, x1: float, y1: float) -> bool:
1252
+ radius_0 = math.hypot(x0 - center_x, y0 - center_y)
1253
+ radius_1 = math.hypot(x1 - center_x, y1 - center_y)
1254
+ if abs(radius_1 - radius_0) > fil_width * 0.25:
1255
+ return False # radial step between rings: travel only
1256
+ if infill_keep is None:
1257
+ return True
1258
+ ring = round((outer_radius - (radius_0 + radius_1) / 2.0) / fil_width)
1259
+ return infill_keep(max(0, int(ring)))
1260
+
1261
+ segments = _classify_polyline(points, valve, keep_segment=keep_segment)
1262
  elif raster_pattern == RASTER_PATTERN_RECTANGULAR_SPIRAL:
1263
  points = _rectangular_spiral_polyline(
1264
  motion.bounds,