Creating a Blender Add-on for 3D Object Slicing and DXF Export

As a 3D artist and enthusiast, I recently embarked on a project to enhance my 3D engraving workflow. My goal was to create a tool that could slice 3D objects in Blender and export each slice as a DXF file, which I could then use for 3D engraving. In this post, I’ll share my journey of developing a Blender add-on to accomplish this task.

The Need for a Custom Slicer

While there are various slicing tools available for 3D printing, I needed something specifically tailored for 3D engraving. The key requirements were:

  1. Ability to work within Blender, my preferred 3D modeling software.
  2. Capability to slice a 3D object into multiple layers.
  3. Export each slice as a separate DXF file, a format compatible with many CNC and engraving machines.

Developing the Blender Add-on

To meet these requirements, I decided to create a custom Blender add-on. Here’s an overview of how I approached the development:

1. Setting Up the Add-on Structure

I started by creating a new Blender operator class SliceObjectOperator that inherits from bpy.types.Operator and ExportHelper. This structure allows the add-on to integrate seamlessly with Blender’s UI and file selection system.

2. Implementing the Slicing Logic

The core of the add-on is the slicing algorithm. Here’s how it works:

  • Calculate the object’s total height and divide it into the specified number of layers.
  • For each layer:
    • Create a cross-section of the object using Blender’s bmesh module.
    • Use the bisect_plane operation to cut the object at the current layer height.
    • Extract the resulting edges to form the slice geometry.

3. DXF Export

For each slice, I implemented a DXF export function using the ezdxf library. This function:

  • Creates a new DXF document.
  • Adds lines representing the edges of the slice to the DXF modelspace.
  • Saves the DXF file with a name indicating the slice number.

4. User Interface and Workflow

The add-on provides a simple user interface where users can specify:

  • The number of slices to create.
  • The output directory for the DXF files.

Users can activate the add-on by selecting an object in Blender and running the “Slice Object and Export DXF” operator.

Challenges and Solutions

During development, I encountered a few challenges:

  1. Empty Slices: Initially, some slices were empty. I resolved this by adjusting the slicing algorithm and adding checks to skip empty slices.
  2. DXF Export Issues: There were some compatibility issues with DXF export. Switching to the ezdxf library and fine-tuning the export process solved these problems.
  3. Performance: For complex objects, the slicing process could be time-consuming. I optimized the code and added a progress bar to keep users informed during longer operations.

The Result

The final add-on successfully slices 3D objects in Blender and exports each slice as a DXF file. This has significantly streamlined my 3D engraving workflow, allowing me to quickly prepare complex 3D models for engraving.

Future Improvements

While the current version of the add-on meets my immediate needs, there’s always room for improvement. Some ideas for future enhancements include:

  • Adding support for more export formats.
  • Implementing parallel processing for faster slicing of complex objects.
  • Creating a more advanced UI with preview capabilities.

Conclusion

Developing this Blender add-on was a rewarding experience that solved a specific problem in my 3D engraving workflow. It demonstrates the power of custom tool development in addressing unique challenges in 3D modeling and fabrication processes.

If you’re interested in 3D engraving or developing Blender add-ons, I hope this project provides inspiration and insight into the process. Happy slicing and engraving!

P.S. Here is the code:

import bpy
import bmesh
import mathutils
from bpy.props import IntProperty, StringProperty
from bpy_extras.io_utils import ExportHelper
import os
import traceback
import ezdxf

