This commit is contained in:
Nicolas Predella 2026-08-06 10:50:50 +03:00
parent 02175f379a
commit 30f9d88e4c
9 changed files with 405 additions and 54 deletions

View file

@ -444,23 +444,157 @@ def _copy_slicing_group(sg: SlicingGroup) -> SlicingGroup:
return new_sg
def _transform_slice_paths(sp: SlicePaths, t_arr: np.ndarray, r_arr: np.ndarray, s_arr: np.ndarray) -> SlicePaths:
new_paths = []
has_scale = any(s != 1.0 for s in s_arr)
has_rot = any(r != 0.0 for r in r_arr)
has_trans = any(t != 0.0 for t in t_arr)
if not (has_scale or has_rot or has_trans):
return sp.copy_with(paths=list(sp))
R = None
if has_rot:
rx, ry, rz = r_arr
cx, sx = np.cos(rx), np.sin(rx)
cy, sy = np.cos(ry), np.sin(ry)
cz, sz = np.cos(rz), np.sin(rz)
Rx = np.array([
[1.0, 0.0, 0.0],
[0.0, cx, -sx],
[0.0, sx, cx]
], dtype=np.float64)
Ry = np.array([
[cy, 0.0, sy],
[0.0, 1.0, 0.0],
[-sy, 0.0, cy]
], dtype=np.float64)
Rz = np.array([
[cz, -sz, 0.0],
[sz, cz, 0.0],
[0.0, 0.0, 1.0]
], dtype=np.float64)
R = Rz @ (Ry @ Rx)
for p in sp:
if len(p) == 0:
new_paths.append(p.copy())
continue
new_p = p.astype(np.float64, copy=True)
dim = new_p.shape[1] if new_p.ndim > 1 else 0
if has_scale and dim > 0:
if dim >= 3:
new_p[:, :3] *= s_arr
else:
new_p[:, :dim] *= s_arr[:dim]
if has_rot and R is not None and dim > 0:
if dim >= 3:
new_p[:, :3] = new_p[:, :3] @ R.T
elif dim == 2:
rot_2d = R[:2, :2]
new_p[:, :2] = new_p[:, :2] @ rot_2d.T
if has_trans and dim > 0:
if dim >= 3:
new_p[:, :3] += t_arr
else:
new_p[:, :dim] += t_arr[:dim]
new_paths.append(new_p)
return sp.copy_with(paths=new_paths)
def _transform_geometry_item(geom: Any, t_arr: np.ndarray, r_arr: np.ndarray, s_arr: np.ndarray, t_rec: dict) -> Any:
if geom is None:
return None
elif isinstance(geom, SlicePaths):
copied_data = _transform_slice_paths(geom, t_arr, r_arr, s_arr)
if not hasattr(copied_data, "transforms") or copied_data.transforms is None:
copied_data.transforms = []
else:
copied_data.transforms = [dict(t) for t in copied_data.transforms]
copied_data.transforms.append(t_rec)
return copied_data
elif isinstance(geom, (list, tuple)):
return [_transform_geometry_item(item, t_arr, r_arr, s_arr, t_rec) for item in geom]
elif isinstance(geom, SlicingGroup):
copied_data = _copy_slicing_group(geom)
for col in copied_data.collections.values():
if any(s != 1.0 for s in s_arr):
col.scale(s_arr)
if any(r != 0.0 for r in r_arr):
col.rotate(r_arr)
if any(t != 0.0 for t in t_arr):
col.offset(t_arr)
if any(t != 0.0 for t in t_arr):
copied_data.wipe_tower_xy = copied_data.wipe_tower_xy + t_arr[0:2]
if any(r != 0.0 for r in r_arr):
rz = r_arr[2]
cz, sz = np.cos(rz), np.sin(rz)
rot_mat_2d = np.array([[cz, -sz], [sz, cz]])
copied_data.wipe_tower_xy = copied_data.wipe_tower_xy @ rot_mat_2d.T
import math
copied_data.wipe_tower_rotation_deg = round(copied_data.wipe_tower_rotation_deg + rz * 180 / math.pi, 5)
if any(s != 1.0 for s in s_arr):
copied_data.wipe_tower_xy *= s_arr[0:2]
if not hasattr(copied_data, "transforms") or copied_data.transforms is None:
copied_data.transforms = []
copied_data.transforms.append(t_rec)
return copied_data
elif isinstance(geom, SlicingCollection):
copied_data = _copy_slicing_collection(geom)
if any(s != 1.0 for s in s_arr):
copied_data.scale(s_arr)
if any(r != 0.0 for r in r_arr):
copied_data.rotate(r_arr)
if any(t != 0.0 for t in t_arr):
copied_data.offset(t_arr)
if not hasattr(copied_data, "transforms") or copied_data.transforms is None:
copied_data.transforms = []
copied_data.transforms.append(t_rec)
return copied_data
elif isinstance(geom, SlicingObject):
copied_data = _copy_slicing_object(geom)
if any(s != 1.0 for s in s_arr):
copied_data.scale(s_arr)
if any(r != 0.0 for r in r_arr):
copied_data.rotate(r_arr)
if any(t != 0.0 for t in t_arr):
copied_data.offset(t_arr)
if not hasattr(copied_data, "transforms") or copied_data.transforms is None:
copied_data.transforms = []
copied_data.transforms.append(t_rec)
return copied_data
return geom
@register_class
class SLICENODE_TransformGeometry(SliceNode):
bl_label = "Transform Geometry"
def init(self, context):
self.add_input("SLICESOCK_Mesh", "Mesh")
self.add_input("SLICESOCK_Geometry", "Geometry")
self.add_input("SLICESOCK_Vector", "Translation")
self.add_input("SLICESOCK_VectorRotation", "Rotation")
self.add_input("SLICESOCK_Vector", "Scale").value = (1.0, 1.0, 1.0)
self.add_output("SLICESOCK_Mesh", "Mesh")
self.add_output("SLICESOCK_Geometry", "Geometry")
def evaluate_output(self, socket_name: str, **_kwargs) -> SlicingCollection | SlicingGroup | None:
if socket_name != "Mesh":
def evaluate_output(self, socket_name: str, **_kwargs) -> Any:
if socket_name not in ("Geometry", "Mesh"):
return None
mesh_data: SlicingCollection | SlicingGroup | None = self.resolve("Mesh")
if not mesh_data:
geom_data: Any = self.resolve("Geometry")
if geom_data is None:
geom_data = self.resolve("Mesh")
if geom_data is None:
return None
translation = self.resolve("Translation") or (0.0, 0.0, 0.0)
@ -471,47 +605,14 @@ class SLICENODE_TransformGeometry(SliceNode):
r_arr = np.array(rotation, dtype=np.float64)
s_arr = np.array(scale, dtype=np.float64)
if isinstance(mesh_data, SlicingGroup):
copied_data = _copy_slicing_group(mesh_data)
for col in copied_data.collections.values():
if any(s != 1.0 for s in s_arr):
col.scale(s_arr)
if any(r != 0.0 for r in r_arr):
col.rotate(r_arr)
if any(t != 0.0 for t in t_arr):
col.offset(t_arr)
# Also offset wipe tower location
if any(t != 0.0 for t in t_arr):
copied_data.wipe_tower_xy = copied_data.wipe_tower_xy + t_arr[0:2]
if any(r != 0.0 for r in r_arr):
rz = r_arr[2]
cz, sz = np.cos(rz), np.sin(rz)
rot_mat_2d = np.array([[cz, -sz], [sz, cz]])
copied_data.wipe_tower_xy = copied_data.wipe_tower_xy @ rot_mat_2d.T
import math
copied_data.wipe_tower_rotation_deg = round(copied_data.wipe_tower_rotation_deg + rz * 180 / math.pi, 5)
if any(s != 1.0 for s in s_arr):
copied_data.wipe_tower_xy *= s_arr[0:2]
else:
copied_data = _copy_slicing_collection(mesh_data)
if any(s != 1.0 for s in s_arr):
copied_data.scale(s_arr)
if any(r != 0.0 for r in r_arr):
copied_data.rotate(r_arr)
if any(t != 0.0 for t in t_arr):
copied_data.offset(t_arr)
t_rec = {
"type": "transform_geometry",
"translation": (float(t_arr[0]), float(t_arr[1]), float(t_arr[2])),
"rotation": (float(r_arr[0]), float(r_arr[1]), float(r_arr[2])),
"scale": (float(s_arr[0]), float(s_arr[1]), float(s_arr[2])),
}
if not hasattr(copied_data, "transforms"):
copied_data.transforms = []
copied_data.transforms.append(t_rec)
return copied_data
return _transform_geometry_item(geom_data, t_arr, r_arr, s_arr, t_rec)
@register_class

