# Blender Addon: 3D Print Mesh Fixer. save as (plain text) meshfix.py
# install add on in blender edit > preferences > addons.
# This script creates a panel in the 3D View's Sidebar (N-Panel)
# with basic and advanced tools to prepare meshes for 3D printing.
bl_info = {
"name": "3D Print Mesh Fixer",
"author": "Peter Farell",
"version": (1, 5, 0),
"blender": (2, 83, 0), # Version for Remesh modifier
"location": "View3D > Sidebar > BruteForceMesh",
"description": "Automates mesh fixes and provides targeted tools for stubborn errors.",
"warning": "The Aggressive Fix (Remesh) will alter topology and can be slow on high-poly meshes.",
"doc_url": "",
"category": "Mesh",
}
import bpy
import bmesh
# --- UTILITY FUNCTIONS ---
def get_non_manifold_count(obj):
"""Efficiently counts non-manifold vertices on an object using bmesh."""
if obj.mode != 'OBJECT':
bpy.ops.object.mode_set(mode='OBJECT')
bm = bmesh.new()
bm.from_mesh(obj.data)
bm.edges.ensure_lookup_table()
non_manifold_edges = [e for e in bm.edges if not e.is_manifold]
bm.free()
return len(non_manifold_edges)
def create_backup(context, obj, self_operator):
"""Creates a hidden backup of the object if it's below a polygon threshold."""
poly_threshold = 250000
poly_count = len(obj.data.polygons)
if poly_count < poly_threshold:
try:
backup_obj = obj.copy()
backup_obj.data = obj.data.copy()
backup_obj.name = obj.name + "_backup"
context.collection.objects.link(backup_obj)
backup_obj.hide_set(True)
backup_obj.hide_render = True
self_operator.report({'INFO'}, f"Created backup object: {backup_obj.name}")
except Exception as e:
self_operator.report({'ERROR'}, f"Could not create backup: {e}")
else:
self_operator.report({'WARNING'}, f"Mesh has {poly_count} polys. Skipping backup to save memory.")
# --- MAIN OPERATOR ---
class MESH_OT_fix_for_3d_print_standard(bpy.types.Operator):
"""(Gentle) Automates fixing common mesh problems with a feedback loop"""
bl_idname = "mesh.fix_for_3d_print_standard"
bl_label = "Fix Mesh"
bl_options = {'REGISTER', 'UNDO'}
# Properties for toggling repair steps
do_merge: bpy.props.BoolProperty(name="Merge by Distance", default=True)
do_normals: bpy.props.BoolProperty(name="Recalculate Normals", default=True)
do_loose: bpy.props.BoolProperty(name="Delete Loose Geometry", default=True)
do_holes: bpy.props.BoolProperty(name="Fill Holes", default=True)
do_dissolve: bpy.props.BoolProperty(name="Limited Dissolve", default=True, description="Cleans up flat surfaces after other operations")
do_triangulate: bpy.props.BoolProperty(name="Triangulate", default=True)
max_iterations: bpy.props.IntProperty(
name="Max Iterations",
description="Number of times to repeat the fix process if errors persist.",
default=3, min=1, max=10
)
@classmethod
def poll(cls, context):
return context.active_object is not None and context.active_object.type == 'MESH'
def draw(self, context):
layout = self.layout
layout.prop(self, "max_iterations")
layout.separator()
layout.prop(self, "do_merge")
layout.prop(self, "do_normals")
layout.prop(self, "do_loose")
layout.prop(self, "do_holes")
layout.prop(self, "do_dissolve")
layout.prop(self, "do_triangulate")
def execute(self, context):
obj = context.active_object
original_mode = obj.mode
if original_mode != 'OBJECT':
bpy.ops.object.mode_set(mode='OBJECT')
create_backup(context, obj, self)
self.report({'INFO'}, "Starting standard mesh fix process...")
last_error_count = float('inf')
initial_error_count = get_non_manifold_count(obj)
if initial_error_count == 0:
self.report({'INFO'}, "No non-manifold errors found. Nothing to do.")
return {'FINISHED'}
for i in range(self.max_iterations):
self.report({'INFO'}, f"--- Starting Repair Loop {i+1}/{self.max_iterations} ---")
# --- Perform all operations within a single bmesh block for stability and speed ---
bm = bmesh.new()
bm.from_mesh(obj.data)
if self.do_merge:
bmesh.ops.remove_doubles(bm, verts=bm.verts, dist=0.0001)
if self.do_normals:
bmesh.ops.recalc_face_normals(bm, faces=bm.faces)
if self.do_loose:
loose_verts = [v for v in bm.verts if not v.link_edges]
if loose_verts:
bmesh.ops.delete(bm, geom=loose_verts, context='VERTS')
if self.do_holes:
bmesh.ops.holes_fill(bm, edges=[e for e in bm.edges if e.is_boundary])
if self.do_dissolve:
bmesh.ops.dissolve_limit(bm, angle_limit=0.0872665, verts=bm.verts, edges=bm.edges)
if self.do_triangulate:
bmesh.ops.triangulate(bm, faces=bm.faces[:])
# Update the mesh and free the bmesh
bm.to_mesh(obj.data)
bm.free()
obj.data.update()
current_error_count = get_non_manifold_count(obj)
if current_error_count == 0:
self.report({'INFO'}, f"SUCCESS: Mesh fixed after {i+1} loop(s).")
break
if current_error_count >= last_error_count:
self.report({'WARNING'}, f"No improvement after loop {i+1}. Stopping.")
break
last_error_count = current_error_count
self.report({'INFO'}, f"Loop {i+1} finished. {current_error_count} errors remain. Continuing...")
if current_error_count > 0:
self.report({'WARNING'}, f"WARNING: {current_error_count} non-manifold elements remain.")
bpy.ops.object.mode_set(mode='EDIT')
bpy.ops.mesh.select_all(action='DESELECT')
bpy.ops.mesh.select_non_manifold()
else:
bpy.ops.object.mode_set(mode=original_mode)
return {'FINISHED'}
def invoke(self, context, event):
return context.window_manager.invoke_props_dialog(self)
# --- TARGETED FIX OPERATORS ---
class MESH_OT_reselect_problems(bpy.types.Operator):
"""Selects non-manifold geometry"""
bl_idname = "mesh.reselect_problems"
bl_label = "Re-Select Problem Areas"
bl_options = {'REGISTER', 'UNDO'}
def execute(self, context):
if context.object.mode != 'EDIT':
bpy.ops.object.mode_set(mode='EDIT')
bpy.ops.mesh.select_all(action='DESELECT')
bpy.ops.mesh.select_non_manifold()
self.report({'INFO'}, "Selected non-manifold geometry.")
return {'FINISHED'}
class MESH_OT_close_gaps(bpy.types.Operator):
"""Attempts to close small gaps by displacing and merging"""
bl_idname = "mesh.close_small_gaps"
bl_label = "Close Small Gaps"
bl_options = {'REGISTER', 'UNDO'}
def execute(self, context):
obj = context.active_object
self.report({'INFO'}, "Attempting to close small gaps...")
bpy.ops.object.mode_set(mode='EDIT')
bpy.ops.mesh.select_all(action='SELECT')
# Displace outwards slightly
bpy.ops.transform.shrink_fatten(value=0.0001)
# Merge the now-overlapping vertices
bpy.ops.mesh.remove_doubles(threshold=0.0002)
bpy.ops.object.mode_set(mode='OBJECT')
self.report({'INFO'}, "Gap closing attempt finished. Check for remaining errors.")
return {'FINISHED'}
class MESH_OT_rebuild_shell(bpy.types.Operator):
"""Rebuilds the outer shell using a Shrinkwrap modifier"""
bl_idname = "mesh.rebuild_shell"
bl_label = "Rebuild Outer Shell"
bl_options = {'REGISTER', 'UNDO'}
thickness: bpy.props.FloatProperty(name="Thickness", default=0.001, min=0.0, unit='LENGTH')
offset: bpy.props.FloatProperty(name="Offset", default=0.0, unit='LENGTH')
def execute(self, context):
target_obj = context.active_object
self.report({'INFO'}, "Rebuilding outer shell...")
# Create a new mesh object (a simple cube is fine)
bpy.ops.mesh.primitive_cube_add(enter_editmode=False, align='WORLD', location=target_obj.location)
new_obj = context.active_object
new_obj.name = target_obj.name + "_shell"
# Add a subdivision surface modifier for a smoother base
subdiv = new_obj.modifiers.new(name="Subdiv", type='SUBSURF')
subdiv.levels = 3
# Add Shrinkwrap modifier
shrinkwrap = new_obj.modifiers.new(name="Shrinkwrap", type='SHRINKWRAP')
shrinkwrap.target = target_obj
# Add Solidify modifier
solidify = new_obj.modifiers.new(name="Solidify", type='SOLIDIFY')
solidify.thickness = self.thickness
solidify.offset = self.offset
# Apply all modifiers
bpy.ops.object.modifier_apply(modifier=subdiv.name)
bpy.ops.object.modifier_apply(modifier=shrinkwrap.name)
bpy.ops.object.modifier_apply(modifier=solidify.name)
self.report({'INFO'}, f"Created new manifold shell object: {new_obj.name}")
return {'FINISHED'}
def invoke(self, context, event):
return context.window_manager.invoke_props_dialog(self)
# --- AGGRESSIVE FIX OPERATOR ---
class MESH_OT_fix_for_3d_print_aggressive(bpy.types.Operator):
"""(Aggressive) Rebuilds the mesh using Voxel Remesh"""
bl_idname = "mesh.fix_for_3d_print_aggressive"
bl_label = "Aggressive Fix (Remesh)"
bl_options = {'REGISTER', 'UNDO'}
# ... (rest of the class is unchanged) ...
voxel_size: bpy.props.FloatProperty(
name="Voxel Size",
description="Controls detail. Smaller is more detailed but slower.",
default=0.01, min=0.001, max=1.0, unit='LENGTH'
)
@classmethod
def poll(cls, context):
return context.active_object is not None and context.active_object.type == 'MESH'
def execute(self, context):
obj = context.active_object
if obj.mode != 'OBJECT':
bpy.ops.object.mode_set(mode='OBJECT')
create_backup(context, obj, self)
self.report({'INFO'}, f"Starting aggressive remesh with voxel size: {self.voxel_size:.4f}")
remesh_mod = obj.modifiers.new(name="PrintFixRemesh", type='REMESH')
remesh_mod.mode = 'VOXEL'
remesh_mod.voxel_size = self.voxel_size
remesh_mod.use_smooth_shade = True
bpy.ops.object.modifier_apply(modifier=remesh_mod.name)
self.report({'INFO'}, "Remesh complete. Running a standard cleanup pass...")
bpy.ops.mesh.fix_for_3d_print_standard('INVOKE_DEFAULT', do_dissolve=False, max_iterations=1)
return {'FINISHED'}
def invoke(self, context, event):
return context.window_manager.invoke_props_dialog(self)
# --- UI PANELS ---
class VIEW3D_PT_mesh_fixer_panel(bpy.types.Panel):
"""Creates a Panel in the 3D View Sidebar"""
bl_label = "3D Print Fixer"
bl_idname = "VIEW3D_PT_mesh_fixer"
bl_space_type = 'VIEW_3D'
bl_region_type = 'UI'
bl_category = '3D Print'
def draw(self, context):
layout = self.layout
box = layout.box()
box.label(text="Standard Repair", icon='TOOL_SETTINGS')
row = box.row()
row.scale_y = 1.5
row.operator(MESH_OT_fix_for_3d_print_standard.bl_idname, text="Fix Mesh...")
box.label(text="Gentle fixes with a feedback loop.", icon='INFO')
layout.separator()
box = layout.box()
box.label(text="Aggressive Repair", icon='MOD_REMESH')
row = box.row()
row.scale_y = 1.5
row.operator(MESH_OT_fix_for_3d_print_aggressive.bl_idname, text="Aggressive Fix...")
box.label(text="Rebuilds mesh to fix intersections.", icon='ERROR')
box.label(text="May lose fine details.", icon='ERROR')
layout.separator()
info_box = layout.box()
info_box.label(text="Note: A hidden backup is made for", icon='LIGHT')
info_box.label(text="meshes under 250k polygons.")
class VIEW3D_PT_targeted_fix_panel(bpy.types.Panel):
"""Shows contextual operators when non-manifold errors are found"""
bl_label = "Targeted Fixes"
bl_idname = "VIEW3D_PT_targeted_fixer"
bl_space_type = 'VIEW_3D'
bl_region_type = 'UI'
bl_category = '3D Print'
bl_options = {'DEFAULT_CLOSED'}
@classmethod
def poll(cls, context):
obj = context.active_object
# Only show this panel if there is an active mesh object and it has selected vertices (implying errors are selected)
return (obj is not None and
obj.type == 'MESH' and
obj.data.total_vert_sel > 0 and
context.mode == 'EDIT_MESH')
def draw(self, context):
layout = self.layout
layout.label(text="Errors remain. Try these tools:", icon='ERROR')
box = layout.box()
box.label(text="Close Tiny Holes", icon='MOD_WAVE')
box.operator(MESH_OT_close_gaps.bl_idname)
box = layout.box()
box.label(text="Fix Internal Geometry", icon='MOD_SHRINKWRAP')
box.operator(MESH_OT_rebuild_shell.bl_idname, text="Rebuild Outer Shell...")
layout.separator()
layout.operator(MESH_OT_reselect_problems.bl_idname)
# --- REGISTRATION ---
classes = (
MESH_OT_fix_for_3d_print_standard,
MESH_OT_fix_for_3d_print_aggressive,
MESH_OT_reselect_problems,
MESH_OT_close_gaps,
MESH_OT_rebuild_shell,
VIEW3D_PT_mesh_fixer_panel,
VIEW3D_PT_targeted_fix_panel,
)
def register():
for cls in classes:
bpy.utils.register_class(cls)
def unregister():
for cls in reversed(classes):
bpy.utils.unregister_class(cls)
if __name__ == "__main__":
register()