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)
|
h = zlib.adler32(struct.pack(">I", crc32_array(a)), h)
|
||||||
return 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 ndarray_lru_cache(maxsize: int = 128) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
|
||||||
def decorator(func: Callable[..., Any]) -> Callable[..., Any]:
|
def decorator(func: Callable[..., Any]) -> Callable[..., Any]:
|
||||||
cache = {}
|
cache: OrderedDict[tuple[Any, ...], Any] = OrderedDict()
|
||||||
order = []
|
|
||||||
def wrapper(*args, **kwargs):
|
def wrapper(*args, **kwargs):
|
||||||
# Create a key by hashing any ndarray arguments
|
|
||||||
key_parts = []
|
key_parts = []
|
||||||
for arg in args:
|
for arg in args:
|
||||||
if isinstance(arg, np.ndarray):
|
if isinstance(arg, np.ndarray):
|
||||||
|
|
@ -40,17 +50,13 @@ def ndarray_lru_cache(maxsize: int = 128) -> Callable[[Callable[..., Any]], Call
|
||||||
|
|
||||||
key = tuple(key_parts)
|
key = tuple(key_parts)
|
||||||
if key in cache:
|
if key in cache:
|
||||||
# Move to end for LRU
|
cache.move_to_end(key)
|
||||||
if key in order: order.remove(key)
|
|
||||||
order.append(key)
|
|
||||||
return cache[key]
|
return cache[key]
|
||||||
|
|
||||||
result = func(*args, **kwargs)
|
result = func(*args, **kwargs)
|
||||||
cache[key] = result
|
cache[key] = result
|
||||||
order.append(key)
|
if len(cache) > maxsize:
|
||||||
if len(order) > maxsize:
|
cache.popitem(last=False)
|
||||||
oldest = order.pop(0)
|
|
||||||
del cache[oldest]
|
|
||||||
return result
|
return result
|
||||||
return wrapper
|
return wrapper
|
||||||
return decorator
|
return decorator
|
||||||
|
|
@ -56,6 +56,24 @@ class InputNode(SliceNode):
|
||||||
if out is None: return None
|
if out is None: return None
|
||||||
return getattr(out, "value", getattr(out, "default_value", 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):
|
class MathNode(SliceNode):
|
||||||
bl_label = "Float Math"
|
bl_label = "Float Math"
|
||||||
|
|
||||||
|
|
@ -97,29 +115,14 @@ class MathNode(SliceNode):
|
||||||
|
|
||||||
def evaluate_output(self, socket_name: str, **_kwargs) -> math_type:
|
def evaluate_output(self, socket_name: str, **_kwargs) -> math_type:
|
||||||
t = self.math_type
|
t = self.math_type
|
||||||
|
|
||||||
a: t = resolve_input(self.inputs[0]) or 0
|
a: t = resolve_input(self.inputs[0]) or 0
|
||||||
b: t = resolve_input(self.inputs[1]) or 0
|
b: t = resolve_input(self.inputs[1]) or 0
|
||||||
|
op_fn = _MATH_OPS.get(self.operation)
|
||||||
op = self.operation
|
if op_fn:
|
||||||
try:
|
try:
|
||||||
if op == 'ADD': return a + b
|
return op_fn(a, b, t)
|
||||||
if op == 'SUBTRACT': return a - b
|
except Exception:
|
||||||
if op == 'MULTIPLY': return a * b
|
return 0
|
||||||
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
|
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
class SliceNodeJoiner(SliceNode):
|
class SliceNodeJoiner(SliceNode):
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ from ..bpy_ns_types import NfBoolProperty, NfEnumProperty, NfIntProperty, NfFloa
|
||||||
from ....registry import register_class
|
from ....registry import register_class
|
||||||
from .base import SliceNode, SlicingCollection, get_all_children, SliceNodeJoiner, SlicingGroup, SlicingObject, SlicePaths
|
from .base import SliceNode, SlicingCollection, get_all_children, SliceNodeJoiner, SlicingGroup, SlicingObject, SlicePaths
|
||||||
from ....infra.blender.blender_mesh_capture import PdfProperties
|
from ....infra.blender.blender_mesh_capture import PdfProperties
|
||||||
|
from ....core.pdf_exporter.geometry import transform_slice_paths
|
||||||
|
|
||||||
@register_class
|
@register_class
|
||||||
class SLICENODE_GetMesh(SliceNode):
|
class SLICENODE_GetMesh(SliceNode):
|
||||||
|
|
@ -454,76 +455,11 @@ def _copy_slicing_group(sg: SlicingGroup) -> SlicingGroup:
|
||||||
return new_sg
|
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:
|
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:
|
if geom is None:
|
||||||
return None
|
return None
|
||||||
elif isinstance(geom, SlicePaths):
|
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:
|
if not hasattr(copied_data, "transforms") or copied_data.transforms is None:
|
||||||
copied_data.transforms = []
|
copied_data.transforms = []
|
||||||
else:
|
else:
|
||||||
|
|
@ -598,12 +534,10 @@ class SLICENODE_TransformGeometry(SliceNode):
|
||||||
self.add_output("SLICESOCK_Geometry", "Geometry")
|
self.add_output("SLICESOCK_Geometry", "Geometry")
|
||||||
|
|
||||||
def evaluate_output(self, socket_name: str, **_kwargs) -> Any:
|
def evaluate_output(self, socket_name: str, **_kwargs) -> Any:
|
||||||
if socket_name not in ("Geometry", "Mesh"):
|
if socket_name not in ("Geometry"):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
geom_data: Any = self.resolve("Geometry")
|
geom_data: Any = self.resolve("Geometry")
|
||||||
if geom_data is None:
|
|
||||||
geom_data = self.resolve("Mesh")
|
|
||||||
if geom_data is None:
|
if geom_data is None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -187,7 +187,7 @@ class ExecSliceNode_DatabaseSubmit(ExecSliceNode):
|
||||||
|
|
||||||
def evaluate(self, operator=None):
|
def evaluate(self, operator=None):
|
||||||
from ....infra.networking.bgcode import convert_to_bgcode
|
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")
|
gcodes: list[str] = self.resolve_multi_input("Gcode")
|
||||||
|
|
||||||
|
|
@ -196,41 +196,10 @@ class ExecSliceNode_DatabaseSubmit(ExecSliceNode):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Parse Metadata from Gcode
|
# Parse Metadata from Gcode
|
||||||
fields = {}
|
|
||||||
part_name = get_gcode_name_from_metadata(gc)
|
part_name = get_gcode_name_from_metadata(gc)
|
||||||
|
fields = parse_gcode_metadata(gc)
|
||||||
fields["Part ID / Name"] = part_name
|
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
|
# Detect binary trigger in gcode comments
|
||||||
is_binary = "; binary_gcode = 1" in gc
|
is_binary = "; binary_gcode = 1" in gc
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
import math
|
||||||
import bpy
|
import bpy
|
||||||
import os
|
import os
|
||||||
|
|
||||||
|
|
@ -51,9 +52,6 @@ class SLICENODE_GcodeToMesh(ExecSliceNode):
|
||||||
last_v_idx = -1
|
last_v_idx = -1
|
||||||
last_p2 = None
|
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_translation = [0.0, 0.0, 0.0]
|
||||||
prep_rotation = [0.0, 0.0, 0.0]
|
prep_rotation = [0.0, 0.0, 0.0]
|
||||||
prep_scale = [1.0, 1.0, 1.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)
|
p2 = (new_x - ox, new_y - oy, new_z)
|
||||||
|
|
||||||
# Calculate e_rate (proportion between E and default_extrusion_rate)
|
# 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:
|
if seg_len_mm > 0 and custom_default_extrusion_rate > 0:
|
||||||
e_rate = (new_e / seg_len_mm) / custom_default_extrusion_rate
|
e_rate = (new_e / seg_len_mm) / custom_default_extrusion_rate
|
||||||
else:
|
else:
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,8 @@ from typing import Any
|
||||||
import numpy as np
|
import numpy as np
|
||||||
from numpy.typing import NDArray
|
from numpy.typing import NDArray
|
||||||
|
|
||||||
|
from ..geometry import euler_rotation_matrix
|
||||||
|
|
||||||
def deduplicate_2d_edges(edges_2d: NDArray[np.float64]) -> NDArray[np.float64]:
|
def deduplicate_2d_edges(edges_2d: NDArray[np.float64]) -> NDArray[np.float64]:
|
||||||
"""
|
"""
|
||||||
Deduplicates 2D edge segments regardless of direction (A->B or B->A).
|
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
|
rot_mat: np.ndarray | None = None
|
||||||
if has_rot:
|
if has_rot:
|
||||||
rx, ry, rz = r_arr
|
rot_mat = euler_rotation_matrix(r_arr[0], r_arr[1], r_arr[2])
|
||||||
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)
|
|
||||||
|
|
||||||
new_paths = []
|
new_paths = []
|
||||||
for p in sp:
|
for p in sp:
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
import math
|
||||||
from numpy import float64
|
from numpy import float64
|
||||||
import numpy as np
|
import numpy as np
|
||||||
import pyclipper
|
import pyclipper
|
||||||
|
|
@ -1643,12 +1644,6 @@ class Pather(object):
|
||||||
new_path[:, 1] += offset_y
|
new_path[:, 1] += offset_y
|
||||||
centered_paths.append(new_path)
|
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:
|
try:
|
||||||
retraction_fr_val = float(self.retraction_feedrate) if self.retraction_feedrate is not None else 0.0
|
retraction_fr_val = float(self.retraction_feedrate) if self.retraction_feedrate is not None else 0.0
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
|
|
@ -1805,7 +1800,7 @@ class Pather(object):
|
||||||
old_x, old_y, old_z = x0, y0, z0
|
old_x, old_y, old_z = x0, y0, z0
|
||||||
old_extras = {prefix: None for prefix in extras}
|
old_extras = {prefix: None for prefix in extras}
|
||||||
for j, (p1, p2) in enumerate(zip(coords, coords[1:])):
|
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]
|
e_segment = seg_len * self.extrusion_rate * e_rate[j]
|
||||||
|
|
||||||
# Per-segment feedrate value (not multiplier)
|
# 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__))
|
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):
|
def write_metadata_xml(group: SlicingGroup, filepath):
|
||||||
# Custom sorting order for object types
|
# Custom sorting order for object types
|
||||||
object_type_order = {
|
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, "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")
|
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 = ET.ElementTree(xml_content)
|
||||||
xml_tree.write(filepath, encoding="UTF-8", xml_declaration=True)
|
xml_tree.write(filepath, encoding="UTF-8", xml_declaration=True)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ from numpy.typing import NDArray
|
||||||
|
|
||||||
from typing import Any
|
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
|
from .blender_bridge import get_all_children
|
||||||
|
|
||||||
TYPES_NAME = 'DEFAULT'
|
TYPES_NAME = 'DEFAULT'
|
||||||
|
|
@ -202,28 +202,7 @@ class SlicingObject():
|
||||||
def rotate(self, angles: NDArray[Any]):
|
def rotate(self, angles: NDArray[Any]):
|
||||||
# angles: array-like of 3 floats (rx, ry, rz) in radians
|
# angles: array-like of 3 floats (rx, ry, rz) in radians
|
||||||
angles_arr = np.array(angles, dtype=np.float64)
|
angles_arr = np.array(angles, dtype=np.float64)
|
||||||
rx, ry, rz = angles_arr
|
R = euler_rotation_matrix(angles_arr[0], angles_arr[1], angles_arr[2])
|
||||||
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)
|
|
||||||
|
|
||||||
# Apply to vertices: shape (N, 3, 3)
|
# Apply to vertices: shape (N, 3, 3)
|
||||||
self.mesh[:, :3, :] = self.mesh[:, :3, :] @ R.T
|
self.mesh[:, :3, :] = self.mesh[:, :3, :] @ R.T
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,11 @@ def parse_gcode_metadata(gcode: str) -> dict[str, Any]:
|
||||||
if weight_match:
|
if weight_match:
|
||||||
fields["Weight per print"] = int(float(weight_match.group(1)))
|
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
|
return fields
|
||||||
|
|
||||||
def get_gcode_name_from_metadata(gcode: str, default: str = "output") -> str:
|
def get_gcode_name_from_metadata(gcode: str, default: str = "output") -> str:
|
||||||
|
|
|
||||||
|
|
@ -1,291 +1,159 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
import operator
|
import operator
|
||||||
import re
|
import re
|
||||||
from typing import TYPE_CHECKING, TypeAlias, Any, cast
|
from typing import Any
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
TOKENS = [
|
||||||
Token: TypeAlias = tuple[str, str]
|
('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),
|
||||||
_ops = {
|
'!=': (str, operator.ne),
|
||||||
"==": (str, operator.eq),
|
'<': (float, operator.lt),
|
||||||
"!=": (str, operator.ne),
|
'>': (float, operator.gt),
|
||||||
"<": (float, operator.lt),
|
'<=': (float, operator.le),
|
||||||
">": (float, operator.gt),
|
'>=': (float, operator.ge),
|
||||||
"<=": (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:
|
class ExprNode:
|
||||||
"""Base class for all expression nodes in the AST."""
|
"""Represents an expression node in the AST."""
|
||||||
def eval(self, context: dict[str, Any]) -> Any:
|
def __init__(self, node: tuple[Any, ...]) -> None:
|
||||||
raise NotImplementedError("Must implement eval in subclass")
|
self.node = node
|
||||||
|
|
||||||
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:
|
def eval(self, context: dict[str, Any]) -> Any:
|
||||||
return self.value
|
return self._eval_node(self.node, context)
|
||||||
|
|
||||||
class VarNode(ExprNode):
|
@staticmethod
|
||||||
"""Represents a variable look-up in the configuration context."""
|
def _eval_node(node: tuple[Any, ...], context: dict[str, Any]) -> Any:
|
||||||
def __init__(self, name: str) -> None:
|
ntype = node[0]
|
||||||
self.name = name
|
if ntype == 'lit':
|
||||||
|
return node[1]
|
||||||
def eval(self, context: dict[str, Any]) -> Any:
|
if ntype == 'var':
|
||||||
return context.get(self.name, '0')
|
return context.get(node[1], '0')
|
||||||
|
if ntype == 'index':
|
||||||
class IndexNode(ExprNode):
|
val = str(context.get(node[1], '0'))
|
||||||
"""Represents an indexed variable look-up (e.g., nozzle_diameter[0])."""
|
parts = val.split(',')
|
||||||
def __init__(self, name: str, index: int) -> None:
|
idx = node[2]
|
||||||
self.name = name
|
return parts[idx].strip() if idx < len(parts) else '0'
|
||||||
self.index = index
|
if ntype == 'unary':
|
||||||
|
cval = ExprNode._eval_node(node[2], context)
|
||||||
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 == "!":
|
|
||||||
try:
|
try:
|
||||||
return not float(cval)
|
return not float(cval)
|
||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
return not bool(cval)
|
return not bool(cval)
|
||||||
|
if ntype == 'bin':
|
||||||
raise RuntimeError(f"Unknown unary operator: {self.op}")
|
op, left, right = node[1], node[2], node[3]
|
||||||
|
if op == 'and':
|
||||||
class BinaryOpNode(ExprNode):
|
lv = ExprNode._eval_node(left, context)
|
||||||
"""Represents a binary operator (e.g., ==, &&, =~)."""
|
if not lv or lv == '0': return False
|
||||||
def __init__(self, left: ExprNode, op: str, right: ExprNode) -> None:
|
return ExprNode._eval_node(right, context)
|
||||||
self.left = left
|
if op == 'or':
|
||||||
self.op = op
|
lv = ExprNode._eval_node(left, context)
|
||||||
self.right = right
|
if lv and lv != '0': return True
|
||||||
|
return ExprNode._eval_node(right, context)
|
||||||
def eval(self, context: dict[str, Any]) -> Any:
|
lv = ExprNode._eval_node(left, context)
|
||||||
# Short-circuiting for logical operators
|
rv = ExprNode._eval_node(right, context)
|
||||||
if self.op in ("and", "&&"):
|
if op in _OPS:
|
||||||
lv = self.left.eval(context)
|
caster, func = _OPS[op]
|
||||||
if not lv or lv == '0':
|
try:
|
||||||
return False
|
return func(caster(lv), caster(rv))
|
||||||
return self.right.eval(context)
|
except (ValueError, TypeError):
|
||||||
|
return func(str(lv), str(rv))
|
||||||
if self.op in ("or", "||"):
|
if op == '=~':
|
||||||
lv = self.left.eval(context)
|
return rv.search(str(lv)) is not None
|
||||||
if lv and lv != '0':
|
if op == '!~':
|
||||||
return True
|
return rv.search(str(lv)) is None
|
||||||
return self.right.eval(context)
|
return '0'
|
||||||
|
|
||||||
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}")
|
|
||||||
|
|
||||||
class Parser:
|
class Parser:
|
||||||
"""Recursive descent parser for PrusaSlicer expressions."""
|
"""Recursive descent parser for PrusaSlicer expressions."""
|
||||||
def __init__(self, text: str):
|
def __init__(self, text: str) -> None:
|
||||||
self.text = text
|
self.tokens = _tokenize(text)
|
||||||
self.tokens = self._tokenize(text)
|
|
||||||
self.pos = 0
|
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:
|
def parse(self) -> ExprNode:
|
||||||
node = self._parse_or()
|
node = self._parse_or()
|
||||||
if self._peek():
|
if self.pos < len(self.tokens):
|
||||||
raise SyntaxError(f"Trailing tokens after expression: {self._peek()}")
|
raise SyntaxError(f'Trailing tokens: {self.tokens[self.pos:]}')
|
||||||
return node
|
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()
|
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()
|
self._advance()
|
||||||
left = BinaryOpNode(left, "or", self._parse_and())
|
left = ('bin', 'or', left, self._parse_and())
|
||||||
return left
|
return left
|
||||||
|
|
||||||
def _parse_and(self) -> ExprNode:
|
def _parse_and(self) -> tuple[Any, ...]:
|
||||||
left = self._parse_not()
|
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()
|
self._advance()
|
||||||
left = BinaryOpNode(left, "and", self._parse_not())
|
left = ('bin', 'and', left, self._parse_not())
|
||||||
return left
|
return left
|
||||||
|
|
||||||
def _parse_not(self) -> ExprNode:
|
def _parse_not(self) -> tuple[Any, ...]:
|
||||||
if (tok := self._peek()) and tok[0] == "OP" and tok[1] == "!":
|
if (tok := self._peek()) and tok == ('OP', '!'):
|
||||||
self._advance()
|
self._advance()
|
||||||
return UnaryOpNode("!", self._parse_not())
|
return ('unary', '!', self._parse_not())
|
||||||
return self._parse_comp()
|
return self._parse_comp()
|
||||||
|
|
||||||
def _parse_comp(self) -> ExprNode:
|
def _parse_comp(self) -> tuple[Any, ...]:
|
||||||
left = self._parse_atom()
|
left = self._parse_atom()
|
||||||
ops = ("==", "!=", "<", ">", "<=", ">=", "=~", "!~")
|
if (tok := self._peek()) and tok[0] == 'OP' and tok[1] in ('==', '!=', '<', '>', '<=', '>=', '=~', '!~'):
|
||||||
if (tok := self._peek()) and tok[0] == "OP" and tok[1] in ops:
|
op = self._advance()[1]
|
||||||
op = tok[1]
|
left = ('bin', op, left, self._parse_atom())
|
||||||
self._advance()
|
|
||||||
left = BinaryOpNode(left, op, self._parse_atom())
|
|
||||||
return left
|
return left
|
||||||
|
|
||||||
def _parse_atom(self) -> ExprNode:
|
def _parse_atom(self) -> tuple[Any, ...]:
|
||||||
tok = self._advance()
|
tok = self._advance()
|
||||||
ttype, val = tok
|
ttype, val = tok
|
||||||
|
if ttype in ('LIT', 'REGEX'):
|
||||||
if ttype in ("STRING", "NUMBER"):
|
return ('lit', val)
|
||||||
return LiteralNode(val)
|
if ttype == 'IDENT':
|
||||||
|
if self._peek() == ('[', '['):
|
||||||
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":
|
|
||||||
self._advance()
|
self._advance()
|
||||||
idx = int(self._expect("NUMBER")[1])
|
idx_tok = self._advance()
|
||||||
self._expect("RBRACK")
|
if self._advance() != (']', ']'): raise SyntaxError('Expected ]')
|
||||||
return IndexNode(val, idx)
|
return ('index', val, int(idx_tok[1]))
|
||||||
return VarNode(val)
|
return ('var', val)
|
||||||
|
if ttype == '(':
|
||||||
if ttype == "LPAREN":
|
|
||||||
node = self._parse_or()
|
node = self._parse_or()
|
||||||
self._expect("RPAREN")
|
if self._advance() != (')', ')'): raise SyntaxError('Expected )')
|
||||||
return node
|
return node
|
||||||
|
raise SyntaxError(f'Unexpected token: {tok}')
|
||||||
raise SyntaxError(f"Unexpected token in atom: {tok}")
|
|
||||||
|
|
|
||||||
|
|
@ -46,16 +46,61 @@ class TestPrusaSlicerProfiles(unittest.TestCase):
|
||||||
self.assertEqual(res["bed_shape"].get("label"), "Bed shape")
|
self.assertEqual(res["bed_shape"].get("label"), "Bed shape")
|
||||||
|
|
||||||
def test_compatibility(self):
|
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()}
|
printer_keys = {k.removeprefix("printer:") for k in self.cache.printer_profiles.keys()}
|
||||||
vendors = set(self.cache.vendors)
|
vendors = set(self.cache.vendors)
|
||||||
|
|
||||||
# This should not raise any exceptions
|
|
||||||
self.cache.evaluate_compatibility(printer_keys, vendors)
|
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())
|
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.")
|
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__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue