"""G-code emission for vector layer stacks. Writes the same machine dialect as the old TIFF pipeline: G91 relative moves, G0 travel / G1 print (or all-G1), WAGO valve commands on every valve-state change, and serial pressure preset/toggle commands with an optional per-layer pressure ramp. """ from __future__ import annotations import tempfile from codecs import encode from pathlib import Path from textwrap import wrap from stl_slicer import LayerStack from vector_toolpath import ( LEAD_IN_DIRECTION_CHOICES, LEAD_IN_DIRECTION_LEFT, RASTER_PATTERN_CHOICES, RASTER_PATTERN_CIRCLE_SPIRAL, RASTER_PATTERN_DIAGONAL_WOODPILE, RASTER_PATTERN_RECTANGULAR_SPIRAL, RASTER_PATTERN_SAME_DIRECTION, RASTER_PATTERN_WOODPILE, RASTER_PATTERN_Y_DIRECTION, ContourSource, _centering_delta, _lead_in_moves, _normalize_raster_pattern, align_stack_to, build_contour_layers, circle_wall_radius, plan_layer_moves, ) __all__ = [ "LEAD_IN_DIRECTION_CHOICES", "LEAD_IN_DIRECTION_LEFT", "RASTER_PATTERN_CHOICES", "RASTER_PATTERN_CIRCLE_SPIRAL", "RASTER_PATTERN_DIAGONAL_WOODPILE", "RASTER_PATTERN_RECTANGULAR_SPIRAL", "RASTER_PATTERN_SAME_DIRECTION", "RASTER_PATTERN_WOODPILE", "RASTER_PATTERN_Y_DIRECTION", "ContourSource", "generate_vector_gcode", "write_gcode_file", ] def _setpress(pressure: float) -> str: pressure_str = str(int(pressure * 10)).zfill(4) command_bytes = bytes("08PS " + pressure_str, "utf-8") hex_command = encode(command_bytes, "hex").decode("utf-8") format_command = "\\x" + "\\x".join( hex_command[i : i + 2] for i in range(0, len(hex_command), 2) ) hex_pairs = wrap(hex_command, 2) decimal_sum = sum(int(pair, 16) for pair in hex_pairs) checksum_bin = bin(decimal_sum % 256)[2:].zfill(8) inverted = int("".join("1" if c == "0" else "0" for c in checksum_bin), 2) + 1 checksum_hex = hex(inverted)[2:].upper() format_checksum = "\\x" + "\\x".join( checksum_hex[i : i + 2] for i in range(0, len(checksum_hex), 2) ) return "b'" + "\\x05\\x02" + format_command + format_checksum + "\\x03" + "'" def _togglepress() -> str: return "b'\\x05\\x02\\x30\\x34\\x44\\x49\\x20\\x20\\x43\\x46\\x03'" def _setpress_cmd(port: str, pressure: float, start: bool) -> str: # {preset} marks pressure setup for the Aerotech host runtime: presets # execute at the controller's START signal, before the initial toggles. if start: return f"\n\r{{preset}}{port}.write(eval(setpress({pressure:g})))" insert = "" return f"\n\r{insert}{port}.write({_setpress(pressure)})" def _toggle_cmd(port: str, start: bool) -> str: if start: return f"\n\r{{preset}}{port}.write(eval(togglepress()))" insert = "" return f"\n\r{insert}{port}.write({_togglepress()})" def _valve_cmd(valve: int, command: int) -> str: return f"\n{{aux_command}}WAGO_ValveCommands({valve}, {command})\n" def _coord(value: float) -> str: """Format a coordinate in fixed-point notation, never scientific. Python's repr writes small floats as e.g. "-5.1e-08", which G-code axis parsers (including this project's viewer) misread as "-5.1". """ text = f"{float(value):.6f}".rstrip("0") if text.endswith("."): text += "0" if text in ("-0.0", "-0"): return "0.0" return text def write_gcode_file( gcode_path: Path, gcode_list: list[dict], pressure: float, valve: int, port: int, increase_pressure_per_layer: float, pressure_ramp_enabled: bool, all_g1: bool, emit_pressure_commands: bool = True, ) -> None: """Write the move list as a G-code file. `emit_pressure_commands` gates EVERY pressure command (preset, toggle, per-layer ramp, closing toggle): the pressure regulator is a PORT device, so when several shapes share a serial port only ONE of their files may own it — the print host compiles all files onto one timeline, and duplicated toggles would flip the regulator on/off/on at start. """ off_color = 0 com_port = f"serialPort{port}" color_dict: dict[int, int] = {0: 100, 255: valve} setpress_lines = [_setpress_cmd(com_port, pressure, start=True)] pressure_on_lines = [_toggle_cmd(com_port, start=True)] pressure_off_lines = [_toggle_cmd(com_port, start=False)] pressure_cur = float(pressure) with open(gcode_path, "w") as f: f.write("G91\n") f.write(_valve_cmd(valve, 0)) if emit_pressure_commands: for line in setpress_lines: f.write(f"{line}\n") for line in pressure_on_lines: f.write(f"{line}\n") pressure_next: str | None = None for i, move in enumerate(gcode_list): prev_color = gcode_list[i - 1]["Color"] if i > 0 else 0 cur_color = move["Color"] if prev_color != cur_color: if cur_color == off_color: f.write(_valve_cmd(color_dict[prev_color], 0)) else: if prev_color == off_color: f.write(_valve_cmd(color_dict[cur_color], 1)) else: f.write(_valve_cmd(color_dict[cur_color], 1)) f.write(_valve_cmd(color_dict[prev_color], 0)) # When all_g1 is set, every move is emitted as G1 regardless of # valve state; the valve commands still mark print vs travel. move_type = "G1" if (all_g1 or cur_color != off_color) else "G0" if "Z" in move: line = ( f"{move_type} X{_coord(move['X'])} Y{_coord(move['Y'])} " f"Z{_coord(move['Z'])} ; Color {move['Color']}" ) if pressure_ramp_enabled and emit_pressure_commands: pressure_cur += increase_pressure_per_layer pressure_next = _setpress_cmd(com_port, pressure_cur, start=False) else: pressure_next = None else: line = ( f"{move_type} X{_coord(move['X'])} Y{_coord(move['Y'])} " f"; Color {move['Color']}" ) pressure_next = None f.write(f"{line}\n") if pressure_next is not None: f.write(f"{pressure_next}\n") pressure_next = None f.write(_valve_cmd(valve, 0)) if emit_pressure_commands: for line in pressure_off_lines: f.write(f"{line}\n") def generate_vector_gcode( shape: LayerStack, *, shape_name: str, pressure: float, valve: int, port: int, fil_width: float, layer_height: float | None = None, raster_pattern: str | None = RASTER_PATTERN_SAME_DIRECTION, motion: LayerStack | None = None, contour_sources: list[ContourSource] | None = None, active_contour_owner: int | None = None, infill: float = 1.0, motion_infill_fractions: list[float] | None = None, emit_pressure_commands: bool = True, increase_pressure_per_layer: float = 0.1, pressure_ramp_enabled: bool = True, all_g1: bool = False, lead_in_enabled: bool = False, lead_in_length: float = 5.0, lead_in_clearance: float = 5.0, lead_in_lines: int = 3, lead_in_direction: str = LEAD_IN_DIRECTION_LEFT, lead_in_dispense: bool = True, wall_sources: list[LayerStack] | None = None, origin_sink: dict | None = None, output_dir: str | Path | None = None, ) -> Path: """Generate G-code for one sliced shape. Without `motion`, the shape's own layers drive both the nozzle path and the valve. With `motion` (the combined reference stack), the nozzle follows the shared reference path while the valve opens only inside this shape's own geometry, aligned into the reference frame — so parallel heads share one motion but each dispenses only its own shape. `wall_sources` (all shapes in the job, whole shapes only) matters for the Circle Spiral under shared motion: every shape's own wall radius joins the ONE shared ring set, so each shape keeps a smooth complete outer circle. Pass the SAME list to every shape's generation call. `motion_infill_fractions` lists EVERY shape's infill fraction (again the same list for every call): raster lines/rings that no head dispenses on are dropped from the shared motion instead of swept valve-off. When omitted, this shape's own fraction bounds its motion. """ if shape is None or not shape.layers: raise ValueError("The shape has no sliced layers to generate G-code from.") if fil_width <= 0: raise ValueError("Filament width must be greater than zero.") raster_pattern = _normalize_raster_pattern(raster_pattern) if layer_height is None: layer_height = shape.layer_height if motion is not None: if not motion.layers: raise ValueError("The reference stack has no layers for motion.") motion_layers = motion.layers valve_layers = align_stack_to(shape, motion, len(motion.layers)) contour_reference = motion else: motion_layers = shape.layers valve_layers = shape.layers contour_reference = None contour_layers = build_contour_layers( contour_sources, len(motion_layers), reference=contour_reference, ) # Anchor the raster scan grid (and the diagonal-raster pivot) to the # motion stack's frame (a split piece's frame is its parent shape's # bounds) so lines stack across layers and stay on one continuous grid # across split pieces. frame_stack = motion if motion is not None else shape if frame_stack.scan_frame is not None: scan_frame = frame_stack.scan_frame else: (frame_x_min, frame_y_min, _fz), (frame_x_max, frame_y_max, _fz2) = frame_stack.bounds scan_frame = (frame_x_min, frame_y_min, frame_x_max, frame_y_max) # Circle Spiral under shared motion (whole shapes): rings centre on the # reference ALIGN centre (each shape is concentric with it) and every # shape's outermost-fitting grid ring joins the shared ring set, so each # shape keeps a complete, uniformly spaced outer circle. extra_wall_radii = None ring_center = None if ( raster_pattern == RASTER_PATTERN_CIRCLE_SPIRAL and motion is not None and motion.scan_frame is None and shape.scan_frame is None ): if motion.align_center is not None: ring_center = motion.align_center else: ring_center = ( (scan_frame[0] + scan_frame[2]) / 2.0, (scan_frame[1] + scan_frame[3]) / 2.0, ) n_layers = len(motion_layers) extra_wall_radii = [[] for _ in range(n_layers)] sources = [ source for source in (wall_sources or []) if source is not None and source.layers and source.scan_frame is None ] if sources: for source in sources: aligned = align_stack_to(source, motion, n_layers) for index in range(n_layers): wall = circle_wall_radius( aligned[index], ring_center[0], ring_center[1], fil_width ) if wall is not None: extra_wall_radii[index].append(wall) else: # No source list: at least this shape's own outer ring. for index in range(n_layers): wall = circle_wall_radius( valve_layers[index], ring_center[0], ring_center[1], fil_width ) if wall is not None: extra_wall_radii[index].append(wall) gcode_list, toolpath_origin = plan_layer_moves( motion_layers, valve_layers, fil_width, float(layer_height), raster_pattern, contour_layers, active_contour_owner, shared_motion=motion is not None, scan_frame=scan_frame, infill_fraction=max(0.0, min(1.0, float(infill))), extra_wall_radii=extra_wall_radii, ring_center=ring_center, motion_infill_fractions=( [max(0.0, min(1.0, float(fraction))) for fraction in motion_infill_fractions] if motion_infill_fractions is not None else None ), ) # World anchor: the toolpath origin expressed in the shape's own frame. # With reference motion the geometry was translated by the centering # delta, so subtract it to get back to the shape's coordinates. It is # handed back through `origin_sink` (NOT written into the G-code — the # printed file stays free of metadata): the app stores it on the shape # record for Auto Align Split Parts and the visualizations. if motion is not None: delta_x, delta_y = _centering_delta(shape, motion) else: delta_x = delta_y = 0.0 path_origin = (toolpath_origin[0] - delta_x, toolpath_origin[1] - delta_y) if origin_sink is not None: origin_sink["path_origin"] = path_origin # A shape that opts out of the lead-in still TRAVELS the purge patch when # motion is shared (all heads must move identically) but keeps its valve # shut; printing solo, it skips the lead-in moves entirely. lead_in = _lead_in_moves( lead_in_enabled and (lead_in_dispense or motion is not None), lead_in_length, lead_in_clearance, lead_in_lines, fil_width, 255 if lead_in_dispense else 0, 0, direction=lead_in_direction, ) if lead_in: gcode_list = [*lead_in, *gcode_list] if output_dir is None: output_dir = Path(tempfile.mkdtemp(prefix="vector_gcode_")) else: output_dir = Path(output_dir) output_dir.mkdir(parents=True, exist_ok=True) gcode_path = output_dir / f"{shape_name}_gcode.txt" write_gcode_file( gcode_path, gcode_list, pressure=float(pressure), valve=int(valve), port=int(port), increase_pressure_per_layer=float(increase_pressure_per_layer), pressure_ramp_enabled=bool(pressure_ramp_enabled), all_g1=bool(all_g1), emit_pressure_commands=bool(emit_pressure_commands), ) return gcode_path