diff --git a/core/nodeslicer/nodes/inputs.py b/core/nodeslicer/nodes/inputs.py index abe2320..97f66fc 100644 --- a/core/nodeslicer/nodes/inputs.py +++ b/core/nodeslicer/nodes/inputs.py @@ -103,6 +103,37 @@ class SLICENODE_WipeTower(SliceNode): "wipe_tower_position": (pos_x, pos_y) } +@register_class +class SLICENODE_OffsetObject(SliceNode): + bl_label = "Offset Object" + + def init(self, context): + self.add_input("SLICESOCK_Float", "X Offset").value = 0.0 + self.add_input("SLICESOCK_Float", "Y Offset").value = 0.0 + self.add_input("SLICESOCK_Float", "Z Offset").value = 0.0 + self.add_output("SLICESOCK_SettingsRo", "Settings") + + def evaluate_output(self, socket_name: str, **_kwargs) -> Any: + if socket_name != "Settings": + return {} + + raw_x = self.resolve("X Offset") + raw_y = self.resolve("Y Offset") + raw_z = self.resolve("Z Offset") + + if isinstance(raw_x, (list, tuple, np.ndarray)) and len(raw_x) >= 2: + ox = float(raw_x[0]) + oy = float(raw_x[1]) + oz = float(raw_x[2]) if len(raw_x) >= 3 else (float(raw_z) if raw_z is not None else 0.0) + else: + ox = float(raw_x) if raw_x is not None else 0.0 + oy = float(raw_y) if raw_y is not None else 0.0 + oz = float(raw_z) if raw_z is not None else 0.0 + + return { + "offset_object": (ox, oy, oz) + } + @register_class class SLICENODE_InputSettings(InputNode): input_type = "Settings" diff --git a/core/nodeslicer/nodes/mesh.py b/core/nodeslicer/nodes/mesh.py index dc8f712..97e1155 100644 --- a/core/nodeslicer/nodes/mesh.py +++ b/core/nodeslicer/nodes/mesh.py @@ -427,6 +427,7 @@ def _copy_slicing_group(sg: SlicingGroup) -> SlicingGroup: new_sg.collections = {k: _copy_slicing_collection(sc) for k, sc in sg.collections.items()} new_sg.wipe_tower_xy = sg.wipe_tower_xy.copy() new_sg.wipe_tower_rotation_deg = sg.wipe_tower_rotation_deg + new_sg.object_offset = sg.object_offset.copy() return new_sg diff --git a/core/nodeslicer/nodes/system.py b/core/nodeslicer/nodes/system.py index fd7d575..cdb5ecb 100644 --- a/core/nodeslicer/nodes/system.py +++ b/core/nodeslicer/nodes/system.py @@ -230,9 +230,20 @@ class SLICENODE_PrusaslicerSlice(SliceNode): except Exception: pass - # Prepare overrides for generate_conf_writer (excluding wipe_tower_position) + # Check for offset_object override + if overrides and "offset_object" in overrides: + obj_offset = overrides["offset_object"] + try: + ox = float(obj_offset[0]) + oy = float(obj_offset[1]) + oz = float(obj_offset[2]) if len(obj_offset) > 2 else 0.0 + slicing_group.object_offset = np.array([ox, oy, oz]) + except Exception: + pass + + # Prepare overrides for generate_conf_writer (excluding wipe_tower_position & offset_object) writer_overrides = { - k: {'value': v} for k, v in (overrides or {}).items() if k != "wipe_tower_position" + k: {'value': v} for k, v in (overrides or {}).items() if k not in ("wipe_tower_position", "offset_object") } # Generate the ConfigWriter diff --git a/core/nodeslicer/tree.py b/core/nodeslicer/tree.py index 0af228a..fd4be57 100644 --- a/core/nodeslicer/tree.py +++ b/core/nodeslicer/tree.py @@ -482,6 +482,7 @@ class SLICENODE_MT_DataInput(Menu): layout.operator("node.add_node", text="Collection Objects").type = "SLICENODE_CollectionObjects" layout.operator("node.add_node", text="Settings").type = "SLICENODE_InputSettings" layout.operator("node.add_node", text="Wipe Tower").type = "SLICENODE_WipeTower" + layout.operator("node.add_node", text="Offset Object").type = "SLICENODE_OffsetObject" layout.operator("node.add_node", text="PrusaSlicer Config").type = "SLICENODE_PrusaslicerConf" layout.separator() layout.operator("node.add_node", text="Printer").type = "SLICENODE_InputPrinter" diff --git a/doc/NODES.md b/doc/NODES.md index 29a1724..298f485 100644 --- a/doc/NODES.md +++ b/doc/NODES.md @@ -51,6 +51,10 @@ All nodes are found in the **Add (Shift+A)** menu under the **SliceNodes** categ - **Wipe Tower**: Configures the Wipe Tower printbed position (`wipe_tower_position`) passed to 3MF metadata (`Prusa_Slicer_wipe_tower_information.xml`) without injecting into profile INI settings. - **Position X**: X coordinate of the wipe tower on the printbed. - **Position Y**: Y coordinate of the wipe tower on the printbed. +- **Offset Object**: Configures the object placement transform offset (`offset_object`) applied to `` inside 3MF model build data (`3D/3dmodel.model`) without injecting into profile INI settings. + - **Offset X**: X translation offset for the build item. + - **Offset Y**: Y translation offset for the build item. + - **Offset Z**: Z translation offset for the build item. - **Printer Intrinsics**: Configures bed size, extrusion/travel rates, retraction, and Z-lift. - **Gcode Block**: Manual text area for entering raw G-code. diff --git a/infra/_3mf_writer/_3mf.py b/infra/_3mf_writer/_3mf.py index 768843c..6bc9d04 100644 --- a/infra/_3mf_writer/_3mf.py +++ b/infra/_3mf_writer/_3mf.py @@ -130,9 +130,11 @@ def write_model_xml(group: SlicingGroup, filename: str): # Write build element file.write(f' \n') + ox, oy, oz = group.object_offset[0], group.object_offset[1], group.object_offset[2] + transform_str = f"1 0 0 0 1 0 0 0 1 {ox} {oy} {oz}" for i, k in enumerate(valid_collections): if not valid_collections[k].unique_verts[0].size: continue - file.writelines([f' \n' ]) + file.writelines([f' \n' ]) file.write(f' \n') # Close the model tag diff --git a/infra/blender/blender_mesh_capture.py b/infra/blender/blender_mesh_capture.py index 3b24cd0..9d42b19 100644 --- a/infra/blender/blender_mesh_capture.py +++ b/infra/blender/blender_mesh_capture.py @@ -385,8 +385,10 @@ class SlicingGroup(): # Metadata wipe_tower_xy: NDArray = np.array([0, 0]) wipe_tower_rotation_deg: float = 0 + object_offset: NDArray = np.array([0.0, 0.0, 0.0]) def __init__(self, selected_objs: list[Object]) -> None: + self.object_offset = np.array([0.0, 0.0, 0.0]) family = [] for obj in selected_objs: @@ -445,6 +447,7 @@ class SlicingGroup(): buf.extend(struct.pack(">I", crc32_array(self.wipe_tower_xy))) buf.extend(struct.pack(">I", crc32_array(np.array(self.wipe_tower_rotation_deg)))) + buf.extend(struct.pack(">I", crc32_array(self.object_offset))) return zlib.crc32(buf) & 0xFFFFFFFF diff --git a/infra/prusaslicer_profiles/models.py b/infra/prusaslicer_profiles/models.py index 72a63e0..ee7ddf4 100644 --- a/infra/prusaslicer_profiles/models.py +++ b/infra/prusaslicer_profiles/models.py @@ -70,7 +70,7 @@ class ConfigWriter: def write_ini_3mf(self, config_local_path: Path): with open(config_local_path, 'w') as file: for key, val in dict(sorted(self.config_dict.items())).items(): - if key == "wipe_tower_position": + if key in ("wipe_tower_position", "offset_object"): continue file.write(f"; {key} = {val}\n") diff --git a/tests/test_wipe_tower.py b/tests/test_wipe_tower.py index 463a98f..e805411 100644 --- a/tests/test_wipe_tower.py +++ b/tests/test_wipe_tower.py @@ -8,23 +8,33 @@ import sys PROJECT_ROOT = Path(__file__).resolve().parent.parent sys.path.append(str(PROJECT_ROOT)) -from NodeSlicer.core.nodeslicer.nodes.inputs import SLICENODE_WipeTower +from NodeSlicer.core.nodeslicer.nodes.inputs import SLICENODE_WipeTower, SLICENODE_OffsetObject from NodeSlicer.infra.prusaslicer_profiles.models import ConfigWriter +from NodeSlicer.infra._3mf_writer._3mf import write_model_xml from NodeSlicer.infra.blender.blender_mesh_capture import SlicingGroup class TestWipeTowerNode(unittest.TestCase): def test_wipe_tower_node_output(self): node = SLICENODE_WipeTower() - node.resolve = lambda key: 180.5 if key == "Position X" else 135.2 + node.resolve = lambda key: 180.5 if key == "X Position" else 135.2 res = node.evaluate_output("Settings") self.assertIn("wipe_tower_position", res) self.assertEqual(res["wipe_tower_position"], (180.5, 135.2)) - def test_config_writer_excludes_wipe_tower_position(self): + def test_offset_object_node_output(self): + node = SLICENODE_OffsetObject() + node.resolve = lambda key: {"Offset X": 12.5, "Offset Y": -5.0, "Offset Z": 2.0}.get(key, 0.0) + + res = node.evaluate_output("Settings") + self.assertIn("offset_object", res) + self.assertEqual(res["offset_object"], (12.5, -5.0, 2.0)) + + def test_config_writer_excludes_special_keys(self): conf = { "layer_height": "0.2", "wipe_tower_position": "(180.5, 135.2)", + "offset_object": "(12.5, -5.0, 2.0)", "nozzle_diameter": "0.4" } writer = ConfigWriter(conf) @@ -39,6 +49,7 @@ class TestWipeTowerNode(unittest.TestCase): self.assertIn("; layer_height = 0.2", content) self.assertIn("; nozzle_diameter = 0.4", content) self.assertNotIn("wipe_tower_position", content) + self.assertNotIn("offset_object", content) if __name__ == "__main__": unittest.main()