get_transforms

This commit is contained in:
Nicolas Predella 2026-10-14 16:39:49 +03:00
parent 4392eb393f
commit 657660ab2e
13 changed files with 889 additions and 212 deletions

View file

@ -36,6 +36,7 @@ def register():
print('Nodeslicer Loaded') print('Nodeslicer Loaded')
def unregister(): def unregister():
try: try:
tree.cancel_timer() tree.cancel_timer()
except Exception as e: except Exception as e:

View file

@ -72,6 +72,8 @@ class SLICENODE_JoinMesh(SliceNodeJoiner):
class SLICENODE_JoinMeshGroups(SliceNodeJoiner): class SLICENODE_JoinMeshGroups(SliceNodeJoiner):
bl_label = "Join Mesh Groups" bl_label = "Join Mesh Groups"
socket_type = "SLICESOCK_Mesh" socket_type = "SLICESOCK_Mesh"
is_list_output = True
always_list_outputs = ["Mesh"]
def init(self, context): def init(self, context):
self.add_output(self.output_socket_type or self.socket_type, display_shape='LIST') 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: if inp is not None:
set_socket_shape(inp, "LIST" if is_list else "CIRCLE") 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 # Remove outputs that are no longer in the range
to_remove = [o for o in self.outputs if o.identifier not in expected_ids] to_remove = [o for o in self.outputs if o.identifier not in expected_ids]
for o in to_remove: for o in to_remove:
@ -229,7 +235,7 @@ class SLICENODE_SeparateMeshByRange(SliceNode):
# Add new outputs # Add new outputs
for id_str in expected_ids: for id_str in expected_ids:
if id_str not in self.outputs: 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: else:
set_socket_shape(self.outputs[id_str], "LIST" if is_list else "CIRCLE") 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) copied_data.transforms = update_transform(getattr(geom, "transforms", []), t_arr, r_arr, s_arr)
return copied_data 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 return geom
@ -582,14 +592,40 @@ class SLICENODE_GetTransforms(SliceNode):
def evaluate_output(self, socket_name: str, **_kwargs) -> Any: def evaluate_output(self, socket_name: str, **_kwargs) -> Any:
from ....core.slice import extract_geometry_transforms 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) from ....infra.blender.list_utils import is_input_a_list
return self.eval_multivalue(
extract_geometry_transforms, sock_lower = socket_name.lower()
main_socket="Geometry", default = (1.0, 1.0, 1.0) if sock_lower == "scale" else (0.0, 0.0, 0.0)
secondary_sockets=[], idx = 0 if sock_lower == "translation" else (1 if sock_lower == "rotation" else 2)
socket_name=socket_name,
default=default, 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 @register_class

View file

@ -104,6 +104,18 @@ def _slice_single_mesh_item(coll: Any, layer_height: float, fl_height: float | N
else: else:
z_values = np.array([], dtype=np.float64) 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( slices_group: list[list[np.ndarray]] | None = slice_at_range(
[merged_object.mesh], [merged_object.mesh],
z_values, z_values,
@ -155,6 +167,7 @@ def _slice_single_mesh_item(coll: Any, layer_height: float, fl_height: float | N
else: else:
break break
has_rot = bool(rotation and any(rotation))
if has_rot: if has_rot:
from mathutils import Euler from mathutils import Euler
mat_inv = np.array(Euler(rotation).to_matrix()) 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 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 @register_class
class SLICENODE_SliceShells(SliceNode): class SLICENODE_SliceShells(SliceNode):
bl_label = "Slice Shells" bl_label = "Slice Shells"
@ -209,6 +315,52 @@ class SLICENODE_SliceShells(SliceNode):
default=SlicePaths(), 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 @register_class
class SLICENODE_BufferShells(SliceNode): class SLICENODE_BufferShells(SliceNode):
bl_label = "Buffer Shells" bl_label = "Buffer Shells"

View file

@ -369,8 +369,8 @@ def depsgraph_update_handler(scene, depsgraph):
for tree in bpy.data.node_groups: for tree in bpy.data.node_groups:
if tree.bl_idname == "SLICETREE_NodeTree": if tree.bl_idname == "SLICETREE_NodeTree":
try: try:
from ...infra.blender.list_utils import sync_group_tree_internal_sockets from ...infra.blender.list_utils import sync_tree_socket_shapes
sync_group_tree_internal_sockets(tree) sync_tree_socket_shapes(tree)
except Exception: except Exception:
pass pass
for node in tree.nodes: for node in tree.nodes:
@ -406,50 +406,19 @@ class SLICETREE_NodeTree(NodeTree):
bl_use_group_interface = True bl_use_group_interface = True
def update(self): def update(self):
super_update = getattr(super(), "update", None) global _is_updating
if callable(super_update): if _is_updating or getattr(self, "_is_syncing_shapes", False):
return
try: try:
super_update() self._is_syncing_shapes = True
from ...infra.blender.list_utils import sync_tree_socket_shapes
sync_tree_socket_shapes(self)
except Exception: except Exception:
pass pass
if _is_updating: finally:
return self._is_syncing_shapes = False
request_auto_refresh(self) 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 @register_class
class SLICENODE_OT_GroupToggle(Operator): 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="Integer").type = "SLICENODE_InputInt"
layout.operator("node.add_node", text="Rotation").type = "SLICENODE_InputVectorRotation" 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="Vector").type = "SLICENODE_InputVector"
layout.operator("node.add_node", text="Layers").type = "SLICENODE_Layers"
layout.separator() layout.separator()
layout.operator("node.add_node", text="Object").type = "SLICENODE_InputObject" layout.operator("node.add_node", text="Object").type = "SLICENODE_InputObject"
layout.operator("node.add_node", text="Collection Objects").type = "SLICENODE_CollectionObjects" 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="Arrange Slicing Group").type = "SLICENODE_ArrangeMesh"
layout.operator("node.add_node", text="Get Paths").type = "SLICENODE_GetPaths" layout.operator("node.add_node", text="Get Paths").type = "SLICENODE_GetPaths"
layout.separator() 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").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="Seam Placement").type = "SLICENODE_SeamPlacement"
layout.operator("node.add_node", text="Buffer Shells").type = "SLICENODE_BufferShells" layout.operator("node.add_node", text="Buffer Shells").type = "SLICENODE_BufferShells"
layout.operator("node.add_node", text="Buffer Shells Multiple").type = "SLICENODE_BufferShellsMultiple" layout.operator("node.add_node", text="Buffer Shells Multiple").type = "SLICENODE_BufferShellsMultiple"

View file

@ -415,6 +415,13 @@ def extract_mesh_transforms(mesh_input: Any) -> list[dict[str, Any]]:
for o in mesh_input.objects: for o in mesh_input.objects:
if hasattr(o, "transforms") and o.transforms: if hasattr(o, "transforms") and o.transforms:
return [dict(t) for t in o.transforms if isinstance(t, dict)] 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): if isinstance(mesh_input, list):
for item in mesh_input: for item in mesh_input:
trans = extract_mesh_transforms(item) trans = extract_mesh_transforms(item)
@ -425,15 +432,23 @@ def extract_mesh_transforms(mesh_input: Any) -> list[dict[str, Any]]:
def extract_geometry_transforms( def extract_geometry_transforms(
geom: Any 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 Extracts consolidated (translation, rotation, scale) from a geometry item
(SlicePaths, SlicingObject, SlicingCollection, SlicingGroup, or Blender Object). (SlicePaths, SlicingObject, SlicingCollection, SlicingGroup, or Blender Object).
Rotation is returned as Euler angles in radians (rx, ry, rz). 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: if geom is None:
return (0.0, 0.0, 0.0), (0.0, 0.0, 0.0), (1.0, 1.0, 1.0) 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) transforms = extract_mesh_transforms(geom)
if not transforms and hasattr(geom, "transforms") and isinstance(geom.transforms, list): if not transforms and hasattr(geom, "transforms") and isinstance(geom.transforms, list):
transforms = [dict(t) for t in geom.transforms if isinstance(t, dict)] transforms = [dict(t) for t in geom.transforms if isinstance(t, dict)]

View file

@ -46,6 +46,15 @@ This file provides a mapping of modules to their primary responsibilities and ke
- `geometry.py`: - `geometry.py`:
- Geometry utility functions. - 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) ## `/core/pdf_exporter` (PDF Export & Technical Marking Engine)
- `sa2_measures.py`: - `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. - `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). - `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. - `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.

