Spaces:
Running
Running
| """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 | |