This commit is contained in:
Nicolas Predella 2026-08-26 12:02:01 +03:00
parent b6f3293303
commit bedc33c7d3
12 changed files with 225 additions and 454 deletions

View file

@ -16,12 +16,22 @@ def crc32_list(l: list[np.ndarray]) -> int:
h = zlib.adler32(struct.pack(">I", crc32_array(a)), h)
return h
def euler_rotation_matrix(rx: float, ry: float, rz: float) -> np.ndarray:
"""Computes a 3x3 rotation matrix (Rz @ Ry @ Rx) from Euler angles in radians."""
cx, sx = np.cos(rx), np.sin(rx)
cy, sy = np.cos(ry), np.sin(ry)
cz, sz = np.cos(rz), np.sin(rz)
Rx = np.array([[1.0, 0.0, 0.0], [0.0, cx, -sx], [0.0, sx, cx]], dtype=np.float64)
Ry = np.array([[cy, 0.0, sy], [0.0, 1.0, 0.0], [-sy, 0.0, cy]], dtype=np.float64)
Rz = np.array([[cz, -sz, 0.0], [sz, cz, 0.0], [0.0, 0.0, 1.0]], dtype=np.float64)
return Rz @ (Ry @ Rx)
from collections import OrderedDict
def ndarray_lru_cache(maxsize: int = 128) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
def decorator(func: Callable[..., Any]) -> Callable[..., Any]:
cache = {}
order = []
cache: OrderedDict[tuple[Any, ...], Any] = OrderedDict()
def wrapper(*args, **kwargs):
# Create a key by hashing any ndarray arguments
key_parts = []
for arg in args:
if isinstance(arg, np.ndarray):
@ -40,17 +50,13 @@ def ndarray_lru_cache(maxsize: int = 128) -> Callable[[Callable[..., Any]], Call
key = tuple(key_parts)
if key in cache:
# Move to end for LRU
if key in order: order.remove(key)
order.append(key)
cache.move_to_end(key)
return cache[key]
result = func(*args, **kwargs)
cache[key] = result
order.append(key)
if len(order) > maxsize:
oldest = order.pop(0)
del cache[oldest]
if len(cache) > maxsize:
cache.popitem(last=False)
return result
return wrapper
return decorator

View file

@ -56,6 +56,24 @@ class InputNode(SliceNode):
if out is None: return None
return getattr(out, "value", getattr(out, "default_value", None))
_MATH_OPS: dict[str, Any] = {
'ADD': lambda a, b, t: a + b,
'SUBTRACT': lambda a, b, t: a - b,
'MULTIPLY': lambda a, b, t: a * b,
'DIVIDE': lambda a, b, t: a // b if b != 0 else 0,
'ABS': lambda a, b, t: abs(a),
'NEGATE': lambda a, b, t: -a,
'POWER': lambda a, b, t: t(a ** b) if not (a == 0 and b < 0) else 0,
'MIN': lambda a, b, t: min(a, b),
'MAX': lambda a, b, t: max(a, b),
'SIGN': lambda a, b, t: (a > 0) - (a < 0),
'DIVIDE_ROUND': lambda a, b, t: t(round(a / b)) if b != 0 else 0,
'DIVIDE_FLOOR': lambda a, b, t: a // b if b != 0 else 0,
'DIVIDE_TRUNCATE': lambda a, b, t: t(a / b) if b != 0 else 0,
'MODULO_FLOORED': lambda a, b, t: a % b if b != 0 else 0,
'MODULO_TRUNCATED': lambda a, b, t: a - (b * t(a / b)) if b != 0 else 0,
}
class MathNode(SliceNode):
bl_label = "Float Math"
@ -97,29 +115,14 @@ class MathNode(SliceNode):
def evaluate_output(self, socket_name: str, **_kwargs) -> math_type:
t = self.math_type
a: t = resolve_input(self.inputs[0]) or 0
b: t = resolve_input(self.inputs[1]) or 0
op = self.operation
try:
if op == 'ADD': return a + b
if op == 'SUBTRACT': return a - b
if op == 'MULTIPLY': return a * b
if op == 'DIVIDE': return a // b if b != 0 else 0
if op == 'ABS': return abs(a)
if op == 'NEGATE': return -a
if op == 'POWER': return t(a ** b) if not (a == 0 and b < 0) else 0
if op == 'MIN': return min(a, b)
if op == 'MAX': return max(a, b)
if op == 'SIGN': return (a > 0) - (a < 0)
if op == 'DIVIDE_ROUND': return t(round(a / b)) if b != 0 else 0
if op == 'DIVIDE_FLOOR': return a // b if b != 0 else 0
if op == 'DIVIDE_TRUNCATE': return t(a / b) if b != 0 else 0
if op == 'MODULO_FLOORED': return a % b if b != 0 else 0
if op == 'MODULO_TRUNCATED': return a - (b * t(a / b)) if b != 0 else 0
except Exception:
return 0
op_fn = _MATH_OPS.get(self.operation)
if op_fn:
try:
return op_fn(a, b, t)
except Exception:
return 0
return 0
class SliceNodeJoiner(SliceNode):

View file

@ -6,6 +6,7 @@ from ..bpy_ns_types import NfBoolProperty, NfEnumProperty, NfIntProperty, NfFloa
from ....registry import register_class
from .base import SliceNode, SlicingCollection, get_all_children, SliceNodeJoiner, SlicingGroup, SlicingObject, SlicePaths
from ....infra.blender.blender_mesh_capture import PdfProperties
from ....core.pdf_exporter.geometry import transform_slice_paths
@register_class
class SLICENODE_GetMesh(SliceNode):
@ -454,76 +455,11 @@ def _copy_slicing_group(sg: SlicingGroup) -> SlicingGroup:
return new_sg
def _transform_slice_paths(sp: SlicePaths, t_arr: np.ndarray, r_arr: np.ndarray, s_arr: np.ndarray) -> SlicePaths:
new_paths = []
has_scale = any(s != 1.0 for s in s_arr)
has_rot = any(r != 0.0 for r in r_arr)
has_trans = any(t != 0.0 for t in t_arr)
if not (has_scale or has_rot or has_trans):
return sp.copy_with(paths=list(sp))
rot_mat: np.ndarray | None = None
if has_rot:
rx, ry, rz = r_arr
cx, sx = np.cos(rx), np.sin(rx)
cy, sy = np.cos(ry), np.sin(ry)
cz, sz = np.cos(rz), np.sin(rz)
Rx = np.array([
[1.0, 0.0, 0.0],
[0.0, cx, -sx],
[0.0, sx, cx]
], dtype=np.float64)
Ry = np.array([
[cy, 0.0, sy],
[0.0, 1.0, 0.0],
[-sy, 0.0, cy]
], dtype=np.float64)
Rz = np.array([
[cz, -sz, 0.0],
[sz, cz, 0.0],
[0.0, 0.0, 1.0]
], dtype=np.float64)
rot_mat = Rz @ (Ry @ Rx)
for p in sp:
if len(p) == 0:
new_paths.append(p.copy())
continue
new_p = p.astype(np.float64, copy=True)
dim = new_p.shape[1] if new_p.ndim > 1 else 0
if has_scale and dim > 0:
if dim >= 3:
new_p[:, :3] *= s_arr
else:
new_p[:, :dim] *= s_arr[:dim]
if has_rot and rot_mat is not None and dim > 0:
if dim >= 3:
new_p[:, :3] = new_p[:, :3] @ rot_mat.T
elif dim == 2:
rot_2d = rot_mat[:2, :2]
new_p[:, :2] = new_p[:, :2] @ rot_2d.T
if has_trans and dim > 0:
if dim >= 3:
new_p[:, :3] += t_arr
else:
new_p[:, :dim] += t_arr[:dim]
new_paths.append(new_p)
return sp.copy_with(paths=new_paths)
def _transform_geometry_item(geom: Any, t_arr: np.ndarray, r_arr: np.ndarray, s_arr: np.ndarray, t_rec: dict[str, Any]) -> Any:
if geom is None:
return None
elif isinstance(geom, SlicePaths):
copied_data = _transform_slice_paths(geom, t_arr, r_arr, s_arr)
copied_data = transform_slice_paths(geom, t_arr, r_arr, s_arr)
if not hasattr(copied_data, "transforms") or copied_data.transforms is None:
copied_data.transforms = []
else:
@ -598,12 +534,10 @@ class SLICENODE_TransformGeometry(SliceNode):
self.add_output("SLICESOCK_Geometry", "Geometry")
def evaluate_output(self, socket_name: str, **_kwargs) -> Any:
if socket_name not in ("Geometry", "Mesh"):
if socket_name not in ("Geometry"):
return None
geom_data: Any = self.resolve("Geometry")
if geom_data is None:
geom_data = self.resolve("Mesh")
if geom_data is None:
return None

View file

@ -187,7 +187,7 @@ class ExecSliceNode_DatabaseSubmit(ExecSliceNode):
def evaluate(self, operator=None):
from ....infra.networking.bgcode import convert_to_bgcode
from ....infra.networking.gcode_utils import get_gcode_name_from_metadata
from ....infra.networking.gcode_utils import get_gcode_name_from_metadata, parse_gcode_metadata
gcodes: list[str] = self.resolve_multi_input("Gcode")
@ -196,40 +196,9 @@ class ExecSliceNode_DatabaseSubmit(ExecSliceNode):
continue
# Parse Metadata from Gcode
fields = {}
part_name = get_gcode_name_from_metadata(gc)
fields = parse_gcode_metadata(gc)
fields["Part ID / Name"] = part_name
# 1. Time per print (decimal hours)
time_match = re.search(r"; estimated printing time \(normal mode\) = (.*)", gc)
if time_match:
time_str = time_match.group(1)
h = re.search(r"(\d+)h", time_str)
m = re.search(r"(\d+)m", time_str)
s = re.search(r"(\d+)s", time_str)
total_hours = 0.0
if h: total_hours += float(h.group(1))
if m: total_hours += float(m.group(1)) / 60.0
if s: total_hours += float(s.group(1)) / 3600.0
fields["Time per print"] = round(total_hours, 2)
# 2. Parts per print
parts_match = re.search(r"; objects_info = (.*)", gc)
if parts_match:
try:
info = json.loads(parts_match.group(1))
fields["Parts per print"] = len(info.get("objects", []))
except Exception: pass
# 3. Weight per print
weight_match = re.search(r"; total filament used \[g\] = ([\d\.]+)", gc)
if weight_match:
fields["Weight per print"] = int(float(weight_match.group(1)))
# 4. Profile for reference
profile_match = re.search(r"; printer_settings_id = (.*)", gc)
if profile_match:
fields["Printer Profile"] = profile_match.group(1)
# Detect binary trigger in gcode comments
is_binary = "; binary_gcode = 1" in gc

View file

@ -1,5 +1,6 @@
from __future__ import annotations
from typing import Any
import math
import bpy
import os
@ -51,9 +52,6 @@ class SLICENODE_GcodeToMesh(ExecSliceNode):
last_v_idx = -1
last_p2 = None
def segment_length_3d(p1, p2) -> float:
return ((p2[0]-p1[0])**2 + (p2[1]-p1[1])**2 + (p2[2]-p1[2])**2)**0.5
prep_translation = [0.0, 0.0, 0.0]
prep_rotation = [0.0, 0.0, 0.0]
prep_scale = [1.0, 1.0, 1.0]
@ -127,7 +125,7 @@ class SLICENODE_GcodeToMesh(ExecSliceNode):
p2 = (new_x - ox, new_y - oy, new_z)
# Calculate e_rate (proportion between E and default_extrusion_rate)
seg_len_mm = segment_length_3d(p1, p2) * (1000.0 * scale_length)
seg_len_mm = math.dist(p1, p2) * (1000.0 * scale_length)
if seg_len_mm > 0 and custom_default_extrusion_rate > 0:
e_rate = (new_e / seg_len_mm) / custom_default_extrusion_rate
else:

View file

@ -7,6 +7,8 @@ from typing import Any
import numpy as np
from numpy.typing import NDArray
from ..geometry import euler_rotation_matrix
def deduplicate_2d_edges(edges_2d: NDArray[np.float64]) -> NDArray[np.float64]:
"""
Deduplicates 2D edge segments regardless of direction (A->B or B->A).
@ -54,27 +56,7 @@ def transform_slice_paths(
rot_mat: np.ndarray | None = None
if has_rot:
rx, ry, rz = r_arr
cx, sx = np.cos(rx), np.sin(rx)
cy, sy = np.cos(ry), np.sin(ry)
cz, sz = np.cos(rz), np.sin(rz)
Rx = np.array([
[1.0, 0.0, 0.0],
[0.0, cx, -sx],
[0.0, sx, cx]
], dtype=np.float64)
Ry = np.array([
[cy, 0.0, sy],
[0.0, 1.0, 0.0],
[-sy, 0.0, cy]
], dtype=np.float64)
Rz = np.array([
[cz, -sz, 0.0],
[sz, cz, 0.0],
[0.0, 0.0, 1.0]
], dtype=np.float64)
rot_mat = Rz @ (Ry @ Rx)
rot_mat = euler_rotation_matrix(r_arr[0], r_arr[1], r_arr[2])
new_paths = []
for p in sp:

View file

@ -1,4 +1,5 @@
from __future__ import annotations
import math
from numpy import float64
import numpy as np
import pyclipper
@ -1643,12 +1644,6 @@ class Pather(object):
new_path[:, 1] += offset_y
centered_paths.append(new_path)
def segment_length_3d(p1, p2) -> float:
dx = p2[0] - p1[0]
dy = p2[1] - p1[1]
dz = p2[2] - p1[2]
return (dx * dx + dy * dy + dz * dz) ** 0.5
try:
retraction_fr_val = float(self.retraction_feedrate) if self.retraction_feedrate is not None else 0.0
except (TypeError, ValueError):
@ -1805,7 +1800,7 @@ class Pather(object):
old_x, old_y, old_z = x0, y0, z0
old_extras = {prefix: None for prefix in extras}
for j, (p1, p2) in enumerate(zip(coords, coords[1:])):
seg_len: float = segment_length_3d(p1, p2)
seg_len: float = math.dist(p1, p2)
e_segment = seg_len * self.extrusion_rate * e_rate[j]
# Per-segment feedrate value (not multiplier)

View file

@ -12,19 +12,6 @@ from ..blender.blender_mesh_capture import SlicingObject, SlicingGroup, SlicingC
script_dir = os.path.dirname(os.path.abspath(__file__))
def indent(elem, level=0):
i = "\n" + level * " "
if len(elem):
if not elem.text or not elem.text.strip():
elem.text = i + " "
for child in elem:
indent(child, level + 1)
if not elem.tail or not elem.tail.strip():
elem.tail = i
else:
if level and (not elem.tail or not elem.tail.strip()):
elem.tail = i
def write_metadata_xml(group: SlicingGroup, filepath):
# Custom sorting order for object types
object_type_order = {
@ -65,7 +52,7 @@ def write_metadata_xml(group: SlicingGroup, filepath):
ET.SubElement(volume_elem, "metadata", type="volume", key="matrix", value="1 0 0 0 0 1 0 0 0 0 1 0 0 0 0 1")
ET.SubElement(volume_elem, "mesh", edges_fixed="0", degenerate_facets="0", facets_removed="0", facets_reversed="0", backwards_edges="0")
indent(xml_content)
ET.indent(xml_content)
xml_tree = ET.ElementTree(xml_content)
xml_tree.write(filepath, encoding="UTF-8", xml_declaration=True)

View file

@ -11,7 +11,7 @@ from numpy.typing import NDArray
from typing import Any
from ...core.geometry import crc32_array
from ...core.geometry import crc32_array, euler_rotation_matrix
from .blender_bridge import get_all_children
TYPES_NAME = 'DEFAULT'
@ -202,28 +202,7 @@ class SlicingObject():
def rotate(self, angles: NDArray[Any]):
# angles: array-like of 3 floats (rx, ry, rz) in radians
angles_arr = np.array(angles, dtype=np.float64)
rx, ry, rz = angles_arr
cx, sx = np.cos(rx), np.sin(rx)
cy, sy = np.cos(ry), np.sin(ry)
cz, sz = np.cos(rz), np.sin(rz)
Rx = np.array([
[1.0, 0.0, 0.0],
[0.0, cx, -sx],
[0.0, sx, cx]
], dtype=np.float64)
Ry = np.array([
[cy, 0.0, sy],
[0.0, 1.0, 0.0],
[-sy, 0.0, cy]
], dtype=np.float64)
Rz = np.array([
[cz, -sz, 0.0],
[sz, cz, 0.0],
[0.0, 0.0, 1.0]
], dtype=np.float64)
R = Rz @ (Ry @ Rx)
R = euler_rotation_matrix(angles_arr[0], angles_arr[1], angles_arr[2])
# Apply to vertices: shape (N, 3, 3)
self.mesh[:, :3, :] = self.mesh[:, :3, :] @ R.T

View file

@ -33,6 +33,11 @@ def parse_gcode_metadata(gcode: str) -> dict[str, Any]:
weight_match = re.search(r"; total filament used \[g\] = ([\d\.]+)", gcode)
if weight_match:
fields["Weight per print"] = int(float(weight_match.group(1)))
# 4. Profile for reference
profile_match = re.search(r"; printer_settings_id = (.*)", gcode)
if profile_match:
fields["Printer Profile"] = profile_match.group(1)
return fields

View file

@ -1,291 +1,159 @@
from __future__ import annotations
import operator
import re
from typing import TYPE_CHECKING, TypeAlias, Any, cast
from typing import Any
if TYPE_CHECKING:
Token: TypeAlias = tuple[str, str]
TOKENS = [
('OP', r'==|!=|<=|>=|=~|!~|&&|\|\||[()\[\]!><]'),
('REGEX', r'/((?:\\/|[^/])*)/'),
('STRING', r'"(?:[^"\\]|\\.)*"|\'(?:[^\'\\]|\\.)*\''),
('NUMBER', r'\d+(?:\.\d+)?'),
('IDENT', r'[a-zA-Z_]\w*'),
]
TOKEN_RE = re.compile('|'.join(f'(?P<{name}>{pattern})' for name, pattern in TOKENS))
# === AST Node Definitions ===
_ops = {
"==": (str, operator.eq),
"!=": (str, operator.ne),
"<": (float, operator.lt),
">": (float, operator.gt),
"<=": (float, operator.le),
">=": (float, operator.ge),
_OPS = {
'==': (str, operator.eq),
'!=': (str, operator.ne),
'<': (float, operator.lt),
'>': (float, operator.gt),
'<=': (float, operator.le),
'>=': (float, operator.ge),
}
def _tokenize(text: str) -> list[tuple[str, Any]]:
tokens: list[tuple[str, Any]] = []
for m in TOKEN_RE.finditer(text):
kind: str = m.lastgroup if m.lastgroup else ""
val = m.group(kind)
if kind == 'OP':
if val == '&&': tokens.append(('OP', 'and'))
elif val == '||': tokens.append(('OP', 'or'))
elif val in ('(', ')', '[', ']'): tokens.append((val, val))
else: tokens.append(('OP', val))
elif kind == 'REGEX':
tokens.append(('REGEX', re.compile(val[1:-1])))
elif kind == 'STRING':
tokens.append(('LIT', val[1:-1]))
elif kind == 'NUMBER':
tokens.append(('LIT', val))
elif kind == 'IDENT':
if val == 'and': tokens.append(('OP', 'and'))
elif val == 'or': tokens.append(('OP', 'or'))
else: tokens.append(('IDENT', val))
return tokens
class ExprNode:
"""Base class for all expression nodes in the AST."""
def eval(self, context: dict[str, Any]) -> Any:
raise NotImplementedError("Must implement eval in subclass")
class LiteralNode(ExprNode):
"""Represents a literal value (string, number, or regex pattern)."""
def __init__(self, value: Any) -> None:
self.value = value
def eval(self, context: dict[str, Any]) -> Any:
return self.value
class VarNode(ExprNode):
"""Represents a variable look-up in the configuration context."""
def __init__(self, name: str) -> None:
self.name = name
"""Represents an expression node in the AST."""
def __init__(self, node: tuple[Any, ...]) -> None:
self.node = node
def eval(self, context: dict[str, Any]) -> Any:
return context.get(self.name, '0')
return self._eval_node(self.node, context)
class IndexNode(ExprNode):
"""Represents an indexed variable look-up (e.g., nozzle_diameter[0])."""
def __init__(self, name: str, index: int) -> None:
self.name = name
self.index = index
def eval(self, context: dict[str, Any]) -> Any:
val = str(context.get(self.name, '0'))
parts = val.split(',')
if self.index >= len(parts):
return '0'
return parts[self.index].strip()
class UnaryOpNode(ExprNode):
"""Represents a unary operator (e.g., !)."""
def __init__(self, op: str, child: ExprNode) -> None:
self.op = op
self.child = child
def eval(self, context: dict[str, Any]) -> Any:
cval = self.child.eval(context)
if isinstance(cval, re.Pattern):
raise TypeError("Unary operator cannot be applied to a regex pattern.")
if self.op == "!":
@staticmethod
def _eval_node(node: tuple[Any, ...], context: dict[str, Any]) -> Any:
ntype = node[0]
if ntype == 'lit':
return node[1]
if ntype == 'var':
return context.get(node[1], '0')
if ntype == 'index':
val = str(context.get(node[1], '0'))
parts = val.split(',')
idx = node[2]
return parts[idx].strip() if idx < len(parts) else '0'
if ntype == 'unary':
cval = ExprNode._eval_node(node[2], context)
try:
return not float(cval)
except (ValueError, TypeError):
return not bool(cval)
raise RuntimeError(f"Unknown unary operator: {self.op}")
class BinaryOpNode(ExprNode):
"""Represents a binary operator (e.g., ==, &&, =~)."""
def __init__(self, left: ExprNode, op: str, right: ExprNode) -> None:
self.left = left
self.op = op
self.right = right
def eval(self, context: dict[str, Any]) -> Any:
# Short-circuiting for logical operators
if self.op in ("and", "&&"):
lv = self.left.eval(context)
if not lv or lv == '0':
return False
return self.right.eval(context)
if self.op in ("or", "||"):
lv = self.left.eval(context)
if lv and lv != '0':
return True
return self.right.eval(context)
lv = self.left.eval(context)
rv = self.right.eval(context)
# Standard comparisons
if self.op in _ops:
caster, func = _ops[self.op]
if isinstance(lv, re.Pattern) or isinstance(rv, re.Pattern):
raise RuntimeError(f"Operands of {self.op} cannot be regex patterns.")
try:
return func(caster(lv), caster(rv))
except (ValueError, TypeError):
# Fallback to string comparison if cast fails
return func(str(lv), str(rv))
# Regex matching
if self.op == "=~":
if not isinstance(rv, re.Pattern):
raise TypeError("Right operand of =~ must be a regex pattern.")
pattern_str = cast(re.Pattern[str], rv)
return pattern_str.search(str(lv)) is not None
if self.op == "!~":
if not isinstance(rv, re.Pattern):
raise TypeError("Right operand of !~ must be a regex pattern.")
pattern_str = cast(re.Pattern[str], rv)
return pattern_str.search(str(lv)) is None
raise RuntimeError(f"Unknown binary operator: {self.op}")
if ntype == 'bin':
op, left, right = node[1], node[2], node[3]
if op == 'and':
lv = ExprNode._eval_node(left, context)
if not lv or lv == '0': return False
return ExprNode._eval_node(right, context)
if op == 'or':
lv = ExprNode._eval_node(left, context)
if lv and lv != '0': return True
return ExprNode._eval_node(right, context)
lv = ExprNode._eval_node(left, context)
rv = ExprNode._eval_node(right, context)
if op in _OPS:
caster, func = _OPS[op]
try:
return func(caster(lv), caster(rv))
except (ValueError, TypeError):
return func(str(lv), str(rv))
if op == '=~':
return rv.search(str(lv)) is not None
if op == '!~':
return rv.search(str(lv)) is None
return '0'
class Parser:
"""Recursive descent parser for PrusaSlicer expressions."""
def __init__(self, text: str):
self.text = text
self.tokens = self._tokenize(text)
def __init__(self, text: str) -> None:
self.tokens = _tokenize(text)
self.pos = 0
def _tokenize(self, text: str) -> list[Token]:
tokens: list[Token] = []
i = 0
length = len(text)
while i < length:
c = text[i]
if c.isspace():
i += 1
continue
# Fixed tokens
if c == "(": tokens.append(("LPAREN", "(")); i += 1; continue
if c == ")": tokens.append(("RPAREN", ")")); i += 1; continue
if c == "[": tokens.append(("LBRACK", "[")); i += 1; continue
if c == "]": tokens.append(("RBRACK", "]")); i += 1; continue
# Multi-character operators
if text.startswith("==", i): tokens.append(("OP", "==")); i += 2; continue
if text.startswith("!=", i): tokens.append(("OP", "!=")); i += 2; continue
if text.startswith(">=", i): tokens.append(("OP", ">=")); i += 2; continue
if text.startswith("<=", i): tokens.append(("OP", "<=")); i += 2; continue
if text.startswith("=~", i): tokens.append(("OP", "=~")); i += 2; continue
if text.startswith("!~", i): tokens.append(("OP", "!~")); i += 2; continue
if text.startswith("&&", i): tokens.append(("OP", "and")); i += 2; continue
if text.startswith("||", i): tokens.append(("OP", "or")); i += 2; continue
if c == ">": tokens.append(("OP", ">")); i += 1; continue
if c == "<": tokens.append(("OP", "<")); i += 1; continue
if c == "!": tokens.append(("OP", "!")); i += 1; continue
# Regex literal
if c == "/":
j = i + 1
while j < length:
if text[j] == "/" and text[j-1] != "\\":
break
j += 1
if j >= length:
raise SyntaxError("Unterminated regex literal.")
tokens.append(("REGEX", text[i+1:j]))
i = j + 1
continue
# String literal
if c in ("'", '"'):
quote = c
j = i + 1
while j < length:
if text[j] == quote and text[j-1] != "\\":
break
j += 1
if j >= length:
raise SyntaxError("Unterminated string literal.")
tokens.append(("STRING", text[i+1:j]))
i = j + 1
continue
# Number
if c.isdigit():
j = i
while j < length and (text[j].isdigit() or text[j] == "."):
j += 1
tokens.append(("NUMBER", text[i:j]))
i = j
continue
# Identifier or Logical keyword
if c.isalpha() or c == "_":
j = i
while j < length and (text[j].isalnum() or text[j] == "_"):
j += 1
ident = text[i:j]
if ident == "and": tokens.append(("OP", "and"))
elif ident == "or": tokens.append(("OP", "or"))
else: tokens.append(("IDENT", ident))
i = j
continue
raise SyntaxError(f"Unexpected character in expression: {c}")
return tokens
def _peek(self) -> Token | None:
return self.tokens[self.pos] if self.pos < len(self.tokens) else None
def _advance(self) -> Token:
tok = self._peek()
if not tok:
raise SyntaxError("Unexpected end of input.")
self.pos += 1
return tok
def _expect(self, ttype: str, value: str | None = None) -> Token:
tok = self._peek()
if not tok or tok[0] != ttype or (value and tok[1] != value):
raise SyntaxError(f"Expected {ttype} {value or ''}, got {tok}")
return self._advance()
def parse(self) -> ExprNode:
node = self._parse_or()
if self._peek():
raise SyntaxError(f"Trailing tokens after expression: {self._peek()}")
return node
if self.pos < len(self.tokens):
raise SyntaxError(f'Trailing tokens: {self.tokens[self.pos:]}')
return ExprNode(node)
def _parse_or(self) -> ExprNode:
def _peek(self) -> tuple[str, Any] | None:
return self.tokens[self.pos] if self.pos < len(self.tokens) else None
def _advance(self) -> tuple[str, Any]:
tok = self.tokens[self.pos]
self.pos += 1
return tok
def _parse_or(self) -> tuple[Any, ...]:
left = self._parse_and()
while (tok := self._peek()) and tok[0] == "OP" and tok[1] == "or":
while (tok := self._peek()) and tok == ('OP', 'or'):
self._advance()
left = BinaryOpNode(left, "or", self._parse_and())
left = ('bin', 'or', left, self._parse_and())
return left
def _parse_and(self) -> ExprNode:
def _parse_and(self) -> tuple[Any, ...]:
left = self._parse_not()
while (tok := self._peek()) and tok[0] == "OP" and tok[1] == "and":
while (tok := self._peek()) and tok == ('OP', 'and'):
self._advance()
left = BinaryOpNode(left, "and", self._parse_not())
left = ('bin', 'and', left, self._parse_not())
return left
def _parse_not(self) -> ExprNode:
if (tok := self._peek()) and tok[0] == "OP" and tok[1] == "!":
def _parse_not(self) -> tuple[Any, ...]:
if (tok := self._peek()) and tok == ('OP', '!'):
self._advance()
return UnaryOpNode("!", self._parse_not())
return ('unary', '!', self._parse_not())
return self._parse_comp()
def _parse_comp(self) -> ExprNode:
def _parse_comp(self) -> tuple[Any, ...]:
left = self._parse_atom()
ops = ("==", "!=", "<", ">", "<=", ">=", "=~", "!~")
if (tok := self._peek()) and tok[0] == "OP" and tok[1] in ops:
op = tok[1]
self._advance()
left = BinaryOpNode(left, op, self._parse_atom())
if (tok := self._peek()) and tok[0] == 'OP' and tok[1] in ('==', '!=', '<', '>', '<=', '>=', '=~', '!~'):
op = self._advance()[1]
left = ('bin', op, left, self._parse_atom())
return left
def _parse_atom(self) -> ExprNode:
def _parse_atom(self) -> tuple[Any, ...]:
tok = self._advance()
ttype, val = tok
if ttype in ("STRING", "NUMBER"):
return LiteralNode(val)
if ttype == "REGEX":
try:
return LiteralNode(re.compile(val))
except re.error as e:
raise SyntaxError(f"Invalid regex /{val}/: {e}")
if ttype == "IDENT":
if (nxt := self._peek()) and nxt[0] == "LBRACK":
if ttype in ('LIT', 'REGEX'):
return ('lit', val)
if ttype == 'IDENT':
if self._peek() == ('[', '['):
self._advance()
idx = int(self._expect("NUMBER")[1])
self._expect("RBRACK")
return IndexNode(val, idx)
return VarNode(val)
if ttype == "LPAREN":
idx_tok = self._advance()
if self._advance() != (']', ']'): raise SyntaxError('Expected ]')
return ('index', val, int(idx_tok[1]))
return ('var', val)
if ttype == '(':
node = self._parse_or()
self._expect("RPAREN")
if self._advance() != (')', ')'): raise SyntaxError('Expected )')
return node
raise SyntaxError(f"Unexpected token in atom: {tok}")
raise SyntaxError(f'Unexpected token: {tok}')

View file

@ -46,16 +46,61 @@ class TestPrusaSlicerProfiles(unittest.TestCase):
self.assertEqual(res["bed_shape"].get("label"), "Bed shape")
def test_compatibility(self):
"""Verify that compatibility evaluation runs without errors."""
"""Verify that compatibility evaluation runs without errors and finds matches."""
printer_keys = {k.removeprefix("printer:") for k in self.cache.printer_profiles.keys()}
vendors = set(self.cache.vendors)
# This should not raise any exceptions
self.cache.evaluate_compatibility(printer_keys, vendors)
# Check if at least one printer has compatible profiles
has_compatible = any(p.compatible_profiles for p in self.cache.printer_profiles.values())
self.assertTrue(has_compatible, "No compatible profiles found for any printer.")
def test_all_conditions_in_baseline(self):
"""Exhaustively parse and evaluate all unique conditions in 2.4.5.ini across all printer profiles."""
from infra.prusaslicer_profiles.parser import Parser
conditions = [p.all_conf_dict.get('compatible_printers_condition') for p in self.cache.profiles.values()]
unique_conditions = sorted(list(set(c for c in conditions if c)))
self.assertGreater(len(unique_conditions), 300)
printers = [p for p in self.cache.profiles.values() if p.category == 'printer']
self.assertGreater(len(printers), 200)
total_evals = 0
positive_evals = 0
for cond_str in unique_conditions:
expr = Parser(cond_str).parse()
self.assertIsNotNone(expr)
for p in printers:
res = bool(expr.eval(p.all_conf_dict))
if res:
positive_evals += 1
total_evals += 1
self.assertEqual(total_evals, len(unique_conditions) * len(printers))
self.assertEqual(positive_evals, 3722)
def test_parser_operators(self):
"""Test individual parser operator semantics."""
from infra.prusaslicer_profiles.parser import Parser
ctx = {
"printer_model": "MK3S",
"nozzle_diameter": "0.4,0.6",
"single_extruder_multi_material": "0",
"layer_height": "0.2"
}
self.assertTrue(Parser('printer_model == "MK3S"').parse().eval(ctx))
self.assertFalse(Parser('printer_model == "MK2"').parse().eval(ctx))
self.assertTrue(Parser('printer_model =~ /MK(2|3).*/').parse().eval(ctx))
self.assertFalse(Parser('printer_model =~ /MINI/').parse().eval(ctx))
self.assertTrue(Parser('printer_model !~ /MINI/').parse().eval(ctx))
self.assertTrue(Parser('nozzle_diameter[0] == 0.4').parse().eval(ctx))
self.assertTrue(Parser('nozzle_diameter[1] == 0.6').parse().eval(ctx))
self.assertTrue(Parser('nozzle_diameter[0] < 0.5 and nozzle_diameter[1] > 0.5').parse().eval(ctx))
self.assertTrue(Parser('! single_extruder_multi_material').parse().eval(ctx))
self.assertTrue(Parser('! (printer_model == "MK2" and nozzle_diameter[0] == 0.25)').parse().eval(ctx))
if __name__ == "__main__":
unittest.main()