CyGuy8 Claude Fable 5 commited on
Commit
49307ad
·
1 Parent(s): 8478b27

Add Simple Shapes sample set, fix parallel circle-spiral edges, align spiral loops

Browse files

Samples & 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 CHANGED
@@ -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 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 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.
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
 
app.py CHANGED
@@ -50,7 +50,11 @@ from vector_toolpath import (
50
  )
51
 
52
 
53
- SAMPLE_STL_FILENAMES = ("Hollow_Pyramid.stl", "Rounded_Cube_Through_Holes.stl", "halfsphere.stl")
 
 
 
 
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
- paths = [str(SAMPLE_STL_DIR / filename) for filename in SAMPLE_STL_FILENAMES if (SAMPLE_STL_DIR / filename).exists()]
 
 
 
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=0.0, step=0.1)
3829
- nozzle_grid_row_spacing = gr.Number(label="Row Gap (Y, mm)", value=0.0, step=0.1)
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],
sample_stls/Simple_Circle.stl ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:74239bc674be23b3e265ab1830e7e9968f07ddb97e3771dc699b48afce9ecbee
3
+ size 14484
sample_stls/Simple_Square.stl ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:774f7323942243b6810307e44126e2e6f5175ac77e3e6559f73691af86c3209f
3
+ size 684
sample_stls/Simple_Triangle.stl ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:1d109f3f99963f82eebda45cb7144560603c790b30fe9b7c7b1d815a421b9bdd
3
+ size 484
tests/test_nozzle_spacing.py CHANGED
@@ -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
tests/test_vector_gcode.py CHANGED
@@ -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
vector_gcode.py CHANGED
@@ -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.
vector_toolpath.py CHANGED
@@ -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
- center_x = (frame[0] + frame[2]) / 2.0
1281
- center_y = (frame[1] + frame[3]) / 2.0
 
 
 
 
 
 
 
 
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
- motion.bounds,
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: ring k sits k*fil inside the
1315
- # half-fil-inset bounding box the spiral walks.
1316
- min_x, min_y, max_x, max_y = motion.bounds
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
- return (
1532
- _snap_to_grid(reference_x - center_x, grid),
1533
- _snap_to_grid(reference_y - center_y, grid),
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(