CyGuy8 Claude Fable 5 commited on
Commit
67c2359
·
1 Parent(s): 84c7b5c

Drop raster lines no head prints from the shared motion; {preset} markers

Browse files

Infill motion optimization:
- Lines that NO shape dispenses on are dropped from the motion instead
of swept valve-off: each grid line survives if ANY shape's infill
pattern prints it, and every shape receives the same combined
fraction list, so the shared motion stays identical across heads.
All shapes at 50% halve the path (measured 49.6%); mixed 50/75/50
drops only lines all three skip (72.7%); any shape at 100% keeps
every line. Solo shapes bound their own motion the same way
- Circle spiral: rings nobody prints drop out; perimeter walls always
stay (they always dispense). Rectangular spiral keeps its full
continuous walk - removing an interior loop would break the spiral
into disconnected rectangles
- Engine default is safe: shared-motion callers that do not pass
motion_infill_fractions get unrestricted motion, so heads can never
desynchronize by accident (the app always passes the list)

Aerotech host compatibility:
- The two pressure-setup header lines now carry the {preset} marker
({preset}serialPort3.write(eval(setpress(25)))), matching the lab's
newer TCode host format: presets execute at the controller's START
signal instead of with the initial valve toggles. Files without the
marker keep working; our parser ignores it
- _format_duration extracted from the viz time estimate (refactor)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

Files changed (5) hide show
  1. README.md +1 -1
  2. app.py +22 -7
  3. tests/test_vector_gcode.py +87 -19
  4. vector_gcode.py +15 -2
  5. vector_toolpath.py +70 -7
README.md CHANGED
@@ -54,7 +54,7 @@ Then open the local Gradio URL in your browser, upload STL files or load the bun
54
  - Splits one sliced shape's geometry into an editable row/column grid for multi-nozzle printing of one large shape
55
  - Converts sliced layers into G-code files with pressure, valve, nozzle, port, and infill % settings per shape from the Shape Settings table
56
  - **Pressure is a port property** (one pressure regulator per serial port): shapes sharing a Port always share one pressure — editing one shape's pressure updates every shape on that port, moving a shape onto a port adopts that port's pressure, and newly added shapes join at their port's existing pressure
57
- - **Infill %** per shape skips dispensing on evenly-distributed raster lines (rings/revolutions for spiral patterns) — at 50% every other line prints while the motion path stays exactly the same, so parallel shapes with different infill still share one print path
58
  - Appends a shape outline contour after each enabled shape layer by tracing that layer's polygon boundary
59
  - 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)
60
  - 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
 
54
  - Splits one sliced shape's geometry into an editable row/column grid for multi-nozzle printing of one large shape
55
  - Converts sliced layers into G-code files with pressure, valve, nozzle, port, and infill % settings per shape from the Shape Settings table
56
  - **Pressure is a port property** (one pressure regulator per serial port): shapes sharing a Port always share one pressure — editing one shape's pressure updates every shape on that port, moving a shape onto a port adopts that port's pressure, and newly added shapes join at their port's existing pressure
57
+ - **Infill %** per shape skips dispensing on evenly-distributed raster lines (rings for the circle spiral) — at 50% every other line prints. Parallel shapes with different infill still share one print path, and lines that **no** shape dispenses on are dropped from the motion entirely instead of being swept valve-off (every shape at 50% roughly halves the print path; with mixed infills a line survives if any shape prints it). The rectangular spiral keeps its full continuous walk; circle-spiral perimeter walls always stay
58
  - Appends a shape outline contour after each enabled shape layer by tracing that layer's polygon boundary
59
  - 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)
60
  - 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
app.py CHANGED
@@ -3941,6 +3941,16 @@ def generate_dynamic_gcode(
3941
  if record.get("layer_stack") is not None
3942
  and getattr(record["layer_stack"], "scan_frame", None) is None
3943
  ]
 
 
 
 
 
 
 
 
 
 
3944
  # Lead-in is driven entirely by the per-shape "Lead In" column: the
3945
  # purge motion exists whenever any shape dispenses it (all heads must
3946
  # share the motion), and each shape's own flag gates its valve.
