list support

This commit is contained in:
Nicolas Predella 2026-10-12 16:23:02 +03:00
parent 12bc6ba08b
commit 47e66bb030
16 changed files with 2045 additions and 435 deletions

View file

@ -134,6 +134,45 @@ class SliceNodeJoiner(SliceNode):
self.add_output(self.output_socket_type or self.socket_type, name=self.socket_label)
self.add_multi_socket_group(self.socket_type, name=self.socket_label)
def update(self) -> None:
super().update()
from ....infra.blender.list_utils import is_socket_linked_to_list, set_socket_shape
for inp in self.inputs:
if getattr(inp, "name", "") == self.socket_label or getattr(inp, "identifier", "").startswith(f"{self.socket_label}_ms_in_"):
is_list = is_socket_linked_to_list(inp)
set_socket_shape(inp, "LIST" if is_list else "CIRCLE")
class SliceNodeListJoiner(SliceNode):
socket_type: str = ""
output_socket_type: str = ""
socket_label: str = ""
output_socket_name: str = ""
is_list_output: bool = True
def init(self, context):
self.add_output(self.output_socket_type or self.socket_type, name=self.output_socket_name or self.socket_label, display_shape='LIST')
self.add_multi_socket_group(self.socket_type, name=self.socket_label)
def update(self) -> None:
super().update()
from ....infra.blender.list_utils import is_socket_linked_to_list, set_socket_shape
for inp in self.inputs:
if getattr(inp, "name", "") == self.socket_label or getattr(inp, "identifier", "").startswith(f"{self.socket_label}_ms_in_"):
is_list = is_socket_linked_to_list(inp)
set_socket_shape(inp, "LIST" if is_list else "CIRCLE")
def evaluate_output(self, socket_name: str, **kwargs) -> list[Any]:
from ....infra.blender.list_utils import flatten_list, is_list_data
items: list[Any] = []
raw_inputs = self.resolve_multi_input(self.socket_label)
for val in raw_inputs:
if val is not None:
if is_list_data(val):
items.extend(flatten_list(val))
else:
items.append(val)
return items
class ExecSliceNode(SliceNode):
button_label: str = 'Execute'

View file

@ -1,15 +1,24 @@
from __future__ import annotations
from typing import Any
import numpy as np
import bpy
import os
from ..bpy_ns_types import NfBoolProperty, NfEnumProperty, NfStringProperty
from ....registry import register_class
from .base import SliceNode, SliceIntrinsics, Pather, SliceNodeJoiner
from .base import SliceNode, SliceIntrinsics, Pather, SliceNodeJoiner, SliceNodeListJoiner
from ....infra.blender.list_utils import eval_element_wise, flatten_list, is_list_data
@register_class
class SLICENODE_GcodesToList(SliceNodeListJoiner):
bl_label = "Gcodes to List"
socket_type = "NodeSocketString"
socket_label = "Gcode"
@register_class
class SLICENODE_PathsToGcode(SliceNode):
bl_label = "Paths to Gcode"
list_socket_mapping = [("Paths", "Gcode")]
use_header: bool = NfBoolProperty(name="Header", default=True)
@ -22,30 +31,35 @@ class SLICENODE_PathsToGcode(SliceNode):
assert layout
layout.prop(self, "use_header")
def evaluate_output(self, socket_name: str, **_kwargs) -> str:
paths: list[np.ndarray] = self.resolve("Paths")
def evaluate_output(self, socket_name: str, **_kwargs) -> Any:
paths: Any = self.resolve("Paths")
intrinsics: SliceIntrinsics | None = self.resolve("Intrinsics")
pather: Pather = Pather(paths)
if hasattr(paths, "layer_height"):
pather.layer_height = getattr(paths, "layer_height")
if hasattr(paths, "first_layer_height"):
pather.first_layer_height = getattr(paths, "first_layer_height")
if not paths:
return ""
if intrinsics:
# Note: Pather currently uses bed_center_x/y as its internal attributes.
# We map the bed size center to these attributes if provided.
pather.bed_center_x = intrinsics.bed_x / 2.0
pather.bed_center_y = intrinsics.bed_y / 2.0
pather.feedrate = intrinsics.feedrate
pather.travel_feedrate = intrinsics.travel_feedrate
pather.extrusion_rate = intrinsics.extrusion_rate
pather.retraction = intrinsics.retraction
pather.retraction_feedrate = getattr(intrinsics, "retraction_feedrate", 0.0)
pather.lift = intrinsics.lift
def _paths_to_gcode(p: Any) -> str:
if not p:
return ""
pather: Pather = Pather(p)
if hasattr(p, "layer_height"):
pather.layer_height = getattr(p, "layer_height")
if hasattr(p, "first_layer_height"):
pather.first_layer_height = getattr(p, "first_layer_height")
gcode: str = pather.to_gcode(include_header=self.use_header)
return gcode
if intrinsics:
pather.bed_center_x = intrinsics.bed_x / 2.0
pather.bed_center_y = intrinsics.bed_y / 2.0
pather.feedrate = intrinsics.feedrate
pather.travel_feedrate = intrinsics.travel_feedrate
pather.extrusion_rate = intrinsics.extrusion_rate
pather.retraction = intrinsics.retraction
pather.retraction_feedrate = getattr(intrinsics, "retraction_feedrate", 0.0)
pather.lift = intrinsics.lift
return pather.to_gcode(include_header=self.use_header)
return eval_element_wise(_paths_to_gcode, paths)
@register_class
class SLICENODE_GcodeBlock(SliceNode):
@ -83,6 +97,7 @@ class SLICENODE_AssignMetadata(SliceNode):
@register_class
class SLICENODE_GcodeRegexReplace(SliceNode):
bl_label = "Gcode Regex Replace"
list_socket_mapping = [("Gcode", "Gcode")]
def init(self, context):
self.add_input("NodeSocketString", "Gcode")
@ -90,29 +105,37 @@ class SLICENODE_GcodeRegexReplace(SliceNode):
self.add_input("NodeSocketString", "Replace")
self.add_output("NodeSocketString", "Gcode")
def evaluate_output(self, socket_name: str, **_kwargs) -> str:
def evaluate_output(self, socket_name: str, **_kwargs) -> Any:
import re
gcode = self.resolve("Gcode") or ""
gcode = self.resolve("Gcode")
find = self.resolve("Find") or ""
replace = self.resolve("Replace") or ""
if not gcode:
return ""
if not find:
return gcode
try:
# Convert $1, $2, ${name} etc to Python backreferences \g<1>, \g<2>, \g<name>
def subst(match):
if match.group(1): return '$'
elif match.group(2): return '\\g<' + match.group(2) + '>'
elif match.group(3): return '\\g<' + match.group(3) + '>'
return match.group(0)
def _replace_one(gc: Any) -> str:
gc_str = str(gc or "")
if not find or not gc_str:
return gc_str
try:
# Convert $1, $2, ${name} etc to Python backreferences \g<1>, \g<2>, \g<name>
def subst(match):
if match.group(1): return '$'
elif match.group(2): return '\\g<' + match.group(2) + '>'
elif match.group(3): return '\\g<' + match.group(3) + '>'
return match.group(0)
replace = re.sub(r'(\$\$)|(?:\$\{([^}]+)\})|(?:\$([a-zA-Z0-9_]+))', subst, replace)
rep = re.sub(r'(\$\$)|(?:\$\{([^}]+)\})|(?:\$([a-zA-Z0-9_]+))', subst, replace)
return re.sub(find, rep, gc_str)
except Exception as e:
print(f"Regex error in node {self.name}: {e}")
return gc_str
return re.sub(find, replace, gcode)
except Exception as e:
print(f"Regex error in node {self.name}: {e}")
return gcode
return eval_element_wise(_replace_one, gcode)
@register_class
class SLICENODE_GcodeHelper(SliceNode):
@ -180,16 +203,52 @@ class SLICENODE_JoinGcode(SliceNodeJoiner):
output_socket_type = "NodeSocketString"
socket_label = "Gcode"
def evaluate_output(self, socket_name: str, **_kwargs) -> str:
from ....infra.blender.blender_slice_nodes_generic import resolve_input
gcode_input: list[str] = []
for input in self.inputs:
val = resolve_input(input)
if val is not None:
gc = str(val)
if gc:
gcode_input.append(gc)
return "\n".join(gcode_input)
def update(self) -> None:
super().update()
from ....infra.blender.list_utils import is_socket_linked_to_list, set_socket_shape
has_list = any(
is_socket_linked_to_list(inp)
for inp in self.inputs
if getattr(inp, "name", "") == self.socket_label or getattr(inp, "identifier", "").startswith(f"{self.socket_label}_ms_in_")
)
out_sock = self.outputs.get(self.socket_label)
if out_sock:
set_socket_shape(out_sock, "LIST" if has_list else "CIRCLE")
def evaluate_output(self, socket_name: str, **_kwargs) -> Any:
raw_inputs = self.resolve_multi_input(self.socket_label)
has_list = any(is_list_data(inp) for inp in raw_inputs)
if not has_list:
flattened = flatten_list(raw_inputs)
gcode_input: list[str] = [str(gc) for gc in flattened if gc]
return "\n".join(gcode_input)
# There is at least one list input
list_lengths = [len(inp) for inp in raw_inputs if is_list_data(inp)]
if len(set(list_lengths)) > 1:
raise ValueError(
f"Join Gcode: all input lists must have the same length, got lengths {list_lengths}"
)
n_elements = list_lengths[0] if list_lengths else 0
result: list[str] = []
for i in range(n_elements):
row_parts: list[str] = []
for inp in raw_inputs:
if is_list_data(inp):
val = inp[i]
else:
val = inp
if is_list_data(val):
val_str = "\n".join(str(s) for s in flatten_list(val) if s)
if val_str:
row_parts.append(val_str)
elif val:
row_parts.append(str(val))
result.append("\n".join(row_parts))
return result
@register_class
class SLICENODE_LoadGcode(SliceNode):

View file

@ -6,7 +6,7 @@ import numpy as np
from ..bpy_ns_types import NfStringProperty, notify_tree_changed, NfEnumProperty, NfPointerProperty
from ....registry import register_class
from .base import InputNode, SliceNode, SliceIntrinsics
from .base import InputNode, SliceNode, SliceIntrinsics, SliceNodeListJoiner
@register_class
class SLICENODE_InputString(InputNode):
@ -39,6 +39,13 @@ class SLICENODE_InputObject(InputNode):
socket_type = "NodeSocketObject"
bl_icon = "OBJECT_DATA"
@register_class
class SLICENODE_ObjectsToList(SliceNodeListJoiner):
bl_label = "Objects to List"
socket_type = "NodeSocketObject"
socket_label = "Object"
output_socket_name = "Objects"
@register_class
class SLICENODE_CollectionObjects(SliceNode):
bl_label = "Collection Objects"

View file

