From b09a5fb636d83687d53ded213895a2463a4131d6 Mon Sep 17 00:00:00 2001 From: Nicolas Predella Date: Fri, 9 Oct 2026 13:53:39 +0300 Subject: [PATCH] output naming --- core/nodeslicer/nodes/output.py | 13 +++++- doc/NODES.md | 2 +- tests/test_transform.py | 76 +++++++++++++++++++++++++++++++++ 3 files changed, 89 insertions(+), 2 deletions(-) diff --git a/core/nodeslicer/nodes/output.py b/core/nodeslicer/nodes/output.py index 730c9fc..d9abb63 100644 --- a/core/nodeslicer/nodes/output.py +++ b/core/nodeslicer/nodes/output.py @@ -20,6 +20,7 @@ class SLICENODE_GcodeToFile(ExecSliceNode): def init(self, context): self._init_exec() self.add_input("NodeSocketString", "Folder") + self.add_input("NodeSocketString", "Name") self.add_multi_socket_group("NodeSocketString", name="Gcode") def draw_buttons(self, context, layout): @@ -38,8 +39,18 @@ class SLICENODE_GcodeToFile(ExecSliceNode): if operator: operator.report({'ERROR'}, "No Folder provided") return 'error' + base_name: str = self.resolve("Name") + total_parts: int = len(gcodes) + num_digits: int = max(2, len(str(total_parts))) + for i, gc in enumerate(gcodes): - name = get_gcode_name_from_metadata(gc, default=f'object_{i}') + if base_name: + if total_parts > 1: + name = f"{base_name}{i:0{num_digits}d}" + else: + name = base_name + else: + name = get_gcode_name_from_metadata(gc, default=f'object_{i}') # User's requested logic: .split('.')[0] folder_path = Path(bpy.path.abspath(f"//{folder}")) diff --git a/doc/NODES.md b/doc/NODES.md index 1204408..951f18f 100644 --- a/doc/NODES.md +++ b/doc/NODES.md @@ -171,7 +171,7 @@ All nodes are found in the **Add (Shift+A)** menu under the **SliceNodes** categ - **Join Execution**: Synchronizes sequential execution flows. ### Action & I/O -- **Gcode to File**: Writes G-code string to a file on disk. +- **Gcode to File**: Writes G-code string to a file on disk. Accepts an optional **Name** input (which applies zero-padded digit suffixes when multiple G-codes are supplied, matching the preview mesh logic). If not plugged, uses the object names extracted from G-code metadata. - **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. - **Gcode to Text Block**: Creates a new Blender text block containing the G-code and opens it in any active text editors. - **Start Print**: Sends G-code directly to a network printer. diff --git a/tests/test_transform.py b/tests/test_transform.py index 08f6f1c..7573d32 100644 --- a/tests/test_transform.py +++ b/tests/test_transform.py @@ -466,6 +466,82 @@ class TestTransform(unittest.TestCase): node._process_gcode.assert_any_call("GC1", "GcodePreview00", None) node._process_gcode.assert_any_call("GC2", "GcodePreview01", None) + def test_gcode_to_file_naming_convention(self): + from unittest.mock import patch, mock_open + from NodeSlicer.core.nodeslicer.nodes.output import SLICENODE_GcodeToFile + node = SLICENODE_GcodeToFile() + + mock_bpy.path.abspath = lambda p: p.replace("//", "/workspace/") + + # 1. Single part with Name plugged - no number suffix + with patch("NodeSlicer.infra.networking.gcode_utils.prepare_gcode_file", return_value=(b"GC", "Part.gcode")) as mock_prep, \ + patch("builtins.open", mock_open()), \ + patch("os.makedirs"): + def resolve_side_effect(name): + if name == "Folder": return "output" + if name == "Name": return "Part" + return "" + node.resolve = MagicMock(side_effect=resolve_side_effect) + node.resolve_multi_input = MagicMock(return_value=["GCODE1"]) + + res = node.evaluate() + self.assertEqual(res, 'ok') + mock_prep.assert_called_once_with("GCODE1", "Part") + + # 2. Three parts with Name plugged - padded to 2 digits (00, 01, 02) + with patch("NodeSlicer.infra.networking.gcode_utils.prepare_gcode_file", return_value=(b"GC", "test.gcode")) as mock_prep, \ + patch("builtins.open", mock_open()), \ + patch("os.makedirs"): + def resolve_side_effect(name): + if name == "Folder": return "output" + if name == "Name": return "Part" + return "" + node.resolve = MagicMock(side_effect=resolve_side_effect) + node.resolve_multi_input = MagicMock(return_value=["GC1", "GC2", "GC3"]) + + res = node.evaluate() + self.assertEqual(res, 'ok') + self.assertEqual(mock_prep.call_count, 3) + mock_prep.assert_any_call("GC1", "Part00") + mock_prep.assert_any_call("GC2", "Part01") + mock_prep.assert_any_call("GC3", "Part02") + + # 3. Fifteen parts with Name plugged - padded to 2 digits (00..14) + with patch("NodeSlicer.infra.networking.gcode_utils.prepare_gcode_file", return_value=(b"GC", "test.gcode")) as mock_prep, \ + patch("builtins.open", mock_open()), \ + patch("os.makedirs"): + def resolve_side_effect(name): + if name == "Folder": return "output" + if name == "Name": return "Bracket" + return "" + node.resolve = MagicMock(side_effect=resolve_side_effect) + node.resolve_multi_input = MagicMock(return_value=[f"GC{i}" for i in range(1, 16)]) + + res = node.evaluate() + self.assertEqual(res, 'ok') + self.assertEqual(mock_prep.call_count, 15) + mock_prep.assert_any_call("GC1", "Bracket00") + mock_prep.assert_any_call("GC15", "Bracket14") + + # 4. Not plugged / empty name - uses object names from metadata + gc_meta1 = '; objects_info = {"objects": [{"name": "CustomPart"}]}\nG1 X10' + gc_meta2 = 'G1 X20' + with patch("NodeSlicer.infra.networking.gcode_utils.prepare_gcode_file", return_value=(b"GC", "test.gcode")) as mock_prep, \ + patch("builtins.open", mock_open()), \ + patch("os.makedirs"): + def resolve_side_effect(name): + if name == "Folder": return "output" + if name == "Name": return "" + return "" + node.resolve = MagicMock(side_effect=resolve_side_effect) + node.resolve_multi_input = MagicMock(return_value=[gc_meta1, gc_meta2]) + + res = node.evaluate() + self.assertEqual(res, 'ok') + self.assertEqual(mock_prep.call_count, 2) + mock_prep.assert_any_call(gc_meta1, "CustomPart") + mock_prep.assert_any_call(gc_meta2, "object_1") + def test_drop_geometry_applied_to_main_transform_no_double_accumulation(self): """Verifies that dropping geometry updates the single main transform without accumulating duplicate records.""" from NodeSlicer.core.nodeslicer.nodes.mesh import SLICENODE_TransformGeometry, SLICENODE_DropGeometry