@@ -3982,6 +3992,7 @@ def generate_dynamic_gcode(
3982
  contour_sources=contour_sources,
3983
  active_contour_owner=int(record.get("idx", 0)),
3984
  infill=_coerce_float(record.get("infill", 100.0), 100.0) / 100.0,
 
3985
  lead_in_enabled=bool(lead_in_enabled),
3986
  lead_in_length=float(lead_in_length),
3987
  lead_in_clearance=effective_lead_in_clearance,
@@ -4060,6 +4071,16 @@ def _parsed_path_length(parsed: dict) -> float:
4060
  return total
4061
 
4062
 
 
 
 
 
 
 
 
 
 
 
4063
  def _print_time_estimate(length_mm: float, nozzle_speed: Any) -> str | None:
4064
  """Human-readable print duration at a constant nozzle speed, or None.
4065
 
@@ -4068,13 +4089,7 @@ def _print_time_estimate(length_mm: float, nozzle_speed: Any) -> str | None:
4068
  speed = _coerce_float(nozzle_speed, 0.0)
4069
  if speed <= 0.0 or length_mm <= 0.0:
4070
  return None
4071
- hours, remainder = divmod(int(round(length_mm / speed)), 3600)
4072
- minutes, seconds = divmod(remainder, 60)
4073
- if hours:
4074
- return f"{hours} h {minutes:02d} min"
4075
- if minutes:
4076
- return f"{minutes} min {seconds:02d} s"
4077
- return f"{seconds} s"
4078
 
4079
 
4080
  def render_dynamic_nozzle_spacing(
 
3941
  if record.get("layer_stack") is not None
3942
  and getattr(record["layer_stack"], "scan_frame", None) is None
3943
  ]
3944
+ # Infill motion optimization: raster lines/rings that NO head dispenses
3945
+ # on are dropped from the shared motion (e.g. every shape at 50% halves
3946
+ # the path). Same list for every shape, so the shared motion stays in
3947
+ # sync across heads.
3948
+ motion_infill_fractions = [
3949
+ _coerce_float(record.get("infill", 100.0), 100.0) / 100.0
3950
+ for record in records
3951
+ if record.get("layer_stack") is not None
3952
+ and getattr(record["layer_stack"], "layers", None)
3953
+ ]
3954
  # Lead-in is driven entirely by the per-shape "Lead In" column: the
3955
  # purge motion exists whenever any shape dispenses it (all heads must
3956
  # share the motion), and each shape's own flag gates its valve.
 
3992
  contour_sources=contour_sources,
3993
  active_contour_owner=int(record.get("idx", 0)),
3994
  infill=_coerce_float(record.get("infill", 100.0), 100.0) / 100.0,
3995
+ motion_infill_fractions=motion_infill_fractions,
3996
  lead_in_enabled=bool(lead_in_enabled),
3997
  lead_in_length=float(lead_in_length),
3998
  lead_in_clearance=effective_lead_in_clearance,
 
4071
  return total
4072
 
4073
 
4074
+ def _format_duration(total_seconds: float) -> str:
4075
+ hours, remainder = divmod(int(round(total_seconds)), 3600)
4076
+ minutes, seconds = divmod(remainder, 60)
4077
+ if hours:
4078
+ return f"{hours} h {minutes:02d} min"
4079
+ if minutes:
4080
+ return f"{minutes} min {seconds:02d} s"
4081
+ return f"{seconds} s"
4082
+
4083
+
4084
  def _print_time_estimate(length_mm: float, nozzle_speed: Any) -> str | None:
4085
  """Human-readable print duration at a constant nozzle speed, or None.
4086
 
 
4089
  speed = _coerce_float(nozzle_speed, 0.0)
4090
  if speed <= 0.0 or length_mm <= 0.0:
4091
  return None
4092
+ return _format_duration(length_mm / speed)
 
 
 
 
 
 
4093
 
4094
 
4095
  def render_dynamic_nozzle_spacing(
tests/test_vector_gcode.py CHANGED
@@ -214,8 +214,9 @@ def test_gcode_header_writes_presets_before_initial_aux_commands(tmp_path) -> No
214
  # (the toolpath's world anchor is returned via origin_sink instead).
215
  assert not any("PathOrigin" in line for line in lines)
216
  assert lines[1] == "{aux_command}WAGO_ValveCommands(7, 0)"
217
- assert lines[2] == "serialPort3.write(eval(setpress(25)))"
218
- assert lines[3] == "serialPort3.write(eval(togglepress()))"
 
219
  assert lines[4].startswith("{aux_command}WAGO_ValveCommands(")
220
  assert lines[5].startswith("{aux_command}WAGO_ValveCommands(")
221
 
@@ -863,7 +864,7 @@ def _print_length(moves: list[dict]) -> float:
863
  )
864
 
865
 
866
- def test_half_infill_skips_alternate_lines_but_keeps_the_same_path(tmp_path) -> None:
867
  layer = box(0.0, 0.0, 4.0, 4.0)
868
  stack = _stack(layer, layer)
869
 
@@ -884,17 +885,18 @@ def test_half_infill_skips_alternate_lines_but_keeps_the_same_path(tmp_path) ->
884
  full = _generate(1.0, "full")
885
  half = _generate(0.5, "half")
886
 
887
- # Identical motion: same final position and same total traversed length.
888
- assert full[-1]["end"] == half[-1]["end"]
889
- assert abs(_total_length(full) - _total_length(half)) < 1e-6
890
 
891
- # Half the lines dispense: 2 of the 4 sweeps per layer print.
 
892
  assert abs(_print_length(half) - _print_length(full) / 2) < 1e-6
893
-
894
- # The printing sweeps sit on alternating scanlines (one fil apart x2).
895
  half_print_rows = sorted({round(m["start"][1], 6) for m in half if m["color"] == 255 and m["start"][2] == 0.0})
896
  assert len(half_print_rows) == 2
897
  assert abs((half_print_rows[1] - half_print_rows[0]) - 2.0) < 1e-9
 
898
 
899
 
900
  def test_infill_selection_is_shared_across_reference_motion(tmp_path) -> None:
@@ -919,12 +921,66 @@ def test_infill_selection_is_shared_across_reference_motion(tmp_path) -> None:
919
  sparse = _generate(small, 0.5, "sparse")
920
  dense = _generate(big, 1.0, "dense")
921
 
922
- # Different infill per shape, one shared motion path.
 
923
  assert sparse[-1]["end"] == dense[-1]["end"]
924
  assert abs(_total_length(sparse) - _total_length(dense)) < 1e-6
925
  assert 0 < _print_length(sparse) < _print_length(dense)
926
 
927
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
928
  def test_spiral_infill_skips_rings_and_keeps_the_path(tmp_path) -> None:
929
  layer = box(0.0, 0.0, 6.0, 6.0)
930
  stack = _stack(layer)
@@ -943,15 +999,27 @@ def test_spiral_infill_skips_rings_and_keeps_the_path(tmp_path) -> None:
943
  )
944
  return _moves_with_colors(path.read_text())
945
 
946
- for pattern in (RASTER_PATTERN_RECTANGULAR_SPIRAL, RASTER_PATTERN_CIRCLE_SPIRAL):
947
- full = _generate(pattern, 1.0, f"{pattern}-full".replace(" ", "_"))
948
- half = _generate(pattern, 0.5, f"{pattern}-half".replace(" ", "_"))
949
- # Identical path within the writer's micron-level delta rounding
950
- # (segment split points differ, so the rounding accumulates
951
- # differently by up to ~1 um over tens of thousands of moves).
952
- assert math.dist(full[-1]["end"], half[-1]["end"]) < 1e-4, pattern
953
- assert abs(_total_length(full) - _total_length(half)) < 1e-3, pattern
954
- assert 0 < _print_length(half) < _print_length(full), pattern
 
 
 
 
 
 
 
 
 
 
 
 
955
 
956
 
957
  def test_layer_contour_loops_follow_polygon_rings() -> None:
 
214
  # (the toolpath's world anchor is returned via origin_sink instead).
215
  assert not any("PathOrigin" in line for line in lines)
216
  assert lines[1] == "{aux_command}WAGO_ValveCommands(7, 0)"
217
+ # {preset} marks pressure setup for the Aerotech host runtime.
218
+ assert lines[2] == "{preset}serialPort3.write(eval(setpress(25)))"
219
+ assert lines[3] == "{preset}serialPort3.write(eval(togglepress()))"
220
  assert lines[4].startswith("{aux_command}WAGO_ValveCommands(")
221
  assert lines[5].startswith("{aux_command}WAGO_ValveCommands(")
222
 
 
864
  )
865
 
866
 
867
+ def test_half_infill_skips_alternate_lines_and_their_motion(tmp_path) -> None:
868
  layer = box(0.0, 0.0, 4.0, 4.0)
869
  stack = _stack(layer, layer)
870
 
 
885
  full = _generate(1.0, "full")
886
  half = _generate(0.5, "half")
887
 
888
+ # A solo shape's motion skips the lines it never prints: the path
889
+ # shrinks instead of sweeping dead lines valve-off.
890
+ assert _total_length(half) < _total_length(full)
891
 
892
+ # Half the lines dispense: 2 of the 4 sweeps per layer print, and the
893
+ # sweeps only visit those 2 scanlines (one fil apart x2).
894
  assert abs(_print_length(half) - _print_length(full) / 2) < 1e-6
895
+ half_rows = sorted({round(m["start"][1], 6) for m in half if m["start"][2] == 0.0 and m["start"][1] == m["end"][1]})
 
