Spaces:
Running
Add Simple Shapes sample set, fix parallel circle-spiral edges, align spiral loops
Browse filesSamples & UI:
- Sample Set dropdown under Load Sample STLs: Standard Shapes (pyramid,
rounded cube, half sphere) or the new Simple Shapes (circle, square,
triangle - flat 20x20x2 test pieces, bundled via Git LFS)
- Nozzle Spacing column/row gaps default to 5 mm
Rectangular spiral:
- Loops are anchored to the shape frame's family, not each layer's own
material bounds: layers with smaller footprints (e.g. the flag's
arms-only first layer) used to spiral at their own offsets, leaving
walls visibly out of line; outer loops that cannot touch a layer's
material are skipped instead of traveled
Circle spiral under shared reference motion:
- Rings centre on the reference ALIGN centre (where every shape's centre
is placed) instead of the union bbox centre, and whole-shape centering
no longer snaps to the fil grid - shapes are exactly concentric with
the ring set
- Every shape's own wall radius joins the ONE shared ring set (all heads
travel all walls, each dispenses only its own); when a grid ring
already hugs the boundary it serves as the wall instead
- Rings grazing a shape's boundary are suppressed for that shape: no
more spotty outer ring (dimensions on the 0.8 grid) or half-circle
edge (off-grid dimensions); square-corner fill is unaffected and all
parallel heads keep identical motion
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- README.md +2 -2
- app.py +29 -5
- sample_stls/Simple_Circle.stl +3 -0
- sample_stls/Simple_Square.stl +3 -0
- sample_stls/Simple_Triangle.stl +3 -0
- tests/test_nozzle_spacing.py +21 -0
- tests/test_vector_gcode.py +143 -0
- vector_gcode.py +57 -0
- vector_toolpath.py +141 -11
|
@@ -38,7 +38,7 @@ Then open the local Gradio URL in your browser, upload STL files or load the bun
|
|
| 38 |
## What the app does
|
| 39 |
|
| 40 |
- Uploads any number of `.stl` files with a single multi-file uploader
|
| 41 |
-
- Loads bundled sample STL files and merges them with already uploaded STLs
|
| 42 |
- Syncs the uploaded STL list back into Shape Settings if the table and uploader get out of step
|
| 43 |
- Shows an interactive selected-shape 3D viewer for rotating each model
|
| 44 |
- Shows model extents, face count, vertex count, and watertight status
|
|
@@ -111,7 +111,7 @@ The **Multi-Nozzle Split** accordion on the **Shapes & Slicing** tab can split o
|
|
| 111 |
- **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.
|
| 112 |
- **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.
|
| 113 |
- 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.
|
| 114 |
-
- **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
|
| 115 |
- **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.
|
| 116 |
- **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.
|
| 117 |
|
|
|
|
| 38 |
## What the app does
|
| 39 |
|
| 40 |
- Uploads any number of `.stl` files with a single multi-file uploader
|
| 41 |
+
- Loads bundled sample STL files and merges them with already uploaded STLs — a **Sample Set** dropdown under the button picks between the standard shapes (hollow pyramid, rounded cube, half sphere) and the simple flat shapes (circle, square, triangle)
|
| 42 |
- Syncs the uploaded STL list back into Shape Settings if the table and uploader get out of step
|
| 43 |
- Shows an interactive selected-shape 3D viewer for rotating each model
|
| 44 |
- Shows model extents, face count, vertex count, and watertight status
|
|
|
|
| 111 |
- **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.
|
| 112 |
- **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.
|
| 113 |
- 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.
|
| 114 |
+
- **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.
|
| 115 |
- **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.
|
| 116 |
- **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.
|
| 117 |
|
|
@@ -50,7 +50,11 @@ from vector_toolpath import (
|
|
| 50 |
)
|
| 51 |
|
| 52 |
|
| 53 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 54 |
SAMPLE_STL_DIR = Path(__file__).resolve().parent / "sample_stls"
|
| 55 |
DEFAULT_TARGET_EXTENTS = (20.0, 20.0, 20.0)
|
| 56 |
DELETE_SHAPE_COOLDOWN_SECONDS = 1.0
|
|
@@ -2291,9 +2295,13 @@ def load_sample_shapes(
|
|
| 2291 |
files: Any,
|
| 2292 |
records: list[dict] | None,
|
| 2293 |
settings_table: Any | None = None,
|
|
|
|
| 2294 |
) -> tuple:
|
| 2295 |
records = _apply_shape_settings(records or [], settings_table)
|
| 2296 |
-
|
|
|
|
|
|
|
|
|
|
| 2297 |
merged_paths = _append_file_paths(files, paths)
|
| 2298 |
return (
|
| 2299 |
gr.update(value=merged_paths),
|
|
@@ -3389,6 +3397,15 @@ def generate_dynamic_gcode(
|
|
| 3389 |
if contour_sources:
|
| 3390 |
enabled = ", ".join(f"Shape {source.owner_idx}" for source in contour_sources)
|
| 3391 |
messages.append(f"Contour tracing enabled for {enabled}.")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3392 |
# Lead-in is driven entirely by the per-shape "Lead In" column: the
|
| 3393 |
# purge motion exists whenever any shape dispenses it (all heads must
|
| 3394 |
# share the motion), and each shape's own flag gates its valve.
|
|
@@ -3433,6 +3450,7 @@ def generate_dynamic_gcode(
|
|
| 3433 |
lead_in_lines=max(1, _coerce_int(lead_in_lines, 3)),
|
| 3434 |
lead_in_direction=lead_in_direction or LEAD_IN_DIRECTION_LEFT,
|
| 3435 |
lead_in_dispense=bool(record.get("lead_in", True)),
|
|
|
|
| 3436 |
)
|
| 3437 |
record["gcode_path"] = str(gcode_path)
|
| 3438 |
messages.append(f"Shape {record['idx']}: wrote `{gcode_path.name}`.")
|
|
@@ -3694,6 +3712,12 @@ def build_dynamic_demo() -> gr.Blocks:
|
|
| 3694 |
)
|
| 3695 |
with gr.Column(scale=0, min_width=200):
|
| 3696 |
load_samples_button = gr.Button("Load Sample STLs", variant="secondary", size="sm", elem_id="load-sample-stls-button")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3697 |
sync_uploads_button = gr.Button("Sync Uploaded STLs", variant="secondary", size="sm")
|
| 3698 |
reset_dimensions_button = gr.Button("Reset Dimensions", variant="secondary", size="sm")
|
| 3699 |
model_opacity = gr.Checkbox(label="Use 75% 3D Model Opacity", value=False)
|
|
@@ -3825,8 +3849,8 @@ def build_dynamic_demo() -> gr.Blocks:
|
|
| 3825 |
)
|
| 3826 |
nozzle_grid_columns = gr.Number(label="Grid Columns", value=2, minimum=1, step=1)
|
| 3827 |
nozzle_grid_rows = gr.Number(label="Grid Rows", value=2, minimum=1, step=1)
|
| 3828 |
-
nozzle_grid_column_spacing = gr.Number(label="Column Gap (X, mm)", value=
|
| 3829 |
-
nozzle_grid_row_spacing = gr.Number(label="Row Gap (Y, mm)", value=
|
| 3830 |
with gr.Row():
|
| 3831 |
auto_align_split_parts_button = gr.Button("Auto Align Split Parts", variant="secondary", size="sm")
|
| 3832 |
nozzle_grid_use_individual_spacing = gr.Checkbox(label="Advanced Grid Spacing", value=False)
|
|
@@ -3960,7 +3984,7 @@ def build_dynamic_demo() -> gr.Blocks:
|
|
| 3960 |
outputs=[nozzle_grid_spacing_table],
|
| 3961 |
queue=False,
|
| 3962 |
)
|
| 3963 |
-
load_samples_button.click(fn=load_sample_shapes, inputs=[stl_upload, shape_records, shape_settings], outputs=[stl_upload, *shape_sync_outputs]).then(
|
| 3964 |
fn=lambda records: _dropdown_update(records),
|
| 3965 |
inputs=[shape_records],
|
| 3966 |
outputs=[split_source],
|
|
|
|
| 50 |
)
|
| 51 |
|
| 52 |
|
| 53 |
+
SAMPLE_STL_SETS = {
|
| 54 |
+
"Standard Shapes": ("Hollow_Pyramid.stl", "Rounded_Cube_Through_Holes.stl", "halfsphere.stl"),
|
| 55 |
+
"Simple Shapes": ("Simple_Circle.stl", "Simple_Square.stl", "Simple_Triangle.stl"),
|
| 56 |
+
}
|
| 57 |
+
DEFAULT_SAMPLE_STL_SET = "Standard Shapes"
|
| 58 |
SAMPLE_STL_DIR = Path(__file__).resolve().parent / "sample_stls"
|
| 59 |
DEFAULT_TARGET_EXTENTS = (20.0, 20.0, 20.0)
|
| 60 |
DELETE_SHAPE_COOLDOWN_SECONDS = 1.0
|
|
|
|
| 2295 |
files: Any,
|
| 2296 |
records: list[dict] | None,
|
| 2297 |
settings_table: Any | None = None,
|
| 2298 |
+
sample_set: str | None = None,
|
| 2299 |
) -> tuple:
|
| 2300 |
records = _apply_shape_settings(records or [], settings_table)
|
| 2301 |
+
filenames = SAMPLE_STL_SETS.get(
|
| 2302 |
+
str(sample_set or ""), SAMPLE_STL_SETS[DEFAULT_SAMPLE_STL_SET]
|
| 2303 |
+
)
|
| 2304 |
+
paths = [str(SAMPLE_STL_DIR / filename) for filename in filenames if (SAMPLE_STL_DIR / filename).exists()]
|
| 2305 |
merged_paths = _append_file_paths(files, paths)
|
| 2306 |
return (
|
| 2307 |
gr.update(value=merged_paths),
|
|
|
|
| 3397 |
if contour_sources:
|
| 3398 |
enabled = ", ".join(f"Shape {source.owner_idx}" for source in contour_sources)
|
| 3399 |
messages.append(f"Contour tracing enabled for {enabled}.")
|
| 3400 |
+
# Circle Spiral under shared motion: every shape's own wall radius joins
|
| 3401 |
+
# the one shared ring set (same list for every shape, so motion stays in
|
| 3402 |
+
# sync). Split pieces carry a scan frame and are excluded.
|
| 3403 |
+
wall_sources = [
|
| 3404 |
+
record["layer_stack"]
|
| 3405 |
+
for record in records
|
| 3406 |
+
if record.get("layer_stack") is not None
|
| 3407 |
+
and getattr(record["layer_stack"], "scan_frame", None) is None
|
| 3408 |
+
]
|
| 3409 |
# Lead-in is driven entirely by the per-shape "Lead In" column: the
|
| 3410 |
# purge motion exists whenever any shape dispenses it (all heads must
|
| 3411 |
# share the motion), and each shape's own flag gates its valve.
|
|
|
|
| 3450 |
lead_in_lines=max(1, _coerce_int(lead_in_lines, 3)),
|
| 3451 |
lead_in_direction=lead_in_direction or LEAD_IN_DIRECTION_LEFT,
|
| 3452 |
lead_in_dispense=bool(record.get("lead_in", True)),
|
| 3453 |
+
wall_sources=wall_sources if use_reference_motion else None,
|
| 3454 |
)
|
| 3455 |
record["gcode_path"] = str(gcode_path)
|
| 3456 |
messages.append(f"Shape {record['idx']}: wrote `{gcode_path.name}`.")
|
|
|
|
| 3712 |
)
|
| 3713 |
with gr.Column(scale=0, min_width=200):
|
| 3714 |
load_samples_button = gr.Button("Load Sample STLs", variant="secondary", size="sm", elem_id="load-sample-stls-button")
|
| 3715 |
+
sample_set_selector = gr.Dropdown(
|
| 3716 |
+
choices=list(SAMPLE_STL_SETS),
|
| 3717 |
+
value=DEFAULT_SAMPLE_STL_SET,
|
| 3718 |
+
label="Sample Set",
|
| 3719 |
+
container=False,
|
| 3720 |
+
)
|
| 3721 |
sync_uploads_button = gr.Button("Sync Uploaded STLs", variant="secondary", size="sm")
|
| 3722 |
reset_dimensions_button = gr.Button("Reset Dimensions", variant="secondary", size="sm")
|
| 3723 |
model_opacity = gr.Checkbox(label="Use 75% 3D Model Opacity", value=False)
|
|
|
|
| 3849 |
)
|
| 3850 |
nozzle_grid_columns = gr.Number(label="Grid Columns", value=2, minimum=1, step=1)
|
| 3851 |
nozzle_grid_rows = gr.Number(label="Grid Rows", value=2, minimum=1, step=1)
|
| 3852 |
+
nozzle_grid_column_spacing = gr.Number(label="Column Gap (X, mm)", value=5.0, step=0.1)
|
| 3853 |
+
nozzle_grid_row_spacing = gr.Number(label="Row Gap (Y, mm)", value=5.0, step=0.1)
|
| 3854 |
with gr.Row():
|
| 3855 |
auto_align_split_parts_button = gr.Button("Auto Align Split Parts", variant="secondary", size="sm")
|
| 3856 |
nozzle_grid_use_individual_spacing = gr.Checkbox(label="Advanced Grid Spacing", value=False)
|
|
|
|
| 3984 |
outputs=[nozzle_grid_spacing_table],
|
| 3985 |
queue=False,
|
| 3986 |
)
|
| 3987 |
+
load_samples_button.click(fn=load_sample_shapes, inputs=[stl_upload, shape_records, shape_settings, sample_set_selector], outputs=[stl_upload, *shape_sync_outputs]).then(
|
| 3988 |
fn=lambda records: _dropdown_update(records),
|
| 3989 |
inputs=[shape_records],
|
| 3990 |
outputs=[split_source],
|
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:74239bc674be23b3e265ab1830e7e9968f07ddb97e3771dc699b48afce9ecbee
|
| 3 |
+
size 14484
|
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:774f7323942243b6810307e44126e2e6f5175ac77e3e6559f73691af86c3209f
|
| 3 |
+
size 684
|
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:1d109f3f99963f82eebda45cb7144560603c790b30fe9b7c7b1d815a421b9bdd
|
| 3 |
+
size 484
|
|
@@ -1093,3 +1093,24 @@ def test_apply_bulk_bool_selection_sets_a_whole_column() -> None:
|
|
| 1093 |
color_pos = SHAPE_SETTINGS_HEADERS.index("Color")
|
| 1094 |
refused, _rows4 = apply_bulk_bool_selection(cleared, rows2, f"{color_pos}|1")
|
| 1095 |
assert refused[0].get("color") == cleared[0].get("color")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1093 |
color_pos = SHAPE_SETTINGS_HEADERS.index("Color")
|
| 1094 |
refused, _rows4 = apply_bulk_bool_selection(cleared, rows2, f"{color_pos}|1")
|
| 1095 |
assert refused[0].get("color") == cleared[0].get("color")
|
| 1096 |
+
|
| 1097 |
+
|
| 1098 |
+
def test_load_sample_shapes_respects_the_selected_set() -> None:
|
| 1099 |
+
from app import DEFAULT_SAMPLE_STL_SET, SAMPLE_STL_SETS, load_sample_shapes
|
| 1100 |
+
|
| 1101 |
+
simple = load_sample_shapes(None, [], None, "Simple Shapes")
|
| 1102 |
+
assert [record["name"] for record in simple[1]] == [
|
| 1103 |
+
"Simple_Circle", "Simple_Square", "Simple_Triangle",
|
| 1104 |
+
]
|
| 1105 |
+
|
| 1106 |
+
standard = load_sample_shapes(None, [], None, "Standard Shapes")
|
| 1107 |
+
assert [record["name"] for record in standard[1]] == [
|
| 1108 |
+
"Hollow_Pyramid", "Rounded_Cube_Through_Holes", "halfsphere",
|
| 1109 |
+
]
|
| 1110 |
+
|
| 1111 |
+
# Unknown/empty selection falls back to the default set.
|
| 1112 |
+
fallback = load_sample_shapes(None, [], None, None)
|
| 1113 |
+
assert [record["name"] for record in fallback[1]] == [
|
| 1114 |
+
"Hollow_Pyramid", "Rounded_Cube_Through_Holes", "halfsphere",
|
| 1115 |
+
]
|
| 1116 |
+
assert DEFAULT_SAMPLE_STL_SET in SAMPLE_STL_SETS
|
|
@@ -1699,3 +1699,146 @@ def test_boundary_grid_line_grazing_from_outside_still_prints() -> None:
|
|
| 1699 |
)
|
| 1700 |
print_ys = sorted({y0 for _x0, y0, _x1, _y1, color in segments if color == 255})
|
| 1701 |
assert abs(print_ys[0] - (-15.2)) < 1e-6 # boundary sweep printed
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1699 |
)
|
| 1700 |
print_ys = sorted({y0 for _x0, y0, _x1, _y1, color in segments if color == 255})
|
| 1701 |
assert abs(print_ys[0] - (-15.2)) < 1e-6 # boundary sweep printed
|
| 1702 |
+
|
| 1703 |
+
|
| 1704 |
+
def test_rectangular_spiral_layers_share_one_loop_family(tmp_path) -> None:
|
| 1705 |
+
from gcode_viewer import parse_gcode_path
|
| 1706 |
+
|
| 1707 |
+
# Layers with different footprints must walk the SAME frame-anchored
|
| 1708 |
+
# rectangles — a smaller layer used to spiral at its own inset, leaving
|
| 1709 |
+
# its walls visibly out of line with the rest of the print.
|
| 1710 |
+
big = box(0.0, 0.0, 20.0, 20.0)
|
| 1711 |
+
small = box(5.0, 5.0, 15.0, 15.0)
|
| 1712 |
+
gcode_path = generate_vector_gcode(
|
| 1713 |
+
_stack(big, small),
|
| 1714 |
+
shape_name="loop_family",
|
| 1715 |
+
pressure=25,
|
| 1716 |
+
valve=7,
|
| 1717 |
+
port=3,
|
| 1718 |
+
fil_width=0.8,
|
| 1719 |
+
layer_height=1.0,
|
| 1720 |
+
raster_pattern=RASTER_PATTERN_RECTANGULAR_SPIRAL,
|
| 1721 |
+
output_dir=tmp_path,
|
| 1722 |
+
)
|
| 1723 |
+
parsed = parse_gcode_path(gcode_path.read_text())
|
| 1724 |
+
origin_x, origin_y = parsed["path_origin"]
|
| 1725 |
+
|
| 1726 |
+
# Loop side lines = x positions of VERTICAL runs (valve-split points
|
| 1727 |
+
# and the start stub sit mid-edge and are not loop lines).
|
| 1728 |
+
per_layer_xs: dict[int, set] = {}
|
| 1729 |
+
for kind in ("print_segments", "travel_segments"):
|
| 1730 |
+
for segment in parsed[kind]:
|
| 1731 |
+
for a, b in zip(segment, segment[1:]):
|
| 1732 |
+
if abs(a[0] - b[0]) < 1e-9 and abs(a[1] - b[1]) > 1e-6:
|
| 1733 |
+
per_layer_xs.setdefault(int(round(a[2])), set()).add(
|
| 1734 |
+
round(a[0] + origin_x, 3)
|
| 1735 |
+
)
|
| 1736 |
+
layer0 = per_layer_xs[0]
|
| 1737 |
+
stray = {x for x in per_layer_xs[1] if x not in layer0}
|
| 1738 |
+
assert not stray, stray
|
| 1739 |
+
|
| 1740 |
+
|
| 1741 |
+
def test_circle_spiral_interior_is_stepped_rings(tmp_path) -> None:
|
| 1742 |
+
from gcode_viewer import parse_gcode_path
|
| 1743 |
+
|
| 1744 |
+
# The fill is concentric CONSTANT-RADIUS rings (wall at the material
|
| 1745 |
+
# edge, interior rings on the global grid) stepping inward by one fil
|
| 1746 |
+
# per revolution - not a continuously decreasing spiral.
|
| 1747 |
+
disc = Point(5.0, 5.0).buffer(5.0, quad_segs=64)
|
| 1748 |
+
gcode_path = generate_vector_gcode(
|
| 1749 |
+
_stack(disc, disc),
|
| 1750 |
+
shape_name="stepped",
|
| 1751 |
+
pressure=25,
|
| 1752 |
+
valve=7,
|
| 1753 |
+
port=3,
|
| 1754 |
+
fil_width=0.8,
|
| 1755 |
+
layer_height=1.0,
|
| 1756 |
+
raster_pattern=RASTER_PATTERN_CIRCLE_SPIRAL,
|
| 1757 |
+
output_dir=tmp_path,
|
| 1758 |
+
)
|
| 1759 |
+
parsed = parse_gcode_path(gcode_path.read_text())
|
| 1760 |
+
origin_x, origin_y = parsed["path_origin"]
|
| 1761 |
+
|
| 1762 |
+
radii = sorted({
|
| 1763 |
+
round(math.hypot(x + origin_x - 5.0, y + origin_y - 5.0), 1)
|
| 1764 |
+
for seg in parsed["print_segments"]
|
| 1765 |
+
for x, y, z in seg
|
| 1766 |
+
if abs(z) < 0.5
|
| 1767 |
+
})
|
| 1768 |
+
# A handful of discrete radii: the wall (4.6) plus grid rings.
|
| 1769 |
+
assert len(radii) <= 8, radii
|
| 1770 |
+
assert abs(radii[-1] - (5.0 - 0.4)) < 0.05 # wall hugs the material edge
|
| 1771 |
+
for radius in radii[:-1]:
|
| 1772 |
+
ring = radius / 0.8 - 0.5
|
| 1773 |
+
assert abs(ring - round(ring)) < 0.15 # interior rings on the grid
|
| 1774 |
+
|
| 1775 |
+
|
| 1776 |
+
def test_parallel_circle_keeps_a_complete_outer_ring(tmp_path) -> None:
|
| 1777 |
+
from gcode_viewer import parse_gcode_path
|
| 1778 |
+
|
| 1779 |
+
# Under shared reference motion the ring set used to come from the
|
| 1780 |
+
# UNION only: the grid ring nearest a circle's boundary grazed it and
|
| 1781 |
+
# printed spotty specks (dimensions on the grid) or a half circle
|
| 1782 |
+
# (off-grid dimensions). Every shape's own wall now joins the shared
|
| 1783 |
+
# ring set and grazing rings are suppressed per shape.
|
| 1784 |
+
for diameter in (20.0, 20.5):
|
| 1785 |
+
disc_layer = Point(diameter / 2.0, diameter / 2.0).buffer(diameter / 2.0, quad_segs=64)
|
| 1786 |
+
square_layer = box(0.0, 0.0, 20.0, 20.0)
|
| 1787 |
+
disc = _stack(disc_layer, name=f"disc{int(diameter * 10)}")
|
| 1788 |
+
square = _stack(square_layer, name=f"square{int(diameter * 10)}")
|
| 1789 |
+
reference = build_reference_stack([disc, square], grid=0.8)
|
| 1790 |
+
wall_sources = [disc, square]
|
| 1791 |
+
|
| 1792 |
+
lengths = []
|
| 1793 |
+
for stack in (disc, square):
|
| 1794 |
+
gcode_path = generate_vector_gcode(
|
| 1795 |
+
stack,
|
| 1796 |
+
shape_name=stack.name + "_p",
|
| 1797 |
+
pressure=25,
|
| 1798 |
+
valve=7,
|
| 1799 |
+
port=3,
|
| 1800 |
+
fil_width=0.8,
|
| 1801 |
+
layer_height=1.0,
|
| 1802 |
+
raster_pattern=RASTER_PATTERN_CIRCLE_SPIRAL,
|
| 1803 |
+
motion=reference,
|
| 1804 |
+
wall_sources=wall_sources,
|
| 1805 |
+
output_dir=tmp_path,
|
| 1806 |
+
)
|
| 1807 |
+
parsed = parse_gcode_path(gcode_path.read_text())
|
| 1808 |
+
lengths.append(
|
| 1809 |
+
round(
|
| 1810 |
+
sum(
|
| 1811 |
+
math.dist(a[:2], b[:2])
|
| 1812 |
+
for kind in ("print_segments", "travel_segments")
|
| 1813 |
+
for seg in parsed[kind]
|
| 1814 |
+
for a, b in zip(seg, seg[1:])
|
| 1815 |
+
),
|
| 1816 |
+
6,
|
| 1817 |
+
)
|
| 1818 |
+
)
|
| 1819 |
+
if stack is not disc:
|
| 1820 |
+
continue
|
| 1821 |
+
origin_x, origin_y = parsed["path_origin"]
|
| 1822 |
+
center = diameter / 2.0
|
| 1823 |
+
per_ring: dict[float, dict[str, float]] = {}
|
| 1824 |
+
for kind, key in (("print_segments", "p"), ("travel_segments", "t")):
|
| 1825 |
+
for seg in parsed[kind]:
|
| 1826 |
+
for a, b in zip(seg, seg[1:]):
|
| 1827 |
+
radius = round(
|
| 1828 |
+
math.hypot(
|
| 1829 |
+
(a[0] + b[0]) / 2 + origin_x - center,
|
| 1830 |
+
(a[1] + b[1]) / 2 + origin_y - center,
|
| 1831 |
+
),
|
| 1832 |
+
1,
|
| 1833 |
+
)
|
| 1834 |
+
per_ring.setdefault(radius, {"p": 0.0, "t": 0.0})[key] += math.dist(a[:2], b[:2])
|
| 1835 |
+
printed = [r for r, v in per_ring.items() if v["p"] > 1.0]
|
| 1836 |
+
outer = max(printed)
|
| 1837 |
+
v = per_ring[outer]
|
| 1838 |
+
# The disc's outermost ring is COMPLETE (no spotty/half arcs).
|
| 1839 |
+
assert v["p"] / (v["p"] + v["t"]) > 0.98, (diameter, outer, v)
|
| 1840 |
+
# And it hugs the boundary (within one bead of the radius).
|
| 1841 |
+
assert diameter / 2.0 - outer < 0.8, (diameter, outer)
|
| 1842 |
+
# Parallel sync: same path length (tolerance = 6-decimal G-code
|
| 1843 |
+
# rounding; moves split at different valve boundaries per shape).
|
| 1844 |
+
assert abs(lengths[0] - lengths[1]) < 1e-3, lengths
|
|
@@ -30,6 +30,7 @@ from vector_toolpath import (
|
|
| 30 |
_normalize_raster_pattern,
|
| 31 |
align_stack_to,
|
| 32 |
build_contour_layers,
|
|
|
|
| 33 |
plan_layer_moves,
|
| 34 |
)
|
| 35 |
|
|
@@ -211,6 +212,7 @@ def generate_vector_gcode(
|
|
| 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.
|
|
@@ -220,6 +222,11 @@ def generate_vector_gcode(
|
|
| 220 |
follows the shared reference path while the valve opens only inside this
|
| 221 |
shape's own geometry, aligned into the reference frame — so parallel
|
| 222 |
heads share one motion but each dispenses only its own shape.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 223 |
"""
|
| 224 |
if shape is None or not shape.layers:
|
| 225 |
raise ValueError("The shape has no sliced layers to generate G-code from.")
|
|
@@ -258,6 +265,53 @@ def generate_vector_gcode(
|
|
| 258 |
(frame_x_min, frame_y_min, _fz), (frame_x_max, frame_y_max, _fz2) = frame_stack.bounds
|
| 259 |
scan_frame = (frame_x_min, frame_y_min, frame_x_max, frame_y_max)
|
| 260 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 261 |
gcode_list, toolpath_origin = plan_layer_moves(
|
| 262 |
motion_layers,
|
| 263 |
valve_layers,
|
|
@@ -269,6 +323,9 @@ def generate_vector_gcode(
|
|
| 269 |
shared_motion=motion is not None,
|
| 270 |
scan_frame=scan_frame,
|
| 271 |
infill_fraction=max(0.0, min(1.0, float(infill))),
|
|
|
|
|
|
|
|
|
|
| 272 |
)
|
| 273 |
|
| 274 |
# World anchor: the toolpath origin expressed in the shape's own frame.
|
|
|
|
| 30 |
_normalize_raster_pattern,
|
| 31 |
align_stack_to,
|
| 32 |
build_contour_layers,
|
| 33 |
+
circle_wall_radius,
|
| 34 |
plan_layer_moves,
|
| 35 |
)
|
| 36 |
|
|
|
|
| 212 |
lead_in_lines: int = 3,
|
| 213 |
lead_in_direction: str = LEAD_IN_DIRECTION_LEFT,
|
| 214 |
lead_in_dispense: bool = True,
|
| 215 |
+
wall_sources: list[LayerStack] | None = None,
|
| 216 |
output_dir: str | Path | None = None,
|
| 217 |
) -> Path:
|
| 218 |
"""Generate G-code for one sliced shape.
|
|
|
|
| 222 |
follows the shared reference path while the valve opens only inside this
|
| 223 |
shape's own geometry, aligned into the reference frame — so parallel
|
| 224 |
heads share one motion but each dispenses only its own shape.
|
| 225 |
+
|
| 226 |
+
`wall_sources` (all shapes in the job, whole shapes only) matters for
|
| 227 |
+
the Circle Spiral under shared motion: every shape's own wall radius
|
| 228 |
+
joins the ONE shared ring set, so each shape keeps a smooth complete
|
| 229 |
+
outer circle. Pass the SAME list to every shape's generation call.
|
| 230 |
"""
|
| 231 |
if shape is None or not shape.layers:
|
| 232 |
raise ValueError("The shape has no sliced layers to generate G-code from.")
|
|
|
|
| 265 |
(frame_x_min, frame_y_min, _fz), (frame_x_max, frame_y_max, _fz2) = frame_stack.bounds
|
| 266 |
scan_frame = (frame_x_min, frame_y_min, frame_x_max, frame_y_max)
|
| 267 |
|
| 268 |
+
# Circle Spiral under shared motion (whole shapes): rings centre on the
|
| 269 |
+
# reference ALIGN centre (each shape is concentric with it), every
|
| 270 |
+
# shape's own wall radius joins the shared ring set, and each shape's
|
| 271 |
+
# dispensing is capped just outside its own wall so grid rings grazing
|
| 272 |
+
# its boundary stop printing spotty specks / half arcs.
|
| 273 |
+
extra_wall_radii = None
|
| 274 |
+
valve_ring_caps = None
|
| 275 |
+
ring_center = None
|
| 276 |
+
if (
|
| 277 |
+
raster_pattern == RASTER_PATTERN_CIRCLE_SPIRAL
|
| 278 |
+
and motion is not None
|
| 279 |
+
and motion.scan_frame is None
|
| 280 |
+
and shape.scan_frame is None
|
| 281 |
+
):
|
| 282 |
+
if motion.align_center is not None:
|
| 283 |
+
ring_center = motion.align_center
|
| 284 |
+
else:
|
| 285 |
+
ring_center = (
|
| 286 |
+
(scan_frame[0] + scan_frame[2]) / 2.0,
|
| 287 |
+
(scan_frame[1] + scan_frame[3]) / 2.0,
|
| 288 |
+
)
|
| 289 |
+
n_layers = len(motion_layers)
|
| 290 |
+
valve_ring_caps = [
|
| 291 |
+
circle_wall_radius(valve_layers[index], ring_center[0], ring_center[1], fil_width)
|
| 292 |
+
for index in range(n_layers)
|
| 293 |
+
]
|
| 294 |
+
extra_wall_radii = [[] for _ in range(n_layers)]
|
| 295 |
+
sources = [
|
| 296 |
+
source
|
| 297 |
+
for source in (wall_sources or [])
|
| 298 |
+
if source is not None and source.layers and source.scan_frame is None
|
| 299 |
+
]
|
| 300 |
+
if sources:
|
| 301 |
+
for source in sources:
|
| 302 |
+
aligned = align_stack_to(source, motion, n_layers)
|
| 303 |
+
for index in range(n_layers):
|
| 304 |
+
wall = circle_wall_radius(
|
| 305 |
+
aligned[index], ring_center[0], ring_center[1], fil_width
|
| 306 |
+
)
|
| 307 |
+
if wall is not None:
|
| 308 |
+
extra_wall_radii[index].append(wall)
|
| 309 |
+
else:
|
| 310 |
+
# No source list: at least this shape's own wall.
|
| 311 |
+
for index, cap in enumerate(valve_ring_caps):
|
| 312 |
+
if cap is not None:
|
| 313 |
+
extra_wall_radii[index].append(cap)
|
| 314 |
+
|
| 315 |
gcode_list, toolpath_origin = plan_layer_moves(
|
| 316 |
motion_layers,
|
| 317 |
valve_layers,
|
|
|
|
| 323 |
shared_motion=motion is not None,
|
| 324 |
scan_frame=scan_frame,
|
| 325 |
infill_fraction=max(0.0, min(1.0, float(infill))),
|
| 326 |
+
extra_wall_radii=extra_wall_radii,
|
| 327 |
+
valve_ring_caps=valve_ring_caps,
|
| 328 |
+
ring_center=ring_center,
|
| 329 |
)
|
| 330 |
|
| 331 |
# World anchor: the toolpath origin expressed in the shape's own frame.
|
|
@@ -685,6 +685,76 @@ def _rectangular_spiral_polyline(
|
|
| 685 |
return _extend_polyline_ends(points, half)
|
| 686 |
|
| 687 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 688 |
def _circle_ring_radii(
|
| 689 |
motion: MultiPolygon,
|
| 690 |
center_x: float,
|
|
@@ -1237,6 +1307,9 @@ def plan_layer_moves(
|
|
| 1237 |
shared_motion: bool = False,
|
| 1238 |
scan_frame: tuple[float, float, float, float] | None = None,
|
| 1239 |
infill_fraction: float = 1.0,
|
|
|
|
|
|
|
|
|
|
| 1240 |
) -> tuple[list[dict], tuple[float, float]]:
|
| 1241 |
"""Assemble per-layer segments into a relative move list for all patterns.
|
| 1242 |
|
|
@@ -1277,9 +1350,45 @@ def plan_layer_moves(
|
|
| 1277 |
# and rings that never touch this layer's material are skipped
|
| 1278 |
# instead of swept as full travel circles.
|
| 1279 |
frame = scan_frame if scan_frame is not None else motion.bounds
|
| 1280 |
-
|
| 1281 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1282 |
radii, wall_radii = _circle_ring_radii(motion, center_x, center_y, fil_width)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1283 |
points = _circle_rings_polyline(center_x, center_y, radii, fil_width)
|
| 1284 |
if layer_number % 2 == 1:
|
| 1285 |
points.reverse()
|
|
@@ -1294,9 +1403,20 @@ def plan_layer_moves(
|
|
| 1294 |
radius_1 = math.hypot(x1 - center_x, y1 - center_y)
|
| 1295 |
if abs(radius_1 - radius_0) > fil_width * 0.25:
|
| 1296 |
return False # radial jump between rings: travel only
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1297 |
if infill_keep is None:
|
| 1298 |
return True
|
| 1299 |
-
radius_mid = (radius_0 + radius_1) / 2.0
|
| 1300 |
if any(abs(radius_mid - wall) <= fil_width * 0.25 for wall in wall_radii):
|
| 1301 |
return True # perimeter walls always print, like contours
|
| 1302 |
ring = round(radius_mid / fil_width - 0.5)
|
|
@@ -1304,16 +1424,24 @@ def plan_layer_moves(
|
|
| 1304 |
|
| 1305 |
segments = _classify_polyline(points, valve, keep_segment=keep_segment)
|
| 1306 |
elif raster_pattern == RASTER_PATTERN_RECTANGULAR_SPIRAL:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1307 |
points = _rectangular_spiral_polyline(
|
| 1308 |
-
|
| 1309 |
fil_width,
|
| 1310 |
reverse=layer_number % 2 == 1,
|
| 1311 |
)
|
| 1312 |
keep_point = None
|
| 1313 |
if infill_keep is not None:
|
| 1314 |
-
# A spiral "line" is one ring
|
| 1315 |
-
#
|
| 1316 |
-
min_x, min_y, max_x, max_y =
|
| 1317 |
half = fil_width / 2.0
|
| 1318 |
left, right = min_x + half, max_x - half
|
| 1319 |
bottom, top = min_y + half, max_y - half
|
|
@@ -1528,10 +1656,12 @@ def _centering_delta(stack: LayerStack, reference: LayerStack) -> tuple[float, f
|
|
| 1528 |
else:
|
| 1529 |
reference_x, reference_y = _stack_center(reference)
|
| 1530 |
center_x, center_y = _alignment_center(stack)
|
| 1531 |
-
|
| 1532 |
-
|
| 1533 |
-
|
| 1534 |
-
|
|
|
|
|
|
|
| 1535 |
|
| 1536 |
|
| 1537 |
def align_stack_to(
|
|
|
|
| 685 |
return _extend_polyline_ends(points, half)
|
| 686 |
|
| 687 |
|
| 688 |
+
def circle_wall_radius(
|
| 689 |
+
layer: MultiPolygon | None,
|
| 690 |
+
center_x: float,
|
| 691 |
+
center_y: float,
|
| 692 |
+
fil_width: float,
|
| 693 |
+
) -> float | None:
|
| 694 |
+
"""A layer's outermost COMPLETE ring radius about the ring centre.
|
| 695 |
+
|
| 696 |
+
Uses the minimum OUTER-boundary distance (holes ignored; the material
|
| 697 |
+
may sit slightly off the ring centre). When a global grid ring already
|
| 698 |
+
lies close under the boundary it IS the outer ring (no extra wall — a
|
| 699 |
+
custom wall a fraction of a bead away would just double-deposit);
|
| 700 |
+
otherwise the wall sits half a bead inside the closest boundary point.
|
| 701 |
+
Only meaningful when the material surrounds the centre.
|
| 702 |
+
"""
|
| 703 |
+
if layer is None or layer.is_empty:
|
| 704 |
+
return None
|
| 705 |
+
center = Point(center_x, center_y)
|
| 706 |
+
for polygon in layer.geoms:
|
| 707 |
+
if not polygon.covers(center):
|
| 708 |
+
continue
|
| 709 |
+
d_min = float(polygon.exterior.distance(center))
|
| 710 |
+
grid_j = int(math.floor(d_min / fil_width - 0.5 + 1e-9))
|
| 711 |
+
if grid_j >= 0:
|
| 712 |
+
grid_ring = (grid_j + 0.5) * fil_width
|
| 713 |
+
if d_min - grid_ring <= fil_width * 0.75:
|
| 714 |
+
return grid_ring
|
| 715 |
+
wall = d_min - fil_width / 2.0
|
| 716 |
+
return wall if wall > fil_width * 0.25 else None
|
| 717 |
+
return None
|
| 718 |
+
|
| 719 |
+
|
| 720 |
+
def _frame_spiral_bounds(
|
| 721 |
+
frame: tuple[float, float, float, float],
|
| 722 |
+
material_bounds: tuple[float, float, float, float],
|
| 723 |
+
fil_width: float,
|
| 724 |
+
) -> tuple[float, float, float, float]:
|
| 725 |
+
"""Outer bounds for a rectangular spiral, on the FRAME's loop family.
|
| 726 |
+
|
| 727 |
+
The spiral's loops live on the family "frame inset by half + k*fil per
|
| 728 |
+
side" so every layer (and every split sibling) walks the same
|
| 729 |
+
rectangles and walls stack. Loops that enclose this layer's material
|
| 730 |
+
with more than half a bead of margin on EVERY side are pure travel:
|
| 731 |
+
skip them by starting k0 loops in. Returns the frame shrunk by k0*fil
|
| 732 |
+
per side (still on the family).
|
| 733 |
+
"""
|
| 734 |
+
frame_left, frame_bottom, frame_right, frame_top = frame
|
| 735 |
+
mat_left, mat_bottom, mat_right, mat_top = material_bounds
|
| 736 |
+
half = fil_width / 2.0
|
| 737 |
+
# Margin between the base loop (frame inset by half) and the material.
|
| 738 |
+
min_margin = min(
|
| 739 |
+
mat_left - (frame_left + half),
|
| 740 |
+
mat_bottom - (frame_bottom + half),
|
| 741 |
+
(frame_right - half) - mat_right,
|
| 742 |
+
(frame_top - half) - mat_top,
|
| 743 |
+
)
|
| 744 |
+
skip = max(0, int(math.ceil((min_margin - half) / fil_width - 1e-9)))
|
| 745 |
+
# Never shrink past the material's own footprint.
|
| 746 |
+
max_skip_x = (frame_right - frame_left - (mat_right - mat_left)) / (2.0 * fil_width)
|
| 747 |
+
max_skip_y = (frame_top - frame_bottom - (mat_top - mat_bottom)) / (2.0 * fil_width)
|
| 748 |
+
skip = min(skip, max(0, int(min(max_skip_x, max_skip_y))))
|
| 749 |
+
inset = skip * fil_width
|
| 750 |
+
return (
|
| 751 |
+
frame_left + inset,
|
| 752 |
+
frame_bottom + inset,
|
| 753 |
+
frame_right - inset,
|
| 754 |
+
frame_top - inset,
|
| 755 |
+
)
|
| 756 |
+
|
| 757 |
+
|
| 758 |
def _circle_ring_radii(
|
| 759 |
motion: MultiPolygon,
|
| 760 |
center_x: float,
|
|
|
|
| 1307 |
shared_motion: bool = False,
|
| 1308 |
scan_frame: tuple[float, float, float, float] | None = None,
|
| 1309 |
infill_fraction: float = 1.0,
|
| 1310 |
+
extra_wall_radii: list[list[float]] | None = None,
|
| 1311 |
+
valve_ring_caps: list[float | None] | None = None,
|
| 1312 |
+
ring_center: tuple[float, float] | None = None,
|
| 1313 |
) -> tuple[list[dict], tuple[float, float]]:
|
| 1314 |
"""Assemble per-layer segments into a relative move list for all patterns.
|
| 1315 |
|
|
|
|
| 1350 |
# and rings that never touch this layer's material are skipped
|
| 1351 |
# instead of swept as full travel circles.
|
| 1352 |
frame = scan_frame if scan_frame is not None else motion.bounds
|
| 1353 |
+
if ring_center is not None:
|
| 1354 |
+
# Shared-motion whole shapes: rings centred where every
|
| 1355 |
+
# shape's centre was ALIGNED to (the reference align
|
| 1356 |
+
# centre), so each shape is concentric with the ring set —
|
| 1357 |
+
# a bbox-union centre can sit off it when shapes differ in
|
| 1358 |
+
# size, making rings graze boundaries.
|
| 1359 |
+
center_x, center_y = ring_center
|
| 1360 |
+
else:
|
| 1361 |
+
center_x = (frame[0] + frame[2]) / 2.0
|
| 1362 |
+
center_y = (frame[1] + frame[3]) / 2.0
|
| 1363 |
radii, wall_radii = _circle_ring_radii(motion, center_x, center_y, fil_width)
|
| 1364 |
+
|
| 1365 |
+
# Shared-motion parallel printing: every shape's own wall radius
|
| 1366 |
+
# joins the ONE ring set (all heads travel all walls; each
|
| 1367 |
+
# dispenses only on its own), so every shape keeps a smooth,
|
| 1368 |
+
# complete outer circle instead of whatever grid ring happens to
|
| 1369 |
+
# graze its boundary.
|
| 1370 |
+
extra_walls = (
|
| 1371 |
+
extra_wall_radii[layer_number]
|
| 1372 |
+
if extra_wall_radii is not None and layer_number < len(extra_wall_radii)
|
| 1373 |
+
else []
|
| 1374 |
+
)
|
| 1375 |
+
if extra_walls:
|
| 1376 |
+
merged = sorted(set(radii) | set(extra_walls), reverse=True)
|
| 1377 |
+
radii = []
|
| 1378 |
+
for radius in merged:
|
| 1379 |
+
if radius <= EPS:
|
| 1380 |
+
continue
|
| 1381 |
+
if radii and radii[-1] - radius < fil_width * 0.05:
|
| 1382 |
+
continue # near-duplicate wall/ring
|
| 1383 |
+
radii.append(radius)
|
| 1384 |
+
wall_radii = tuple(sorted(set(wall_radii) | set(extra_walls), reverse=True))
|
| 1385 |
+
|
| 1386 |
+
valve_cap = (
|
| 1387 |
+
valve_ring_caps[layer_number]
|
| 1388 |
+
if valve_ring_caps is not None and layer_number < len(valve_ring_caps)
|
| 1389 |
+
else None
|
| 1390 |
+
)
|
| 1391 |
+
|
| 1392 |
points = _circle_rings_polyline(center_x, center_y, radii, fil_width)
|
| 1393 |
if layer_number % 2 == 1:
|
| 1394 |
points.reverse()
|
|
|
|
| 1403 |
radius_1 = math.hypot(x1 - center_x, y1 - center_y)
|
| 1404 |
if abs(radius_1 - radius_0) > fil_width * 0.25:
|
| 1405 |
return False # radial jump between rings: travel only
|
| 1406 |
+
radius_mid = (radius_0 + radius_1) / 2.0
|
| 1407 |
+
if (
|
| 1408 |
+
valve_cap is not None
|
| 1409 |
+
and valve_cap + fil_width * 0.25
|
| 1410 |
+
< radius_mid
|
| 1411 |
+
<= valve_cap + fil_width
|
| 1412 |
+
):
|
| 1413 |
+
# The thin band just outside this shape's own wall:
|
| 1414 |
+
# rings there graze its boundary and would print spotty
|
| 1415 |
+
# specks/half arcs. Rings further out are genuine
|
| 1416 |
+
# crossings (e.g. a square's corners) and stay.
|
| 1417 |
+
return False
|
| 1418 |
if infill_keep is None:
|
| 1419 |
return True
|
|
|
|
| 1420 |
if any(abs(radius_mid - wall) <= fil_width * 0.25 for wall in wall_radii):
|
| 1421 |
return True # perimeter walls always print, like contours
|
| 1422 |
ring = round(radius_mid / fil_width - 0.5)
|
|
|
|
| 1424 |
|
| 1425 |
segments = _classify_polyline(points, valve, keep_segment=keep_segment)
|
| 1426 |
elif raster_pattern == RASTER_PATTERN_RECTANGULAR_SPIRAL:
|
| 1427 |
+
# Anchor the loop family to the FRAME (constant across layers,
|
| 1428 |
+
# shapes, and split pieces), not each layer's own material
|
| 1429 |
+
# bounds: layers with smaller footprints would otherwise spiral
|
| 1430 |
+
# at their own offsets and the walls would not stack. Outer
|
| 1431 |
+
# loops that enclose this layer's material with more than half a
|
| 1432 |
+
# bead to spare are skipped instead of traveled.
|
| 1433 |
+
frame = scan_frame if scan_frame is not None else motion.bounds
|
| 1434 |
+
spiral_bounds = _frame_spiral_bounds(frame, motion.bounds, fil_width)
|
| 1435 |
points = _rectangular_spiral_polyline(
|
| 1436 |
+
spiral_bounds,
|
| 1437 |
fil_width,
|
| 1438 |
reverse=layer_number % 2 == 1,
|
| 1439 |
)
|
| 1440 |
keep_point = None
|
| 1441 |
if infill_keep is not None:
|
| 1442 |
+
# A spiral "line" is one ring; index rings from the FRAME's
|
| 1443 |
+
# base loop so the selection lines up across layers.
|
| 1444 |
+
min_x, min_y, max_x, max_y = frame
|
| 1445 |
half = fil_width / 2.0
|
| 1446 |
left, right = min_x + half, max_x - half
|
| 1447 |
bottom, top = min_y + half, max_y - half
|
|
|
|
| 1656 |
else:
|
| 1657 |
reference_x, reference_y = _stack_center(reference)
|
| 1658 |
center_x, center_y = _alignment_center(stack)
|
| 1659 |
+
# No grid snap here: whole shapes centre EXACTLY on the align centre
|
| 1660 |
+
# (circle-spiral rings are centred there, and a snapped residue of up
|
| 1661 |
+
# to half a fil would make rings graze the shape's boundary). The snap
|
| 1662 |
+
# only ever mattered for split pieces, which take the scan-frame corner
|
| 1663 |
+
# rule above.
|
| 1664 |
+
return (reference_x - center_x, reference_y - center_y)
|
| 1665 |
|
| 1666 |
|
| 1667 |
def align_stack_to(
|