View file

@ -461,10 +461,10 @@ class SLICENODE_MeshToPdf(ExecSliceNode):
def evaluate(self, operator=None):
import datetime
from ...pdf_exporter import extract_unique_top_view_edges, export_mesh_to_pdf
from ...pdf_exporter import extract_unique_top_view_edges, export_mesh_to_pdf, get_first_object_name
geom = self.resolve("Geometry")
slice_paths = self.resolve("Paths") or self.resolve("SlicePaths")
slice_paths = self.resolve("Paths")
folder: str = self.resolve("Folder") or ""
# Collect raw geometry items
@ -556,6 +556,16 @@ class SLICENODE_MeshToPdf(ExecSliceNode):
current_raw_geom = None
current_slice_paths = target_item
current_header_info = dict(header_info)
part_name_prop = (self.header_part_name or "").strip()
if not part_name_prop:
default_part_name = (
get_first_object_name(target_item)
or get_first_object_name(current_raw_geom)
or get_first_object_name(current_slice_paths)
)
current_header_info["part_name"] = default_part_name
edges_2d = np.empty((0, 2, 2), dtype=np.float64)
if current_raw_geom is not None:
raw_e = extract_unique_top_view_edges(current_raw_geom)
@ -580,7 +590,7 @@ class SLICENODE_MeshToPdf(ExecSliceNode):
draw_bbox=self.draw_bbox,
draw_sa2_holes=self.draw_sa2_holes,
draw_header=self.draw_header,
header_info=header_info,
header_info=current_header_info,
crease_angle=self.crease_angle,
margin_mm=self.margin,
font_size_mm=self.font_size * 2.834645669291339,

View file

@ -231,16 +231,21 @@ class SLICENODE_GcodeToMesh(ExecSliceNode):
mod["Socket_9"] = tuple(map(float, prep_scale))
def evaluate(self, operator=None):
gcodes = self.resolve_multi_input("Gcode")
gcodes: list[str] = [g for g in (self.resolve_multi_input("Gcode") or []) if g]
base_mesh_name: str = self.resolve("Name")
if not base_mesh_name:
base_mesh_name = "GcodePreview"
for i, gcode in enumerate(gcodes):
if not gcode: continue
total_parts = len(gcodes)
num_digits = max(2, len(str(total_parts)))
for i, gcode in enumerate(gcodes):
if total_parts > 1:
mesh_name = f"{base_mesh_name}{i+1:0{num_digits}d}"
else:
mesh_name = base_mesh_name
mesh_name = base_mesh_name if i == 0 else f"{base_mesh_name}_{i+1}"
try:
self._process_gcode(gcode, mesh_name, operator)
except Exception as e:

View file

@ -20,6 +20,7 @@ from .geometry import (
)
from .metadata import (
get_first_attribute_value,
get_first_object_name,
extract_settings_items,
extract_object_label_info,
extract_object_name_positions,

View file

@ -59,6 +59,55 @@ def get_first_attribute_value(so_item: Any, attr_name: str) -> Any:
return None
def get_first_object_name(mesh_data: Any) -> str:
"""
Extracts the name of the first SlicingObject, SlicePaths, SlicingCollection,
or Blender Object in mesh_data.
"""
from ...infra.blender.blender_mesh_capture import SlicingGroup, SlicingCollection, SlicingObject
from ..slice import SlicePaths
if mesh_data is None:
return ""
if isinstance(mesh_data, (SlicingObject, SlicePaths)):
if getattr(mesh_data, "name", ""):
return str(mesh_data.name)
return ""
if isinstance(mesh_data, (list, tuple)) and not isinstance(mesh_data, SlicePaths):
for sub in mesh_data:
name = get_first_object_name(sub)
if name:
return name
return ""
if isinstance(mesh_data, SlicingGroup) and mesh_data.collections:
for key, coll in mesh_data.collections.items():
name = get_first_object_name(coll)
if name:
return name
if key:
return str(key)
return ""
if isinstance(mesh_data, SlicingCollection):
if mesh_data.objects:
for so in mesh_data.objects:
name = get_first_object_name(so)
if name:
return name
if getattr(mesh_data, "name", ""):
return str(mesh_data.name)
return ""
if hasattr(mesh_data, "name") and getattr(mesh_data, "name"):
return str(getattr(mesh_data, "name"))
return ""
def resolve_object_properties(so_item: Any, props: list[tuple[str, str]]) -> list[tuple[str, str]]:
"""
Resolves property tuples for a SlicingObject.

View file

@ -96,7 +96,8 @@ def render_technical_header_table(
# Extract field values with fallbacks
company_val = str(header_info.get("company", "") or "-")
part_name_val = str(header_info.get("part_name", "") or obj_name or "-")
raw_part_name = header_info.get("part_name", "")
part_name_val = str((raw_part_name or "").strip() or obj_name or "-")
dwg_val = str(header_info.get("drawing_number", "") or "-")
author_val = str(header_info.get("author", "") or "-")
rev_val = str(header_info.get("revision", "") or "A")
@ -647,11 +648,16 @@ class PDFHeaderTableElement(PDFElement):
if not exp.draw_header or not exp.header_info:
return
from .metadata import get_first_object_name
primary_obj_name = ""
if exp.slicing_objs:
primary_obj_name = str(getattr(exp.slicing_objs[0], "name", ""))
elif exp.raw_geometry is not None and hasattr(exp.raw_geometry, "name"):
primary_obj_name = str(getattr(exp.raw_geometry, "name", ""))
for so in exp.slicing_objs:
if hasattr(so, "name") and getattr(so, "name"):
primary_obj_name = str(getattr(so, "name"))
break
if not primary_obj_name:
primary_obj_name = get_first_object_name(exp.raw_geometry) or get_first_object_name(exp.slice_paths)
render_technical_header_table(
s=s,

View file

@ -70,8 +70,8 @@ All nodes are found in the **Add (Shift+A)** menu under the **SliceNodes** categ
- **Assign Property to Object**: Assigns custom settings/properties to geometry (`SlicingObject`, `SlicingCollection`, `SlicingGroup`, or `SlicePaths`) for rendering in PDF properties tables.
- **Geometry**: Generic geometry input socket (accepts `SLICESOCK_Mesh`, `SLICESOCK_Paths`, or `SLICESOCK_Geometry`).
- **Settings**: Input socket accepting configuration settings or property items.
- **Transform Geometry**: Moves, rotates, and scales a `SlicingCollection` or `SlicingGroup` relative to the origin.
- **Mesh**: Input mesh data.
- **Transform Geometry**: Moves, rotates, and scales geometry (`SlicingObject`, `SlicingCollection`, `SlicingGroup`, or `SlicePaths`) relative to the origin.
- **Geometry**: Input geometry data.
- **Translation**: 3D vector specifying the translation offset.
- **Rotation**: 3D vector/Euler rotation values.
- **Scale**: 3D vector specifying scaling factors along the X, Y, and Z axes (defaults to `1.0, 1.0, 1.0`).
@ -128,7 +128,7 @@ All nodes are found in the **Add (Shift+A)** menu under the **SliceNodes** categ
- **Gcode to File**: Writes G-code string to a file on disk.
- **Automatic Binary Conversion**: If the G-code contains the comment `; binary_gcode = 1`, the node automatically converts the output to Prusa's binary G-code format (`.bgcode`) using MeatPack encoding and Heatshrink compression.
- **Mesh to PDF**: Generates a 1:1 true-scale 2D top-view (+Z orthographic) vector PDF from a `SlicingGroup` or `SlicingCollection` with a custom grid and background color. Subpanel menus are collapsed by default.
- **Technical Drawing Header**: Collapsible menu containing string inputs for technical drawing info (Company, Part Name, Drawing No., Author, Revision, Material, Tolerance). Automatically computes ISO date and scale length (`1:1`). Renders an ISO/DIN technical drawing header table on the bottom-right of the drawing.
- **Technical Drawing Header**: Collapsible menu containing string inputs for technical drawing info (Company, Part Name, Drawing No., Author, Revision, Material, Tolerance). If Part Name is left empty, it automatically defaults to the name of the first object being rendered. Automatically computes ISO date and scale length (`1:1`). Renders an ISO/DIN technical drawing header table on the bottom-right of the drawing.
- **Geometry**: Input socket accepting a single geometry (`SlicingGroup`, `SlicingCollection`, or `SlicingObject`). Join multiple geometries using Join Geometry nodes.
- **SlicePaths**: Input socket accepting `SlicePaths` (reserved for path rendering).
- **Folder**: Target folder or PDF file path on disk.

View file

@ -1427,6 +1427,83 @@ class TestPdfExporter(unittest.TestCase):
self.assertIn(" m ", content)
def test_get_first_object_name(self):
from NodeSlicer.core.pdf_exporter import get_first_object_name
from NodeSlicer.infra.blender.blender_mesh_capture import SlicingObject, SlicingGroup, SlicingCollection
from NodeSlicer.core.slice import SlicePaths
so = SlicingObject()
so.name = "FirstObj"
self.assertEqual(get_first_object_name(so), "FirstObj")
sp = SlicePaths()
sp.name = "PathObj"
self.assertEqual(get_first_object_name(sp), "PathObj")
self.assertEqual(get_first_object_name([None, so]), "FirstObj")
coll = SlicingCollection.__new__(SlicingCollection)
coll.objects = [so]
coll.name = "CollName"
group = SlicingGroup.__new__(SlicingGroup)
group.collections = {"GroupKey": coll}
self.assertEqual(get_first_object_name(group), "FirstObj")
def test_mesh_to_pdf_default_part_name_first_object(self):
from NodeSlicer.core.nodeslicer.nodes.output import SLICENODE_MeshToPdf
from NodeSlicer.infra.blender.blender_mesh_capture import SlicingObject
node = SLICENODE_MeshToPdf.__new__(SLICENODE_MeshToPdf)
node.draw_wireframe = True
node.draw_boundary = False
node.draw_contour = False
node.draw_crease = False
node.crease_angle = 30.0
node.draw_bbox = False
node.draw_sa2_holes = False
node.draw_header = True
node.header_company = ""
node.header_part_name = "" # Empty field -> should default to object name
node.header_drawing_number = ""
node.header_author = ""
node.header_revision = "A"
node.header_material = ""
node.header_tolerance = ""
node.font_size = 0.0
node.margin = 10.0
node.wire_color = (0.0, 0.0, 0.0)
node.bg_color = (1.0, 1.0, 1.0)
node.bbox_color = (0.0, 0.0, 1.0)
class MockOperator:
def __init__(self):
self.reports = []
def report(self, type_set, message):
self.reports.append((type_set, message))
op = MockOperator()
so = SlicingObject()
so.name = "GearWheel"
so.mesh = np.array([[[0.0, 0.0, 0.0], [10.0, 0.0, 0.0], [0.0, 10.0, 0.0]]], dtype=np.float64)
with tempfile.TemporaryDirectory() as tmpdir:
pdf_file = os.path.join(tmpdir, "output.pdf")
resolved_inputs = {
"Geometry": so,
"Paths": None,
"Folder": pdf_file,
"Settings": None,
}
node.resolve = lambda fieldname: resolved_inputs.get(fieldname)
res = node.evaluate(operator=op)
self.assertEqual(res, "ok")
self.assertTrue(os.path.exists(pdf_file))
with open(pdf_file, "rb") as f:
content = decompress_pdf_streams(f.read())
self.assertIn("(GearWheel) Tj", content)
if __name__ == '__main__':
unittest.main()

View file

@ -201,6 +201,66 @@ class TestTransform(unittest.TestCase):
# v0 becomes [6.0, 22.0, 36.0]
np.testing.assert_allclose(result_sc.objects[0].mesh[0, 0], [6.0, 22.0, 36.0], atol=1e-7)
def test_transform_node_slice_paths(self):
from NodeSlicer.core.nodeslicer.nodes.mesh import SLICENODE_TransformGeometry
from NodeSlicer.core.slice import SlicePaths
path = np.array([[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]], dtype=np.float64)
sp = SlicePaths([path])
node = SLICENODE_TransformGeometry()
node.resolve = lambda f: {
"Geometry": sp,
"Translation": (10.0, 20.0, 30.0),
"Rotation": (0.0, 0.0, np.pi / 2.0),
"Scale": (2.0, 2.0, 2.0)
}.get(f)
result_sp = node.evaluate_output("Geometry")
self.assertIsNotNone(result_sp)
self.assertIsInstance(result_sp, SlicePaths)
self.assertIsNot(result_sp, sp)
# Point 1: [1, 2, 3] -> scale [2, 4, 6] -> rot Z pi/2 [-4, 2, 6] -> trans [6, 22, 36]
np.testing.assert_allclose(result_sp[0][0], [6.0, 22.0, 36.0], atol=1e-7)
# Point 2: [4, 5, 6] -> scale [8, 10, 12] -> rot Z pi/2 [-10, 8, 12] -> trans [0, 28, 42]
np.testing.assert_allclose(result_sp[0][1], [0.0, 28.0, 42.0], atol=1e-7)
# Check transforms metadata
self.assertEqual(len(result_sp.transforms), 1)
self.assertEqual(result_sp.transforms[0]["type"], "transform_geometry")
def test_transform_node_list_input(self):
from NodeSlicer.core.nodeslicer.nodes.mesh import SLICENODE_TransformGeometry
from NodeSlicer.core.slice import SlicePaths
so = SlicingObject()
so.mesh = np.array([[[1.0, 2.0, 3.0], [4.0, 5.0, 6.0], [7.0, 8.0, 9.0], [0.0, 0.0, 1.0]]], dtype=np.float64)
sc = SlicingCollection([], "coll")
sc.objects = [so]
path = np.array([[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]], dtype=np.float64)
sp = SlicePaths([path])
geom_list = [sc, sp]
node = SLICENODE_TransformGeometry()
node.resolve = lambda f: {
"Geometry": geom_list,
"Translation": (10.0, 0.0, 0.0),
"Rotation": (0.0, 0.0, 0.0),
"Scale": (1.0, 1.0, 1.0)
}.get(f)
result_list = node.evaluate_output("Geometry")
self.assertIsInstance(result_list, list)
self.assertEqual(len(result_list), 2)
self.assertIsInstance(result_list[0], SlicingCollection)
self.assertIsInstance(result_list[1], SlicePaths)
np.testing.assert_allclose(result_list[0].objects[0].mesh[0, 0], [11.0, 2.0, 3.0])
np.testing.assert_allclose(result_list[1][0][0], [11.0, 2.0, 3.0])
def test_drop_geometry_node(self):
from NodeSlicer.core.nodeslicer.nodes.mesh import SLICENODE_DropGeometry
@ -364,6 +424,48 @@ class TestTransform(unittest.TestCase):
self.assertEqual(tuple(mock_mod_dict.get("Socket_6")), (0.15, 0.25, 0.35))
self.assertEqual(tuple(mock_mod_dict.get("Socket_9")), (1.5, 2.0, 2.5))
def test_gcode_to_mesh_naming_convention(self):
from NodeSlicer.core.nodeslicer.nodes.preview import SLICENODE_GcodeToMesh
node = SLICENODE_GcodeToMesh()
# 1. Single part - no number suffix
node.resolve_multi_input = MagicMock(return_value=["GCODE1"])
node.resolve = MagicMock(return_value="Part")
node._process_gcode = MagicMock()
node.evaluate()
node._process_gcode.assert_called_once_with("GCODE1", "Part", None)
# 2. Three parts - padded to 2 digits (01, 02, 03)
node.resolve_multi_input = MagicMock(return_value=["GC1", "GC2", "GC3"])
node.resolve = MagicMock(return_value="Part")
node._process_gcode = MagicMock()
node.evaluate()
self.assertEqual(node._process_gcode.call_count, 3)
node._process_gcode.assert_any_call("GC1", "Part01", None)
node._process_gcode.assert_any_call("GC2", "Part02", None)
node._process_gcode.assert_any_call("GC3", "Part03", None)
# 3. Fifteen parts - padded to 2 digits (01..15)
node.resolve_multi_input = MagicMock(return_value=[f"GC{i}" for i in range(1, 16)])
node.resolve = MagicMock(return_value="Part")
node._process_gcode = MagicMock()
node.evaluate()
self.assertEqual(node._process_gcode.call_count, 15)
node._process_gcode.assert_any_call("GC1", "Part01", None)
node._process_gcode.assert_any_call("GC15", "Part15", None)
# 4. Default base name when empty name provided
node.resolve_multi_input = MagicMock(return_value=["GC1", "GC2"])
node.resolve = MagicMock(return_value="")
node._process_gcode = MagicMock()
node.evaluate()
node._process_gcode.assert_any_call("GC1", "GcodePreview01", None)
node._process_gcode.assert_any_call("GC2", "GcodePreview02", None)
if __name__ == '__main__':
unittest.main()