class SliceObjectOperator(bpy.types.Operator, ExportHelper):
    """Slice Object and Export DXF"""
    bl_idname = "object.slice_object_operator"
    bl_label = "Slice Object and Export DXF"
    bl_options = {'REGISTER', 'UNDO'}

    num_layers: IntProperty(
        name="Number of Layers",
        description="Number of layers to slice the object into",
        default=10,
        min=1
    )
    filename_ext = ".dxf"
    filter_glob: StringProperty(default="*.dxf", options={'HIDDEN'})

    def execute(self, context):
        selected_objects = context.selected_objects
        if not selected_objects:
            self.report({'ERROR'}, "No object selected.")
            return {'CANCELLED'}
        
        obj = selected_objects[0]
        export_path = os.path.dirname(self.filepath)
        
        self.report({'INFO'}, f"Selected object: {obj.name}")
        self.report({'INFO'}, f"Exporting DXF files to: {export_path}")
        self.report({'INFO'}, f"Number of layers: {self.num_layers}")
        
        original_obj = obj.copy()
        original_obj.data = obj.data.copy()
        context.collection.objects.link(original_obj)
        original_obj.hide_set(True)
        
        self.slice_object(context, obj, original_obj, self.num_layers, export_path)
        
        bpy.data.objects.remove(original_obj)
        
        return {'FINISHED'}
    
    def invoke(self, context, event):
        context.window_manager.fileselect_add(self)
        return {'RUNNING_MODAL'}

    def slice_object(self, context, obj, original_obj, num_layers, export_path):
        if not os.access(export_path, os.W_OK):
            self.report({'ERROR'}, f"No write permission for directory: {export_path}")
            return

        bbox_corners = [obj.matrix_world @ mathutils.Vector(corner) for corner in obj.bound_box]
        z_min = min(corner.z for corner in bbox_corners)
        z_max = max(corner.z for corner in bbox_corners)
        total_height = z_max - z_min
        layer_height = total_height / num_layers
        
        self.report({'INFO'}, f"Total height: {total_height:.4f}, Layer height: {layer_height:.4f}")
        
        bpy.ops.object.select_all(action='DESELECT')
        bpy.context.window_manager.progress_begin(0, num_layers)
        
        for i in range(num_layers):
            bpy.context.window_manager.progress_update(i)
            self.process_layer(context, obj, original_obj, i, num_layers, z_min, layer_height, export_path)
        
        bpy.context.window_manager.progress_end()
        
        exported_files = [f for f in os.listdir(export_path) if f.endswith('.dxf')]
        self.report({'INFO'}, f"Total DXF files exported: {len(exported_files)}")
        self.report({'INFO'}, f"Finished slicing and exporting {num_layers} layers to {export_path}.")

    def process_layer(self, context, obj, original_obj, layer_index, num_layers, z_min, layer_height, export_path):
        self.report({'INFO'}, f"Processing layer {layer_index + 1} of {num_layers}")
        
        z_slice = z_min + layer_index * layer_height
        self.report({'INFO'}, f"Creating slice at Z: {z_slice:.4f}")
        
        obj.data = original_obj.data.copy()
        bm = bmesh.new()
        bm.from_mesh(obj.data)
        
        cut_mesh = bmesh.ops.bisect_plane(
            bm,
            geom=bm.verts[:] + bm.edges[:] + bm.faces[:],
            plane_co=(0, 0, z_slice),
            plane_no=(0, 0, 1),
            clear_inner=False,
            clear_outer=False
        )
        
        cross_section_edges = [e for e in cut_mesh['geom_cut'] if isinstance(e, bmesh.types.BMEdge)]
        cross_section_mesh = bpy.data.meshes.new(f"CrossSection_{layer_index + 1}")
        cross_section_bm = bmesh.new()
        
        for edge in cross_section_edges:
            v1 = cross_section_bm.verts.new(edge.verts[0].co)
            v2 = cross_section_bm.verts.new(edge.verts[1].co)
            cross_section_bm.edges.new((v1, v2))
        
        cross_section_bm.to_mesh(cross_section_mesh)
        cross_section_bm.free()
        
        if len(cross_section_mesh.vertices) == 0:
            self.report({'WARNING'}, f"Slice {layer_index + 1} is empty, skipping export.")
            bpy.data.meshes.remove(cross_section_mesh)
            return
        
        self.report({'INFO'}, f"Slice {layer_index + 1} has {len(cross_section_mesh.vertices)} vertices and {len(cross_section_mesh.edges)} edges.")
        
        slice_filename = f"{export_path}/slice_{layer_index + 1}.dxf"
        self.report({'INFO'}, f"Exporting slice {layer_index + 1} to: {slice_filename}")
        
        try:
            entity_count = self.export_to_dxf(cross_section_mesh, slice_filename)
            
            if os.path.exists(slice_filename):
                file_size = os.path.getsize(slice_filename)
                self.report({'INFO'}, f"Successfully exported slice {layer_index + 1} to DXF. File size: {file_size} bytes, Entities: {entity_count}")
            else:
                self.report({'WARNING'}, f"DXF file for slice {layer_index + 1} was not created.")
        except Exception as e:
            self.report({'ERROR'}, f"DXF export failed for slice {layer_index + 1}: {str(e)}")
            self.report({'ERROR'}, f"Traceback: {traceback.format_exc()}")
        
        bpy.data.meshes.remove(cross_section_mesh)

    def export_to_dxf(self, mesh, filename):
        doc = ezdxf.new('R2010')
        msp = doc.modelspace()

        entity_count = 0
        for edge in mesh.edges:
            v1 = mesh.vertices[edge.vertices[0]].co
            v2 = mesh.vertices[edge.vertices[1]].co
            msp.add_line((v1.x, v1.y), (v2.x, v2.y))
            entity_count += 1

        doc.saveas(filename)
        return entity_count

def register():
    bpy.utils.register_class(SliceObjectOperator)

def unregister():
    bpy.utils.unregister_class(SliceObjectOperator)

if __name__ == "__main__":
    register()
    
    # To trigger the operator, press F3 in Blender and search for "Slice Object and Export DXF"
    bpy.ops.object.slice_object_operator('INVOKE_DEFAULT')
4 Likes