diff --git a/core/nodeslicer/nodes.py b/core/nodeslicer/nodes.py
index a4f061d..57bc527 100644
--- a/core/nodeslicer/nodes.py
+++ b/core/nodeslicer/nodes.py
@@ -1042,6 +1042,65 @@ class SLICENODE_StartPrint(ExecSliceNode):
return 'ok'
+@register_class
+class SLICENODE_PrusaslicerSlice(ExecSliceNode):
+ bl_label = "PrusaSlicer Slice"
+ button_label = "Generate 3MF"
+
+ def init(self, context):
+ self._init_exec()
+ self.add_input("SLICESOCK_Object", "Object")
+ self.add_input("SLICESOCK_PrusaslicerConf", "Config")
+
+ def evaluate(self, operator=None):
+ from ...infra.blender_mesh_capture import SlicingGroup
+ from ...infra._3mf_writer._3mf import prepare_3mf
+ import tempfile
+ from pathlib import Path
+
+ obj = resolve_input(self.inputs.get("Object"))
+ conf_data = resolve_input(self.inputs.get("Config"))
+
+ if not obj:
+ if operator: operator.report({'ERROR'}, "No Object connected")
+ return 'no object'
+ if not conf_data:
+ if operator: operator.report({'ERROR'}, "No Config connected")
+ return 'no config'
+
+ # Extract data from config dict
+ printer_id = conf_data['printer']
+ filament_ids = conf_data['filament']
+ print_id = conf_data['print']
+ cache = conf_data['cache']
+
+ # Generate the SlicingGroup
+ slicing_group = SlicingGroup([obj])
+
+ # Offset by the bed center
+ a_z = -slicing_group.min_z
+ slicing_group.offset()
+
+ # Generate the ConfigWriter with no overrides
+ conf_writer = cache.generate_conf_writer(printer_id, filament_ids, print_id, overrides={})
+
+ # Prepare 3MF path
+ temp_dir = Path(tempfile.gettempdir())
+ output_path = temp_dir / f"{obj.name}_NodeSlicer.3mf"
+
+ try:
+ prepare_3mf(output_path, slicing_group, conf_writer, z_gcodes=[])
+ if operator:
+ operator.report({'INFO'}, f"3MF generated at {output_path}")
+
+ except Exception as e:
+ if operator: operator.report({'ERROR'}, f"Failed to generate 3MF: {str(e)}")
+ import traceback
+ traceback.print_exc()
+ return 'error'
+
+ return 'ok'
+
@register_class
class SLICENODE_GcodeToMesh(ExecSliceNode):
bl_label = "Gcode to Mesh"
diff --git a/core/nodeslicer/tree.py b/core/nodeslicer/tree.py
index 4eb68ed..b62d50e 100644
--- a/core/nodeslicer/tree.py
+++ b/core/nodeslicer/tree.py
@@ -149,6 +149,7 @@ class SLICENODE_MT_Menu(Menu):
layout.operator("node.add_node", text="Gcode to Text Block").type = "SLICENODE_GcodeToTextBlock"
layout.operator("node.add_node", text="Start Print").type = "SLICENODE_StartPrint"
layout.operator("node.add_node", text="Gcode to Mesh").type = "SLICENODE_GcodeToMesh"
+ layout.operator("node.add_node", text="PrusaSlicer Slice").type = "SLICENODE_PrusaslicerSlice"
layout.operator("node.add_node", text="Executor").type = "SLICENODE_Executor"
def draw_add_menu(self, context):
diff --git a/infra/_3mf_writer/_3mf.py b/infra/_3mf_writer/_3mf.py
new file mode 100644
index 0000000..c6328f0
--- /dev/null
+++ b/infra/_3mf_writer/_3mf.py
@@ -0,0 +1,182 @@
+from __future__ import annotations
+
+from numpy.typing import NDArray
+from pathlib import Path
+import numpy as np
+import os, shutil, tempfile
+import xml.etree.ElementTree as ET
+from datetime import date
+
+from ..blender_mesh_capture import SlicingObject, SlicingGroup, SlicingCollection
+
+script_dir = os.path.dirname(os.path.abspath(__file__))
+
+def indent(elem, level=0):
+ i = "\n" + level * " "
+ if len(elem):
+ if not elem.text or not elem.text.strip():
+ elem.text = i + " "
+ for child in elem:
+ indent(child, level + 1)
+ if not elem.tail or not elem.tail.strip():
+ elem.tail = i
+ else:
+ if level and (not elem.tail or not elem.tail.strip()):
+ elem.tail = i
+
+def write_metadata_xml(group: SlicingGroup, filepath):
+ # Custom sorting order for object types
+ object_type_order = {
+ 'ModelPart': 0,
+ 'NegativeVolume': 1,
+ 'ParameterModifier': 2,
+ 'SupportBlocker': 3,
+ 'SupportEnforcer': 4
+ }
+
+ xml_content = ET.Element("config")
+
+ valid_collections = {k: c for k, c in group.collections.items() if c.meshes}
+
+ for j, (k, collection) in enumerate(valid_collections.items()):
+
+ sorted_data: list[tuple[NDArray, NDArray, SlicingObject]] = sorted(zip(collection.mesh_start_ids, collection.mesh_end_ids, collection.objects), key=lambda x: object_type_order.get(x[2].object_type, 5))
+
+ object_elem = ET.SubElement(xml_content, "object", id=str(j+1), instances_count="1")
+ ET.SubElement(object_elem, "metadata", type="object", key="name", value=collection.name)
+
+ for i, (start, end, metadata) in enumerate(sorted_data):
+ if metadata.object_type == "ModelPart":
+ for mod in metadata.modifiers:
+ ET.SubElement(object_elem, "metadata", type="object", key=mod['param_id'], value=mod['param_value'])
+
+ volume_elem = ET.SubElement(object_elem, "volume", firstid=str(start), lastid=str(end))
+
+ ET.SubElement(volume_elem, "metadata", type="volume", key="name", value=metadata.name)
+ if metadata.object_type == "ParameterModifier":
+ ET.SubElement(volume_elem, "metadata", type="volume", key="modifier", value="1")
+ for mod in metadata.modifiers:
+ ET.SubElement(volume_elem, "metadata", type="volume", key=mod['param_id'], value=mod['param_value'])
+ ET.SubElement(volume_elem, "metadata", type="volume", key="volume_type", value=metadata.object_type)
+ ET.SubElement(volume_elem, "metadata", type="volume", key="extruder", value=str(metadata.extruder))
+ ET.SubElement(volume_elem, "metadata", type="volume", key="source_object_id", value="0")
+ ET.SubElement(volume_elem, "metadata", type="volume", key="source_volume_id", value=str(i))
+ ET.SubElement(volume_elem, "metadata", type="volume", key="matrix", value="1 0 0 0 0 1 0 0 0 0 1 0 0 0 0 1")
+ ET.SubElement(volume_elem, "mesh", edges_fixed="0", degenerate_facets="0", facets_removed="0", facets_reversed="0", backwards_edges="0")
+
+ indent(xml_content)
+ xml_tree = ET.ElementTree(xml_content)
+ xml_tree.write(filepath, encoding="UTF-8", xml_declaration=True)
+
+def write_wipe_tower_xml(group: SlicingGroup, filename):
+ with open(filename, 'w', encoding="UTF-8") as file:
+ file.write(f'\n')
+ file.write(f'\n')
+
+def write_model_xml(group: SlicingGroup, filename: str):
+ now = date.today().isoformat()
+
+ # Open file for writing
+ with open(filename, 'w', encoding="UTF-8") as file:
+ # Write the XML declaration and opening model tag
+ file.write(f'\n')
+ file.write(f'\n')
+
+ # Write metadata entries using list comprehension
+ metadata_entries: list[tuple[str, str]] = [
+ ("slic3rpe:Version3mf", "1"),
+ ("Title", "box"),
+ ("Designer", ""),
+ ("Description", "box"),
+ ("Copyright", ""),
+ ("LicenseTerms", ""),
+ ("Rating", ""),
+ ("CreationDate", now),
+ ("ModificationDate", now),
+ ("Application", "PrusaSlicer-2.9.0")
+ ]
+ file.writelines([f' {value}\n' for name, value in metadata_entries])
+
+ # Write resources element and object using list comprehension
+ file.write(f' \n')
+
+ verts_template = np.vectorize(lambda x, y, z: '\n' % (x, y, z))
+ idx_template = np.vectorize(lambda a, b, c: '\n' % (a, b, c))
+
+ valid_collections: dict[str, SlicingCollection] = {k: c for k, c in group.collections.items() if c.meshes}
+
+ for i, (k, collection) in enumerate[tuple[str, SlicingCollection]](valid_collections.items()):
+ if not collection.meshes: continue
+
+ uv, t_idx = collection.unique_verts
+
+ if uv.size and t_idx.size:
+ file.write(f' \n')
+
+ file.write(f' \n')
+
+ # Write build element
+ file.write(f' \n')
+ for i, k in enumerate(valid_collections):
+ if not valid_collections[k].unique_verts[0].size: continue
+ file.writelines([f' \n' ])
+ file.write(f' \n')
+
+ # Close the model tag
+ file.write(f'\n')
+
+
+def to_3mf(folder_path, output_base_path):
+ zip_file_path = shutil.make_archive(os.path.splitext(output_base_path)[0], 'zip', folder_path)
+ new_file_path = os.path.splitext(zip_file_path)[0] + '.3mf'
+ os.replace(zip_file_path, new_file_path)
+
+def write_z_gcodes(z_gcodes, filename: str):
+ root = ET.Element("custom_gcodes_per_print_z", bed_idx="0")
+
+ for c in z_gcodes:
+ ET.SubElement(root, "code", {
+ "print_z": str(c.z),
+ "type": str(c.type),
+ "extruder": str(c.extruder),
+ "color": c.color,
+ "extra": c.extra,
+ "gcode": c.gcode,
+ })
+
+ ET.SubElement(root, "mode", {"value": "SingleExtruder"})
+
+ ET.ElementTree(root).write(filename, encoding="utf-8", xml_declaration=True)
+
+def prepare_3mf(filepath: Path, geoms: SlicingGroup, conf, z_gcodes) -> None:
+
+ source_folder = os.path.join(script_dir, 'prusaslicer_3mf')
+ temp_dir = tempfile.mkdtemp()
+ shutil.copytree(source_folder, temp_dir, dirs_exist_ok=True)
+
+ os.makedirs(os.path.join(temp_dir, '3D'), exist_ok=True)
+ os.makedirs(os.path.join(temp_dir, 'Metadata'), exist_ok=True)
+
+ write_model_xml(geoms, os.path.join(temp_dir, '3D', '3dmodel.model'))
+
+ write_metadata_xml(geoms, os.path.join(temp_dir, 'Metadata', 'Slic3r_PE_model.config'))
+ write_wipe_tower_xml(geoms, os.path.join(temp_dir, 'Metadata', 'Prusa_Slicer_wipe_tower_information.xml'))
+ write_z_gcodes(z_gcodes, os.path.join(temp_dir, 'Metadata', 'Prusa_Slicer_custom_gcode_per_print_z.xml'))
+ conf.write_ini_3mf(os.path.join(temp_dir, 'Metadata', 'Slic3r_PE.config'))
+
+ to_3mf(temp_dir, filepath)
+
+ return None
\ No newline at end of file
diff --git a/infra/_3mf_writer/prusaslicer_3mf/[Content_Types].xml b/infra/_3mf_writer/prusaslicer_3mf/[Content_Types].xml
new file mode 100644
index 0000000..b2b4690
--- /dev/null
+++ b/infra/_3mf_writer/prusaslicer_3mf/[Content_Types].xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
diff --git a/infra/_3mf_writer/prusaslicer_3mf/_rels/.rels b/infra/_3mf_writer/prusaslicer_3mf/_rels/.rels
new file mode 100644
index 0000000..b904773
--- /dev/null
+++ b/infra/_3mf_writer/prusaslicer_3mf/_rels/.rels
@@ -0,0 +1,5 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/infra/blender_mesh_capture.py b/infra/blender_mesh_capture.py
index 746f51f..95f002d 100644
--- a/infra/blender_mesh_capture.py
+++ b/infra/blender_mesh_capture.py
@@ -21,6 +21,9 @@ class SlicingObject():
name: str
parent: str
mesh: NDArray[float64]
+ object_type: str = "ModelPart"
+ modifiers: list[dict] = []
+ extruder: int = 1
def __init__(self, obj: Object | None = None, parent: str = "") -> None:
if not bpy.context.scene: raise Exception('No scene currently open!')
@@ -69,6 +72,12 @@ class SlicingObject():
@property
def max_y(self) -> float: return self.mesh[:, :3, 1].max()
+ @property
+ def min_z(self) -> float: return self.mesh[:, :3, 2].min()
+
+ @property
+ def max_z(self) -> float: return self.mesh[:, :3, 2].max()
+
@property
def min_xy(self) -> NDArray: return np.array([self.min_x, self.min_y, 0.0])
@@ -165,6 +174,14 @@ class SlicingCollection():
def max_y(self) -> float | None:
return max([so.max_y for so in self.objects if np.any(so.mesh)], default=None)
+ @property
+ def min_z(self) -> float | None:
+ return min([so.min_z for so in self.objects if np.any(so.mesh)], default=None)
+
+ @property
+ def max_z(self) -> float | None:
+ return max([so.max_z for so in self.objects if np.any(so.mesh)], default=None)
+
@property
def min_xy(self) -> NDArray | None:
if self.min_x is None or self.min_y is None: return None
@@ -197,7 +214,7 @@ class SlicingGroup():
# Remove duplicates
family = list(set(family))
- filtered_objs = [o for o in family if getattr(o, TYPES_NAME).object_type != 'Ignore']
+ filtered_objs = [o for o in family] # if getattr(o, TYPES_NAME).object_type != 'Ignore'
def find_top_parent(o):
current = o
@@ -224,10 +241,11 @@ class SlicingGroup():
if obj.mode == 'EDIT':
obj.update_from_editmode()
eval_obj = obj.evaluated_get(depsgraph)
- if eval_obj.blendertoprusaslicer.object_type == 'WipeTower':
- self.wipe_tower_xy = np.array(eval_obj.location[0:2]) * scale_tx
- self.wipe_tower_rotation_deg = round(eval_obj.rotation_euler[2]*180/math.pi,5)
- break
+ if hasattr(eval_obj, 'blendertoprusaslicer'):
+ if eval_obj.blendertoprusaslicer.object_type == 'WipeTower':
+ self.wipe_tower_xy = np.array(eval_obj.location[0:2]) * scale_tx
+ self.wipe_tower_rotation_deg = round(eval_obj.rotation_euler[2]*180/math.pi,5)
+ break
return
@@ -262,7 +280,13 @@ class SlicingGroup():
def min_y(self) -> float: return min([so.min_y for k, so in self.collections.items() if so.min_y], default=0)
@property
- def max_y(self) -> float: return max([so.max_y for k, so in self.collections.items() if so.max_y], default=0)
+ def max_y(self) -> float: return max([so.max_y for k, so in self.collections.items() if so.max_y is not None], default=0)
+
+ @property
+ def min_z(self) -> float: return min([so.min_z for k, so in self.collections.items() if so.min_z is not None], default=0)
+
+ @property
+ def max_z(self) -> float: return max([so.max_z for k, so in self.collections.items() if so.max_z is not None], default=0)
@property
def min_xy(self) -> NDArray: