Source code for gcode_reader.emulate.machine_options

import os
import re
import json
import logging

from ..syntax import resolve_flavor, floating_point_regex

FILAMENT_EXTRUDER = "filament"
PELLET_EXTRUDER = "pellet"
BOTH_EXTRUDER = "both"
ABS_EXTRUSION = "absolute"
REL_EXTRUSION = "relative"
EXPECTED_UNITS = ("mm", "cm", "in")
GCODE_FILES = (".nc", ".mpf", ".gcode")
CONFIG_FILES = (".ini", ".config")
JSON_FILES = (".json", ".s2c")

DEFAULT_GCODE_SYNTAX = {
    "offset": [
        rf"XYZ Translation Data: ({floating_point_regex})",
        rf"XYZ Translation Data: {floating_point_regex}, ({floating_point_regex})",
        rf"XYZ Translation Data: {floating_point_regex}, {floating_point_regex}, ({floating_point_regex})",
    ],
    "extrusion_type": r"Extruder type:\s*([a-zA-Z]+)",
    "externals_units": rf"((?:[a-zA-Z]+ )*[a-zA-Z]+)\s*=\s*({floating_point_regex})\s?([a-zA-Z*/]*)$",
    "exclude_externals": [],
    "motion_profile": None,
    "g0_feed": None,
    "g1_feed": None,
}


