from dataclasses import dataclass, field, asdict
from typing import Tuple
from copy import copy
from ..read import apply_word_abstractions
from ..syntax import flavors
from . import tag_literals
_ATTR_CACHE: dict[type, tuple] = {}
[docs]
class Command:
"""Base class for abstract representation of a G-code line's Words"""
TAG = ""
ATTRIBUTES = (
"G",
"comment",
"raw",
"settings",
)
_COLLECT_WORD_MAP_SETTINGS = True
def __init__(self, **kwargs):
self._words = {"G": None, "comment": "", "raw": ""}
self.processed_by = None
self.settings = {}
if kwargs:
for attr in self._get_all_attributes():
if attr in kwargs:
setattr(self, attr, kwargs[attr])
@classmethod
def _get_all_attributes(cls) -> tuple:
"""Walks the class hierarchy (MRO) to collect all ATTRIBUTES. Result is cached per class."""
if cls not in _ATTR_CACHE:
all_attrs = set()
for base_class in cls.__mro__:
if hasattr(base_class, "ATTRIBUTES"):
all_attrs.update(base_class.ATTRIBUTES)
_ATTR_CACHE[cls] = tuple(all_attrs)
return _ATTR_CACHE[cls]
[docs]
@classmethod
def from_words(cls, word_map=flavors["default"]["word_map"], **words):
"""Creates a new command from a set of words and word_map"""
return cls._from_words_dict(word_map, words)
@classmethod
def _from_words_dict(cls, word_map, words):
"""Internal: same as from_words but takes a pre-built dict to avoid kwargs unpack/repack."""
command = cls()
command._words = apply_word_abstractions(word_map, words)
attrs_to_set = cls._get_all_attributes()
command._set_attributes_from_words(*attrs_to_set)
command._collect_settings_from_words(word_map)
return command
def _collect_settings_from_words(self, word_map: dict):
"""Moves abstract word_map keys that weren't promoted to ATTRIBUTES into settings.
Subclasses can set _COLLECT_WORD_MAP_SETTINGS = False to opt out (e.g. Config,
which manages settings via its own __init__ kwargs path).
"""
if not self._COLLECT_WORD_MAP_SETTINGS:
return
if self.settings is None:
self.settings = {}
words = self._words
# Intersection avoids iterating word_map keys that aren't in _words.
for key in word_map.keys() & words.keys():
self.settings[key] = words.pop(key)
def _set_attributes_from_words(self, *attributes: str):
"""Promotes specified keys from the internal `_words` dictionary to class attributes.
This helper method iterates through a given sequence of attribute names.
For each name, it removes the corresponding item from the `self._words`
dictionary and sets it as a direct attribute on the instance (e.g., `self.location`).
This is used to move well-defined parameters out of the generic
word bucket and into typed, accessible properties. If an attribute
is not found in `_words`, its corresponding instance attribute is set to `None`.
Args:
*attributes (str): A variable number of attribute names to process.
"""
for attr in attributes:
value = self._words.pop(attr, None)
setattr(self, attr, value)
@property
def raw(self):
return getattr(self, "_raw", self._words.get("raw", ""))
@raw.setter
def raw(self, v):
self._raw = str(v) if v is not None else ""
@property
def comment(self):
return getattr(self, "_comment", self._words.get("comment", ""))
@comment.setter
def comment(self, v):
self._comment = str(v) if v is not None else ""
@property
def process_data_type(self):
return self._process_data_type
[docs]
def to_dict(self) -> dict:
data = {}
for attr in self._get_all_attributes():
if hasattr(self, attr):
data[attr] = getattr(self, attr)
data["words"] = self._words # Add any remaining, unprocessed words
return data
def __eq__(self, other) -> bool:
if type(self) is not type(other):
return False
for attr in self._get_all_attributes():
if getattr(self, attr, None) != getattr(other, attr, None):
return False
return self._words == other._words
[docs]
class Move(Command):
"""A Linear Movement to a location at a specified feed rate"""
TAG = tag_literals.LINEAR_MOVE
ATTRIBUTES = ("location", "feed_rate") + Command.ATTRIBUTES
def __init__(self, location: list = None, feed_rate: float = None, **kwargs):
super().__init__(location=location, feed_rate=feed_rate, **kwargs)
@property
def location(self):
return self._location
@location.setter
def location(self, v):
self._location = tuple(v) if v is not None else (None, None, None)
@property
def feed_rate(self):
return self._feed_rate
@feed_rate.setter
def feed_rate(self, v):
self._feed_rate = float(v) if v is not None else None
[docs]
class Arc(Move):
"""An arced movement to a location at a specified feed rate about a center point"""
TAG = tag_literals.ARC_MOVE
ATTRIBUTES = ("arc_center",) + Move.ATTRIBUTES
def __init__(
self,
arc_center: list = None,
location: list = None,
feed_rate: float = None,
**kwargs,
):
super().__init__(
location=location, feed_rate=feed_rate, arc_center=arc_center, **kwargs
)
@property
def arc_center(self):
return self._arc_center
@arc_center.setter
def arc_center(self, v):
self._arc_center = tuple(v) if v is not None else (None, None)
def __eq__(self, value):
return super().__eq__(value) and self.arc_center == value.arc_center
[docs]
class Extrude(Command):
"""Specifies a deposit amount for a toolhead"""
TAG = tag_literals.EXTRUDE
ATTRIBUTES = ("deposited_volume", "extrusion_rate") + Command.ATTRIBUTES
def __init__(
self,
deposited_volume: float = None,
**kwargs,
):
super().__init__(
deposited_volume=deposited_volume,
**kwargs,
)
@property
def deposited_volume(self):
return self._deposited_volume
@deposited_volume.setter
def deposited_volume(self, v):
self._deposited_volume = float(v) if v is not None else None
if v is not None:
self._extrusion_rate = None
@property
def extrusion_rate(self):
return self._extrusion_rate
@extrusion_rate.setter
def extrusion_rate(self, v):
self._extrusion_rate = float(v) if v is not None else None
if v is not None:
self._deposited_volume = None
[docs]
class MoveExtrude(Move, Extrude):
"""Specifies a composition of movement to a location at a feed rate, and an extrusion rate for a toolhead"""
TAG = tag_literals.MOVE_EXTRUDE
ATTRIBUTES = Move.ATTRIBUTES + Extrude.ATTRIBUTES
def __init__(
self,
location: list = None,
feed_rate: float = None,
deposited_volume: float = 0.0,
**kwargs,
):
super().__init__(
location=location,
feed_rate=feed_rate,
deposited_volume=deposited_volume,
**kwargs,
)
[docs]
class ArcExtrude(Arc, Extrude):
"""Specifies a composition of arc movement to a location at a feed rate about a center point, and an extrusion rate for a toolhead"""
TAG = f"{tag_literals.MOVE_EXTRUDE}{tag_literals.ARC_MOVE}"
ATTRIBUTES = Arc.ATTRIBUTES + Extrude.ATTRIBUTES
def __init__(
self,
arc_center: list = None,
location: list = None,
feed_rate: float = None,
deposited_volume: float = 0,
**kwargs,
):
super().__init__(
arc_center=arc_center,
location=location,
feed_rate=feed_rate,
deposited_volume=deposited_volume,
**kwargs,
)
[docs]
class Mill(Command):
"""Specifies a spindle rate for a milling toolhead"""
TAG = tag_literals.MILL
ATTRIBUTES = ("spindle",) + Command.ATTRIBUTES
def __init__(self, spindle: float = None, **kwargs):
super().__init__(spindle=spindle, **kwargs)
@property
def spindle(self):
return self._spindle
@spindle.setter
def spindle(self, v):
self._spindle = float(v) if v is not None else None
[docs]
class MoveMill(Move, Mill):
"""Composition of a linear movement and a milling toolhead spindle rate"""
TAG = tag_literals.MOVE_MILL
ATTRIBUTES = Move.ATTRIBUTES + Mill.ATTRIBUTES
def __init__(
self, location: list = None, feed_rate: float = None, spindle=None, **kwargs
):
super().__init__(
location=location,
feed_rate=feed_rate,
spindle=spindle,
**kwargs,
)
[docs]
class ArcMill(Arc, Mill):
"""Specifies a composition of arc movement to a location at a feed rate about a center point, and an extrusion rate for a toolhead"""
TAG = f"{tag_literals.MOVE_MILL}{tag_literals.ARC_MOVE}"
ATTRIBUTES = Arc.ATTRIBUTES + Mill.ATTRIBUTES
def __init__(
self,
arc_center: list = None,
location: list = None,
feed_rate: float = None,
spindle: float = None,
**kwargs,
):
super().__init__(
arc_center=arc_center,
location=location,
feed_rate=feed_rate,
spindle=spindle,
**kwargs,
)
[docs]
class Dwell(Command):
"""Informs the machine to rest at the most recent location for a specified amount of time"""
TAG = tag_literals.DWELL
# NOTE: We need both time_s and time_ms attrs to successfully construct via Command.from_words(). Only store time_s though.
ATTRIBUTES = ("time_s", "time_ms") + Command.ATTRIBUTES
def __init__(self, time_ms: float = None, time_s: float = None, **kwargs):
super().__init__(**kwargs)
if time_s is not None and time_ms is not None:
raise ValueError("Dwell.__init__: Cannot specify both time_s and time_ms.")
if time_s is not None:
self.time_s = time_s
elif time_ms is not None:
self.time_ms = time_ms
else:
self._time_s = None
@property
def time_ms(self):
return self.time_s * 1000.0 if self.time_s is not None else 0.0
@time_ms.setter
def time_ms(self, v):
# NOTE: Since we store only time_s, we want to only update the underlying attr (_time_s) if there is a value provided to this setter.
self._time_s = float(v) / 1000.0 if v is not None else self._time_s
@property
def time_s(self):
return self._time_s if self._time_s is not None else 0.0
@time_s.setter
def time_s(self, v):
self._time_s = float(v) if v is not None else self._time_s
[docs]
class Config(Command):
"""Class for codes that modify a machine's configuration"""
TAG = tag_literals.CONFIG
ATTRIBUTES = ("code", "settings") + Command.ATTRIBUTES
_COLLECT_WORD_MAP_SETTINGS = False # Manually override promotion of additional settings in .from_words() classmethod
def __init__(self, code: int = None, settings: dict = None, **kwargs):
super().__init__(**kwargs)
self._code = None
if code is not None:
self.code = code
self.settings = {}
if settings is not None:
self.settings.update(settings)
self.settings.update(kwargs)
@property
def code(self):
return self._code
@code.setter
def code(self, v):
if isinstance(v, (list, tuple)):
self._code = [int(c) for c in v]
elif v is not None:
self._code = int(v)
# NOTE: Cannot set to None via property setter, because M assigns 'M' property to 'code'. This avoids Command.from_words() race condition
@property
def is_modal_group(self):
return isinstance(self.code, (list, tuple))
[docs]
class M(Config):
"""A M-code configuration command."""
TAG = f"#M{Config.TAG}"
ATTRIBUTES = ("M",) + Config.ATTRIBUTES
@property
def M(self):
return self._code
@M.setter
def M(self, v):
self.code = v
[docs]
class G(Config):
"""A G-code configuration command."""
TAG = f"#G{Config.TAG}"
@property
def G(self):
return self._code
@G.setter
def G(self, v):
self.code = v
[docs]
class FeedRate(Config):
"""A feedrate configuration command. Used to only update the feedrate state."""
TAG = f"{tag_literals.FEED_RATE}"
[docs]
class Purge(Dwell, Extrude):
"""Dwells while extruding"""
TAG = f"{tag_literals.DWELL}{tag_literals.EXTRUDE}"
ATTRIBUTES = (
("feed_rate",) + Dwell.ATTRIBUTES + Extrude.ATTRIBUTES + Config.ATTRIBUTES
)
def __init__(self, time_ms=None, deposited_volume: float = None, **kwargs):
super().__init__(
time_ms=time_ms,
deposited_volume=deposited_volume,
**kwargs,
)
[docs]
class AbsolutePosition(Config):
"""An Absolute Position configuration command. Typically G90."""
TAG = f"{tag_literals.ABSOLUTE_POS}"
[docs]
class IncrementalPosition(Config):
"""An Incremental Position configuration command. Typically G91."""
TAG = f"{tag_literals.INCREMENTAL_POS}"
[docs]
class CoordinateSystemChange(Config):
"""A coordinate-system selection command that may carry target coordinates.
This is a configuration command (not a motion command). It may optionally
carry XYZ coordinates — e.g. ``G53 X0 Y0 Z0`` — which are stored in
``location``. It inherits from ``Config``, not ``Move``, so that
``isinstance(cmd, Move)`` correctly returns ``False`` for these commands.
"""
ATTRIBUTES = ("location",) + Config.ATTRIBUTES
TAG = ""
def __init__(self, location=None, **kwargs):
super().__init__(**kwargs)
self.location = location
@property
def location(self):
return self._location
@location.setter
def location(self, v):
self._location = tuple(v) if v is not None else (None, None, None)
[docs]
class WorkCoordinates(CoordinateSystemChange):
"""A Work Coordinate System command. Typically G54."""
TAG = f"{tag_literals.WORK_COORD}"
[docs]
class MachineCoordinates(CoordinateSystemChange):
"""A Machine Coordinate System command. Typically G53."""
TAG = f"{tag_literals.MACHINE_COORD}"