Newer
Older
# ##### BEGIN GPL LICENSE BLOCK #####
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software Foundation,
# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# ##### END GPL LICENSE BLOCK #####
# <pep8-80 compliant>
# All Operator
import bpy
from bpy.types import Operator
Mikhail Rachinskiy
committed
import bmesh
def clean_float(text):
# strip trailing zeros: 0.000 -> 0.0
index = text.rfind(".")
if index != -1:
index += 2
head, tail = text[:index], text[index:]
tail = tail.rstrip("0")
text = head + tail
return text
# ---------
# Mesh Info
class MESH_OT_Print3D_Info_Volume(Operator):
"""Report the volume of the active mesh"""
bl_idname = "mesh.print3d_info_volume"
bl_label = "Print3D Info Volume"
def execute(self, context):
scene = context.scene
unit = scene.unit_settings
scale = 1.0 if unit.system == 'NONE' else unit.scale_length
obj = context.active_object
bm = mesh_helpers.bmesh_copy_from_object(obj, apply_modifiers=True)
volume = bm.calc_volume()
if unit.system == 'METRIC':
volume = volume * (scale ** 3.0) / (0.01 ** 3.0)
volume_fmt = clean_float(f"{volume:.4f}") + " cm"
elif unit.system == 'IMPERIAL':
volume = volume * (scale ** 3.0) / (0.0254 ** 3.0)
volume_fmt = clean_float(f"{volume:.4f}") + ' "'
else:
volume_fmt = clean_float(f"{volume:.8f}")
report.update((f"Volume: {volume_fmt}³", None))
class MESH_OT_Print3D_Info_Area(Operator):
"""Report the surface area of the active mesh"""
bl_idname = "mesh.print3d_info_area"
bl_label = "Print3D Info Area"
def execute(self, context):
scene = context.scene
unit = scene.unit_settings
scale = 1.0 if unit.system == 'NONE' else unit.scale_length
obj = context.active_object
bm = mesh_helpers.bmesh_copy_from_object(obj, apply_modifiers=True)
area = mesh_helpers.bmesh_calc_area(bm)
bm.free()
if unit.system == 'METRIC':
area = area * (scale ** 2.0) / (0.01 ** 2.0)
area_fmt = clean_float(f"{area:.4f}") + " cm"
elif unit.system == 'IMPERIAL':
area = area * (scale ** 2.0) / (0.0254 ** 2.0)
area_fmt = clean_float(f"{area:.4f}") + ' "'
else:
area_fmt = clean_float(f"{area:.8f}")
report.update((f"Area: {area_fmt}²", None))
return {'FINISHED'}
# ---------------
# Geometry Checks
def execute_check(self, context):
obj = context.active_object
info = []
self.main_check(obj, info)
report.update(*info)
multiple_obj_warning(self, context)
def multiple_obj_warning(self, context):
if len(context.selected_objects) > 1:
self.report({"INFO"}, "Multiple selected objects. Only the active one will be evaluated")
class MESH_OT_Print3D_Check_Solid(Operator):
"""Check for geometry is solid (has valid inside/outside) and correct normals"""
bl_idname = "mesh.print3d_check_solid"
bl_label = "Print3D Check Solid"
@staticmethod
def main_check(obj, info):
bm = mesh_helpers.bmesh_copy_from_object(obj, transform=False, triangulate=False)
edges_non_manifold = array.array('i', (i for i, ele in enumerate(bm.edges) if not ele.is_manifold))
edges_non_contig = array.array(
'i',
(i for i, ele in enumerate(bm.edges) if ele.is_manifold and (not ele.is_contiguous)),
)
info.append((f"Non Manifold Edge: {len(edges_non_manifold)}", (bmesh.types.BMEdge, edges_non_manifold)))
info.append((f"Bad Contig. Edges: {len(edges_non_contig)}", (bmesh.types.BMEdge, edges_non_contig)))
bm.free()
def execute(self, context):
return execute_check(self, context)
class MESH_OT_Print3D_Check_Intersections(Operator):
"""Check geometry for self intersections"""
bl_idname = "mesh.print3d_check_intersect"
bl_label = "Print3D Check Intersections"
@staticmethod
def main_check(obj, info):
faces_intersect = mesh_helpers.bmesh_check_self_intersect_object(obj)
info.append((f"Intersect Face: {len(faces_intersect)}", (bmesh.types.BMFace, faces_intersect)))
def execute(self, context):
return execute_check(self, context)
class MESH_OT_Print3D_Check_Degenerate(Operator):
"""Check for degenerate geometry that may not print properly """ \
"""(zero area faces, zero length edges)"""
bl_idname = "mesh.print3d_check_degenerate"
bl_label = "Print3D Check Degenerate"
@staticmethod
def main_check(obj, info):
scene = bpy.context.scene
print_3d = scene.print_3d
threshold = print_3d.threshold_zero
bm = mesh_helpers.bmesh_copy_from_object(obj, transform=False, triangulate=False)
faces_zero = array.array('i', (i for i, ele in enumerate(bm.faces) if ele.calc_area() <= threshold))
edges_zero = array.array('i', (i for i, ele in enumerate(bm.edges) if ele.calc_length() <= threshold))
info.append((f"Zero Faces: {len(faces_zero)}", (bmesh.types.BMFace, faces_zero)))
info.append((f"Zero Edges: {len(edges_zero)}", (bmesh.types.BMEdge, edges_zero)))
bm.free()
def execute(self, context):
return execute_check(self, context)
class MESH_OT_Print3D_Check_Distorted(Operator):
"""Check for non-flat faces """
bl_idname = "mesh.print3d_check_distort"
bl_label = "Print3D Check Distorted Faces"
@staticmethod
def main_check(obj, info):
scene = bpy.context.scene
print_3d = scene.print_3d
angle_distort = print_3d.angle_distort
bm = mesh_helpers.bmesh_copy_from_object(obj, transform=True, triangulate=False)
bm.normal_update()
faces_distort = array.array(
'i',
(i for i, ele in enumerate(bm.faces) if mesh_helpers.face_is_distorted(ele, angle_distort))
)
info.append((f"Non-Flat Faces: {len(faces_distort)}", (bmesh.types.BMFace, faces_distort)))
bm.free()
def execute(self, context):
return execute_check(self, context)
class MESH_OT_Print3D_Check_Thick(Operator):
"""Check geometry is above the minimum thickness preference """ \
"""(relies on correct normals)"""
bl_idname = "mesh.print3d_check_thick"
bl_label = "Print3D Check Thickness"
@staticmethod
def main_check(obj, info):
scene = bpy.context.scene
print_3d = scene.print_3d
faces_error = mesh_helpers.bmesh_check_thick_object(obj, print_3d.thickness_min)
info.append((f"Thin Faces: {len(faces_error)}", (bmesh.types.BMFace, faces_error)))
def execute(self, context):
return execute_check(self, context)
class MESH_OT_Print3D_Check_Sharp(Operator):
"""Check edges are below the sharpness preference"""
bl_idname = "mesh.print3d_check_sharp"
bl_label = "Print3D Check Sharp"
@staticmethod
def main_check(obj, info):
scene = bpy.context.scene
print_3d = scene.print_3d
angle_sharp = print_3d.angle_sharp
bm = mesh_helpers.bmesh_copy_from_object(obj, transform=True, triangulate=False)
bm.normal_update()
edges_sharp = [
ele.index for ele in bm.edges
if ele.is_manifold and ele.calc_face_angle_signed() > angle_sharp
]
info.append((f"Sharp Edge: {len(edges_sharp)}", (bmesh.types.BMEdge, edges_sharp)))
bm.free()
def execute(self, context):
return execute_check(self, context)
class MESH_OT_Print3D_Check_Overhang(Operator):
"""Check faces don't overhang past a certain angle"""
bl_idname = "mesh.print3d_check_overhang"
bl_label = "Print3D Check Overhang"
@staticmethod
def main_check(obj, info):
import math
from mathutils import Vector
scene = bpy.context.scene
print_3d = scene.print_3d
angle_overhang = (math.pi / 2.0) - print_3d.angle_overhang
if angle_overhang == math.pi:
info.append(("Skipping Overhang", ()))
return
bm = mesh_helpers.bmesh_copy_from_object(obj, transform=True, triangulate=False)
bm.normal_update()
z_down = Vector((0, 0, -1.0))
z_down_angle = z_down.angle
# 4.0 ignores zero area faces
faces_overhang = [
ele.index for ele in bm.faces
if z_down_angle(ele.normal, 4.0) < angle_overhang
]
info.append((f"Overhang Face: {len(faces_overhang)}", (bmesh.types.BMFace, faces_overhang)))
bm.free()
def execute(self, context):
return execute_check(self, context)
class MESH_OT_Print3D_Check_All(Operator):
"""Run all checks"""
bl_idname = "mesh.print3d_check_all"
bl_label = "Print3D Check All"
check_cls = (
MESH_OT_Print3D_Check_Solid,
MESH_OT_Print3D_Check_Intersections,
MESH_OT_Print3D_Check_Degenerate,
MESH_OT_Print3D_Check_Distorted,
MESH_OT_Print3D_Check_Thick,
MESH_OT_Print3D_Check_Sharp,
MESH_OT_Print3D_Check_Overhang,
def execute(self, context):
obj = context.active_object
info = []
for cls in self.check_cls:
cls.main_check(obj, info)
report.update(*info)
multiple_obj_warning(self, context)
class MESH_OT_Print3D_Clean_Isolated(Operator):
"""Cleanup isolated vertices and edges"""
bl_idname = "mesh.print3d_clean_isolated"
bl_label = "Print3D Clean Isolated "
bl_options = {'REGISTER', 'UNDO'}
def execute(self, context):
obj = context.active_object
bm = mesh_helpers.bmesh_from_object(obj)
info = []
change = False
def face_is_isolated(ele):
for loop in ele.loops:
loop_next = loop.link_loop_radial_next
if loop is not loop_next:
return False
return True
def edge_is_isolated(ele):
return ele.is_wire
def vert_is_isolated(ele):
# --- face
elems_remove = [ele for ele in bm.faces if face_is_isolated(ele)]
remove = bm.faces.remove
for ele in elems_remove:
remove(ele)
change |= bool(elems_remove)
info.append((f"Faces Removed: {len(elems_remove)}", None))
# --- edge
elems_remove = [ele for ele in bm.edges if edge_is_isolated(ele)]
remove = bm.edges.remove
for ele in elems_remove:
remove(ele)
change |= bool(elems_remove)
info.append((f"Edge Removed: {len(elems_remove)}", None))
# --- vert
elems_remove = [ele for ele in bm.verts if vert_is_isolated(ele)]
remove = bm.verts.remove
for ele in elems_remove:
remove(ele)
change |= bool(elems_remove)
info.append((f"Verts Removed: {len(elems_remove)}", None))
del elems_remove
# ---
report.update(*info)
if change:
mesh_helpers.bmesh_to_object(obj, bm)
return {'FINISHED'}
else:
return {'CANCELLED'}
class MESH_OT_Print3D_Clean_Distorted(Operator):
"""Tessellate distorted faces"""
bl_idname = "mesh.print3d_clean_distorted"
bl_label = "Print3D Clean Distorted"
bl_options = {'REGISTER', 'UNDO'}
def execute(self, context):
scene = bpy.context.scene
print_3d = scene.print_3d
angle_distort = print_3d.angle_distort
obj = context.active_object
bm = mesh_helpers.bmesh_from_object(obj)
bm.normal_update()
elems_triangulate = [ele for ele in bm.faces if mesh_helpers.face_is_distorted(ele, angle_distort)]
if elems_triangulate:
bmesh.ops.triangulate(bm, faces=elems_triangulate)
mesh_helpers.bmesh_to_object(obj, bm)
return {'FINISHED'}
return {'CANCELLED'}
class MESH_OT_Print3D_Clean_Non_Manifold(Operator):
"""Cleanup problems, like holes, non-manifold vertices, and inverted normals"""
bl_idname = "mesh.print3d_clean_non_manifold"
bl_label = "Print3D Clean Non-Manifold and Inverted"
bl_options = {'REGISTER', 'UNDO'}
threshold: bpy.props.FloatProperty(
name="threshold",
description="Minimum distance between elements to merge",
default=0.0001,
)
sides: bpy.props.IntProperty(
name="sides",
description="Number of sides in hole required to fill",
def execute(self, context):
self.context = context
mode_orig = context.mode
self.setup_environment()
bm_key_orig = self.elem_count(context)
self.delete_loose()
self.remove_doubles(self.threshold)
self.dissolve_degenerate(self.threshold)
self.fix_non_manifold(context, self.sides) # may take a while
self.make_normals_consistently_outwards()
bm_key = self.elem_count(context)
if mode_orig != 'EDIT_MESH':
bpy.ops.object.mode_set(mode='OBJECT')
verts = bm_key[0] - bm_key_orig[0]
edges = bm_key[1] - bm_key_orig[1]
faces = bm_key[2] - bm_key_orig[2]
self.report({'INFO'}, f"Modified Verts:{verts:+}, Edges:{edges:+}, Faces:{faces:+}")
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
return {'FINISHED'}
@staticmethod
def elem_count(context):
bm = bmesh.from_edit_mesh(context.edit_object.data)
return len(bm.verts), len(bm.edges), len(bm.faces)
@staticmethod
def setup_environment():
"""set the mode as edit, select mode as vertices, and reveal hidden vertices"""
bpy.ops.object.mode_set(mode='EDIT')
bpy.ops.mesh.select_mode(type='VERT')
bpy.ops.mesh.reveal()
@staticmethod
def remove_doubles(threshold):
"""remove duplicate vertices"""
bpy.ops.mesh.select_all(action='SELECT')
bpy.ops.mesh.remove_doubles(threshold=threshold)
@staticmethod
def delete_loose():
"""delete loose vertices/edges/faces"""
bpy.ops.mesh.select_all(action='SELECT')
bpy.ops.mesh.delete_loose()
@staticmethod
def dissolve_degenerate(threshold):
"""dissolve zero area faces and zero length edges"""
bpy.ops.mesh.select_all(action='SELECT')
bpy.ops.mesh.dissolve_degenerate(threshold=threshold)
@staticmethod
def make_normals_consistently_outwards():
"""have all normals face outwards"""
bpy.ops.mesh.select_all(action='SELECT')
bpy.ops.mesh.normals_make_consistent()
@classmethod
def fix_non_manifold(cls, context, sides):
"""naive iterate-until-no-more approach for fixing manifolds"""
total_non_manifold = cls.count_non_manifold_verts(context)
if not total_non_manifold:
return
bm_states = set()
bm_key = cls.elem_count(context)
bm_states.add(bm_key)
while True:
cls.fill_non_manifold(sides)
cls.delete_newly_generated_non_manifold_verts()
bm_key = cls.elem_count(context)
if bm_key in bm_states:
break
else:
bm_states.add(bm_key)
@staticmethod
def select_non_manifold_verts(
use_wire=False,
use_boundary=False,
use_multi_face=False,
use_non_contiguous=False,
"""select non-manifold vertices"""
bpy.ops.mesh.select_non_manifold(
extend=False,
use_wire=use_wire,
use_boundary=use_boundary,
use_multi_face=use_multi_face,
use_non_contiguous=use_non_contiguous,
use_verts=use_verts,
)
@classmethod
def count_non_manifold_verts(cls, context):
"""return a set of coordinates of non-manifold vertices"""
cls.select_non_manifold_verts(use_wire=True, use_boundary=True, use_verts=True)
bm = bmesh.from_edit_mesh(context.edit_object.data)
return sum((1 for v in bm.verts if v.select))
@classmethod
def fill_non_manifold(cls, sides):
"""fill holes and then fill in any remnant non-manifolds"""
bpy.ops.mesh.select_all(action='SELECT')
bpy.ops.mesh.fill_holes(sides=sides)
@classmethod
def delete_newly_generated_non_manifold_verts(cls):
"""delete any newly generated vertices from the filling repair"""
cls.select_non_manifold_verts(use_wire=True, use_verts=True)
bpy.ops.mesh.delete(type='VERT')
class MESH_OT_Print3D_Clean_Thin(Operator):
"""Ensure minimum thickness"""
bl_idname = "mesh.print3d_clean_thin"
bl_label = "Print3D Clean Thin"
bl_options = {'REGISTER', 'UNDO'}
def execute(self, context):
return {'FINISHED'}
# -------------
# Select Report
# ... helper function for info UI
class MESH_OT_Print3D_Select_Report(Operator):
bl_idname = "mesh.print3d_select_report"
bl_label = "Print3D Select Report"
bl_options = {'INTERNAL'}
_type_to_mode = {
bmesh.types.BMVert: 'VERT',
bmesh.types.BMEdge: 'EDGE',
bmesh.types.BMFace: 'FACE',
_type_to_attr = {
bmesh.types.BMVert: "verts",
bmesh.types.BMEdge: "edges",
bmesh.types.BMFace: "faces",
def execute(self, context):
obj = context.edit_object
info = report.info()
text, data = info[self.index]
bm_type, bm_array = data
bpy.ops.mesh.reveal()
bpy.ops.mesh.select_all(action='DESELECT')
bpy.ops.mesh.select_mode(type=self._type_to_mode[bm_type])
bm = bmesh.from_edit_mesh(obj.data)
elems = getattr(bm, MESH_OT_Print3D_Select_Report._type_to_attr[bm_type])[:]
try:
for i in bm_array:
elems[i].select_set(True)
except:
# possible arrays are out of sync
self.report({'WARNING'}, "Report is out of date, re-run check")
# bpy.ops.view3d.view_selected(use_all_regions=False)
# -----------
# Scale to...
def _scale(scale, report=None, report_suffix=""):
if scale != 1.0:
bpy.ops.transform.resize(
value=(scale,) * 3,
mirror=False,
use_proportional_edit=False,
snap=False,
texture_space=False,
)
if report is not None:
scale_fmt = clean_float(f"{scale:.6f}")
report({'INFO'}, f"Scaled by {scale_fmt}{report_suffix}")
class MESH_OT_Print3D_Scale_To_Volume(Operator):
"""Scale edit-mesh or selected-objects to a set volume"""
bl_idname = "mesh.print3d_scale_to_volume"
bl_label = "Scale to Volume"
bl_options = {'REGISTER', 'UNDO'}
volume_init: FloatProperty(
options={'HIDDEN'},
)
volume: FloatProperty(
name="Volume",
unit='VOLUME',
min=0.0, max=100000.0,
)
def execute(self, context):
import math
scale = math.pow(self.volume, 1 / 3) / math.pow(self.volume_init, 1 / 3)
scale_fmt = clean_float(f"{scale:.6f}")
self.report({'INFO'}, f"Scaled by {scale_fmt}")
_scale(scale, self.report)
return {'FINISHED'}
def invoke(self, context, event):
def calc_volume(obj):
bm = mesh_helpers.bmesh_copy_from_object(obj, apply_modifiers=True)
volume = bm.calc_volume(signed=True)
bm.free()
return volume
if context.mode == 'EDIT_MESH':
volume = calc_volume(context.edit_object)
else:
volume = sum(calc_volume(obj) for obj in context.selected_editable_objects if obj.type == 'MESH')
if volume == 0.0:
self.report({'WARNING'}, "Object has zero volume")
return {'CANCELLED'}
self.volume_init = self.volume = abs(volume)
wm = context.window_manager
return wm.invoke_props_dialog(self)
class MESH_OT_Print3D_Scale_To_Bounds(Operator):
"""Scale edit-mesh or selected-objects to fit within a maximum length"""
bl_idname = "mesh.print3d_scale_to_bounds"
bl_label = "Scale to Bounds"
bl_options = {'REGISTER', 'UNDO'}
length_init: FloatProperty(
options={'HIDDEN'},
)
axis_init: IntProperty(
options={'HIDDEN'},
)
length: FloatProperty(
name="Length Limit",
unit='LENGTH',
def execute(self, context):
scale = self.length / self.length_init
axis = "XYZ"[self.axis_init]
_scale(scale, report=self.report, report_suffix=f", Clamping {axis}-Axis")
return {'FINISHED'}
def invoke(self, context, event):
from mathutils import Vector
def calc_length(vecs):
return max(((max(v[i] for v in vecs) - min(v[i] for v in vecs)), i) for i in range(3))
if context.mode == 'EDIT_MESH':
length, axis = calc_length(
[Vector(v) @ obj.matrix_world for obj in [context.edit_object] for v in obj.bound_box]
)
length, axis = calc_length(
[
Vector(v) @ obj.matrix_world for obj in context.selected_editable_objects
if obj.type == 'MESH'
for v in obj.bound_box
]
)
if length == 0.0:
self.report({'WARNING'}, "Object has zero bounds")
return {'CANCELLED'}
self.length_init = self.length = length
self.axis_init = axis
wm = context.window_manager
return wm.invoke_props_dialog(self)
class MESH_OT_Print3D_Export(Operator):
"""Export active object using print3d settings"""
bl_idname = "mesh.print3d_export"
bl_label = "Print3D Export"
def execute(self, context):
from . import export
info = []
ret = export.write_mesh(context, info, self.report)
report.update(*info)
if ret:
return {'FINISHED'}