import logging
import re
from typing import List
import pandas as pd
from ..syntax import resolve_flavor
from ..read import read_gcode_file, read_gcode_line
from .commands import (
Command,
Comment,
M,
G,
Move,
Arc,
Extrude,
MoveExtrude,
ArcExtrude,
Mill,
MoveMill,
ArcMill,
Dwell,
Purge,
FeedRate,
AbsolutePosition,
IncrementalPosition,
WorkCoordinates,
MachineCoordinates,
)
_G_SPECIAL_CODE_MAP = {
"absolute_position": AbsolutePosition,
"incremental_position": IncrementalPosition,
"work_coordinate": WorkCoordinates,
"machine_coordinate": MachineCoordinates,
}
def _to_iterable(val):
"""Normalise a code_map value that may be a single int or a tuple of ints."""
if val is None:
return ()
return val if isinstance(val, (list, tuple)) else (val,)
[docs]
class GcodeParser:
"""Parses G-code text into a list of high-level Command objects.
This class acts as a bridge between the low-level dictionary representation
of a G-code line (from the `read` module) and the structured `Command`
objects used for analysis and machine simulation. It uses a series of
helper methods to identify the specific type of command for each line.
Attributes:
syntax (dict): The syntax definition for the chosen G-code flavor.
helpers (list): An ordered list of handler methods used to parse a
dictionary of G-code words into a specific Command object.
"""
def __init__(self, syntax_name="default"):
"""Initializes the GcodeParser with a specific syntax flavor.
Args:
syntax_name (str, optional): The name of the syntax flavor to use,
which corresponds to a definition in `gcode_reader.syntax.flavors`.
Defaults to "default".
"""
# The following default codes can be overridden by the syntax definition. Setter will handle this
self._syntax = None
self._word_map = {}
self.syntax = syntax_name
# Kept for backward compatibility; the hot path now uses _g_dispatch instead.
self.helpers = (
self._gcode_words_handle_comments,
self._gcode_words_handle_config_special_codes,
self._gcode_words_handle_g_special_codes,
self._gcode_words_handle_single_setting_update,
self._gcode_words_handle_dwell,
self._gcode_words_handle_tool_change,
self._gcode_words_handle_tool_settings,
self._gcode_words_handle_movement,
)
self._build_dispatch()
def _build_dispatch(self):
"""Build the O(1) G-code dispatch dict from the current syntax."""
code_map = self.code_map
word_map = self.syntax.get("word_map", {})
self._word_map = word_map
self._g_dispatch = {}
for code in _to_iterable(code_map.get("linear_move")):
self._g_dispatch[code] = self._gcode_words_handle_movement
for code in _to_iterable(code_map.get("arc_move")):
self._g_dispatch[code] = self._gcode_words_handle_movement
if "dwell" in code_map:
self._g_dispatch[code_map["dwell"]] = self._gcode_words_handle_dwell
for key in _G_SPECIAL_CODE_MAP:
if key in code_map:
self._g_dispatch[code_map[key]] = (
self._gcode_words_handle_g_special_codes
)
self._config_word = word_map.get("config")
self._tool_word = word_map.get("tool")
self._location_keys = tuple(word_map.get("location", []))
self._arc_center_keys = tuple(word_map.get("arc_center", []))
self._extrusion_rate_key = word_map.get("extrusion_rate")
self._spindle_key = word_map.get("spindle")
self._feed_rate_key = word_map.get("feed_rate")
self._line_number_key = word_map.get("line_number")
self._arc_move_codes_set = set(_to_iterable(code_map.get("arc_move")))
self._linear_move_codes_set = set(_to_iterable(code_map.get("linear_move")))
self._no_gm_helpers = (
self._gcode_words_handle_comments,
self._gcode_words_handle_single_setting_update,
self._gcode_words_handle_tool_settings,
self._gcode_words_handle_movement, # handles location-only moves (e.g. CEAD flavor)
)
@property
def word_map(self):
return self._word_map
@property
def syntax(self):
return self._syntax
@syntax.setter
def syntax(self, val):
self._syntax = resolve_flavor(val)
# Cached dispatch state (word_map, location_keys, etc.) is keyed off the
# syntax — rebuild whenever the syntax changes so the parser sees the
# new flavor's word_map.
if hasattr(self, "_g_dispatch"):
self._build_dispatch()
@property
def code_map(self):
return self.syntax.get("code_map", {})
@property
def linear_move_codes(self):
return self.code_map.get("linear_move", {})
@property
def arc_move_codes(self):
return self.code_map.get("arc_move", {})
@property
def dwell_code(self):
return self.code_map.get("dwell", {})
[docs]
def gcode_file_to_commands(self, filepath: str) -> List[Command]:
"""Reads a G-code file and converts it into a list of Command objects.
This is a high-level method that handles opening, reading, and parsing
an entire G-code file from start to finish.
Args:
filepath (str): The path to the G-code file.
Returns:
List[Command]: A list of parsed Command objects, one for each line in the file.
"""
gcode_lines = read_gcode_file(filepath, self.syntax)
return [self._gcode_words_to_command_dict(words) for words in gcode_lines]
[docs]
def dataframe_to_commands(self, df: pd.DataFrame):
"""Reads a gcode dataframe and converts it to a list of Command objects.
Ignores nan data in the input dataframe.
Args:
df (DataFrame): The dataframe of gcode information to read.
Returns:
List[Command]: A list of parsed Command Objects, one for each row of the dataframe.
"""
columns = df.columns.tolist()
n_cols = len(columns)
mask = df.notna().to_numpy()
values = df.to_numpy()
return [
self.gcode_words_to_command(
**{columns[j]: values[i, j] for j in range(n_cols) if mask[i, j]}
)
for i in range(len(df))
]
[docs]
def gcode_line_to_command(self, line: str, **named_parameters):
"""Reads a single G-code line string and converts it to a Command object.
Args:
line (str): The G-code line to parse.
**named_parameters: Additional parameters to inject into the parsed words,
used for context not present in the line itself.
Returns:
Command: A parsed Command object.
"""
words = read_gcode_line(
line, named_parameters=named_parameters, flavor=self.syntax
)
return self.gcode_words_to_command(**words)
def _gcode_words_handle_comments(self, **words):
"""Handler to identify lines that are exclusively comments or whitespace.
This method checks if a line of G-code contains only a comment or is
empty. If so, it returns a `Comment` object. It's designed to ignore
comments that are attached to other commands (e.g., "G1 X10 ; move").
Args:
**words: A dictionary of G-code words from a single line.
Returns:
Optional[Comment]: A `Comment` object if the line is only a comment,
otherwise `None`.
"""
raw_line = words.get("raw")
comment = words.get("comment")
if raw_line:
# Remove the group indicators from the comment regex
# Check if entire raw G-code line string matches the comment regex or if the raw G-code line is empty
comment_re = self.syntax["comment_re"].pattern.replace("(.*)", ".*")
if (
raw_line == ""
or raw_line == "\n"
or re.match(rf"^{comment_re}", raw_line)
):
return Comment.from_words(self.word_map, **words)
elif len(words) == 1 and comment:
return Comment.from_words(self.word_map, **words)
return None
def _gcode_words_handle_g_special_codes(self, **words):
"""Handler for G-codes that change machine positioning mode or coordinate system.
Checks the parsed G-code number against the flavor's ``special_codes``
entries that correspond to entries in ``_G_SPECIAL_CODE_MAP`` (e.g.
``absolute_position``, ``incremental_position``, ``work_coordinate``,
``machine_coordinate``). Returns the matching command class instance
so that the machine can react to these modal codes, or ``None`` if the
G-code is not a recognised positioning command.
Args:
**words: A dictionary of G-code words from a single line.
Returns:
Optional[Command]: A positioning command object if matched,
otherwise ``None``.
"""
g_code = words.get("G")
if g_code is None:
return None
code_map = self.syntax.get("code_map", {})
for key, command_cls in _G_SPECIAL_CODE_MAP.items():
if key in code_map and g_code == code_map[key]:
return command_cls.from_words(self.word_map, **words)
return None
def _gcode_words_handle_single_setting_update(self, **words):
"""Handle G-code lines that update a single machine setting without a G/M command.
Some G-code lines contain only a setting update (e.g., "F1200" to set feed rate)
without an accompanying G or M code. This method identifies and processes such
lines, returning the appropriate setting object.
Args:
**words: A dictionary of G-code words from a single line.
Returns:
Optional[Command]: The corresponding setting object if the line
represents a single setting update, otherwise `None`.
"""
g_code = words.get("G")
m_code = words.get("M")
if g_code or m_code:
return None
# Metadata keys that don't count as "setting words"
metadata_keys = {"comment", "raw"}
if self._line_number_key:
metadata_keys.add(self._line_number_key)
setting_words = {k: v for k, v in words.items() if k not in metadata_keys}
if len(setting_words) != 1:
return None
#
# We have now isolated the single setting word that is being updated
#
if words.get(self._feed_rate_key):
return FeedRate(code=words[self._feed_rate_key], **words)
return None
def _gcode_words_handle_dwell(self, **words):
"""Handler to identify a dwell command (e.g., G4).
This method specifically looks for the G-code corresponding to a
dwell command, as defined in the syntax flavor.
Args:
**words: A dictionary of G-code words from a single line.
Returns:
Optional[Dwell]: A `Dwell` object if a dwell command is found,
otherwise `None`.
"""
g_code = words.get("G")
if g_code and g_code == self.dwell_code:
return Dwell.from_words(self.word_map, **words)
return None
def _gcode_words_handle_tool_change(self, **words):
"""Handler to identify a tool change command (e.g., T1).
This method looks for the presence of a tool selection word (typically 'T')
to create a generic `Command` representing a tool change.
Args:
**words: A dictionary of G-code words from a single line.
Returns:
Optional[Command]: A `Command` object if a tool change is detected,
otherwise `None`.
"""
if "tool" in self.word_map and self.word_map["tool"] in words:
return Command.from_words(self.word_map, **words)
return None
def _gcode_words_handle_tool_settings(self, **words):
"""Handler for commands that set tool parameters without movement.
This identifies commands that configure the tool, such as setting the
spindle speed or extrusion rate, but are not associated with any
X, Y, or Z axis movement in the same line.
Args:
**words: A dictionary of G-code words from a single line.
Returns:
Optional[Union[Extrude, Mill]]: An `Extrude` or `Mill` object
if a relevant setting is found, otherwise `None`.
"""
# Determine if there is movement associated with this command
for _k in self._location_keys:
if _k in words:
return None
for _k in self._arc_center_keys:
if _k in words:
return None
if self._extrusion_rate_key and self._extrusion_rate_key in words:
return Extrude.from_words(self._word_map, **words)
if self._spindle_key and self._spindle_key in words:
return Mill.from_words(self._word_map, **words)
return None
def _gcode_words_handle_config_special_codes(self, **words):
"""Handler for miscellaneous configuration M-codes.
This method processes M-codes, which are used for machine
configuration. It can create specific command types like `Purge` for
known special codes, or a `M` object for others.
Args:
**words: A dictionary of G-code words from a single line.
Returns:
Optional[Union[M, Purge]]: A specific command object if a
special M-code is found, otherwise `None`.
"""
special_codes = self.syntax.get("special_codes", {})
config_word = self.word_map.get("config")
if config_word in words:
if (
"purge" in special_codes
and words[config_word] == special_codes["purge"]
):
return Purge.from_words(self.word_map, **words)
# other special codes with unique classes
else:
return M(
code=words[config_word], **words
) # Special case where we just use the constructor directly. This will put any additional words inside of "settings" attribute
return None
def _gcode_words_handle_movement(self, **words):
"""Handler for all commands involving axis movement (G0, G1, G2, G3).
This is the primary handler for any G-code that results in physical
movement. It determines the specific type of movement (linear vs. arc)
and whether it involves a secondary action like extruding or milling,
then returns the appropriate `Command` subclass.
Args:
**words: A dictionary of G-code words from a single line.
Returns:
Optional[Command]: A specific movement command object (e.g.,
`Move`, `ArcExtrude`, `MoveMill`) if movement is detected,
otherwise `None`.
"""
g_code = words.get("G")
has_location = False
for _k in self._location_keys:
if _k in words:
has_location = True
break
has_arc_center = False
for _k in self._arc_center_keys:
if _k in words:
has_arc_center = True
break
has_arc_g_code = g_code in self._arc_move_codes_set
has_linear_g_code = g_code in self._linear_move_codes_set
is_extruding = self._extrusion_rate_key in words
is_milling = self._spindle_key in words
is_arc = has_arc_center or has_arc_g_code
is_linear = has_location or has_linear_g_code
if not (is_arc or is_linear):
return None
if is_arc:
if is_extruding:
cls = ArcExtrude
elif is_milling:
cls = ArcMill
else:
cls = Arc
else: # Must be a linear move
if is_extruding:
cls = MoveExtrude
elif is_milling:
cls = MoveMill
else:
cls = Move
return cls._from_words_dict(self.word_map, words)
[docs]
def gcode_words_to_command(self, **words) -> Command:
"""Translates a dictionary of G-code words into a specific Command object.
This method orchestrates the parsing process. It takes a dictionary
of words from a single G-code line and passes it sequentially to each
function in the `self.helpers` list. The first helper that successfully
identifies the command pattern and returns a `Command` object wins.
If no helper can identify the command, it logs a warning and returns a
generic `Command` object containing the original data.
Args:
**words: A dictionary where keys are G-code words (e.g., 'G', 'X')
and values are their numeric or string values.
Returns:
Command: The specific `Command` subclass instance that best matches
the G-code words.
"""
return self._gcode_words_to_command_dict(words)
def _gcode_words_to_command_dict(self, words: dict) -> Command:
"""Internal: same as gcode_words_to_command but takes a dict to avoid kwargs unpack/repack."""
if not words:
return Command()
words = {key: value for key, value in words.items() if value is not None}
# Tool change: preserve original priority (fires before G-code dispatch)
if self._tool_word and self._tool_word in words:
return Command._from_words_dict(self.word_map, words)
# Fast path: O(1) dispatch by G code
# Modal groups produce a list G value (unhashable) — skip dispatch for those.
g_code = words.get("G")
if g_code is not None:
if not isinstance(g_code, list):
handler = self._g_dispatch.get(g_code)
if handler is not None:
result = handler(**words)
if result is not None:
return result
return G(code=g_code, **words)
# Fast path: M code
if self._config_word and self._config_word in words:
return self._gcode_words_handle_config_special_codes(**words)
# Slow path: comment-only lines, single setting updates, bare tool settings
for helper in self._no_gm_helpers:
result = helper(**words)
if result is not None:
return result
logging.warning(
f"GcodeParser:gcode_words_to_command() unable to determine command type from: {words}. Returning default command"
)
return Command._from_words_dict(self.word_map, words)