[docs] class MachineOptions: """A generic machine options base class""" def __init__( self, flavor="default", offset=None, extrusion_type=ABS_EXTRUSION, units=None, motion_profile=None, g0_feed=0, g1_feed=0, max_feedrate=None, max_acceleration=None, max_jerk=None, **kwargs, ): """Initialize the options for a machine. Args: flavor (dict, optional): Syntax definition. Defaults to "default". offset (list, optional): List of position data for machine initial position. Defaults to 0 for all location commands. extrusion_type (string, optional): Absolute or relative extrusion. Defaults to absolute. units (string, optional): Units of the print. Defaults to mm. motion_profile (string, optional): motion profile g0_feed (float, optional): Feedrate for G0 commands (if not specified in the command). Defaults to 0.0. g1_feed (float, optional): Feedrate for G1 commands (if not specified in the command). Defaults to 0.0. max_feedrate (float, optional): Feedrate limit. Defaults to None. max_acceleration (float, optional): Defaults to None. max_jerk (float, optional): Defaults to None. """ self.gcode_options_syntax = DEFAULT_GCODE_SYNTAX.copy() self.syntax = resolve_flavor(flavor) self._other = {} self._offset = [0 for _ in self.syntax["word_map"]["location"]] self._extrusion_type = None self._units = None self._motion_profile = None self._g0_feed = None self._g1_feed = None self._max_feedrate = None self._max_acceleration = None self._max_jerk = None if offset is None: offset = [None, None, None] self._parse_options( offset, extrusion_type, units, motion_profile, g0_feed, g1_feed, max_feedrate, max_acceleration, max_jerk, **kwargs, ) def _parse_options( self, offset=[None, None, None], extrusion_type=None, units=None, motion_profile=None, g0_feed=0, g1_feed=0, max_feedrate=None, max_acceleration=None, max_jerk=None, **kwargs, ): self._other = kwargs self.offset = offset self.extrusion_type = extrusion_type self.units = units self.motion_profile = motion_profile self.g0_feed = g0_feed self.g1_feed = g1_feed self.max_feedrate = max_feedrate self.max_acceleration = max_acceleration self.max_jerk = max_jerk if "x_offset" in self._other: self.offset[0] = float(self._other["x_offset"]) if "y_offset" in self._other: self.offset[1] = float(self._other["y_offset"]) if "z_offset" in self._other: self.offset[2] = float(self._other["z_offset"]) return self.all_options @property def all_options(self): out_data = self._other out_data["offset"] = self.offset out_data["extrusion_type"] = self.extrusion_type out_data["units"] = self.units out_data["motion_profile"] = self.motion_profile out_data["g0_feed"] = self.g0_feed out_data["g1_feed"] = self.g1_feed out_data["max_feedrate"] = self.max_feedrate out_data["max_acceleration"] = self.max_acceleration out_data["max_jerk"] = self.max_jerk return out_data # Properties @property def offset(self): return self._offset @offset.setter def offset(self, val): if len(val) != len(self.syntax["word_map"]["location"]): self._offset = [0 for _ in self.syntax["word_map"]["location"]] for i, pos in enumerate(val): if pos is not None: self._offset[i] = pos @property def extrusion_type(self): return self._extrusion_type @extrusion_type.setter def extrusion_type(self, val): if type(val) != str: self._extrusion_type = ABS_EXTRUSION else: self._extrusion_type = val @property def units(self): return self._units @units.setter def units(self, val): if val is not None: # Might be passed a list if type(val) is list: if len(val) > 0: # Check for some known units first for unit in EXPECTED_UNITS: if unit in val: self._units = unit # If it is still none, no known unit was found, just take the fist one if self._units is None: self._units = val[0] # Empty list passed else: logging.info("No unit specified, defaulting to mm") self._units = "mm" # Or just a string else: self._units = val else: logging.info("No unit specified, defaulting to mm") self._units = "mm" @property def motion_profile(self): return self._motion_profile @motion_profile.setter def motion_profile(self, val): if val is None: self._motion_profile = "trapezoid" else: self._motion_profile = val @property def g0_feed(self): return self._g0_feed @g0_feed.setter def g0_feed(self, val): if val is not None: self._g0_feed = float(val) else: self._g0_feed = 0.0 @property def g1_feed(self): return self._g1_feed @g1_feed.setter def g1_feed(self, val): if val is not None: self._g1_feed = float(val) else: self._g1_feed = 0.0 @property def max_feedrate(self): return self._max_feedrate @max_feedrate.setter def max_feedrate(self, val): if val is not None: val = float(val) self._max_feedrate = val @property def max_acceleration(self): return self._max_acceleration @max_acceleration.setter def max_acceleration(self, val): if val is not None: val = float(val) self._max_acceleration = val @property def max_jerk(self): return self._max_jerk @max_jerk.setter def max_jerk(self, val): if val is not None: val = float(val) self._max_jerk = val
[docs] def find_other_setting(self, property): if property in self._other: return self._other[property] elif "externals" in self._other and property in self._other["externals"]: return self._other["externals"][property]
[docs] def load(self, filepath: str, file_type="", inplace=True): """Determines which load function to use based on file extension Accepts files with extensions: ".nc", ".mpf", ".gcode", ".ini", ".config", ".json", ".s2c" Args: filepath (str): The path to the file to infer options from. file_type (str, Optional): Defaults to "". Accepts "gcode", "config", "json", "3mf". When set, ignore the file extension and parse the file using that format. inplace (bool, Optional): Defaults to True. When True, the data is saved to this MachineOptions before being returned. """ _, ext = os.path.splitext(filepath) if file_type == "gcode" or (file_type == "" and ext in GCODE_FILES): return self.load_gcode(filepath, inplace=inplace, ignore_extension=True) elif file_type == "config" or (file_type == "" and ext in CONFIG_FILES): return self.load_config(filepath, inplace=inplace, ignore_extension=True) elif file_type == "json" or (file_type == "" and ext in JSON_FILES): return self.load_json(filepath, inplace=inplace, ignore_extension=True) elif file_type == "3mf" or (file_type == "" and ext == ".3mf"): return self.load_3mf(filepath, inplace=inplace, ignore_extension=True) else: raise ValueError(f"File extension {ext} of file {filepath} not recognized")
# Extracting options from a file
[docs] def load_gcode( self, filepath: str, inplace=True, ignore_extension=False, encoding="utf-8" ): """Infers options from a gcode file. File should have extension ".nc", ".mpf", or ".gcode" Args: filepath (str): The path to the file to infer options from. inplace (bool, Optional): Defaults to True. When True, the data is saved to this MachineOptions before being returned. ignore_extension (bool, Optional): Defaults to False. When True, file extension is not checked encoding (str, optional): File encoding. Defaults to 'utf-8'. Raises: ValueError: Unrecognized file extension and ignore_extension is false Returns: dict: The options data parsed from the file """ _, ext = os.path.splitext(filepath) if not ignore_extension and ext not in GCODE_FILES: raise ValueError(f"File extension {ext} of file {filepath} not recognized") with open(filepath, "r", encoding=encoding) as f: self._other = {} offset = [0, 0, 0] units = [] extrusion_type = None externals = {} for line in f: comment = "".join(re.findall(self.syntax["comment_re"], line)).strip() # Want to read the header, but not the settings footer (that's for the slicer/person) if comment.lower() in ["settings footer", "prusaslicer_config = begin"]: break elif comment != "": # Offset for i in range(3): off = re.match(self.gcode_options_syntax["offset"][i], comment) if off: offset[i] = float(off.group(1)) # Extrusion type extruder = re.match( self.gcode_options_syntax["extrusion_type"], comment, ) if extruder: extrusion_type = extruder.group(1).lower() # Externals and Units ext_unit = re.match( self.gcode_options_syntax["externals_units"], comment, ) if ext_unit: if ext_unit.group(3) != "" and ext_unit.group(3) not in units: units.append(ext_unit.group(3)) if ( ext_unit.group(1) not in self.gcode_options_syntax["exclude_externals"] ): externals[ext_unit.group(1)] = float(ext_unit.group(2)) if inplace: return self._parse_options( offset=offset, units=units, extrusion_type=extrusion_type, **externals ) else: externals["offset"] = offset externals["units"] = units externals["extrusion_type"] = extrusion_type return externals
[docs] def load_config(self, filepath: str, inplace=True, ignore_extension=False): """Infers options from a config file. File should have extension ".config", or ".ini" Args: filepath (str): The path to the file to infer options from. inplace (bool, Optional): Defaults to True. When True, the data is saved to this MachineOptions before being returned. ignore_extension (bool, Optional): Defaults to False. When True, file extension is not checked Raises: ValueError: Unrecognized file extension and ignore_extension is false Returns: dict: The options data parsed from the file """ _, ext = os.path.splitext(filepath) if not ignore_extension and ext not in CONFIG_FILES: raise ValueError(f"File extension {ext} of file {filepath} not recognized") data = {} mach_pattern = "" if ext == ".ini": mach_pattern = r"(.+) = (.+)" elif ext == ".config": mach_pattern = r"; (.+) = (.+)" with open(filepath, "r") as f: for line in f: key_val = re.match(mach_pattern, line) # Pull out the pairs if key_val: key = key_val.group(1) val = key_val.group(2) data[key] = val if inplace: self._parse_options(**data) return self._other return data
[docs] def load_json(self, filepath: str, inplace=True, ignore_extension=False): """Infers options from a json file. File should have extension ".json", or ".s2c" Args: filepath (str): The path to the file to infer options from. inplace (bool, Optional): Defaults to True. When True, the data is saved to this MachineOptions before being returned. ignore_extension (bool, Optional): Defaults to False. When True, file extension is not checked Raises: ValueError: Unrecognized file extension and ignore_extension is false Returns: dict: The options data parsed from the file """ import json _, ext = os.path.splitext(filepath) if not ignore_extension and ext not in JSON_FILES: raise ValueError(f"File extension {ext} of file {filepath} not recognized") data = {} with open(filepath, "r") as f: file_object = json.load(f) # Check for the settings key if "settings" in file_object.keys(): if type(file_object["settings"]) == list: settings = file_object["settings"][0] else: settings = file_object["settings"] data = settings else: data = file_object if inplace: self._parse_options(**data) return self._other return data
[docs] def load_3mf(self, filepath: str, inplace=True, ignore_extension=False): """Infers options from a 3mf file. File should have extension ".3mf" Args: filepath (str): The path to the file to infer options from. inplace (bool, Optional): Defaults to True. When True, the data is saved to this MachineOptions before being returned. ignore_extension (bool, Optional): Defaults to False. When True, file extension is not checked Raises: ValueError: Unrecognized file extension and ignore_extension is false Returns: dict: The options data parsed from the file """ _, ext = os.path.splitext(filepath) if not ignore_extension and ext != ".3mf": raise ValueError(f"File extension {ext} of file {filepath} not recognized") # 3mf files have the same compression as ZIP files import shutil import tempfile data = {} # Unpack to a temp directory, using zip format # https://stackoverflow.com/a/65571978 with tempfile.TemporaryDirectory() as temp_directory: # https://stackoverflow.com/a/64110098 shutil.unpack_archive(filepath, temp_directory, format="zip") # Check for ProcessData directory in the unpacked directory process_data_path = os.path.join(temp_directory, "Metadata") if os.path.exists(process_data_path): # Find config files in directory # https://stackoverflow.com/a/3207973 files = [ os.path.join(process_data_path, f) for f in os.listdir(process_data_path) if os.path.isfile(os.path.join(process_data_path, f)) ] configs = [f for f in files if os.path.splitext(f)[1] == ".config"] # Make sure to look at only the slicer config and not the model config for path in configs: with open(path) as file: # Just looking at the first file found - will there ever be more than one? if file.readline()[0] == ";": data = self.load_config(path, inplace) break return data
[docs] def export(self, filepath, overwrite: bool = False): """Exports options to a config or json file. File should have extension ".config", ".ini", ".json", or ".s2c" Attempts to create a new file. If the file already exists, the export fails and returns None. Args: filepath (str): The path to the file to export the options to. overwrite (bool, optional): Defaults to False. If False, nothing is written if the file exists. If True, the file's contents will be overwritten if it exists. Raises: ValueError: Unrecognized file extension Returns: None """ from datetime import datetime from .. import __version__ _, ext = os.path.splitext(filepath) if not (ext in CONFIG_FILES or ext in JSON_FILES): raise ValueError( f"File extension {ext} of file {filepath} not recognized. Aborting without writing" ) # Use 'w' (write/overwrite) if allowed, 'x' (exclusive create) otherwise mode = "w" if overwrite else "x" try: # Use 'with open' for safe resource management with open(filepath, mode) as file: # Config (now safe from circular import) if ext in CONFIG_FILES: now = datetime.now().strftime("%m/%d/%Y at %H:%M:%S") # Use the imported __version__ directly file.write( f"# Generated by G-code Reader v{__version__} on {now}\n" ) file.write( "\n".join( [f"{key} = {str(val)}" for key, val in self._other.items()] ) ) # JSON elif ext in JSON_FILES: json.dump( self._other, file, ensure_ascii=False, indent=4, ) except FileExistsError: # Catches the specific error for mode='x' logging.warning(f"File: {filepath} already exists. Cancelling write.") return except Exception as e: logging.error(f"Error writing to file {filepath}: {e}") raise e return
[docs] class CEADOptions(MachineOptions): def __init__(self, **kwargs): super().__init__("cead", **kwargs) self.gcode_options_syntax["exclude_externals"] = ["Layer time"] self.gcode_options_syntax["externals_units"] = ( rf"((?:[a-zA-Z]+ )*[a-zA-Z]+):\s*({floating_point_regex})\s?([a-zA-Z*/]*)$" )
[docs] class BAAMOptions(MachineOptions): def __init__(self, **kwargs): super().__init__("cincinnati", **kwargs) self.gcode_options_syntax["exclude_externals"] = ["BEGINNING LAYER"] self.gcode_options_syntax["externals_units"] = ( rf"((?:[a-zA-Z]+ )*[a-zA-Z]+):\s*({floating_point_regex})\s?([a-zA-Z*/]*)$" )
[docs] def load_config(self, filepath): super().load_config(filepath) if "initial_w" in self._other: self.offset[3] = float(self._other["initial_w"]) return self._other
[docs] class IngersollOptions(MachineOptions): def __init__(self, **kwargs): super().__init__("ingersoll", **kwargs) self.gcode_options_syntax["externals_units"] = ( rf"((?:[a-zA-Z]+ )*[a-zA-Z]+):\s*({floating_point_regex})\s?([a-zA-Z*/]*)$" )
[docs] class JuggerbotOptions(MachineOptions): def __init__(self, **kwargs): super().__init__("juggerbot", **kwargs) self.gcode_options_syntax["exclude_externals"] = ["BEGINNING LAYER"] self.gcode_options_syntax["externals_units"] = ( rf"((?:[a-zA-Z]+ )*[a-zA-Z]+):\s*({floating_point_regex})\s?([a-zA-Z*/]*)$" )
[docs] class RepRapOptions(MachineOptions): def __init__(self, **kwargs): super().__init__("default", **kwargs)
[docs] class HendrickOptions(MachineOptions): def __init__(self, **kwargs): super().__init__("hendrick", **kwargs)
[docs] class ElectroImpactOptions(MachineOptions): def __init__(self, **kwargs): super().__init__("electroimpact", **kwargs)