"""
Pre-processes lines of G-code programs to a consistent key-value format.
Terminology Definitions:
- Line (Block): The RS-274/NGC language is based on lines of code. Each line (block) may include commands to a machining center to do several different things. Lines of code may be collected in a file to make a program.
- Word: A letter other than N followed by a real value. A real value is some collection of characters that can be processed to come up with a number. A real value may be an explicit number (such as 341 or -0.8807), a parameter value, an expression, or a unary operation value. Definitions of these follow immediately. Processing characters to come up with a number is called “evaluating”. An explicit number evaluates to itself.
- Parameter: A key-value pair
- Expression: A combination of parameters, operators, and values that can be evaluated to produce a result. Expressions can be used to calculate values dynamically based on other variables or conditions.
References:
- https://fabricesalvaire.github.io/pythonic-gcode-machine/gcode-reference/rs-274/index.html#format-of-a-line
- https://www.nist.gov/publications/nist-rs274ngc-interpreter-version-3
"""
import logging
import re
from numpy import cos, tan, sin, arccos, arctan, abs, deg2rad, sqrt, round, concatenate
from . import syntax
# Cache compiled regex patterns to avoid recompilation overhead
_compiled_patterns = {}
# Per-flavor stripping spec: id(flavor) -> list of (compiled_re_or_list, repl)
_strip_spec_cache: dict = {}
# Per-flavor param spec: id(flavor) -> (param_re, preset_re_or_None)
_param_spec_cache: dict = {}
# Per-flavor decl spec: id(flavor) -> (param_line_re_or_None, param_decl_re_or_None, has_indicator)
_decl_spec_cache: dict = {}
# Per-flavor fn-expr fast-path: id(flavor) -> bool (True = safe to skip when no brackets in line)
_fn_expr_bracket_fast_path: dict = {}
def _get_compiled(pattern):
"""Return compiled regex or list of compiled regexes for a given pattern spec.
The input may be None, a string, a compiled re.Pattern, or a list of strings/patterns.
Compiled patterns are cached in module-level dict to avoid repeated compile cost.
"""
if pattern is None:
return None
if isinstance(pattern, re.Pattern):
return pattern
if isinstance(pattern, list):
compiled = []
for p in pattern:
if isinstance(p, re.Pattern):
compiled.append(p)
else:
if p not in _compiled_patterns:
_compiled_patterns[p] = re.compile(p)
compiled.append(_compiled_patterns[p])
return compiled
else:
if pattern not in _compiled_patterns:
_compiled_patterns[pattern] = re.compile(pattern)
return _compiled_patterns[pattern]
def _get_strip_spec(flavor: dict):
"""Return (and cache) the list of (compiled_re, replacement) pairs used for stripping."""
flavor_id = id(flavor)
if flavor_id in _strip_spec_cache:
return _strip_spec_cache[flavor_id]
spec = []
for key in ("comment_re", "word_re", "function_expression_re"):
pattern = flavor.get(key)
if pattern is None:
continue
compiled = _get_compiled(pattern)
if isinstance(compiled, list):
for compiled_re in compiled:
repl = replacement_function if compiled_re.groups >= 3 else ""
spec.append((compiled_re, repl))
else:
repl = replacement_function if compiled.groups >= 3 else ""
spec.append((compiled, repl))
_strip_spec_cache[flavor_id] = spec
return spec
[docs]
def line_is_modal_group(line: str, flavor: dict = syntax.flavors["default"]):
"""Determines if G-code line is a modal group. Modal commands are arranged in sets such as "G7 G18 G19"
Args:
line (str): Un-processed line (block) from a G-code program
flavor (dict, optional): Syntax definition for line. Defaults to syntax.flavors["default"].
Returns:
(bool, list): True if line is modal group, modal group
"""
cmd_re = _get_compiled(flavor["command_re"])
modal_group = cmd_re.findall(line) if cmd_re is not None else []
if len(modal_group) <= 1:
return False, None
return True, modal_group
[docs]
def line_declares_parameters(
line: str, named_parameters: dict = {}, flavor: dict = syntax.flavors["default"]
):
"""Uses syntax definition to identify declaration of named parameters in line.
Args:
line (str): line (block) from a G-code program
named_parameters (dict): Named parameter definition
flavor (dict, optional): Syntax definition for line. Defaults to syntax.flavors["default"].
Returns:
(bool, dict): Existence of parameter declaration, dictionary of parameters
"""
# If gcode syntax does not support parameters then ignore
if not flavor["parameter_declare_re"] or not flavor["parameter_re"]:
return False, named_parameters
flavor_id = id(flavor)
if flavor_id not in _decl_spec_cache:
has_indicator = "parameter_line_indicator" in flavor
param_line_indicator_re = (
_get_compiled(flavor["parameter_line_indicator"]) if has_indicator else None
)
param_declare_re = _get_compiled(flavor["parameter_declare_re"])
_decl_spec_cache[flavor_id] = (
param_line_indicator_re,
param_declare_re,
has_indicator,
)
param_line_re, param_decl_re, has_indicator = _decl_spec_cache[flavor_id]
if has_indicator:
if not (param_line_re.search(line) if param_line_re is not None else None):
return False, named_parameters
parameters = param_decl_re.findall(line) if param_decl_re is not None else []
if len(parameters) == 0:
return False, named_parameters
for key, val in parameters:
named_parameters[key] = eval(val)
return True, named_parameters
[docs]
def line_insert_parameters(
line: str, named_parameters: dict = {}, flavor: dict = syntax.flavors["default"]
):
"""Uses syntax definition to identify named parameters in line. Replaces with values
Args:
line (str): line (block) from a G-code program
named_parameters (dict): Named parameter definition
flavor (dict, optional): Syntax definition for line. Defaults to syntax.flavors["default"].
Returns:
str: Modified line
"""
# if the gcode syntax does not support parameters, ignore
if not flavor["parameter_re"]:
return line
# Cached per-flavor: param_re and optional preset_re (CEAD only)
flavor_id = id(flavor)
if flavor_id not in _param_spec_cache:
param_re = _get_compiled(flavor["parameter_re"])
preset_re = (
_get_compiled(r"(PRESETON\(.*\))")
if flavor is syntax.flavors["cead"]
else None
)
_param_spec_cache[flavor_id] = (param_re, preset_re)
param_re, preset_re = _param_spec_cache[flavor_id]
if param_re is None:
return line
# Quick check: skip entirely when no parameters are declared, or none appear in the line.
# Skip True-valued placeholder entries (added below to flag undeclared identifiers — they're
# not real parameters and including them in the substring check causes false hits, e.g. a
# placeholder "EL" from a TRANS line matches every "ELX=..." motion line).
if not named_parameters:
return line
for _k, _v in named_parameters.items():
if _v is True:
continue
if _k in line:
break
else:
return line
# Strip all words, comments, and commands from the line
stripped_line = line
# Pull out the PRESETON in CEAD lines
if preset_re is not None:
stripped_line = preset_re.sub("", stripped_line)
# Strip words, comments, and function expressions using precomputed spec.
for compiled_re, repl in _get_strip_spec(flavor):
stripped_line = compiled_re.sub(repl, stripped_line)
# Get all the parameters
all_parameters = param_re.findall(stripped_line)
# Parameter substitution - only do string replaces if we found parameters
if all_parameters:
for parameter in all_parameters:
if parameter in named_parameters:
clean_parameter = flavor["parameter"].replace("PARAM", parameter)
line = line.replace(clean_parameter, str(named_parameters[parameter]))
else:
named_parameters[parameter] = True
return line
# Used to replace functions with just their inner functions
[docs]
def replacement_function(match):
groups = match.groups()
if len(groups) >= 3:
return groups[2]
else:
return ""
[docs]
def line_insert_generic_expressions(
line: str, flavor: dict = syntax.flavors["default"]
):
"""Inserts the evaluation of a generic expression into the line, such as [1+1], recursively.
Args:
line (str): line (block) for a G-code program
flavor (dict, optional): Syntax definition for line. Defaults to syntax.flavors["default"].
Returns:
str: Modified line
Reference:
https://fabricesalvaire.github.io/pythonic-gcode-machine/gcode-reference/rs-274/index.html#expressions-and-binary-operations
"""
initial_line = line
# if gcode syntax does not support generic expressions, ignore
generic_expr_re = flavor.get("generic_expression_re")
if not generic_expr_re:
return line
# Fast path: all expression syntaxes use brackets — skip regex scan if absent
if "(" not in line and "[" not in line:
return line
# Cache compiled comment pattern for reuse
comment_re = _get_compiled(flavor.get("comment_re"))
# NOTE: Needs to be a while loop to avoid stack overflow. Warning to user if the line isn't processed. Explicit failure probably not warranted here, the line will just be ignored
iteration_count = 0
max_iterations = 100 # Prevent infinite loops
while iteration_count < max_iterations:
iteration_count += 1
original_line = line
# Get expressions to process
if isinstance(generic_expr_re, list):
compiled_list = _get_compiled(generic_expr_re)
expressions = [compiled_re.findall(line) for compiled_re in compiled_list]
expressions = [item for sublist in expressions for item in sublist]
else:
compiled = _get_compiled(generic_expr_re)
expressions = compiled.findall(line) if compiled is not None else []
if len(expressions) == 0:
break
# Don't look into expressions inside a comment (only needed if expressions found)
comments = comment_re.findall(line) if comment_re is not None else []
no_comments = len(comments) == 0
comment_index = -1
if len(comments) == 1:
comment_index = line.find(comments[0])
# Filter out expressions in comments
if not no_comments:
expressions = [ex for ex in expressions if line.find(ex) <= comment_index]
if len(expressions) == 0:
break
# Process expressions
for match in expressions:
original_text = match
eval_expr = match.replace("[", "(").replace("]", ")").lower()
try:
result = str(eval(eval_expr))
line = line.replace(original_text, result)
except NameError:
try:
line = line_insert_function_expressions(line, flavor)
except:
NameError(
f'Failed to evaluate expression "{eval_expr}" from text {original_text}'
)
except:
raise NameError(
f'Failed to evaluate expression "{eval_expr}" from text {original_text}'
)
# TERMINATION CHECK
if line == original_line:
logging.warning(
f"gcode_reader.preprocess.line_insert_generic_expressions: Generic expression loop failed to fully process the line: {initial_line}. processed to: {line}.Terminating loop."
)
break
return line
def _cead_function_handler(fn: str, inner_expression: str):
if fn == "ic":
return None, inner_expression
elif fn == "preseton":
return None, "0"
elif fn == "device":
return None, "0"
return fn, inner_expression
def _ingersoll_function_handler(fn: str, inner_expression: str):
if fn == "extruder":
return None, f"E{inner_expression}"
elif fn == "extruderb":
val = int((inner_expression.strip().lower() == "true"))
return None, f"E{val}"
elif fn == "ca":
# inner expression might need evaluation if it includes division or multiplication
inner_expression = eval(inner_expression)
return None, f"CA{inner_expression}"
return fn, inner_expression
[docs]
def line_insert_function_expressions(line, flavor: dict = syntax.flavors["default"]):
"""Inserts the evaluation of a functional expression into the line, such as COS[213], recursively.
Args:
line (str): line (block) for a G-code program
flavor (dict, optional): Syntax definition for line. Defaults to syntax.flavors["default"].
Returns:
str: Modified line
Reference:
https://fabricesalvaire.github.io/pythonic-gcode-machine/gcode-reference/rs-274/index.html#expressions-and-binary-operations
"""
# if gcode syntax does not support functions then ignore
if not flavor["function_expression_re"]:
return line
# Fast path: if all patterns require literal brackets in the matched text, skip when absent.
# Computed once per flavor. A pattern requires brackets iff it contains \( or \[.
flavor_id = id(flavor)
if flavor_id not in _fn_expr_bracket_fast_path:
function_expr_re = flavor["function_expression_re"]
patterns = (
function_expr_re
if isinstance(function_expr_re, list)
else [function_expr_re]
)
safe = all(
(
(r"\(" in p or r"\[" in p)
if isinstance(p, str)
else (r"\(" in p.pattern or r"\[" in p.pattern)
)
for p in patterns
)
_fn_expr_bracket_fast_path[flavor_id] = safe
if _fn_expr_bracket_fast_path[flavor_id] and "(" not in line and "[" not in line:
return line
initial_line = line
while True:
original_line = line
function_expr_re = flavor.get("function_expression_re")
if function_expr_re is None:
functional_expressions = []
elif isinstance(function_expr_re, list):
compiled_list = _get_compiled(function_expr_re)
functional_expressions = [
compiled_re.findall(line) for compiled_re in compiled_list
]
functional_expressions = [
item for sublist in functional_expressions for item in sublist
]
else:
compiled = _get_compiled(function_expr_re)
functional_expressions = (
compiled.findall(line) if compiled is not None else []
)
if len(functional_expressions) == 0:
return line
for func in functional_expressions:
text, fn, inner_expression = func
fn = fn.lower() # Must ensure lowercase
if flavor == syntax.flavors["cead"]:
# Check these before recursion because the "PRESETON" has a comma in it that causes infinite recursion
fn, inner_expression = _cead_function_handler(fn, inner_expression)
elif flavor == syntax.flavors["ingersoll"]:
fn, inner_expression = _ingersoll_function_handler(fn, inner_expression)
# Inner expression might be an expression itself
inner_expression = line_insert_generic_expressions(inner_expression, flavor)
if fn is None:
line = line.replace(
text, inner_expression, 1
) # NOTE: count=1 prevents the line from replacing all occurances, restricts to only the first
continue
# Typically inner expression is in degrees. However, need to convert to radians
# NOTE: This assumes radians. What happens if its degrees?
if fn in ["cos", "acos", "sin", "asin", "tan", "atan"]:
inner_expression = f"deg2rad({inner_expression})"
eval_expression = f"{fn}({inner_expression})"
try:
val = eval(eval_expression)
line = line.replace(text, str(val))
except:
raise ValueError(
f'Failed to evaluate expression "{eval_expression}" from text {text}'
)
# TERMINATION CHECK
if line == original_line:
logging.warning(
f"gcode_reader.preprocess.line_insert_function_expressions: Function expression loop failed to fully process the line: {initial_line}. processed to: {line}.Terminating loop."
)
break
return line