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)