View file

@ -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. - **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. - **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. - **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 ## Nodes
@ -60,6 +61,11 @@ All nodes are found in the **Add (Shift+A)** menu under the **SliceNodes** categ
- **Printer**: Printer name picker. - **Printer**: Printer name picker.
- **Printer List**: List of configured printers. - **Printer List**: List of configured printers.
- **Printer Intrinsics**: Configures bed size, extrusion/travel rates, retraction distance & feedrate, and Z-lift. - **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 ### Geometry Processing
- **Get Mesh**: Converts a Blender Object into internal `SlicingObject` format. - **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`). - **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. - **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. - **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). - **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. - **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. - **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.

View file

@ -117,6 +117,14 @@ class SlicingObject():
self.name = str(obj.name) if obj else "" self.name = str(obj.name) if obj else ""
self.parent = parent self.parent = parent
self.attributes = {} self.attributes = {}
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.transforms = []
self.pdf_properties = PdfProperties() self.pdf_properties = PdfProperties()
self.pdf_object_properties = [] self.pdf_object_properties = []
@ -250,6 +258,14 @@ class SlicingCollection():
self.objects = [SlicingObject(obj, parent) for obj in objs] self.objects = [SlicingObject(obj, parent) for obj in objs]
self.objects = [o for o in self.objects if o.mesh.size] self.objects = [o for o in self.objects if o.mesh.size]
self.name = parent self.name = parent
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 = [] self.transforms = []
def _clear_cache(self): def _clear_cache(self):
@ -373,6 +389,14 @@ class SlicingGroup():
transforms: list[dict[str, Any]] transforms: list[dict[str, Any]]
def __init__(self, selected_objs: list[Object]) -> None: def __init__(self, selected_objs: list[Object]) -> None:
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 = [] self.transforms = []
family = [] family = []

View file

@ -119,16 +119,17 @@ class SliceNode(Node, AutoBlIdName):
return False return False
list_socket_mapping: list[tuple[str, list[str] | str]] = [] 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: def update(self) -> None:
self._update_multi_groups() self._update_multi_groups()
self._sync_list_socket_shapes() self._sync_list_socket_shapes()
def _sync_list_socket_shapes(self) -> None: def _sync_list_socket_shapes(self) -> None:
mapping = getattr(self, "list_socket_mapping", None)
if mapping:
from .list_utils import sync_node_list_sockets from .list_utils import sync_node_list_sockets
sync_node_list_sockets(self, mapping) sync_node_list_sockets(self, getattr(self, "list_socket_mapping", None))
def evaluate_output(self, socket_name: str, **_kwargs) -> Any: ... def evaluate_output(self, socket_name: str, **_kwargs) -> Any: ...
@ -154,6 +155,7 @@ class SliceNode(Node, AutoBlIdName):
socket_name: str | None = None, socket_name: str | None = None,
default: Any = None, default: Any = None,
is_vector: list[bool] | None = None, is_vector: list[bool] | None = None,
is_atomic: list[bool] | None = None,
) -> Any: ) -> Any:
from .list_utils import eval_multivalue from .list_utils import eval_multivalue
return eval_multivalue( return eval_multivalue(
@ -164,6 +166,7 @@ class SliceNode(Node, AutoBlIdName):
socket_name=socket_name, socket_name=socket_name,
default=default, default=default,
is_vector=is_vector, 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 = ""): 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 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 = self.outputs.new(bl_idname, eval_name, identifier=identifier or eval_name)
sock.hide = hide 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: if display_shape:
sock.display_shape = display_shape sock.display_shape = display_shape
if description and hasattr(sock, "description"): 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 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 = self.inputs.new(bl_idname, eval_name, identifier=identifier or eval_name)
sock.hide = hide 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: if display_shape:
sock.display_shape = display_shape sock.display_shape = display_shape
if description and hasattr(sock, "description"): if description and hasattr(sock, "description"):

View file

@ -36,6 +36,18 @@ def is_vector_value(val: Any) -> bool:
return True return True
return False 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: def is_input_a_list(val: Any, is_vector: bool = False) -> bool:
"""Determines whether an input value represents a list of items. """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.""" 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:
if is_vector_value(val): if is_vector_value(val):
return False return False
return isinstance(val, (list, tuple)) return _is_sequence_collection(val)
return isinstance(val, (list, tuple)) return _is_sequence_collection(val)
def is_list_data(val: Any) -> bool: def is_list_data(val: Any) -> bool:
"""Checks if val represents a list of items (Object, Mesh, Path, Gcode). """Checks if val represents a list of items (Object, Mesh, Path, Gcode).
@ -74,12 +86,15 @@ def prepare_indexed_inputs(
*secondary_inputs: Any, *secondary_inputs: Any,
is_vector: list[bool] | None = None, is_vector: list[bool] | None = None,
input_names: list[str] | None = None, input_names: list[str] | None = None,
is_atomic: list[bool] | None = None,
) -> tuple[bool, list[Any], list[list[Any]]]: ) -> tuple[bool, list[Any], list[list[Any]]]:
"""Prepares and validates main and secondary inputs according to the generalized list architecture: """Prepares and validates main and secondary inputs according to the generalized list architecture:
- Main input can be a list or a single item. - 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). - 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 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 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: Returns:
(is_main_list, main_items, broadcasted_secondary_lists) (is_main_list, main_items, broadcasted_secondary_lists)
@ -89,13 +104,37 @@ def prepare_indexed_inputs(
n = len(main_items) n = len(main_items)
vec_flags = is_vector or [False] * len(secondary_inputs) 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))] names = input_names or [f"input_{i+1}" for i in range(len(secondary_inputs))]
broadcasted_secondaries: list[list[Any]] = [] broadcasted_secondaries: list[list[Any]] = []
for i, sec_val in enumerate(secondary_inputs): for i, sec_val in enumerate(secondary_inputs):
is_vec = vec_flags[i] if i < len(vec_flags) else False 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}" 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): if not is_input_a_list(sec_val, is_vector=is_vec):
broadcasted_secondaries.append([sec_val] * n) broadcasted_secondaries.append([sec_val] * n)
elif len(sec_val) == n: elif len(sec_val) == n:
@ -120,6 +159,7 @@ def eval_element_wise_multivalue(
is_vector: list[bool] | None = None, is_vector: list[bool] | None = None,
input_names: list[str] | None = None, input_names: list[str] | None = None,
output_index: int | None = None, output_index: int | None = None,
is_atomic: list[bool] | None = None,
) -> Any: ) -> Any:
"""Evaluates op_func element-wise with support for main and secondary list inputs: """Evaluates op_func element-wise with support for main and secondary list inputs:
- If main_data is a list, returns a list of results. - If main_data is a list, returns a list of results.
@ -134,6 +174,7 @@ def eval_element_wise_multivalue(
*secondary_inputs, *secondary_inputs,
is_vector=is_vector, is_vector=is_vector,
input_names=input_names, input_names=input_names,
is_atomic=is_atomic,
) )
def _call(item: Any, i: int) -> Any: def _call(item: Any, i: int) -> Any:
@ -179,6 +220,7 @@ def eval_multivalue(
socket_name: str | None = None, socket_name: str | None = None,
default: Any = None, default: Any = None,
is_vector: list[bool] | None = None, is_vector: list[bool] | None = None,
is_atomic: list[bool] | None = None,
) -> Any: ) -> Any:
"""Generalized element-wise multi-value evaluator for SliceNode instances.""" """Generalized element-wise multi-value evaluator for SliceNode instances."""
mapping = getattr(node, "list_socket_mapping", None) mapping = getattr(node, "list_socket_mapping", None)
@ -197,14 +239,15 @@ def eval_multivalue(
# Determine output index / socket validation # Determine output index / socket validation
output_index: int | None = None output_index: int | None = None
if socket_name is not None and mapping: 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 out_spec is not None:
if isinstance(out_spec, (list, tuple)): 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 return default
output_index = out_spec.index(socket_name) output_index = matched_idx
elif isinstance(out_spec, str) and out_spec: elif isinstance(out_spec, str) and out_spec:
if socket_name != out_spec: if socket_name.lower() != out_spec.lower():
return default return default
# Resolve main input # Resolve main input
@ -240,6 +283,7 @@ def eval_multivalue(
is_vector=vec_flags, is_vector=vec_flags,
input_names=secondary_sockets, input_names=secondary_sockets,
output_index=output_index, output_index=output_index,
is_atomic=is_atomic,
) )
except Exception as e: except Exception as e:
from .blender_slice_nodes_generic import attach_node_failure_info from .blender_slice_nodes_generic import attach_node_failure_info
@ -258,6 +302,7 @@ def set_socket_shape(sock: Any, shape: str) -> None:
"""Safely updates socket display_shape property ('LIST', 'CIRCLE', etc.).""" """Safely updates socket display_shape property ('LIST', 'CIRCLE', etc.)."""
if sock is not None: if sock is not None:
try: try:
if getattr(sock, "display_shape", "") != shape:
sock.display_shape = shape sock.display_shape = shape
except (AttributeError, TypeError): except (AttributeError, TypeError):
pass 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): if getattr(from_node, "is_list_output", False):
return True 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": if getattr(from_node, "bl_idname", "") == "NodeGroupInput":
in_item = _get_input_interface_item(tree, from_sock) in_item = _get_input_interface_item(tree, from_sock)
if not in_item: if not in_item:
@ -543,6 +611,29 @@ def is_group_output_a_list(
if getattr(src_node, "is_list_output", False): if getattr(src_node, "is_list_output", False):
return True 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": if src_bl_id == "SLICENODE_JoinGcode":
for inp in getattr(src_node, "inputs", []): for inp in getattr(src_node, "inputs", []):
if getattr(inp, "name", "") == "Gcode" or getattr(inp, "identifier", "").startswith("Gcode_ms_in_"): 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): if getattr(node, "is_list_output", False):
return True 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"): if bl_idname in ("SLICENODE_FloatMath", "SLICENODE_IntMath"):
return any(is_socket_linked_to_list(inp) for inp in getattr(node, "inputs", [])) 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 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": if bl_idname == "SLICENODE_Group":
tree = getattr(node, "slicer_tree", None) or getattr(node, "node_tree", None) tree = getattr(node, "slicer_tree", None) or getattr(node, "node_tree", None)
if tree: if tree:
@ -683,31 +787,111 @@ def is_socket_linked_to_list(sock: Any) -> bool:
from_socket = _follow_reroute_upstream(from_socket) from_socket = _follow_reroute_upstream(from_socket)
return is_output_socket_a_list(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: 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. """Updates display_shape on input and output sockets based on mapping and node rules.
Also updates display_shape for any secondary input sockets linked to a list. Ensures list inputs/outputs consistently display as 'LIST', and single values as 'CIRCLE'.
mapping is a list of tuples: (input_socket_name, output_socket_names)
e.g. [('Paths', ['Paths']), ('Mesh', ['Selection', 'Inverted'])]
""" """
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_inputs = set()
mapped_outputs = set()
for in_name, out_names in mapping: for in_name, out_names in mapping:
mapped_inputs.add(in_name) mapped_inputs.add(in_name)
inp = getattr(node, "inputs", None) and node.inputs.get(in_name) inp = getattr(node, "inputs", None) and node.inputs.get(in_name)
if inp is None: if inp is None:
continue continue
is_list = is_socket_linked_to_list(inp) in_is_list = (in_name in always_in) or is_socket_linked_to_list(inp)
set_socket_shape(inp, "LIST" if is_list else "CIRCLE") set_socket_shape(inp, "LIST" if in_is_list else "CIRCLE")
if isinstance(out_names, str): if isinstance(out_names, str):
targets = [out_names] targets = [out_names]
else: else:
targets = list(out_names) targets = list(out_names)
for out_name in targets: for out_name in targets:
mapped_outputs.add(out_name)
out_sock = getattr(node, "outputs", None) and node.outputs.get(out_name) out_sock = getattr(node, "outputs", None) and node.outputs.get(out_name)
if out_sock is not None: 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", []): for inp in getattr(node, "inputs", []):
if getattr(inp, "name", "") not in mapped_inputs: inp_name = getattr(inp, "name", "")
is_list = is_socket_linked_to_list(inp) if inp_name not in mapped_inputs:
set_socket_shape(inp, "LIST" if is_list else "CIRCLE") 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)

379
tests/test_layers.py Normal file
View file

@ -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()

View file

@ -58,7 +58,6 @@ from NodeSlicer.infra.blender.list_utils import (
) )
from NodeSlicer.core.slice import SlicePaths from NodeSlicer.core.slice import SlicePaths
from NodeSlicer.infra.blender.blender_mesh_capture import SlicingObject, SlicingCollection, SlicingGroup from NodeSlicer.infra.blender.blender_mesh_capture import SlicingObject, SlicingCollection, SlicingGroup
from NodeSlicer.core.nodeslicer.tree import SLICETREE_NodeTree
class TestListUtils(unittest.TestCase): class TestListUtils(unittest.TestCase):
@ -185,56 +184,6 @@ class TestSocketShapesAndLinks(unittest.TestCase):
self.assertEqual(out_path.display_shape, "LIST") 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): class TestNodeEvaluationsWithLists(unittest.TestCase):
def test_path_operations_with_first_input_list(self): def test_path_operations_with_first_input_list(self):
from NodeSlicer.core.nodeslicer.nodes.path import SLICENODE_PathOperations 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_in.display_shape, "LIST")
self.assertEqual(grp_out.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): 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): def test_resolve_input_implicit_get_mesh_single_object(self):
from NodeSlicer.infra.blender.blender_slice_nodes_generic import resolve_input, execution_cache from NodeSlicer.infra.blender.blender_slice_nodes_generic import resolve_input, execution_cache

View file

@ -739,14 +739,29 @@ class TestTransform(unittest.TestCase):
# 9. Blender Object fallback # 9. Blender Object fallback
class MockBpyObject: class MockBpyObject:
location = (11.0, 22.0, 33.0) def __init__(self, loc=(11.0, 22.0, 33.0), rot=(0.4, 0.5, 0.6), sc=(1.1, 2.2, 3.3)):
rotation_euler = (0.4, 0.5, 0.6) self.location = loc
scale = (1.1, 2.2, 3.3) self.rotation_euler = rot
self.scale = sc
node.resolve = lambda s: MockBpyObject() node.resolve = lambda s: MockBpyObject()
self.assertEqual(node.evaluate_output("Translation"), (11.0, 22.0, 33.0)) 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("Rotation"), (0.4, 0.5, 0.6))
self.assertEqual(node.evaluate_output("Scale"), (1.1, 2.2, 3.3)) 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): def test_path_operations_acquire_transforms(self):
"""Tests Acquire Transforms checkbox and behavior on SLICENODE_PathOperations.""" """Tests Acquire Transforms checkbox and behavior on SLICENODE_PathOperations."""
from NodeSlicer.core.nodeslicer.nodes.path import SLICENODE_PathOperations from NodeSlicer.core.nodeslicer.nodes.path import SLICENODE_PathOperations