This commit is contained in:
Nicolas Predella 2026-07-24 14:49:55 +03:00
parent 8cff75d569
commit e3e2335754
9 changed files with 71 additions and 7 deletions

View file

@ -103,6 +103,37 @@ class SLICENODE_WipeTower(SliceNode):
"wipe_tower_position": (pos_x, pos_y) "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 @register_class
class SLICENODE_InputSettings(InputNode): class SLICENODE_InputSettings(InputNode):
input_type = "Settings" input_type = "Settings"

View file

@ -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.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_xy = sg.wipe_tower_xy.copy()
new_sg.wipe_tower_rotation_deg = sg.wipe_tower_rotation_deg new_sg.wipe_tower_rotation_deg = sg.wipe_tower_rotation_deg
new_sg.object_offset = sg.object_offset.copy()
return new_sg return new_sg

View file

@ -230,9 +230,20 @@ class SLICENODE_PrusaslicerSlice(SliceNode):
except Exception: except Exception:
pass 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 = { 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 # Generate the ConfigWriter

View file

@ -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="Collection Objects").type = "SLICENODE_CollectionObjects"
layout.operator("node.add_node", text="Settings").type = "SLICENODE_InputSettings" 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="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.operator("node.add_node", text="PrusaSlicer Config").type = "SLICENODE_PrusaslicerConf"
layout.separator() layout.separator()
layout.operator("node.add_node", text="Printer").type = "SLICENODE_InputPrinter" layout.operator("node.add_node", text="Printer").type = "SLICENODE_InputPrinter"

View file

@ -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. - **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 X**: X coordinate of the wipe tower on the printbed.
- **Position Y**: Y 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 `<item transform="...">` 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. - **Printer Intrinsics**: Configures bed size, extrusion/travel rates, retraction, and Z-lift.
- **Gcode Block**: Manual text area for entering raw G-code. - **Gcode Block**: Manual text area for entering raw G-code.

View file

@ -130,9 +130,11 @@ def write_model_xml(group: SlicingGroup, filename: str):
# Write build element # Write build element
file.write(f' <build>\n') file.write(f' <build>\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): for i, k in enumerate(valid_collections):
if not valid_collections[k].unique_verts[0].size: continue if not valid_collections[k].unique_verts[0].size: continue
file.writelines([f' <item objectid="{str(i+1)}" transform="1 0 0 0 1 0 0 0 1 0 0 0" printable="1" />\n' ]) file.writelines([f' <item objectid="{str(i+1)}" transform="{transform_str}" printable="1" />\n' ])
file.write(f' </build>\n') file.write(f' </build>\n')
# Close the model tag # Close the model tag

View file

@ -385,8 +385,10 @@ class SlicingGroup():
# Metadata # Metadata
wipe_tower_xy: NDArray = np.array([0, 0]) wipe_tower_xy: NDArray = np.array([0, 0])
wipe_tower_rotation_deg: float = 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: def __init__(self, selected_objs: list[Object]) -> None:
self.object_offset = np.array([0.0, 0.0, 0.0])
family = [] family = []
for obj in selected_objs: 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(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(np.array(self.wipe_tower_rotation_deg))))
buf.extend(struct.pack(">I", crc32_array(self.object_offset)))
return zlib.crc32(buf) & 0xFFFFFFFF return zlib.crc32(buf) & 0xFFFFFFFF

View file

@ -70,7 +70,7 @@ class ConfigWriter:
def write_ini_3mf(self, config_local_path: Path): def write_ini_3mf(self, config_local_path: Path):
with open(config_local_path, 'w') as file: with open(config_local_path, 'w') as file:
for key, val in dict(sorted(self.config_dict.items())).items(): 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 continue
file.write(f"; {key} = {val}\n") file.write(f"; {key} = {val}\n")

View file

@ -8,23 +8,33 @@ import sys
PROJECT_ROOT = Path(__file__).resolve().parent.parent PROJECT_ROOT = Path(__file__).resolve().parent.parent
sys.path.append(str(PROJECT_ROOT)) 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.prusaslicer_profiles.models import ConfigWriter
from NodeSlicer.infra._3mf_writer._3mf import write_model_xml
from NodeSlicer.infra.blender.blender_mesh_capture import SlicingGroup from NodeSlicer.infra.blender.blender_mesh_capture import SlicingGroup
class TestWipeTowerNode(unittest.TestCase): class TestWipeTowerNode(unittest.TestCase):
def test_wipe_tower_node_output(self): def test_wipe_tower_node_output(self):
node = SLICENODE_WipeTower() 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") res = node.evaluate_output("Settings")
self.assertIn("wipe_tower_position", res) self.assertIn("wipe_tower_position", res)
self.assertEqual(res["wipe_tower_position"], (180.5, 135.2)) 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 = { conf = {
"layer_height": "0.2", "layer_height": "0.2",
"wipe_tower_position": "(180.5, 135.2)", "wipe_tower_position": "(180.5, 135.2)",
"offset_object": "(12.5, -5.0, 2.0)",
"nozzle_diameter": "0.4" "nozzle_diameter": "0.4"
} }
writer = ConfigWriter(conf) writer = ConfigWriter(conf)
@ -39,6 +49,7 @@ class TestWipeTowerNode(unittest.TestCase):
self.assertIn("; layer_height = 0.2", content) self.assertIn("; layer_height = 0.2", content)
self.assertIn("; nozzle_diameter = 0.4", content) self.assertIn("; nozzle_diameter = 0.4", content)
self.assertNotIn("wipe_tower_position", content) self.assertNotIn("wipe_tower_position", content)
self.assertNotIn("offset_object", content)
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()