896
  half_print_rows = sorted({round(m["start"][1], 6) for m in half if m["color"] == 255 and m["start"][2] == 0.0})
897
  assert len(half_print_rows) == 2
898
  assert abs((half_print_rows[1] - half_print_rows[0]) - 2.0) < 1e-9
899
+ assert set(half_rows) == set(half_print_rows)
900
 
901
 
902
  def test_infill_selection_is_shared_across_reference_motion(tmp_path) -> None:
 
921
  sparse = _generate(small, 0.5, "sparse")
922
  dense = _generate(big, 1.0, "dense")
923
 
924
+ # Different infill per shape, one shared motion path (without a
925
+ # motion_infill_fractions list, shared motion is never restricted).
926
  assert sparse[-1]["end"] == dense[-1]["end"]
927
  assert abs(_total_length(sparse) - _total_length(dense)) < 1e-6
928
  assert 0 < _print_length(sparse) < _print_length(dense)
929
 
930
 
931
+ def test_shared_motion_skips_lines_no_head_prints(tmp_path) -> None:
932
+ layer = box(0.0, 0.0, 4.0, 4.0)
933
+ first = _stack(layer, name="first")
934
+ second = _stack(layer, name="second")
935
+ reference = build_reference_stack([first, second])
936
+
937
+ def _generate(stack: LayerStack, infill: float, fractions, label: str):
938
+ path = generate_vector_gcode(
939
+ stack,
940
+ shape_name=label,
941
+ pressure=25,
942
+ valve=7,
943
+ port=3,
944
+ fil_width=1.0,
945
+ motion=reference,
946
+ infill=infill,
947
+ motion_infill_fractions=fractions,
948
+ output_dir=tmp_path / label,
949
+ )
950
+ return _moves_with_colors(path.read_text())
951
+
952
+ # Both shapes at 50%: the union skips every other line, so the shared
953
+ # motion halves — and stays IDENTICAL across heads.
954
+ half_a = _generate(first, 0.5, [0.5, 0.5], "half_a")
955
+ half_b = _generate(second, 0.5, [0.5, 0.5], "half_b")
956
+ assert [(m["start"], m["end"]) for m in half_a] == [
957
+ (m["start"], m["end"]) for m in half_b
958
+ ]
959
+ full_a = _generate(first, 1.0, [1.0, 1.0], "full_a")
960
+ assert _total_length(half_a) < _total_length(full_a)
961
+ rows_half = {round(m["start"][1], 6) for m in half_a if m["start"][1] == m["end"][1]}
962
+ rows_full = {round(m["start"][1], 6) for m in full_a if m["start"][1] == m["end"][1]}
963
+ assert len(rows_half) == 2 and len(rows_full) == 4
964
+
965
+ # Mixed 50% + 75%: a line survives if EITHER pattern prints it (only
966
+ # lines both skip drop out). 50% keeps odd k, 75% skips k=0 (mod 4):
967
+ # of the 4 grid lines, only k=0 drops.
968
+ # (Move SPLIT points differ per head — each splits at its own valve
969
+ # transitions — so compare the path itself: length, end, and rows.)
970
+ mixed_a = _generate(first, 0.5, [0.5, 0.75], "mixed_a")
971
+ mixed_b = _generate(second, 0.75, [0.5, 0.75], "mixed_b")
972
+ assert abs(_total_length(mixed_a) - _total_length(mixed_b)) < 1e-6
973
+ assert mixed_a[-1]["end"] == mixed_b[-1]["end"]
974
+ rows_mixed = {round(m["start"][1], 6) for m in mixed_a if m["start"][1] == m["end"][1]}
975
+ assert rows_mixed == {round(m["start"][1], 6) for m in mixed_b if m["start"][1] == m["end"][1]}
976
+ assert len(rows_mixed) == 3
977
+
978
+ # Any head at 100% keeps every line in the motion.
979
+ dense = _generate(first, 0.5, [0.5, 1.0], "dense_pair")
980
+ rows_dense = {round(m["start"][1], 6) for m in dense if m["start"][1] == m["end"][1]}
981
+ assert len(rows_dense) == 4
982
+
983
+
984
  def test_spiral_infill_skips_rings_and_keeps_the_path(tmp_path) -> None:
985
  layer = box(0.0, 0.0, 6.0, 6.0)
986
  stack = _stack(layer)
 
999
  )
1000
  return _moves_with_colors(path.read_text())
1001
 
