From bedc33c7d3c8c5fcfc3eeb84cc416c4c9a3d6829 Mon Sep 17 00:00:00 2001 From: Nicolas Predella Date: Wed, 26 Aug 2026 12:02:01 +0300 Subject: [PATCH] ref --- core/geometry.py | 26 +- core/nodeslicer/nodes/base.py | 45 ++-- core/nodeslicer/nodes/mesh.py | 72 +---- core/nodeslicer/nodes/output.py | 35 +-- core/nodeslicer/nodes/preview.py | 6 +- core/pdf_exporter/geometry.py | 24 +- core/slice.py | 9 +- infra/_3mf_writer/_3mf.py | 15 +- infra/blender/blender_mesh_capture.py | 25 +- infra/networking/gcode_utils.py | 5 + infra/prusaslicer_profiles/parser.py | 366 ++++++++------------------ tests/test_profiles.py | 51 +++- 12 files changed, 225 insertions(+), 454 deletions(-) diff --git a/core/geometry.py b/core/geometry.py index 1b53405..b455de7 100644 --- a/core/geometry.py +++ b/core/geometry.py @@ -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 \ No newline at end of file diff --git a/core/nodeslicer/nodes/base.py b/core/nodeslicer/nodes/base.py index c8e5c33..6742f15 100644 --- a/core/nodeslicer/nodes/base.py +++ b/core/nodeslicer/nodes/base.py @@ -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): diff --git a/core/nodeslicer/nodes/mesh.py b/core/nodeslicer/nodes/mesh.py index dccb480..08db20f 100644 --- a/core/nodeslicer/nodes/mesh.py +++ b/core/nodeslicer/nodes/mesh.py @@ -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 diff --git a/core/nodeslicer/nodes/output.py b/core/nodeslicer/nodes/output.py index 710c7c9..960b594 100644 --- a/core/nodeslicer/nodes/output.py +++ b/core/nodeslicer/nodes/output.py @@ -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 diff --git a/core/nodeslicer/nodes/preview.py b/core/nodeslicer/nodes/preview.py index c679f00..4de1ad3 100644 --- a/core/nodeslicer/nodes/preview.py +++ b/core/nodeslicer/nodes/preview.py @@ -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: diff --git a/core/pdf_exporter/geometry.py b/core/pdf_exporter/geometry.py index c923534..123c40c 100644 --- a/core/pdf_exporter/geometry.py +++ b/core/pdf_exporter/geometry.py @@ -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: diff --git a/core/slice.py b/core/slice.py index 018b5ad..b4f65fa 100644 --- a/core/slice.py +++ b/core/slice.py @@ -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) diff --git a/infra/_3mf_writer/_3mf.py b/infra/_3mf_writer/_3mf.py index 66e56b7..743e9de 100644 --- a/infra/_3mf_writer/_3mf.py +++ b/infra/_3mf_writer/_3mf.py @@ -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) diff --git a/infra/blender/blender_mesh_capture.py b/infra/blender/blender_mesh_capture.py index 7a5b3be..b1951ca 100644 --- a/infra/blender/blender_mesh_capture.py +++ b/infra/blender/blender_mesh_capture.py @@ -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 diff --git a/infra/networking/gcode_utils.py b/infra/networking/gcode_utils.py index 43fe1d2..9c2b939 100644 --- a/infra/networking/gcode_utils.py +++ b/infra/networking/gcode_utils.py @@ -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 diff --git a/infra/prusaslicer_profiles/parser.py b/infra/prusaslicer_profiles/parser.py index 1c5c01f..f87e05c 100644 --- a/infra/prusaslicer_profiles/parser.py +++ b/infra/prusaslicer_profiles/parser.py @@ -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}') diff --git a/tests/test_profiles.py b/tests/test_profiles.py index 991420d..16d9d3e 100644 --- a/tests/test_profiles.py +++ b/tests/test_profiles.py @@ -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()