Spaces:
Sleeping
Sleeping
Contour Order option: trace outlines before or after each layer's infill
Browse files- New Contour Order dropdown in the Generate G-Code options: "After
infill" (default, unchanged behavior) or "Before infill", which traces
each layer's contours right after the Z lift and runs the raster
afterwards.
- The same order drives every shape, so shared reference motion stays in
sync across parallel heads (verified: identical path lengths and
endpoints with contours first).
- Included in the stale-G-code fingerprint and the settings export/import
round trip.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- README.md +1 -0
- app.py +29 -1
- tests/test_vector_gcode.py +92 -0
- vector_gcode.py +4 -0
- vector_toolpath.py +62 -22
README.md
CHANGED
|
@@ -61,6 +61,7 @@ Then open the local Gradio URL in your browser, upload STL files or load the bun
|
|
| 61 |
- Appends a shape outline contour after each enabled shape layer by tracing that layer's polygon boundary
|
| 62 |
- Offers a choice of raster pattern for G-code generation; all shapes always share one combined reference outline for motion (one nozzle path; each dispenses only its own geometry), and every move is emitted as `G1` at one constant speed (no `G0` rapid travel)
|
| 63 |
- **Sweep Buffer (mm)** sets the valve-settle travel before and after each raster line (default 0.8, 0 disables it); it applies to the axis rasters and both woodpiles, and the same value drives every shape so the shared motion stays in sync
|
|
|
|
| 64 |
- Slicing is automatic: **Generate G-Code** slices every shape (fresh or stale) before writing G-code, and **Split Selected Shape into Grid Pieces** slices before splitting — "upload, then Generate G-Code" works in one click with no separate slice step
|
| 65 |
- The generation status leads with the shared print path length and a time estimate at the Visualization tab's nozzle speed, and generation auto-renders the parallel view so switching to the Visualization tab always shows the current print
|
| 66 |
- A **stale-G-code banner** appears above the Generate button whenever the table or any generation option changes after files were generated (each file carries a settings fingerprint), so outdated G-code is never downloaded or printed unnoticed
|
|
|
|
| 61 |
- Appends a shape outline contour after each enabled shape layer by tracing that layer's polygon boundary
|
| 62 |
- Offers a choice of raster pattern for G-code generation; all shapes always share one combined reference outline for motion (one nozzle path; each dispenses only its own geometry), and every move is emitted as `G1` at one constant speed (no `G0` rapid travel)
|
| 63 |
- **Sweep Buffer (mm)** sets the valve-settle travel before and after each raster line (default 0.8, 0 disables it); it applies to the axis rasters and both woodpiles, and the same value drives every shape so the shared motion stays in sync
|
| 64 |
+
- **Contour Order** picks when each layer's contours are traced: after the layer's infill (default) or before it, right after the layer's Z lift; the same order drives every shape so the shared motion stays in sync
|
| 65 |
- Slicing is automatic: **Generate G-Code** slices every shape (fresh or stale) before writing G-code, and **Split Selected Shape into Grid Pieces** slices before splitting — "upload, then Generate G-Code" works in one click with no separate slice step
|
| 66 |
- The generation status leads with the shared print path length and a time estimate at the Visualization tab's nozzle speed, and generation auto-renders the parallel view so switching to the Visualization tab always shows the current print
|
| 67 |
- A **stale-G-code banner** appears above the Generate button whenever the table or any generation option changes after files were generated (each file carries a settings fingerprint), so outdated G-code is never downloaded or printed unnoticed
|
app.py
CHANGED
|
@@ -41,6 +41,8 @@ from stl_slicer import (
|
|
| 41 |
)
|
| 42 |
from vector_gcode import MAX_PRESSURE_PSI, generate_vector_gcode
|
| 43 |
from vector_toolpath import (
|
|
|
|
|
|
|
| 44 |
LEAD_IN_DIRECTION_CHOICES,
|
| 45 |
LEAD_IN_DIRECTION_LEFT,
|
| 46 |
LEAD_IN_LINE_AUTO,
|
|
@@ -4335,6 +4337,7 @@ def _gcode_settings_snapshot(
|
|
| 4335 |
scale_mode: str | None,
|
| 4336 |
sweep_buffer: float = 0.8,
|
| 4337 |
lead_in_orientation: str | None = None,
|
|
|
|
| 4338 |
) -> dict:
|
| 4339 |
"""Fingerprint of every setting that shapes this record's G-code.
|
| 4340 |
|
|
@@ -4366,6 +4369,7 @@ def _gcode_settings_snapshot(
|
|
| 4366 |
"fil_width": round(_coerce_float(fil_width, 0.8), 6),
|
| 4367 |
"scale_mode": _normalize_scale_mode(scale_mode),
|
| 4368 |
"sweep_buffer": round(_coerce_float(sweep_buffer, 0.8), 6),
|
|
|
|
| 4369 |
}
|
| 4370 |
|
| 4371 |
|
|
@@ -4389,6 +4393,7 @@ def check_gcode_staleness(
|
|
| 4389 |
scale_mode: str | None,
|
| 4390 |
sweep_buffer: float = 0.8,
|
| 4391 |
lead_in_orientation: str | None = None,
|
|
|
|
| 4392 |
) -> str:
|
| 4393 |
"""Warning banner text when generated G-code no longer matches the settings."""
|
| 4394 |
records = _apply_shape_settings(records or [], settings_table)
|
|
@@ -4406,6 +4411,7 @@ def check_gcode_staleness(
|
|
| 4406 |
scale_mode,
|
| 4407 |
sweep_buffer,
|
| 4408 |
lead_in_orientation,
|
|
|
|
| 4409 |
)
|
| 4410 |
for record in records:
|
| 4411 |
if not record.get("gcode_path"):
|
|
@@ -4447,6 +4453,7 @@ def export_project_settings(
|
|
| 4447 |
lead_in_direction: str | None,
|
| 4448 |
lead_in_orientation: str | None,
|
| 4449 |
nozzle_speed: Any,
|
|
|
|
| 4450 |
) -> tuple[str | None, str]:
|
| 4451 |
"""Write the session's settings to a small JSON file, keyed by STL name.
|
| 4452 |
|
|
@@ -4483,6 +4490,7 @@ def export_project_settings(
|
|
| 4483 |
"lead_in_direction": str(lead_in_direction or LEAD_IN_DIRECTION_LEFT),
|
| 4484 |
"lead_in_orientation": str(lead_in_orientation or LEAD_IN_LINE_AUTO),
|
| 4485 |
"nozzle_speed": _coerce_float(nozzle_speed, 10.0),
|
|
|
|
| 4486 |
},
|
| 4487 |
}
|
| 4488 |
settings_path = Path(tempfile.mkdtemp(prefix="pp_settings_")) / "parallelprint_settings.json"
|
|
@@ -4506,7 +4514,7 @@ def import_project_settings(
|
|
| 4506 |
"""
|
| 4507 |
|
| 4508 |
def _skip_options() -> tuple:
|
| 4509 |
-
return tuple(gr.skip() for _ in range(
|
| 4510 |
|
| 4511 |
paths = _uploaded_file_paths(settings_upload)
|
| 4512 |
if not paths:
|
|
@@ -4568,6 +4576,7 @@ def import_project_settings(
|
|
| 4568 |
option("fil_width"),
|
| 4569 |
option("scale_mode"),
|
| 4570 |
option("nozzle_speed"),
|
|
|
|
| 4571 |
)
|
| 4572 |
|
| 4573 |
|
|
@@ -4608,6 +4617,7 @@ def generate_dynamic_gcode(
|
|
| 4608 |
nozzle_speed: Any = None,
|
| 4609 |
sweep_buffer: float = 0.8,
|
| 4610 |
lead_in_orientation: str | None = None,
|
|
|
|
| 4611 |
progress: gr.Progress = gr.Progress(),
|
| 4612 |
) -> tuple:
|
| 4613 |
records = _apply_shape_settings(records or [], settings_table)
|
|
@@ -4700,6 +4710,9 @@ def generate_dynamic_gcode(
|
|
| 4700 |
raster_pattern=raster_pattern,
|
| 4701 |
contour_sources=contour_sources,
|
| 4702 |
active_contour_owner=int(record.get("idx", 0)),
|
|
|
|
|
|
|
|
|
|
| 4703 |
infill=_coerce_float(record.get("infill", 100.0), 100.0) / 100.0,
|
| 4704 |
motion_infill_fractions=motion_infill_fractions,
|
| 4705 |
emit_pressure_commands=owns_port_pressure,
|
|
@@ -4735,6 +4748,7 @@ def generate_dynamic_gcode(
|
|
| 4735 |
scale_mode,
|
| 4736 |
sweep_buffer,
|
| 4737 |
lead_in_orientation,
|
|
|
|
| 4738 |
)
|
| 4739 |
messages.append(f"Shape {record['idx']}: wrote `{gcode_path.name}`.")
|
| 4740 |
except Exception as exc:
|
|
@@ -5285,6 +5299,15 @@ def build_dynamic_demo() -> gr.Blocks:
|
|
| 5285 |
min_width=170,
|
| 5286 |
info="Valve-settle travel before/after each raster line (0 = none).",
|
| 5287 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5288 |
with gr.Accordion("Lead In Options", open=False, elem_classes=["settings-accordion"]):
|
| 5289 |
gr.Markdown("Applies to shapes with **Lead In** checked in the Shape Settings table.")
|
| 5290 |
with gr.Row():
|
|
@@ -5669,6 +5692,7 @@ def build_dynamic_demo() -> gr.Blocks:
|
|
| 5669 |
gcode_lead_in_direction,
|
| 5670 |
gcode_lead_in_orientation,
|
| 5671 |
viz_nozzle_speed,
|
|
|
|
| 5672 |
],
|
| 5673 |
outputs=[settings_export_file, settings_status],
|
| 5674 |
queue=False,
|
|
@@ -5692,6 +5716,7 @@ def build_dynamic_demo() -> gr.Blocks:
|
|
| 5692 |
fil_width,
|
| 5693 |
scale_mode,
|
| 5694 |
viz_nozzle_speed,
|
|
|
|
| 5695 |
],
|
| 5696 |
)
|
| 5697 |
|
|
@@ -5712,6 +5737,7 @@ def build_dynamic_demo() -> gr.Blocks:
|
|
| 5712 |
scale_mode,
|
| 5713 |
gcode_sweep_buffer,
|
| 5714 |
gcode_lead_in_orientation,
|
|
|
|
| 5715 |
]
|
| 5716 |
shape_settings.change(
|
| 5717 |
fn=check_gcode_staleness,
|
|
@@ -5731,6 +5757,7 @@ def build_dynamic_demo() -> gr.Blocks:
|
|
| 5731 |
scale_mode,
|
| 5732 |
gcode_sweep_buffer,
|
| 5733 |
gcode_lead_in_orientation,
|
|
|
|
| 5734 |
):
|
| 5735 |
stale_control.change(
|
| 5736 |
fn=check_gcode_staleness,
|
|
@@ -5775,6 +5802,7 @@ def build_dynamic_demo() -> gr.Blocks:
|
|
| 5775 |
viz_nozzle_speed,
|
| 5776 |
gcode_sweep_buffer,
|
| 5777 |
gcode_lead_in_orientation,
|
|
|
|
| 5778 |
],
|
| 5779 |
outputs=[shape_records, ref_layers, gcode_downloads, gcode_status, gcode_text_source, gcode_source, gcode_download_all],
|
| 5780 |
).then(
|
|
|
|
| 41 |
)
|
| 42 |
from vector_gcode import MAX_PRESSURE_PSI, generate_vector_gcode
|
| 43 |
from vector_toolpath import (
|
| 44 |
+
CONTOUR_ORDER_CHOICES,
|
| 45 |
+
CONTOUR_ORDER_LAST,
|
| 46 |
LEAD_IN_DIRECTION_CHOICES,
|
| 47 |
LEAD_IN_DIRECTION_LEFT,
|
| 48 |
LEAD_IN_LINE_AUTO,
|
|
|
|
| 4337 |
scale_mode: str | None,
|
| 4338 |
sweep_buffer: float = 0.8,
|
| 4339 |
lead_in_orientation: str | None = None,
|
| 4340 |
+
contour_order: str | None = None,
|
| 4341 |
) -> dict:
|
| 4342 |
"""Fingerprint of every setting that shapes this record's G-code.
|
| 4343 |
|
|
|
|
| 4369 |
"fil_width": round(_coerce_float(fil_width, 0.8), 6),
|
| 4370 |
"scale_mode": _normalize_scale_mode(scale_mode),
|
| 4371 |
"sweep_buffer": round(_coerce_float(sweep_buffer, 0.8), 6),
|
| 4372 |
+
"contour_order": str(contour_order or CONTOUR_ORDER_LAST),
|
| 4373 |
}
|
| 4374 |
|
| 4375 |
|
|
|
|
| 4393 |
scale_mode: str | None,
|
| 4394 |
sweep_buffer: float = 0.8,
|
| 4395 |
lead_in_orientation: str | None = None,
|
| 4396 |
+
contour_order: str | None = None,
|
| 4397 |
) -> str:
|
| 4398 |
"""Warning banner text when generated G-code no longer matches the settings."""
|
| 4399 |
records = _apply_shape_settings(records or [], settings_table)
|
|
|
|
| 4411 |
scale_mode,
|
| 4412 |
sweep_buffer,
|
| 4413 |
lead_in_orientation,
|
| 4414 |
+
contour_order,
|
| 4415 |
)
|
| 4416 |
for record in records:
|
| 4417 |
if not record.get("gcode_path"):
|
|
|
|
| 4453 |
lead_in_direction: str | None,
|
| 4454 |
lead_in_orientation: str | None,
|
| 4455 |
nozzle_speed: Any,
|
| 4456 |
+
contour_order: str | None = None,
|
| 4457 |
) -> tuple[str | None, str]:
|
| 4458 |
"""Write the session's settings to a small JSON file, keyed by STL name.
|
| 4459 |
|
|
|
|
| 4490 |
"lead_in_direction": str(lead_in_direction or LEAD_IN_DIRECTION_LEFT),
|
| 4491 |
"lead_in_orientation": str(lead_in_orientation or LEAD_IN_LINE_AUTO),
|
| 4492 |
"nozzle_speed": _coerce_float(nozzle_speed, 10.0),
|
| 4493 |
+
"contour_order": str(contour_order or CONTOUR_ORDER_LAST),
|
| 4494 |
},
|
| 4495 |
}
|
| 4496 |
settings_path = Path(tempfile.mkdtemp(prefix="pp_settings_")) / "parallelprint_settings.json"
|
|
|
|
| 4514 |
"""
|
| 4515 |
|
| 4516 |
def _skip_options() -> tuple:
|
| 4517 |
+
return tuple(gr.skip() for _ in range(13))
|
| 4518 |
|
| 4519 |
paths = _uploaded_file_paths(settings_upload)
|
| 4520 |
if not paths:
|
|
|
|
| 4576 |
option("fil_width"),
|
| 4577 |
option("scale_mode"),
|
| 4578 |
option("nozzle_speed"),
|
| 4579 |
+
option("contour_order"),
|
| 4580 |
)
|
| 4581 |
|
| 4582 |
|
|
|
|
| 4617 |
nozzle_speed: Any = None,
|
| 4618 |
sweep_buffer: float = 0.8,
|
| 4619 |
lead_in_orientation: str | None = None,
|
| 4620 |
+
contour_order: str | None = None,
|
| 4621 |
progress: gr.Progress = gr.Progress(),
|
| 4622 |
) -> tuple:
|
| 4623 |
records = _apply_shape_settings(records or [], settings_table)
|
|
|
|
| 4710 |
raster_pattern=raster_pattern,
|
| 4711 |
contour_sources=contour_sources,
|
| 4712 |
active_contour_owner=int(record.get("idx", 0)),
|
| 4713 |
+
# Same order for every shape: contour placement is part of
|
| 4714 |
+
# the shared motion path.
|
| 4715 |
+
contour_order=contour_order,
|
| 4716 |
infill=_coerce_float(record.get("infill", 100.0), 100.0) / 100.0,
|
| 4717 |
motion_infill_fractions=motion_infill_fractions,
|
| 4718 |
emit_pressure_commands=owns_port_pressure,
|
|
|
|
| 4748 |
scale_mode,
|
| 4749 |
sweep_buffer,
|
| 4750 |
lead_in_orientation,
|
| 4751 |
+
contour_order,
|
| 4752 |
)
|
| 4753 |
messages.append(f"Shape {record['idx']}: wrote `{gcode_path.name}`.")
|
| 4754 |
except Exception as exc:
|
|
|
|
| 5299 |
min_width=170,
|
| 5300 |
info="Valve-settle travel before/after each raster line (0 = none).",
|
| 5301 |
)
|
| 5302 |
+
gcode_contour_order = gr.Dropdown(
|
| 5303 |
+
label="Contour Order",
|
| 5304 |
+
choices=list(CONTOUR_ORDER_CHOICES),
|
| 5305 |
+
value=CONTOUR_ORDER_LAST,
|
| 5306 |
+
allow_custom_value=False,
|
| 5307 |
+
scale=1,
|
| 5308 |
+
min_width=170,
|
| 5309 |
+
info="Trace shape outlines before or after each layer's infill.",
|
| 5310 |
+
)
|
| 5311 |
with gr.Accordion("Lead In Options", open=False, elem_classes=["settings-accordion"]):
|
| 5312 |
gr.Markdown("Applies to shapes with **Lead In** checked in the Shape Settings table.")
|
| 5313 |
with gr.Row():
|
|
|
|
| 5692 |
gcode_lead_in_direction,
|
| 5693 |
gcode_lead_in_orientation,
|
| 5694 |
viz_nozzle_speed,
|
| 5695 |
+
gcode_contour_order,
|
| 5696 |
],
|
| 5697 |
outputs=[settings_export_file, settings_status],
|
| 5698 |
queue=False,
|
|
|
|
| 5716 |
fil_width,
|
| 5717 |
scale_mode,
|
| 5718 |
viz_nozzle_speed,
|
| 5719 |
+
gcode_contour_order,
|
| 5720 |
],
|
| 5721 |
)
|
| 5722 |
|
|
|
|
| 5737 |
scale_mode,
|
| 5738 |
gcode_sweep_buffer,
|
| 5739 |
gcode_lead_in_orientation,
|
| 5740 |
+
gcode_contour_order,
|
| 5741 |
]
|
| 5742 |
shape_settings.change(
|
| 5743 |
fn=check_gcode_staleness,
|
|
|
|
| 5757 |
scale_mode,
|
| 5758 |
gcode_sweep_buffer,
|
| 5759 |
gcode_lead_in_orientation,
|
| 5760 |
+
gcode_contour_order,
|
| 5761 |
):
|
| 5762 |
stale_control.change(
|
| 5763 |
fn=check_gcode_staleness,
|
|
|
|
| 5802 |
viz_nozzle_speed,
|
| 5803 |
gcode_sweep_buffer,
|
| 5804 |
gcode_lead_in_orientation,
|
| 5805 |
+
gcode_contour_order,
|
| 5806 |
],
|
| 5807 |
outputs=[shape_records, ref_layers, gcode_downloads, gcode_status, gcode_text_source, gcode_source, gcode_download_all],
|
| 5808 |
).then(
|
tests/test_vector_gcode.py
CHANGED
|
@@ -1683,6 +1683,98 @@ def test_split_contour_gcode_never_traces_the_cuts(tmp_path) -> None:
|
|
| 1683 |
assert vertical_prints == []
|
| 1684 |
|
| 1685 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1686 |
def test_split_pieces_center_on_the_shared_motion() -> None:
|
| 1687 |
from vector_toolpath import _centering_delta
|
| 1688 |
|
|
|
|
| 1683 |
assert vertical_prints == []
|
| 1684 |
|
| 1685 |
|
| 1686 |
+
def test_contour_order_option_places_contours_first_or_last(tmp_path) -> None:
|
| 1687 |
+
from vector_toolpath import CONTOUR_ORDER_FIRST
|
| 1688 |
+
|
| 1689 |
+
layer = box(0.0, 0.0, 6.0, 4.0)
|
| 1690 |
+
stack = _stack(layer, layer, name="order")
|
| 1691 |
+
sources = [ContourSource(owner_idx=1, stack=stack)]
|
| 1692 |
+
|
| 1693 |
+
def per_layer_kinds(contour_order):
|
| 1694 |
+
tag = str(contour_order or "default").replace(" ", "_")
|
| 1695 |
+
gcode_path = generate_vector_gcode(
|
| 1696 |
+
stack,
|
| 1697 |
+
shape_name=f"order_{tag}",
|
| 1698 |
+
pressure=25,
|
| 1699 |
+
valve=4,
|
| 1700 |
+
port=1,
|
| 1701 |
+
fil_width=1.0,
|
| 1702 |
+
contour_sources=sources,
|
| 1703 |
+
active_contour_owner=1,
|
| 1704 |
+
contour_order=contour_order,
|
| 1705 |
+
output_dir=tmp_path / tag,
|
| 1706 |
+
)
|
| 1707 |
+
moves = _moves_with_colors(gcode_path.read_text())
|
| 1708 |
+
prints = [move for move in moves if move["color"] == 255]
|
| 1709 |
+
# The contour rims are the outermost printed Y lines; the X-raster
|
| 1710 |
+
# scanlines run strictly inside them.
|
| 1711 |
+
rim_lo = min(min(m["start"][1], m["end"][1]) for m in prints)
|
| 1712 |
+
rim_hi = max(max(m["start"][1], m["end"][1]) for m in prints)
|
| 1713 |
+
|
| 1714 |
+
def kind(move: dict) -> str:
|
| 1715 |
+
if abs(move["end"][1] - move["start"][1]) > 1e-9:
|
| 1716 |
+
return "contour" # vertical print: box side edge
|
| 1717 |
+
on_rim = (
|
| 1718 |
+
min(abs(move["start"][1] - rim_lo), abs(move["start"][1] - rim_hi))
|
| 1719 |
+
< 1e-6
|
| 1720 |
+
)
|
| 1721 |
+
return "contour" if on_rim else "infill"
|
| 1722 |
+
|
| 1723 |
+
layers: list[list[str]] = [[]]
|
| 1724 |
+
for move in moves:
|
| 1725 |
+
if abs(move["end"][2] - move["start"][2]) > 1e-9:
|
| 1726 |
+
layers.append([])
|
| 1727 |
+
continue
|
| 1728 |
+
if move["color"] != 255:
|
| 1729 |
+
continue
|
| 1730 |
+
layers[-1].append(kind(move))
|
| 1731 |
+
return [kinds for kinds in layers if kinds]
|
| 1732 |
+
|
| 1733 |
+
# Default: infill first, contours close the layer.
|
| 1734 |
+
for kinds in per_layer_kinds(None):
|
| 1735 |
+
assert "contour" in kinds and "infill" in kinds
|
| 1736 |
+
first_contour = kinds.index("contour")
|
| 1737 |
+
assert all(kind == "contour" for kind in kinds[first_contour:])
|
| 1738 |
+
assert kinds[0] == "infill"
|
| 1739 |
+
|
| 1740 |
+
# Before infill: contours open the layer, infill follows.
|
| 1741 |
+
for kinds in per_layer_kinds(CONTOUR_ORDER_FIRST):
|
| 1742 |
+
assert "contour" in kinds and "infill" in kinds
|
| 1743 |
+
first_infill = kinds.index("infill")
|
| 1744 |
+
assert all(kind == "infill" for kind in kinds[first_infill:])
|
| 1745 |
+
assert kinds[0] == "contour"
|
| 1746 |
+
|
| 1747 |
+
# Shared motion stays in sync with contours first: every head traces one
|
| 1748 |
+
# identical path.
|
| 1749 |
+
stack_b = _stack(layer, layer, name="order_b")
|
| 1750 |
+
reference = build_reference_stack([stack, stack_b], grid=1.0)
|
| 1751 |
+
shared_sources = [
|
| 1752 |
+
ContourSource(owner_idx=1, stack=stack),
|
| 1753 |
+
ContourSource(owner_idx=2, stack=stack_b),
|
| 1754 |
+
]
|
| 1755 |
+
lengths = set()
|
| 1756 |
+
ends = set()
|
| 1757 |
+
for idx, shape in enumerate((stack, stack_b), start=1):
|
| 1758 |
+
path = generate_vector_gcode(
|
| 1759 |
+
shape,
|
| 1760 |
+
shape_name=f"sync_{idx}",
|
| 1761 |
+
pressure=25,
|
| 1762 |
+
valve=3 + idx,
|
| 1763 |
+
port=1,
|
| 1764 |
+
fil_width=1.0,
|
| 1765 |
+
motion=reference,
|
| 1766 |
+
contour_sources=shared_sources,
|
| 1767 |
+
active_contour_owner=idx,
|
| 1768 |
+
contour_order=CONTOUR_ORDER_FIRST,
|
| 1769 |
+
output_dir=tmp_path / f"sync_{idx}",
|
| 1770 |
+
)
|
| 1771 |
+
moves = _moves_with_colors(path.read_text())
|
| 1772 |
+
lengths.add(round(_total_length(moves), 4))
|
| 1773 |
+
ends.add(moves[-1]["end"])
|
| 1774 |
+
assert len(lengths) == 1
|
| 1775 |
+
assert len(ends) == 1
|
| 1776 |
+
|
| 1777 |
+
|
| 1778 |
def test_split_pieces_center_on_the_shared_motion() -> None:
|
| 1779 |
from vector_toolpath import _centering_delta
|
| 1780 |
|
vector_gcode.py
CHANGED
|
@@ -193,6 +193,7 @@ def generate_vector_gcode(
|
|
| 193 |
motion: LayerStack | None = None,
|
| 194 |
contour_sources: list[ContourSource] | None = None,
|
| 195 |
active_contour_owner: int | None = None,
|
|
|
|
| 196 |
infill: float = 1.0,
|
| 197 |
motion_infill_fractions: list[float] | None = None,
|
| 198 |
emit_pressure_commands: bool = True,
|
|
@@ -331,6 +332,9 @@ def generate_vector_gcode(
|
|
| 331 |
# Valve-settle travel before/after each raster sweep; one fil_width
|
| 332 |
# when not given. Pass the SAME value for every shape sharing motion.
|
| 333 |
sweep_buffer=sweep_buffer,
|
|
|
|
|
|
|
|
|
|
| 334 |
)
|
| 335 |
|
| 336 |
# World anchor: the toolpath origin expressed in the shape's own frame.
|
|
|
|
| 193 |
motion: LayerStack | None = None,
|
| 194 |
contour_sources: list[ContourSource] | None = None,
|
| 195 |
active_contour_owner: int | None = None,
|
| 196 |
+
contour_order: str | None = None,
|
| 197 |
infill: float = 1.0,
|
| 198 |
motion_infill_fractions: list[float] | None = None,
|
| 199 |
emit_pressure_commands: bool = True,
|
|
|
|
| 332 |
# Valve-settle travel before/after each raster sweep; one fil_width
|
| 333 |
# when not given. Pass the SAME value for every shape sharing motion.
|
| 334 |
sweep_buffer=sweep_buffer,
|
| 335 |
+
# Same value for every shape sharing motion: contour placement is
|
| 336 |
+
# part of the shared path.
|
| 337 |
+
contour_order=contour_order,
|
| 338 |
)
|
| 339 |
|
| 340 |
# World anchor: the toolpath origin expressed in the shape's own frame.
|
vector_toolpath.py
CHANGED
|
@@ -1281,6 +1281,16 @@ def _append_layer_contours(
|
|
| 1281 |
return current_x, current_y
|
| 1282 |
|
| 1283 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1284 |
LEAD_IN_DIRECTION_LEFT = "Left"
|
| 1285 |
LEAD_IN_DIRECTION_RIGHT = "Right"
|
| 1286 |
LEAD_IN_DIRECTION_UP = "Up"
|
|
@@ -1440,9 +1450,14 @@ def plan_layer_moves(
|
|
| 1440 |
ring_center: tuple[float, float] | None = None,
|
| 1441 |
motion_infill_fractions: list[float] | None = None,
|
| 1442 |
sweep_buffer: float | None = None,
|
|
|
|
| 1443 |
) -> tuple[list[dict], tuple[float, float]]:
|
| 1444 |
"""Assemble per-layer segments into a relative move list for all patterns.
|
| 1445 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1446 |
`scan_frame` (an XY box) pins the rasters' scanlines to a global grid so
|
| 1447 |
lines stack across layers and across split pieces, and provides the
|
| 1448 |
rotation pivot for diagonal layers.
|
|
@@ -1481,6 +1496,7 @@ def plan_layer_moves(
|
|
| 1481 |
origin_y = 0.0
|
| 1482 |
raster_origin_initialized = False
|
| 1483 |
contour_layers = contour_layers or []
|
|
|
|
| 1484 |
|
| 1485 |
for layer_number, (motion, valve) in enumerate(zip(motion_layers, valve_layers)):
|
| 1486 |
if motion is None or motion.is_empty:
|
|
@@ -1671,6 +1687,18 @@ def plan_layer_moves(
|
|
| 1671 |
origin_x, origin_y = first_x, first_y
|
| 1672 |
raster_origin_initialized = True
|
| 1673 |
elif layer_number > 0:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1674 |
current_x, current_y = _append_relative_move(
|
| 1675 |
gcode_list,
|
| 1676 |
current_x,
|
|
@@ -1678,9 +1706,20 @@ def plan_layer_moves(
|
|
| 1678 |
first_x,
|
| 1679 |
first_y,
|
| 1680 |
0,
|
| 1681 |
-
z_step=layer_height,
|
| 1682 |
)
|
| 1683 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1684 |
current_x, current_y = _append_relative_move(
|
| 1685 |
gcode_list,
|
| 1686 |
current_x,
|
|
@@ -1708,26 +1747,27 @@ def plan_layer_moves(
|
|
| 1708 |
color,
|
| 1709 |
)
|
| 1710 |
|
| 1711 |
-
|
| 1712 |
-
|
| 1713 |
-
|
| 1714 |
-
|
| 1715 |
-
|
| 1716 |
-
|
| 1717 |
-
|
| 1718 |
-
|
| 1719 |
-
|
| 1720 |
-
|
| 1721 |
-
|
| 1722 |
-
|
| 1723 |
-
|
| 1724 |
-
|
| 1725 |
-
|
| 1726 |
-
|
| 1727 |
-
|
| 1728 |
-
|
| 1729 |
-
|
| 1730 |
-
|
|
|
|
| 1731 |
|
| 1732 |
return gcode_list, (origin_x, origin_y)
|
| 1733 |
|
|
|
|
| 1281 |
return current_x, current_y
|
| 1282 |
|
| 1283 |
|
| 1284 |
+
CONTOUR_ORDER_LAST = "After infill"
|
| 1285 |
+
CONTOUR_ORDER_FIRST = "Before infill"
|
| 1286 |
+
CONTOUR_ORDER_CHOICES = (CONTOUR_ORDER_LAST, CONTOUR_ORDER_FIRST)
|
| 1287 |
+
|
| 1288 |
+
|
| 1289 |
+
def _contours_first(contour_order: str | None) -> bool:
|
| 1290 |
+
text = str(contour_order or "").strip().lower()
|
| 1291 |
+
return text.startswith("before") or text == "first"
|
| 1292 |
+
|
| 1293 |
+
|
| 1294 |
LEAD_IN_DIRECTION_LEFT = "Left"
|
| 1295 |
LEAD_IN_DIRECTION_RIGHT = "Right"
|
| 1296 |
LEAD_IN_DIRECTION_UP = "Up"
|
|
|
|
| 1450 |
ring_center: tuple[float, float] | None = None,
|
| 1451 |
motion_infill_fractions: list[float] | None = None,
|
| 1452 |
sweep_buffer: float | None = None,
|
| 1453 |
+
contour_order: str | None = None,
|
| 1454 |
) -> tuple[list[dict], tuple[float, float]]:
|
| 1455 |
"""Assemble per-layer segments into a relative move list for all patterns.
|
| 1456 |
|
| 1457 |
+
`contour_order` picks when each layer's contours are traced:
|
| 1458 |
+
"After infill" (default) appends them after the layer's raster;
|
| 1459 |
+
"Before infill" traces them first, right after the layer's Z lift.
|
| 1460 |
+
|
| 1461 |
`scan_frame` (an XY box) pins the rasters' scanlines to a global grid so
|
| 1462 |
lines stack across layers and across split pieces, and provides the
|
| 1463 |
rotation pivot for diagonal layers.
|
|
|
|
| 1496 |
origin_y = 0.0
|
| 1497 |
raster_origin_initialized = False
|
| 1498 |
contour_layers = contour_layers or []
|
| 1499 |
+
contours_first = _contours_first(contour_order)
|
| 1500 |
|
| 1501 |
for layer_number, (motion, valve) in enumerate(zip(motion_layers, valve_layers)):
|
| 1502 |
if motion is None or motion.is_empty:
|
|
|
|
| 1687 |
origin_x, origin_y = first_x, first_y
|
| 1688 |
raster_origin_initialized = True
|
| 1689 |
elif layer_number > 0:
|
| 1690 |
+
current_x, current_y = _append_relative_move(
|
| 1691 |
+
gcode_list,
|
| 1692 |
+
current_x,
|
| 1693 |
+
current_y,
|
| 1694 |
+
# Contours-first: lift Z in place — the contours run before
|
| 1695 |
+
# the travel to the raster start.
|
| 1696 |
+
current_x if contours_first else first_x,
|
| 1697 |
+
current_y if contours_first else first_y,
|
| 1698 |
+
0,
|
| 1699 |
+
z_step=layer_height,
|
| 1700 |
+
)
|
| 1701 |
+
elif not contours_first:
|
| 1702 |
current_x, current_y = _append_relative_move(
|
| 1703 |
gcode_list,
|
| 1704 |
current_x,
|
|
|
|
| 1706 |
first_x,
|
| 1707 |
first_y,
|
| 1708 |
0,
|
|
|
|
| 1709 |
)
|
| 1710 |
+
|
| 1711 |
+
if contours_first:
|
| 1712 |
+
current_x, current_y = _append_layer_contours(
|
| 1713 |
+
gcode_list,
|
| 1714 |
+
current_x,
|
| 1715 |
+
current_y,
|
| 1716 |
+
contour_layers,
|
| 1717 |
+
layer_number,
|
| 1718 |
+
active_contour_owner,
|
| 1719 |
+
origin_x,
|
| 1720 |
+
origin_y,
|
| 1721 |
+
shared_motion,
|
| 1722 |
+
)
|
| 1723 |
current_x, current_y = _append_relative_move(
|
| 1724 |
gcode_list,
|
| 1725 |
current_x,
|
|
|
|
| 1747 |
color,
|
| 1748 |
)
|
| 1749 |
|
| 1750 |
+
if not contours_first:
|
| 1751 |
+
layer_end_x, layer_end_y = current_x, current_y
|
| 1752 |
+
current_x, current_y = _append_layer_contours(
|
| 1753 |
+
gcode_list,
|
| 1754 |
+
current_x,
|
| 1755 |
+
current_y,
|
| 1756 |
+
contour_layers,
|
| 1757 |
+
layer_number,
|
| 1758 |
+
active_contour_owner,
|
| 1759 |
+
origin_x,
|
| 1760 |
+
origin_y,
|
| 1761 |
+
shared_motion,
|
| 1762 |
+
)
|
| 1763 |
+
current_x, current_y = _append_relative_move(
|
| 1764 |
+
gcode_list,
|
| 1765 |
+
current_x,
|
| 1766 |
+
current_y,
|
| 1767 |
+
layer_end_x,
|
| 1768 |
+
layer_end_y,
|
| 1769 |
+
0,
|
| 1770 |
+
)
|
| 1771 |
|
| 1772 |
return gcode_list, (origin_x, origin_y)
|
| 1773 |
|