1002
+ # Rectangular spiral: the continuous walk is kept (a skipped loop would
1003
+ # break the spiral into disconnected rectangles), so the motion stays
1004
+ # identical within the writer's micron-level delta rounding.
1005
+ full = _generate(RASTER_PATTERN_RECTANGULAR_SPIRAL, 1.0, "rect-full")
1006
+ half = _generate(RASTER_PATTERN_RECTANGULAR_SPIRAL, 0.5, "rect-half")
1007
+ assert math.dist(full[-1]["end"], half[-1]["end"]) < 1e-4
1008
+ assert abs(_total_length(full) - _total_length(half)) < 1e-3
1009
+ assert 0 < _print_length(half) < _print_length(full)
1010
+
1011
+ # Circle spiral: rings a solo shape never prints drop out of the motion
1012
+ # (the perimeter wall always stays and always dispenses).
1013
+ full = _generate(RASTER_PATTERN_CIRCLE_SPIRAL, 1.0, "circle-full")
1014
+ half = _generate(RASTER_PATTERN_CIRCLE_SPIRAL, 0.5, "circle-half")
1015
+ assert _total_length(half) < _total_length(full)
1016
+ assert 0 < _print_length(half) < _print_length(full)
1017
+ wall_radius = max(
1018
+ math.hypot((m["start"][0] + m["end"][0]) / 2 - 3.0, (m["start"][1] + m["end"][1]) / 2 - 3.0)
1019
+ for m in half
1020
+ if m["color"] == 255
1021
+ )
1022
+ assert wall_radius > 2.0 # the outer wall is still printed
1023
 
1024
 
1025
  def test_layer_contour_loops_follow_polygon_rings() -> None:
vector_gcode.py CHANGED
@@ -75,15 +75,17 @@ def _togglepress() -> str:
75
 
76
 
77
  def _setpress_cmd(port: str, pressure: float, start: bool) -> str:
 
 
78
  if start:
79
- return f"\n\r{port}.write(eval(setpress({pressure:g})))"
80
  insert = ""
81
  return f"\n\r{insert}{port}.write({_setpress(pressure)})"
82
 
83
 
84
  def _toggle_cmd(port: str, start: bool) -> str:
85
  if start:
86
- return f"\n\r{port}.write(eval(togglepress()))"
87
  insert = ""
88
  return f"\n\r{insert}{port}.write({_togglepress()})"
89
 
@@ -195,6 +197,7 @@ def generate_vector_gcode(
195
  contour_sources: list[ContourSource] | None = None,
196
  active_contour_owner: int | None = None,
197
  infill: float = 1.0,
 
198
  increase_pressure_per_layer: float = 0.1,
199
  pressure_ramp_enabled: bool = True,
200
  all_g1: bool = False,
@@ -220,6 +223,11 @@ def generate_vector_gcode(
220
  the Circle Spiral under shared motion: every shape's own wall radius
221
  joins the ONE shared ring set, so each shape keeps a smooth complete
222
  outer circle. Pass the SAME list to every shape's generation call.
 
 
 
 
 
223
  """
224
  if shape is None or not shape.layers:
225
  raise ValueError("The shape has no sliced layers to generate G-code from.")
@@ -315,6 +323,11 @@ def generate_vector_gcode(
315
  infill_fraction=max(0.0, min(1.0, float(infill))),
316
  extra_wall_radii=extra_wall_radii,
317
  ring_center=ring_center,
 
 
 
 
 
318
  )
319
 
320
  # World anchor: the toolpath origin expressed in the shape's own frame.
 
75
 
76
 
77
  def _setpress_cmd(port: str, pressure: float, start: bool) -> str:
78
+ # {preset} marks pressure setup for the Aerotech host runtime: presets
79
+ # execute at the controller's START signal, before the initial toggles.
80
  if start:
81
+ return f"\n\r{{preset}}{port}.write(eval(setpress({pressure:g})))"
82
  insert = ""
83
  return f"\n\r{insert}{port}.write({_setpress(pressure)})"
84
 
85
 
86
  def _toggle_cmd(port: str, start: bool) -> str:
87
  if start:
88
+ return f"\n\r{{preset}}{port}.write(eval(togglepress()))"
89
  insert = ""
90
  return f"\n\r{insert}{port}.write({_togglepress()})"
91
 
 
197
  contour_sources: list[ContourSource] | None = None,
198
  active_contour_owner: int | None = None,
199
  infill: float = 1.0,
200
+ motion_infill_fractions: list[float] | None = None,
201
  increase_pressure_per_layer: float = 0.1,
202
  pressure_ramp_enabled: bool = True,
203
  all_g1: bool = False,
 
223
  the Circle Spiral under shared motion: every shape's own wall radius
224
  joins the ONE shared ring set, so each shape keeps a smooth complete
225
  outer circle. Pass the SAME list to every shape's generation call.
226
+
227
+ `motion_infill_fractions` lists EVERY shape's infill fraction (again the
228
+ same list for every call): raster lines/rings that no head dispenses on
229
+ are dropped from the shared motion instead of swept valve-off. When
230
+ omitted, this shape's own fraction bounds its motion.
231
  """
232
  if shape is None or not shape.layers:
233
  raise ValueError("The shape has no sliced layers to generate G-code from.")
 
323
  infill_fraction=max(0.0, min(1.0, float(infill))),
324
  extra_wall_radii=extra_wall_radii,
325
  ring_center=ring_center,
326
+ motion_infill_fractions=(
327
+ [max(0.0, min(1.0, float(fraction))) for fraction in motion_infill_fractions]
328
+ if motion_infill_fractions is not None
329
+ else None
330
+ ),
331
  )
332
 
333
  # World anchor: the toolpath origin expressed in the shape's own frame.
vector_toolpath.py CHANGED
@@ -328,6 +328,30 @@ def _infill_line_keep(fraction: float):
328
  return keep
329
 
330
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
331
  def _scan_anchor(lo: float, hi: float, fil_width: float) -> float:
332
  """First scanline position of the centred grid spanning [lo, hi].
333
 
@@ -386,6 +410,7 @@ def _axis_raster_segments(
386
  start_forward: bool = True,
387
  scan_anchor: float | None = None,
388
  infill_keep=None,
 
389
  ) -> list[Seg]:
390
  """Snake raster of `motion`, dispensing only inside `valve`.
391
 
@@ -395,7 +420,9 @@ def _axis_raster_segments(
395
  valve-settle travel buffer before and after. `scan_anchor` pins the
396
  scanlines to a global grid (see `_scan_coords`). `infill_keep(k)` gates
397
  dispensing per grid line for partial infill: skipped lines are still
398
- swept, just with the valve closed, so the motion never changes.
 
 
399
  """
400
  if motion is None or motion.is_empty:
401
  return []
@@ -428,6 +455,9 @@ def _axis_raster_segments(
428
  segments: list[Seg] = []
429
  sweep_number = 0
430
  for coord in coords:
 
 
 
431
  probe = min(max(coord, probe_lo), probe_hi)
432
  motion_runs = _chord_runs(motion, axis, probe)
433
  if not motion_runs:
@@ -435,9 +465,7 @@ def _axis_raster_segments(
435
 
436
  sweep_lo = motion_runs[0][0]
437
  sweep_hi = motion_runs[-1][1]
438
- if infill_keep is not None and not infill_keep(
439
- int(round((coord - index_base) / fil_width))
440
- ):
441
  valve_runs: list[tuple[float, float]] = []
442
  else:
443
  valve_runs = []
@@ -500,10 +528,17 @@ def _oriented_axis_raster_segments(
500
  prefer_default: bool = False,
501
  scan_anchor: float | None = None,
502
  infill_keep=None,
 
503
  ) -> list[Seg]:
504
  """Pick the raster orientation whose start is nearest the current position."""
505
  default_segments = _axis_raster_segments(
506
- motion, valve, fil_width, axis, scan_anchor=scan_anchor, infill_keep=infill_keep
 
 
 
 
 
 
507
  )
508
  if prefer_default or not default_segments:
509
  return default_segments
@@ -520,6 +555,7 @@ def _oriented_axis_raster_segments(
520
  start_forward=start_forward,
521
  scan_anchor=scan_anchor,
522
  infill_keep=infill_keep,
 
523
  )
524
  if segments and segments not in candidates:
525
  candidates.append(segments)
@@ -545,6 +581,7 @@ def _rotated_raster_segments(
545
  prefer_default: bool,
546
  frame: tuple[float, float, float, float],
547
  infill_keep=None,
 
548
  ) -> list[Seg]:
549
  """Snake raster at an arbitrary angle, reusing the axis raster machinery.
550
 
@@ -588,6 +625,7 @@ def _rotated_raster_segments(
588
  prefer_default=prefer_default,
589
  scan_anchor=anchor,
590
  infill_keep=infill_keep,
 
591
  )
592
 
593
  theta_back = math.radians(angle_degrees)
@@ -1340,6 +1378,7 @@ def plan_layer_moves(
1340
  infill_fraction: float = 1.0,
1341
  extra_wall_radii: list[list[float]] | None = None,
1342
  ring_center: tuple[float, float] | None = None,
 
1343
  ) -> tuple[list[dict], tuple[float, float]]:
1344
  """Assemble per-layer segments into a relative move list for all patterns.
1345
 
@@ -1348,8 +1387,14 @@ def plan_layer_moves(
1348
  rotation pivot for diagonal layers.
1349
 
1350
  `infill_fraction` < 1 skips dispensing on evenly-distributed lines (grid
1351
- lines for axis rasters, rings/revolutions for spirals) while the motion
1352
- path stays exactly the same as at 100% infill.
 
 
 
 
 
 
1353
 
1354
  Returns the move list and the toolpath origin — the world position the
1355
  relative moves start from (the first segment start of the first non-empty
@@ -1357,6 +1402,12 @@ def plan_layer_moves(
1357
  """
1358
  raster_pattern = _normalize_raster_pattern(raster_pattern)
1359
  infill_keep = _infill_line_keep(infill_fraction)
 
 
 
 
 
 
1360
  if scan_frame is not None:
1361
  anchor_x = _scan_anchor(scan_frame[0], scan_frame[2], fil_width)
1362
  anchor_y = _scan_anchor(scan_frame[1], scan_frame[3], fil_width)
@@ -1413,6 +1464,16 @@ def plan_layer_moves(
1413
  radii.append(radius)
1414
  wall_radii = tuple(sorted(set(wall_radii) | set(extra_walls), reverse=True))
1415
 
 
 
 
 
 
 
 
 
 
 
1416
  points = _circle_rings_polyline(center_x, center_y, radii, fil_width)
1417
  if layer_number % 2 == 1:
1418
  points.reverse()
@@ -1486,6 +1547,7 @@ def plan_layer_moves(
1486
  prefer_default=not raster_origin_initialized,
1487
  frame=frame,
1488
  infill_keep=infill_keep,
 
1489
  )
1490
  else:
1491
  raster_axis = _raster_axis_for_pattern(raster_pattern, layer_number)
@@ -1502,6 +1564,7 @@ def plan_layer_moves(
1502
  prefer_default=not raster_origin_initialized,
1503
  scan_anchor=axis_anchor,
1504
  infill_keep=infill_keep,
 
1505
  )
1506
 
1507
  if not segments:
 
328
  return keep
329
 
330
 
331
+ def _combined_infill_keep(fractions):
332
+ """Union infill gate over every shape sharing the motion; None = keep all.
333
+
334
+ A grid line is traversed iff ANY shape's infill pattern dispenses on it —
335
+ lines nobody prints are dropped from the MOTION entirely instead of being
336
+ swept valve-off (e.g. every shape at 50% infill halves the path). Every
337
+ shape must be given the same fraction list so the shared motion stays
338
+ identical across heads.
339
+ """
340
+ keeps = []
341
+ for fraction in fractions or []:
342
+ keep = _infill_line_keep(fraction)
343
+ if keep is None:
344
+ return None # someone prints every line: no motion line can drop
345
+ keeps.append(keep)
346
+ if not keeps:
347
+ return None
348
+
349
+ def keep_any(line_index: int) -> bool:
350
+ return any(keep(line_index) for keep in keeps)
351
+
352
+ return keep_any
353
+
354
+
355
  def _scan_anchor(lo: float, hi: float, fil_width: float) -> float:
356
  """First scanline position of the centred grid spanning [lo, hi].
357
 
 
410
  start_forward: bool = True,
411
  scan_anchor: float | None = None,
412
  infill_keep=None,
413
+ motion_keep=None,
414
  ) -> list[Seg]:
415
  """Snake raster of `motion`, dispensing only inside `valve`.
416
 
 
420
  valve-settle travel buffer before and after. `scan_anchor` pins the
421
  scanlines to a global grid (see `_scan_coords`). `infill_keep(k)` gates
422
  dispensing per grid line for partial infill: skipped lines are still
423
+ swept with the valve closed. `motion_keep(k)` drops a grid line from the
424
+ MOTION entirely — used when NO shape sharing the motion dispenses there
425
+ (the union of every head's infill pattern), so nobody sweeps dead lines.
426
  """
427
  if motion is None or motion.is_empty:
428
  return []
 
455
  segments: list[Seg] = []
456
  sweep_number = 0
457
  for coord in coords:
458
+ line_index = int(round((coord - index_base) / fil_width))
459
+ if motion_keep is not None and not motion_keep(line_index):
460
+ continue # no head prints this line: drop it from the motion
461
  probe = min(max(coord, probe_lo), probe_hi)
462
  motion_runs = _chord_runs(motion, axis, probe)
463
  if not motion_runs:
 
465
 
466
  sweep_lo = motion_runs[0][0]
467
  sweep_hi = motion_runs[-1][1]
468
+ if infill_keep is not None and not infill_keep(line_index):
 
 
469
  valve_runs: list[tuple[float, float]] = []
470
  else:
471
  valve_runs = []
 
528
  prefer_default: bool = False,
529
  scan_anchor: float | None = None,
530
  infill_keep=None,
531
+ motion_keep=None,
532
  ) -> list[Seg]:
533
  """Pick the raster orientation whose start is nearest the current position."""
534
  default_segments = _axis_raster_segments(
535
+ motion,
536
+ valve,
537
+ fil_width,
538
+ axis,
539
+ scan_anchor=scan_anchor,
540
+ infill_keep=infill_keep,
541
+ motion_keep=motion_keep,
542
  )
543
  if prefer_default or not default_segments:
544
  return default_segments
 
555
  start_forward=start_forward,
556
  scan_anchor=scan_anchor,
557
  infill_keep=infill_keep,
558
+ motion_keep=motion_keep,
559
  )
560
  if segments and segments not in candidates:
561
  candidates.append(segments)
 
581
  prefer_default: bool,
582
  frame: tuple[float, float, float, float],
583
  infill_keep=None,
584
+ motion_keep=None,
585
  ) -> list[Seg]:
586
  """Snake raster at an arbitrary angle, reusing the axis raster machinery.
587
 
 
625
  prefer_default=prefer_default,
626
  scan_anchor=anchor,
627
  infill_keep=infill_keep,
628
+ motion_keep=motion_keep,
629
  )
630
 
631
  theta_back = math.radians(angle_degrees)
 
1378
  infill_fraction: float = 1.0,
1379
  extra_wall_radii: list[list[float]] | None = None,
1380
  ring_center: tuple[float, float] | None = None,
1381
+ motion_infill_fractions: list[float] | None = None,
1382
  ) -> tuple[list[dict], tuple[float, float]]:
1383
  """Assemble per-layer segments into a relative move list for all patterns.
1384
 
 
1387
  rotation pivot for diagonal layers.
1388
 
1389
  `infill_fraction` < 1 skips dispensing on evenly-distributed lines (grid
1390
+ lines for axis rasters, rings for the circle spiral). Lines that NO head
1391
+ prints are dropped from the motion entirely: `motion_infill_fractions`
1392
+ lists every shape sharing the motion (pass the SAME list to every shape,
1393
+ or the shared paths diverge). When omitted, a SOLO shape's own fraction
1394
+ bounds its motion, while shared motion is never restricted (the other
1395
+ heads' infill is unknown). The rectangular spiral keeps its full
1396
+ continuous walk (a skipped loop would break the spiral into
1397
+ disconnected rectangles).
1398
 
1399
  Returns the move list and the toolpath origin — the world position the
1400
  relative moves start from (the first segment start of the first non-empty
 
1402
  """
1403
  raster_pattern = _normalize_raster_pattern(raster_pattern)
1404
  infill_keep = _infill_line_keep(infill_fraction)
1405
+ if motion_infill_fractions is not None:
1406
+ motion_keep = _combined_infill_keep(motion_infill_fractions)
1407
+ elif shared_motion:
1408
+ motion_keep = None
1409
+ else:
1410
+ motion_keep = infill_keep
1411
  if scan_frame is not None:
1412
  anchor_x = _scan_anchor(scan_frame[0], scan_frame[2], fil_width)
1413
  anchor_y = _scan_anchor(scan_frame[1], scan_frame[3], fil_width)
 
1464
  radii.append(radius)
1465
  wall_radii = tuple(sorted(set(wall_radii) | set(extra_walls), reverse=True))
1466
 
1467
+ if motion_keep is not None:
1468
+ # Rings NO head dispenses on drop out of the motion (walls
1469
+ # always print, so they always stay).
1470
+ radii = [
1471
+ radius
1472
+ for radius in radii
1473
+ if any(abs(radius - wall) <= fil_width * 0.25 for wall in wall_radii)
1474
+ or motion_keep(max(0, int(round(radius / fil_width - 0.5))))
1475
+ ]
1476
+
1477
  points = _circle_rings_polyline(center_x, center_y, radii, fil_width)
1478
  if layer_number % 2 == 1:
1479
  points.reverse()
 
1547
  prefer_default=not raster_origin_initialized,
1548
  frame=frame,
1549
  infill_keep=infill_keep,
1550
+ motion_keep=motion_keep,
1551
  )
1552
  else:
1553
  raster_axis = _raster_axis_for_pattern(raster_pattern, layer_number)
 
1564
  prefer_default=not raster_origin_initialized,
1565
  scan_anchor=axis_anchor,
1566
  infill_keep=infill_keep,
1567
+ motion_keep=motion_keep,
1568
  )
1569
 
1570
  if not segments: