get_transforms
This commit is contained in:
parent
4392eb393f
commit
657660ab2e
13 changed files with 889 additions and 212 deletions
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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):
|
||||||
try:
|
|
||||||
super_update()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
if _is_updating:
|
|
||||||
return
|
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)
|
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"
|
||||||
|
|
|
||||||
|
|
@ -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)]
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
11
doc/NODES.md
11
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.
|
- **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.
|
||||||
|
|
|
||||||
|
|
@ -117,7 +117,15 @@ 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 = {}
|
||||||
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_properties = PdfProperties()
|
||||||
self.pdf_object_properties = []
|
self.pdf_object_properties = []
|
||||||
|
|
||||||
|
|
@ -250,7 +258,15 @@ 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
|
||||||
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):
|
def _clear_cache(self):
|
||||||
for prop in ["all_verts", "unique_verts"]:
|
for prop in ["all_verts", "unique_verts"]:
|
||||||
|
|
@ -373,7 +389,15 @@ 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:
|
||||||
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 = []
|
family = []
|
||||||
for obj in selected_objs:
|
for obj in selected_objs:
|
||||||
|
|
|
||||||
|
|
@ -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)
|
from .list_utils import sync_node_list_sockets
|
||||||
if mapping:
|
sync_node_list_sockets(self, getattr(self, "list_socket_mapping", None))
|
||||||
from .list_utils import sync_node_list_sockets
|
|
||||||
sync_node_list_sockets(self, mapping)
|
|
||||||
|
|
||||||
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"):
|
||||||
|
|
|
||||||
|
|
@ -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,7 +302,8 @@ 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:
|
||||||
sock.display_shape = shape
|
if getattr(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
379
tests/test_layers.py
Normal 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()
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue