Spaces:
Running
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 +2 -1
- app.py +66 -15
- tests/test_nozzle_spacing.py +48 -3
- tests/test_vector_gcode.py +165 -4
- vector_gcode.py +12 -2
- vector_toolpath.py +123 -53
|
@@ -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`
|
| 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 |
|
|
@@ -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=
|
| 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 |
-
|
| 2846 |
-
|
| 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(
|
|
@@ -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[-
|
|
|
|
|
|
|
| 115 |
assert rows[0][6:9] == [4, 1, 1]
|
| 116 |
-
assert rows[0][
|
|
|
|
|
|
|
| 117 |
|
| 118 |
rows[0][7] = 3
|
| 119 |
rows[0][8] = 2
|
| 120 |
-
rows[0][
|
|
|
|
| 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 |
|
|
@@ -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[:
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 230 |
]
|
| 231 |
-
assert all(move["end"][2] == 0.0 for move in moves[:
|
| 232 |
|
| 233 |
first_z_index = next(index for index, move in enumerate(moves) if move["end"][2] > 0.0)
|
| 234 |
-
assert first_z_index >
|
| 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(
|
|
@@ -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]
|
|
@@ -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
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 678 |
-
|
| 679 |
-
|
| 680 |
-
|
| 681 |
-
|
| 682 |
-
|
| 683 |
-
|
| 684 |
-
|
| 685 |
-
|
| 686 |
-
|
| 687 |
-
|
| 688 |
-
|
| 689 |
-
|
| 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 |
-
|
| 1097 |
-
|
|
|
|
| 1098 |
|
| 1099 |
-
def append_move(
|
| 1100 |
-
nonlocal
|
| 1101 |
-
if
|
| 1102 |
return
|
| 1103 |
-
moves.append(
|
| 1104 |
-
|
| 1105 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1106 |
|
| 1107 |
-
append_move(
|
| 1108 |
-
|
| 1109 |
for pass_index in range(pass_count):
|
| 1110 |
-
append_move(
|
| 1111 |
-
|
| 1112 |
if pass_index < pass_count - 1:
|
| 1113 |
append_move(0.0, spacing, off_color)
|
| 1114 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1115 |
return moves
|
| 1116 |
|
| 1117 |
|
|
@@ -1165,30 +1229,36 @@ def plan_layer_moves(
|
|
| 1165 |
fil_width,
|
| 1166 |
reverse=layer_number % 2 == 1,
|
| 1167 |
)
|
| 1168 |
-
|
| 1169 |
-
|
| 1170 |
-
|
| 1171 |
-
|
| 1172 |
-
|
| 1173 |
-
|
| 1174 |
-
|
| 1175 |
-
|
| 1176 |
-
|
| 1177 |
-
|
| 1178 |
-
|
| 1179 |
-
|
| 1180 |
-
|
| 1181 |
-
|
| 1182 |
-
)
|
|
|
|
| 1183 |
)
|
|
|
|
| 1184 |
|
| 1185 |
-
|
| 1186 |
-
|
| 1187 |
-
|
| 1188 |
-
|
| 1189 |
-
|
| 1190 |
-
|
| 1191 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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,
|