@ -4,14 +4,16 @@ import numpy as np
from ..bpy_ns_types import NfBoolProperty, NfEnumProperty, NfIntProperty, NfFloatProperty, NfStringProperty, NfFloatVectorProperty, notify_tree_changed
from ....registry import register_class
from .base import SliceNode, SlicingCollection, get_all_children, SliceNodeJoiner, SlicingGroup, SlicingObject, SlicePaths
from .base import SliceNode, SlicingCollection, get_all_children, SliceNodeJoiner, SliceNodeListJoiner, SlicingGroup, SlicingObject, SlicePaths
from ....infra.blender.blender_mesh_capture import PdfProperties
from ....core.pdf_exporter.geometry import transform_slice_paths
from ....core.geometry import update_transform
from ....infra.blender.list_utils import eval_element_wise, flatten_list, is_list_data
@register_class
class SLICENODE_GetMesh(SliceNode):
bl_label = "Get Mesh"
list_socket_mapping = [("Object", "Mesh")]
include_children: bool = NfBoolProperty(name="Include Children", default=True)
@ -23,17 +25,25 @@ class SLICENODE_GetMesh(SliceNode):
assert layout
layout.prop(self, "include_children")
def evaluate_output(self, socket_name: str, **_kwargs) -> SlicingCollection | None:
def evaluate_output(self, socket_name: str, **_kwargs) -> Any:
if socket_name != "Mesh": return None
obj: Any = self.resolve("Object")
if not obj: return None
family = [obj] + (get_all_children(obj) if self.include_children else [])
def _to_collection(o: Any) -> SlicingCollection | None:
if not o: return None
family = [o] + (get_all_children(o) if self.include_children else [])
return SlicingCollection(family, getattr(o, "name", "Mesh"))
sc: SlicingCollection = SlicingCollection(family, obj.name)
return sc
return eval_element_wise(_to_collection, obj)
@register_class
class SLICENODE_MeshesToList(SliceNodeListJoiner):
bl_label = "Meshes to List"
socket_type = "SLICESOCK_Mesh"
socket_label = "Mesh"
output_socket_name = "Meshes"
@register_class
class SLICENODE_JoinMesh(SliceNodeJoiner):
@ -42,19 +52,23 @@ class SLICENODE_JoinMesh(SliceNodeJoiner):
def evaluate_output(self, socket_name: str, **_kwargs) -> SlicingCollection:
from ....infra.blender.blender_slice_nodes_generic import resolve_input
first_coll = resolve_input(self.inputs[0]) if self.inputs else None
all_colls = []
for inp in self.inputs:
if getattr(inp, "is_linked", False) or getattr(inp, "value", None):
val = resolve_input(inp)
if val:
all_colls.extend(flatten_list(val))
first_coll = all_colls[0] if all_colls else None
merged_coll = SlicingCollection([], first_coll.name if first_coll else '')
if first_coll:
merged_coll.objects = list(first_coll.objects)
merged_coll.transforms = [dict(t) for t in getattr(first_coll, "transforms", [])]
for input in self.inputs[1:]:
coll = resolve_input(input)
if coll:
for coll in all_colls:
if hasattr(coll, "objects"):
merged_coll.objects += list(coll.objects)
for t in getattr(coll, "transforms", []):
if t not in merged_coll.transforms:
merged_coll.transforms.append(dict(t))
elif isinstance(coll, SlicingObject):
merged_coll.objects.append(coll)
for t in getattr(coll, "transforms", []):
if t not in merged_coll.transforms:
merged_coll.transforms.append(dict(t))
return merged_coll
@ -70,22 +84,28 @@ class SLICENODE_JoinMeshGroups(SliceNodeJoiner):
def evaluate_output(self, socket_name: str, **_kwargs) -> SlicingGroup:
from ....infra.blender.blender_slice_nodes_generic import resolve_input
group: SlicingGroup = SlicingGroup([])
for idx, input_socket in enumerate(self.inputs):
coll = resolve_input(input_socket)
if coll:
col_name = getattr(coll, "name", "")
if not col_name and coll.objects:
col_name = coll.objects[0].name
if not col_name:
col_name = f"collection_{idx}"
group.collections[col_name] = coll
all_colls = []
for input_socket in self.inputs:
if getattr(input_socket, "is_linked", False) or getattr(input_socket, "value", None):
val = resolve_input(input_socket)
if val:
all_colls.extend(flatten_list(val))
for idx, coll in enumerate(all_colls):
col_name = getattr(coll, "name", "")
if not col_name and getattr(coll, "objects", None):
col_name = coll.objects[0].name
if not col_name:
col_name = f"collection_{idx}"
group.collections[col_name] = coll
return group
@register_class
class SLICENODE_SeparateMesh(SliceNode):
bl_label = "Separate Mesh"
list_socket_mapping = [("Mesh", ["Selection", "Inverted"])]
comparison: str = NfEnumProperty(
name="Comparison",
@ -112,69 +132,74 @@ class SLICENODE_SeparateMesh(SliceNode):
assert layout
layout.prop(self, "comparison", text="")
def evaluate_output(self, socket_name: str, **_kwargs) -> SlicingCollection | None:
coll: SlicingCollection | None = self.resolve("Mesh")
def evaluate_output(self, socket_name: str, **_kwargs) -> Any:
coll: Any = self.resolve("Mesh")
attr_name: str = self.resolve("Attribute")
value: float = self.resolve("Value")
if not coll: return None
res_selection = SlicingCollection([], coll.name + "_selection")
res_inverted = SlicingCollection([], coll.name + "_inverted")
def _split_coll(sc: SlicingCollection) -> tuple[SlicingCollection, SlicingCollection]:
res_selection = SlicingCollection([], sc.name + "_selection")
res_inverted = SlicingCollection([], sc.name + "_inverted")
for so in coll.objects:
if attr_name not in so.attributes:
# If attribute missing, entire object goes to inverted
res_inverted.objects.append(so)
continue
attr = so.attributes[attr_name]
vals = attr["values"]
mask = np.zeros(len(so.mesh), dtype=bool)
if self.comparison == 'EQUAL': mask = vals == value
elif self.comparison == 'NOT_EQUAL': mask = vals != value
elif self.comparison == 'GREATER_THAN': mask = vals > value
elif self.comparison == 'LESS_THAN': mask = vals < value
elif self.comparison == 'GREATER_EQUAL': mask = vals >= value
elif self.comparison == 'LESS_EQUAL': mask = vals <= value
if np.all(mask):
res_selection.objects.append(so)
elif not np.any(mask):
res_inverted.objects.append(so)
else:
# Split the object
so_sel = SlicingObject()
so_sel.name = so.name + "_sel"
so_sel.parent = so.parent
so_sel.mesh = so.mesh[mask]
so_sel.transforms = [dict(t) for t in getattr(so, "transforms", [])]
so_sel.pdf_properties = copy.deepcopy(so.pdf_properties) if hasattr(so, "pdf_properties") and so.pdf_properties else PdfProperties()
so_sel.pdf_object_properties = list(getattr(so, "pdf_object_properties", []))
for k, v in so.attributes.items():
so_sel.attributes[k] = {
"domain": v["domain"],
"type": v["type"],
"values": v["values"][mask]
}
res_selection.objects.append(so_sel)
for so in sc.objects:
if attr_name not in so.attributes:
res_inverted.objects.append(so)
continue
attr = so.attributes[attr_name]
vals = attr["values"]
mask = np.zeros(len(so.mesh), dtype=bool)
if self.comparison == 'EQUAL': mask = vals == value
elif self.comparison == 'NOT_EQUAL': mask = vals != value
elif self.comparison == 'GREATER_THAN': mask = vals > value
elif self.comparison == 'LESS_THAN': mask = vals < value
elif self.comparison == 'GREATER_EQUAL': mask = vals >= value
elif self.comparison == 'LESS_EQUAL': mask = vals <= value
if np.all(mask):
res_selection.objects.append(so)
elif not np.any(mask):
res_inverted.objects.append(so)
else:
so_sel = SlicingObject()
so_sel.name = so.name + "_sel"
so_sel.parent = so.parent
so_sel.mesh = so.mesh[mask]
so_sel.transforms = [dict(t) for t in getattr(so, "transforms", [])]
so_sel.pdf_properties = copy.deepcopy(so.pdf_properties) if hasattr(so, "pdf_properties") and so.pdf_properties else PdfProperties()
so_sel.pdf_object_properties = list(getattr(so, "pdf_object_properties", []))
for k, v in so.attributes.items():
so_sel.attributes[k] = {
"domain": v["domain"],
"type": v["type"],
"values": v["values"][mask]
}
res_selection.objects.append(so_sel)
so_inv = SlicingObject()
so_inv.name = so.name + "_inv"
so_inv.parent = so.parent
so_inv.mesh = so.mesh[~mask]
so_inv.transforms = [dict(t) for t in getattr(so, "transforms", [])]
so_inv.pdf_properties = copy.deepcopy(so.pdf_properties) if hasattr(so, "pdf_properties") and so.pdf_properties else PdfProperties()
so_inv.pdf_object_properties = list(getattr(so, "pdf_object_properties", []))
for k, v in so.attributes.items():
so_inv.attributes[k] = {
"domain": v["domain"],
"type": v["type"],
"values": v["values"][~mask]
}
res_inverted.objects.append(so_inv)
so_inv = SlicingObject()
so_inv.name = so.name + "_inv"
so_inv.parent = so.parent
so_inv.mesh = so.mesh[~mask]
so_inv.transforms = [dict(t) for t in getattr(so, "transforms", [])]
so_inv.pdf_properties = copy.deepcopy(so.pdf_properties) if hasattr(so, "pdf_properties") and so.pdf_properties else PdfProperties()
so_inv.pdf_object_properties = list(getattr(so, "pdf_object_properties", []))
for k, v in so.attributes.items():
so_inv.attributes[k] = {
"domain": v["domain"],
"type": v["type"],
"values": v["values"][~mask]
}
res_inverted.objects.append(so_inv)
return res_selection, res_inverted
if is_list_data(coll):
results = [_split_coll(c) for c in coll]
return [r[0] for r in results] if socket_name == "Selection" else [r[1] for r in results]
res_selection, res_inverted = _split_coll(coll)
if socket_name == "Selection":
return res_selection
return res_inverted
@ -202,8 +227,13 @@ class SLICENODE_SeparateMeshByRange(SliceNode):
end = max(start, end_val)
expected_ids = [str(i) for i in range(start, end + 1)]
from ....infra.blender.list_utils import is_socket_linked_to_list, set_socket_shape
inp = self.inputs.get("Mesh") if hasattr(self, "inputs") else None
is_list = is_socket_linked_to_list(inp) if inp is not None else False
if inp is not None:
set_socket_shape(inp, "LIST" if is_list else "CIRCLE")
# Remove outputs that are no longer in the range
# We use a list to avoid modification-during-iteration issues
to_remove = [o for o in self.outputs if o.identifier not in expected_ids]
for o in to_remove:
self.outputs.remove(o)
@ -211,7 +241,9 @@ class SLICENODE_SeparateMeshByRange(SliceNode):
# Add new outputs
for id_str in expected_ids:
if id_str not in self.outputs:
self.add_output("SLICESOCK_Mesh", id_str, identifier=id_str)
self.add_output("SLICESOCK_Mesh", id_str, identifier=id_str, display_shape="LIST" if is_list else "")
else:
set_socket_shape(self.outputs[id_str], "LIST" if is_list else "CIRCLE")
# Re-order to match range
for i, id_str in enumerate(expected_ids):
@ -225,8 +257,8 @@ class SLICENODE_SeparateMeshByRange(SliceNode):
col.prop(self, "start_val")
col.prop(self, "end_val")
def evaluate_output(self, socket_name: str, **_kwargs) -> SlicingCollection | None:
coll: SlicingCollection | None = self.resolve("Mesh")
def evaluate_output(self, socket_name: str, **_kwargs) -> Any:
coll: Any = self.resolve("Mesh")
attr_name: str = self.resolve("Attribute")
if not coll: return None
@ -237,47 +269,50 @@ class SLICENODE_SeparateMeshByRange(SliceNode):
except ValueError:
return None
res = SlicingCollection([], f"{coll.name}_{target_val}")
start = self.start_val
end = max(start, self.end_val)
start = int(self.start_val) if isinstance(getattr(self, "start_val", None), (int, float)) else 0
end_val = int(self.end_val) if isinstance(getattr(self, "end_val", None), (int, float)) else 1
end = max(start, end_val)
total_parts = end - start + 1
num_digits = max(2, len(str(total_parts)))
i = target_val - start
for so in coll.objects:
if attr_name not in so.attributes:
continue
attr = so.attributes[attr_name]
vals = attr["values"]
# Attribute values might be floats or ints, we check for equality
mask = vals == target_val
if np.any(mask):
# Split the object
so_new = SlicingObject()
base_mesh_name = so.name
so_new.name = f"{base_mesh_name}{i:0{num_digits}d}"
so_new.parent = so.parent
so_new.mesh = so.mesh[mask]
so_new.transforms = [dict(t) for t in getattr(so, "transforms", [])]
so_new.pdf_properties = copy.deepcopy(so.pdf_properties) if hasattr(so, "pdf_properties") and so.pdf_properties else PdfProperties()
so_new.pdf_object_properties = list(getattr(so, "pdf_object_properties", []))
for k, v in so.attributes.items():
so_new.attributes[k] = {
"domain": v["domain"],
"type": v["type"],
"values": v["values"][mask]
}
res.objects.append(so_new)
def _split_coll(sc: SlicingCollection) -> SlicingCollection:
res = SlicingCollection([], f"{sc.name}_{target_val}")
for so in sc.objects:
if attr_name not in so.attributes:
continue
attr = so.attributes[attr_name]
vals = attr["values"]
# Attribute values might be floats or ints, we check for equality
mask = vals == target_val
if np.any(mask):
# Split the object
so_new = SlicingObject()
base_mesh_name = so.name
so_new.name = f"{base_mesh_name}{i:0{num_digits}d}"
so_new.parent = so.parent
so_new.mesh = so.mesh[mask]
so_new.transforms = [dict(t) for t in getattr(so, "transforms", [])]
so_new.pdf_properties = copy.deepcopy(so.pdf_properties) if hasattr(so, "pdf_properties") and so.pdf_properties else PdfProperties()
so_new.pdf_object_properties = list(getattr(so, "pdf_object_properties", []))
for k, v in so.attributes.items():
so_new.attributes[k] = {
"domain": v["domain"],
"type": v["type"],
"values": v["values"][mask]
}
res.objects.append(so_new)
return res
return res
return eval_element_wise(_split_coll, coll)
@register_class
class SLICENODE_ArrangeMesh(SliceNode):
bl_label = "Arrange Slicing Group"
list_socket_mapping = [("Slicing Group", "Slicing Group")]
method: str = NfEnumProperty(
name="Method",
@ -297,11 +332,11 @@ class SLICENODE_ArrangeMesh(SliceNode):
)
def init(self, context):
self.add_input("SLICESOCK_Mesh", "Slicing Group", display_shape='LIST')
self.add_input("SLICESOCK_Mesh", "Slicing Group")
self.add_input("NodeSocketFloat", "Bed X").value = 250.0
self.add_input("NodeSocketFloat", "Bed Y").value = 250.0
self.add_input("NodeSocketFloat", "Spacing").value = 2.0
self.add_output("SLICESOCK_Mesh", "Slicing Group", display_shape='LIST')
self.add_output("SLICESOCK_Mesh", "Slicing Group")
def draw_buttons(self, context, layout):
assert layout
@ -320,9 +355,22 @@ class SLICENODE_ArrangeMesh(SliceNode):
bed_y: float = self.resolve("Bed Y") or 250.0
spacing: float = self.resolve("Spacing") or 2.0
# Convert input to SlicingGroup if it's a SlicingCollection
# Convert input to SlicingGroup if it's a SlicingCollection or list
is_list = is_list_data(mesh_data)
if isinstance(mesh_data, SlicingGroup):
slicing_group = mesh_data
elif is_list:
slicing_group = SlicingGroup([])
for idx, item in enumerate(mesh_data):
if isinstance(item, SlicingGroup):
for k, sc in item.collections.items():
slicing_group.collections[f"{k}_{idx}"] = sc
elif isinstance(item, SlicingCollection):
col_name = item.name or f"mesh_{idx}"
slicing_group.collections[col_name] = item
elif isinstance(item, SlicingObject):
sc = SlicingCollection([item], item.name or f"mesh_{idx}")
slicing_group.collections[sc.name] = sc
else:
slicing_group = SlicingGroup([])
slicing_group.collections = {mesh_data.name: mesh_data}
@ -419,8 +467,8 @@ class SLICENODE_ArrangeMesh(SliceNode):
so.mesh[:, 3, 1] = ny_new
else:
name, sc = collection_map[id(item)]
print(f"Warning: Collection '{name}' did not fit on the build plate (binId: {bin_id})")
if is_list:
return list(copied_group.collections.values())
return copied_group
@ -515,6 +563,7 @@ def _transform_geometry_item(geom: Any, t_arr: np.ndarray, r_arr: np.ndarray, s_
@register_class
class SLICENODE_TransformGeometry(SliceNode):
bl_label = "Transform Geometry"
list_socket_mapping = [("Geometry", "Geometry")]
def init(self, context):
self.add_input("SLICESOCK_Geometry", "Geometry")
@ -545,6 +594,7 @@ class SLICENODE_TransformGeometry(SliceNode):
@register_class
class SLICENODE_DropGeometry(SliceNode):
bl_label = "Drop Geometry"
list_socket_mapping = [("Mesh", ["Mesh", "Translation"])]
def init(self, context):
self.add_input("SLICESOCK_Mesh", "Mesh")
@ -552,23 +602,8 @@ class SLICENODE_DropGeometry(SliceNode):
self.add_output("SLICESOCK_Mesh", "Mesh")
self.add_output("NodeSocketVector", "Translation")
def evaluate_output(self, socket_name: str, **_kwargs) -> SlicingCollection | SlicingGroup | tuple[float, float, float] | None:
if socket_name == "Translation":
mesh_data: SlicingCollection | SlicingGroup | None = self.resolve("Mesh")
if not mesh_data:
return None
z = self.resolve('Z')
if z is None:
z = 0.0
assert mesh_data.min_z is not None
min_z = round(mesh_data.min_z, 6)
offset_z = float(z - min_z)
return (0.0, 0.0, offset_z)
if socket_name != "Mesh":
return None
mesh_data = self.resolve("Mesh")
def evaluate_output(self, socket_name: str, **_kwargs) -> Any:
mesh_data: Any = self.resolve("Mesh")
if not mesh_data:
return None
@ -576,29 +611,38 @@ class SLICENODE_DropGeometry(SliceNode):
if z is None:
z = 0.0
assert mesh_data.min_z is not None
min_z = round(mesh_data.min_z, 6)
offset_z = float(z - min_z)
if isinstance(mesh_data, SlicingGroup):
copied_data = _copy_slicing_group(mesh_data)
else:
copied_data = _copy_slicing_collection(mesh_data)
drop_vec = np.array([0.0, 0.0, offset_z], dtype=np.float64)
copied_data.offset(drop_vec)
copied_data.transforms = update_transform(getattr(copied_data, "transforms", []), t_arr=drop_vec)
if isinstance(copied_data, SlicingGroup):
for col in copied_data.collections.values():
col.transforms = update_transform(getattr(col, "transforms", []), t_arr=drop_vec)
for so in col.objects:
def _drop_item(item: Any) -> tuple[Any, tuple[float, float, float]]:
if not item or getattr(item, "min_z", None) is None:
return item, (0.0, 0.0, 0.0)
min_z = round(item.min_z, 6)
offset_z = float(z - min_z)
if isinstance(item, SlicingGroup):
copied_data = _copy_slicing_group(item)
else:
copied_data = _copy_slicing_collection(item)
drop_vec = np.array([0.0, 0.0, offset_z], dtype=np.float64)
copied_data.offset(drop_vec)
copied_data.transforms = update_transform(getattr(copied_data, "transforms", []), t_arr=drop_vec)
if isinstance(copied_data, SlicingGroup):
for col in copied_data.collections.values():
col.transforms = update_transform(getattr(col, "transforms", []), t_arr=drop_vec)
for so in col.objects:
so.transforms = update_transform(getattr(so, "transforms", []), t_arr=drop_vec)
elif isinstance(copied_data, SlicingCollection):
for so in copied_data.objects:
so.transforms = update_transform(getattr(so, "transforms", []), t_arr=drop_vec)
elif isinstance(copied_data, SlicingCollection):
for so in copied_data.objects:
so.transforms = update_transform(getattr(so, "transforms", []), t_arr=drop_vec)
return copied_data, (0.0, 0.0, offset_z)
return copied_data
if is_list_data(mesh_data):
results = [_drop_item(m) for m in mesh_data]
if socket_name == "Translation":
return [r[1] for r in results]
return [r[0] for r in results]
dropped, trans = _drop_item(mesh_data)
if socket_name == "Translation":
return trans
return dropped
def format_name_template(template: str, so: Any, default_old_name: str = "") -> str:
@ -664,6 +708,7 @@ def _rename_slicing_item(geom: Any, new_name: str) -> Any:
@register_class
class SLICENODE_RenameMesh(SliceNode):
bl_label = "Rename Mesh"
list_socket_mapping = [("Geometry", "Geometry")]
def init(self, context):
self.add_input("SLICESOCK_Mesh", "Geometry")
@ -716,6 +761,7 @@ def _assign_pdf_properties_to_item(geom: Any, line_width: float, line_color: tup
@register_class
class SLICENODE_AssignPdfProperties(SliceNode):
bl_label = "Assign PDF Properties"
list_socket_mapping = [("Geometry", "Geometry")]
line_width: float = NfFloatProperty(name="Line Width (mm)", default=0.0, min=0.0, max=50.0)
line_color: tuple[float, float, float] = NfFloatVectorProperty(
@ -777,7 +823,7 @@ def _assign_properties_to_item(geom: Any, props: list[tuple[str, str]]) -> Any:
return copied
elif isinstance(geom, SlicingObject):
copied = _copy_slicing_object(geom)
copied.pdf_object_properties = resolve_object_properties(copied, props)
copied.pdf_object_properties = resolve_object_properties(geom, props)
return copied
return geom
@ -785,6 +831,7 @@ def _assign_properties_to_item(geom: Any, props: list[tuple[str, str]]) -> Any:
@register_class
class SLICENODE_AssignPropertyToObject(SliceNode):
bl_label = "Assign PDF Object Properties"
list_socket_mapping = [("Geometry", "Geometry")]
def init(self, context):
self.add_input("SLICESOCK_Mesh", "Geometry")

View file

@ -23,13 +23,22 @@ class SLICENODE_GcodeToFile(ExecSliceNode):
self.add_input("NodeSocketString", "Name")
self.add_multi_socket_group("NodeSocketString", name="Gcode")
def update(self) -> None:
super().update()
from ....infra.blender.list_utils import is_socket_linked_to_list, set_socket_shape
for inp in self.inputs:
if getattr(inp, "name", "") == "Gcode" or getattr(inp, "identifier", "").startswith("Gcode_ms_in_"):
set_socket_shape(inp, "LIST" if is_socket_linked_to_list(inp) else "CIRCLE")
def draw_buttons(self, context, layout):
super().draw_buttons(context, layout)
def evaluate(self, operator=None):
from ....infra.networking.gcode_utils import get_gcode_name_from_metadata, prepare_gcode_file
gcodes: list[str] = [gc for gc in self.resolve_multi_input('Gcode') if gc]
from ....infra.blender.list_utils import flatten_list
raw_gcodes = self.resolve_multi_input('Gcode')
gcodes: list[str] = [str(gc) for gc in flatten_list(raw_gcodes) if gc]
folder: str = self.resolve("Folder")
if not gcodes:
@ -86,21 +95,38 @@ class SLICENODE_GcodeToTextBlock(ExecSliceNode):
self._init_exec()
self.add_input("NodeSocketString", "Gcode")
def update(self) -> None:
super().update()
from ....infra.blender.list_utils import is_socket_linked_to_list, set_socket_shape
inp = self.inputs.get("Gcode")
if inp:
set_socket_shape(inp, "LIST" if is_socket_linked_to_list(inp) else "CIRCLE")
def draw_buttons(self, context, layout):
super().draw_buttons(context, layout)
layout.prop(self, "text_block_name")
def evaluate(self, operator=None):
gcode: str = self.resolve("Gcode")
if not gcode:
from ....infra.blender.list_utils import flatten_list, is_list_data
raw = self.resolve("Gcode")
if not raw:
return 'ok'
name = self.text_block_name
text_block = bpy.data.texts.get(name)
if text_block is None:
text_block = bpy.data.texts.new(name=name)
gcodes = [str(g) for g in flatten_list(raw) if g] if is_list_data(raw) else ([str(raw)] if raw else [])
if not gcodes:
return 'ok'
text_block.from_string(gcode)
base_name = self.text_block_name
name_root, ext = os.path.splitext(base_name)
total_parts = len(gcodes)
num_digits = max(2, len(str(total_parts)))
for i, gcode in enumerate(gcodes):
name = f"{name_root}_{i:0{num_digits}d}{ext}" if total_parts > 1 else base_name
text_block = bpy.data.texts.get(name)
if text_block is None:
text_block = bpy.data.texts.new(name=name)
text_block.from_string(gcode)
# Attempt to show in an open text editor
assert bpy.context.window_manager
@ -124,14 +150,21 @@ class SLICENODE_StartPrint(ExecSliceNode):
self.add_input("SLICESOCK_Printer")
def evaluate(self, operator=None):
from ....infra.blender.list_utils import is_list_data
raw_gcode = self.resolve("Gcode")
if is_list_data(raw_gcode):
if operator: operator.report({'ERROR'}, "Start Print does not accept a list of Gcode")
return 'error'
from ....infra.networking.printer_backends import (
PrinterConfig,
PrusaLinkBackend,
CrealityBackend,
MoonrakerBackend
)
gcode: str = self.resolve("Gcode")
gcode: str = str(raw_gcode or "")
config: PrinterConfig | None = self.resolve("Printer")
if not gcode:
@ -192,6 +225,13 @@ class ExecSliceNode_DatabaseSubmit(ExecSliceNode):
self.add_input("NodeSocketString", "Gcode")
self.add_input("SLICESOCK_Settings", "Metadata")
def update(self) -> None:
super().update()
from ....infra.blender.list_utils import is_socket_linked_to_list, set_socket_shape
inp = self.inputs.get("Gcode")
if inp:
set_socket_shape(inp, "LIST" if is_socket_linked_to_list(inp) else "CIRCLE")
def draw_buttons(self, context, layout):
super().draw_buttons(context, layout)
layout.prop(self, "record_id")
@ -199,8 +239,9 @@ class ExecSliceNode_DatabaseSubmit(ExecSliceNode):
def evaluate(self, operator=None):
from ....infra.networking.bgcode import convert_to_bgcode
from ....infra.networking.gcode_utils import get_gcode_name_from_metadata, parse_gcode_metadata
gcodes: list[str] = self.resolve_multi_input("Gcode")
from ....infra.blender.list_utils import flatten_list
gcodes: list[str] = [str(gc) for gc in flatten_list(self.resolve_multi_input("Gcode")) if gc]
for gc in gcodes:
if not gc:
@ -413,6 +454,14 @@ class SLICENODE_MeshToPdf(ExecSliceNode):
self.add_input("SLICESOCK_Mesh", "Geometry")
self.add_input("SLICESOCK_Paths", "Paths")
def update(self) -> None:
super().update()
from ....infra.blender.list_utils import is_socket_linked_to_list, set_socket_shape
for name in ("Geometry", "Paths"):
inp = self.inputs.get(name)
if inp:
set_socket_shape(inp, "LIST" if is_socket_linked_to_list(inp) else "CIRCLE")
def draw_buttons(self, context, layout):
super().draw_buttons(context, layout)

View file

@ -4,12 +4,20 @@ import numpy as np
from ..bpy_ns_types import NfEnumProperty, NfBoolProperty, NfFloatProperty, notify_tree_changed
from ....registry import register_class
from .base import SliceNode, SlicePaths, SlicingObject, SlicingCollection, slice_at_range, SliceNodeJoiner, SlicingGroup
from .base import SliceNode, SlicePaths, SlicingObject, SlicingCollection, slice_at_range, SliceNodeJoiner, SliceNodeListJoiner, SlicingGroup
from ...slice import align_path_seam_sharpest, is_closed_path
from ....infra.blender.list_utils import eval_element_wise, is_list_data, set_socket_shape, is_socket_linked_to_list
@register_class
class SLICENODE_PathsToList(SliceNodeListJoiner):
bl_label = "Paths to List"
socket_type = "SLICESOCK_Paths"
socket_label = "Paths"
@register_class
class SLICENODE_GetPaths(SliceNode):
bl_label = "Get Paths"
list_socket_mapping = [("Object", "Paths")]
def init(self, context):
self.add_input("NodeSocketObject")
@ -21,27 +29,143 @@ class SLICENODE_GetPaths(SliceNode):
def draw_buttons(self, context, layout):
pass
def evaluate_output(self, socket_name: str, **_kwargs) -> SlicePaths:
def evaluate_output(self, socket_name: str, **_kwargs) -> Any:
obj: Any = self.resolve("Object")
e_attr_name: str = self.resolve("Extrusion Multiplier Attribute")
f_attr_name: str = self.resolve("Feedrate Attribute")
extra_attrs: dict[str, str] = self.resolve("Extra Attributes")
from ....infra.blender.blender_mesh_capture import capture_mesh_edges
so, e_rates, f_rates, extra_attrs_data = capture_mesh_edges(obj, e_attr_name, f_attr_name, extra_attrs)
sp = SlicePaths(so, extrusion_rate=e_rates, feedrate=f_rates, extra_attributes=extra_attrs_data)
if hasattr(obj, "transforms"):
sp.transforms = list(getattr(obj, "transforms", []))
if hasattr(obj, "name") and getattr(obj, "name"):
name_val = str(getattr(obj, "name"))
sp.name = name_val
sp.objects_info = {"objects": [{"name": name_val, "polygon": []}]}
return sp
def _get_single_path(o: Any) -> SlicePaths:
if not o:
return SlicePaths()
so, e_rates, f_rates, extra_attrs_data = capture_mesh_edges(o, e_attr_name, f_attr_name, extra_attrs)
sp = SlicePaths(so, extrusion_rate=e_rates, feedrate=f_rates, extra_attributes=extra_attrs_data)
if hasattr(o, "transforms"):
sp.transforms = list(getattr(o, "transforms", []))
if hasattr(o, "name") and getattr(o, "name"):
name_val = str(getattr(o, "name"))
sp.name = name_val
sp.objects_info = {"objects": [{"name": name_val, "polygon": []}]}
return sp
return eval_element_wise(_get_single_path, obj)
def _slice_single_mesh_item(coll: Any, layer_height: float, fl_height: float | None, rotation: Any, ignore_holes: bool) -> SlicePaths:
if not coll:
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())
h_first = fl_height if fl_height is not None else layer_height
min_z_um = int(round(min_z * 1000))
lh_um = int(round(layer_height * 1000))
flh_um = int(round(h_first * 1000))
max_z_um = int(round(max_z * 1000))
z_first_um = min_z_um + flh_um
z_rest_start_um = z_first_um + lh_um
if z_first_um <= max_z_um:
if z_rest_start_um <= max_z_um and lh_um > 0:
z_um_values = np.arange(z_rest_start_um, max_z_um + 1, lh_um, dtype=np.int64)
z_values = np.concatenate(([z_first_um], z_um_values)) / 1000.0
else:
z_values = np.array([z_first_um], dtype=np.float64) / 1000.0
else:
z_values = np.array([], dtype=np.float64)
slices_group: list[list[np.ndarray]] | None = slice_at_range(
[merged_object.mesh],
z_values,
ignore_holes=ignore_holes
)
if not slices_group or not slices_group[0]:
return SlicePaths()
paths = slices_group[0]
z_to_group: dict[int, int] = {}
groups = []
for p in paths:
if len(p) > 0:
z_k = int(round(p[0, 2] * 1000))
if z_k not in z_to_group:
z_to_group[z_k] = len(z_to_group)
groups.append(z_to_group[z_k])
else:
groups.append(0)
slices = SlicePaths(paths, groups=groups)
slices.layer_height = layer_height
if fl_height is not None:
slices.first_layer_height = fl_height
slices.transforms = list(getattr(coll, "transforms", [])) or list(getattr(merged_object, "transforms", []))
names = []
if objects:
for o in objects:
n = getattr(o, "name", "")
if n:
if n.endswith("_merged"):
n = n[:-7]
if n and n not in names:
names.append(n)
if not names and hasattr(coll, "name") and getattr(coll, "name"):
names.append(str(getattr(coll, "name")))
if names:
slices.name = names[0]
slices.objects_info = {"objects": [{"name": n, "polygon": []} for n in names]}
if len(z_values) > 0 and fl_height is not None and layer_height > 0:
mult = fl_height / layer_height
z_first_um = int(round(z_values[0] * 1000))
for i, p in enumerate(slices):
if len(p) > 0 and int(round(p[0, 2] * 1000)) == z_first_um:
slices.extrusion_rate[i] = slices.extrusion_rate[i] * mult
else:
break
if has_rot:
from mathutils import Euler
mat_inv = np.array(Euler(rotation).to_matrix())
slices = slices.transform(mat_inv)
slices = slices.align_seams(mode="sharpest")
return slices
@register_class
class SLICENODE_SliceShells(SliceNode):
bl_label = "Slice Shells"
list_socket_mapping = [("Mesh", "Paths")]
ignore_holes: bool = NfBoolProperty(
name="Ignore Holes",
@ -61,7 +185,7 @@ class SLICENODE_SliceShells(SliceNode):
if hasattr(self, "ignore_holes"):
layout.prop(self, "ignore_holes")
def evaluate_output(self, socket_name: str, **_kwargs) -> SlicePaths:
def evaluate_output(self, socket_name: str, **_kwargs) -> Any:
if socket_name != "Paths": return SlicePaths()
coll: Any = self.resolve("Mesh")
@ -92,126 +216,15 @@ class SLICENODE_SliceShells(SliceNode):
if not coll: return SlicePaths()
# Handle SlicingGroup, SlicingCollection, SlicingObject, or list
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]
elif isinstance(coll, list):
objects = []
for item in coll:
if isinstance(item, SlicingGroup):
for c in item.collections.values():
objects.extend(c.objects)
elif isinstance(item, SlicingCollection):
objects.extend(item.objects)
elif isinstance(item, SlicingObject):
objects.append(item)
else:
objects = getattr(coll, "objects", [coll] if hasattr(coll, "mesh") else [])
def _slice_one(item: Any) -> SlicePaths:
return _slice_single_mesh_item(item, layer_height, fl_height, rotation, ignore_holes)
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())
h_first = fl_height if fl_height is not None else layer_height
min_z_um = int(round(min_z * 1000))
lh_um = int(round(layer_height * 1000))
flh_um = int(round(h_first * 1000))
max_z_um = int(round(max_z * 1000))
z_first_um = min_z_um + flh_um
z_rest_start_um = z_first_um + lh_um
if z_first_um <= max_z_um:
if z_rest_start_um <= max_z_um and lh_um > 0:
z_um_values = np.arange(z_rest_start_um, max_z_um + 1, lh_um, dtype=np.int64)
z_values = np.concatenate(([z_first_um], z_um_values)) / 1000.0
else:
z_values = np.array([z_first_um], dtype=np.float64) / 1000.0
else:
z_values = np.array([], dtype=np.float64)
slices_group: list[list[np.ndarray]] | None = slice_at_range(
[merged_object.mesh],
z_values,
ignore_holes=ignore_holes
)
if not slices_group or not slices_group[0]:
return SlicePaths()
paths = slices_group[0]
# Assign layer-consistent group indices based on Z height in integer microns
z_to_group: dict[int, int] = {}
groups = []
for p in paths:
if len(p) > 0:
z_k = int(round(p[0, 2] * 1000))
if z_k not in z_to_group:
z_to_group[z_k] = len(z_to_group)
groups.append(z_to_group[z_k])
else:
groups.append(0)
slices = SlicePaths(paths, groups=groups)
slices.layer_height = layer_height
if fl_height is not None:
slices.first_layer_height = fl_height
slices.transforms = list(getattr(coll, "transforms", [])) or list(getattr(merged_object, "transforms", []))
names = []
if objects:
for o in objects:
n = getattr(o, "name", "")
if n:
if n.endswith("_merged"):
n = n[:-7]
if n and n not in names:
names.append(n)
if not names and hasattr(coll, "name") and getattr(coll, "name"):
names.append(str(getattr(coll, "name")))
if names:
slices.name = names[0]
slices.objects_info = {"objects": [{"name": n, "polygon": []} for n in names]}
if len(z_values) > 0 and fl_height is not None and layer_height > 0:
mult = fl_height / layer_height
z_first_um = int(round(z_values[0] * 1000))
for i, p in enumerate(slices):
if len(p) > 0 and int(round(p[0, 2] * 1000)) == z_first_um:
slices.extrusion_rate[i] = slices.extrusion_rate[i] * mult
else:
break
if has_rot:
from mathutils import Euler
mat_inv = np.array(Euler(rotation).to_matrix())
slices = slices.transform(mat_inv)
slices = slices.align_seams(mode="sharpest")
return slices
return eval_element_wise(_slice_one, coll)
@register_class
class SLICENODE_BufferShells(SliceNode):
bl_label = "Buffer Shells"
list_socket_mapping = [("Paths", "Paths")]
shape_option: str = NfEnumProperty(
name="Miter",
@ -232,21 +245,26 @@ class SLICENODE_BufferShells(SliceNode):
assert layout
layout.prop(self, "shape_option")
def evaluate_output(self, socket_name: str, **_kwargs) -> SlicePaths:
def evaluate_output(self, socket_name: str, **_kwargs) -> Any:
if socket_name != "Paths": return SlicePaths()
paths: SlicePaths = self.resolve("Paths")
paths = self.resolve("Paths")
distance_val = self.resolve("Distance")
distance: float = float(distance_val) if distance_val is not None else 0.0
rotation: Any = self.resolve("Rotation") or (0.0, 0.0, 0.0)
if not paths: return SlicePaths()
return paths.buffer(distance, miter=self.shape_option, rotation=rotation)
def _buf(p: Any) -> SlicePaths:
if not p: return SlicePaths()
return p.buffer(distance, miter=self.shape_option, rotation=rotation)
return eval_element_wise(_buf, paths)
@register_class
class SLICENODE_BufferCurve(SliceNode):
bl_label = "Buffer Curve"
list_socket_mapping = [("Paths", "Paths")]
cap_style: str = NfEnumProperty(
name="Cap Style",
@ -286,26 +304,31 @@ class SLICENODE_BufferCurve(SliceNode):
layout.prop(self, "join_style")
layout.prop(self, "single_sided")
def evaluate_output(self, socket_name: str, **_kwargs) -> SlicePaths:
def evaluate_output(self, socket_name: str, **_kwargs) -> Any:
if socket_name != "Paths": return SlicePaths()
paths: SlicePaths = self.resolve("Paths")
paths = self.resolve("Paths")
distance: float = self.resolve("Distance") or 0.0
rotation: Any = self.resolve("Rotation") or (0.0, 0.0, 0.0)
if not paths: return SlicePaths()
return paths.buffer_curve(
distance,
cap_style=self.cap_style,
join_style=self.join_style,
single_sided=self.single_sided,
rotation=rotation
)
def _buf(p: Any) -> SlicePaths:
if not p: return SlicePaths()
return p.buffer_curve(
distance,
cap_style=self.cap_style,
join_style=self.join_style,
single_sided=self.single_sided,
rotation=rotation
)
return eval_element_wise(_buf, paths)
@register_class
class SLICENODE_OffsetCurve(SliceNode):
bl_label = "Offset Curve"
list_socket_mapping = [("Paths", "Paths")]
join_style: str = NfEnumProperty(
name="Join Style",
@ -327,24 +350,29 @@ class SLICENODE_OffsetCurve(SliceNode):
assert layout
layout.prop(self, "join_style")
def evaluate_output(self, socket_name: str, **_kwargs) -> SlicePaths:
def evaluate_output(self, socket_name: str, **_kwargs) -> Any:
if socket_name != "Paths": return SlicePaths()
paths: SlicePaths = self.resolve("Paths")
paths = self.resolve("Paths")
distance: float = self.resolve("Distance") or 0.0
rotation: Any = self.resolve("Rotation") or (0.0, 0.0, 0.0)
if not paths: return SlicePaths()
return paths.offset_curve(
distance,
join_style=cast(Literal['round', 'mitre'], self.join_style),
rotation=rotation
)
def _off(p: Any) -> SlicePaths:
if not p: return SlicePaths()
return p.offset_curve(
distance,
join_style=cast(Literal['round', 'mitre'], self.join_style),
rotation=rotation
)
return eval_element_wise(_off, paths)
@register_class
class SLICENODE_PathOffset(SliceNode):
bl_label = "Path Offset"
list_socket_mapping = [("Paths", "Paths")]
def init(self, context):
self.add_input("SLICESOCK_Paths", "Paths")
@ -352,16 +380,21 @@ class SLICENODE_PathOffset(SliceNode):
self.add_output("SLICESOCK_Paths", "Paths")
def evaluate_output(self, socket_name: str, **_kwargs) -> Any:
paths: SlicePaths = self.resolve("Paths")
paths = self.resolve("Paths")
offset: Any = self.resolve("Offset") or (0.0, 0.0, 0.0)
if not paths: return SlicePaths()
return paths.offset(np.array(offset, dtype=np.float64))
def _off(p: Any) -> SlicePaths:
if not p: return SlicePaths()
return p.offset(np.array(offset, dtype=np.float64))
return eval_element_wise(_off, paths)
@register_class
class SLICENODE_RotatePath(SliceNode):
bl_label = "Rotate Path"
list_socket_mapping = [("Paths", "Paths")]
def init(self, context):
self.add_input("SLICESOCK_Paths", "Paths")
@ -370,19 +403,24 @@ class SLICENODE_RotatePath(SliceNode):
self.add_output("SLICESOCK_Paths", "Paths")
def evaluate_output(self, socket_name: str, **_kwargs) -> Any:
paths: SlicePaths = self.resolve("Paths")
paths = self.resolve("Paths")
angle: float = self.resolve("Angle") or 0.0
center: Any = self.resolve("Center")
if not paths: return SlicePaths()
center_vec = np.array(center, dtype=np.float64) if center and any(center) else None
return paths.rotate(angle, center=center_vec)
def _rot(p: Any) -> SlicePaths:
if not p: return SlicePaths()
return p.rotate(angle, center=center_vec)
return eval_element_wise(_rot, paths)
@register_class
class SLICENODE_BufferShellsMultiple(SliceNode):
bl_label = "Buffer Shells Multiple"
list_socket_mapping = [("Paths", ["Paths", "Outermost Path", "Innermost Path"])]
shape_option: str = NfEnumProperty(
name="Miter",
@ -407,8 +445,8 @@ class SLICENODE_BufferShellsMultiple(SliceNode):
assert layout
layout.prop(self, "shape_option")
def evaluate_output(self, socket_name: str, **_kwargs) -> SlicePaths:
paths: SlicePaths = self.resolve("Paths")
def evaluate_output(self, socket_name: str, **_kwargs) -> Any:
paths = self.resolve("Paths")
base_distance_val = self.resolve("Distance")
base_distance: float = float(base_distance_val) if base_distance_val is not None else 0.0
width_step_val = self.resolve("Width Step")
@ -420,20 +458,25 @@ class SLICENODE_BufferShellsMultiple(SliceNode):
if not paths or count <= 0:
return SlicePaths()
if socket_name == "Outermost Path":
outer_dist = max(base_distance, base_distance + (count - 1) * width_step) if count > 1 else base_distance
return paths.buffer(outer_dist, miter=self.shape_option, rotation=rotation)
elif socket_name == "Innermost Path":
inner_dist = min(base_distance, base_distance + (count - 1) * width_step) if count > 1 else base_distance
return paths.buffer(inner_dist, miter=self.shape_option, rotation=rotation)
elif socket_name == "Paths":
return paths.buffer_multiple(base_distance, count, width_step, miter=self.shape_option, rotation=rotation)
def _buf_multi(p: Any) -> SlicePaths:
if not p or count <= 0:
return SlicePaths()
if socket_name == "Outermost Path":
outer_dist = max(base_distance, base_distance + (count - 1) * width_step) if count > 1 else base_distance
return p.buffer(outer_dist, miter=self.shape_option, rotation=rotation)
elif socket_name == "Innermost Path":
inner_dist = min(base_distance, base_distance + (count - 1) * width_step) if count > 1 else base_distance
return p.buffer(inner_dist, miter=self.shape_option, rotation=rotation)
elif socket_name == "Paths":
return p.buffer_multiple(base_distance, count, width_step, miter=self.shape_option, rotation=rotation)
return SlicePaths()
return SlicePaths()
return eval_element_wise(_buf_multi, paths)
@register_class
class SLICENODE_Infill(SliceNode):
bl_label = "Infill"
list_socket_mapping = [("Paths", "Paths")]
infill_type: str = NfEnumProperty(
name="Type",
@ -457,26 +500,31 @@ class SLICENODE_Infill(SliceNode):
assert layout
layout.prop(self, "infill_type", text="")
def evaluate_output(self, socket_name: str, **_kwargs) -> SlicePaths:
def evaluate_output(self, socket_name: str, **_kwargs) -> Any:
if socket_name != "Paths": return SlicePaths()
paths: SlicePaths = self.resolve("Paths")
paths = self.resolve("Paths")
density: float = self.resolve("Density") or 2.0
infill_rotation: float = self.resolve("Infill Rotation") or 0.0
rotation: Any = self.resolve("Layer Rotation") or (0.0, 0.0, 0.0)
if not paths: return SlicePaths()
return paths.infill(
density,
rotation_deg=infill_rotation,
infill_type=self.infill_type, # pyright: ignore[reportOptionalMemberAccess]
layer_rotation=rotation
)
def _inf(p: Any) -> SlicePaths:
if not p: return SlicePaths()
return p.infill(
density,
rotation_deg=infill_rotation,
infill_type=self.infill_type,
layer_rotation=rotation
)
return eval_element_wise(_inf, paths)
@register_class
class SLICENODE_OverhangMask(SliceNode):
bl_label = "Overhang Mask"
list_socket_mapping = [("Paths", "Paths")]
def init(self, context):
self.add_input("SLICESOCK_Paths")
@ -486,15 +534,19 @@ class SLICENODE_OverhangMask(SliceNode):
def draw_buttons(self, context, layout):
pass
def evaluate_output(self, socket_name: str, **_kwargs) -> SlicePaths:
def evaluate_output(self, socket_name: str, **_kwargs) -> Any:
if socket_name != "Paths": return SlicePaths()
paths: SlicePaths = self.resolve("Paths")
paths = self.resolve("Paths")
tolerance: float = self.resolve("Tolerance") or 0.05
if not paths: return SlicePaths()
return paths.get_overhang_mask(tolerance=tolerance)
def _mask(p: Any) -> SlicePaths:
if not p: return SlicePaths()
return p.get_overhang_mask(tolerance=tolerance)
return eval_element_wise(_mask, paths)
@register_class
class SLICENODE_PathOperations(SliceNodeJoiner):
@ -522,37 +574,56 @@ class SLICENODE_PathOperations(SliceNodeJoiner):
default=False
)
def update(self) -> None:
super().update()
if self.inputs:
first_inp = self.inputs[0]
is_list = is_socket_linked_to_list(first_inp)
set_socket_shape(first_inp, "LIST" if is_list else "CIRCLE")
if self.outputs:
set_socket_shape(self.outputs[0], "LIST" if is_list else "CIRCLE")
def draw_buttons(self, context, layout):
assert layout
layout.prop(self, "operation", text="")
if self.operation in ['MIN_COVERAGE', 'INTERSECTION', 'SUBTRACT']:
layout.prop(self, "connect_along_boundary")
def evaluate_output(self, socket_name: str, **_kwargs) -> SlicePaths:
def evaluate_output(self, socket_name: str, **_kwargs) -> Any:
from ....infra.blender.blender_slice_nodes_generic import resolve_input
inputs_data: list[SlicePaths] = []
for input_socket in self.inputs:
if hasattr(input_socket, "is_linked") and not input_socket.is_linked:
continue
paths: Any = resolve_input(input_socket)
if isinstance(paths, SlicePaths) and len(paths) > 0:
inputs_data.append(paths)
if not inputs_data:
if not self.inputs:
return SlicePaths()
output = SlicePaths.path_operations(
inputs_data,
operation=self.operation,
connect_along_boundary=self.connect_along_boundary
)
return output
first_val = resolve_input(self.inputs[0]) if (self.inputs[0].is_linked or getattr(self.inputs[0], 'value', None)) else None
other_paths: list[SlicePaths] = []
for input_socket in self.inputs[1:]:
if getattr(input_socket, "is_linked", False):
val = resolve_input(input_socket)
if isinstance(val, SlicePaths) and len(val) > 0:
other_paths.append(val)
def _run_op(primary_path: Any) -> SlicePaths:
inputs_data = [p for p in [primary_path] + other_paths if isinstance(p, SlicePaths) and len(p) > 0]
if not inputs_data:
return SlicePaths()
return SlicePaths.path_operations(
inputs_data,
operation=self.operation,
connect_along_boundary=self.connect_along_boundary
)
if is_list_data(first_val):
return [_run_op(item) for item in first_val]
elif isinstance(first_val, SlicePaths) or other_paths:
return _run_op(first_val)
return SlicePaths()
@register_class
class SLICENODE_SimplifyPaths(SliceNode):
bl_label = "Simplify Paths"
list_socket_mapping = [("Paths", "Paths")]
preserve_topology: bool = NfBoolProperty(
name="Preserve Topology",
@ -569,21 +640,27 @@ class SLICENODE_SimplifyPaths(SliceNode):
assert layout
layout.prop(self, "preserve_topology")
def evaluate_output(self, socket_name: str, **_kwargs) -> SlicePaths:
def evaluate_output(self, socket_name: str, **_kwargs) -> Any:
if socket_name != "Paths":
return SlicePaths()
paths: SlicePaths = self.resolve("Paths")
paths = self.resolve("Paths")
tolerance: float = self.resolve("Tolerance") or 0.0
if not paths or tolerance <= 0:
return paths
return paths.simplify(tolerance, preserve_topology=self.preserve_topology)
def _simp(p: Any) -> SlicePaths:
if not p or tolerance <= 0:
return p
return p.simplify(tolerance, preserve_topology=self.preserve_topology)
return eval_element_wise(_simp, paths)
@register_class
class SLICENODE_SeamPlacement(SliceNode):
bl_label = "Seam Placement"
list_socket_mapping = [("Paths", "Paths")]
mode: str = NfEnumProperty(
name="Mode",
@ -614,11 +691,11 @@ class SLICENODE_SeamPlacement(SliceNode):
layout.prop(self, "mode", text="")
layout.prop(self, "acquire_transforms")
def evaluate_output(self, socket_name: str, **_kwargs) -> SlicePaths:
def evaluate_output(self, socket_name: str, **_kwargs) -> Any:
if socket_name != "Paths":
return SlicePaths()
paths: SlicePaths = self.resolve("Paths")
paths = self.resolve("Paths")
mesh_input: Any = self.resolve("Mesh")
stagger_val = self.resolve("Seam Staggering")
stagger_mm: float = float(stagger_val) if stagger_val is not None else 0.0
@ -626,10 +703,15 @@ class SLICENODE_SeamPlacement(SliceNode):
if not paths:
return SlicePaths()
if not mesh_input and abs(stagger_mm) < 1e-6:
return paths
acquire_t = bool(getattr(self, "acquire_transforms", True))
return paths.place_seams(mesh_input, mode=self.mode.lower(), acquire_transforms=acquire_t, stagger_mm=stagger_mm)
def _place(p: Any) -> SlicePaths:
if not p:
return SlicePaths()
if not mesh_input and abs(stagger_mm) < 1e-6:
return p
return p.place_seams(mesh_input, mode=self.mode.lower(), acquire_transforms=acquire_t, stagger_mm=stagger_mm)
return eval_element_wise(_place, paths)

View file

@ -20,6 +20,13 @@ class SLICENODE_GcodeToMesh(ExecSliceNode):
self.add_input("NodeSocketString", "Name")
self.add_multi_socket_group("NodeSocketString", name="Gcode")
def update(self) -> None:
super().update()
from ....infra.blender.list_utils import is_socket_linked_to_list, set_socket_shape
for inp in self.inputs:
if getattr(inp, "name", "") == "Gcode" or getattr(inp, "identifier", "").startswith("Gcode_ms_in_"):
set_socket_shape(inp, "LIST" if is_socket_linked_to_list(inp) else "CIRCLE")
def draw_buttons(self, context, layout):
super().draw_buttons(context, layout)
layout.prop(self, 'show_e_0')
@ -229,7 +236,9 @@ class SLICENODE_GcodeToMesh(ExecSliceNode):
mod["Socket_9"] = tuple(map(float, prep_scale))
def evaluate(self, operator=None):
gcodes: list[str] = [g for g in (self.resolve_multi_input("Gcode") or []) if g]
from ....infra.blender.list_utils import flatten_list
raw_gcodes = self.resolve_multi_input("Gcode") or []
gcodes: list[str] = [str(g) for g in flatten_list(raw_gcodes) if g]
base_mesh_name: str = self.resolve("Name")
if not base_mesh_name:

View file

@ -100,6 +100,11 @@ class SLICENODE_PrinterList(ExecSliceNode):
if not gcode:
continue
from ....infra.blender.list_utils import is_list_data
if is_list_data(gcode):
if operator: operator.report({'ERROR'}, f"Printer List does not accept a list of Gcode on socket {sock.name}")
continue
p_data = printer_configs[sock.name]
host = str(p_data.get("ip", "localhost"))
port = int(p_data.get("port", 80))

View file

@ -121,12 +121,23 @@ def depsgraph_update_handler(scene, depsgraph):
# Sync SLICENODE_Group sockets if interface changed and migrate legacy sockets
for tree in bpy.data.node_groups:
if tree.bl_idname == "SLICETREE_NodeTree":
try:
from ...infra.blender.list_utils import sync_group_tree_internal_sockets
sync_group_tree_internal_sockets(tree)
except Exception:
pass
for node in tree.nodes:
if node.bl_idname == "SLICENODE_Group" and hasattr(node, "sync_sockets"):
try:
node.sync_sockets()
except Exception:
pass
if node.bl_idname == "SLICENODE_Group":
if hasattr(node, "sync_sockets"):
try:
node.sync_sockets()
except Exception:
pass
elif hasattr(node, "sync_socket_shapes"):
try:
node.sync_socket_shapes()
except Exception:
pass
elif node.bl_idname == "SLICENODE_CollectionObjects" and hasattr(node, "_update_outputs"):
try:
node._update_outputs()
@ -149,9 +160,35 @@ class SLICETREE_NodeTree(NodeTree):
def valid_link(self, from_socket: Any, to_socket: Any) -> bool:
geom_sockets = {"SLICESOCK_Mesh", "SLICESOCK_Paths", "SLICESOCK_Geometry"}
if from_socket.bl_idname in geom_sockets and to_socket.bl_idname in geom_sockets:
return True
return from_socket.bl_idname == to_socket.bl_idname
compatible = (
(from_socket.bl_idname in geom_sockets and to_socket.bl_idname in geom_sockets)
or from_socket.bl_idname == to_socket.bl_idname
)
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(from_socket):
to_node = getattr(to_socket, "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
# Path Operations: only input 0 can accept a list; secondary inputs reject lists
if to_node.bl_idname == "SLICENODE_PathOperations":
if getattr(to_node, "inputs", None) and to_socket != to_node.inputs[0]:
return False
# Seam Placement: secondary input (Mesh) rejects lists
if to_node.bl_idname == "SLICENODE_SeamPlacement":
if getattr(to_socket, "name", "") == "Mesh":
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, to_socket):
return False
return True
@register_class
@ -590,6 +627,11 @@ class SLICENODE_MT_Joiners(Menu):
def draw(self, context):
layout = self.layout
assert layout
layout.operator("node.add_node", text="Objects to List").type = "SLICENODE_ObjectsToList"
layout.operator("node.add_node", text="Meshes to List").type = "SLICENODE_MeshesToList"
layout.operator("node.add_node", text="Paths to List").type = "SLICENODE_PathsToList"
layout.operator("node.add_node", text="Gcodes to List").type = "SLICENODE_GcodesToList"
layout.separator()
layout.operator("node.add_node", text="Join Mesh Parts").type = "SLICENODE_JoinMesh"
layout.operator("node.add_node", text="Join Mesh Groups").type = "SLICENODE_JoinMeshGroups"
layout.operator("node.add_node", text="Path Operations").type = "SLICENODE_PathOperations"

View file

@ -1060,6 +1060,7 @@ def shift_path_seam_by_distance(
class SlicePaths(list[np.ndarray]):
is_slice_paths: bool = True
groups: list[int]
extrusion_rate: list[np.ndarray]
feedrate: list[np.ndarray]

View file

@ -72,3 +72,9 @@ This file provides a mapping of modules to their primary responsibilities and ke
- `get_last_debug_dump()` / `get_last_debug_text()` / `load_last_inputs()`: Utilities to review and reload the most recent execution dump for debugging.
- `debug_session()`: Context manager enabling execution capture across node evaluations.
- `SLICENODE_OT_DebugDump`: Blender operator (`slicenode.debug_dump`) to dump the active node's inputs on demand.
- `list_utils.py`:
- `is_list_data()`: Type discrimination distinguishing list/tuple collections from atomic objects (such as `SlicePaths`).
- `flatten_list()`: Recursive flattener for multi-input joiners and batch sinks.
- `eval_element_wise()`: Vectorized applicator executing operations element-wise on lists or directly on single items.
- `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`).

View file

@ -168,10 +168,14 @@ All nodes are found in the **Add (Shift+A)** menu under the **SliceNodes** categ
- **Load Gcode**: Reads a G-code file from disk.
### Utility & Joiners
- **Join Mesh Parts**: Merges multiple `SlicingObject` instances into one `SlicingCollection`.
- **Join Mesh Groups**: Combines multiple `SlicingCollection` instances into a `SlicingGroup`.
- **Path Operations**: Multi-input joiner performing Zip, Extend, Maximum coverage, Minimum coverage, Intersection, Subtract, and XOR operations.
- **Join Gcode**: Concatenates multiple G-code strings.
- **Objects to List**: Multi-input list joiner that bundles objects into a `list[bpy.types.Object]` with LIST socket shape. Flattens incoming lists.
- **Meshes to List**: Multi-input list joiner that bundles meshes into a `list[SlicingCollection]` with LIST socket shape. Flattens incoming lists.
- **Paths to List**: Multi-input list joiner that bundles toolpaths into a `list[SlicePaths]` with LIST socket shape. Flattens incoming lists.
- **Gcodes to List**: Multi-input list joiner that bundles G-code strings into a `list[str]` with LIST socket shape. Flattens incoming lists.
- **Join Mesh Parts**: Merges multiple `SlicingObject` instances into one `SlicingCollection`. Flattens incoming lists.
- **Join Mesh Groups**: Combines multiple `SlicingCollection` instances into a `SlicingGroup`. Flattens incoming lists.
- **Path Operations**: Multi-input joiner performing Zip, Extend, Maximum coverage, Minimum coverage, Intersection, Subtract, and XOR operations. The first input can be a list, in which case the operation runs per-element against the same secondary inputs.
- **Join Gcode**: Concatenates multiple G-code inputs. If no input is a list, concatenates scalar strings into a single string (CIRCLE). If one or more inputs are lists (which must all be the same length), outputs a list of strings (LIST) where each element at index $i$ is joined with static elements broadcast across all indices.
- **Join Settings**: Sequentially merges multiple override dictionaries. Later inputs overwrite earlier ones for matching keys.
- **Join Execution**: Synchronizes sequential execution flows.

View file

@ -1,4 +1,4 @@
Type,Category,Node Name,Class Name,File,Archetype,First Input Socket,Other Input Sockets,Output Socket(s),First Input Shape,Output Shape,Single Item Behavior,List Behavior
Type,Category,Node Name,Class Name,File,Archetype,First Input Socket,Other Input Sockets,Output Socket(s),First Input Shape,Output Shape,Single Item Behavior,List Behavior
Object,List Joiners (New),Objects to List,SLICENODE_ObjectsToList,core/nodeslicer/nodes/inputs.py,List Joiner,Multi-input NodeSocketObject (Object),None,NodeSocketObject (Objects),Standard / LIST,Fixed LIST,Wraps single item into a 1-element list [Obj1],Flattens incoming lists and combines all objects into list[bpy.types.Object]. Output appearance is LIST.
Mesh,List Joiners (New),Meshes to List,SLICENODE_MeshesToList,core/nodeslicer/nodes/mesh.py,List Joiner,Multi-input SLICESOCK_Mesh (Mesh),None,SLICESOCK_Mesh (Meshes),Standard / LIST,Fixed LIST,Wraps single mesh into a 1-element list [Mesh1],Flattens incoming lists and bundles all meshes into list[SlicingCollection]. Output appearance is LIST.
Path,List Joiners (New),Paths to List,SLICENODE_PathsToList,core/nodeslicer/nodes/path.py,List Joiner,Multi-input SLICESOCK_Paths (Paths),None,SLICESOCK_Paths (Paths),Standard / LIST,Fixed LIST,Wraps single path into a 1-element list [Path1],Flattens incoming lists and bundles all toolpaths into list[SlicePaths]. Output appearance is LIST.
@ -33,8 +33,8 @@ Path,G-code Processing,Paths to Gcode,SLICENODE_PathsToGcode,core/nodeslicer/nod
Gcode,G-code Processing,Gcode Regex Replace,SLICENODE_GcodeRegexReplace,core/nodeslicer/nodes/gcode.py,Element-wise Operator,NodeSocketString (Gcode),"Find, Replace (cannot be list)",NodeSocketString (Gcode),Dynamic (Changes to LIST if input is list),Dynamic (Changes to LIST if input is list),Runs regex find & replace on G-code string,"First input is list [G1, G2, ...]. Runs regex search and replace on each G-code string using the same pattern. Output is [G1', G2', ...]. Sockets switch to LIST."
Gcode,Action & I/O,Gcode to File,SLICENODE_GcodeToFile,core/nodeslicer/nodes/output.py,Batch Sink,NodeSocketString (Gcode) [Can be a list or multi-input],"Folder, Name (cannot be list)",Execution Link,Dynamic (Displays LIST if input is list),Standard Execution Link,Saves G-code string to .gcode or .bgcode file on disk,"First input is list [G1, G2, ...]. Saves a separate file for each element using the same folder/name template with zero-padded numbering (Part00.gcode, Part01.gcode) or metadata names."
Gcode,Action & I/O,Gcode to Text Block,SLICENODE_GcodeToTextBlock,core/nodeslicer/nodes/output.py,Batch Sink,NodeSocketString (Gcode) [Can be a list],Text Block Name (cannot be list),Execution Link,Dynamic (Displays LIST if input is list),Standard Execution Link,Writes G-code string to an active Blender text editor data-block,"First input is list [G1, G2, ...]. Writes separate numbered Blender text blocks (NodeSlicer_00.gcode, NodeSlicer_01.gcode) using the base name."
Gcode,Action & I/O,Start Print,SLICENODE_StartPrint,core/nodeslicer/nodes/output.py,Batch Sink,NodeSocketString (Gcode) [Can be a list],SLICESOCK_Printer (cannot be list),Execution Link,Dynamic (Displays LIST if input is list),Standard Execution Link,Uploads and starts print on network printer,"First input is list [G1, G2, ...]. Sequentially queues and sends each G-code print job to the target printer using the same printer configuration."
Gcode,Action & I/O,Start Print,SLICENODE_StartPrint,core/nodeslicer/nodes/output.py,Single Item Sink,NodeSocketString (Gcode) [Single item only],SLICESOCK_Printer (Single item only),Execution Link,Standard (CIRCLE),Standard Execution Link,Uploads and starts print on network printer,List is not accepted. Sockets reject list connections and evaluate() reports an error if a list is received.
Gcode,Action & I/O,Baserow Submit,SLICENODE_BaserowSubmit,core/nodeslicer/nodes/output.py,Batch Sink,NodeSocketString (Gcode) [Can be a list or multi-input],"Settings, Host, Token, Table ID, Record ID (cannot be list)",Execution Link,Dynamic (Displays LIST if input is list),Standard Execution Link,Uploads G-code attachment and creates/updates row in Baserow database,"First input is list [G1, G2, ...]. Submits each G-code as a separate record and attachment in Baserow using the same database credentials."
Gcode,Action & I/O,Airtable Submit,SLICENODE_AirtableSubmit,core/nodeslicer/nodes/output.py,Batch Sink,NodeSocketString (Gcode) [Can be a list or multi-input],"Settings, PAT File, Base ID, Table ID, Record ID (cannot be list)",Execution Link,Dynamic (Displays LIST if input is list),Standard Execution Link,Uploads G-code attachment and creates/updates row in Airtable base,"First input is list [G1, G2, ...]. Submits each G-code as a separate record and attachment in Airtable using the same database credentials."
Gcode,Action & I/O,Gcode to Preview Mesh,SLICENODE_GcodeToMesh,core/nodeslicer/nodes/preview.py,Batch Sink,NodeSocketString (Gcode) [Can be a list or multi-input],"Name, show_e_0 (cannot be list)",Execution Link,Dynamic (Displays LIST if input is list),Standard Execution Link,Decodes toolpaths and reconstructs 3D wireframe preview mesh object in Blender,"First input is list [G1, G2, ...]. Generates a separate preview mesh object in the scene (Preview00, Preview01, ...) for each G-code in the list."
Gcode,Action & I/O,Printer List,SLICENODE_PrinterList,core/nodeslicer/nodes/system.py,Batch Sink,Dynamic G-code sockets per printer [Can be a list],Config File JSON (cannot be list),Execution Link,Dynamic (Displays LIST if input is list),Standard Execution Link,Sends G-code to corresponding printer defined in config JSON,"If a printer socket receives a list [G1, G2, ...], queues and dispatches all jobs for that printer."
Gcode,Action & I/O,Printer List,SLICENODE_PrinterList,core/nodeslicer/nodes/system.py,Single Item Sink,Dynamic G-code sockets per printer [Single item only],Config File JSON (cannot be list),Execution Link,Standard (CIRCLE),Standard Execution Link,Sends G-code to corresponding printer defined in config JSON,List is not accepted. Sockets reject list connections and evaluate() ignores list inputs.

1 Type Category Node Name Class Name File Archetype First Input Socket Other Input Sockets Output Socket(s) First Input Shape Output Shape Single Item Behavior List Behavior
2 Object List Joiners (New) Objects to List SLICENODE_ObjectsToList core/nodeslicer/nodes/inputs.py List Joiner Multi-input NodeSocketObject (Object) None NodeSocketObject (Objects) Standard / LIST Fixed LIST Wraps single item into a 1-element list [Obj1] Flattens incoming lists and combines all objects into list[bpy.types.Object]. Output appearance is LIST.
3 Mesh List Joiners (New) Meshes to List SLICENODE_MeshesToList core/nodeslicer/nodes/mesh.py List Joiner Multi-input SLICESOCK_Mesh (Mesh) None SLICESOCK_Mesh (Meshes) Standard / LIST Fixed LIST Wraps single mesh into a 1-element list [Mesh1] Flattens incoming lists and bundles all meshes into list[SlicingCollection]. Output appearance is LIST.
4 Path List Joiners (New) Paths to List SLICENODE_PathsToList core/nodeslicer/nodes/path.py List Joiner Multi-input SLICESOCK_Paths (Paths) None SLICESOCK_Paths (Paths) Standard / LIST Fixed LIST Wraps single path into a 1-element list [Path1] Flattens incoming lists and bundles all toolpaths into list[SlicePaths]. Output appearance is LIST.
33 Gcode G-code Processing Gcode Regex Replace SLICENODE_GcodeRegexReplace core/nodeslicer/nodes/gcode.py Element-wise Operator NodeSocketString (Gcode) Find, Replace (cannot be list) NodeSocketString (Gcode) Dynamic (Changes to LIST if input is list) Dynamic (Changes to LIST if input is list) Runs regex find & replace on G-code string First input is list [G1, G2, ...]. Runs regex search and replace on each G-code string using the same pattern. Output is [G1', G2', ...]. Sockets switch to LIST.
34 Gcode Action & I/O Gcode to File SLICENODE_GcodeToFile core/nodeslicer/nodes/output.py Batch Sink NodeSocketString (Gcode) [Can be a list or multi-input] Folder, Name (cannot be list) Execution Link Dynamic (Displays LIST if input is list) Standard Execution Link Saves G-code string to .gcode or .bgcode file on disk First input is list [G1, G2, ...]. Saves a separate file for each element using the same folder/name template with zero-padded numbering (Part00.gcode, Part01.gcode) or metadata names.
35 Gcode Action & I/O Gcode to Text Block SLICENODE_GcodeToTextBlock core/nodeslicer/nodes/output.py Batch Sink NodeSocketString (Gcode) [Can be a list] Text Block Name (cannot be list) Execution Link Dynamic (Displays LIST if input is list) Standard Execution Link Writes G-code string to an active Blender text editor data-block First input is list [G1, G2, ...]. Writes separate numbered Blender text blocks (NodeSlicer_00.gcode, NodeSlicer_01.gcode) using the base name.
36 Gcode Action & I/O Start Print SLICENODE_StartPrint core/nodeslicer/nodes/output.py Batch Sink Single Item Sink NodeSocketString (Gcode) [Can be a list] NodeSocketString (Gcode) [Single item only] SLICESOCK_Printer (cannot be list) SLICESOCK_Printer (Single item only) Execution Link Dynamic (Displays LIST if input is list) Standard (CIRCLE) Standard Execution Link Uploads and starts print on network printer First input is list [G1, G2, ...]. Sequentially queues and sends each G-code print job to the target printer using the same printer configuration. List is not accepted. Sockets reject list connections and evaluate() reports an error if a list is received.
37 Gcode Action & I/O Baserow Submit SLICENODE_BaserowSubmit core/nodeslicer/nodes/output.py Batch Sink NodeSocketString (Gcode) [Can be a list or multi-input] Settings, Host, Token, Table ID, Record ID (cannot be list) Execution Link Dynamic (Displays LIST if input is list) Standard Execution Link Uploads G-code attachment and creates/updates row in Baserow database First input is list [G1, G2, ...]. Submits each G-code as a separate record and attachment in Baserow using the same database credentials.
38 Gcode Action & I/O Airtable Submit SLICENODE_AirtableSubmit core/nodeslicer/nodes/output.py Batch Sink NodeSocketString (Gcode) [Can be a list or multi-input] Settings, PAT File, Base ID, Table ID, Record ID (cannot be list) Execution Link Dynamic (Displays LIST if input is list) Standard Execution Link Uploads G-code attachment and creates/updates row in Airtable base First input is list [G1, G2, ...]. Submits each G-code as a separate record and attachment in Airtable using the same database credentials.
39 Gcode Action & I/O Gcode to Preview Mesh SLICENODE_GcodeToMesh core/nodeslicer/nodes/preview.py Batch Sink NodeSocketString (Gcode) [Can be a list or multi-input] Name, show_e_0 (cannot be list) Execution Link Dynamic (Displays LIST if input is list) Standard Execution Link Decodes toolpaths and reconstructs 3D wireframe preview mesh object in Blender First input is list [G1, G2, ...]. Generates a separate preview mesh object in the scene (Preview00, Preview01, ...) for each G-code in the list.
40 Gcode Action & I/O Printer List SLICENODE_PrinterList core/nodeslicer/nodes/system.py Batch Sink Single Item Sink Dynamic G-code sockets per printer [Can be a list] Dynamic G-code sockets per printer [Single item only] Config File JSON (cannot be list) Execution Link Dynamic (Displays LIST if input is list) Standard (CIRCLE) Standard Execution Link Sends G-code to corresponding printer defined in config JSON If a printer socket receives a list [G1, G2, ...], queues and dispatches all jobs for that printer. List is not accepted. Sockets reject list connections and evaluate() ignores list inputs.

View file

@ -9,6 +9,8 @@ from bpy.types import NodeSocket, Node
class NodeException(Exception): ...
from .list_utils import is_list_data, flatten_list, eval_element_wise, set_socket_shape, is_socket_linked_to_list, sync_node_list_sockets
_EvalCache = dict[Any, Any]
_VisitedSet = set[Any]
@ -116,8 +118,17 @@ class SliceNode(Node, AutoBlIdName):
return key in self._data
return False
list_socket_mapping: list[tuple[str, list[str] | str]] = []
def update(self) -> None:
self._update_multi_groups()
self._sync_list_socket_shapes()
def _sync_list_socket_shapes(self) -> None:
mapping = getattr(self, "list_socket_mapping", None)
if mapping:
from .list_utils import sync_node_list_sockets
sync_node_list_sockets(self, mapping)
def evaluate_output(self, socket_name: str, **_kwargs) -> Any: ...
@ -475,6 +486,21 @@ class SLICENODE_Group(bpy.types.NodeCustomGroup, AutoBlIdName):
# 3. Sync Outputs
self._sync_socket_collection(self.outputs, expected_outputs)
# 4. Sync Socket Shapes (LIST vs CIRCLE)
self.sync_socket_shapes()
def sync_socket_shapes(self):
try:
if not self.name or not self.slicer_tree:
return
except Exception:
return
from .list_utils import sync_group_node_socket_shapes
sync_group_node_socket_shapes(self)
def update(self):
self.sync_socket_shapes()
def _sync_socket_collection(self, collection, expected):
# Remove sockets that shouldn't be there
expected_ids = {e[0] for e in expected}

563
infra/blender/list_utils.py Normal file
View file

@ -0,0 +1,563 @@
from __future__ import annotations
from typing import Any, Callable
LIST_CAPABLE_SOCKET_TYPES = {
"SLICESOCK_Mesh",
"SLICESOCK_Paths",
"SLICESOCK_Object",
"SLICESOCK_Geometry",
"NodeSocketObject",
"NodeSocketString",
}
def is_list_data(val: Any) -> bool:
"""Checks if val represents a list of items (Object, Mesh, Path, Gcode).
Note: SlicePaths is a list subclass representing an individual toolpath collection,
not a list of toolpaths, so it is treated as an atomic item (not list data)."""
if val is None or isinstance(val, (str, bytes)):
return False
if type(val).__name__ == "SlicePaths" or getattr(val, "is_slice_paths", False):
return False
return isinstance(val, (list, tuple))
def flatten_list(items: Any) -> list[Any]:
"""Recursively flattens nested lists/tuples, keeping atomic items intact."""
result: list[Any] = []
if not is_list_data(items):
if items is not None:
result.append(items)
return result
for item in items:
if is_list_data(item):
result.extend(flatten_list(item))
elif item is not None:
result.append(item)
return result
def eval_element_wise(op_func: Callable[[Any], Any], data: Any) -> Any:
"""If data is a list, applies op_func to each element and returns a list.
Otherwise applies op_func to data directly."""
if is_list_data(data):
return [op_func(item) for item in data]
return op_func(data)
def set_socket_shape(sock: Any, shape: str) -> None:
"""Safely updates socket display_shape property ('LIST', 'CIRCLE', etc.)."""
if sock is not None:
try:
sock.display_shape = shape
except (AttributeError, TypeError):
pass
def _follow_reroute_upstream(sock: Any, visited: set | None = None) -> Any:
"""Follows reroute nodes backwards to find the original source output socket."""
if visited is None:
visited = set()
current = sock
while (
current is not None
and getattr(current, "node", None) is not None
and getattr(current.node, "bl_idname", "") == "NodeReroute"
and getattr(current.node, "inputs", None)
and current.node.inputs[0].is_linked
and current.node.inputs[0].links
):
ptr = current.as_pointer() if hasattr(current, "as_pointer") else id(current)
if ptr in visited:
break
visited.add(ptr)
current = current.node.inputs[0].links[0].from_socket
return current
def _follow_reroute_downstream(reroute_node: Any, visited: set | None = None) -> list[Any]:
"""Follows reroute nodes forwards to find all downstream target input sockets."""
if visited is None:
visited = set()
node_ptr = reroute_node.as_pointer() if hasattr(reroute_node, "as_pointer") else id(reroute_node)
if node_ptr in visited:
return []
visited.add(node_ptr)
targets: list[Any] = []
for outp in getattr(reroute_node, "outputs", []):
for link in getattr(outp, "links", []):
to_sock = link.to_socket
to_node = getattr(to_sock, "node", None)
if to_node and getattr(to_node, "bl_idname", "") == "NodeReroute":
targets.extend(_follow_reroute_downstream(to_node, visited))
else:
targets.append(to_sock)
return targets
def socket_accepts_list(sock: Any, visited: set | None = None) -> bool:
"""Determines whether a given input socket can accept a list input."""
if sock is None:
return False
if visited is None:
visited = set()
sock_ptr = sock.as_pointer() if hasattr(sock, "as_pointer") else id(sock)
if sock_ptr in visited:
return False
visited.add(sock_ptr)
node = getattr(sock, "node", None)
if node is None:
return False
bl_idname = getattr(node, "bl_idname", "")
# Strictly forbidden nodes
if bl_idname in ("SLICENODE_StartPrint", "SLICENODE_PrinterList"):
return False
# Path Operations: only input 0 can accept a list
if bl_idname == "SLICENODE_PathOperations":
inputs = getattr(node, "inputs", None)
if inputs and sock != inputs[0]:
return False
return True
# Seam Placement: "Mesh" input rejects lists; "Paths" accepts lists
if bl_idname == "SLICENODE_SeamPlacement":
if getattr(sock, "name", "") == "Mesh":
return False
return True
# Socket type check
sock_type = getattr(sock, "bl_idname", "")
if sock_type not in LIST_CAPABLE_SOCKET_TYPES:
return False
# String sockets: only Gcode accepts lists
if sock_type == "NodeSocketString":
sock_name = getattr(sock, "name", "")
if bl_idname == "SLICENODE_JoinGcode":
return True
if bl_idname in ("SLICENODE_WriteGcode", "SLICENODE_ExecuteGcode"):
return sock_name == "Gcode"
if bl_idname == "SLICENODE_GcodeRegexReplace":
return sock_name == "Gcode"
if bl_idname == "SLICENODE_GcodesToList":
return True
if bl_idname == "NodeGroupOutput":
return sock_name.lower().startswith("gcode")
if bl_idname == "SLICENODE_Group":
inner_tree = getattr(node, "slicer_tree", None) or getattr(node, "node_tree", None)
if inner_tree:
return group_input_can_accept_list(inner_tree, sock, visited)
return False
return False
# Reroute node: check downstream targets
if bl_idname == "NodeReroute":
targets = _follow_reroute_downstream(node, visited)
if not targets:
return False
return all(socket_accepts_list(t, visited) for t in targets)
# NodeGroupOutput
if bl_idname == "NodeGroupOutput":
return True
# Nested SLICENODE_Group
if bl_idname == "SLICENODE_Group":
inner_tree = getattr(node, "slicer_tree", None) or getattr(node, "node_tree", None)
if inner_tree:
return group_input_can_accept_list(inner_tree, sock, visited)
return False
# List joiners accept lists
if getattr(node, "is_list_output", False):
return True
# Data joiners accept lists
if bl_idname in ("SLICENODE_JoinMesh", "SLICENODE_JoinMeshGroups", "SLICENODE_JoinGcode"):
return True
# Nodes with list_socket_mapping
mapping = getattr(node, "list_socket_mapping", None)
if mapping:
mapped_in_names = {item[0] for item in mapping}
if getattr(sock, "name", "") in mapped_in_names:
return True
return False
def _get_input_interface_item(tree: Any, sock_or_idx_or_ident: Any) -> Any:
if not hasattr(tree, "interface"):
inputs = getattr(tree, "inputs", [])
if isinstance(sock_or_idx_or_ident, int) and 0 <= sock_or_idx_or_ident < len(inputs):
return inputs[sock_or_idx_or_ident]
ident = getattr(sock_or_idx_or_ident, "identifier", str(sock_or_idx_or_ident))
for s in inputs:
if s.identifier == ident or s.name == ident:
return s
return None
input_items = [
item for item in getattr(tree.interface, "items_tree", [])
if getattr(item, "item_type", "") == 'SOCKET' and getattr(item, "in_out", "") == 'INPUT'
]
if isinstance(sock_or_idx_or_ident, int):
if 0 <= sock_or_idx_or_ident < len(input_items):
return input_items[sock_or_idx_or_ident]
return None
ident = getattr(sock_or_idx_or_ident, "identifier", None)
name = getattr(sock_or_idx_or_ident, "name", None)
if isinstance(sock_or_idx_or_ident, str):
ident = sock_or_idx_or_ident
name = sock_or_idx_or_ident
for item in input_items:
if ident and getattr(item, "identifier", None) == ident:
return item
for item in input_items:
if name and getattr(item, "name", None) == name:
return item
return None
def _get_output_interface_item(tree: Any, sock_or_idx_or_ident: Any) -> Any:
if not hasattr(tree, "interface"):
outputs = getattr(tree, "outputs", [])
if isinstance(sock_or_idx_or_ident, int) and 0 <= sock_or_idx_or_ident < len(outputs):
return outputs[sock_or_idx_or_ident]
ident = getattr(sock_or_idx_or_ident, "identifier", str(sock_or_idx_or_ident))
for s in outputs:
if s.identifier == ident or s.name == ident:
return s
return None
output_items = [
item for item in getattr(tree.interface, "items_tree", [])
if getattr(item, "item_type", "") == 'SOCKET' and getattr(item, "in_out", "") == 'OUTPUT'
]
if isinstance(sock_or_idx_or_ident, int):
if 0 <= sock_or_idx_or_ident < len(output_items):
return output_items[sock_or_idx_or_ident]
return None
ident = getattr(sock_or_idx_or_ident, "identifier", None)
name = getattr(sock_or_idx_or_ident, "name", None)
if isinstance(sock_or_idx_or_ident, str):
ident = sock_or_idx_or_ident
name = sock_or_idx_or_ident
for item in output_items:
if ident and getattr(item, "identifier", None) == ident:
return item
for item in output_items:
if name and getattr(item, "name", None) == name:
return item
return None
def _find_group_input_socket(tree: Any, item: Any) -> Any:
if not item or not hasattr(tree, "nodes"):
return None
ident = getattr(item, "identifier", "")
name = getattr(item, "name", "")
for n in tree.nodes:
if getattr(n, "bl_idname", "") == "NodeGroupInput":
for outp in getattr(n, "outputs", []):
if outp.identifier == ident or outp.name == name:
return outp
return None
def _find_group_output_socket(tree: Any, item: Any) -> Any:
if not item or not hasattr(tree, "nodes"):
return None
ident = getattr(item, "identifier", "")
name = getattr(item, "name", "")
for n in tree.nodes:
if getattr(n, "bl_idname", "") == "NodeGroupOutput":
for inp in getattr(n, "inputs", []):
if inp.identifier == ident or inp.name == name:
return inp
return None
def group_input_can_accept_list(tree: Any, sock_or_idx_or_ident: Any, visited: set | None = None) -> bool:
"""Determines whether a group tree input socket can accept a list input."""
if not tree:
return False
if visited is None:
visited = set()
item = _get_input_interface_item(tree, sock_or_idx_or_ident)
if not item:
return False
item_key = (tree.as_pointer() if hasattr(tree, "as_pointer") else id(tree), getattr(item, "identifier", id(item)))
if item_key in visited:
return False
visited.add(item_key)
stype = getattr(item, "socket_type", "") or getattr(item, "bl_idname", "")
if stype not in LIST_CAPABLE_SOCKET_TYPES:
return False
gin_sock = _find_group_input_socket(tree, item)
if not gin_sock or not getattr(gin_sock, "is_linked", False) or not gin_sock.links:
return False
targets: list[Any] = []
for link in gin_sock.links:
to_sock = link.to_socket
to_node = getattr(to_sock, "node", None)
if to_node and getattr(to_node, "bl_idname", "") == "NodeReroute":
targets.extend(_follow_reroute_downstream(to_node, visited))
else:
targets.append(to_sock)
if not targets:
return False
return all(socket_accepts_list(t, visited) for t in targets)
def is_internal_socket_a_list(group_node: Any, tree: Any, in_sock: Any, visited: set | None = None) -> bool:
"""Determines whether an internal input socket receives a list within a group context."""
if not in_sock or not getattr(in_sock, "is_linked", False) or not in_sock.links:
return False
from_sock = in_sock.links[0].from_socket
from_sock = _follow_reroute_upstream(from_sock, visited)
if not from_sock:
return False
from_node = getattr(from_sock, "node", None)
if not from_node:
return False
if getattr(from_node, "is_list_output", False):
return True
if getattr(from_node, "bl_idname", "") == "NodeGroupInput":
in_item = _get_input_interface_item(tree, from_sock)
if not in_item:
return False
if group_node is not None:
for grp_in in getattr(group_node, "inputs", []):
if grp_in.identifier == getattr(in_item, "identifier", None) or grp_in.name == getattr(in_item, "name", None):
return is_socket_linked_to_list(grp_in)
return False
else:
return group_input_can_accept_list(tree, in_item, visited)
if getattr(from_node, "bl_idname", "") == "SLICENODE_Group":
inner = getattr(from_node, "slicer_tree", None) or getattr(from_node, "node_tree", None)
if inner:
return is_group_output_a_list(from_node, inner, from_sock, visited)
return False
if getattr(from_node, "bl_idname", "") == "SLICENODE_JoinGcode":
for inp in getattr(from_node, "inputs", []):
if getattr(inp, "name", "") == "Gcode" or getattr(inp, "identifier", "").startswith("Gcode_ms_in_"):
if is_internal_socket_a_list(group_node, tree, inp, visited):
return True
return False
mapping = getattr(from_node, "list_socket_mapping", None)
if mapping:
sock_name = getattr(from_sock, "name", "")
for m_in_name, m_out_names in mapping:
targets = [m_out_names] if isinstance(m_out_names, str) else list(m_out_names)
if sock_name in targets:
mapped_inp = getattr(from_node, "inputs", None) and from_node.inputs.get(m_in_name)
if mapped_inp:
return is_internal_socket_a_list(group_node, tree, mapped_inp, visited)
return getattr(from_sock, "display_shape", "") == "LIST"
def is_group_output_a_list(
group_node: Any,
tree: Any,
sock_or_idx_or_ident: Any,
visited: set | None = None
) -> bool:
"""Determines whether a group tree output socket produces a list."""
if not tree:
return False
if visited is None:
visited = set()
item = _get_output_interface_item(tree, sock_or_idx_or_ident)
if not item:
return False
item_key = (
tree.as_pointer() if hasattr(tree, "as_pointer") else id(tree),
getattr(item, "identifier", id(item)),
id(group_node) if group_node else 0,
)
if item_key in visited:
return False
visited.add(item_key)
gout_sock = _find_group_output_socket(tree, item)
if not gout_sock or not getattr(gout_sock, "is_linked", False) or not gout_sock.links:
return False
src_sock = gout_sock.links[0].from_socket
src_sock = _follow_reroute_upstream(src_sock, visited)
if not src_sock:
return False
src_node = getattr(src_sock, "node", None)
if not src_node:
return False
src_bl_id = getattr(src_node, "bl_idname", "")
if getattr(src_node, "is_list_output", False):
return True
if src_bl_id == "SLICENODE_JoinGcode":
for inp in getattr(src_node, "inputs", []):
if getattr(inp, "name", "") == "Gcode" or getattr(inp, "identifier", "").startswith("Gcode_ms_in_"):
if is_internal_socket_a_list(group_node, tree, inp, visited):
return True
return False
if src_bl_id == "SLICENODE_Group":
inner_tree = getattr(src_node, "slicer_tree", None) or getattr(src_node, "node_tree", None)
if inner_tree:
return is_group_output_a_list(src_node, inner_tree, src_sock, visited)
return False
if src_bl_id == "NodeGroupInput":
in_item = _get_input_interface_item(tree, src_sock)
if not in_item:
return False
if group_node is not None:
for grp_in in getattr(group_node, "inputs", []):
if grp_in.identifier == getattr(in_item, "identifier", None) or grp_in.name == getattr(in_item, "name", None):
return is_socket_linked_to_list(grp_in)
return False
else:
return group_input_can_accept_list(tree, in_item, visited)
mapping = getattr(src_node, "list_socket_mapping", None)
if mapping:
sock_name = getattr(src_sock, "name", "")
for in_name, out_names in mapping:
targets = [out_names] if isinstance(out_names, str) else list(out_names)
if sock_name in targets:
inp = getattr(src_node, "inputs", None) and src_node.inputs.get(in_name)
if inp:
return is_internal_socket_a_list(group_node, tree, inp, visited)
return getattr(src_sock, "display_shape", "") == "LIST"
def sync_group_node_socket_shapes(group_node: Any) -> None:
"""Updates display_shape property on a SLICENODE_Group node's inputs and outputs."""
tree = getattr(group_node, "slicer_tree", None) or getattr(group_node, "node_tree", None)
if not tree:
return
for inp in getattr(group_node, "inputs", []):
can_list = group_input_can_accept_list(tree, inp)
set_socket_shape(inp, "LIST" if can_list else "CIRCLE")
for outp in getattr(group_node, "outputs", []):
is_list = is_group_output_a_list(group_node, tree, outp)
set_socket_shape(outp, "LIST" if is_list else "CIRCLE")
def sync_group_tree_internal_sockets(tree: Any) -> None:
"""Updates display_shape property on NodeGroupInput and NodeGroupOutput within a group tree."""
if not tree or not hasattr(tree, "nodes"):
return
for node in tree.nodes:
bl_id = getattr(node, "bl_idname", "")
if bl_id == "NodeGroupInput":
for outp in getattr(node, "outputs", []):
if outp.name == "" or outp.identifier == "__extend__":
continue
can_list = group_input_can_accept_list(tree, outp)
set_socket_shape(outp, "LIST" if can_list else "CIRCLE")
elif bl_id == "NodeGroupOutput":
for inp in getattr(node, "inputs", []):
if inp.name == "" or inp.identifier == "__extend__":
continue
is_list = is_group_output_a_list(None, tree, inp)
set_socket_shape(inp, "LIST" if is_list else "CIRCLE")
def is_output_socket_a_list(sock: Any, visited: set | None = None) -> bool:
"""Determines whether an output socket produces a list."""
if sock is None:
return False
if visited is None:
visited = set()
sock_ptr = sock.as_pointer() if hasattr(sock, "as_pointer") else id(sock)
if sock_ptr in visited:
return False
visited.add(sock_ptr)
if getattr(sock, "display_shape", "") == "LIST":
return True
node = getattr(sock, "node", None)
if node is None:
return False
bl_idname = getattr(node, "bl_idname", "")
if getattr(node, "is_list_output", False):
return True
if bl_idname == "NodeReroute":
inputs = getattr(node, "inputs", None)
if inputs and inputs[0].is_linked and inputs[0].links:
return is_output_socket_a_list(inputs[0].links[0].from_socket, visited)
return False
if bl_idname == "SLICENODE_JoinGcode":
inputs = getattr(node, "inputs", [])
return any(
is_socket_linked_to_list(inp)
for inp in inputs
if getattr(inp, "name", "") == "Gcode" or getattr(inp, "identifier", "").startswith("Gcode_ms_in_")
)
if bl_idname == "SLICENODE_Group":
tree = getattr(node, "slicer_tree", None) or getattr(node, "node_tree", None)
if tree:
return is_group_output_a_list(node, tree, sock, visited)
return False
if bl_idname == "NodeGroupInput":
tree = getattr(node, "id_data", None)
if tree:
return group_input_can_accept_list(tree, sock, visited)
return False
mapping = getattr(node, "list_socket_mapping", None)
if mapping:
sock_name = getattr(sock, "name", "")
for in_name, out_names in mapping:
targets = [out_names] if isinstance(out_names, str) else list(out_names)
if sock_name in targets:
inp = getattr(node, "inputs", None) and node.inputs.get(in_name)
if inp and is_socket_linked_to_list(inp):
return True
return False
def is_socket_linked_to_list(sock: Any) -> bool:
"""Determines whether an input socket is linked to an upstream list output."""
if not getattr(sock, "is_linked", False) or not getattr(sock, "links", None):
return False
from_socket = sock.links[0].from_socket
from_socket = _follow_reroute_upstream(from_socket)
return is_output_socket_a_list(from_socket)
def sync_node_list_sockets(node: Any, mapping: list[tuple[str, list[str] | str]]) -> None:
"""Updates display_shape on input and output sockets based on mapping.
mapping is a list of tuples: (input_socket_name, output_socket_names)
e.g. [('Paths', ['Paths']), ('Mesh', ['Selection', 'Inverted'])]
"""
for in_name, out_names in mapping:
inp = getattr(node, "inputs", None) and node.inputs.get(in_name)
if inp is None:
continue
is_list = is_socket_linked_to_list(inp)
set_socket_shape(inp, "LIST" if is_list else "CIRCLE")
if isinstance(out_names, str):
targets = [out_names]
else:
targets = list(out_names)
for out_name in targets:
out_sock = getattr(node, "outputs", None) and node.outputs.get(out_name)
if out_sock is not None:
set_socket_shape(out_sock, "LIST" if is_list else "CIRCLE")

671
tests/test_lists.py Normal file
View file

@ -0,0 +1,671 @@
import sys
import unittest
from unittest.mock import MagicMock
from pathlib import Path
import numpy as np
PROJECT_ROOT = Path(__file__).resolve().parent.parent
sys.path.append(str(PROJECT_ROOT.parent))
# Ensure bpy mocks
class DummyObject:
def __init__(self, name="TestObj"):
self.name = name
class DummyMesh: pass
class DummyBase: pass
mock_types = MagicMock()
mock_types.NodeSocket = type("NodeSocket", (DummyBase,), {})
mock_types.Node = type("Node", (DummyBase,), {})
mock_types.NodeCustomGroup = type("NodeCustomGroup", (DummyBase,), {})
mock_types.NodeTree = type("NodeTree", (DummyBase,), {})
mock_types.PropertyGroup = type("PropertyGroup", (DummyBase,), {})
mock_types.Operator = type("Operator", (DummyBase,), {})
mock_types.Panel = type("Panel", (DummyBase,), {})
mock_types.Object = DummyObject
mock_types.Mesh = DummyMesh
mock_bpy = MagicMock()
mock_bpy.context.scene = MagicMock()
mock_bpy.context.scene.unit_settings.scale_length = 0.001
mock_bpy.types = mock_types
mock_bpy.path.abspath = lambda path: path.replace("//", "/abs/")
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.infra.blender.list_utils import (
is_list_data,
flatten_list,
eval_element_wise,
set_socket_shape,
is_socket_linked_to_list,
is_output_socket_a_list,
sync_node_list_sockets,
group_input_can_accept_list,
is_group_output_a_list,
sync_group_node_socket_shapes,
)
from NodeSlicer.core.slice import SlicePaths
from NodeSlicer.infra.blender.blender_mesh_capture import SlicingObject, SlicingCollection, SlicingGroup
from NodeSlicer.core.nodeslicer.tree import SLICETREE_NodeTree
class TestListUtils(unittest.TestCase):
def test_is_list_data(self):
self.assertFalse(is_list_data(None))
self.assertFalse(is_list_data("a string"))
self.assertFalse(is_list_data(b"bytes"))
self.assertFalse(is_list_data(123))
self.assertFalse(is_list_data(12.34))
# SlicePaths subclasses list, but represents an atomic toolpath set, NOT a list of toolpaths
sp = SlicePaths()
self.assertFalse(is_list_data(sp))
# Normal lists and tuples
self.assertTrue(is_list_data([]))
self.assertTrue(is_list_data([1, 2, 3]))
self.assertTrue(is_list_data((1, 2)))
self.assertTrue(is_list_data([sp, sp]))
def test_flatten_list(self):
sp1 = SlicePaths()
sp2 = SlicePaths()
nested = [sp1, [sp2, None], [["gcode1"], "gcode2"]]
flattened = flatten_list(nested)
self.assertEqual(flattened, [sp1, sp2, "gcode1", "gcode2"])
def test_eval_element_wise(self):
def double(x):
return x * 2
self.assertEqual(eval_element_wise(double, 5), 10)
self.assertEqual(eval_element_wise(double, [1, 2, 3]), [2, 4, 6])
class DummySocket:
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.value = None
class DummyLink:
def __init__(self, from_socket, to_socket):
self.from_socket = from_socket
self.to_socket = to_socket
class DummySocketCollection(list):
def get(self, name, default=None):
for s in self:
if getattr(s, "name", "") == name:
return s
return default
class DummyNode:
def __init__(self, bl_idname="DummyNode"):
self.bl_idname = bl_idname
self.inputs = DummySocketCollection()
self.outputs = DummySocketCollection()
self.is_list_output = False
def add_in(self, sock):
sock.node = self
self.inputs.append(sock)
def add_out(self, sock):
sock.node = self
self.outputs.append(sock)
class TestSocketShapesAndLinks(unittest.TestCase):
def test_socket_linked_to_list(self):
upstream_node = DummyNode("SLICENODE_MeshesToList")
upstream_node.is_list_output = True
out_sock = DummySocket("Meshes", "SLICESOCK_Mesh", display_shape="LIST")
upstream_node.add_out(out_sock)
downstream_node = DummyNode("SLICENODE_SliceShells")
in_sock = DummySocket("Mesh", "SLICESOCK_Mesh", display_shape="CIRCLE")
downstream_node.add_in(in_sock)
# Before linking
self.assertFalse(is_socket_linked_to_list(in_sock))
self.assertTrue(is_output_socket_a_list(out_sock))
# Link them
link = DummyLink(out_sock, in_sock)
in_sock.is_linked = True
in_sock.links = [link]
self.assertTrue(is_socket_linked_to_list(in_sock))
def test_sync_node_list_sockets(self):
upstream_node = DummyNode("SLICENODE_MeshesToList")
out_sock = DummySocket("Meshes", "SLICESOCK_Mesh", display_shape="LIST")
upstream_node.add_out(out_sock)
node = DummyNode("SLICENODE_SliceShells")
in_sock = DummySocket("Mesh", "SLICESOCK_Mesh", display_shape="CIRCLE")
out_path = DummySocket("Paths", "SLICESOCK_Paths", display_shape="CIRCLE")
node.add_in(in_sock)
node.add_out(out_path)
link = DummyLink(out_sock, in_sock)
in_sock.is_linked = True
in_sock.links = [link]
mapping = [("Mesh", "Paths")]
sync_node_list_sockets(node, mapping)
self.assertEqual(in_sock.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_reject_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 rejects list
self.assertFalse(self.tree.valid_link(list_out, p1))
def test_valid_link_seam_placement_cutting_mesh_rejects_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 rejects list
self.assertFalse(self.tree.valid_link(list_mesh_out, m_in))
class TestNodeEvaluationsWithLists(unittest.TestCase):
def test_path_operations_with_first_input_list(self):
from NodeSlicer.core.nodeslicer.nodes.path import SLICENODE_PathOperations
node = SLICENODE_PathOperations()
node.operation = 'EXTEND'
p1 = SlicePaths([np.array([[0, 0, 0], [1, 1, 0]], dtype=np.float64)])
p2 = SlicePaths([np.array([[2, 2, 0], [3, 3, 0]], dtype=np.float64)])
q = SlicePaths([np.array([[10, 10, 0], [11, 11, 0]], dtype=np.float64)])
# Mock inputs
inp0 = DummySocket("Paths", "SLICESOCK_Paths")
inp0.is_linked = True
inp1 = DummySocket("Paths", "SLICESOCK_Paths")
inp1.is_linked = True
node.inputs = [inp0, inp1]
# resolve input mocking
def mock_resolve_input(sock, **kwargs):
if sock is inp0:
return [p1, p2] # List input
elif sock is inp1:
return q
return None
import NodeSlicer.infra.blender.blender_slice_nodes_generic as b_gen
original_resolve = b_gen.resolve_input
b_gen.resolve_input = mock_resolve_input
try:
res = node.evaluate_output("Paths")
self.assertIsInstance(res, list)
self.assertEqual(len(res), 2)
# res[0] is EXTEND of p1 and q
self.assertEqual(len(res[0]), 2)
# res[1] is EXTEND of p2 and q
self.assertEqual(len(res[1]), 2)
finally:
b_gen.resolve_input = original_resolve
def test_join_gcode_no_lists(self):
from NodeSlicer.core.nodeslicer.nodes.gcode import SLICENODE_JoinGcode
node = SLICENODE_JoinGcode()
node.resolve_multi_input = lambda label: ["G28", "G90", "M104 S200"]
res = node.evaluate_output("Gcode")
self.assertEqual(res, "G28\nG90\nM104 S200")
def test_join_gcode_one_list(self):
from NodeSlicer.core.nodeslicer.nodes.gcode import SLICENODE_JoinGcode
node = SLICENODE_JoinGcode()
node.resolve_multi_input = lambda label: ["START", ["LAYER1", "LAYER2", "LAYER3"], "END"]
res = node.evaluate_output("Gcode")
self.assertEqual(res, [
"START\nLAYER1\nEND",
"START\nLAYER2\nEND",
"START\nLAYER3\nEND",
])
def test_join_gcode_multiple_lists_zipped(self):
from NodeSlicer.core.nodeslicer.nodes.gcode import SLICENODE_JoinGcode
node = SLICENODE_JoinGcode()
node.resolve_multi_input = lambda label: [
"INIT",
["A1", "A2", "A3"],
"MID",
["B1", "B2", "B3"],
"FINAL",
]
res = node.evaluate_output("Gcode")
self.assertEqual(res, [
"INIT\nA1\nMID\nB1\nFINAL",
"INIT\nA2\nMID\nB2\nFINAL",
"INIT\nA3\nMID\nB3\nFINAL",
])
def test_join_gcode_mismatched_lengths_raises_error(self):
from NodeSlicer.core.nodeslicer.nodes.gcode import SLICENODE_JoinGcode
node = SLICENODE_JoinGcode()
node.resolve_multi_input = lambda label: [["A1", "A2"], ["B1", "B2", "B3"]]
with self.assertRaises(ValueError):
node.evaluate_output("Gcode")
def test_join_gcode_socket_shape_updates(self):
from NodeSlicer.core.nodeslicer.nodes.gcode import SLICENODE_JoinGcode
node = SLICENODE_JoinGcode()
in0 = DummySocket("Gcode", "NodeSocketString", display_shape="CIRCLE")
out0 = DummySocket("Gcode", "NodeSocketString", display_shape="CIRCLE")
node.inputs = DummySocketCollection([in0])
node.outputs = DummySocketCollection([out0])
# Initially without list link
node.update()
self.assertEqual(out0.display_shape, "CIRCLE")
# Linked to list
upstream = DummyNode("SLICENODE_GcodesToList")
upstream.is_list_output = True
up_out = DummySocket("Gcode", "NodeSocketString", display_shape="LIST")
upstream.add_out(up_out)
link = DummyLink(up_out, in0)
in0.is_linked = True
in0.links = [link]
node.update()
self.assertEqual(out0.display_shape, "LIST")
def test_gcode_regex_replace_list(self):
from NodeSlicer.core.nodeslicer.nodes.gcode import SLICENODE_GcodeRegexReplace
node = SLICENODE_GcodeRegexReplace()
node.resolve = lambda field: {
"Gcode": ["G1 X10 Y10", "G1 X20 Y20"],
"Find": "G1",
"Replace": "G0"
}.get(field)
res = node.evaluate_output("Gcode")
self.assertEqual(res, ["G0 X10 Y10", "G0 X20 Y20"])
def test_list_joiners_evaluation(self):
from NodeSlicer.core.nodeslicer.nodes.inputs import SLICENODE_ObjectsToList
from NodeSlicer.core.nodeslicer.nodes.mesh import SLICENODE_MeshesToList
from NodeSlicer.core.nodeslicer.nodes.path import SLICENODE_PathsToList
from NodeSlicer.core.nodeslicer.nodes.gcode import SLICENODE_GcodesToList
# ObjectsToList
obj1 = DummyObject("Obj1")
obj2 = DummyObject("Obj2")
o_node = SLICENODE_ObjectsToList()
o_node.resolve_multi_input = lambda label: [[obj1], obj2]
self.assertEqual(o_node.evaluate_output("Objects"), [obj1, obj2])
# MeshesToList
sc1 = SlicingCollection([], "M1")
sc2 = SlicingCollection([], "M2")
m_node = SLICENODE_MeshesToList()
m_node.resolve_multi_input = lambda label: [sc1, [sc2]]
self.assertEqual(m_node.evaluate_output("Meshes"), [sc1, sc2])
# PathsToList
sp1 = SlicePaths([np.array([[0, 0, 0], [1, 1, 0]], dtype=np.float64)])
sp2 = SlicePaths([np.array([[2, 2, 0], [3, 3, 0]], dtype=np.float64)])
p_node = SLICENODE_PathsToList()
p_node.resolve_multi_input = lambda label: [sp1, [sp2]]
self.assertEqual(p_node.evaluate_output("Paths"), [sp1, sp2])
# GcodesToList
g_node = SLICENODE_GcodesToList()
g_node.resolve_multi_input = lambda label: [["G1 X1"], "G1 X2"]
self.assertEqual(g_node.evaluate_output("Gcode"), ["G1 X1", "G1 X2"])
def test_join_mesh_flattens_lists(self):
from NodeSlicer.core.nodeslicer.nodes.mesh import SLICENODE_JoinMesh
node = SLICENODE_JoinMesh()
so1 = SlicingObject()
so1.name = "part1"
so1.mesh = np.ones((1, 4, 3), dtype=np.float64)
sc1 = SlicingCollection([], "Coll1")
sc1.objects = [so1]
so2 = SlicingObject()
so2.name = "part2"
so2.mesh = np.ones((2, 4, 3), dtype=np.float64)
sc2 = SlicingCollection([], "Coll2")
sc2.objects = [so2]
so3 = SlicingObject()
so3.name = "part3"
so3.mesh = np.ones((3, 4, 3), dtype=np.float64)
sc3 = SlicingCollection([], "Coll3")
sc3.objects = [so3]
# Mock inputs delivering [ [sc1, sc2], sc3 ]
mock_in1 = DummySocket("Mesh", "SLICESOCK_Mesh")
mock_in1.is_linked = True
mock_in2 = DummySocket("Mesh", "SLICESOCK_Mesh")
mock_in2.is_linked = True
node.inputs = [mock_in1, mock_in2]
import NodeSlicer.infra.blender.blender_slice_nodes_generic as b_gen
original_resolve = b_gen.resolve_input
b_gen.resolve_input = lambda sock, **kw: [sc1, sc2] if sock is mock_in1 else sc3
try:
res = node.evaluate_output("Mesh")
self.assertIsInstance(res, SlicingCollection)
self.assertEqual(len(res.objects), 3)
self.assertEqual({o.name for o in res.objects}, {"part1", "part2", "part3"})
finally:
b_gen.resolve_input = original_resolve
def test_drop_geometry_list_evaluation(self):
from NodeSlicer.core.nodeslicer.nodes.mesh import SLICENODE_DropGeometry
node = SLICENODE_DropGeometry()
so1 = SlicingObject()
so1.name = "part1"
so1.mesh = np.array([[[0, 0, 5], [1, 0, 5], [0, 1, 5], [0, 0, 1]]], dtype=np.float64)
sc1 = SlicingCollection([], "M1")
sc1.objects = [so1]
so2 = SlicingObject()
so2.name = "part2"
so2.mesh = np.array([[[0, 0, 2], [1, 0, 2], [0, 1, 2], [0, 0, 1]]], dtype=np.float64)
sc2 = SlicingCollection([], "M2")
sc2.objects = [so2]
node.resolve = lambda field: [sc1, sc2] if field == "Mesh" else 0.0
res_mesh = node.evaluate_output("Mesh")
res_trans = node.evaluate_output("Translation")
self.assertIsInstance(res_mesh, list)
self.assertEqual(len(res_mesh), 2)
self.assertIsInstance(res_trans, list)
self.assertEqual(res_trans, [(0.0, 0.0, -5.0), (0.0, 0.0, -2.0)])
def test_start_print_rejects_list_in_evaluate(self):
from NodeSlicer.core.nodeslicer.nodes.output import SLICENODE_StartPrint
node = SLICENODE_StartPrint()
node.resolve = lambda field: ["G1 X0", "G1 X10"] if field == "Gcode" else None
op = MagicMock()
res = node.evaluate(operator=op)
self.assertEqual(res, 'error')
op.report.assert_called_with({'ERROR'}, "Start Print does not accept a list of Gcode")
class DummyInterfaceItem:
def __init__(self, name, identifier, in_out="INPUT", socket_type="SLICESOCK_Paths"):
self.name = name
self.identifier = identifier
self.in_out = in_out
self.socket_type = socket_type
self.item_type = 'SOCKET'
class DummyInterface:
def __init__(self, items):
self.items_tree = items
class DummyGroupTree:
def __init__(self, items=None, nodes=None):
self.interface = DummyInterface(items or [])
self.nodes = nodes or []
class DummyGroupNode(DummyNode):
def __init__(self, name="Group", tree=None):
super().__init__("SLICENODE_Group")
self.name = name
self.slicer_tree = tree
class TestGroupNodeListShapes(unittest.TestCase):
def test_group_input_can_accept_list_detection(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])
# Paths input can accept list because target is BufferShells
self.assertTrue(group_input_can_accept_list(tree, item_paths))
# FloatVal cannot accept list because socket type is Float
self.assertFalse(group_input_can_accept_list(tree, item_float))
def test_group_input_connected_to_forbidden_node_rejects_list(self):
item_gcode = DummyInterfaceItem("Gcode", "sock_gcode", in_out="INPUT", socket_type="NodeSocketString")
gin = DummyNode("NodeGroupInput")
gin_out_gcode = DummySocket("Gcode", "NodeSocketString")
gin_out_gcode.identifier = "sock_gcode"
gin.add_out(gin_out_gcode)
sp_node = DummyNode("SLICENODE_StartPrint")
sp_in = DummySocket("Gcode", "NodeSocketString")
sp_node.add_in(sp_in)
link = DummyLink(gin_out_gcode, sp_in)
gin_out_gcode.is_linked = True
gin_out_gcode.links = [link]
tree = DummyGroupTree([item_gcode], [gin, sp_node])
# StartPrint forbids lists, so group input must NOT accept list
self.assertFalse(group_input_can_accept_list(tree, item_gcode))
def test_group_output_inherent_list(self):
item_out = DummyInterfaceItem("Paths", "sock_out_paths", in_out="OUTPUT", socket_type="SLICESOCK_Paths")
gout = DummyNode("NodeGroupOutput")
gout_in = DummySocket("Paths", "SLICESOCK_Paths")
gout_in.identifier = "sock_out_paths"
gout.add_in(gout_in)
list_joiner = DummyNode("SLICENODE_PathsToList")
list_joiner.is_list_output = True
lj_out = DummySocket("Paths", "SLICESOCK_Paths", display_shape="LIST")
list_joiner.add_out(lj_out)
link = DummyLink(lj_out, gout_in)
gout_in.is_linked = True
gout_in.links = [link]
tree = DummyGroupTree([item_out], [gout, list_joiner])
# Output fed from PathsToList is always a list
self.assertTrue(is_group_output_a_list(None, tree, item_out))
def test_group_output_element_wise_propagation_and_sync(self):
item_in = DummyInterfaceItem("Paths", "sock_in", in_out="INPUT", socket_type="SLICESOCK_Paths")
item_out = DummyInterfaceItem("Paths", "sock_out", in_out="OUTPUT", socket_type="SLICESOCK_Paths")
gin = DummyNode("NodeGroupInput")
gin_out = DummySocket("Paths", "SLICESOCK_Paths")
gin_out.identifier = "sock_in"
gin.add_out(gin_out)
buf_node = DummyNode("SLICENODE_BufferShells")
buf_node.list_socket_mapping = [("Paths", "Paths")]
buf_in = DummySocket("Paths", "SLICESOCK_Paths")
buf_out = DummySocket("Paths", "SLICESOCK_Paths")
buf_node.add_in(buf_in)
buf_node.add_out(buf_out)
gout = DummyNode("NodeGroupOutput")
gout_in = DummySocket("Paths", "SLICESOCK_Paths")
gout_in.identifier = "sock_out"
gout.add_in(gout_in)
link1 = DummyLink(gin_out, buf_in)
gin_out.is_linked = True
gin_out.links = [link1]
buf_in.is_linked = True
buf_in.links = [link1]
link2 = DummyLink(buf_out, gout_in)
buf_out.is_linked = True
buf_out.links = [link2]
gout_in.is_linked = True
gout_in.links = [link2]
tree = DummyGroupTree([item_in, item_out], [gin, buf_node, gout])
grp_node = DummyGroupNode("MyGroup", tree)
grp_in = DummySocket("Paths", "SLICESOCK_Paths", display_shape="CIRCLE")
grp_in.identifier = "sock_in"
grp_out = DummySocket("Paths", "SLICESOCK_Paths", display_shape="CIRCLE")
grp_out.identifier = "sock_out"
grp_node.add_in(grp_in)
grp_node.add_out(grp_out)
# Before external link:
# Input should be LIST (because list input is possible)
# Output should be CIRCLE (because no list is fed)
sync_group_node_socket_shapes(grp_node)
self.assertEqual(grp_in.display_shape, "LIST")
self.assertEqual(grp_out.display_shape, "CIRCLE")
# Now link a list to the group's input externally:
up_node = DummyNode("SLICENODE_PathsToList")
up_node.is_list_output = True
up_out = DummySocket("Paths", "SLICESOCK_Paths", display_shape="LIST")
up_node.add_out(up_out)
ext_link = DummyLink(up_out, grp_in)
grp_in.is_linked = True
grp_in.links = [ext_link]
# After external list link:
# Output should now become LIST!
sync_group_node_socket_shapes(grp_node)
self.assertEqual(grp_in.display_shape, "LIST")
self.assertEqual(grp_out.display_shape, "LIST")
def test_valid_link_with_group_nodes(self):
item_paths = DummyInterfaceItem("Paths", "sock_paths", in_out="INPUT", socket_type="SLICESOCK_Paths")
item_float = DummyInterfaceItem("FloatVal", "sock_float", in_out="INPUT", socket_type="NodeSocketFloat")
gin = DummyNode("NodeGroupInput")
gin_out_paths = DummySocket("Paths", "SLICESOCK_Paths")
gin_out_paths.identifier = "sock_paths"
gin_out_float = DummySocket("FloatVal", "NodeSocketFloat")
gin_out_float.identifier = "sock_float"
gin.add_out(gin_out_paths)
gin.add_out(gin_out_float)
target_node = DummyNode("SLICENODE_BufferShells")
target_node.list_socket_mapping = [("Paths", "Paths")]
target_in_paths = DummySocket("Paths", "SLICESOCK_Paths")
target_node.add_in(target_in_paths)
link = DummyLink(gin_out_paths, target_in_paths)
gin_out_paths.is_linked = True
gin_out_paths.links = [link]
tree = DummyGroupTree([item_paths, item_float], [gin, target_node])
grp_node = DummyGroupNode("Group", tree)
grp_in_paths = DummySocket("Paths", "SLICESOCK_Paths")
grp_in_paths.identifier = "sock_paths"
grp_in_float = DummySocket("FloatVal", "NodeSocketFloat")
grp_in_float.identifier = "sock_float"
grp_node.add_in(grp_in_paths)
grp_node.add_in(grp_in_float)
list_out = DummySocket("Paths", "SLICESOCK_Paths", display_shape="LIST")
validator = SLICETREE_NodeTree()
# Linking list to Paths group input (which accepts list): allowed
self.assertTrue(validator.valid_link(list_out, grp_in_paths))
# Linking list to FloatVal group input: rejected
self.assertFalse(validator.valid_link(list_out, grp_in_float))
if __name__ == '__main__':
unittest.main()