Spaces:
Sleeping
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>
- README.md +2 -2
- tests/test_vector_gcode.py +103 -42
- vector_toolpath.py +57 -42
|
@@ -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).
|
| 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 |
|
|
@@ -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
|
| 246 |
-
#
|
| 247 |
-
#
|
| 248 |
-
|
|
|
|
| 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 |
-
|
| 255 |
-
assert
|
| 256 |
-
|
| 257 |
-
#
|
| 258 |
-
#
|
| 259 |
-
|
| 260 |
-
|
| 261 |
-
|
| 262 |
-
|
| 263 |
-
|
| 264 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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[:
|
| 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 |
-
#
|
| 383 |
-
# home through the clearance lane
|
| 384 |
-
#
|
| 385 |
-
{"start": (-4.0, 1.0, 0.0), "end": (
|
| 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[:
|
| 390 |
|
| 391 |
first_z_index = next(index for index, move in enumerate(moves) if move["end"][2] > 0.0)
|
| 392 |
-
assert first_z_index >
|
| 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
|
| 420 |
-
#
|
| 421 |
-
#
|
| 422 |
-
|
|
|
|
| 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
|
|
|
|
| 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 |
-
|
| 493 |
-
|
| 494 |
-
|
| 495 |
-
|
| 496 |
-
|
| 497 |
-
|
| 498 |
-
|
| 499 |
-
|
| 500 |
-
|
| 501 |
-
|
| 502 |
-
|
| 503 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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)
|
|
@@ -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,
|
| 706 |
-
add(right,
|
| 707 |
-
|
| 708 |
|
| 709 |
if bottom <= top + eps:
|
| 710 |
-
add(right,
|
| 711 |
right -= fil_width
|
| 712 |
|
| 713 |
if bottom <= top + eps:
|
| 714 |
-
add(left,
|
| 715 |
-
|
| 716 |
|
| 717 |
if left <= right + eps:
|
| 718 |
if bottom <= top + eps:
|
| 719 |
-
add(left,
|
| 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)
|
| 909 |
-
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 1356 |
-
|
| 1357 |
-
|
| 1358 |
-
|
| 1359 |
-
|
|
|
|
|
|
|
| 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 |
-
|
| 1399 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
#
|
| 1407 |
-
# through the clearance lane
|
| 1408 |
-
|
| 1409 |
-
|
| 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:
|
| 1416 |
-
#
|
| 1417 |
-
|
| 1418 |
-
stroke
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
#
|
| 1426 |
-
#
|
| 1427 |
-
#
|
| 1428 |
-
|
| 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 |
|