From 7535bd7a85ac9d195dc39e73b92fccc107958cf2 Mon Sep 17 00:00:00 2001 From: Nicolas Predella Date: Tue, 25 Aug 2026 20:47:31 +0300 Subject: [PATCH] typing --- __init__.py | 4 +- core/geometry.py | 7 +- core/nodeslicer/bpy_ns_types.py | 39 ++++-- core/nodeslicer/nodes/base.py | 4 +- core/nodeslicer/nodes/gcode.py | 8 +- core/nodeslicer/nodes/inputs.py | 44 ++++--- core/nodeslicer/nodes/mesh.py | 129 ++++++++++--------- core/nodeslicer/nodes/output.py | 81 ++++++------ core/nodeslicer/nodes/path.py | 50 ++++--- core/nodeslicer/nodes/preview.py | 10 +- core/nodeslicer/nodes/system.py | 10 +- core/nodeslicer/sockets.py | 20 +-- core/nodeslicer/tree.py | 7 +- core/pdf_exporter/geometry.py | 16 +-- core/pdf_exporter/metadata.py | 20 ++- core/pdf_exporter/sa2_measures.py | 7 +- core/slice.py | 42 +++--- doc/NODES.md | 2 + infra/_3mf_writer/_3mf.py | 3 +- infra/blender/blender_mesh_capture.py | 86 +++++++------ infra/blender/blender_slice_nodes_generic.py | 83 +++++++----- infra/blender/mixins.py | 7 +- infra/networking/airtable_backend.py | 5 +- infra/networking/baserow_backend.py | 8 +- infra/networking/printer_backends.py | 8 +- infra/prusaslicer_profiles/parser.py | 8 +- registry.py | 15 +++ tests/test_slice.py | 52 ++++++++ 28 files changed, 451 insertions(+), 324 deletions(-) diff --git a/__init__.py b/__init__.py index 7be2f5c..1bfe9ac 100644 --- a/__init__.py +++ b/__init__.py @@ -25,7 +25,7 @@ def register(): bpy.app.handlers.depsgraph_update_post.append(tree.depsgraph_update_handler) if not hasattr(bpy.types.Object, "nodeslicer"): - bpy.types.Object.nodeslicer = bpy.props.PointerProperty(type=SlicerObjectPropertyGroup, name="nodeslicer") #type: ignore + setattr(bpy.types.Object, "nodeslicer", bpy.props.PointerProperty(type=SlicerObjectPropertyGroup, name="nodeslicer")) _registered_nodeslicer = True # Register Keymaps @@ -70,7 +70,7 @@ def unregister(): if _registered_nodeslicer: try: if hasattr(bpy.types.Object, "nodeslicer"): - del bpy.types.Object.nodeslicer #type: ignore + delattr(bpy.types.Object, "nodeslicer") except Exception as e: print(f"Error removing property: {e}") finally: diff --git a/core/geometry.py b/core/geometry.py index 1d34c67..1b53405 100644 --- a/core/geometry.py +++ b/core/geometry.py @@ -1,6 +1,3 @@ -import numpy as np -import zlib - import numpy as np import zlib import struct @@ -19,8 +16,8 @@ def crc32_list(l: list[np.ndarray]) -> int: h = zlib.adler32(struct.pack(">I", crc32_array(a)), h) return h -def ndarray_lru_cache(maxsize=128): - def decorator(func: Callable): +def ndarray_lru_cache(maxsize: int = 128) -> Callable[[Callable[..., Any]], Callable[..., Any]]: + def decorator(func: Callable[..., Any]) -> Callable[..., Any]: cache = {} order = [] def wrapper(*args, **kwargs): diff --git a/core/nodeslicer/bpy_ns_types.py b/core/nodeslicer/bpy_ns_types.py index ef31aa0..d909c72 100644 --- a/core/nodeslicer/bpy_ns_types.py +++ b/core/nodeslicer/bpy_ns_types.py @@ -1,3 +1,5 @@ +from __future__ import annotations +from typing import Any, Callable import bpy from bpy.props import PointerProperty, StringProperty, FloatProperty, IntProperty, EnumProperty, BoolProperty, FloatVectorProperty, CollectionProperty @@ -52,16 +54,33 @@ def notify_tree_changed(self, context): except Exception as e: print(f"Error updating node {node.name}: {e}") -def NfProperty(prop_func): - def wrapper(**kwargs): +class MockPropertyDescriptor: + def __init__(self, default: Any = None): + self.default = default + self._values: dict[int, Any] = {} + + def __get__(self, instance: Any, owner: Any = None) -> Any: + if instance is None: + return self + return self._values.get(id(instance), self.default) + + def __set__(self, instance: Any, value: Any) -> None: + self._values[id(instance)] = value + +def NfProperty(prop_func: Callable[..., Any]) -> Callable[..., Any]: + def wrapper(**kwargs: Any) -> Any: kwargs.setdefault("update", notify_tree_changed) - return prop_func(**kwargs) + res = prop_func(**kwargs) + if "Mock" in type(res).__name__: + default = kwargs.get("default", None) + return MockPropertyDescriptor(default) + return res return wrapper -NfStringProperty = NfProperty(StringProperty) -NfIntProperty = NfProperty(IntProperty) -NfFloatProperty = NfProperty(FloatProperty) -NfEnumProperty = NfProperty(EnumProperty) -NfBoolProperty = NfProperty(BoolProperty) -NfPointerProperty = NfProperty(PointerProperty) -NfFloatVectorProperty = NfProperty(FloatVectorProperty) \ No newline at end of file +NfStringProperty: Callable[..., Any] = NfProperty(StringProperty) +NfIntProperty: Callable[..., Any] = NfProperty(IntProperty) +NfFloatProperty: Callable[..., Any] = NfProperty(FloatProperty) +NfEnumProperty: Callable[..., Any] = NfProperty(EnumProperty) +NfBoolProperty: Callable[..., Any] = NfProperty(BoolProperty) +NfPointerProperty: Callable[..., Any] = NfProperty(PointerProperty) +NfFloatVectorProperty: Callable[..., Any] = NfProperty(FloatVectorProperty) \ No newline at end of file diff --git a/core/nodeslicer/nodes/base.py b/core/nodeslicer/nodes/base.py index c16cc93..d7e751e 100644 --- a/core/nodeslicer/nodes/base.py +++ b/core/nodeslicer/nodes/base.py @@ -61,7 +61,7 @@ class InputNode(SliceNode): class MathNode(SliceNode): bl_label = "Float Math" - operation: NfEnumProperty( + operation: str = NfEnumProperty( name="Operation", items=[ ('ADD', "Add", "Add the two values"), @@ -81,7 +81,7 @@ class MathNode(SliceNode): ('MODULO_TRUNCATED', "Truncated Modulo", "Modulo using truncated division"), ], default='ADD' - ) # pyright: ignore[reportInvalidTypeForm] + ) math_type: TypeAlias = float socket_label: str = "Float" diff --git a/core/nodeslicer/nodes/gcode.py b/core/nodeslicer/nodes/gcode.py index e6d3d82..8903170 100644 --- a/core/nodeslicer/nodes/gcode.py +++ b/core/nodeslicer/nodes/gcode.py @@ -12,7 +12,7 @@ from .base import SliceNode, SliceIntrinsics, Pather, SliceNodeJoiner, SlicePath class SLICENODE_PathsToGcode(SliceNode): bl_label = "Paths to Gcode" - use_header: NfBoolProperty(name="Header", default=True) # pyright: ignore[reportInvalidTypeForm] + use_header: bool = NfBoolProperty(name="Header", default=True) def init(self, context): self.add_input("SLICESOCK_Paths") @@ -119,7 +119,7 @@ class SLICENODE_GcodeRegexReplace(SliceNode): class SLICENODE_GcodeHelper(SliceNode): bl_label = "Gcode Helper" - command: NfEnumProperty( + command: str = NfEnumProperty( name="Command", items=[ ('G0', "G0: Rapid Move", "Fast movement without extrusion"), @@ -149,9 +149,9 @@ class SLICENODE_GcodeHelper(SliceNode): ('T', "T: Change Extruder", "Switch to a different extruder (e.g. T0, T1)"), ], default='G1' - ) # pyright: ignore[reportInvalidTypeForm] + ) - parameters: NfStringProperty(name="Params", default="") # pyright: ignore[reportInvalidTypeForm] + parameters: str = NfStringProperty(name="Params", default="") def init(self, context): self.add_output("NodeSocketString") diff --git a/core/nodeslicer/nodes/inputs.py b/core/nodeslicer/nodes/inputs.py index 45407f1..e50ece7 100644 --- a/core/nodeslicer/nodes/inputs.py +++ b/core/nodeslicer/nodes/inputs.py @@ -4,7 +4,7 @@ import bpy import os import numpy as np -from ..bpy_ns_types import NfStringProperty, notify_tree_changed, NfEnumProperty +from ..bpy_ns_types import NfStringProperty, notify_tree_changed, NfEnumProperty, NfPointerProperty from ....registry import register_class from .base import InputNode, SliceNode, SliceIntrinsics @@ -43,11 +43,11 @@ class SLICENODE_InputObject(InputNode): class SLICENODE_CollectionObjects(SliceNode): bl_label = "Collection Objects" - collection: bpy.props.PointerProperty( + collection: bpy.types.Collection | None = NfPointerProperty( name="Collection", type=bpy.types.Collection, update=notify_tree_changed - ) # pyright: ignore[reportInvalidTypeForm] + ) def init(self, context): self._update_outputs() @@ -61,11 +61,12 @@ class SLICENODE_CollectionObjects(SliceNode): layout.prop(self, "collection", text="") def _update_outputs(self): - if not self.collection: + col = getattr(self, "collection", None) + if not col or not hasattr(col, "objects"): self.outputs.clear() return - objs = [obj for obj in self.collection.objects if obj.type == 'MESH'] + objs = [obj for obj in col.objects if getattr(obj, "type", "") == 'MESH'] expected_names = [obj.name for obj in objs] # Remove outputs that are no longer in the collection @@ -96,11 +97,12 @@ class SLICENODE_WipeTower(SliceNode): if socket_name != "Settings": return {} - raw_x = self.resolve("X Position") - raw_y = self.resolve("Y Position") + raw_x: Any = self.resolve("X Position") + raw_y: Any = self.resolve("Y Position") - if isinstance(raw_x, (list, tuple, np.ndarray)) and len(raw_x) >= 2: - pos_x, pos_y = float(raw_x[0]), float(raw_x[1]) + if isinstance(raw_x, (list, tuple, np.ndarray)): + pos_x = float(raw_x[0]) if len(raw_x) >= 1 else 0.0 + pos_y = float(raw_x[1]) if len(raw_x) >= 2 else (float(raw_y) if raw_y is not None else 0.0) else: pos_x = float(raw_x) if raw_x is not None else 0.0 pos_y = float(raw_y) if raw_y is not None else 0.0 @@ -123,13 +125,13 @@ class SLICENODE_OffsetObject(SliceNode): if socket_name != "Settings": return {} - raw_x = self.resolve("X Offset") - raw_y = self.resolve("Y Offset") - raw_z = self.resolve("Z Offset") + raw_x: Any = self.resolve("X Offset") + raw_y: Any = self.resolve("Y Offset") + raw_z: Any = self.resolve("Z Offset") - if isinstance(raw_x, (list, tuple, np.ndarray)) and len(raw_x) >= 2: - ox = float(raw_x[0]) - oy = float(raw_x[1]) + if isinstance(raw_x, (list, tuple, np.ndarray)): + ox = float(raw_x[0]) if len(raw_x) >= 1 else 0.0 + oy = float(raw_x[1]) if len(raw_x) >= 2 else (float(raw_y) if raw_y is not None else 0.0) oz = float(raw_x[2]) if len(raw_x) >= 3 else (float(raw_z) if raw_z is not None else 0.0) else: ox = float(raw_x) if raw_x is not None else 0.0 @@ -205,9 +207,9 @@ class SLICENODE_PrusaslicerConf(SliceNode): def search_prints(self, _context, edit_text) -> list[str]: return self.search_conf(edit_text, conf_type="prints") - printer_id: NfStringProperty(name="Printer", default='', update=update_profiles, search=search_printers) # pyright: ignore[reportInvalidTypeForm] - filament_id: NfStringProperty(name="Filament", default='', search=search_filaments) # pyright: ignore[reportInvalidTypeForm] - print_id: NfStringProperty(name="Print", default='', search=search_prints) # pyright: ignore[reportInvalidTypeForm] + printer_id: str = NfStringProperty(name="Printer", default='', update=update_profiles, search=search_printers) + filament_id: str = NfStringProperty(name="Filament", default='', search=search_filaments) + print_id: str = NfStringProperty(name="Print", default='', search=search_prints) def init(self, context): self.add_output("SLICESOCK_PrusaslicerConf", "Config") @@ -242,9 +244,9 @@ class SLICENODE_InputPrinter(SliceNode): self.label = self.printer_label notify_tree_changed(self, context) - printer_label: NfStringProperty(name="Label", default="Printer", update=update_label) # pyright: ignore[reportInvalidTypeForm] + printer_label: str = NfStringProperty(name="Label", default="Printer", update=update_label) - host_type: NfEnumProperty( + host_type: str = NfEnumProperty( name="Type", items=[ ('PRUSALINK', "PrusaLink", "PrusaLink API"), @@ -252,7 +254,7 @@ class SLICENODE_InputPrinter(SliceNode): ('MOONRAKER', "Moonraker", "Moonraker (Klipper) API"), ], default='PRUSALINK' - ) # pyright: ignore[reportInvalidTypeForm] + ) def init(self, context): self.add_output("SLICESOCK_Printer") diff --git a/core/nodeslicer/nodes/mesh.py b/core/nodeslicer/nodes/mesh.py index 1ae36d5..dccb480 100644 --- a/core/nodeslicer/nodes/mesh.py +++ b/core/nodeslicer/nodes/mesh.py @@ -11,7 +11,7 @@ from ....infra.blender.blender_mesh_capture import PdfProperties class SLICENODE_GetMesh(SliceNode): bl_label = "Get Mesh" - include_children: NfBoolProperty(name="Include Children", default=True) # pyright: ignore[reportInvalidTypeForm] + include_children: bool = NfBoolProperty(name="Include Children", default=True) def init(self, context): self.add_input("NodeSocketObject") @@ -73,7 +73,7 @@ class SLICENODE_JoinMeshGroups(SliceNodeJoiner): class SLICENODE_SeparateMesh(SliceNode): bl_label = "Separate Mesh" - comparison: NfEnumProperty( + comparison: str = NfEnumProperty( name="Comparison", items=[ ('EQUAL', "Equal", "=="), @@ -85,7 +85,7 @@ class SLICENODE_SeparateMesh(SliceNode): ], default='EQUAL', update=notify_tree_changed - ) # pyright: ignore[reportInvalidTypeForm] + ) def init(self, context): self.add_input("SLICESOCK_Mesh") @@ -167,8 +167,8 @@ class SLICENODE_SeparateMesh(SliceNode): class SLICENODE_SeparateMeshByRange(SliceNode): bl_label = "Separate Mesh by Range" - start_val: NfIntProperty(name="Start", default=0, update=notify_tree_changed) # pyright: ignore[reportInvalidTypeForm] - end_val: NfIntProperty(name="End", default=1, update=notify_tree_changed) # pyright: ignore[reportInvalidTypeForm] + start_val: int = NfIntProperty(name="Start", default=0, update=notify_tree_changed) + end_val: int = NfIntProperty(name="End", default=1, update=notify_tree_changed) def init(self, context): self.add_input("SLICESOCK_Mesh") @@ -181,8 +181,9 @@ class SLICENODE_SeparateMeshByRange(SliceNode): def _update_outputs(self): # Ensure end >= start - start = self.start_val - end = max(start, self.end_val) + start = int(self.start_val) if isinstance(getattr(self, "start_val", None), (int, float)) else 0 + end_val = int(self.end_val) if isinstance(getattr(self, "end_val", None), (int, float)) else 1 + end = max(start, end_val) expected_ids = [str(i) for i in range(start, end + 1)] # Remove outputs that are no longer in the range @@ -222,6 +223,12 @@ class SLICENODE_SeparateMeshByRange(SliceNode): res = SlicingCollection([], f"{coll.name}_{target_val}") + start = self.start_val + end = max(start, self.end_val) + total_parts = end - start + 1 + num_digits = max(2, len(str(total_parts))) + i = target_val - start + for so in coll.objects: if attr_name not in so.attributes: continue @@ -231,19 +238,20 @@ class SLICENODE_SeparateMeshByRange(SliceNode): # Attribute values might be floats or ints, we check for equality mask = vals == target_val - - num_digits = max(2, len(str(self.end_val))) - if np.all(mask): - res.objects.append(so) - elif np.any(mask): + if np.any(mask): # Split the object so_new = SlicingObject() - so_new.name = f"{so.name}{target_val:0{num_digits}d}" + base_mesh_name = so.name + so_new.name = f"{base_mesh_name}{i:0{num_digits}d}" so_new.parent = so.parent so_new.object_type = so.object_type so_new.extruder = so.extruder so_new.mesh = so.mesh[mask] + so_new.transforms = [dict(t) for t in getattr(so, "transforms", [])] + so_new.pdf_properties = copy.deepcopy(so.pdf_properties) if hasattr(so, "pdf_properties") and so.pdf_properties else PdfProperties() + so_new.object_properties = list(getattr(so, "object_properties", [])) + so_new.modifiers = so.modifiers.copy() if so.modifiers else [] for k, v in so.attributes.items(): so_new.attributes[k] = { "domain": v["domain"], @@ -258,7 +266,7 @@ class SLICENODE_SeparateMeshByRange(SliceNode): class SLICENODE_ArrangeMesh(SliceNode): bl_label = "Arrange Slicing Group" - method: NfEnumProperty( + method: str = NfEnumProperty( name="Method", items=[ ('NFP', "NFP (Default)", "Nest using No-Fit Polygon placer + First-Fit selection"), @@ -267,13 +275,13 @@ class SLICENODE_ArrangeMesh(SliceNode): ], default='NFP', update=notify_tree_changed - ) # pyright: ignore[reportInvalidTypeForm] + ) - allow_rotations: NfBoolProperty( + allow_rotations: bool = NfBoolProperty( name="Allow Rotations", default=True, update=notify_tree_changed - ) # pyright: ignore[reportInvalidTypeForm] + ) def init(self, context): self.add_input("SLICESOCK_Mesh", "Slicing Group", display_shape='LIST') @@ -455,7 +463,7 @@ def _transform_slice_paths(sp: SlicePaths, t_arr: np.ndarray, r_arr: np.ndarray, if not (has_scale or has_rot or has_trans): return sp.copy_with(paths=list(sp)) - R = None + rot_mat: np.ndarray | None = None if has_rot: rx, ry, rz = r_arr cx, sx = np.cos(rx), np.sin(rx) @@ -477,7 +485,7 @@ def _transform_slice_paths(sp: SlicePaths, t_arr: np.ndarray, r_arr: np.ndarray, [sz, cz, 0.0], [0.0, 0.0, 1.0] ], dtype=np.float64) - R = Rz @ (Ry @ Rx) + rot_mat = Rz @ (Ry @ Rx) for p in sp: if len(p) == 0: @@ -493,11 +501,11 @@ def _transform_slice_paths(sp: SlicePaths, t_arr: np.ndarray, r_arr: np.ndarray, else: new_p[:, :dim] *= s_arr[:dim] - if has_rot and R is not None and dim > 0: + if has_rot and rot_mat is not None and dim > 0: if dim >= 3: - new_p[:, :3] = new_p[:, :3] @ R.T + new_p[:, :3] = new_p[:, :3] @ rot_mat.T elif dim == 2: - rot_2d = R[:2, :2] + rot_2d = rot_mat[:2, :2] new_p[:, :2] = new_p[:, :2] @ rot_2d.T if has_trans and dim > 0: @@ -511,7 +519,7 @@ def _transform_slice_paths(sp: SlicePaths, t_arr: np.ndarray, r_arr: np.ndarray, 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) -> 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: return None elif isinstance(geom, SlicePaths): @@ -590,10 +598,12 @@ 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"): + if socket_name not in ("Geometry", "Mesh"): 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 @@ -641,7 +651,7 @@ class SLICENODE_DropGeometry(SliceNode): if socket_name != "Mesh": return None - mesh_data: SlicingCollection | SlicingGroup | None = self.resolve("Mesh") + mesh_data = self.resolve("Mesh") if not mesh_data: return None @@ -682,7 +692,7 @@ def format_name_template(template: str, so: Any, default_old_name: str = "") -> import re from ....core.pdf_exporter.metadata import get_first_attribute_value - def replace_match(match: re.Match) -> str: + def replace_match(match: re.Match[str]) -> str: attr_name = match.group(1).strip() val = get_first_attribute_value(so, attr_name) if val is None and attr_name in ("Name", "name"): @@ -699,6 +709,11 @@ def format_name_template(template: str, so: Any, default_old_name: str = "") -> def _rename_slicing_item(geom: Any, new_name: str) -> Any: if geom is None or not new_name: return geom + elif isinstance(geom, SlicePaths): + copied = geom.copy_with(paths=list(geom)) + target_name = format_name_template(new_name, copied, default_old_name=getattr(copied, "name", "")) + copied.name = target_name + return copied elif isinstance(geom, (list, tuple)): return [_rename_slicing_item(item, f"{new_name}_{i}" if (len(geom) > 1 and "{" not in new_name) else new_name) for i, item in enumerate(geom)] elif isinstance(geom, SlicingGroup): @@ -706,40 +721,26 @@ def _rename_slicing_item(geom: Any, new_name: str) -> Any: old_colls = dict(copied.collections) new_colls = {} for i, (old_key, coll) in enumerate(old_colls.items()): - if coll.objects: - coll_name = format_name_template(new_name, coll.objects[0], default_old_name=old_key) - else: - coll_name = format_name_template(new_name, None, default_old_name=old_key) - if len(old_colls) > 1 and "{" not in new_name: - coll_name = f"{coll_name}_{i}" - coll.name = coll_name - for j, obj in enumerate(coll.objects): - obj_name = format_name_template(new_name, obj, default_old_name=obj.name) - if (len(coll.objects) > 1 or len(old_colls) > 1) and "{" not in new_name: - obj_name = f"{obj_name}_{j}" - obj.name = obj_name - obj.parent = coll_name - new_colls[coll_name] = coll + coll_old_name = getattr(coll, "name", old_key) + target_name = format_name_template(new_name, coll, default_old_name=coll_old_name) + coll_new_name = f"{target_name}_{i}" if (len(old_colls) > 1 and "{" not in new_name and target_name == new_name) else target_name + new_colls[coll_new_name] = _rename_slicing_item(coll, coll_new_name) copied.collections = new_colls return copied elif isinstance(geom, SlicingCollection): copied = _copy_slicing_collection(geom) - old_coll_name = geom.name - if copied.objects: - coll_name = format_name_template(new_name, copied.objects[0], default_old_name=old_coll_name) - else: - coll_name = format_name_template(new_name, None, default_old_name=old_coll_name) - copied.name = coll_name - for i, obj in enumerate(copied.objects): - obj_name = format_name_template(new_name, obj, default_old_name=obj.name) - if len(copied.objects) > 1 and "{" not in new_name: - obj_name = f"{obj_name}_{i}" - obj.name = obj_name - obj.parent = coll_name + target_name = format_name_template(new_name, copied, default_old_name=getattr(copied, "name", "")) + copied.name = target_name + for i, so in enumerate(copied.objects): + so.parent = target_name + so_old_name = getattr(so, "name", "") + so_target = format_name_template(new_name, so, default_old_name=so_old_name) + so.name = f"{so_target}_{i}" if (len(copied.objects) > 1 and "{" not in new_name and so_target == new_name) else so_target return copied elif isinstance(geom, SlicingObject): copied = _copy_slicing_object(geom) - copied.name = format_name_template(new_name, copied, default_old_name=geom.name) + target_name = format_name_template(new_name, copied, default_old_name=getattr(copied, "name", "")) + copied.name = target_name return copied return geom @@ -758,7 +759,7 @@ class SLICENODE_RenameMesh(SliceNode): return None geom: Any = self.resolve("Geometry") - if not geom: + if geom is None: return None name_socket: str | None = self.resolve("Name") @@ -769,7 +770,7 @@ class SLICENODE_RenameMesh(SliceNode): return _rename_slicing_item(geom, target_name) -def _assign_pdf_properties_to_item(geom: Any, line_width: float, line_color: tuple[float, float, float], show_name: bool = False) -> Any: +def _assign_pdf_properties_to_item(geom: Any, line_width: float, line_color: tuple[float, float, float] | None, show_name: bool) -> Any: if geom is None: return None elif isinstance(geom, SlicePaths): @@ -780,14 +781,14 @@ def _assign_pdf_properties_to_item(geom: Any, line_width: float, line_color: tup return [_assign_pdf_properties_to_item(item, line_width, line_color, show_name) for item in geom] elif isinstance(geom, SlicingGroup): copied = _copy_slicing_group(geom) - for coll in copied.collections.values(): - for obj in coll.objects: - obj.pdf_properties = PdfProperties(line_width=line_width, line_color=line_color, show_name=show_name) + new_colls = {} + for k, coll in copied.collections.items(): + new_colls[k] = _assign_pdf_properties_to_item(coll, line_width, line_color, show_name) + copied.collections = new_colls return copied elif isinstance(geom, SlicingCollection): copied = _copy_slicing_collection(geom) - for obj in copied.objects: - obj.pdf_properties = PdfProperties(line_width=line_width, line_color=line_color, show_name=show_name) + copied.objects = [_assign_pdf_properties_to_item(so, line_width, line_color, show_name) for so in copied.objects] return copied elif isinstance(geom, SlicingObject): copied = _copy_slicing_object(geom) @@ -800,16 +801,16 @@ def _assign_pdf_properties_to_item(geom: Any, line_width: float, line_color: tup class SLICENODE_AssignPdfProperties(SliceNode): bl_label = "Assign PDF Properties" - line_width: NfFloatProperty(name="Line Width (mm)", default=0.0, min=0.0, max=50.0) # pyright: ignore[reportInvalidTypeForm] - line_color: NfFloatVectorProperty( + line_width: float = NfFloatProperty(name="Line Width (mm)", default=0.0, min=0.0, max=50.0) + line_color: tuple[float, float, float] = NfFloatVectorProperty( name="Line Color", subtype='COLOR', default=(0.0, 0.0, 0.0), min=0.0, max=1.0, size=3 - ) # pyright: ignore[reportInvalidTypeForm] - show_name: NfBoolProperty(name="Show Name", default=False) # pyright: ignore[reportInvalidTypeForm] + ) + show_name: bool = NfBoolProperty(name="Show Name", default=False) def init(self, context): self.add_input("SLICESOCK_Mesh", "Geometry") diff --git a/core/nodeslicer/nodes/output.py b/core/nodeslicer/nodes/output.py index f8f38f7..710c7c9 100644 --- a/core/nodeslicer/nodes/output.py +++ b/core/nodeslicer/nodes/output.py @@ -69,7 +69,7 @@ class SLICENODE_GcodeToTextBlock(ExecSliceNode): bl_label = "Gcode to Text Block" button_label = "Update Text Block" - text_block_name: NfStringProperty(name="Text Block Name", default="NodeSlicer_Gcode.gcode") # pyright: ignore[reportInvalidTypeForm] + text_block_name: str = NfStringProperty(name="Text Block Name", default="NodeSlicer_Gcode.gcode") def init(self, context): self._init_exec() @@ -171,14 +171,15 @@ class SLICENODE_StartPrint(ExecSliceNode): return 'ok' class ExecSliceNode_DatabaseSubmit(ExecSliceNode): - record_id: NfStringProperty(name="Record ID", default="") # pyright: ignore[reportInvalidTypeForm] + record_id: str = NfStringProperty(name="Record ID", default="") _db_name = "Database" _token_prop_name = "token_path" def init(self, context): self._init_exec() - self.add_multi_socket_group("NodeSocketString", name="Gcode") + self.add_input("NodeSocketString", "Gcode") + self.add_input("SLICESOCK_Settings", "Metadata") def draw_buttons(self, context, layout): super().draw_buttons(context, layout) @@ -275,7 +276,7 @@ class ExecSliceNode_DatabaseSubmit(ExecSliceNode): def _check_config(self, token: str) -> bool: return False - def _submit_to_db(self, token: str, fields: dict, temp_path: Path) -> str: + def _submit_to_db(self, token: str, fields: dict[str, Any], temp_path: Path) -> str: return "" @register_class @@ -283,9 +284,9 @@ class SLICENODE_BaserowSubmit(ExecSliceNode_DatabaseSubmit): bl_label = "Baserow Submit" button_label = "Submit to Baserow" - host: NfStringProperty(name="Host", default="https://baserow.io") # pyright: ignore[reportInvalidTypeForm] - token_path: NfStringProperty(name="Token File", subtype='FILE_PATH', default="") # pyright: ignore[reportInvalidTypeForm] - table_id: NfStringProperty(name="Table ID", default="") # pyright: ignore[reportInvalidTypeForm] + host: str = NfStringProperty(name="Host", default="https://baserow.io") + token_path: str = NfStringProperty(name="Token File", subtype='FILE_PATH', default="") + table_id: str = NfStringProperty(name="Table ID", default="") _db_name = "Baserow" _token_prop_name = "token_path" @@ -321,9 +322,9 @@ class SLICENODE_AirtableSubmit(ExecSliceNode_DatabaseSubmit): bl_label = "Airtable Submit" button_label = "Submit to Airtable" - pat_path: NfStringProperty(name="PAT File", subtype='FILE_PATH', default="") # pyright: ignore[reportInvalidTypeForm] - base_id: NfStringProperty(name="Base ID", default="") # pyright: ignore[reportInvalidTypeForm] - table_id: NfStringProperty(name="Table ID", default="") # pyright: ignore[reportInvalidTypeForm] + pat_path: str = NfStringProperty(name="PAT File", subtype='FILE_PATH', default="") + base_id: str = NfStringProperty(name="Base ID", default="") + table_id: str = NfStringProperty(name="Table ID", default="") _db_name = "Airtable" _token_prop_name = "pat_path" @@ -358,59 +359,59 @@ class SLICENODE_MeshToPdf(ExecSliceNode): bl_label = "Mesh to PDF" button_label = "Generate PDF" - draw_wireframe: NfBoolProperty(name="Wireframe", default=True) # pyright: ignore[reportInvalidTypeForm] - draw_boundary: NfBoolProperty(name="Boundary (1 Face)", default=False) # pyright: ignore[reportInvalidTypeForm] - draw_contour: NfBoolProperty(name="Contour", default=False) # pyright: ignore[reportInvalidTypeForm] - draw_crease: NfBoolProperty(name="Crease Angle", default=False) # pyright: ignore[reportInvalidTypeForm] - crease_angle: NfFloatProperty(name="Angle (°)", default=30.0, min=0.0, max=180.0) # pyright: ignore[reportInvalidTypeForm] - draw_bbox: NfBoolProperty(name="Bounding Box", default=False) # pyright: ignore[reportInvalidTypeForm] - draw_sa2_holes: NfBoolProperty(name="Holes (Ø)", default=False) # pyright: ignore[reportInvalidTypeForm] - show_lineart: NfBoolProperty(name="Lineart Visibility", default=False) # pyright: ignore[reportInvalidTypeForm] - show_sa2: NfBoolProperty(name="SA2 Settings", default=False) # pyright: ignore[reportInvalidTypeForm] - draw_grid_xy: NfBoolProperty(name="XY Grid (Top)", default=True) # pyright: ignore[reportInvalidTypeForm] - draw_grid_xz: NfBoolProperty(name="XZ Grid (Front)", default=False) # pyright: ignore[reportInvalidTypeForm] - draw_grid_yz: NfBoolProperty(name="YZ Grid (Side)", default=False) # pyright: ignore[reportInvalidTypeForm] - show_grids: NfBoolProperty(name="Grids", default=False) # pyright: ignore[reportInvalidTypeForm] + draw_wireframe: bool = NfBoolProperty(name="Wireframe", default=True) + draw_boundary: bool = NfBoolProperty(name="Boundary (1 Face)", default=False) + draw_contour: bool = NfBoolProperty(name="Contour", default=False) + draw_crease: bool = NfBoolProperty(name="Crease Angle", default=False) + crease_angle: float = NfFloatProperty(name="Angle (°)", default=30.0, min=0.0, max=180.0) + draw_bbox: bool = NfBoolProperty(name="Bounding Box", default=False) + draw_sa2_holes: bool = NfBoolProperty(name="Holes (Ø)", default=False) + show_lineart: bool = NfBoolProperty(name="Lineart Visibility", default=False) + show_sa2: bool = NfBoolProperty(name="SA2 Settings", default=False) + draw_grid_xy: bool = NfBoolProperty(name="XY Grid (Top)", default=True) + draw_grid_xz: bool = NfBoolProperty(name="XZ Grid (Front)", default=False) + draw_grid_yz: bool = NfBoolProperty(name="YZ Grid (Side)", default=False) + show_grids: bool = NfBoolProperty(name="Grids", default=False) - draw_header: NfBoolProperty(name="Technical Header", default=True) # pyright: ignore[reportInvalidTypeForm] - show_header: NfBoolProperty(name="Technical Drawing Header", default=False) # pyright: ignore[reportInvalidTypeForm] - header_company: NfStringProperty(name="Company", default="") # pyright: ignore[reportInvalidTypeForm] - header_part_name: NfStringProperty(name="Part Name", default="") # pyright: ignore[reportInvalidTypeForm] - header_drawing_number: NfStringProperty(name="Drawing No.", default="") # pyright: ignore[reportInvalidTypeForm] - header_author: NfStringProperty(name="Author", default="") # pyright: ignore[reportInvalidTypeForm] - header_revision: NfStringProperty(name="Revision", default="A") # pyright: ignore[reportInvalidTypeForm] - header_material: NfStringProperty(name="Material", default="") # pyright: ignore[reportInvalidTypeForm] - header_tolerance: NfStringProperty(name="Tolerance", default="") # pyright: ignore[reportInvalidTypeForm] + draw_header: bool = NfBoolProperty(name="Technical Header", default=True) + show_header: bool = NfBoolProperty(name="Technical Drawing Header", default=False) + header_company: str = NfStringProperty(name="Company", default="") + header_part_name: str = NfStringProperty(name="Part Name", default="") + header_drawing_number: str = NfStringProperty(name="Drawing No.", default="") + header_author: str = NfStringProperty(name="Author", default="") + header_revision: str = NfStringProperty(name="Revision", default="A") + header_material: str = NfStringProperty(name="Material", default="") + header_tolerance: str = NfStringProperty(name="Tolerance", default="") - font_size: NfFloatProperty(name="Font Size (mm)", default=0.0, min=0.0, max=100.0) # pyright: ignore[reportInvalidTypeForm] - margin: NfFloatProperty(name="Margin (mm)", default=10.0, min=0.0, max=500.0) # pyright: ignore[reportInvalidTypeForm] + font_size: float = NfFloatProperty(name="Font Size (mm)", default=0.0, min=0.0, max=100.0) + margin: float = NfFloatProperty(name="Margin (mm)", default=10.0, min=0.0, max=500.0) - bg_color: NfFloatVectorProperty( + bg_color: tuple[float, float, float] = NfFloatVectorProperty( name="Background", subtype='COLOR', default=(1.0, 1.0, 1.0), min=0.0, max=1.0, size=3 - ) # pyright: ignore[reportInvalidTypeForm] + ) - wire_color: NfFloatVectorProperty( + wire_color: tuple[float, float, float] = NfFloatVectorProperty( name="Wireframe", subtype='COLOR', default=(0.0, 0.0, 0.0), min=0.0, max=1.0, size=3 - ) # pyright: ignore[reportInvalidTypeForm] + ) - bbox_color: NfFloatVectorProperty( + bbox_color: tuple[float, float, float] = NfFloatVectorProperty( name="BBox Color", subtype='COLOR', default=(0.0, 0.0, 1.0), min=0.0, max=1.0, size=3 - ) # pyright: ignore[reportInvalidTypeForm] + ) def init(self, context): self._init_exec() diff --git a/core/nodeslicer/nodes/path.py b/core/nodeslicer/nodes/path.py index c80b5b8..4e86a33 100644 --- a/core/nodeslicer/nodes/path.py +++ b/core/nodeslicer/nodes/path.py @@ -1,5 +1,4 @@ -from __future__ import annotations -from typing import Any +from typing import Any, cast, Literal import numpy as np from ..bpy_ns_types import NfEnumProperty, NfBoolProperty, notify_tree_changed @@ -149,14 +148,14 @@ class SLICENODE_SliceShells(SliceNode): class SLICENODE_BufferShells(SliceNode): bl_label = "Buffer Shells" - shape_option: NfEnumProperty( + shape_option: str = NfEnumProperty( name="Miter", items=[ ('round', "Round", ""), ('square', "Square", ""), ('flat', "Flat", "") ] - ) # pyright: ignore[reportInvalidTypeForm] + ) def init(self, context): self.add_input("SLICESOCK_Paths") @@ -183,7 +182,7 @@ class SLICENODE_BufferShells(SliceNode): class SLICENODE_BufferCurve(SliceNode): bl_label = "Buffer Curve" - cap_style: NfEnumProperty( + cap_style: str = NfEnumProperty( name="Cap Style", items=[ ('round', "Round", ""), @@ -191,9 +190,9 @@ class SLICENODE_BufferCurve(SliceNode): ('square', "Square", "") ], default='round' - ) # pyright: ignore[reportInvalidTypeForm] + ) - join_style: NfEnumProperty( + join_style: str = NfEnumProperty( name="Join Style", items=[ ('round', "Round", ""), @@ -201,13 +200,13 @@ class SLICENODE_BufferCurve(SliceNode): ('bevel', "Bevel", "") ], default='round' - ) # pyright: ignore[reportInvalidTypeForm] + ) - single_sided: NfBoolProperty( + single_sided: bool = NfBoolProperty( name="Single Sided", description="Buffer only on one side of the curve", default=False - ) # pyright: ignore[reportInvalidTypeForm] + ) def init(self, context): self.add_input("SLICESOCK_Paths") @@ -242,7 +241,7 @@ class SLICENODE_BufferCurve(SliceNode): class SLICENODE_OffsetCurve(SliceNode): bl_label = "Offset Curve" - join_style: NfEnumProperty( + join_style: str = NfEnumProperty( name="Join Style", items=[ ('round', "Round", ""), @@ -250,7 +249,7 @@ class SLICENODE_OffsetCurve(SliceNode): ('bevel', "Bevel", "") ], default='round' - ) # pyright: ignore[reportInvalidTypeForm] + ) def init(self, context): self.add_input("SLICESOCK_Paths") @@ -273,7 +272,7 @@ class SLICENODE_OffsetCurve(SliceNode): return paths.offset_curve( distance, - join_style=self.join_style, + join_style=cast(Literal['round', 'mitre'], self.join_style), rotation=rotation ) @@ -319,14 +318,14 @@ class SLICENODE_RotatePath(SliceNode): class SLICENODE_BufferShellsMultiple(SliceNode): bl_label = "Buffer Shells Multiple" - shape_option: NfEnumProperty( + shape_option: str = NfEnumProperty( name="Miter", items=[ ('round', "Round", ""), ('square', "Square", ""), ('flat', "Flat", "") ] - ) # pyright: ignore[reportInvalidTypeForm] + ) def init(self, context): self.add_input("SLICESOCK_Paths") @@ -357,7 +356,7 @@ class SLICENODE_BufferShellsMultiple(SliceNode): class SLICENODE_Infill(SliceNode): bl_label = "Infill" - infill_type: NfEnumProperty( + infill_type: str = NfEnumProperty( name="Type", items=[ ('GRID', "Grid", "Cross-hatch grid"), @@ -366,7 +365,7 @@ class SLICENODE_Infill(SliceNode): ], default='GRID', update=notify_tree_changed - ) # pyright: ignore[reportInvalidTypeForm] + ) def init(self, context): self.add_input("SLICESOCK_Paths") @@ -443,7 +442,7 @@ class SLICENODE_JoinSlices(SliceNodeJoiner): bl_label = "Boolean Paths" socket_type = "SLICESOCK_Paths" - operation: NfEnumProperty( + operation: str = NfEnumProperty( name="Operation", items=[ ('UNION', "Union", "Union of the slices"), @@ -452,13 +451,13 @@ class SLICENODE_JoinSlices(SliceNodeJoiner): ('XOR', "Exclusive or", "Exclusive or of the slices"), ], default='UNION' - ) # pyright: ignore[reportInvalidTypeForm] + ) - connect_along_boundary: NfBoolProperty( + connect_along_boundary: bool = NfBoolProperty( name="Connect along boundary", description="Connect clipped segments along the boundary of the clipping polygon", default=False - ) # pyright: ignore[reportInvalidTypeForm] + ) def draw_buttons(self, context, layout): assert layout @@ -489,14 +488,14 @@ class SLICENODE_JoinPaths(SliceNodeJoiner): bl_label = "Join Paths" socket_type = "SLICESOCK_Paths" - mode: NfEnumProperty( + mode: str = NfEnumProperty( name="Mode", items=[ ('EXTEND', "Extend", "Append paths sequentially"), ('ZIP', "Zip", "Interleave paths from all inputs"), ], default='EXTEND' - ) # pyright: ignore[reportInvalidTypeForm] + ) def draw_buttons(self, context, layout): assert layout @@ -507,7 +506,7 @@ class SLICENODE_JoinPaths(SliceNodeJoiner): inputs_data: list[SlicePaths] = [] for input in self.inputs: - paths: SlicePaths = resolve_input(input) + paths: SlicePaths | None = resolve_input(input) if paths: inputs_data.append(paths) @@ -526,7 +525,7 @@ class SLICENODE_JoinPaths(SliceNodeJoiner): class SLICENODE_SimplifyPaths(SliceNode): bl_label = "Simplify Paths" - preserve_topology: NfBoolProperty( # pyright: ignore[reportInvalidTypeForm] + preserve_topology: bool = NfBoolProperty( name="Preserve Topology", description="Prevents swapping of contained/containing geometry during simplification", default=True @@ -547,7 +546,6 @@ class SLICENODE_SimplifyPaths(SliceNode): paths: SlicePaths = self.resolve("Paths") tolerance: float = self.resolve("Tolerance") or 0.0 - if not paths or tolerance <= 0: return paths diff --git a/core/nodeslicer/nodes/preview.py b/core/nodeslicer/nodes/preview.py index 5604c61..c679f00 100644 --- a/core/nodeslicer/nodes/preview.py +++ b/core/nodeslicer/nodes/preview.py @@ -1,4 +1,5 @@ from __future__ import annotations +from typing import Any import bpy import os @@ -11,7 +12,7 @@ class SLICENODE_GcodeToMesh(ExecSliceNode): bl_label = "Gcode to Preview Mesh" button_label = "Update Preview" - show_e_0: NfBoolProperty(name="Show Zero Extrusions", default=False) # pyright: ignore[reportInvalidTypeForm] + show_e_0: bool = NfBoolProperty(name="Show Zero Extrusions", default=False) def init(self, context): self._init_exec() @@ -201,15 +202,14 @@ class SLICENODE_GcodeToMesh(ExecSliceNode): ng = bpy.data.node_groups.get(ng_name) # Get existing 'Preview' modifier or create it + mod: Any = None if hasattr(obj.modifiers, "get"): mod = obj.modifiers.get("Preview") elif isinstance(obj.modifiers, list): mod = next((m for m in obj.modifiers if (isinstance(m, dict) and m.get("name") == "Preview") or getattr(m, "name", "Preview") == "Preview"), None) - else: - mod = None - if (not mod or (hasattr(mod, "type") and mod.type != 'NODES')) and hasattr(obj.modifiers, "new"): - mod = obj.modifiers.new(name="Preview", type='NODES') + if (not mod or (hasattr(mod, "type") and getattr(mod, "type", None) != 'NODES')) and hasattr(obj.modifiers, "new"): + mod = getattr(obj.modifiers, "new")(name="Preview", type='NODES') elif not mod and isinstance(obj.modifiers, list): mod = {"name": "Preview", "type": "NODES"} obj.modifiers.append(mod) diff --git a/core/nodeslicer/nodes/system.py b/core/nodeslicer/nodes/system.py index 705120e..3b12926 100644 --- a/core/nodeslicer/nodes/system.py +++ b/core/nodeslicer/nodes/system.py @@ -56,7 +56,7 @@ class SLICENODE_PrinterList(ExecSliceNode): def update_config(self, context): self._refresh() - printers_json_path: NfStringProperty(name="Config File", default="", subtype='FILE_PATH', update=update_config) # pyright: ignore[reportInvalidTypeForm] + printers_json_path: str = NfStringProperty(name="Config File", default="", subtype='FILE_PATH', update=update_config) def init(self, context): self._init_exec() @@ -157,7 +157,7 @@ class SLICENODE_PrinterList(ExecSliceNode): class SLICENODE_PrusaslicerSlice(SliceNode): bl_label = "PrusaSlicer Slice" - prusaslicer_executable: NfStringProperty(name="PrusaSlicer Path", subtype='FILE_PATH', default='flatpak run com.prusa3d.PrusaSlicer') # pyright: ignore[reportInvalidTypeForm] + prusaslicer_executable: str = NfStringProperty(name="PrusaSlicer Path", subtype='FILE_PATH', default='flatpak run com.prusa3d.PrusaSlicer') def init(self, context): self.add_input("SLICESOCK_PrusaslicerConf", "Config") @@ -309,11 +309,11 @@ class SLICENODE_PrusaslicerPreview(ExecSliceNode): bl_label = "PrusaSlicer Preview" button_label = "Open Preview" - prusaslicer_executable: NfStringProperty( + prusaslicer_executable: str = NfStringProperty( name="PrusaSlicer Path", subtype='FILE_PATH', default='flatpak run com.prusa3d.PrusaSlicer' - ) # pyright: ignore[reportInvalidTypeForm] + ) def init(self, context): self._init_exec() @@ -396,7 +396,7 @@ class SLICENODE_Executor(ExecSliceNode, SliceNodeJoiner): button_label = "Execute All" socket_type = "SLICESOCK_ExecutionLink" - auto_refresh: NfBoolProperty(name="Auto-refresh", default=False) # pyright: ignore[reportInvalidTypeForm] + auto_refresh: bool = NfBoolProperty(name="Auto-refresh", default=False) def init(self, context): self._init_exec() diff --git a/core/nodeslicer/sockets.py b/core/nodeslicer/sockets.py index d72fbd7..5fb4166 100644 --- a/core/nodeslicer/sockets.py +++ b/core/nodeslicer/sockets.py @@ -94,8 +94,8 @@ class SLICESOCK_PrusaslicerConf(LabelNodeSocket): @register_class class SettingItem(bpy.types.PropertyGroup): - field: NfStringProperty(name="Field", default="") # pyright: ignore[reportInvalidTypeForm] - value_str: NfStringProperty(name="Value", default="") # pyright: ignore[reportInvalidTypeForm] + field: str = NfStringProperty(name="Field", default="") + value_str: str = NfStringProperty(name="Value", default="") @property def is_active(self): @@ -137,14 +137,14 @@ class SLICESOCK_ExecutionLink(LabelNodeSocket): class SLICESOCK_Printer(InputNodeSocket): bl_label = "Printer" - host: NfStringProperty(name="Host", default="localhost") # pyright: ignore[reportInvalidTypeForm] - port: NfIntProperty(name="Port", default=80, min=1, max=65535) # pyright: ignore[reportInvalidTypeForm] - prefix: NfStringProperty(name="Prefix", default="") # pyright: ignore[reportInvalidTypeForm] - label: NfStringProperty(name="Label", default="Printer") # pyright: ignore[reportInvalidTypeForm] - username: NfStringProperty(name="Username", default="") # pyright: ignore[reportInvalidTypeForm] - password: NfStringProperty(name="Password", default="", subtype='PASSWORD') # pyright: ignore[reportInvalidTypeForm] + host: str = NfStringProperty(name="Host", default="localhost") + port: int = NfIntProperty(name="Port", default=80, min=1, max=65535) + prefix: str = NfStringProperty(name="Prefix", default="") + label: str = NfStringProperty(name="Label", default="Printer") + username: str = NfStringProperty(name="Username", default="") + password: str = NfStringProperty(name="Password", default="", subtype='PASSWORD') - host_type: NfEnumProperty( + host_type: str = NfEnumProperty( name="Type", items=[ ('PRUSALINK', "PrusaLink", "PrusaLink API"), @@ -153,7 +153,7 @@ class SLICESOCK_Printer(InputNodeSocket): ], default='PRUSALINK', update=notify_tree_changed - ) # pyright: ignore[reportInvalidTypeForm] + ) bl_color = (1.0, 0.45, 0.1, 1.0) # Orange diff --git a/core/nodeslicer/tree.py b/core/nodeslicer/tree.py index 1298377..9507ba4 100644 --- a/core/nodeslicer/tree.py +++ b/core/nodeslicer/tree.py @@ -1,5 +1,4 @@ -from __future__ import annotations -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any, cast import bpy from bpy.types import NodeTree, Operator, Menu from bpy.app.handlers import persistent @@ -179,7 +178,7 @@ class SLICENODE_OT_GroupToggle(Operator): bl_description = "Enter or exit the selected node group" bl_options = {'REGISTER', 'UNDO'} - node_name: bpy.props.StringProperty() # pyright: ignore[reportInvalidTypeForm] + node_name: str = cast(Any, bpy.props.StringProperty()) @classmethod def poll(cls, context): @@ -463,7 +462,7 @@ class SLICENODE_OT_Execute(Operator): bl_label = "Execute" bl_description = "Execute" - node_name: bpy.props.StringProperty() # pyright: ignore[reportInvalidTypeForm] + node_name: str = cast(Any, bpy.props.StringProperty()) def execute(self, context) -> set['OperatorReturnItems']: node_tree = context.space_data.edit_tree # pyright: ignore[reportAttributeAccessIssue, reportOptionalMemberAccess] diff --git a/core/pdf_exporter/geometry.py b/core/pdf_exporter/geometry.py index be3ce08..c923534 100644 --- a/core/pdf_exporter/geometry.py +++ b/core/pdf_exporter/geometry.py @@ -52,7 +52,7 @@ def transform_slice_paths( if not (has_scale or has_rot or has_trans): return sp.copy_with(paths=list(sp)) - R = None + rot_mat: np.ndarray | None = None if has_rot: rx, ry, rz = r_arr cx, sx = np.cos(rx), np.sin(rx) @@ -74,7 +74,7 @@ def transform_slice_paths( [sz, cz, 0.0], [0.0, 0.0, 1.0] ], dtype=np.float64) - R = Rz @ (Ry @ Rx) + rot_mat = Rz @ (Ry @ Rx) new_paths = [] for p in sp: @@ -91,11 +91,11 @@ def transform_slice_paths( else: new_p[:, :dim] *= s_arr[:dim] - if has_rot and R is not None and dim > 0: + if has_rot and rot_mat is not None and dim > 0: if dim >= 3: - new_p[:, :3] = new_p[:, :3] @ R.T + new_p[:, :3] = new_p[:, :3] @ rot_mat.T elif dim == 2: - rot_2d = R[:2, :2] + rot_2d = rot_mat[:2, :2] new_p[:, :2] = new_p[:, :2] @ rot_2d.T if has_trans and dim > 0: @@ -752,12 +752,12 @@ def extract_silhouette_contour_edges(mesh_data: Any) -> NDArray[np.float64]: elif isinstance(item, SlicingObject): if item.mesh.size > 0: verts_3d = item.mesh[:, :3, :3] + v0_3d = verts_3d[:, 0, :] + v1_3d = verts_3d[:, 1, :] + v2_3d = verts_3d[:, 2, :] if item.mesh.shape[1] >= 4: normals = item.mesh[:, 3, :] else: - v0_3d = verts_3d[:, 0, :] - v1_3d = verts_3d[:, 1, :] - v2_3d = verts_3d[:, 2, :] normals = np.cross(v1_3d - v0_3d, v2_3d - v0_3d) norms_len = np.linalg.norm(normals, axis=1, keepdims=True) diff --git a/core/pdf_exporter/metadata.py b/core/pdf_exporter/metadata.py index 5f02c85..2ff9cf6 100644 --- a/core/pdf_exporter/metadata.py +++ b/core/pdf_exporter/metadata.py @@ -38,22 +38,34 @@ def get_first_attribute_value(so_item: Any, attr_name: str) -> Any: return val.item() return val + if hasattr(so_item, 'objects') and isinstance(so_item.objects, list): + for obj in so_item.objects: + v = get_first_attribute_value(obj, attr_name) + if v is not None: + return v + + if hasattr(so_item, 'collections') and isinstance(so_item.collections, dict): + for coll in so_item.collections.values(): + v = get_first_attribute_value(coll, attr_name) + if v is not None: + return v + attr_key = attr_name.lower().replace(" ", "_") if hasattr(so_item, attr_key): val = getattr(so_item, attr_key) if val is not None and not callable(val): if isinstance(val, (list, tuple, np.ndarray)) and len(val) > 0: val = val[0] - if hasattr(val, 'item'): - return val.item() + if hasattr(val, 'item') and callable(getattr(val, 'item')): + return getattr(val, 'item')() return val elif hasattr(so_item, attr_name): val = getattr(so_item, attr_name) if val is not None and not callable(val): if isinstance(val, (list, tuple, np.ndarray)) and len(val) > 0: val = val[0] - if hasattr(val, 'item'): - return val.item() + if hasattr(val, 'item') and callable(getattr(val, 'item')): + return getattr(val, 'item')() return val return None diff --git a/core/pdf_exporter/sa2_measures.py b/core/pdf_exporter/sa2_measures.py index cc48d2f..835028a 100644 --- a/core/pdf_exporter/sa2_measures.py +++ b/core/pdf_exporter/sa2_measures.py @@ -24,7 +24,7 @@ from .pdf_utils import PdfStream, escape_pdf_text # Geometry Utilities # ============================================================================= -def circumcircle_3pts(p1: NDArray, p2: NDArray, p3: NDArray) -> tuple[tuple[float, float] | None, float | None]: +def circumcircle_3pts(p1: NDArray[Any], p2: NDArray[Any], p3: NDArray[Any]) -> tuple[tuple[float, float] | None, float | None]: """Calculate center and radius of circumcircle passing through 3 2D points.""" ax, ay = float(p1[0]), float(p1[1]) bx, by = float(p2[0]), float(p2[1]) @@ -518,7 +518,7 @@ def render_sa2_measures_pdf( """ Renders ISO 129-1 compliant SA2 hole measurements (bores, concentric steps, Z-depth, pitch) on PDF stream. """ - if raw_geometry is None or not draw_sa2_holes: + if raw_geometry is None or not draw_sa2_holes or to_pdf_pt is None: return sa2_objects = extract_slicing_objects_bounds(raw_geometry) @@ -586,6 +586,7 @@ def render_sa2_measures_pdf( ) # Concentric Bores Grouping & Hole Callouts + concentric_groups: list[list[tuple[float, float, float]]] = [] if draw_sa2_holes and holes: hole_color = (0.10, 0.45, 0.90) s.stroke_color(*hole_color).fill_color(*hole_color) @@ -611,7 +612,7 @@ def render_sa2_measures_pdf( ) # --- Pitch Dimensions --- - if draw_sa2_holes and 'concentric_groups' in locals() and len(concentric_groups) >= 2: + if draw_sa2_holes and len(concentric_groups) >= 2: pitch_color = (0.05, 0.58, 0.53) s.stroke_color(*pitch_color).fill_color(*pitch_color) diff --git a/core/slice.py b/core/slice.py index d355667..018b5ad 100644 --- a/core/slice.py +++ b/core/slice.py @@ -2,9 +2,10 @@ from __future__ import annotations from numpy import float64 import numpy as np import pyclipper -from typing import Any, Literal +from typing import Any, Literal, cast import copy from shapely.geometry.base import BaseGeometry +from shapely.geometry import Polygon, MultiPolygon, LineString, LinearRing, MultiLineString, GeometryCollection from ..infra.blender.blender_mesh_capture import PdfProperties @@ -162,13 +163,13 @@ class SlicePaths(list[np.ndarray]): extra_attributes: dict[str, list[np.ndarray]] layer_height: float first_layer_height: float | None - transforms: list[dict] + transforms: list[dict[str, Any]] object_properties: list[tuple[str, str]] pdf_properties: PdfProperties name: str - objects_info: dict | None + objects_info: dict[str, Any] | None - def __init__(self, paths: list[np.ndarray] = [], groups: list[int] = [], extrusion_rate: list[np.ndarray] | None = None, feedrate: list[np.ndarray] | None = None, extra_attributes: dict[str, list[np.ndarray]] | None = None, layer_height: float = 0.2, first_layer_height: float | None = None, transforms: list[dict] | None = None, name: str = "", objects_info: dict | None = None): + def __init__(self, paths: list[np.ndarray] = [], groups: list[int] = [], extrusion_rate: list[np.ndarray] | None = None, feedrate: list[np.ndarray] | None = None, extra_attributes: dict[str, list[np.ndarray]] | None = None, layer_height: float = 0.2, first_layer_height: float | None = None, transforms: list[dict[str, Any]] | None = None, name: str = "", objects_info: dict[str, Any] | None = None): super().__init__(paths) self.groups = [] @@ -357,8 +358,9 @@ class SlicePaths(list[np.ndarray]): merged_names = [] merged_objs = [] for sp in paths_list: - if hasattr(sp, "objects_info") and getattr(sp, "objects_info"): - for obj_entry in sp.objects_info.get("objects", []): + obj_info = getattr(sp, "objects_info", None) + if obj_info: + for obj_entry in obj_info.get("objects", []): if obj_entry not in merged_objs: merged_objs.append(dict(obj_entry)) n_str = obj_entry.get("name", "") @@ -1048,13 +1050,13 @@ def boolean_slice(subject: SlicePaths, object: SlicePaths, operation: str = 'UNI return results -def geometry_to_paths(geom: BaseGeometry, z: float) -> list[np.ndarray]: +def geometry_to_paths(geom: Any, z: float) -> list[np.ndarray]: """Helper to convert shapely geometries to a list of 3D numpy arrays with a given Z height.""" paths = [] if geom.is_empty: return paths - if geom.geom_type == 'Polygon': + if isinstance(geom, Polygon): ext_coords = np.array(geom.exterior.coords) ext_coords_3d = np.column_stack([ext_coords, np.full(len(ext_coords), z)]) paths.append(ext_coords_3d) @@ -1062,12 +1064,9 @@ def geometry_to_paths(geom: BaseGeometry, z: float) -> list[np.ndarray]: int_coords = np.array(interior.coords) int_coords_3d = np.column_stack([int_coords, np.full(len(int_coords), z)]) paths.append(int_coords_3d) - elif geom.geom_type == 'MultiPolygon': + elif isinstance(geom, (MultiPolygon, GeometryCollection)): for poly in geom.geoms: paths.extend(geometry_to_paths(poly, z)) - elif geom.geom_type == 'GeometryCollection': - for sub_geom in geom.geoms: - paths.extend(geometry_to_paths(sub_geom, z)) return paths @@ -1109,8 +1108,8 @@ def buffer_curve_slice( poly = line.buffer( distance, - cap_style=cap_style, - join_style=join_style, + cap_style=cast(Literal['round', 'square', 'flat'], cap_style), + join_style=cast(Literal['round', 'mitre', 'bevel'], join_style), single_sided=single_sided, quad_segs=16 ) @@ -1125,25 +1124,25 @@ def buffer_curve_slice( return paths_list, groups_list -def offset_geometry_to_paths(geom: BaseGeometry, z: float) -> list[np.ndarray]: +def offset_geometry_to_paths(geom: Any, z: float) -> list[np.ndarray]: """Helper to convert shapely line geometries to a list of 3D numpy arrays with a given Z height.""" paths = [] if geom.is_empty: return paths - if geom.geom_type in ('LineString', 'LinearRing'): + if isinstance(geom, (LineString, LinearRing)): coords = np.array(geom.coords) coords_3d = np.column_stack([coords, np.full(len(coords), z)]) paths.append(coords_3d) - elif geom.geom_type == 'MultiLineString': + elif isinstance(geom, MultiLineString): for line in geom.geoms: coords = np.array(line.coords) coords_3d = np.column_stack([coords, np.full(len(coords), z)]) paths.append(coords_3d) - elif geom.geom_type == 'GeometryCollection': + elif isinstance(geom, GeometryCollection): for sub_geom in geom.geoms: paths.extend(offset_geometry_to_paths(sub_geom, z)) - elif geom.geom_type in ('Polygon', 'MultiPolygon'): + elif isinstance(geom, (Polygon, MultiPolygon)): boundary = geom.boundary paths.extend(offset_geometry_to_paths(boundary, z)) return paths @@ -1288,7 +1287,6 @@ def paths_to_shapely_polygon(paths: list[np.ndarray]) -> BaseGeometry: return Polygon() import pyclipper - from shapely.geometry import Polygon from shapely.ops import unary_union pc = pyclipper.Pyclipper() # pyright: ignore[reportAttributeAccessIssue] @@ -1606,8 +1604,8 @@ class Pather(object): self.retraction: float = 0.0 self.retraction_feedrate: float = 0.0 self.lift: float = 0.0 - self.transforms: list[dict] = list(getattr(paths, "transforms", [])) - self.objects_info: dict | None = getattr(paths, "objects_info", None) + self.transforms: list[dict[str, Any]] = list(getattr(paths, "transforms", [])) + self.objects_info: dict[str, Any] | None = getattr(paths, "objects_info", None) self.name: str = str(getattr(paths, "name", "")) @property diff --git a/doc/NODES.md b/doc/NODES.md index 22feea0..3f9ec32 100644 --- a/doc/NODES.md +++ b/doc/NODES.md @@ -61,6 +61,8 @@ All nodes are found in the **Add (Shift+A)** menu under the **SliceNodes** categ ### Geometry Processing - **Get Mesh**: Converts a Blender Object into internal `SlicingObject` format. +- **Separate Mesh**: Splits a `SlicingCollection` into two collections (`Selection` and `Inverted`) by comparing a specified mesh attribute against a target value with comparison operators (`==`, `!=`, `>`, `<`, `>=`, `<=`). +- **Separate Mesh by Range**: Splits a `SlicingCollection` into multiple output sockets across an integer range (`Start` to `End`), matching a specified integer attribute. Objects in each output socket are renamed with a zero-indexed padded suffix (`f"{base_mesh_name}{i:0{num_digits}d}"` where `num_digits = max(2, len(str(total_parts)))`). - **Rename Mesh**: Overrides the internal metadata name of a `SlicingObject`, `SlicingCollection`, or `SlicingGroup`. Supports `{AttributeName}` attribute placeholders (e.g., `Rebar-L{Rebar Length}M`) that evaluate to index zero of the target attribute for each object, as well as `{Name}` to include the item's previous name (e.g., `{Name}_T{Thickness}mm`). - **Geometry**: Input geometry data. - **Name**: String input socket or property for the new name template. diff --git a/infra/_3mf_writer/_3mf.py b/infra/_3mf_writer/_3mf.py index 6bc9d04..66e56b7 100644 --- a/infra/_3mf_writer/_3mf.py +++ b/infra/_3mf_writer/_3mf.py @@ -1,4 +1,5 @@ from __future__ import annotations +from typing import Any from numpy.typing import NDArray from pathlib import Path @@ -40,7 +41,7 @@ def write_metadata_xml(group: SlicingGroup, filepath): for j, (k, collection) in enumerate(valid_collections.items()): - sorted_data: list[tuple[NDArray, NDArray, SlicingObject]] = sorted(zip(collection.mesh_start_ids, collection.mesh_end_ids, collection.objects), key=lambda x: object_type_order.get(x[2].object_type, 5)) + sorted_data: list[tuple[NDArray[Any], NDArray[Any], SlicingObject]] = sorted(zip(collection.mesh_start_ids, collection.mesh_end_ids, collection.objects), key=lambda x: object_type_order.get(x[2].object_type, 5)) object_elem = ET.SubElement(xml_content, "object", id=str(j+1), instances_count="1") ET.SubElement(object_elem, "metadata", type="object", key="name", value=collection.name) diff --git a/infra/blender/blender_mesh_capture.py b/infra/blender/blender_mesh_capture.py index 18f86ef..7a5b3be 100644 --- a/infra/blender/blender_mesh_capture.py +++ b/infra/blender/blender_mesh_capture.py @@ -40,7 +40,8 @@ def get_evaluated_mesh(obj: Object): try: eval_obj = obj.evaluated_get(depsgraph) m = eval_obj.to_mesh(preserve_all_data_layers=True, depsgraph=depsgraph) - if m and len(m.vertices) > 0 and not is_hidden and (not obj.modifiers or len(m.vertices) != len(obj.data.vertices)): + orig_verts = getattr(obj.data, "vertices", None) + if m and len(m.vertices) > 0 and not is_hidden and (not obj.modifiers or (orig_verts is not None and len(m.vertices) != len(orig_verts))): mesh = m elif m: eval_obj.to_mesh_clear() @@ -52,7 +53,9 @@ def get_evaluated_mesh(obj: Object): try: temp_obj = obj.copy() temp_obj.hide_viewport = False - bpy.context.scene.collection.objects.link(temp_obj) + scene = bpy.context.scene + if scene and scene.collection: + scene.collection.objects.link(temp_obj) depsgraph = bpy.context.evaluated_depsgraph_get() depsgraph.update() @@ -63,7 +66,9 @@ def get_evaluated_mesh(obj: Object): except Exception: if temp_obj is not None: try: - bpy.context.scene.collection.objects.unlink(temp_obj) + scene = bpy.context.scene + if scene and scene.collection: + scene.collection.objects.unlink(temp_obj) bpy.data.objects.remove(temp_obj, do_unlink=True) except Exception: pass @@ -80,7 +85,9 @@ def get_evaluated_mesh(obj: Object): pass if is_temp and temp_obj is not None: try: - bpy.context.scene.collection.objects.unlink(temp_obj) + scene = bpy.context.scene + if scene and scene.collection: + scene.collection.objects.unlink(temp_obj) bpy.data.objects.remove(temp_obj, do_unlink=True) bpy.context.evaluated_depsgraph_get().update() except Exception: @@ -100,9 +107,9 @@ class SlicingObject(): name: str parent: str mesh: NDArray[float64] - attributes: dict[str, dict] + attributes: dict[str, Any] object_type: str = "ModelPart" - modifiers: list[dict] = [] + modifiers: list[dict[str, Any]] = [] extruder: int = 1 pdf_properties: PdfProperties object_properties: list[tuple[str, str]] @@ -176,10 +183,10 @@ class SlicingObject(): } return merged - def offset(self, offset: NDArray): + def offset(self, offset: NDArray[Any]): self.mesh[:, :3, :] += offset - def scale(self, factor: float | NDArray): + def scale(self, factor: float | NDArray[Any]): self.mesh[:, :3, :] *= factor if isinstance(factor, (list, tuple, np.ndarray)): factor_arr = np.array(factor, dtype=np.float64) @@ -192,7 +199,7 @@ class SlicingObject(): norms_len = np.where(norms_len == 0, 1.0, norms_len) self.mesh[:, 3, :] = norms / norms_len - def rotate(self, angles: NDArray): + 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 @@ -268,18 +275,18 @@ class SlicingObject(): def max_z(self) -> float: return self.mesh[:, :3, 2].max() @property - def min_xy(self) -> NDArray: return np.array([self.min_x, self.min_y, 0.0]) + def min_xy(self) -> NDArray[Any]: return np.array([self.min_x, self.min_y, 0.0]) @property - def max_xy(self) -> NDArray: return np.array([self.max_x, self.max_y, 0.0]) + def max_xy(self) -> NDArray[Any]: return np.array([self.max_x, self.max_y, 0.0]) @property - def center_xy(self) -> NDArray: return (self.min_xy + self.max_xy) / 2.0 + def center_xy(self) -> NDArray[Any]: return (self.min_xy + self.max_xy) / 2.0 class SlicingCollection(): name: str objects: list[SlicingObject] - transforms: list[dict] + transforms: list[dict[str, Any]] def __init__(self, objs: list[Object], parent: str): self.objects = [SlicingObject(obj, parent) for obj in objs] @@ -292,15 +299,15 @@ class SlicingCollection(): if prop in self.__dict__: del self.__dict__[prop] - def offset(self, offset: NDArray): + def offset(self, offset: NDArray[Any]): for so in self.objects: so.offset(offset) self._clear_cache() - def scale(self, factor: float | NDArray): + def scale(self, factor: float | NDArray[Any]): for so in self.objects: so.scale(factor) self._clear_cache() - def rotate(self, angles: NDArray): + def rotate(self, angles: NDArray[Any]): for so in self.objects: so.rotate(angles) self._clear_cache() @@ -315,26 +322,26 @@ class SlicingCollection(): return max(height_all) @property - def meshes(self) -> list[NDArray]: + def meshes(self) -> list[NDArray[Any]]: return [o.mesh for o in self.objects] @cached_property - def mesh_lengths_ids(self) -> NDArray: + def mesh_lengths_ids(self) -> NDArray[Any]: return np.array([len(m) for m in self.meshes]) @cached_property - def mesh_start_ids(self) -> NDArray: + def mesh_start_ids(self) -> NDArray[Any]: lengths = self.mesh_lengths_ids return np.insert(np.cumsum(lengths)[:-1], 0, 0) @cached_property - def mesh_end_ids(self) -> NDArray: + def mesh_end_ids(self) -> NDArray[Any]: starts = self.mesh_start_ids lengths = self.mesh_lengths_ids return starts + lengths - 1 @cached_property - def all_verts(self) -> NDArray: + def all_verts(self) -> NDArray[Any]: all_verts = np.vstack(self.meshes)[:, :3, :] return all_verts.reshape(-1, 3) @@ -388,17 +395,17 @@ class SlicingCollection(): return max([so.max_z for so in self.objects if np.any(so.mesh)], default=None) @property - def min_xy(self) -> NDArray | None: + def min_xy(self) -> NDArray[Any] | None: if self.min_x is None or self.min_y is None: return None return np.array([self.min_x, self.min_y, 0.0]) @property - def max_xy(self) -> NDArray | None: + def max_xy(self) -> NDArray[Any] | None: if self.max_x is None or self.max_y is None: return None return np.array([self.max_x, self.max_y, 0.0]) @property - def center_xy(self) -> NDArray | None: + def center_xy(self) -> NDArray[Any] | None: if self.min_xy is None or self.max_xy is None: return None return (self.min_xy + self.max_xy) / 2.0 @@ -407,10 +414,10 @@ class SlicingGroup(): collections: dict[str, SlicingCollection] # Metadata - wipe_tower_xy: NDArray = np.array([0, 0]) + wipe_tower_xy: NDArray[Any] = np.array([0, 0]) wipe_tower_rotation_deg: float = 0 - object_offset: NDArray = np.array([0.0, 0.0, 0.0]) - transforms: list[dict] + object_offset: NDArray[Any] = np.array([0.0, 0.0, 0.0]) + transforms: list[dict[str, Any]] def __init__(self, selected_objs: list[Object]) -> None: self.object_offset = np.array([0.0, 0.0, 0.0]) @@ -443,18 +450,19 @@ class SlicingGroup(): assert bpy.context.scene and bpy.context.scene.unit_settings scale_tx = 1000. * bpy.context.scene.unit_settings.scale_length #type: ignore - self.wipe_tower_xy = self.center_xy[0:2] + if self.center_xy is not None: + self.wipe_tower_xy = self.center_xy[0:2] for obj in objs: with get_evaluated_mesh(obj) as (eval_obj, _m): - if eval_obj and hasattr(eval_obj, 'nodeslicer'): - if eval_obj.nodeslicer.object_type == 'WipeTower': - self.wipe_tower_xy = np.array(eval_obj.location[0:2]) * scale_tx - self.wipe_tower_rotation_deg = round(eval_obj.rotation_euler[2]*180/math.pi,5) - break + ns_prop = getattr(eval_obj, 'nodeslicer', None) + if eval_obj is not None and ns_prop and getattr(ns_prop, 'object_type', None) == 'WipeTower': + self.wipe_tower_xy = np.array(eval_obj.location[0:2]) * scale_tx + self.wipe_tower_rotation_deg = round(eval_obj.rotation_euler[2]*180/math.pi,5) + break return - def offset(self, offset: NDArray): + def offset(self, offset: NDArray[Any]): for k, so in self.collections.items(): so.offset(offset) self.wipe_tower_xy = self.wipe_tower_xy + offset[0:2] @@ -499,17 +507,17 @@ class SlicingGroup(): def max_z(self) -> float: return max([so.max_z for k, so in self.collections.items() if so.max_z is not None], default=0) @property - def min_xy(self) -> NDArray: + def min_xy(self) -> NDArray[Any]: if self.min_x is None or self.min_y is None: return np.array([.0, .0, .0]) return np.array([self.min_x, self.min_y, 0.0]) @property - def max_xy(self) -> NDArray: + def max_xy(self) -> NDArray[Any]: if self.max_x is None or self.max_y is None: return np.array([.0, .0, .0]) return np.array([self.max_x, self.max_y, 0.0]) @property - def center_xy(self) -> NDArray: + def center_xy(self) -> NDArray[Any]: if self.min_xy is None or self.max_xy is None: return np.array([.0, .0, .0]) return (self.min_xy + self.max_xy) / 2.0 @@ -525,7 +533,7 @@ class TriMesh(): self.length_tris = t self.matrix_world = m -def _cached_objects_to_tris(obj_names: tuple[str, ...], matrices: tuple[tuple[float, ...], ...], scale: float) -> tuple[NDArray[float64], dict[str, dict]]: +def _cached_objects_to_tris(obj_names: tuple[str, ...], matrices: tuple[tuple[float, ...], ...], scale: float) -> tuple[NDArray[float64], dict[str, dict[str, Any]]]: tris_count: int = 0 meshes: list[TriMesh] = [] @@ -656,7 +664,7 @@ def _cached_objects_to_tris(obj_names: tuple[str, ...], matrices: tuple[tuple[fl return tris, attributes_data -def objects_to_tris(objects: list[Object], scale) -> tuple[NDArray[float64], dict[str, dict]]: +def objects_to_tris(objects: list[Object], scale: float) -> tuple[NDArray[float64], dict[str, dict[str, Any]]]: obj_names = tuple(o.name for o in objects) matrices = tuple(tuple(val for row in o.matrix_world for val in row) for o in objects) return _cached_objects_to_tris(obj_names, matrices, scale) diff --git a/infra/blender/blender_slice_nodes_generic.py b/infra/blender/blender_slice_nodes_generic.py index 13db522..f295d2d 100644 --- a/infra/blender/blender_slice_nodes_generic.py +++ b/infra/blender/blender_slice_nodes_generic.py @@ -1,15 +1,15 @@ from __future__ import annotations from dataclasses import field import typing +from typing import Any, cast import bpy from contextlib import contextmanager - -from typing import Any from bpy.types import NodeSocket, Node class NodeException(Exception): ... -_EvalCache = dict[tuple[int, str, str], Any] +_EvalCache = dict[Any, Any] +_VisitedSet = set[Any] NATIVE_SOCKET_MAP: dict[str, str] = {} @@ -39,7 +39,7 @@ if hasattr(bpy.types, "NodeSocket") and not hasattr(bpy.types.NodeSocket, "value self._value = val try: - bpy.types.NodeSocket.value = property(_get_socket_value, _set_socket_value) + setattr(bpy.types.NodeSocket, "value", property(_get_socket_value, _set_socket_value)) except Exception: pass @@ -55,6 +55,17 @@ class AutoBlIdName: if "bl_idname" not in cls.__dict__: cls.bl_idname = cls.__name__ + if not hasattr(cls, "__annotations__"): + cls.__annotations__ = {} + for name, val in list(cls.__dict__.items()): + val_type_name = type(val).__name__ + if "PropertyDeferred" in val_type_name or val_type_name.startswith("_Property") or (isinstance(val, tuple) and len(val) > 0 and "Property" in type(val[0]).__name__): + cls.__annotations__[name] = val + try: + delattr(cls, name) + except (AttributeError, TypeError): + pass + class SliceNodeSocket(NodeSocket, AutoBlIdName): default: Any = None bl_color = (1.0, 1.0, 1.0, 1.0) @@ -64,7 +75,11 @@ class SliceNodeSocket(NodeSocket, AutoBlIdName): from ...registry import get_label_by_name, register_class class SliceNode(Node, AutoBlIdName): - def get(self, key: str, default: Any = None) -> Any: + _current_cache: _EvalCache | None = None + _current_visited: _VisitedSet | None = None + _data: dict[str, Any] | None = None + + def get(self, key: str, default: Any = None) -> Any: # pyright: ignore[reportIncompatibleMethodOverride] try: return super().get(key, default) # type: ignore except (AttributeError, TypeError): @@ -73,7 +88,7 @@ class SliceNode(Node, AutoBlIdName): return self._data.get(key, default) return getattr(self, key, default) - def __setitem__(self, key: str, value: Any) -> None: + def __setitem__(self, key: str, value: Any) -> None: # pyright: ignore[reportIncompatibleMethodOverride] try: super().__setitem__(key, value) # type: ignore except (AttributeError, TypeError): @@ -81,7 +96,7 @@ class SliceNode(Node, AutoBlIdName): self._data = {} self._data[key] = value - def __getitem__(self, key: str) -> Any: + def __getitem__(self, key: str) -> Any: # pyright: ignore[reportIncompatibleMethodOverride] try: return super().__getitem__(key) # type: ignore except (AttributeError, TypeError): @@ -91,11 +106,14 @@ class SliceNode(Node, AutoBlIdName): def __contains__(self, key: str) -> bool: try: - return super().__contains__(key) # type: ignore + contains_fn = getattr(super(), "__contains__", None) + if contains_fn: + return contains_fn(key) # type: ignore except (AttributeError, TypeError): - if hasattr(self, "_data") and isinstance(self._data, dict): - return key in self._data - return False + pass + if hasattr(self, "_data") and isinstance(self._data, dict): + return key in self._data + return False def update(self) -> None: self._update_multi_groups() @@ -292,10 +310,13 @@ class SLICENODE_Group(bpy.types.NodeCustomGroup, AutoBlIdName): self.sync_sockets() - slicer_tree: bpy.props.PointerProperty( # pyright: ignore[reportInvalidTypeForm] - type=bpy.types.NodeTree, - name="Node Tree", - update=_tree_updated + slicer_tree: bpy.types.NodeTree | None = cast( + Any, + bpy.props.PointerProperty( + type=bpy.types.NodeTree, + name="Node Tree", + update=_tree_updated + ) ) def sync_sockets(self): @@ -375,19 +396,16 @@ class SLICENODE_Group(bpy.types.NodeCustomGroup, AutoBlIdName): return "Group" -def _resolve_group_output(socket: NodeSocket, cache: _EvalCache | None, visited: set[tuple[int, str]] | None): +def _resolve_group_output(socket: NodeSocket, cache: _EvalCache | None, visited: _VisitedSet | None): node = socket.node - assert isinstance(node, (bpy.types.NodeGroup, SLICENODE_Group)) + assert node is not None - if isinstance(node, SLICENODE_Group): - tree = node.slicer_tree - else: - tree = node.node_tree - + tree = getattr(node, "slicer_tree", None) or getattr(node, "node_tree", None) if not tree: return None try: - idx = list(node.outputs).index(socket) + outputs_list = list(node.outputs) if node.outputs is not None else [] + idx = outputs_list.index(socket) except (ValueError, IndexError): return None @@ -410,14 +428,16 @@ def _resolve_group_output(socket: NodeSocket, cache: _EvalCache | None, visited: with _group_context(node, cache=cache): return resolve_input(typing.cast(Any, inner_socket), cache=cache, visited=visited) -def _resolve_group_input(socket: NodeSocket, cache: _EvalCache | None, visited: set[tuple[int, str]] | None): +def _resolve_group_input(socket: NodeSocket, cache: _EvalCache | None, visited: _VisitedSet | None): stack = _get_group_stack(cache) if not stack: return None group_node = stack.pop() # NodeGroupInput outputs usually match the group inputs by index try: - idx = list(socket.node.outputs).index(socket) + assert socket.node is not None + outputs_list = list(socket.node.outputs) if socket.node.outputs is not None else [] + idx = outputs_list.index(socket) outer_socket = group_node.inputs[idx] except (ValueError, IndexError): stack.append(group_node) @@ -428,12 +448,9 @@ def _resolve_group_input(socket: NodeSocket, cache: _EvalCache | None, visited: finally: stack.append(group_node) -def _follow_reroute(socket: NodeSocket) -> NodeSocket: - current: NodeSocket = socket - seen: set[int] = set() - - while getattr(current.node, "bl_idname", "") == "NodeReroute": - assert current.node +def _follow_reroute(current: NodeSocket) -> NodeSocket: + seen = set() + while current.node and getattr(current.node, "bl_idname", "") == "NodeReroute": ptr = current.node.as_pointer() if ptr in seen: break @@ -451,7 +468,7 @@ def resolve_input( socket: SliceNodeSocket | None, *, cache: _EvalCache | None = None, - visited: set[tuple[int, str]] | None = None, + visited: _VisitedSet | None = None, ): if socket is None: raise Exception(f"The queried socket is non-existant") @@ -494,7 +511,7 @@ def _resolve_output( *, default: Any = None, cache: _EvalCache | None = None, - visited: set[tuple[int, str]] | None = None, + visited: _VisitedSet | None = None, ): if cache is None: cache = _current_execution_cache diff --git a/infra/blender/mixins.py b/infra/blender/mixins.py index 71051d1..9903961 100644 --- a/infra/blender/mixins.py +++ b/infra/blender/mixins.py @@ -1,7 +1,8 @@ +from typing import Any import bpy class DrawCollectionMixin(): - items: bpy.props.CollectionProperty # pyright: ignore[reportGeneralTypeIssues] + items: Any = None def draw_collection(self, items, layout): col = layout.column(align=True) @@ -11,7 +12,7 @@ class DrawCollectionMixin(): def _draw_prop(self, text, layout): self.draw_collection(self.items, layout) -def update_collection(items: bpy.props.CollectionProperty) -> None: # pyright: ignore[reportGeneralTypeIssues] +def update_collection(items: Any) -> None: if len(items) == 0: items.add() @@ -25,7 +26,7 @@ def update_collection(items: bpy.props.CollectionProperty) -> None: # pyright: break class UpdateCollectionMixin(): - items: bpy.props.CollectionProperty # pyright: ignore[reportGeneralTypeIssues] + items: Any = None def __init__(self, *args, **kwargs) -> None: super().__init__(*args, **kwargs) diff --git a/infra/networking/airtable_backend.py b/infra/networking/airtable_backend.py index 0c3d9e8..f027dad 100644 --- a/infra/networking/airtable_backend.py +++ b/infra/networking/airtable_backend.py @@ -1,5 +1,6 @@ from pathlib import Path -import requests, base64, mimetypes, os, time +import requests # pyright: ignore[reportMissingModuleSource] +import base64, mimetypes, os, time from typing import Callable, TypeAlias, TypeVar, Any F = TypeVar('F', bound=Callable[..., Any]) @@ -102,7 +103,7 @@ class AirtableInterface: for i in range(0, len(records), 10): batch = records[i:i+10] - payload = { + payload: dict[str, Any] = { "records": [{'fields': r} for r in batch], "typecast": typecast, "returnFieldsByFieldId": returnFieldsByFieldId, diff --git a/infra/networking/baserow_backend.py b/infra/networking/baserow_backend.py index c950c77..3ceb4f3 100644 --- a/infra/networking/baserow_backend.py +++ b/infra/networking/baserow_backend.py @@ -11,20 +11,20 @@ class BaserowBackend: "Content-Type": "application/json" } - def get_record(self, table_id: str, record_id: int) -> dict: + def get_record(self, table_id: str, record_id: int) -> dict[str, Any]: url = f"{self.host}/api/database/rows/table/{table_id}/{record_id}/" response = requests.get(url, headers=self.headers, params={"user_field_names": "true"}) response.raise_for_status() return response.json() - def upsert_record(self, table_id: str, record_id: int, fields: dict[str, Any]) -> dict: + def upsert_record(self, table_id: str, record_id: int, fields: dict[str, Any]) -> dict[str, Any]: try: self.get_record(table_id, record_id) return self.update_record(table_id, record_id, fields) except (requests.exceptions.HTTPError, ValueError): return self.create_record(table_id, fields) - def create_record(self, table_id: str, fields: dict[str, Any]) -> dict: + def create_record(self, table_id: str, fields: dict[str, Any]) -> dict[str, Any]: url = f"{self.host}/api/database/rows/table/{table_id}/" response = requests.post(url, headers=self.headers, json=fields, params={"user_field_names": "true"}) try: @@ -34,7 +34,7 @@ class BaserowBackend: raise e return response.json() - def update_record(self, table_id: str, record_id: int, fields: dict[str, Any]) -> dict: + def update_record(self, table_id: str, record_id: int, fields: dict[str, Any]) -> dict[str, Any]: url = f"{self.host}/api/database/rows/table/{table_id}/{record_id}/" response = requests.patch(url, headers=self.headers, json=fields, params={"user_field_names": "true"}) try: diff --git a/infra/networking/printer_backends.py b/infra/networking/printer_backends.py index fa795b5..1daa95f 100644 --- a/infra/networking/printer_backends.py +++ b/infra/networking/printer_backends.py @@ -73,14 +73,14 @@ class HttpBackend: headers = {**self.headers, **kw.pop('headers', {})} return self.session.delete(self.base + path, headers=headers, timeout=self.timeout, **kw) - def _json(self, resp, default: dict = {}, ok=(200, 201, 202, 204)) -> dict: + def _json(self, resp: requests.Response, default: dict[str, Any] | None = None, ok: tuple[int, ...] = (200, 201, 202, 204)) -> dict[str, Any]: if resp.status_code not in ok: resp.raise_for_status() if resp.status_code == 204 or not resp.content: - return default + return default if default is not None else {} return resp.json() -class PrinterHttpBackend(HttpBackend, PrinterBackend): +class PrinterHttpBackend(HttpBackend): pass class PrusaLinkBackend(PrinterHttpBackend): @@ -326,7 +326,7 @@ class MoonrakerBackend(PrinterHttpBackend): @dataclass class ManagedPrinter: name: str - backend: PrinterHttpBackend + backend: PrinterBackend status: PrinterStatus = field(default_factory=PrinterStatus) last_error: str | None = None last_command_time: str | None = None diff --git a/infra/prusaslicer_profiles/parser.py b/infra/prusaslicer_profiles/parser.py index 867fc94..1c5c01f 100644 --- a/infra/prusaslicer_profiles/parser.py +++ b/infra/prusaslicer_profiles/parser.py @@ -1,7 +1,7 @@ from __future__ import annotations import operator import re -from typing import TYPE_CHECKING, TypeAlias, Any +from typing import TYPE_CHECKING, TypeAlias, Any, cast if TYPE_CHECKING: Token: TypeAlias = tuple[str, str] @@ -109,12 +109,14 @@ class BinaryOpNode(ExprNode): if self.op == "=~": if not isinstance(rv, re.Pattern): raise TypeError("Right operand of =~ must be a regex pattern.") - return rv.search(str(lv)) is not None + 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.") - return rv.search(str(lv)) is None + pattern_str = cast(re.Pattern[str], rv) + return pattern_str.search(str(lv)) is None raise RuntimeError(f"Unknown binary operator: {self.op}") diff --git a/registry.py b/registry.py index 4e29565..9bb06ef 100644 --- a/registry.py +++ b/registry.py @@ -5,8 +5,22 @@ from typing import Any, Callable _bpy_class_registry: list[type] = [] _timer_registry: list[Callable[..., int | None]] = [] +def setup_bpy_annotations(cls: type) -> type: + if not hasattr(cls, "__annotations__"): + cls.__annotations__ = {} + for name, val in list(cls.__dict__.items()): + val_type_name = type(val).__name__ + if "PropertyDeferred" in val_type_name or val_type_name.startswith("_Property") or (isinstance(val, tuple) and len(val) > 0 and "Property" in type(val[0]).__name__): + cls.__annotations__[name] = val + try: + delattr(cls, name) + except (AttributeError, TypeError): + pass + return cls + # BPY CLASSES def register_class(cls: type) -> type: + setup_bpy_annotations(cls) if cls not in _bpy_class_registry: _bpy_class_registry.append(cls) return cls @@ -29,6 +43,7 @@ def get_class_by_name(name: str) -> type | None: def blender_register_classes(): for cls in _bpy_class_registry: try: + setup_bpy_annotations(cls) bpy.utils.register_class(cls) except Exception as e: print(f"Nodeslicer: Failed to register {cls}: {e}") diff --git a/tests/test_slice.py b/tests/test_slice.py index b207b53..0762fc9 100644 --- a/tests/test_slice.py +++ b/tests/test_slice.py @@ -739,6 +739,7 @@ class TestSlice(unittest.TestCase): from NodeSlicer.infra.blender.blender_mesh_capture import SlicingCollection, SlicingObject node = SLICENODE_SeparateMeshByRange() + node.start_val = 0 node.end_val = 6 so = SlicingObject() @@ -758,9 +759,60 @@ class TestSlice(unittest.TestCase): coll.transforms = [] node.resolve = lambda name: coll if name == "Mesh" else ("part_id" if name == "Attribute" else None) + # start_val = 0, end_val = 6 -> total_parts = 7 -> num_digits = 2 + # socket "1" -> i = 1 -> CA01 res_1 = node.evaluate_output("1") self.assertIsNotNone(res_1) self.assertEqual(res_1.objects[0].name, "CA01") + # socket "2" -> i = 2 -> CA02 + res_2 = node.evaluate_output("2") + self.assertIsNotNone(res_2) + self.assertEqual(res_2.objects[0].name, "CA02") + + # Now test start_val = 1, end_val = 6 -> total_parts = 6 -> num_digits = 2 + # Numbering starts from zero, so socket "1" has i = 0 -> CA00, socket "2" has i = 1 -> CA01 + node.start_val = 1 + node.end_val = 6 + + res_start1_out1 = node.evaluate_output("1") + self.assertIsNotNone(res_start1_out1) + self.assertEqual(res_start1_out1.objects[0].name, "CA00") + + res_start1_out2 = node.evaluate_output("2") + self.assertIsNotNone(res_start1_out2) + self.assertEqual(res_start1_out2.objects[0].name, "CA01") + + # Test total_parts >= 100 -> num_digits = 3 + node.start_val = 0 + node.end_val = 100 # total_parts = 101 -> num_digits = 3 + res_large_1 = node.evaluate_output("1") + self.assertIsNotNone(res_large_1) + self.assertEqual(res_large_1.objects[0].name, "CA001") + + # Test np.all(mask) where all faces match the target + so_full = SlicingObject() + so_full.name = "FullMesh" + so_full.mesh = np.zeros((4, 4, 3)) + so_full.attributes = { + "part_id": { + "domain": "FACE", + "type": "INT", + "values": np.array([2, 2, 2, 2]) + } + } + coll_full = SlicingCollection.__new__(SlicingCollection) + coll_full.objects = [so_full] + coll_full.name = "FullColl" + coll_full.transforms = [] + node.start_val = 2 + node.end_val = 5 # total_parts = 4, num_digits = 2 + node.resolve = lambda name: coll_full if name == "Mesh" else ("part_id" if name == "Attribute" else None) + + res_full_2 = node.evaluate_output("2") + self.assertIsNotNone(res_full_2) + self.assertEqual(res_full_2.objects[0].name, "FullMesh00") + if __name__ == '__main__': unittest.main() +