CyGuy8 Claude Fable 5 commited on
Commit
cc79beb
·
1 Parent(s): 810dafa

Spirals start at the bottom; lead-in always finishes next to the print start

Browse files

- Both spirals now start printing at the BOTTOM of the shape and work
upward, like the other raster patterns: the rectangular spiral runs the
bottom edge first (mirrored walk), and each circle-spiral revolution
starts/ends at the circle's lowest point (ring jumps happen there too).
A below-the-shape lead-in therefore lands directly under the spiral's
start, matching the other print paths.
- The purge patch always finishes at the corner nearest the toolpath
start, whatever the line count's parity: across-the-approach strokes
print the farthest line first and step back TOWARD the shape, and the
zigzag starts from whichever end makes the last stroke land on the
start corner. The return is one short straight travel through the
clearance lane - the primed nozzle no longer takes a long detour (or
finishes far from the print) on odd line counts or below+horizontal
patches.
- New tests: spirals' first print and toolpath origin sit at the shape's
lowest line; a geometric check that no lead-in travel crosses an
already-printed stroke for 1-4 lines.

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

Files changed (3) hide show
  1. README.md +2 -2
  2. tests/test_vector_gcode.py +103 -42
  3. vector_toolpath.py +57 -42
README.md CHANGED
@@ -125,10 +125,10 @@ The **Multi-Nozzle Split** accordion on the **Shapes & G-Code** tab can split on
125
  - Pressure increases by `0.1` psi per layer by default.
126
  - Pressures are bounded to the regulator's range: table values clamp to 0–100 psi and snap to the tenths place (the serial protocol encodes `pressure × 10`), the per-layer ramp never climbs past 100 psi in the files, and the generation status warns when a ramp would hit the ceiling before the last layer.
127
  - 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.
128
- - **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.
129
  - **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.
130
  - 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.
131
- - **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.
132
  - **Auto Align Split Parts**: in Nozzle Spacing, computes exact per-connection grid gaps from the split pieces' generated G-code (world toolpath 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. **Generate G-Code runs it automatically** whenever the table contains split pieces, so the parallel view comes up already reassembled; the button remains for re-aligning after manual spacing edits. The layout keeps the split's grid arrangement: the reassembled pieces form one rigid block, and non-split shapes flow into the remaining grid slots, always kept clear of everything already placed (with the default column/row spacing as the margins) — nothing can overlap.
133
  - **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). Multi-material assembly parts (shapes sharing a nozzle) work the same way: boundary within half a bead of a sibling material counts as an internal interface and is not contoured — only the assembled shape's true outer surface is traced, and a part fully embedded in the assembly gets no contour at all.
134
 
 
125
  - Pressure increases by `0.1` psi per layer by default.
126
  - Pressures are bounded to the regulator's range: table values clamp to 0–100 psi and snap to the tenths place (the serial protocol encodes `pressure × 10`), the per-layer ramp never climbs past 100 psi in the files, and the generation status warns when a ramp would hit the ceiling before the last layer.
127
  - 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.
128
+ - **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). The purge always finishes at the patch corner nearest the toolpath start, whatever the line count's parity: strokes running across the approach print the farthest line first and step back toward the shape, and the zigzag starts from whichever end makes the last stroke land on the start corner — so the freshly primed nozzle travels only the short clearance lane to the print, never 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.
129
  - **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.
130
  - 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.
