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') + + file.write(f' \n') + file.writelines(verts_template(uv[:,0], uv[:,1], uv[:,2])) + file.write(f' \n') + + file.write(f' \n') + file.writelines(idx_template(t_idx[:,0], t_idx[:,1], t_idx[:,2])) + file.write(f' \n') + + file.write(f' \n') + 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: