From 7e6e45d7a7c80798a21684c278334188cce0e501 Mon Sep 17 00:00:00 2001 From: Campbell Barton Date: Sun, 24 Mar 2013 14:30:17 +0000 Subject: move print toolbox into trunk [[Split portion of a mixed commit.]] --- object_print3d_utils/__init__.py | 147 ++++++++++ object_print3d_utils/export.py | 159 +++++++++++ object_print3d_utils/mesh_helpers.py | 323 ++++++++++++++++++++++ object_print3d_utils/operators.py | 511 +++++++++++++++++++++++++++++++++++ object_print3d_utils/readme.rst | 4 + object_print3d_utils/report.py | 30 ++ object_print3d_utils/todo.rst | 77 ++++++ object_print3d_utils/ui.py | 128 +++++++++ 8 files changed, 1379 insertions(+) create mode 100644 object_print3d_utils/__init__.py create mode 100644 object_print3d_utils/export.py create mode 100644 object_print3d_utils/mesh_helpers.py create mode 100644 object_print3d_utils/operators.py create mode 100644 object_print3d_utils/readme.rst create mode 100644 object_print3d_utils/report.py create mode 100644 object_print3d_utils/todo.rst create mode 100644 object_print3d_utils/ui.py (limited to 'object_print3d_utils') diff --git a/object_print3d_utils/__init__.py b/object_print3d_utils/__init__.py new file mode 100644 index 00000000..3dbec458 --- /dev/null +++ b/object_print3d_utils/__init__.py @@ -0,0 +1,147 @@ +# ##### 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 ##### + +# + +bl_info = { + "name": "3D Print Toolbox", + "author": "Campbell Barton", + "blender": (2, 65, 0), + "location": "3D View > Toolbox", + "description": "Utilities for 3D printing", + "warning": "", + "wiki_url": "http://wiki.blender.org/index.php/Extensions:2.6/Py/" + "Scripts/Modeling/PrintToolbox", + "tracker_url": "", + "support": 'OFFICIAL', + "category": "Mesh"} + + +if "bpy" in locals(): + import imp + imp.reload(ui) + imp.reload(operators) +else: + import bpy + from bpy.props import (StringProperty, + BoolProperty, + IntProperty, + FloatProperty, + FloatVectorProperty, + EnumProperty, + PointerProperty, + ) + from bpy.types import (Operator, + AddonPreferences, + PropertyGroup, + ) + from . import ui + from . import operators + +import math + +class Print3DSettings(PropertyGroup): + export_format = EnumProperty( + name="Format", + description="Format type to export to", + items=(('STL', "STL", ""), + ('PLY', "PLY", ""), + ('WRL', "VRML2", ""), + ('X3D', "X3D", ""), + ('OBJ', "OBJ", "")), + default='STL', + ) + export_path = StringProperty( + name="Export Directory", + description="Path to directory where the files are created", + default="//", maxlen=1024, subtype="DIR_PATH", + ) + thickness_min = FloatProperty( + name="Thickness", + description="Minimum thickness", + subtype='DISTANCE', + default=0.001, # 1mm + min=0.0, max=1.0, + ) + threshold_zero = FloatProperty( + name="Threshold", + description="Limit for checking zero area/length", + default=0.0001, + precision=5, + min=0.0, max=0.2, + ) + angle_distort = FloatProperty( + name="Angle", + description="Limit for checking distorted faces", + subtype='ANGLE', + default=math.radians(15.0), + min=0.0, max=math.radians(180.0), + ) + angle_sharp = FloatProperty( + name="Angle", + subtype='ANGLE', + default=math.radians(160.0), + min=0.0, max=math.radians(180.0), + ) + angle_overhang = FloatProperty( + name="Angle", + subtype='ANGLE', + default=math.radians(45.0), + min=0.0, max=math.radians(90.0), + ) + +classes = ( + ui.Print3DToolBarObject, + ui.Print3DToolBarMesh, + + operators.Print3DInfoVolume, + operators.Print3DInfoArea, + + operators.Print3DCheckDegenerate, + operators.Print3DCheckDistorted, + operators.Print3DCheckSolid, + operators.Print3DCheckIntersections, + operators.Print3DCheckThick, + operators.Print3DCheckSharp, + operators.Print3DCheckOverhang, + operators.Print3DCheckAll, + + operators.Print3DCleanIsolated, + operators.Print3DCleanDistorted, + operators.Print3DCleanThin, + + operators.Print3DSelectReport, + + operators.Print3DExport, + + Print3DSettings, + ) + + +def register(): + for cls in classes: + bpy.utils.register_class(cls) + + bpy.types.Scene.print_3d = PointerProperty(type=Print3DSettings) + + +def unregister(): + for cls in classes: + bpy.utils.unregister_class(cls) + + del bpy.types.Scene.print_3d diff --git a/object_print3d_utils/export.py b/object_print3d_utils/export.py new file mode 100644 index 00000000..3a2b6677 --- /dev/null +++ b/object_print3d_utils/export.py @@ -0,0 +1,159 @@ +# ##### 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 ##### + +# + +# Export wrappers and integration with external tools. + +import bpy +import os + + +def write_mesh(context, info, report_cb): + scene = context.scene + print_3d = scene.print_3d + + obj_base = scene.object_bases.active + obj = obj_base.object + + export_format = print_3d.export_format + + context_override = context.copy() + + obj_base_tmp = None + + # PLY can only export single mesh objects! + if export_format == 'PLY': + context_backup = context.copy() + bpy.ops.object.mode_set(mode='OBJECT', toggle=False) + + from . import mesh_helpers + obj_base_tmp = mesh_helpers.object_merge(context, context_override["selected_objects"]) + context_override["active_object"] = obj_base_tmp.object + context_override["selected_bases"] = [obj_base_tmp] + context_override["selected_objects"] = [obj_base_tmp.object] + else: + if obj_base not in context_override["selected_bases"]: + context_override["selected_bases"].append(obj_base) + if obj not in context_override["selected_objects"]: + context_override["selected_objects"].append(obj) + + export_path = bpy.path.abspath(print_3d.export_path) + + # Create name 'export_path/blendname-objname' + # add the filename component + if bpy.data.is_saved: + name = os.path.basename(bpy.data.filepath) + name = os.path.splitext(name)[0] + else: + name = "untitled" + # add object name + name += "-%s" % bpy.path.clean_name(obj.name) + + # first ensure the path is created + if export_path: + # this can fail with strange errors, + # if the dir cant be made then we get an error later. + try: + os.makedirs(export_path, exist_ok=True) + except: + import traceback + traceback.print_exc() + + filepath = os.path.join(export_path, name) + + # ensure addon is enabled + import addon_utils + + def addon_ensure(addon_id): + # Enable the addon, dont change preferences. + default_state, loaded_state = addon_utils.check(addon_id) + if not loaded_state: + addon_utils.enable(addon_id, default_set=False) + + if export_format == 'STL': + addon_ensure("io_mesh_stl") + filepath = bpy.path.ensure_ext(filepath, ".stl") + ret = bpy.ops.export_mesh.stl( + context_override, + filepath=filepath, + ascii=False, + use_mesh_modifiers=True, + ) + elif export_format == 'PLY': + addon_ensure("io_mesh_ply") + filepath = bpy.path.ensure_ext(filepath, ".ply") + ret = bpy.ops.export_mesh.ply( + context_override, + filepath=filepath, + use_mesh_modifiers=True, + ) + elif export_format == 'X3D': + addon_ensure("io_scene_x3d") + filepath = bpy.path.ensure_ext(filepath, ".x3d") + ret = bpy.ops.export_scene.x3d( + context_override, + filepath=filepath, + use_mesh_modifiers=True, + use_selection=True, + ) + elif export_format == 'WRL': + addon_ensure("io_scene_vrml2") + filepath = bpy.path.ensure_ext(filepath, ".wrl") + ret = bpy.ops.export_scene.vrml2( + context_override, + filepath=filepath, + use_mesh_modifiers=True, + use_selection=True, + ) + elif export_format == 'OBJ': + addon_ensure("io_scene_obj") + filepath = bpy.path.ensure_ext(filepath, ".obj") + ret = bpy.ops.export_scene.obj( + context_override, + filepath=filepath, + use_mesh_modifiers=True, + use_selection=True, + ) + else: + assert(0) + + if obj_base_tmp is not None: + obj = obj_base_tmp.object + mesh = obj.data + scene.objects.unlink(obj) + bpy.data.objects.remove(obj) + bpy.data.meshes.remove(mesh) + del obj_base_tmp, obj, mesh + + # restore context + base = None + for base in context_backup["selected_bases"]: + base.select = True + del base + scene.objects.active = context_backup["active_object"] + + if 'FINISHED' in ret: + info.append(("%r ok" % os.path.basename(filepath), None)) + + if report_cb is not None: + report_cb({'INFO'}, "Exported: %r" % filepath) + return True + else: + info.append(("%r fail" % os.path.basename(filepath), None)) + return False diff --git a/object_print3d_utils/mesh_helpers.py b/object_print3d_utils/mesh_helpers.py new file mode 100644 index 00000000..82db96bf --- /dev/null +++ b/object_print3d_utils/mesh_helpers.py @@ -0,0 +1,323 @@ +# ##### 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 ##### + +# + +# Generic helper functions, to be used by any modules. + +import bmesh +import array + + +def bmesh_copy_from_object(obj, transform=True, triangulate=True, apply_modifiers=False): + """ + Returns a transformed, triangulated copy of the mesh + """ + + assert(obj.type == 'MESH') + + if apply_modifiers and obj.modifiers: + import bpy + me = obj.to_mesh(bpy.context.scene, True, 'PREVIEW', calc_tessface=False) + bm = bmesh.new() + bm.from_mesh(me) + bpy.data.meshes.remove(me) + del bpy + else: + me = obj.data + if obj.mode == 'EDIT': + bm_orig = bmesh.from_edit_mesh(me) + bm = bm_orig.copy() + else: + bm = bmesh.new() + bm.from_mesh(me) + + # TODO. remove all customdata layers. + # would save ram + + if transform: + bm.transform(obj.matrix_world) + + if triangulate: + bmesh.ops.triangulate(bm, faces=bm.faces, use_beauty=True) + + return bm + + +def bmesh_from_object(obj): + """ + Object/Edit Mode get mesh, use bmesh_to_object() to write back. + """ + me = obj.data + is_editmode = (obj.mode == 'EDIT') + if is_editmode: + bm = bmesh.from_edit_mesh(me) + else: + bm = bmesh.new() + bm.from_mesh(me) + return bm + + +def bmesh_to_object(obj, bm): + """ + Object/Edit Mode update the object. + """ + me = obj.data + is_editmode = (obj.mode == 'EDIT') + if is_editmode: + bmesh.update_edit_mesh(me, True) + else: + bm.to_mesh(me) + # grr... cause an update + if me.vertices: + me.vertices[0].co[0] = me.vertices[0].co[0] + + +def bmesh_calc_volume(bm): + """ + Calculate the volume of a triangulated bmesh. + """ + def tri_signed_volume(p1, p2, p3): + return p1.dot(p2.cross(p3)) / 6.0 + return abs(sum((tri_signed_volume(*(v.co for v in f.verts)) + for f in bm.faces))) + + +def bmesh_calc_area(bm): + """ + Calculate the surface area. + """ + return sum(f.calc_area() for f in bm.faces) + + +def bmesh_check_self_intersect_object(obj): + """ + Check if any faces self intersect + + returns an array of edge index values. + """ + import bpy + + # Heres what we do! + # + # * Take original Mesh. + # * Copy it and triangulate it (keeping list of original edge index values) + # * Move the BMesh into a temp Mesh. + # * Make a temp Object in the scene and assign the temp Mesh. + # * For every original edge - ray-cast on the object to find which intersect. + # * Report all edge intersections. + + # Triangulate + bm = bmesh_copy_from_object(obj, transform=False, triangulate=False) + face_map_index_org = {f: i for i, f in enumerate(bm.faces)} + ret = bmesh.ops.triangulate(bm, faces=bm.faces, use_beauty=False) + face_map = ret["face_map"] + # map new index to original index + face_map_index = {i: face_map_index_org[face_map.get(f, f)] for i, f in enumerate(bm.faces)} + del face_map_index_org + del ret + + # Create a real mesh (lame!) + scene = bpy.context.scene + me_tmp = bpy.data.meshes.new(name="~temp~") + bm.to_mesh(me_tmp) + bm.free() + obj_tmp = bpy.data.objects.new(name=me_tmp.name, object_data=me_tmp) + scene.objects.link(obj_tmp) + scene.update() + ray_cast = obj_tmp.ray_cast + + faces_error = set() + + EPS_NORMAL = 0.0001 + EPS_CENTER = 0.00001 # should always be bigger + + for ed in me_tmp.edges: + v1i, v2i = ed.vertices + v1 = me_tmp.vertices[v1i] + v2 = me_tmp.vertices[v2i] + + # setup the edge with an offset + co_1 = v1.co.copy() + co_2 = v2.co.copy() + co_mid = (co_1 + co_2) * 0.5 + no_mid = (v1.normal + v2.normal).normalized() * EPS_NORMAL + co_1 = co_1.lerp(co_mid, EPS_CENTER) + no_mid + co_2 = co_2.lerp(co_mid, EPS_CENTER) + no_mid + + co, no, index = ray_cast(co_1, co_2) + if index != -1: + faces_error.add(face_map_index[index]) + + scene.objects.unlink(obj_tmp) + bpy.data.objects.remove(obj_tmp) + bpy.data.meshes.remove(me_tmp) + + return array.array('i', faces_error) + + +def bmesh_face_points_random(f, num_points=1, margin=0.05): + import random + from random import uniform + uniform_args = 0.0 + margin, 1.0 - margin + + # for pradictable results + random.seed(f.index) + + vecs = [v.co for v in f.verts] + + for i in range(num_points): + u1 = uniform(*uniform_args) + u2 = uniform(*uniform_args) + u_tot = u1 + u2 + + if u_tot > 1.0: + u1 = 1.0 - u1 + u2 = 1.0 - u2 + + side1 = vecs[1] - vecs[0] + side2 = vecs[2] - vecs[0] + + yield vecs[0] + u1 * side1 + u2 * side2 + + +def bmesh_check_thick_object(obj, thickness): + + import bpy + + # Triangulate + bm = bmesh_copy_from_object(obj, transform=True, triangulate=False) + # map original faces to their index. + face_index_map_org = {f: i for i, f in enumerate(bm.faces)} + ret = bmesh.ops.triangulate(bm, faces=bm.faces, use_beauty=False) + face_map = ret["face_map"] + del ret + # old edge -> new mapping + + # Convert new/old map to index dict. + + # Create a real mesh (lame!) + scene = bpy.context.scene + me_tmp = bpy.data.meshes.new(name="~temp~") + bm.to_mesh(me_tmp) + # bm.free() # delay free + obj_tmp = bpy.data.objects.new(name=me_tmp.name, object_data=me_tmp) + scene.objects.link(obj_tmp) + scene.update() + ray_cast = obj_tmp.ray_cast + + EPS_BIAS = 0.0001 + + faces_error = set() + + bm_faces_new = bm.faces[:] + + for f in bm_faces_new: + no = f.normal + no_sta = no * EPS_BIAS + no_end = no * thickness + for p in bmesh_face_points_random(f, num_points=6): + # Cast the ray backwards + p_a = p - no_sta + p_b = p - no_end + + co, no, index = ray_cast(p_a, p_b) + + if index != -1: + # Add the face we hit + for f_iter in (f, bm_faces_new[index]): + # if the face wasn't triangulated, just use existing + f_org = face_map.get(f_iter, f_iter) + f_org_index = face_index_map_org[f_org] + faces_error.add(f_org_index) + + # finished with bm + bm.free() + + scene.objects.unlink(obj_tmp) + bpy.data.objects.remove(obj_tmp) + bpy.data.meshes.remove(me_tmp) + + return array.array('i', faces_error) + + + +def object_merge(context, objects): + """ + Caller must remove. + """ + + import bpy + + def cd_remove_all_but_active(seq): + tot = len(seq) + if tot > 1: + act = seq.active_index + for i in range(tot - 1, -1, -1): + if i != act: + seq.remove(seq[i]) + + scene = context.scene + + # deselect all + for obj in scene.objects: + obj.select = False + + # add empty object + mesh_base = bpy.data.meshes.new(name="~tmp~") + obj_base = bpy.data.objects.new(name="~tmp~", object_data=mesh_base) + base_base = scene.objects.link(obj_base) + scene.objects.active = obj_base + obj_base.select = True + + # loop over all meshes + for obj in objects: + if obj.type != 'MESH': + continue + + # convert each to a mesh + mesh_new = obj.to_mesh(scene=scene, + apply_modifiers=True, + settings='PREVIEW', + calc_tessface=False) + + # remove non-active uvs/vcols + cd_remove_all_but_active(mesh_new.vertex_colors) + cd_remove_all_but_active(mesh_new.uv_textures) + + # join into base mesh + obj_new = bpy.data.objects.new(name="~tmp-new~", object_data=mesh_new) + base_new = scene.objects.link(obj_new) + obj_new.matrix_world = obj.matrix_world + + fake_context = context.copy() + fake_context["active_object"] = obj_base + fake_context["selected_editable_bases"] = [base_base, base_new] + + bpy.ops.object.join(fake_context) + del base_new, obj_new + + # remove object and its mesh, join does this + #~ scene.objects.unlink(obj_new) + #~ bpy.data.objects.remove(obj_new) + + bpy.data.meshes.remove(mesh_new) + + # return new object + return base_base + diff --git a/object_print3d_utils/operators.py b/object_print3d_utils/operators.py new file mode 100644 index 00000000..b63226d2 --- /dev/null +++ b/object_print3d_utils/operators.py @@ -0,0 +1,511 @@ +# ##### 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 ##### + +# + +# All Operator + +import bpy +import bmesh +from bpy.types import Operator +from bpy.props import (StringProperty, + BoolProperty, + IntProperty, + FloatProperty, + FloatVectorProperty, + EnumProperty, + PointerProperty, + ) + +from . import mesh_helpers +from . import report + + +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 Print3DInfoVolume(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 = mesh_helpers.bmesh_calc_volume(bm) + bm.free() + + info = [] + info.append(("Volume: %s³" % clean_float("%.4f" % volume), + None)) + info.append(("%s cm³" % clean_float("%.4f" % ((volume * (scale * scale * scale)) / (0.01 * 0.01 * 0.01) )), + None)) + + report.update(*info) + return {'FINISHED'} + + +class Print3DInfoArea(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() + + info = [] + info.append(("Area: %s²" % clean_float("%.4f" % area), + None)) + info.append(("%s cm²" % clean_float("%.4f" % ((area * (scale * scale)) / (0.01 * 0.01))), + None)) + report.update(*info) + return {'FINISHED'} + + +# --------------- +# Geometry Checks + +def execute_check(self, context): + obj = context.active_object + + info = [] + self.main_check(obj, info) + report.update(*info) + + return {'FINISHED'} + + +class Print3DCheckSolid(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): + import array + + 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(("Non Manifold Edge: %d" % len(edges_non_manifold), + (bmesh.types.BMEdge, edges_non_manifold))) + + info.append(("Bad Contig. Edges: %d" % len(edges_non_contig), + (bmesh.types.BMEdge, edges_non_contig))) + + bm.free() + + def execute(self, context): + return execute_check(self, context) + + + +class Print3DCheckIntersections(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(("Intersect Face: %d" % len(faces_intersect), + (bmesh.types.BMFace, faces_intersect))) + + def execute(self, context): + return execute_check(self, context) + + +class Print3DCheckDegenerate(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): + import array + 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(("Zero Faces: %d" % len(faces_zero), + (bmesh.types.BMFace, faces_zero))) + + info.append(("Zero Edges: %d" % len(edges_zero), + (bmesh.types.BMEdge, edges_zero))) + + bm.free() + + def execute(self, context): + return execute_check(self, context) + + +class Print3DCheckDistorted(Operator): + """Check for non-flat faces """ + bl_idname = "mesh.print3d_check_distort" + bl_label = "Print3D Check Distorted Faces" + + @staticmethod + def main_check(obj, info): + import array + + scene = bpy.context.scene + print_3d = scene.print_3d + angle_distort = print_3d.angle_distort + + def face_is_distorted(ele): + no = ele.normal + angle_fn = no.angle + for loop in ele.loops: + if angle_fn(loop.calc_normal(), 1000.0) > angle_distort: + return True + return False + + 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 face_is_distorted(ele))) + + info.append(("Non-Flat Faces: %d" % len(faces_distort), + (bmesh.types.BMFace, faces_distort))) + + bm.free() + + def execute(self, context): + return execute_check(self, context) + + +class Print3DCheckThick(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(("Thin Faces: %d" % len(faces_error), + (bmesh.types.BMFace, faces_error))) + + + def execute(self, context): + return execute_check(self, context) + + +class Print3DCheckSharp(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() > angle_sharp] + + info.append(("Sharp Edge: %d" % len(edges_sharp), + (bmesh.types.BMEdge, edges_sharp))) + bm.free() + + def execute(self, context): + return execute_check(self, context) + + +class Print3DCheckOverhang(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 + + faces_overhang = [ele.index for ele in bm.faces + if z_down_angle(ele.normal) < angle_overhang] + + info.append(("Overhang Face: %d" % len(faces_overhang), + (bmesh.types.BMFace, faces_overhang))) + bm.free() + + def execute(self, context): + return execute_check(self, context) + + +class Print3DCheckAll(Operator): + """Run all checks""" + bl_idname = "mesh.print3d_check_all" + bl_label = "Print3D Check All" + + check_cls = ( + Print3DCheckSolid, + Print3DCheckIntersections, + Print3DCheckDegenerate, + Print3DCheckDistorted, + Print3DCheckThick, + Print3DCheckSharp, + Print3DCheckOverhang, + ) + + def execute(self, context): + obj = context.active_object + + info = [] + for cls in self.check_cls: + cls.main_check(obj, info) + + report.update(*info) + + return {'FINISHED'} + + +class Print3DCleanIsolated(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): + return (not bool(ele.link_edges)) + + # --- 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(("Faces Removed: %d" % len(elems_remove), + None)) + del elems_remove + # --- 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(("Edge Removed: %d" % len(elems_remove), + None)) + del elems_remove + # --- 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(("Verts Removed: %d" % 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 Print3DCleanDistorted(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 + + def face_is_distorted(ele): + no = ele.normal + angle_fn = no.angle + for loop in ele.loops: + if angle_fn(loop.calc_normal(), 1000.0) > angle_distort: + return True + return False + + obj = context.active_object + bm = mesh_helpers.bmesh_from_object(obj) + bm.normal_update() + elems_triangulate = [ele for ele in bm.faces if face_is_distorted(ele)] + + # edit + if elems_triangulate: + bmesh.ops.triangulate(bm, faces=elems_triangulate) + mesh_helpers.bmesh_to_object(obj, bm) + return {'FINISHED'} + else: + return {'CANCELLED'} + + +class Print3DCleanThin(Operator): + """Ensure minimum thickness""" + bl_idname = "mesh.print3d_clean_thin" + bl_label = "Print3D Clean Thin" + bl_options = {'REGISTER', 'UNDO'} + + def execute(self, context): + TODO + + return {'FINISHED'} + + +# ------------- +# Select Report +# ... helper function for info UI + +class Print3DSelectReport(Operator): + """Select the data assosiated with this report""" + bl_idname = "mesh.print3d_select_report" + bl_label = "Print3D Select Report" + bl_options = {'INTERNAL'} + + index = IntProperty() + + _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, Print3DSelectReport._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") + + # Perhaps this is annoying? but also handy! + bpy.ops.view3d.view_selected(use_all_regions=False) + + return {'FINISHED'} + + +# ------ +# Export + +class Print3DExport(Operator): + """Export active object using print3d settings""" + bl_idname = "mesh.print3d_export" + bl_label = "Print3D Export" + + def execute(self, context): + scene = bpy.context.scene + print_3d = scene.print_3d + from . import export + + info = [] + ret = export.write_mesh(context, info, self.report) + report.update(*info) + + if ret: + return {'FINISHED'} + else: + return {'CANCELLED'} diff --git a/object_print3d_utils/readme.rst b/object_print3d_utils/readme.rst new file mode 100644 index 00000000..70815d15 --- /dev/null +++ b/object_print3d_utils/readme.rst @@ -0,0 +1,4 @@ +object_print3d_utils +==================== + +3d Printing Addon for Blender 2.66 \ No newline at end of file diff --git a/object_print3d_utils/report.py b/object_print3d_utils/report.py new file mode 100644 index 00000000..a27986f0 --- /dev/null +++ b/object_print3d_utils/report.py @@ -0,0 +1,30 @@ +# ##### 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 ##### + +# + +# Report errors with the mesh. + + +_data = [] + +def update(*args): + _data[:] = args + +def info(): + return tuple(_data) diff --git a/object_print3d_utils/todo.rst b/object_print3d_utils/todo.rst new file mode 100644 index 00000000..c2f78e29 --- /dev/null +++ b/object_print3d_utils/todo.rst @@ -0,0 +1,77 @@ +Useful 3D printing tools +======================== + +Statistics +---------- + +- volume +- surface area + *(if you gold plate for eg, or use expensive paint :))* + + +Checks +------ + +- *degenerate geometry* +- - zero area faces +- - zero length edges +- - bow-tie quads. +- *solid geometry* +- - self intersections +- - non-manifold +- - non-contiguous normals (bad flipping) + + +Mesh Cleanup +------------ + +- basics - stray verts, loose edges +- degenerate faces, bow-tie quads + + +Visualisation +------------- + +- visualize areas of low wall thickness. +- visualize overhangs (some printers have this as s limit). +- areas of low wall thickness. +- sharp/pointy surface. + + +Utilities +--------- + +- add text on an object *(common tasks - lots of people want this to add a name to personalize items)* +- Rig sizes (rings also common item to make) +- others??? + + +Exporters +--------- + +- nice UI with format select and output paths for the print. + *no need to recode re-use existing exporters, maybe recode some in C if too slow.* + + +Integration with toolplating +---------------------------- +*(the thing that gets the model into printer commands)* + +- http://slic3r.org +- https://github.com/makerbot/Miracle-Grue/blob/master/README.md +- Use a sliver, like slicer, skeinforge, cura, kissslicer, netfabb, .... + +...not sure yet exactly how this would work, but we could have a `Print` button and it would send the file off and print :). + + +Notes +----- + +- Normals are important +- Self intersections _can_ be ok. +- Some printer software already prevents solid areas from taking too much space by filling with non-solid grid. + *(So we may not have to care about solid shapes so much)* + +- For extrusion printers like makerbots it is really hard to print "overhangs"... + because they build "from the bottom up" they can't for instance make an arm sticking out of a character sideways +- Check on http://www.shapeways.com/tutorials/ diff --git a/object_print3d_utils/ui.py b/object_print3d_utils/ui.py new file mode 100644 index 00000000..d8da9663 --- /dev/null +++ b/object_print3d_utils/ui.py @@ -0,0 +1,128 @@ +# ##### 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 ##### + +# + +# Interface for this addon. + +import bmesh +from bpy.types import Panel +from . import report + +class Print3DToolBar: + bl_label = "Print3D" + bl_space_type = 'VIEW_3D' + bl_region_type = 'TOOLS' + + _type_to_icon = { + bmesh.types.BMVert: 'VERTEXSEL', + bmesh.types.BMEdge: 'EDGESEL', + bmesh.types.BMFace: 'FACESEL', + } + + @classmethod + def poll(cls, context): + obj = context.active_object + return (obj and obj.type == 'MESH') + + @staticmethod + def draw_report(layout, context): + """Display Reports""" + info = report.info() + if info: + obj = context.edit_object + + layout.label("Output:") + box = layout.box() + col = box.column(align=False) + # box.alert = True + for i, (text, data) in enumerate(info): + if obj and data and data[1]: + bm_type, bm_array = data + col.operator("mesh.print3d_select_report", + text=text, + icon=Print3DToolBar._type_to_icon[bm_type]).index = i + else: + col.label(text) + + def draw(self, context): + layout = self.layout + + scene = context.scene + print_3d = scene.print_3d + obj = context.object + + # TODO, presets + + row = layout.row() + row.label("Statistics:") + col = layout.column(align=True) + col.operator("mesh.print3d_info_volume", text="Volume") + col.operator("mesh.print3d_info_area", text="Area") + + row = layout.row() + row.label("Checks:") + col = layout.column(align=True) + col.operator("mesh.print3d_check_solid", text="Solid") + col.operator("mesh.print3d_check_intersect", text="Intersections") + rowsub = col.row() + rowsub.operator("mesh.print3d_check_degenerate", text="Degenerate") + rowsub.prop(print_3d, "threshold_zero", text="") + rowsub = col.row() + rowsub.operator("mesh.print3d_check_distort", text="Distorted") + rowsub.prop(print_3d, "angle_distort", text="") + rowsub = col.row() + rowsub.operator("mesh.print3d_check_thick", text="Thickness") + rowsub.prop(print_3d, "thickness_min", text="") + rowsub = col.row() + rowsub.operator("mesh.print3d_check_sharp", text="Edge Sharp") + rowsub.prop(print_3d, "angle_sharp", text="") + rowsub = col.row() + rowsub.operator("mesh.print3d_check_overhang", text="Overhang") + rowsub.prop(print_3d, "angle_overhang", text="") + col = layout.column() + col.operator("mesh.print3d_check_all", text="Check All") + + row = layout.row() + row.label("Cleanup:") + col = layout.column(align=True) + col.operator("mesh.print3d_clean_isolated", text="Isolated") + rowsub = col.row() + rowsub.operator("mesh.print3d_clean_distorted", text="Distorted") + rowsub.prop(print_3d, "angle_distort", text="") + # XXX TODO + # col.operator("mesh.print3d_clean_thin", text="Wall Thickness") + + col = layout.column() + col.label("Export Directory:") + col.prop(print_3d, "export_path", text="") + + rowsub = col.row(align=True) + rowsub.prop(print_3d, "export_format", text="") + rowsub.operator("mesh.print3d_export", text="", icon='EXPORT') + + Print3DToolBar.draw_report(layout, context) + +# So we can have a panel in both object mode and editmode +class Print3DToolBarObject(Panel, Print3DToolBar): + bl_idname = "MESH_PT_print3d_object" + bl_context = "objectmode" + +class Print3DToolBarMesh(Panel, Print3DToolBar): + bl_idname = "MESH_PT_print3d_mesh" + bl_context = "mesh_edit" -- cgit v1.2.3