131
+ - **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. Both spirals start printing at the BOTTOM of the shape and work upward, like the other patterns — so a below-the-shape lead-in connects directly to the start of the print. `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.
132
  - **Auto Align Split Parts**: in Nozzle Spacing, computes exact per-connection grid gaps from the split pieces' generated G-code (world toolpath 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. **Generate G-Code runs it automatically** whenever the table contains split pieces, so the parallel view comes up already reassembled; the button remains for re-aligning after manual spacing edits. The layout keeps the split's grid arrangement: the reassembled pieces form one rigid block, and non-split shapes flow into the remaining grid slots, always kept clear of everything already placed (with the default column/row spacing as the margins) — nothing can overlap.
133
  - **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). Multi-material assembly parts (shapes sharing a nozzle) work the same way: boundary within half a bead of a sibling material counts as an internal interface and is not contoured — only the assembled shape's true outer surface is traced, and a part fully embedded in the assembly gets no contour at all.
134
 
tests/test_vector_gcode.py CHANGED
@@ -242,26 +242,33 @@ def test_lead_in_line_direction_can_run_across_the_approach(tmp_path) -> None:
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
 
@@ -372,24 +379,22 @@ def test_gcode_lead_in_runs_once_before_first_layer(tmp_path) -> None:
372
 
373
  moves = _moves_with_colors(gcode_path.read_text())
374
 
375
- assert moves[:9] == [
376
  {"start": (0.0, 0.0, 0.0), "end": (-7.0, 0.0, 0.0), "color": 0},
377
  {"start": (-7.0, 0.0, 0.0), "end": (-4.0, 0.0, 0.0), "color": 255},
378
  {"start": (-4.0, 0.0, 0.0), "end": (-4.0, 0.5, 0.0), "color": 0},
379
  {"start": (-4.0, 0.5, 0.0), "end": (-7.0, 0.5, 0.0), "color": 255},
380
  {"start": (-7.0, 0.5, 0.0), "end": (-7.0, 1.0, 0.0), "color": 0},
381
  {"start": (-7.0, 1.0, 0.0), "end": (-4.0, 1.0, 0.0), "color": 255},
382
- # Return route: exit the patch one spacing to the outside, travel
383
- # home through the clearance lane, then step onto the start point —
384
- # never dragging the primed nozzle back across the purge lines.
385
- {"start": (-4.0, 1.0, 0.0), "end": (-4.0, -0.5, 0.0), "color": 0},
386
- {"start": (-4.0, -0.5, 0.0), "end": (0.0, -0.5, 0.0), "color": 0},
387
- {"start": (0.0, -0.5, 0.0), "end": (0.0, 0.0, 0.0), "color": 0},
388
  ]
389
- assert all(move["end"][2] == 0.0 for move in moves[:9])
390
 
391
  first_z_index = next(index for index, move in enumerate(moves) if move["end"][2] > 0.0)
392
- assert first_z_index > 9
393
  assert not any(
394
  move["start"][0] < -3.0 or move["end"][0] < -3.0
395
  for move in moves[first_z_index:]
@@ -416,18 +421,20 @@ def test_gcode_lead_in_direction_points_the_purge_patch(tmp_path) -> None:
416
  )
417
 
418
  moves = _moves_with_colors(gcode_path.read_text())
419
- # Patch is ABOVE the start: first travel goes +7 in Y, purge strokes are
420
- # vertical and sit in y in [4, 7]; only the half-fil-wide return lane
421
- # dips to the negative lateral side.
422
- assert moves[0]["end"] == (0.0, 7.0, 0.0)
 
423
  lead_prints = [m for m in moves[:6] if m["color"] == 255]
424
- assert lead_prints
425
  assert all(abs(m["end"][0] - m["start"][0]) < 1e-9 for m in lead_prints)
426
  assert all(
427
  3.9 <= min(m["start"][1], m["end"][1]) and max(m["start"][1], m["end"][1]) <= 7.1
428
  for m in lead_prints
429
  )
430
- assert all(m["end"][0] >= -0.5 - 1e-9 for m in moves[:9])
 
431
 
432
 
433
  def test_lead_in_opt_out_travels_shared_patch_but_skips_it_solo(tmp_path) -> None:
@@ -486,21 +493,38 @@ def test_gcode_lead_in_return_never_crosses_the_purge_lines(tmp_path) -> None:
486
  lead_in_lines=lines,
487
  output_dir=tmp_path / str(lines),
488
  )
 
 
489
  moves = _moves_with_colors(gcode_path.read_text())
490
  lead_end = next(i for i, m in enumerate(moves) if m["end"] == (0.0, 0.0, 0.0))
 
 
 
491
  prints = [m for m in moves[: lead_end + 1] if m["color"] == 255]
492
- travels = [m for m in moves[: lead_end + 1] if m["color"] == 0]
493
- # No travel move's interior crosses a printed purge line: every
494
- # printed line sits on a lane y = k*0.5, and travels only run along
495
- # x = const (lane changes at line ends) or at y = -0.5 / y <= 0.
496
- for travel in travels[1:]:
497
- y0, y1 = travel["start"][1], travel["end"][1]
498
- x0, x1 = travel["start"][0], travel["end"][0]
499
- if abs(y1 - y0) < 1e-9 and abs(x1 - x0) > 1e-9:
500
- # Horizontal travel: must be outside the printed lanes.
501
- assert y0 < -1e-9 or not any(
502
- abs(p["start"][1] - y0) < 1e-9 for p in prints
503
- ), (lines, travel)
 
 
 
 
 
 
 
 
 
 
 
 
504
 
505
 
506
  def test_gcode_pressure_ramp_can_be_disabled(tmp_path) -> None:
@@ -784,6 +808,43 @@ def test_raster_crosses_interior_holes_with_valve_off(tmp_path) -> None:
784
  )
785
 
786
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
787
  def test_rectangular_spiral_polyline_reverses_center_to_edge() -> None:
788
  inward = _rectangular_spiral_polyline((0.0, 0.0, 3.0, 3.0), 1.0)
789
  outward = _rectangular_spiral_polyline((0.0, 0.0, 3.0, 3.0), 1.0, reverse=True)
 
242
  )
243
  moves = _moves_with_colors(gcode_path.read_text())
244
 
245
+ # The purge patch sits BELOW the shape with HORIZONTAL strokes. The
246
+ # FARTHEST row prints first and every next stroke steps back TOWARD the
247
+ # shape, so the purge finishes on the nearest row: the first travel
248
+ # drops to the farthest row y = -(clearance + spacing*(lines-1)).
249
+ assert moves[0]["end"] == (0.0, -5.0, 0.0)
250
  home_index = next(i for i, m in enumerate(moves[1:], start=1) if m["end"] == (0.0, 0.0, 0.0))
251
  lead_prints = [m for m in moves[:home_index] if m["color"] == 255]
252
  assert len(lead_prints) == 3
253
  assert all(abs(m["end"][1] - m["start"][1]) < 1e-9 for m in lead_prints) # horizontal
254
  assert all(abs(abs(m["end"][0] - m["start"][0]) - 3.0) < 1e-9 for m in lead_prints)
255
+ # Farthest first, stepping toward the shape.
256
+ assert [round(m["start"][1], 6) for m in lead_prints] == [-5.0, -4.5, -4.0]
257
+ # The odd line count starts the zigzag from the far end, so the LAST
258
+ # stroke lands on the corner nearest the toolpath start — the return is
259
+ # one short straight travel home through the clearance lane.
260
+ assert lead_prints[-1]["end"] == (0.0, -4.0, 0.0)
261
+ assert moves[home_index]["start"] == (0.0, -4.0, 0.0)
262
+
263
+ # No travel drags along a row that has ALREADY been printed.
264
+ wet_rows: list[float] = []
265
+ for move in moves[1:home_index]:
266
+ if move["color"] == 255:
267
+ wet_rows.append(round(move["start"][1], 6))
268
+ continue
269
+ y0, y1 = move["start"][1], move["end"][1]
270
+ x0, x1 = move["start"][0], move["end"][0]
271
+ if abs(y1 - y0) < 1e-9 and abs(x1 - x0) > 1e-9 and round(y0, 6) in wet_rows:
272
  raise AssertionError(f"travel drags along wet row y={y0}")
273
 
274
 
 
379
 
380
  moves = _moves_with_colors(gcode_path.read_text())
381
 
382
+ assert moves[:7] == [
383
  {"start": (0.0, 0.0, 0.0), "end": (-7.0, 0.0, 0.0), "color": 0},
384
  {"start": (-7.0, 0.0, 0.0), "end": (-4.0, 0.0, 0.0), "color": 255},
385
  {"start": (-4.0, 0.0, 0.0), "end": (-4.0, 0.5, 0.0), "color": 0},
386
  {"start": (-4.0, 0.5, 0.0), "end": (-7.0, 0.5, 0.0), "color": 255},
387
  {"start": (-7.0, 0.5, 0.0), "end": (-7.0, 1.0, 0.0), "color": 0},
388
  {"start": (-7.0, 1.0, 0.0), "end": (-4.0, 1.0, 0.0), "color": 255},
389
+ # The last stroke ends on the clearance line; the return is one
390
+ # straight travel home through the clearance lane (all strokes sit
391
+ # beyond it, so nothing wet is crossed).
392
+ {"start": (-4.0, 1.0, 0.0), "end": (0.0, 0.0, 0.0), "color": 0},
 
 
393
  ]
394
+ assert all(move["end"][2] == 0.0 for move in moves[:7])
395
 
396
  first_z_index = next(index for index, move in enumerate(moves) if move["end"][2] > 0.0)
397
+ assert first_z_index > 7
398
  assert not any(
399
  move["start"][0] < -3.0 or move["end"][0] < -3.0
400
  for move in moves[first_z_index:]
 
421
  )
422
 
423
  moves = _moves_with_colors(gcode_path.read_text())
424
+ # Patch is ABOVE the start with vertical strokes in y in [4, 7]. The
425
+ # EVEN line count starts from the near end (first travel = clearance
426
+ # only) so the second stroke prints back down and lands on the
427
+ # clearance line — the purge always finishes nearest the start.
428
+ assert moves[0]["end"] == (0.0, 4.0, 0.0)
429
  lead_prints = [m for m in moves[:6] if m["color"] == 255]
430
+ assert len(lead_prints) == 2
431
  assert all(abs(m["end"][0] - m["start"][0]) < 1e-9 for m in lead_prints)
432
  assert all(
433
  3.9 <= min(m["start"][1], m["end"][1]) and max(m["start"][1], m["end"][1]) <= 7.1
434
  for m in lead_prints
435
  )
436
+ assert abs(lead_prints[-1]["end"][1] - 4.0) < 1e-9 # ends on the clearance line
437
+ assert all(abs(m["end"][0]) <= 0.5 + 1e-9 for m in moves[:6])
438
 
439
 
440
  def test_lead_in_opt_out_travels_shared_patch_but_skips_it_solo(tmp_path) -> None:
 
493
  lead_in_lines=lines,
494
  output_dir=tmp_path / str(lines),
495
  )
496
+ from shapely.geometry import LineString
497
+
498
  moves = _moves_with_colors(gcode_path.read_text())
499
  lead_end = next(i for i, m in enumerate(moves) if m["end"] == (0.0, 0.0, 0.0))
500
+ # Whatever the line count's parity, the purge finishes on the
501
+ # clearance line and no travel crosses an ALREADY-printed stroke
502
+ # anywhere but at a shared endpoint.
503
  prints = [m for m in moves[: lead_end + 1] if m["color"] == 255]
504
+ patch_far = min(min(p["start"][0], p["end"][0]) for p in prints)
505
+ assert abs(patch_far + 7.0) < 1e-9
506
+ assert prints[-1]["end"][0] == -4.0 # last stroke ends on the clearance line
507
+
508
+ wet: list[LineString] = []
509
+ for move in moves[: lead_end + 1]:
510
+ if move["color"] == 255:
511
+ wet.append(LineString([move["start"][:2], move["end"][:2]]))
512
+ continue
513
+ travel_line = LineString([move["start"][:2], move["end"][:2]])
514
+ for stroke in wet:
515
+ crossing = travel_line.intersection(stroke)
516
+ if crossing.is_empty:
517
+ continue
518
+ assert crossing.geom_type == "Point", (lines, move)
519
+ point = (crossing.x, crossing.y)
520
+ endpoints = [
521
+ move["start"][:2], move["end"][:2],
522
+ tuple(stroke.coords[0]), tuple(stroke.coords[-1]),
523
+ ]
524
+ assert any(
525
+ abs(point[0] - ex) < 1e-6 and abs(point[1] - ey) < 1e-6
526
+ for ex, ey in endpoints
527
+ ), (lines, move)
528
 
529
 
530
  def test_gcode_pressure_ramp_can_be_disabled(tmp_path) -> None:
 
808
  )
809
 
810
 
811
+ def test_spirals_start_printing_at_the_bottom(tmp_path) -> None:
812
+ layer = box(0.0, 0.0, 6.0, 6.0)
813
+ stack = _stack(layer, layer, name="spiral_bottom")
814
+ for pattern in (RASTER_PATTERN_RECTANGULAR_SPIRAL, RASTER_PATTERN_CIRCLE_SPIRAL):
815
+ tag = pattern.replace(" ", "_")
816
+ path = generate_vector_gcode(
817
+ stack,
818
+ shape_name=f"start_{tag}",
819
+ pressure=25,
820
+ valve=7,
821
+ port=3,
822
+ fil_width=1.0,
823
+ raster_pattern=pattern,
824
+ output_dir=tmp_path / tag,
825
+ )
826
+ moves = _moves_with_colors(path.read_text())
827
+ layer0 = [
828
+ m
829
+ for m in moves
830
+ if m["color"] == 255
831
+ and abs(m["end"][2] - m["start"][2]) < 1e-9
832
+ and m["start"][2] == 0.0
833
+ ]
834
+ assert layer0, pattern
835
+ min_y = min(min(m["start"][1], m["end"][1]) for m in layer0)
836
+ first = layer0[0]
837
+ # Like the other patterns, the spiral starts printing at the BOTTOM
838
+ # of the shape (within one fil of its lowest printed line) and works
839
+ # upward — so a below-the-shape lead-in connects directly to the
840
+ # start of the print.
841
+ assert min(first["start"][1], first["end"][1]) <= min_y + 1.0 + 1e-6, pattern
842
+ # The toolpath origin itself (where the lead-in would attach) also
843
+ # sits at the bottom.
844
+ origin_y = moves[0]["start"][1]
845
+ assert origin_y <= min_y + 1.0 + 1e-6, pattern
846
+
847
+
848
  def test_rectangular_spiral_polyline_reverses_center_to_edge() -> None:
849
  inward = _rectangular_spiral_polyline((0.0, 0.0, 3.0, 3.0), 1.0)
850
  outward = _rectangular_spiral_polyline((0.0, 0.0, 3.0, 3.0), 1.0, reverse=True)
vector_toolpath.py CHANGED
@@ -682,7 +682,12 @@ def _rectangular_spiral_polyline(
682
  fil_width: float,
683
  reverse: bool = False,
684
  ) -> list[tuple[float, float]]:
685
- """Corner polyline spiralling from the bounds inward (or outward if reversed)."""
 
 
 
 
 
686
  min_x, min_y, max_x, max_y = bounds
687
  half = fil_width / 2.0
688
  left = min_x + half
@@ -702,21 +707,21 @@ def _rectangular_spiral_polyline(
702
  points.append((x, y))
703
 
704
  while left <= right + eps and bottom <= top + eps:
705
- add(left, top)
706
- add(right, top)
707
- top -= fil_width
708
 
709
  if bottom <= top + eps:
710
- add(right, bottom)
711
  right -= fil_width
712
 
713
  if bottom <= top + eps:
714
- add(left, bottom)
715
- bottom += fil_width
716
 
717
  if left <= right + eps:
718
  if bottom <= top + eps:
719
- add(left, top)
720
  left += fil_width
721
 
722
  if len(points) == 1:
@@ -905,8 +910,11 @@ def _circle_rings_polyline(
905
  """Concentric-ring "spiral": one full circle per radius, in list order.
906
 
907
  Each revolution stays at a CONSTANT radius (so the printed walls are true
908
- smooth circles); consecutive rings are joined by a radial jump at theta 0,
909
- which the caller classifies as valve-off travel.
 
 
 
910
  """
911
  pitch = max(float(pitch), 1e-9)
912
  points: list[tuple[float, float]] = []
@@ -917,7 +925,7 @@ def _circle_rings_polyline(
917
  d_theta = min(math.pi / 10.0, pitch / max(radius, pitch))
918
  steps = max(8, int(math.ceil((2.0 * math.pi) / d_theta)))
919
  for index in range(steps + 1):
920
- theta = (2.0 * math.pi) * index / steps
921
  points.append(
922
  (
923
  center_x + (radius * math.cos(theta)),
@@ -1352,11 +1360,13 @@ def _lead_in_moves(
1352
  `clearance` away). `orientation` sets which way the purge strokes RUN:
1353
  Auto points them at the shape (horizontal for Left/Right patches,
1354
  vertical for Up/Down), Horizontal/Vertical force an absolute direction.
1355
- When strokes run along the approach, lines step laterally; when they run
1356
- across it, lines step further AWAY from the shape — either way the
1357
- return route exits one step outside the wet patch and comes home through
1358
- virgin ground, so the freshly primed nozzle never drags back across the
1359
- purge lines.
 
 
1360
  """
1361
  if not enabled:
1362
  return []
@@ -1395,43 +1405,48 @@ def _lead_in_moves(
1395
  current_v += delta_v
1396
 
1397
  if strokes_along_away:
1398
- append_move(lead_clearance + lead_length, 0.0, off_color)
1399
- stroke = -1.0 # first stroke prints back toward the part
 
 
 
 
 
 
 
 
1400
  for pass_index in range(pass_count):
1401
  append_move(stroke * lead_length, 0.0, print_color)
1402
  stroke *= -1.0
1403
  if pass_index < pass_count - 1:
1404
  append_move(0.0, spacing, off_color)
1405
 
1406
- # Return: step one spacing outside the patch laterally, travel home
1407
- # through the clearance lane, then step back onto the start point.
1408
- if spacing > 0.0:
1409
- append_move(0.0, -(current_v + spacing), off_color)
1410
- append_move(-current_a, 0.0, off_color)
1411
- append_move(0.0, -current_v, off_color)
1412
- else:
1413
- append_move(-current_a, -current_v, off_color)
1414
  else:
1415
- # Strokes run ACROSS the approach: lines step further away from the
1416
- # shape, so the patch grows outward from `clearance`.
1417
- append_move(lead_clearance, 0.0, off_color)
1418
- stroke = 1.0
 
 
 
 
 
 
 
1419
  for pass_index in range(pass_count):
1420
  append_move(0.0, stroke * lead_length, print_color)
1421
  stroke *= -1.0
1422
  if pass_index < pass_count - 1:
1423
- append_move(spacing, 0.0, off_color)
1424
-
1425
- # Return: step one spacing PAST the outermost wet line, cross to the
1426
- # clean lane one spacing outside the stroke span, come home along
1427
- # it, then step onto the start point.
1428
- if spacing > 0.0:
1429
- append_move(spacing, 0.0, off_color)
1430
- append_move(0.0, -(current_v + spacing), off_color)
1431
- append_move(-current_a, 0.0, off_color)
1432
- append_move(0.0, -current_v, off_color)
1433
- else:
1434
- append_move(-current_a, -current_v, off_color)
1435
  return moves
1436
 
1437
 
 
682
  fil_width: float,
683
  reverse: bool = False,
684
  ) -> list[tuple[float, float]]:
685
+ """Corner polyline spiralling from the bounds inward (or outward if reversed).
686
+
687
+ Starts at the BOTTOM-left corner and runs the bottom edge first, climbing
688
+ upward — matching the other raster patterns (and putting the toolpath
689
+ start where a below-the-shape lead-in ends).
690
+ """
691
  min_x, min_y, max_x, max_y = bounds
692
  half = fil_width / 2.0
693
  left = min_x + half
 
707
  points.append((x, y))
708
 
709
  while left <= right + eps and bottom <= top + eps:
710
+ add(left, bottom)
711
+ add(right, bottom)
712
+ bottom += fil_width
713
 
714
  if bottom <= top + eps:
715
+ add(right, top)
716
  right -= fil_width
717
 
718
  if bottom <= top + eps:
719
+ add(left, top)
720
+ top -= fil_width
721
 
722
  if left <= right + eps:
723
  if bottom <= top + eps:
724
+ add(left, bottom)
725
  left += fil_width
726
 
727
  if len(points) == 1:
 
910
  """Concentric-ring "spiral": one full circle per radius, in list order.
911
 
912
  Each revolution stays at a CONSTANT radius (so the printed walls are true
913
+ smooth circles) and starts/ends at the BOTTOM of the circle — the path
914
+ begins at the shape's lowest point and sweeps upward, matching the other
915
+ raster patterns (and putting the toolpath start where a below-the-shape
916
+ lead-in ends). Consecutive rings are joined by a radial jump at the
917
+ bottom, which the caller classifies as valve-off travel.
918
  """
919
  pitch = max(float(pitch), 1e-9)
920
  points: list[tuple[float, float]] = []
 
925
  d_theta = min(math.pi / 10.0, pitch / max(radius, pitch))
926
  steps = max(8, int(math.ceil((2.0 * math.pi) / d_theta)))
927
  for index in range(steps + 1):
928
+ theta = -math.pi / 2.0 + (2.0 * math.pi) * index / steps
929
  points.append(
930
  (
931
  center_x + (radius * math.cos(theta)),
 
1360
  `clearance` away). `orientation` sets which way the purge strokes RUN:
1361
  Auto points them at the shape (horizontal for Left/Right patches,
1362
  vertical for Up/Down), Horizontal/Vertical force an absolute direction.
1363
+
1364
+ The purge always FINISHES at the patch corner nearest the toolpath
1365
+ start, whatever the line count's parity: strokes across the approach
1366
+ print the farthest line first and step back toward the shape, and the
1367
+ zigzag starts from whichever end makes the LAST stroke land on the
1368
+ start corner. The freshly primed nozzle then travels only the short
1369
+ clearance lane home — never back across the purge lines.
1370
  """
1371
  if not enabled:
1372
  return []
 
1405
  current_v += delta_v
1406
 
1407
  if strokes_along_away:
1408
+ # Strokes run along the approach, stepping laterally between lines.
1409
+ # The LAST stroke must print TOWARD the part (ending at the
1410
+ # clearance line): with an odd count that means starting from the
1411
+ # far end, with an even count from the near end.
1412
+ if pass_count % 2 == 1:
1413
+ append_move(lead_clearance + lead_length, 0.0, off_color)
1414
+ stroke = -1.0
1415
+ else:
1416
+ append_move(lead_clearance, 0.0, off_color)
1417
+ stroke = 1.0
1418
  for pass_index in range(pass_count):
1419
  append_move(stroke * lead_length, 0.0, print_color)
1420
  stroke *= -1.0
1421
  if pass_index < pass_count - 1:
1422
  append_move(0.0, spacing, off_color)
1423
 
1424
+ # Ends at the clearance line, one lateral span from the start:
1425
+ # travel home through the clearance lane (the strokes all sit
1426
+ # beyond it, so nothing wet is crossed).
1427
+ append_move(-current_a, -current_v, off_color)
 
 
 
 
1428
  else:
1429
+ # Strokes run ACROSS the approach: the FARTHEST line prints first
1430
+ # and each next line steps back TOWARD the shape, so the purge
1431
+ # finishes on the nearest line. With an odd count the zigzag starts
1432
+ # from the far lateral end so the last stroke also lands on the
1433
+ # start corner.
1434
+ append_move(lead_clearance + spacing * (pass_count - 1), 0.0, off_color)
1435
+ if pass_count % 2 == 1:
1436
+ append_move(0.0, lead_length, off_color)
1437
+ stroke = -1.0
1438
+ else:
1439
+ stroke = 1.0
1440
  for pass_index in range(pass_count):
1441
  append_move(0.0, stroke * lead_length, print_color)
1442
  stroke *= -1.0
1443
  if pass_index < pass_count - 1:
1444
+ append_move(-spacing, 0.0, off_color)
1445
+
1446
+ # Ends at the near line's start-corner end: straight home along the
1447
+ # clearance lane (later lines were printed closer, so the route
1448
+ # never crosses a wet stroke).
1449
+ append_move(-current_a, -current_v, off_color)
 
 
 
 
 
 
1450
  return moves
1451
 
1452