Spaces:
Sleeping
Add reference-stack motion option and global G-code toggles
Browse filesNew options on the TIFF Slices to GCode tab:
- "Use Reference Stack for motion" (default on): every shape shares one
nozzle path derived from the combined Reference TIFF Stack, while the
valve still follows each shape, so parallel heads move in lockstep and
each dispenses only its own geometry. The shape slice is centred onto
the reference canvas to align with the motion path; layers beyond a
shape's height move without dispensing. Skips with a clear message if
no Reference TIFF Stack has been generated.
- "Use G1 for all moves" promoted from three per-shape checkboxes to one
global checkbox (default on) applied to every shape.
generate_snake_path_gcode now takes separate path (motion) and color
(valve) image sources; default behavior is unchanged.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- app.py +29 -0
- tiff_to_gcode.py +62 -17
|
@@ -962,6 +962,9 @@ def run_all_tiff_to_gcode(
|
|
| 962 |
pressure3: float,
|
| 963 |
valve3: float,
|
| 964 |
port3: float,
|
|
|
|
|
|
|
|
|
|
| 965 |
layer_height: float = 0.8,
|
| 966 |
pixel_size: float = 0.8,
|
| 967 |
) -> tuple[str | None, str | None, str | None, str]:
|
|
@@ -971,6 +974,12 @@ def run_all_tiff_to_gcode(
|
|
| 971 |
(3, zip3, pressure3, valve3, port3),
|
| 972 |
]
|
| 973 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 974 |
outputs: list[str | None] = [None, None, None]
|
| 975 |
messages: list[str] = []
|
| 976 |
|
|
@@ -979,6 +988,13 @@ def run_all_tiff_to_gcode(
|
|
| 979 |
messages.append(f"Shape {idx}: skipped (no TIFF ZIP available).")
|
| 980 |
continue
|
| 981 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 982 |
zip_name = Path(zip_path).stem
|
| 983 |
default_shape_name = f"shape{idx}"
|
| 984 |
shape_name = zip_name.replace("_tiff_slices", "") or default_shape_name
|
|
@@ -992,6 +1008,8 @@ def run_all_tiff_to_gcode(
|
|
| 992 |
port=int(port),
|
| 993 |
layer_height=float(layer_height),
|
| 994 |
fil_width=float(pixel_size),
|
|
|
|
|
|
|
| 995 |
)
|
| 996 |
outputs[idx - 1] = str(gcode_path)
|
| 997 |
messages.append(f"Shape {idx}: wrote `{gcode_path.name}`.")
|
|
@@ -1602,6 +1620,14 @@ def build_demo() -> gr.Blocks:
|
|
| 1602 |
precision=0,
|
| 1603 |
)
|
| 1604 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1605 |
gcode_button = gr.Button("Generate G-Code", variant="primary")
|
| 1606 |
|
| 1607 |
with gr.Row():
|
|
@@ -1642,6 +1668,9 @@ def build_demo() -> gr.Blocks:
|
|
| 1642 |
gcode_pressure_3,
|
| 1643 |
gcode_valve_3,
|
| 1644 |
gcode_port_3,
|
|
|
|
|
|
|
|
|
|
| 1645 |
layer_height,
|
| 1646 |
pixel_size,
|
| 1647 |
],
|
|
|
|
| 962 |
pressure3: float,
|
| 963 |
valve3: float,
|
| 964 |
port3: float,
|
| 965 |
+
all_g1: bool = False,
|
| 966 |
+
use_reference_motion: bool = False,
|
| 967 |
+
ref_state: ViewerState | None = None,
|
| 968 |
layer_height: float = 0.8,
|
| 969 |
pixel_size: float = 0.8,
|
| 970 |
) -> tuple[str | None, str | None, str | None, str]:
|
|
|
|
| 974 |
(3, zip3, pressure3, valve3, port3),
|
| 975 |
]
|
| 976 |
|
| 977 |
+
# When reference-driven motion is requested, every shape's nozzle path comes
|
| 978 |
+
# from the combined Reference TIFF Stack; the valve still follows each shape.
|
| 979 |
+
motion_tiffs: list[str] | None = None
|
| 980 |
+
if use_reference_motion:
|
| 981 |
+
motion_tiffs = (ref_state or {}).get("tiff_paths") or None
|
| 982 |
+
|
| 983 |
outputs: list[str | None] = [None, None, None]
|
| 984 |
messages: list[str] = []
|
| 985 |
|
|
|
|
| 988 |
messages.append(f"Shape {idx}: skipped (no TIFF ZIP available).")
|
| 989 |
continue
|
| 990 |
|
| 991 |
+
if use_reference_motion and not motion_tiffs:
|
| 992 |
+
messages.append(
|
| 993 |
+
f"Shape {idx}: skipped (Reference motion selected, but no Reference "
|
| 994 |
+
f"TIFF Stack has been generated on the first tab)."
|
| 995 |
+
)
|
| 996 |
+
continue
|
| 997 |
+
|
| 998 |
zip_name = Path(zip_path).stem
|
| 999 |
default_shape_name = f"shape{idx}"
|
| 1000 |
shape_name = zip_name.replace("_tiff_slices", "") or default_shape_name
|
|
|
|
| 1008 |
port=int(port),
|
| 1009 |
layer_height=float(layer_height),
|
| 1010 |
fil_width=float(pixel_size),
|
| 1011 |
+
all_g1=bool(all_g1),
|
| 1012 |
+
motion_tiffs=motion_tiffs,
|
| 1013 |
)
|
| 1014 |
outputs[idx - 1] = str(gcode_path)
|
| 1015 |
messages.append(f"Shape {idx}: wrote `{gcode_path.name}`.")
|
|
|
|
| 1620 |
precision=0,
|
| 1621 |
)
|
| 1622 |
|
| 1623 |
+
gcode_use_ref_motion = gr.Checkbox(
|
| 1624 |
+
label="Use Reference Stack for motion (all shapes share one nozzle path; each dispenses only its own geometry). Generate the Reference TIFF Stack on the first tab first.",
|
| 1625 |
+
value=True,
|
| 1626 |
+
)
|
| 1627 |
+
gcode_all_g1 = gr.Checkbox(
|
| 1628 |
+
label="Use G1 for all moves (no G0 travel commands) — applies to all shapes",
|
| 1629 |
+
value=True,
|
| 1630 |
+
)
|
| 1631 |
gcode_button = gr.Button("Generate G-Code", variant="primary")
|
| 1632 |
|
| 1633 |
with gr.Row():
|
|
|
|
| 1668 |
gcode_pressure_3,
|
| 1669 |
gcode_valve_3,
|
| 1670 |
gcode_port_3,
|
| 1671 |
+
gcode_all_g1,
|
| 1672 |
+
gcode_use_ref_motion,
|
| 1673 |
+
ref_state,
|
| 1674 |
layer_height,
|
| 1675 |
pixel_size,
|
| 1676 |
],
|
|
@@ -163,6 +163,22 @@ def _load_grayscale(path: Path, invert: bool) -> np.ndarray:
|
|
| 163 |
return array
|
| 164 |
|
| 165 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 166 |
def generate_snake_path_gcode(
|
| 167 |
zip_path: str | Path,
|
| 168 |
shape_name: str,
|
|
@@ -173,6 +189,8 @@ def generate_snake_path_gcode(
|
|
| 173 |
fil_width: float = 0.8,
|
| 174 |
invert: bool = True,
|
| 175 |
increase_pressure_per_layer: float = 0.1,
|
|
|
|
|
|
|
| 176 |
) -> Path:
|
| 177 |
zip_path = Path(zip_path)
|
| 178 |
if not zip_path.exists():
|
|
@@ -185,19 +203,40 @@ def generate_snake_path_gcode(
|
|
| 185 |
if not tiff_files:
|
| 186 |
raise ValueError("No TIFF files found in the ZIP archive.")
|
| 187 |
|
| 188 |
-
ref_list: list[np.ndarray] = []
|
| 189 |
-
img_list: list[np.ndarray] = []
|
| 190 |
-
for i, path in enumerate(tiff_files):
|
| 191 |
-
img = _load_grayscale(path, invert=invert)
|
| 192 |
-
ref_list.append(img.copy())
|
| 193 |
-
if (i + 1) % 2 == 0:
|
| 194 |
-
img = np.flipud(img)
|
| 195 |
-
img_list.append(img)
|
| 196 |
-
|
| 197 |
off_color = 0
|
| 198 |
com_port = f"serialPort{port}"
|
| 199 |
color_dict: dict[int, int] = {0: 100, 255: valve}
|
| 200 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 201 |
setpress_lines = [_setpress_cmd(com_port, pressure, start=True)]
|
| 202 |
pressure_on_lines = [_toggle_cmd(com_port, start=True)]
|
| 203 |
pressure_off_lines = [_toggle_cmd(com_port, start=False)]
|
|
@@ -208,10 +247,9 @@ def generate_snake_path_gcode(
|
|
| 208 |
use_flip_y = False
|
| 209 |
direction = -1
|
| 210 |
|
| 211 |
-
for layers in range(len(
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
last_image_ref = ref_list[layers - 1] if layers > 0 else None
|
| 215 |
y_ref = current_image_ref.shape[0]
|
| 216 |
|
| 217 |
def find_first_valid_y(row: np.ndarray | None, flip: bool = False) -> int | None:
|
|
@@ -265,9 +303,14 @@ def generate_snake_path_gcode(
|
|
| 265 |
dist_sign_long = -dist_sign_long
|
| 266 |
dist_sign_long = -dist_sign_long
|
| 267 |
|
| 268 |
-
|
| 269 |
-
|
| 270 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 271 |
|
| 272 |
if layers == 0:
|
| 273 |
direction = -1
|
|
@@ -306,7 +349,9 @@ def generate_snake_path_gcode(
|
|
| 306 |
f.write(_valve_cmd(color_dict[cur_color], 1))
|
| 307 |
f.write(_valve_cmd(color_dict[prev_color], 0))
|
| 308 |
|
| 309 |
-
|
|
|
|
|
|
|
| 310 |
if "Z" in move:
|
| 311 |
line = (
|
| 312 |
f"{move_type} X{move['X']} Y{move['Y']} Z{move['Z']} "
|
|
|
|
| 163 |
return array
|
| 164 |
|
| 165 |
|
| 166 |
+
def _center_on_canvas(
|
| 167 |
+
img: np.ndarray, canvas_h: int, canvas_w: int, fill: int = 0
|
| 168 |
+
) -> np.ndarray:
|
| 169 |
+
"""Place `img` centred on a (canvas_h, canvas_w) canvas filled with `fill`.
|
| 170 |
+
|
| 171 |
+
Mirrors the centring used to build the reference stack, so a shape's slice
|
| 172 |
+
lines up pixel-for-pixel with the reference (motion) slice of the same layer.
|
| 173 |
+
"""
|
| 174 |
+
h, w = img.shape[:2]
|
| 175 |
+
out = np.full((canvas_h, canvas_w), fill, dtype=img.dtype)
|
| 176 |
+
y_off = max(0, (canvas_h - h) // 2)
|
| 177 |
+
x_off = max(0, (canvas_w - w) // 2)
|
| 178 |
+
out[y_off : y_off + h, x_off : x_off + w] = img[: canvas_h, : canvas_w]
|
| 179 |
+
return out
|
| 180 |
+
|
| 181 |
+
|
| 182 |
def generate_snake_path_gcode(
|
| 183 |
zip_path: str | Path,
|
| 184 |
shape_name: str,
|
|
|
|
| 189 |
fil_width: float = 0.8,
|
| 190 |
invert: bool = True,
|
| 191 |
increase_pressure_per_layer: float = 0.1,
|
| 192 |
+
all_g1: bool = False,
|
| 193 |
+
motion_tiffs: list[str] | None = None,
|
| 194 |
) -> Path:
|
| 195 |
zip_path = Path(zip_path)
|
| 196 |
if not zip_path.exists():
|
|
|
|
| 203 |
if not tiff_files:
|
| 204 |
raise ValueError("No TIFF files found in the ZIP archive.")
|
| 205 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 206 |
off_color = 0
|
| 207 |
com_port = f"serialPort{port}"
|
| 208 |
color_dict: dict[int, int] = {0: 100, 255: valve}
|
| 209 |
|
| 210 |
+
# Two non-flipped source image lists. The "path" images drive the nozzle
|
| 211 |
+
# motion (which rows are swept, the sweep extent, the inter-layer shifts);
|
| 212 |
+
# the "color" images decide the valve state (material) at each swept pixel.
|
| 213 |
+
# Normally both are this shape's own slices. When reference motion tiffs are
|
| 214 |
+
# supplied, motion comes from the combined reference stack while the valve is
|
| 215 |
+
# still driven by this shape's slices, centred onto the reference canvas — so
|
| 216 |
+
# parallel heads share one motion path but each dispenses only its geometry.
|
| 217 |
+
shape_imgs = [_load_grayscale(p, invert=invert) for p in tiff_files]
|
| 218 |
+
|
| 219 |
+
if motion_tiffs:
|
| 220 |
+
motion_paths = sorted(
|
| 221 |
+
(Path(p) for p in motion_tiffs), key=lambda p: _sort_key(p.name)
|
| 222 |
+
)
|
| 223 |
+
path_ref_list = [_load_grayscale(p, invert=invert) for p in motion_paths]
|
| 224 |
+
if not path_ref_list:
|
| 225 |
+
raise ValueError("No reference TIFF files provided for motion.")
|
| 226 |
+
color_ref_list: list[np.ndarray] = []
|
| 227 |
+
for li, motion_img in enumerate(path_ref_list):
|
| 228 |
+
h_c, w_c = motion_img.shape[:2]
|
| 229 |
+
if li < len(shape_imgs):
|
| 230 |
+
color_ref_list.append(
|
| 231 |
+
_center_on_canvas(shape_imgs[li], h_c, w_c, fill=off_color)
|
| 232 |
+
)
|
| 233 |
+
else:
|
| 234 |
+
# Reference is taller than this shape: move but dispense nothing.
|
| 235 |
+
color_ref_list.append(np.full((h_c, w_c), off_color, dtype=np.uint8))
|
| 236 |
+
else:
|
| 237 |
+
path_ref_list = [im.copy() for im in shape_imgs]
|
| 238 |
+
color_ref_list = [im.copy() for im in shape_imgs]
|
| 239 |
+
|
| 240 |
setpress_lines = [_setpress_cmd(com_port, pressure, start=True)]
|
| 241 |
pressure_on_lines = [_toggle_cmd(com_port, start=True)]
|
| 242 |
pressure_off_lines = [_toggle_cmd(com_port, start=False)]
|
|
|
|
| 247 |
use_flip_y = False
|
| 248 |
direction = -1
|
| 249 |
|
| 250 |
+
for layers in range(len(path_ref_list)):
|
| 251 |
+
current_image_ref = path_ref_list[layers]
|
| 252 |
+
last_image_ref = path_ref_list[layers - 1] if layers > 0 else None
|
|
|
|
| 253 |
y_ref = current_image_ref.shape[0]
|
| 254 |
|
| 255 |
def find_first_valid_y(row: np.ndarray | None, flip: bool = False) -> int | None:
|
|
|
|
| 303 |
dist_sign_long = -dist_sign_long
|
| 304 |
dist_sign_long = -dist_sign_long
|
| 305 |
|
| 306 |
+
# Flip path and color together on even layers so they stay aligned.
|
| 307 |
+
even_layer = (layers + 1) % 2 == 0
|
| 308 |
+
ref_for_path = (
|
| 309 |
+
np.flipud(current_image_ref) if even_layer else current_image_ref.copy()
|
| 310 |
+
)
|
| 311 |
+
current_image = (
|
| 312 |
+
np.flipud(color_ref_list[layers]) if even_layer else color_ref_list[layers]
|
| 313 |
+
)
|
| 314 |
|
| 315 |
if layers == 0:
|
| 316 |
direction = -1
|
|
|
|
| 349 |
f.write(_valve_cmd(color_dict[cur_color], 1))
|
| 350 |
f.write(_valve_cmd(color_dict[prev_color], 0))
|
| 351 |
|
| 352 |
+
# When all_g1 is set, every move is emitted as G1 regardless of
|
| 353 |
+
# valve state; the valve commands still mark print vs travel.
|
| 354 |
+
move_type = "G1" if (all_g1 or cur_color != off_color) else "G0"
|
| 355 |
if "Z" in move:
|
| 356 |
line = (
|
| 357 |
f"{move_type} X{move['X']} Y{move['Y']} Z{move['Z']} "
|