ref
This commit is contained in:
parent
b6f3293303
commit
bedc33c7d3
12 changed files with 225 additions and 454 deletions
|
|
@ -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
|
||||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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,41 +196,10 @@ 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
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -34,6 +34,11 @@ def parse_gcode_metadata(gcode: str) -> dict[str, Any]:
|
|||
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
|
||||
|
||||
def get_gcode_name_from_metadata(gcode: str, default: str = "output") -> str:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
"""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 self.value
|
||||
return self._eval_node(self.node, context)
|
||||
|
||||
class VarNode(ExprNode):
|
||||
"""Represents a variable look-up in the configuration context."""
|
||||
def __init__(self, name: str) -> None:
|
||||
self.name = name
|
||||
|
||||
def eval(self, context: dict[str, Any]) -> Any:
|
||||
return context.get(self.name, '0')
|
||||
|
||||
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}')
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue