diff --git a/__init__.py b/__init__.py index 1e960b1..510679a 100644 --- a/__init__.py +++ b/__init__.py @@ -36,6 +36,7 @@ def register(): print('Nodeslicer Loaded') def unregister(): + try: tree.cancel_timer() except Exception as e: diff --git a/core/nodeslicer/nodes/mesh.py b/core/nodeslicer/nodes/mesh.py index caeb6f4..ce339bf 100644 --- a/core/nodeslicer/nodes/mesh.py +++ b/core/nodeslicer/nodes/mesh.py @@ -72,6 +72,8 @@ class SLICENODE_JoinMesh(SliceNodeJoiner): class SLICENODE_JoinMeshGroups(SliceNodeJoiner): bl_label = "Join Mesh Groups" socket_type = "SLICESOCK_Mesh" + is_list_output = True + always_list_outputs = ["Mesh"] def init(self, context): self.add_output(self.output_socket_type or self.socket_type, display_shape='LIST') @@ -221,6 +223,10 @@ class SLICENODE_SeparateMeshByRange(SliceNode): if inp is not None: set_socket_shape(inp, "LIST" if is_list else "CIRCLE") + attr_inp = self.inputs.get("Attribute") if hasattr(self, "inputs") else None + if attr_inp is not None: + set_socket_shape(attr_inp, "LIST" if is_socket_linked_to_list(attr_inp) else "CIRCLE") + # Remove outputs that are no longer in the range to_remove = [o for o in self.outputs if o.identifier not in expected_ids] for o in to_remove: @@ -229,7 +235,7 @@ class SLICENODE_SeparateMeshByRange(SliceNode): # Add new outputs for id_str in expected_ids: if id_str not in self.outputs: - self.add_output("SLICESOCK_Mesh", id_str, identifier=id_str, display_shape="LIST" if is_list else "") + self.add_output("SLICESOCK_Mesh", id_str, identifier=id_str, display_shape="LIST" if is_list else "CIRCLE") else: set_socket_shape(self.outputs[id_str], "LIST" if is_list else "CIRCLE") @@ -539,6 +545,10 @@ def _transform_geometry_item(geom: Any, t_arr: np.ndarray, r_arr: np.ndarray, s_ copied_data.transforms = update_transform(getattr(geom, "transforms", []), t_arr, r_arr, s_arr) return copied_data + elif hasattr(geom, "location") and hasattr(geom, "rotation_euler") and hasattr(geom, "scale") and not hasattr(geom, "mesh"): + from ....infra.blender.blender_slice_nodes_generic import object_to_mesh + mesh_geom = object_to_mesh(geom, include_children=True) + return _transform_geometry_item(mesh_geom, t_arr, r_arr, s_arr) return geom @@ -582,14 +592,40 @@ class SLICENODE_GetTransforms(SliceNode): def evaluate_output(self, socket_name: str, **_kwargs) -> Any: from ....core.slice import extract_geometry_transforms - default = (1.0, 1.0, 1.0) if socket_name == "Scale" else (0.0, 0.0, 0.0) - return self.eval_multivalue( - extract_geometry_transforms, - main_socket="Geometry", - secondary_sockets=[], - socket_name=socket_name, - default=default, - ) + from ....infra.blender.list_utils import is_input_a_list + + sock_lower = socket_name.lower() + default = (1.0, 1.0, 1.0) if sock_lower == "scale" else (0.0, 0.0, 0.0) + idx = 0 if sock_lower == "translation" else (1 if sock_lower == "rotation" else 2) + + geom = self.resolve("Geometry") + if geom is None: + return default + + is_list = is_input_a_list(geom, is_vector=False) + if not is_list and isinstance(geom, (list, tuple)): + if type(geom).__name__ != "SlicePaths" and not getattr(geom, "is_slice_paths", False): + is_list = True + elif not is_list and hasattr(geom, "__iter__") and not hasattr(geom, "shape") and not isinstance(geom, (str, bytes, dict, set)) and type(geom).__name__ != "SlicePaths" and not getattr(geom, "is_slice_paths", False) and not hasattr(geom, "mesh") and not hasattr(geom, "objects") and not hasattr(geom, "collections") and not hasattr(geom, "location"): + is_list = True + + if is_list: + items = list(geom) + if len(items) == 0: + return [] + results = [] + for item in items: + trans = extract_geometry_transforms(item) + if isinstance(trans, tuple) and len(trans) > idx: + results.append(trans[idx]) + else: + results.append(default) + return results + else: + trans = extract_geometry_transforms(geom) + if isinstance(trans, tuple) and len(trans) > idx: + return trans[idx] + return default @register_class diff --git a/core/nodeslicer/nodes/path.py b/core/nodeslicer/nodes/path.py index d2aa4ce..26fc9d8 100644 --- a/core/nodeslicer/nodes/path.py +++ b/core/nodeslicer/nodes/path.py @@ -104,6 +104,18 @@ def _slice_single_mesh_item(coll: Any, layer_height: float, fl_height: float | N else: z_values = np.array([], dtype=np.float64) + return _finish_slice_paths(merged_object, coll, objects, z_values, rotation, ignore_holes, fl_height, layer_height) + +def _finish_slice_paths( + merged_object: Any, + coll: Any, + objects: list[Any], + z_values: np.ndarray, + rotation: Any, + ignore_holes: bool, + fl_height: float | None, + layer_height: float, +) -> SlicePaths: slices_group: list[list[np.ndarray]] | None = slice_at_range( [merged_object.mesh], z_values, @@ -155,6 +167,7 @@ def _slice_single_mesh_item(coll: Any, layer_height: float, fl_height: float | N else: break + has_rot = bool(rotation and any(rotation)) if has_rot: from mathutils import Euler mat_inv = np.array(Euler(rotation).to_matrix()) @@ -163,6 +176,99 @@ def _slice_single_mesh_item(coll: Any, layer_height: float, fl_height: float | N return slices +def _slice_single_mesh_item_with_layers(coll: Any, layers: Any, rotation: Any, ignore_holes: bool) -> SlicePaths: + if not coll or layers is None: + return SlicePaths() + + # Handle SlicingGroup, SlicingCollection, SlicingObject + if isinstance(coll, SlicingGroup): + objects = [] + for c in coll.collections.values(): + objects.extend(c.objects) + elif isinstance(coll, SlicingCollection): + objects = list(coll.objects) + elif isinstance(coll, SlicingObject): + objects = [coll] + else: + objects = getattr(coll, "objects", [coll] if hasattr(coll, "mesh") else []) + + merged_object = SlicingObject.merge(objects) + if merged_object.mesh is None or merged_object.mesh.size == 0: + return SlicePaths() + + has_rot = bool(rotation and any(rotation)) + if has_rot: + from mathutils import Euler + mat = np.array(Euler(rotation).to_matrix().inverted()) + merged_object.mesh[:, :3, :] = merged_object.mesh[:, :3, :] @ mat.T + merged_object.mesh[:, 3:4, :] = merged_object.mesh[:, 3:4, :] @ mat.T + + min_z = float(merged_object.mesh[:, :3, 2].min()) + if abs(min_z) < 1e-6: + min_z = 0.0 + max_z = float(merged_object.mesh[:, :3, 2].max()) + + min_z_um = int(round(min_z * 1000)) + max_z_um = int(round(max_z * 1000)) + + raw_layers = layers if isinstance(layers, (list, tuple, np.ndarray)) else [layers] + valid_z: list[float] = [] + for z in raw_layers: + try: + zf = float(z) + z_um = int(round(zf * 1000)) + if min_z_um <= z_um <= max_z_um: + valid_z.append(zf) + except (ValueError, TypeError): + continue + + if not valid_z: + return SlicePaths() + + z_values = np.array(valid_z, dtype=np.float64) + fl_height = float(z_values[0]) if len(z_values) > 0 else None + layer_height = float(z_values[1] - z_values[0]) if len(z_values) > 1 else (fl_height or 0.2) + + return _finish_slice_paths(merged_object, coll, objects, z_values, rotation, ignore_holes, fl_height, layer_height) + +@register_class +class SLICENODE_Layers(SliceNode): + bl_label = "Layers" + is_list_output = True + always_list_outputs = ["Layers"] + list_socket_mapping = [("Layer Height", "Layers")] + + def init(self, context): + self.add_input("NodeSocketFloat", "First Layer Height").value = 0.2 + self.add_input("NodeSocketFloat", "Layer Height").value = 0.2 + self.add_input("NodeSocketFloat", "Total Height").value = 250.0 + self.add_output("NodeSocketFloat", "Layers", display_shape='LIST') + + def evaluate_output(self, socket_name: str, **_kwargs) -> Any: + def _calc_layers(lh_val: Any, flh_val: Any, tot_val: Any) -> list[float]: + flh = float(flh_val) if flh_val is not None and float(flh_val) > 0 else 0.2 + lh = float(lh_val) if lh_val is not None and float(lh_val) > 0 else 0.2 + tot = float(tot_val) if tot_val is not None and float(tot_val) > 0 else 250.0 + + flh_um = max(1, int(round(flh * 1000))) + lh_um = max(1, int(round(lh * 1000))) + tot_um = int(round(tot * 1000)) + + layers: list[float] = [] + z_um = flh_um + while z_um <= tot_um: + layers.append(z_um / 1000.0) + z_um += lh_um + return layers + + return self.eval_multivalue( + _calc_layers, + main_socket="Layer Height", + secondary_sockets=["First Layer Height", "Total Height"], + socket_name=socket_name, + default=[], + ) + @register_class class SLICENODE_SliceShells(SliceNode): bl_label = "Slice Shells" @@ -209,6 +315,52 @@ class SLICENODE_SliceShells(SliceNode): default=SlicePaths(), ) +@register_class +class SLICENODE_SliceShellsLayers(SliceNode): + bl_label = "Slice Shells (Layers)" + always_list_inputs = ["Layers"] + list_socket_mapping = [("Mesh", "Paths")] + + ignore_holes: bool = NfBoolProperty( + name="Ignore Holes", + description="Ignore interior hole boundaries and generate only outer shells", + default=False + ) + + def init(self, context): + self.add_input("SLICESOCK_Mesh") + self.add_input("NodeSocketFloat", "Layers", display_shape='LIST') + self.add_input("NodeSocketRotation", "Rotation") + self.add_output("SLICESOCK_Paths") + + def draw_buttons(self, context, layout): + assert layout + if hasattr(self, "ignore_holes"): + layout.prop(self, "ignore_holes") + + def evaluate_output(self, socket_name: str, **_kwargs) -> Any: + ignore_holes: bool = bool(getattr(self, "ignore_holes", False)) + if hasattr(self, "inputs") and "Ignore Holes" in self.inputs: + try: + val = self.resolve("Ignore Holes") + if val is not None: + ignore_holes = bool(val) + except Exception: + pass + + def _slice_one(item: Any, layers_val: Any, rot_val: Any) -> SlicePaths: + return _slice_single_mesh_item_with_layers(item, layers_val, rot_val, ignore_holes) + + return self.eval_multivalue( + _slice_one, + main_socket="Mesh", + secondary_sockets=["Layers", "Rotation"], + is_vector=[False, True], + is_atomic=[True, False], + socket_name=socket_name, + default=SlicePaths(), + ) + @register_class class SLICENODE_BufferShells(SliceNode): bl_label = "Buffer Shells" diff --git a/core/nodeslicer/tree.py b/core/nodeslicer/tree.py index ac3b8a2..92eb4e3 100644 --- a/core/nodeslicer/tree.py +++ b/core/nodeslicer/tree.py @@ -369,8 +369,8 @@ def depsgraph_update_handler(scene, depsgraph): for tree in bpy.data.node_groups: if tree.bl_idname == "SLICETREE_NodeTree": try: - from ...infra.blender.list_utils import sync_group_tree_internal_sockets - sync_group_tree_internal_sockets(tree) + from ...infra.blender.list_utils import sync_tree_socket_shapes + sync_tree_socket_shapes(tree) except Exception: pass for node in tree.nodes: @@ -406,50 +406,19 @@ class SLICETREE_NodeTree(NodeTree): bl_use_group_interface = True def update(self): - super_update = getattr(super(), "update", None) - if callable(super_update): - try: - super_update() - except Exception: - pass - if _is_updating: + global _is_updating + if _is_updating or getattr(self, "_is_syncing_shapes", False): return + try: + self._is_syncing_shapes = True + from ...infra.blender.list_utils import sync_tree_socket_shapes + sync_tree_socket_shapes(self) + except Exception: + pass + finally: + self._is_syncing_shapes = False request_auto_refresh(self) - def valid_link(self, from_socket: Any, to_socket: Any) -> bool: - geom_sockets = {"SLICESOCK_Mesh", "SLICESOCK_Paths", "SLICESOCK_Geometry"} - out_s, in_s = from_socket, to_socket - if getattr(from_socket, "is_output", None) is False or getattr(to_socket, "is_output", None) is True: - out_s, in_s = to_socket, from_socket - - is_obj_to_mesh = ( - getattr(out_s, "bl_idname", "") in ("NodeSocketObject", "SLICESOCK_Object") - and getattr(in_s, "bl_idname", "") == "SLICESOCK_Mesh" - ) - - compatible = ( - (out_s.bl_idname in geom_sockets and in_s.bl_idname in geom_sockets) - or out_s.bl_idname == in_s.bl_idname - or is_obj_to_mesh - ) - if not compatible: - return False - - from ...infra.blender.list_utils import is_output_socket_a_list, group_input_can_accept_list - if is_output_socket_a_list(out_s): - to_node = getattr(in_s, "node", None) - if to_node is not None: - # Start Print and Printer List reject lists - if to_node.bl_idname in ("SLICENODE_StartPrint", "SLICENODE_PrinterList"): - return False - # Group node: check if input can accept list - if to_node.bl_idname == "SLICENODE_Group": - tree = getattr(to_node, "slicer_tree", None) or getattr(to_node, "node_tree", None) - if tree and not group_input_can_accept_list(tree, in_s): - return False - - return True - @register_class class SLICENODE_OT_GroupToggle(Operator): @@ -813,6 +782,7 @@ class SLICENODE_MT_DataInput(Menu): layout.operator("node.add_node", text="Integer").type = "SLICENODE_InputInt" layout.operator("node.add_node", text="Rotation").type = "SLICENODE_InputVectorRotation" layout.operator("node.add_node", text="Vector").type = "SLICENODE_InputVector" + layout.operator("node.add_node", text="Layers").type = "SLICENODE_Layers" layout.separator() layout.operator("node.add_node", text="Object").type = "SLICENODE_InputObject" layout.operator("node.add_node", text="Collection Objects").type = "SLICENODE_CollectionObjects" @@ -840,7 +810,9 @@ class SLICENODE_MT_Geometry(Menu): layout.operator("node.add_node", text="Arrange Slicing Group").type = "SLICENODE_ArrangeMesh" layout.operator("node.add_node", text="Get Paths").type = "SLICENODE_GetPaths" layout.separator() + layout.operator("node.add_node", text="Layers").type = "SLICENODE_Layers" layout.operator("node.add_node", text="Slice Shells").type = "SLICENODE_SliceShells" + layout.operator("node.add_node", text="Slice Shells (Layers)").type = "SLICENODE_SliceShellsLayers" layout.operator("node.add_node", text="Seam Placement").type = "SLICENODE_SeamPlacement" layout.operator("node.add_node", text="Buffer Shells").type = "SLICENODE_BufferShells" layout.operator("node.add_node", text="Buffer Shells Multiple").type = "SLICENODE_BufferShellsMultiple" diff --git a/core/slice.py b/core/slice.py index b844bdd..f283217 100644 --- a/core/slice.py +++ b/core/slice.py @@ -415,6 +415,13 @@ def extract_mesh_transforms(mesh_input: Any) -> list[dict[str, Any]]: for o in mesh_input.objects: if hasattr(o, "transforms") and o.transforms: return [dict(t) for t in o.transforms if isinstance(t, dict)] + if hasattr(mesh_input, "location") and hasattr(mesh_input, "rotation_euler") and hasattr(mesh_input, "scale"): + return [{ + "type": "INIT", + "translation": tuple(float(v) for v in mesh_input.location), + "rotation": tuple(float(v) for v in mesh_input.rotation_euler), + "scale": tuple(float(v) for v in mesh_input.scale), + }] if isinstance(mesh_input, list): for item in mesh_input: trans = extract_mesh_transforms(item) @@ -425,15 +432,23 @@ def extract_mesh_transforms(mesh_input: Any) -> list[dict[str, Any]]: def extract_geometry_transforms( geom: Any -) -> tuple[tuple[float, float, float], tuple[float, float, float], tuple[float, float, float]]: +) -> tuple[tuple[float, float, float], tuple[float, float, float], tuple[float, float, float]] | list[Any]: """ Extracts consolidated (translation, rotation, scale) from a geometry item (SlicePaths, SlicingObject, SlicingCollection, SlicingGroup, or Blender Object). Rotation is returned as Euler angles in radians (rx, ry, rz). + If geom is a list/tuple of geometry items (not a single SlicePaths), + returns a list of consolidated (translation, rotation, scale) tuples. """ if geom is None: return (0.0, 0.0, 0.0), (0.0, 0.0, 0.0), (1.0, 1.0, 1.0) + # Check if geom is a list or tuple of geometry items (excluding SlicePaths) + if isinstance(geom, (list, tuple)) and type(geom).__name__ != "SlicePaths" and not getattr(geom, "is_slice_paths", False): + return [extract_geometry_transforms(item) for item in geom] + elif hasattr(geom, "__iter__") and not hasattr(geom, "shape") and not isinstance(geom, (str, bytes, dict, set)) and type(geom).__name__ != "SlicePaths" and not getattr(geom, "is_slice_paths", False) and not hasattr(geom, "mesh") and not hasattr(geom, "objects") and not hasattr(geom, "collections") and not hasattr(geom, "location"): + return [extract_geometry_transforms(item) for item in geom] + transforms = extract_mesh_transforms(geom) if not transforms and hasattr(geom, "transforms") and isinstance(geom.transforms, list): transforms = [dict(t) for t in geom.transforms if isinstance(t, dict)] diff --git a/doc/MODULES.md b/doc/MODULES.md index c288e87..24ec16f 100644 --- a/doc/MODULES.md +++ b/doc/MODULES.md @@ -46,6 +46,15 @@ This file provides a mapping of modules to their primary responsibilities and ke - `geometry.py`: - Geometry utility functions. +## `/core/nodeslicer` (Node Definitions & Tree Infrastructure) + +- `tree.py`: + - `SLICETREE_NodeTree`: Custom node tree type for NodeSlicer nodes. + +- `nodes/path.py`: + - `SLICENODE_Layers`: Generates bounded list of layer slicing heights. + - `SLICENODE_SliceShellsLayers`: Slices meshes using an explicit list of layer heights for multi-object layer alignment. + ## `/core/pdf_exporter` (PDF Export & Technical Marking Engine) - `sa2_measures.py`: @@ -83,4 +92,4 @@ This file provides a mapping of modules to their primary responsibilities and ke - `eval_element_wise()`: Vectorized applicator executing operations element-wise on lists or directly on single items. - `prepare_indexed_inputs()`: Normalizes and matches secondary inputs index-per-index against the primary input, broadcasting single values or validating that list lengths match exactly (raising `ValueError` on mismatch). - `eval_element_wise_multivalue()`: General element-wise operator executing multi-argument operations with index matching and broadcasting. - - `set_socket_shape()` / `is_socket_linked_to_list()` / `is_output_socket_a_list()` / `sync_node_list_sockets()`: Dynamic socket visual appearance management (`LIST` vs `CIRCLE`). + - `set_socket_shape()` / `is_socket_linked_to_list()` / `is_output_socket_a_list()` / `sync_node_list_sockets()` / `sync_tree_socket_shapes()`: Dynamic socket visual appearance management (`LIST` vs `CIRCLE`) and tree-wide propagation. diff --git a/doc/NODES.md b/doc/NODES.md index c17beaa..1e3d825 100644 --- a/doc/NODES.md +++ b/doc/NODES.md @@ -45,6 +45,7 @@ NodeSlicer supports generalized list processing across geometry, path, G-code, a - **Index-per-index Matching**: Secondary input sockets (such as translation vectors, rotations, scale vectors, drop Z coordinates, cutting meshes, rename templates, and parameters) also accept lists. When supplied with a list, secondary inputs are matched index-per-index with the main input list. - **Broadcasting**: If a secondary input receives a single value (or a list of length 1), that value is automatically broadcast across all elements of the main input list. - **Length Constraint**: It is mandatory that secondary input lists have either the exact same length as the main input list, or length 1. If lengths mismatch ($M \ne N$ and $M \ne 1$), or if a single main input receives a multi-item secondary list, a `ValueError` is raised with a descriptive error message. +- **Consistent Socket Appearance**: Sockets representing lists display Blender's `LIST` shape, while single-value sockets display `CIRCLE`. Sockets adapt dynamically based on upstream connections (e.g. connecting a list output to `Mesh` dynamically converts `Mesh` input and downstream outputs to `LIST`), and dedicated list sockets (such as `Layers` output on the **Layers** node, `Layers` input on **Slice Shells (Layers)**, and list joiner outputs) consistently maintain `LIST` shape across link creations, deletions, and tree updates. ## Nodes @@ -60,6 +61,11 @@ All nodes are found in the **Add (Shift+A)** menu under the **SliceNodes** categ - **Printer**: Printer name picker. - **Printer List**: List of configured printers. - **Printer Intrinsics**: Configures bed size, extrusion/travel rates, retraction distance & feedrate, and Z-lift. +- **Layers**: Generates an ordered list of slicing heights (floats) bounded by the maximum build height. + - **First Layer Height**: Float value specifying the initial slicing height (defaults to `0.2` mm). + - **Layer Height**: Float value specifying the interval between successive layers (defaults to `0.2` mm). + - **Total Height**: Float value specifying the maximum build height to bound layer generation (defaults to `250.0` mm). + - **Layers**: Output socket delivering the list of layer Z heights `[first_layer, first_layer + layer_height, ...]`. ### Geometry Processing - **Get Mesh**: Converts a Blender Object into internal `SlicingObject` format. @@ -94,6 +100,11 @@ All nodes are found in the **Add (Shift+A)** menu under the **SliceNodes** categ - **Extra Attributes**: Dictionary mapping mesh attribute names to custom G-code prefixes (e.g., `opening_size` -> `S`). - **Slice Shells**: Slices a `SlicingObject` into 2D `SlicePaths` at a given layer height. Automatically aligns path seams to the back (+Y) of each layer. Supports rotation for slicing at an angle. - **Ignore Holes**: Boolean option to omit interior hole perimeters and generate only the outer shell boundaries. +- **Slice Shells (Layers)**: Slices a `SlicingObject` into 2D `SlicePaths` using an explicit list of layer heights rather than fixed layer intervals. This ensures multiple objects sliced separately align precisely at identical layer elevations. Automatically filters layers within the mesh's Z-bounds, aligns seams to the back (+Y), and supports rotation for slicing at an angle. + - **Mesh**: Input mesh data (`SlicingObject`, `SlicingCollection`, `SlicingGroup`, or lists). + - **Layers**: List of float numbers representing the slicing Z heights (e.g. from the **Layers** node). + - **Rotation**: 3D vector/Euler rotation values. + - **Ignore Holes**: Boolean option to omit interior hole perimeters and generate only outer shell boundaries. - **Seam Placement**: Repositions the starting vertex (seam) of closed slice paths (including outer contours and interior holes) to the intersection points between the slice paths and a guide/seam mesh. If there is no intersection for a given path, its seam remains unchanged. Agnostic to mesh input type (`SlicingObject`, `SlicingCollection`, `SlicingGroup`, or lists). - **Mesh**: Input cutting mesh socket. - **Seam Staggering**: Float input socket specifying a distance in millimeters to shift seams along the perimeter alternating layer-by-layer (+stagger on even layers, -stagger on odd layers) to avoid vertical seam alignment weak spots. Can also be used standalone without a cutting mesh. diff --git a/infra/blender/blender_mesh_capture.py b/infra/blender/blender_mesh_capture.py index 2c23952..f3fe8d2 100644 --- a/infra/blender/blender_mesh_capture.py +++ b/infra/blender/blender_mesh_capture.py @@ -117,7 +117,15 @@ class SlicingObject(): self.name = str(obj.name) if obj else "" self.parent = parent self.attributes = {} - self.transforms = [] + if obj and hasattr(obj, "location") and hasattr(obj, "rotation_euler") and hasattr(obj, "scale"): + self.transforms = [{ + "type": "INIT", + "translation": tuple(float(v) for v in obj.location), + "rotation": tuple(float(v) for v in obj.rotation_euler), + "scale": tuple(float(v) for v in obj.scale), + }] + else: + self.transforms = [] self.pdf_properties = PdfProperties() self.pdf_object_properties = [] @@ -250,7 +258,15 @@ class SlicingCollection(): self.objects = [SlicingObject(obj, parent) for obj in objs] self.objects = [o for o in self.objects if o.mesh.size] self.name = parent - self.transforms = [] + if objs and objs[0] and hasattr(objs[0], "location") and hasattr(objs[0], "rotation_euler") and hasattr(objs[0], "scale"): + self.transforms = [{ + "type": "INIT", + "translation": tuple(float(v) for v in objs[0].location), + "rotation": tuple(float(v) for v in objs[0].rotation_euler), + "scale": tuple(float(v) for v in objs[0].scale), + }] + else: + self.transforms = [] def _clear_cache(self): for prop in ["all_verts", "unique_verts"]: @@ -373,7 +389,15 @@ class SlicingGroup(): transforms: list[dict[str, Any]] def __init__(self, selected_objs: list[Object]) -> None: - self.transforms = [] + if selected_objs and selected_objs[0] and hasattr(selected_objs[0], "location") and hasattr(selected_objs[0], "rotation_euler") and hasattr(selected_objs[0], "scale"): + self.transforms = [{ + "type": "INIT", + "translation": tuple(float(v) for v in selected_objs[0].location), + "rotation": tuple(float(v) for v in selected_objs[0].rotation_euler), + "scale": tuple(float(v) for v in selected_objs[0].scale), + }] + else: + self.transforms = [] family = [] for obj in selected_objs: diff --git a/infra/blender/blender_slice_nodes_generic.py b/infra/blender/blender_slice_nodes_generic.py index 1f01b47..31d752c 100644 --- a/infra/blender/blender_slice_nodes_generic.py +++ b/infra/blender/blender_slice_nodes_generic.py @@ -119,16 +119,17 @@ class SliceNode(Node, AutoBlIdName): return False list_socket_mapping: list[tuple[str, list[str] | str]] = [] + always_list_inputs: list[str] = [] + always_list_outputs: list[str] = [] + is_list_output: bool = False def update(self) -> None: self._update_multi_groups() self._sync_list_socket_shapes() def _sync_list_socket_shapes(self) -> None: - mapping = getattr(self, "list_socket_mapping", None) - if mapping: - from .list_utils import sync_node_list_sockets - sync_node_list_sockets(self, mapping) + from .list_utils import sync_node_list_sockets + sync_node_list_sockets(self, getattr(self, "list_socket_mapping", None)) def evaluate_output(self, socket_name: str, **_kwargs) -> Any: ... @@ -154,6 +155,7 @@ class SliceNode(Node, AutoBlIdName): socket_name: str | None = None, default: Any = None, is_vector: list[bool] | None = None, + is_atomic: list[bool] | None = None, ) -> Any: from .list_utils import eval_multivalue return eval_multivalue( @@ -164,6 +166,7 @@ class SliceNode(Node, AutoBlIdName): socket_name=socket_name, default=default, is_vector=is_vector, + is_atomic=is_atomic, ) def add_output(self, bl_idname: str, name: str = "", identifier: str = "", hide: bool = False, display_shape: str = "", description: str = ""): @@ -171,6 +174,9 @@ class SliceNode(Node, AutoBlIdName): eval_name = name or NATIVE_SOCKET_LABELS.get(bl_idname) or get_label_by_name(bl_idname) or bl_idname sock = self.outputs.new(bl_idname, eval_name, identifier=identifier or eval_name) sock.hide = hide + if not display_shape: + if getattr(self, "is_list_output", False) or (getattr(self, "always_list_outputs", None) and eval_name in self.always_list_outputs): + display_shape = 'LIST' if display_shape: sock.display_shape = display_shape if description and hasattr(sock, "description"): @@ -185,6 +191,9 @@ class SliceNode(Node, AutoBlIdName): eval_name = name or NATIVE_SOCKET_LABELS.get(bl_idname) or get_label_by_name(bl_idname) or bl_idname sock = self.inputs.new(bl_idname, eval_name, identifier=identifier or eval_name) sock.hide = hide + if not display_shape: + if getattr(self, "always_list_inputs", None) and eval_name in self.always_list_inputs: + display_shape = 'LIST' if display_shape: sock.display_shape = display_shape if description and hasattr(sock, "description"): diff --git a/infra/blender/list_utils.py b/infra/blender/list_utils.py index 4be64e7..be9266a 100644 --- a/infra/blender/list_utils.py +++ b/infra/blender/list_utils.py @@ -36,6 +36,18 @@ def is_vector_value(val: Any) -> bool: return True return False +def _is_sequence_collection(val: Any) -> bool: + if isinstance(val, (list, tuple)): + return True + if hasattr(val, "__iter__") and not hasattr(val, "shape") and not isinstance(val, (str, bytes, dict, set)): + cls_name = type(val).__name__ + if "bpy_prop" in cls_name or "Collection" in cls_name or hasattr(val, "foreach_get"): + return True + import collections.abc + if isinstance(val, collections.abc.Sequence): + return True + return False + def is_input_a_list(val: Any, is_vector: bool = False) -> bool: """Determines whether an input value represents a list of items. If is_vector is True, a single vector/rotation/color is treated as an atomic single value.""" @@ -46,8 +58,8 @@ def is_input_a_list(val: Any, is_vector: bool = False) -> bool: if is_vector: if is_vector_value(val): return False - return isinstance(val, (list, tuple)) - return isinstance(val, (list, tuple)) + return _is_sequence_collection(val) + return _is_sequence_collection(val) def is_list_data(val: Any) -> bool: """Checks if val represents a list of items (Object, Mesh, Path, Gcode). @@ -74,12 +86,15 @@ def prepare_indexed_inputs( *secondary_inputs: Any, is_vector: list[bool] | None = None, input_names: list[str] | None = None, + is_atomic: list[bool] | None = None, ) -> tuple[bool, list[Any], list[list[Any]]]: """Prepares and validates main and secondary inputs according to the generalized list architecture: - Main input can be a list or a single item. - Secondary inputs that are lists must have either the same length as the main input, or be a single value (length 1 or non-list). - If a secondary input is a single value, it is broadcast across all elements of the main input. - If list lengths mismatch and length != 1, raises ValueError. + - If is_atomic is True for a secondary input, it is treated as a single atomic data value (e.g. layer height list) + unless it is explicitly a nested list matching the main input length. Returns: (is_main_list, main_items, broadcasted_secondary_lists) @@ -89,13 +104,37 @@ def prepare_indexed_inputs( n = len(main_items) vec_flags = is_vector or [False] * len(secondary_inputs) + atom_flags = is_atomic or [False] * len(secondary_inputs) names = input_names or [f"input_{i+1}" for i in range(len(secondary_inputs))] broadcasted_secondaries: list[list[Any]] = [] for i, sec_val in enumerate(secondary_inputs): is_vec = vec_flags[i] if i < len(vec_flags) else False + is_atom = atom_flags[i] if i < len(atom_flags) else False name = names[i] if i < len(names) else f"input_{i+1}" + if is_atom: + is_nested_list = ( + isinstance(sec_val, (list, tuple)) + and len(sec_val) > 0 + and isinstance(sec_val[0], (list, tuple)) + ) + if not is_nested_list: + broadcasted_secondaries.append([sec_val] * n) + elif len(sec_val) == n: + broadcasted_secondaries.append(list(sec_val)) + elif len(sec_val) == 1: + broadcasted_secondaries.append([sec_val[0]] * n) + else: + m = len(sec_val) + expected = "1 or a single value" if not main_is_list else f"{n} or 1" + main_desc = f"{n} items" if main_is_list else "a single item" + raise ValueError( + f"Input list length mismatch: main input has {main_desc}, " + f"but secondary input '{name}' has {m} items (must be {expected})." + ) + continue + if not is_input_a_list(sec_val, is_vector=is_vec): broadcasted_secondaries.append([sec_val] * n) elif len(sec_val) == n: @@ -120,6 +159,7 @@ def eval_element_wise_multivalue( is_vector: list[bool] | None = None, input_names: list[str] | None = None, output_index: int | None = None, + is_atomic: list[bool] | None = None, ) -> Any: """Evaluates op_func element-wise with support for main and secondary list inputs: - If main_data is a list, returns a list of results. @@ -134,6 +174,7 @@ def eval_element_wise_multivalue( *secondary_inputs, is_vector=is_vector, input_names=input_names, + is_atomic=is_atomic, ) def _call(item: Any, i: int) -> Any: @@ -179,6 +220,7 @@ def eval_multivalue( socket_name: str | None = None, default: Any = None, is_vector: list[bool] | None = None, + is_atomic: list[bool] | None = None, ) -> Any: """Generalized element-wise multi-value evaluator for SliceNode instances.""" mapping = getattr(node, "list_socket_mapping", None) @@ -197,14 +239,15 @@ def eval_multivalue( # Determine output index / socket validation output_index: int | None = None if socket_name is not None and mapping: - out_spec = next((outs for m_in, outs in mapping if m_in == main_socket), None) + out_spec = next((outs for m_in, outs in mapping if m_in.lower() == (main_socket or "").lower()), None) if out_spec is not None: if isinstance(out_spec, (list, tuple)): - if socket_name not in out_spec: + matched_idx = next((idx for idx, s in enumerate(out_spec) if s.lower() == socket_name.lower()), None) + if matched_idx is None: return default - output_index = out_spec.index(socket_name) + output_index = matched_idx elif isinstance(out_spec, str) and out_spec: - if socket_name != out_spec: + if socket_name.lower() != out_spec.lower(): return default # Resolve main input @@ -240,6 +283,7 @@ def eval_multivalue( is_vector=vec_flags, input_names=secondary_sockets, output_index=output_index, + is_atomic=is_atomic, ) except Exception as e: from .blender_slice_nodes_generic import attach_node_failure_info @@ -258,7 +302,8 @@ def set_socket_shape(sock: Any, shape: str) -> None: """Safely updates socket display_shape property ('LIST', 'CIRCLE', etc.).""" if sock is not None: try: - sock.display_shape = shape + if getattr(sock, "display_shape", "") != shape: + sock.display_shape = shape except (AttributeError, TypeError): pass @@ -463,6 +508,29 @@ def is_internal_socket_a_list(group_node: Any, tree: Any, in_sock: Any, visited: if getattr(from_node, "is_list_output", False): return True + always_outs = getattr(from_node, "always_list_outputs", None) + if always_outs and getattr(from_sock, "name", "") in always_outs: + return True + + if getattr(from_node, "bl_idname", "") in ("SLICENODE_FloatMath", "SLICENODE_IntMath"): + for inp in getattr(from_node, "inputs", []): + if is_internal_socket_a_list(group_node, tree, inp, visited): + return True + return False + + if getattr(from_node, "bl_idname", "") == "SLICENODE_PathOperations": + inputs = getattr(from_node, "inputs", []) + if inputs: + return is_internal_socket_a_list(group_node, tree, inputs[0], visited) + return False + + if getattr(from_node, "bl_idname", "") == "SLICENODE_SeparateMeshByRange": + inputs = getattr(from_node, "inputs", None) + mesh_inp = inputs.get("Mesh") if inputs else None + if mesh_inp: + return is_internal_socket_a_list(group_node, tree, mesh_inp, visited) + return False + if getattr(from_node, "bl_idname", "") == "NodeGroupInput": in_item = _get_input_interface_item(tree, from_sock) if not in_item: @@ -543,6 +611,29 @@ def is_group_output_a_list( if getattr(src_node, "is_list_output", False): return True + always_outs = getattr(src_node, "always_list_outputs", None) + if always_outs and getattr(src_sock, "name", "") in always_outs: + return True + + if src_bl_id in ("SLICENODE_FloatMath", "SLICENODE_IntMath"): + for inp in getattr(src_node, "inputs", []): + if is_internal_socket_a_list(group_node, tree, inp, visited): + return True + return False + + if src_bl_id == "SLICENODE_PathOperations": + inputs = getattr(src_node, "inputs", []) + if inputs: + return is_internal_socket_a_list(group_node, tree, inputs[0], visited) + return False + + if src_bl_id == "SLICENODE_SeparateMeshByRange": + inputs = getattr(src_node, "inputs", None) + mesh_inp = inputs.get("Mesh") if inputs else None + if mesh_inp: + return is_internal_socket_a_list(group_node, tree, mesh_inp, visited) + return False + if src_bl_id == "SLICENODE_JoinGcode": for inp in getattr(src_node, "inputs", []): if getattr(inp, "name", "") == "Gcode" or getattr(inp, "identifier", "").startswith("Gcode_ms_in_"): @@ -634,6 +725,10 @@ def is_output_socket_a_list(sock: Any, visited: set | None = None) -> bool: if getattr(node, "is_list_output", False): return True + always_outs = getattr(node, "always_list_outputs", None) + if always_outs and getattr(sock, "name", "") in always_outs: + return True + if bl_idname in ("SLICENODE_FloatMath", "SLICENODE_IntMath"): return any(is_socket_linked_to_list(inp) for inp in getattr(node, "inputs", [])) @@ -651,6 +746,15 @@ def is_output_socket_a_list(sock: Any, visited: set | None = None) -> bool: if getattr(inp, "name", "") == "Gcode" or getattr(inp, "identifier", "").startswith("Gcode_ms_in_") ) + if bl_idname == "SLICENODE_PathOperations": + inputs = getattr(node, "inputs", None) + return is_socket_linked_to_list(inputs[0]) if inputs else False + + if bl_idname == "SLICENODE_SeparateMeshByRange": + inputs = getattr(node, "inputs", None) + mesh_inp = inputs.get("Mesh") if inputs else None + return is_socket_linked_to_list(mesh_inp) if mesh_inp else False + if bl_idname == "SLICENODE_Group": tree = getattr(node, "slicer_tree", None) or getattr(node, "node_tree", None) if tree: @@ -683,31 +787,111 @@ def is_socket_linked_to_list(sock: Any) -> bool: from_socket = _follow_reroute_upstream(from_socket) return is_output_socket_a_list(from_socket) -def sync_node_list_sockets(node: Any, mapping: list[tuple[str, list[str] | str]]) -> None: - """Updates display_shape on input and output sockets based on mapping. - Also updates display_shape for any secondary input sockets linked to a list. - mapping is a list of tuples: (input_socket_name, output_socket_names) - e.g. [('Paths', ['Paths']), ('Mesh', ['Selection', 'Inverted'])] +def sync_node_list_sockets(node: Any, mapping: list[tuple[str, list[str] | str]] | None = None) -> None: + """Updates display_shape on input and output sockets based on mapping and node rules. + Ensures list inputs/outputs consistently display as 'LIST', and single values as 'CIRCLE'. """ + if node is None: + return + + bl_id = getattr(node, "bl_idname", "") + if bl_id == "NodeReroute": + return + if bl_id == "SLICENODE_Group": + sync_group_node_socket_shapes(node) + return + + if mapping is None: + mapping = getattr(node, "list_socket_mapping", None) or [] + + always_in = set(getattr(node, "always_list_inputs", []) or []) + always_out = set(getattr(node, "always_list_outputs", []) or []) + is_list_out = bool(getattr(node, "is_list_output", False)) + + if bl_id in ("SLICENODE_FloatMath", "SLICENODE_IntMath"): + inputs = getattr(node, "inputs", []) + any_list = any(is_socket_linked_to_list(inp) for inp in inputs) + for inp in inputs: + set_socket_shape(inp, "LIST" if is_socket_linked_to_list(inp) else "CIRCLE") + for outp in getattr(node, "outputs", []): + set_socket_shape(outp, "LIST" if any_list else "CIRCLE") + return + + if bl_id == "SLICENODE_JoinGcode": + inputs = getattr(node, "inputs", []) + has_list = any( + is_socket_linked_to_list(inp) + for inp in inputs + if getattr(inp, "name", "") == "Gcode" or getattr(inp, "identifier", "").startswith("Gcode_ms_in_") + ) + for inp in inputs: + set_socket_shape(inp, "LIST" if is_socket_linked_to_list(inp) else "CIRCLE") + out_sock = getattr(node, "outputs", None) and node.outputs.get("Gcode") + if out_sock: + set_socket_shape(out_sock, "LIST" if has_list else "CIRCLE") + return + + if bl_id == "SLICENODE_PathOperations": + inputs = getattr(node, "inputs", []) + first_is_list = is_socket_linked_to_list(inputs[0]) if inputs else False + for inp in inputs: + set_socket_shape(inp, "LIST" if is_socket_linked_to_list(inp) else "CIRCLE") + out_sock = getattr(node, "outputs", None) and (node.outputs.get("Paths") or (node.outputs[0] if node.outputs else None)) + if out_sock: + set_socket_shape(out_sock, "LIST" if first_is_list else "CIRCLE") + return + + if bl_id == "SLICENODE_SeparateMeshByRange": + if hasattr(node, "_update_outputs"): + node._update_outputs() + return + mapped_inputs = set() + mapped_outputs = set() + for in_name, out_names in mapping: mapped_inputs.add(in_name) inp = getattr(node, "inputs", None) and node.inputs.get(in_name) if inp is None: continue - is_list = is_socket_linked_to_list(inp) - set_socket_shape(inp, "LIST" if is_list else "CIRCLE") + in_is_list = (in_name in always_in) or is_socket_linked_to_list(inp) + set_socket_shape(inp, "LIST" if in_is_list else "CIRCLE") if isinstance(out_names, str): targets = [out_names] else: targets = list(out_names) for out_name in targets: + mapped_outputs.add(out_name) out_sock = getattr(node, "outputs", None) and node.outputs.get(out_name) if out_sock is not None: - set_socket_shape(out_sock, "LIST" if is_list else "CIRCLE") + out_is_list = is_list_out or (out_name in always_out) or in_is_list + set_socket_shape(out_sock, "LIST" if out_is_list else "CIRCLE") for inp in getattr(node, "inputs", []): - if getattr(inp, "name", "") not in mapped_inputs: - is_list = is_socket_linked_to_list(inp) - set_socket_shape(inp, "LIST" if is_list else "CIRCLE") + inp_name = getattr(inp, "name", "") + if inp_name not in mapped_inputs: + in_is_list = (inp_name in always_in) or is_socket_linked_to_list(inp) + set_socket_shape(inp, "LIST" if in_is_list else "CIRCLE") + + for outp in getattr(node, "outputs", []): + outp_name = getattr(outp, "name", "") + if outp_name not in mapped_outputs: + out_is_list = is_list_out or (outp_name in always_out) + set_socket_shape(outp, "LIST" if out_is_list else "CIRCLE") + + +def sync_tree_socket_shapes(tree: Any) -> None: + """Updates display_shape ('LIST' vs 'CIRCLE') consistently across all nodes in the tree.""" + if not tree or not hasattr(tree, "nodes"): + return + + sync_group_tree_internal_sockets(tree) + + for _ in range(2): + for node in tree.nodes: + bl_id = getattr(node, "bl_idname", "") + if bl_id in ("NodeReroute", "NodeGroupInput", "NodeGroupOutput"): + continue + sync_node_list_sockets(node) + diff --git a/tests/test_layers.py b/tests/test_layers.py new file mode 100644 index 0000000..a0a26da --- /dev/null +++ b/tests/test_layers.py @@ -0,0 +1,379 @@ +import sys +from pathlib import Path +import unittest +from unittest.mock import MagicMock +import numpy as np + +# Add workspace directory to sys.path to resolve root package name (NodeSlicer) +PROJECT_ROOT = Path(__file__).resolve().parent.parent +if str(PROJECT_ROOT.parent) not in sys.path: + sys.path.append(str(PROJECT_ROOT.parent)) + +class DummyNodeSocket: pass +class DummyNode: pass +class DummyObject: pass +class DummyMesh: pass +class DummyNodeSocketShader: pass +class DummyNodeCustomGroup: pass +class DummyNodeTree: pass +class DummyOperator: pass +class DummyMenu: pass +class DummyPanel: pass +class DummyPropertyGroup: pass + +mock_types = MagicMock() +mock_types.NodeSocket = DummyNodeSocket +mock_types.Node = DummyNode +mock_types.Object = DummyObject +mock_types.Mesh = DummyMesh +mock_types.NodeSocketShader = DummyNodeSocketShader +mock_types.NodeCustomGroup = DummyNodeCustomGroup +mock_types.NodeTree = DummyNodeTree +mock_types.Operator = DummyOperator +mock_types.Menu = DummyMenu +mock_types.Panel = DummyPanel +mock_types.PropertyGroup = DummyPropertyGroup + +mock_bpy = MagicMock() +mock_bpy.context.scene = MagicMock() +mock_bpy.context.scene.unit_settings.scale_length = 0.001 +mock_bpy.types = mock_types + +sys.modules['bpy'] = mock_bpy +sys.modules['bpy.types'] = mock_types +sys.modules['bpy.props'] = MagicMock() +sys.modules['bpy.utils'] = MagicMock() +sys.modules['bpy.utils.previews'] = MagicMock() +sys.modules['bpy.app'] = MagicMock() +sys.modules['bpy.app.handlers'] = MagicMock() + +from NodeSlicer.core.slice import SlicePaths +from NodeSlicer.infra.blender.blender_mesh_capture import SlicingObject +from NodeSlicer.core.nodeslicer.nodes.path import SLICENODE_Layers, SLICENODE_SliceShellsLayers + + +def _make_box_mesh(z_min: float = 0.0, z_max: float = 1.0) -> SlicingObject: + """Helper to create a box mesh between z_min and z_max.""" + vertices = np.array([ + [0.0, 0.0, z_min], + [10.0, 0.0, z_min], + [10.0, 10.0, z_min], + [0.0, 10.0, z_min], + [0.0, 0.0, z_max], + [10.0, 0.0, z_max], + [10.0, 10.0, z_max], + [0.0, 10.0, z_max], + ], dtype=np.float64) + + faces = [ + [0, 1, 2], [0, 2, 3], # Bottom + [4, 6, 5], [4, 7, 6], # Top + [0, 4, 5], [0, 5, 1], # Front + [2, 6, 7], [2, 7, 3], # Back + [0, 3, 7], [0, 7, 4], # Left + [1, 5, 6], [1, 6, 2], # Right + ] + + triangles = np.zeros((len(faces), 3, 3), dtype=np.float64) + for i, face in enumerate(faces): + triangles[i, 0] = vertices[face[0]] + triangles[i, 1] = vertices[face[1]] + triangles[i, 2] = vertices[face[2]] + + sobj = SlicingObject() + sobj.name = "TestBox" + sobj.mesh = triangles + return sobj + + +class TestLayersNode(unittest.TestCase): + def test_layers_generation_single(self): + node = SLICENODE_Layers() + node.resolve = lambda name: 0.2 if name == "First Layer Height" else (0.2 if name == "Layer Height" else (1.0 if name == "Total Height" else None)) + res = node.evaluate_output("Layers") + self.assertEqual(res, [0.2, 0.4, 0.6, 0.8, 1.0]) + + def test_layers_different_flh(self): + node = SLICENODE_Layers() + node.resolve = lambda name: 0.3 if name == "First Layer Height" else (0.2 if name == "Layer Height" else (0.8 if name == "Total Height" else None)) + res = node.evaluate_output("Layers") + self.assertEqual(res, [0.3, 0.5, 0.7]) + + def test_layers_list_layer_height(self): + node = SLICENODE_Layers() + # Layer Height is a list of two values + node.resolve = lambda name: 0.2 if name == "First Layer Height" else ([0.2, 0.5] if name == "Layer Height" else (1.0 if name == "Total Height" else None)) + res = node.evaluate_output("Layers") + self.assertIsInstance(res, list) + self.assertEqual(len(res), 2) + self.assertEqual(res[0], [0.2, 0.4, 0.6, 0.8, 1.0]) + self.assertEqual(res[1], [0.2, 0.7]) + + +class TestSliceShellsLayersNode(unittest.TestCase): + def test_slice_shells_layers_single_mesh(self): + box = _make_box_mesh(z_min=0.0, z_max=1.0) + node = SLICENODE_SliceShellsLayers() + layers = [0.2, 0.4, 0.6, 0.8, 1.0, 1.2, 1.4] # Has layers above box + node.resolve = lambda name: box if name == "Mesh" else (layers if name == "Layers" else None) + + slices = node.evaluate_output("Paths") + self.assertIsInstance(slices, SlicePaths) + # Should only slice in box range 0.0 .. 1.0 + self.assertEqual(len(slices), 5) + np.testing.assert_allclose([s[0, 2] for s in slices], [0.2, 0.4, 0.6, 0.8, 1.0]) + + def test_slice_shells_layers_multi_mesh(self): + box1 = _make_box_mesh(z_min=0.0, z_max=0.5) + box2 = _make_box_mesh(z_min=0.0, z_max=1.0) + node = SLICENODE_SliceShellsLayers() + layers = [0.2, 0.4, 0.6, 0.8, 1.0] + + # Multi-mesh with single layer plan + node.resolve = lambda name: [box1, box2] if name == "Mesh" else (layers if name == "Layers" else None) + + slices_list = node.evaluate_output("Paths") + self.assertIsInstance(slices_list, list) + self.assertEqual(len(slices_list), 2) + # Box1 has layers up to 0.5 -> 0.2, 0.4 (2 layers) + self.assertEqual(len(slices_list[0]), 2) + np.testing.assert_allclose([s[0, 2] for s in slices_list[0]], [0.2, 0.4]) + # Box2 has layers up to 1.0 -> 0.2, 0.4, 0.6, 0.8, 1.0 (5 layers) + self.assertEqual(len(slices_list[1]), 5) + np.testing.assert_allclose([s[0, 2] for s in slices_list[1]], [0.2, 0.4, 0.6, 0.8, 1.0]) + + +class DummySock: + def __init__(self, name="Socket", bl_idname="NodeSocketFloat", display_shape="CIRCLE"): + self.name = name + self.identifier = name + self.bl_idname = bl_idname + self.display_shape = display_shape + self.is_linked = False + self.links = [] + self.node = None + self.is_output = False + + +class DummyLink: + def __init__(self, from_sock, to_sock): + self.from_socket = from_sock + self.to_socket = to_sock + self.from_node = getattr(from_sock, "node", None) + self.to_node = getattr(to_sock, "node", None) + + +class DummySocketCollection(list): + def get(self, name, default=None): + for s in self: + if getattr(s, "name", None) == name or getattr(s, "identifier", None) == name: + return s + return default + + +class DummyNodeContainer: + def __init__(self, bl_idname="SliceNode"): + self.bl_idname = bl_idname + self.name = bl_idname + self.inputs = DummySocketCollection() + self.outputs = DummySocketCollection() + self.list_socket_mapping = [] + self.always_list_inputs = [] + self.always_list_outputs = [] + self.is_list_output = False + + def add_in(self, sock): + sock.node = self + sock.is_output = False + self.inputs.append(sock) + + def add_out(self, sock): + sock.node = self + sock.is_output = True + self.outputs.append(sock) + + +class TestConsistentSocketAppearance(unittest.TestCase): + def test_layers_node_output_always_list(self): + from NodeSlicer.infra.blender.list_utils import sync_node_list_sockets, is_output_socket_a_list + + node = DummyNodeContainer("SLICENODE_Layers") + node.is_list_output = True + node.always_list_outputs = ["Layers"] + node.list_socket_mapping = [("Layer Height", "Layers")] + + in_flh = DummySock("First Layer Height", "NodeSocketFloat", "CIRCLE") + in_lh = DummySock("Layer Height", "NodeSocketFloat", "CIRCLE") + in_tot = DummySock("Total Height", "NodeSocketFloat", "CIRCLE") + out_layers = DummySock("Layers", "NodeSocketFloat", "LIST") + + node.add_in(in_flh) + node.add_in(in_lh) + node.add_in(in_tot) + node.add_out(out_layers) + + # Sync with unlinked inputs + sync_node_list_sockets(node) + self.assertEqual(out_layers.display_shape, "LIST") + self.assertTrue(is_output_socket_a_list(out_layers)) + self.assertEqual(in_lh.display_shape, "CIRCLE") + + # When Layer Height is linked to a list + upstream_list = DummySock("ListOut", "NodeSocketFloat", "LIST") + link = DummyLink(upstream_list, in_lh) + in_lh.is_linked = True + in_lh.links = [link] + + sync_node_list_sockets(node) + self.assertEqual(in_lh.display_shape, "LIST") + self.assertEqual(out_layers.display_shape, "LIST") + + def test_slice_shells_layers_socket_shapes(self): + from NodeSlicer.infra.blender.list_utils import sync_node_list_sockets + + node = DummyNodeContainer("SLICENODE_SliceShellsLayers") + node.always_list_inputs = ["Layers"] + node.list_socket_mapping = [("Mesh", "Paths")] + + in_mesh = DummySock("Mesh", "SLICESOCK_Mesh", "CIRCLE") + in_layers = DummySock("Layers", "NodeSocketFloat", "LIST") + in_rot = DummySock("Rotation", "NodeSocketRotation", "CIRCLE") + out_paths = DummySock("Paths", "SLICESOCK_Paths", "CIRCLE") + + node.add_in(in_mesh) + node.add_in(in_layers) + node.add_in(in_rot) + node.add_out(out_paths) + + # Before linking: Layers is LIST, Mesh/Paths/Rotation are CIRCLE + sync_node_list_sockets(node) + self.assertEqual(in_layers.display_shape, "LIST") + self.assertEqual(in_mesh.display_shape, "CIRCLE") + self.assertEqual(out_paths.display_shape, "CIRCLE") + self.assertEqual(in_rot.display_shape, "CIRCLE") + + # Link Mesh to a list output + mesh_list_out = DummySock("Meshes", "SLICESOCK_Mesh", "LIST") + link_mesh = DummyLink(mesh_list_out, in_mesh) + in_mesh.is_linked = True + in_mesh.links = [link_mesh] + + sync_node_list_sockets(node) + self.assertEqual(in_layers.display_shape, "LIST") + self.assertEqual(in_mesh.display_shape, "LIST") + self.assertEqual(out_paths.display_shape, "LIST") + self.assertEqual(in_rot.display_shape, "CIRCLE") + + # Link Rotation to a list output + rot_list_out = DummySock("Rotations", "NodeSocketRotation", "LIST") + link_rot = DummyLink(rot_list_out, in_rot) + in_rot.is_linked = True + in_rot.links = [link_rot] + + sync_node_list_sockets(node) + self.assertEqual(in_rot.display_shape, "LIST") + + # Disconnect Mesh list: should revert Mesh and Paths back to CIRCLE, Layers stays LIST + in_mesh.is_linked = False + in_mesh.links = [] + + sync_node_list_sockets(node) + self.assertEqual(in_layers.display_shape, "LIST") + self.assertEqual(in_mesh.display_shape, "CIRCLE") + self.assertEqual(out_paths.display_shape, "CIRCLE") + + def test_get_transforms_socket_shapes(self): + from NodeSlicer.infra.blender.list_utils import sync_node_list_sockets + + node = DummyNodeContainer("SLICENODE_GetTransforms") + node.list_socket_mapping = [("Geometry", ["Translation", "Rotation", "Scale"])] + + in_geom = DummySock("Geometry", "SLICESOCK_Geometry", "CIRCLE") + out_t = DummySock("Translation", "NodeSocketVector", "CIRCLE") + out_r = DummySock("Rotation", "NodeSocketRotation", "CIRCLE") + out_s = DummySock("Scale", "NodeSocketVector", "CIRCLE") + + node.add_in(in_geom) + node.add_out(out_t) + node.add_out(out_r) + node.add_out(out_s) + + # Unlinked -> all CIRCLE + sync_node_list_sockets(node) + self.assertEqual(in_geom.display_shape, "CIRCLE") + self.assertEqual(out_t.display_shape, "CIRCLE") + self.assertEqual(out_r.display_shape, "CIRCLE") + self.assertEqual(out_s.display_shape, "CIRCLE") + + # Linked to list -> all 3 outputs become LIST + geom_list_out = DummySock("Geometry", "SLICESOCK_Geometry", "LIST") + link = DummyLink(geom_list_out, in_geom) + in_geom.is_linked = True + in_geom.links = [link] + + sync_node_list_sockets(node) + self.assertEqual(in_geom.display_shape, "LIST") + self.assertEqual(out_t.display_shape, "LIST") + self.assertEqual(out_r.display_shape, "LIST") + self.assertEqual(out_s.display_shape, "LIST") + + def test_sync_tree_socket_shapes_propagation(self): + from NodeSlicer.infra.blender.list_utils import sync_tree_socket_shapes + + # Chain: NodeA (List Output) -> NodeB (GetMesh) -> NodeC (SliceShells) + node_a = DummyNodeContainer("SLICENODE_ObjectsToList") + node_a.is_list_output = True + node_a.always_list_outputs = ["Objects"] + out_a = DummySock("Objects", "NodeSocketObject", "LIST") + node_a.add_out(out_a) + + node_b = DummyNodeContainer("SLICENODE_GetMesh") + node_b.list_socket_mapping = [("Object", "Mesh")] + in_b = DummySock("Object", "NodeSocketObject", "CIRCLE") + out_b = DummySock("Mesh", "SLICESOCK_Mesh", "CIRCLE") + node_b.add_in(in_b) + node_b.add_out(out_b) + + node_c = DummyNodeContainer("SLICENODE_SliceShells") + node_c.list_socket_mapping = [("Mesh", "Paths")] + in_c = DummySock("Mesh", "SLICESOCK_Mesh", "CIRCLE") + out_c = DummySock("Paths", "SLICESOCK_Paths", "CIRCLE") + node_c.add_in(in_c) + node_c.add_out(out_c) + + # Link A -> B and B -> C + link_ab = DummyLink(out_a, in_b) + in_b.is_linked = True + in_b.links = [link_ab] + + link_bc = DummyLink(out_b, in_c) + in_c.is_linked = True + in_c.links = [link_bc] + + tree = type("DummyTree", (), {})() + tree.nodes = [node_a, node_b, node_c] + + sync_tree_socket_shapes(tree) + + # Both B and C should have LIST shapes + self.assertEqual(out_a.display_shape, "LIST") + self.assertEqual(in_b.display_shape, "LIST") + self.assertEqual(out_b.display_shape, "LIST") + self.assertEqual(in_c.display_shape, "LIST") + self.assertEqual(out_c.display_shape, "LIST") + + # Disconnect A -> B: everything down the chain should revert to CIRCLE + in_b.is_linked = False + in_b.links = [] + + sync_tree_socket_shapes(tree) + + self.assertEqual(out_a.display_shape, "LIST") + self.assertEqual(in_b.display_shape, "CIRCLE") + self.assertEqual(out_b.display_shape, "CIRCLE") + self.assertEqual(in_c.display_shape, "CIRCLE") + self.assertEqual(out_c.display_shape, "CIRCLE") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_lists.py b/tests/test_lists.py index 7229985..789c121 100644 --- a/tests/test_lists.py +++ b/tests/test_lists.py @@ -58,7 +58,6 @@ from NodeSlicer.infra.blender.list_utils import ( ) from NodeSlicer.core.slice import SlicePaths from NodeSlicer.infra.blender.blender_mesh_capture import SlicingObject, SlicingCollection, SlicingGroup -from NodeSlicer.core.nodeslicer.tree import SLICETREE_NodeTree class TestListUtils(unittest.TestCase): @@ -185,56 +184,6 @@ class TestSocketShapesAndLinks(unittest.TestCase): self.assertEqual(out_path.display_shape, "LIST") -class TestTreeValidLinkRules(unittest.TestCase): - def setUp(self): - self.tree = SLICETREE_NodeTree() - - def test_valid_link_start_print_rejects_list(self): - list_out = DummySocket("Gcode", "NodeSocketString", display_shape="LIST") - start_print = DummyNode("SLICENODE_StartPrint") - sp_in = DummySocket("Gcode", "NodeSocketString") - start_print.add_in(sp_in) - - # Connection should be rejected - self.assertFalse(self.tree.valid_link(list_out, sp_in)) - - # Standard non-list connection should be allowed - normal_out = DummySocket("Gcode", "NodeSocketString", display_shape="CIRCLE") - self.assertTrue(self.tree.valid_link(normal_out, sp_in)) - - def test_valid_link_printer_list_rejects_list(self): - list_out = DummySocket("Gcode", "NodeSocketString", display_shape="LIST") - printer_list = DummyNode("SLICENODE_PrinterList") - pl_in = DummySocket("Printer1", "NodeSocketString") - printer_list.add_in(pl_in) - - self.assertFalse(self.tree.valid_link(list_out, pl_in)) - - def test_valid_link_path_operations_secondary_inputs_accept_list(self): - list_out = DummySocket("Paths", "SLICESOCK_Paths", display_shape="LIST") - path_ops = DummyNode("SLICENODE_PathOperations") - p0 = DummySocket("Paths", "SLICESOCK_Paths") - p1 = DummySocket("Paths", "SLICESOCK_Paths") - path_ops.add_in(p0) - path_ops.add_in(p1) - - # Primary input p0 accepts list - self.assertTrue(self.tree.valid_link(list_out, p0)) - # Secondary input p1 also accepts list (matched index per index) - self.assertTrue(self.tree.valid_link(list_out, p1)) - - def test_valid_link_seam_placement_cutting_mesh_accepts_list(self): - list_mesh_out = DummySocket("Mesh", "SLICESOCK_Mesh", display_shape="LIST") - seam_node = DummyNode("SLICENODE_SeamPlacement") - p_in = DummySocket("Paths", "SLICESOCK_Paths") - m_in = DummySocket("Mesh", "SLICESOCK_Mesh") - seam_node.add_in(p_in) - seam_node.add_in(m_in) - - # Secondary cutting mesh accepts list (matched index per index) - self.assertTrue(self.tree.valid_link(list_mesh_out, m_in)) - - class TestNodeEvaluationsWithLists(unittest.TestCase): def test_path_operations_with_first_input_list(self): from NodeSlicer.core.nodeslicer.nodes.path import SLICENODE_PathOperations @@ -669,86 +618,7 @@ class TestGroupNodeListShapes(unittest.TestCase): self.assertEqual(grp_in.display_shape, "LIST") self.assertEqual(grp_out.display_shape, "LIST") - def test_valid_link_with_group_nodes(self): - item_paths = DummyInterfaceItem("Paths", "sock_paths", in_out="INPUT", socket_type="SLICESOCK_Paths") - item_float = DummyInterfaceItem("FloatVal", "sock_float", in_out="INPUT", socket_type="NodeSocketFloat") - - gin = DummyNode("NodeGroupInput") - gin_out_paths = DummySocket("Paths", "SLICESOCK_Paths") - gin_out_paths.identifier = "sock_paths" - gin_out_float = DummySocket("FloatVal", "NodeSocketFloat") - gin_out_float.identifier = "sock_float" - gin.add_out(gin_out_paths) - gin.add_out(gin_out_float) - - target_node = DummyNode("SLICENODE_BufferShells") - target_node.list_socket_mapping = [("Paths", "Paths")] - target_in_paths = DummySocket("Paths", "SLICESOCK_Paths") - target_node.add_in(target_in_paths) - - link = DummyLink(gin_out_paths, target_in_paths) - gin_out_paths.is_linked = True - gin_out_paths.links = [link] - - tree = DummyGroupTree([item_paths, item_float], [gin, target_node]) - - grp_node = DummyGroupNode("Group", tree) - grp_in_paths = DummySocket("Paths", "SLICESOCK_Paths") - grp_in_paths.identifier = "sock_paths" - grp_in_float = DummySocket("FloatVal", "NodeSocketFloat") - grp_in_float.identifier = "sock_float" - grp_node.add_in(grp_in_paths) - grp_node.add_in(grp_in_float) - - list_out = DummySocket("Paths", "SLICESOCK_Paths", display_shape="LIST") - validator = SLICETREE_NodeTree() - - # Linking list to Paths group input (which accepts list): allowed - self.assertTrue(validator.valid_link(list_out, grp_in_paths)) - - # Linking list to FloatVal group input: rejected - self.assertFalse(validator.valid_link(list_out, grp_in_float)) - - class TestImplicitGetMesh(unittest.TestCase): - def setUp(self): - self.tree = SLICETREE_NodeTree() - - def test_valid_link_object_to_mesh(self): - obj_out = DummySocket("Object", "NodeSocketObject") - obj_out.is_output = True - mesh_in = DummySocket("Mesh", "SLICESOCK_Mesh") - mesh_in.is_output = False - mesh_in.node = DummyNode("SLICENODE_SliceShells") - mesh_in.node.list_socket_mapping = [("Mesh", "Paths")] - - # Object output to Mesh input is valid - self.assertTrue(self.tree.valid_link(obj_out, mesh_in)) - - # Reverse: Mesh output to Object input is NOT valid - mesh_out = DummySocket("Mesh", "SLICESOCK_Mesh") - mesh_out.is_output = True - obj_in = DummySocket("Object", "NodeSocketObject") - obj_in.is_output = False - self.assertFalse(self.tree.valid_link(mesh_out, obj_in)) - - def test_valid_link_object_list_to_mesh(self): - list_obj_out = DummySocket("Objects", "NodeSocketObject", display_shape="LIST") - list_obj_out.is_output = True - mesh_in = DummySocket("Mesh", "SLICESOCK_Mesh") - mesh_in.is_output = False - mesh_in.node = DummyNode("SLICENODE_SliceShells") - mesh_in.node.list_socket_mapping = [("Mesh", "Paths")] - - # Target accepts lists -> allowed - self.assertTrue(self.tree.valid_link(list_obj_out, mesh_in)) - - # SeamPlacement cutting mesh accepts lists - seam_mesh_in = DummySocket("Mesh", "SLICESOCK_Mesh") - seam_mesh_in.is_output = False - seam_mesh_in.node = DummyNode("SLICENODE_SeamPlacement") - self.assertTrue(self.tree.valid_link(list_obj_out, seam_mesh_in)) - def test_resolve_input_implicit_get_mesh_single_object(self): from NodeSlicer.infra.blender.blender_slice_nodes_generic import resolve_input, execution_cache diff --git a/tests/test_transform.py b/tests/test_transform.py index 69e9785..ca831ce 100644 --- a/tests/test_transform.py +++ b/tests/test_transform.py @@ -739,14 +739,29 @@ class TestTransform(unittest.TestCase): # 9. Blender Object fallback class MockBpyObject: - location = (11.0, 22.0, 33.0) - rotation_euler = (0.4, 0.5, 0.6) - scale = (1.1, 2.2, 3.3) + def __init__(self, loc=(11.0, 22.0, 33.0), rot=(0.4, 0.5, 0.6), sc=(1.1, 2.2, 3.3)): + self.location = loc + self.rotation_euler = rot + self.scale = sc node.resolve = lambda s: MockBpyObject() self.assertEqual(node.evaluate_output("Translation"), (11.0, 22.0, 33.0)) self.assertEqual(node.evaluate_output("Rotation"), (0.4, 0.5, 0.6)) self.assertEqual(node.evaluate_output("Scale"), (1.1, 2.2, 3.3)) + # 10. List of Blender Objects + obj1 = MockBpyObject((1.0, 2.0, 3.0), (0.0, 0.0, 0.0), (1.0, 1.0, 1.0)) + obj2 = MockBpyObject((4.0, 5.0, 6.0), (0.1, 0.2, 0.3), (2.0, 2.0, 2.0)) + node.resolve = lambda s: [obj1, obj2] + self.assertEqual(node.evaluate_output("Translation"), [(1.0, 2.0, 3.0), (4.0, 5.0, 6.0)]) + self.assertEqual(node.evaluate_output("Rotation"), [(0.0, 0.0, 0.0), (0.1, 0.2, 0.3)]) + self.assertEqual(node.evaluate_output("Scale"), [(1.0, 1.0, 1.0), (2.0, 2.0, 2.0)]) + + # 11. Empty list + node.resolve = lambda s: [] + self.assertEqual(node.evaluate_output("Translation"), []) + self.assertEqual(node.evaluate_output("Rotation"), []) + self.assertEqual(node.evaluate_output("Scale"), []) + def test_path_operations_acquire_transforms(self): """Tests Acquire Transforms checkbox and behavior on SLICENODE_PathOperations.""" from NodeSlicer.core.nodeslicer.nodes.path import SLICENODE_PathOperations