Spaces:
Running
Drop raster lines no head prints from the shared motion; {preset} markers
Browse filesInfill 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>
- README.md +1 -1
- app.py +22 -7
- tests/test_vector_gcode.py +87 -19
- vector_gcode.py +15 -2
- vector_toolpath.py +70 -7
|
@@ -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
|
| 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
|
|
@@ -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 |
-
|
| 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(
|
|
@@ -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 |
-
|
| 218 |
-
assert lines[
|
|
|
|
| 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
|
| 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 |
-
#
|
| 888 |
-
|
| 889 |
-
assert
|
| 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 |
-
|
| 947 |
-
|
| 948 |
-
|
| 949 |
-
|
| 950 |
-
|
| 951 |
-
|
| 952 |
-
|
| 953 |
-
|
| 954 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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:
|
|
@@ -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.
|
|
@@ -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
|
|
|
|
|
|
|
| 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,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 1352 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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:
|