Cleanup: quiet character escape warnings
[blender-addons.git] / object_print3d_utils / operators.py
blobafc7b83d50c0d7568b1640a570ee86e1b84c577f
1 # ##### BEGIN GPL LICENSE BLOCK #####
3 # This program is free software; you can redistribute it and/or
4 # modify it under the terms of the GNU General Public License
5 # as published by the Free Software Foundation; either version 2
6 # of the License, or (at your option) any later version.
8 # This program is distributed in the hope that it will be useful,
9 # but WITHOUT ANY WARRANTY; without even the implied warranty of
10 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 # GNU General Public License for more details.
13 # You should have received a copy of the GNU General Public License
14 # along with this program; if not, write to the Free Software Foundation,
15 # Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17 # ##### END GPL LICENSE BLOCK #####
19 # <pep8-80 compliant>
21 # All Operator
24 import math
26 import bpy
27 from bpy.types import Operator
28 from bpy.props import (
29 IntProperty,
30 FloatProperty,
32 import bmesh
34 from . import report
37 def clean_float(value: float, precision: int = 0) -> str:
38 # Avoid scientific notation and strip trailing zeros: 0.000 -> 0.0
40 text = f"{value:.{precision}f}"
41 index = text.rfind(".")
43 if index != -1:
44 index += 2
45 head, tail = text[:index], text[index:]
46 tail = tail.rstrip("0")
47 text = head + tail
49 return text
52 def get_unit(unit_system: str, unit: str) -> tuple[float, str]:
53 # Returns unit length relative to meter and unit symbol
55 units = {
56 "METRIC": {
57 "KILOMETERS": (1000.0, "km"),
58 "METERS": (1.0, "m"),
59 "CENTIMETERS": (0.01, "cm"),
60 "MILLIMETERS": (0.001, "mm"),
61 "MICROMETERS": (0.000001, "µm"),
63 "IMPERIAL": {
64 "MILES": (1609.344, "mi"),
65 "FEET": (0.3048, "\'"),
66 "INCHES": (0.0254, "\""),
67 "THOU": (0.0000254, "thou"),
71 try:
72 return units[unit_system][unit]
73 except KeyError:
74 fallback_unit = "CENTIMETERS" if unit_system == "METRIC" else "INCHES"
75 return units[unit_system][fallback_unit]
78 # ---------
79 # Mesh Info
82 class MESH_OT_print3d_info_volume(Operator):
83 bl_idname = "mesh.print3d_info_volume"
84 bl_label = "3D-Print Info Volume"
85 bl_description = "Report the volume of the active mesh"
87 def execute(self, context):
88 from . import mesh_helpers
90 scene = context.scene
91 unit = scene.unit_settings
92 scale = 1.0 if unit.system == 'NONE' else unit.scale_length
93 obj = context.active_object
95 bm = mesh_helpers.bmesh_copy_from_object(obj, apply_modifiers=True)
96 volume = bm.calc_volume()
97 bm.free()
99 if unit.system == 'NONE':
100 volume_fmt = clean_float(volume, 8)
101 else:
102 length, symbol = get_unit(unit.system, unit.length_unit)
104 volume_unit = volume * (scale ** 3.0) / (length ** 3.0)
105 volume_str = clean_float(volume_unit, 4)
106 volume_fmt = f"{volume_str} {symbol}"
108 report.update((f"Volume: {volume_fmt}³", None))
110 return {'FINISHED'}
113 class MESH_OT_print3d_info_area(Operator):
114 bl_idname = "mesh.print3d_info_area"
115 bl_label = "3D-Print Info Area"
116 bl_description = "Report the surface area of the active mesh"
118 def execute(self, context):
119 from . import mesh_helpers
121 scene = context.scene
122 unit = scene.unit_settings
123 scale = 1.0 if unit.system == 'NONE' else unit.scale_length
124 obj = context.active_object
126 bm = mesh_helpers.bmesh_copy_from_object(obj, apply_modifiers=True)
127 area = mesh_helpers.bmesh_calc_area(bm)
128 bm.free()
130 if unit.system == 'NONE':
131 area_fmt = clean_float(area, 8)
132 else:
133 length, symbol = get_unit(unit.system, unit.length_unit)
135 area_unit = area * (scale ** 2.0) / (length ** 2.0)
136 area_str = clean_float(area_unit, 4)
137 area_fmt = f"{area_str} {symbol}"
139 report.update((f"Area: {area_fmt}²", None))
141 return {'FINISHED'}
144 # ---------------
145 # Geometry Checks
147 def execute_check(self, context):
148 obj = context.active_object
150 info = []
151 self.main_check(obj, info)
152 report.update(*info)
154 multiple_obj_warning(self, context)
156 return {'FINISHED'}
159 def multiple_obj_warning(self, context):
160 if len(context.selected_objects) > 1:
161 self.report({"INFO"}, "Multiple selected objects. Only the active one will be evaluated")
164 class MESH_OT_print3d_check_solid(Operator):
165 bl_idname = "mesh.print3d_check_solid"
166 bl_label = "3D-Print Check Solid"
167 bl_description = "Check for geometry is solid (has valid inside/outside) and correct normals"
169 @staticmethod
170 def main_check(obj, info):
171 import array
172 from . import mesh_helpers
174 bm = mesh_helpers.bmesh_copy_from_object(obj, transform=False, triangulate=False)
176 edges_non_manifold = array.array('i', (i for i, ele in enumerate(bm.edges) if not ele.is_manifold))
177 edges_non_contig = array.array(
178 'i',
179 (i for i, ele in enumerate(bm.edges) if ele.is_manifold and (not ele.is_contiguous)),
182 info.append((f"Non Manifold Edge: {len(edges_non_manifold)}", (bmesh.types.BMEdge, edges_non_manifold)))
183 info.append((f"Bad Contig. Edges: {len(edges_non_contig)}", (bmesh.types.BMEdge, edges_non_contig)))
185 bm.free()
187 def execute(self, context):
188 return execute_check(self, context)
191 class MESH_OT_print3d_check_intersections(Operator):
192 bl_idname = "mesh.print3d_check_intersect"
193 bl_label = "3D-Print Check Intersections"
194 bl_description = "Check geometry for self intersections"
196 @staticmethod
197 def main_check(obj, info):
198 from . import mesh_helpers
200 faces_intersect = mesh_helpers.bmesh_check_self_intersect_object(obj)
201 info.append((f"Intersect Face: {len(faces_intersect)}", (bmesh.types.BMFace, faces_intersect)))
203 def execute(self, context):
204 return execute_check(self, context)
207 class MESH_OT_print3d_check_degenerate(Operator):
208 bl_idname = "mesh.print3d_check_degenerate"
209 bl_label = "3D-Print Check Degenerate"
210 bl_description = (
211 "Check for degenerate geometry that may not print properly "
212 "(zero area faces, zero length edges)"
215 @staticmethod
216 def main_check(obj, info):
217 import array
218 from . import mesh_helpers
220 scene = bpy.context.scene
221 print_3d = scene.print_3d
222 threshold = print_3d.threshold_zero
224 bm = mesh_helpers.bmesh_copy_from_object(obj, transform=False, triangulate=False)
226 faces_zero = array.array('i', (i for i, ele in enumerate(bm.faces) if ele.calc_area() <= threshold))
227 edges_zero = array.array('i', (i for i, ele in enumerate(bm.edges) if ele.calc_length() <= threshold))
229 info.append((f"Zero Faces: {len(faces_zero)}", (bmesh.types.BMFace, faces_zero)))
230 info.append((f"Zero Edges: {len(edges_zero)}", (bmesh.types.BMEdge, edges_zero)))
232 bm.free()
234 def execute(self, context):
235 return execute_check(self, context)
238 class MESH_OT_print3d_check_distorted(Operator):
239 bl_idname = "mesh.print3d_check_distort"
240 bl_label = "3D-Print Check Distorted Faces"
241 bl_description = "Check for non-flat faces"
243 @staticmethod
244 def main_check(obj, info):
245 import array
246 from . import mesh_helpers
248 scene = bpy.context.scene
249 print_3d = scene.print_3d
250 angle_distort = print_3d.angle_distort
252 bm = mesh_helpers.bmesh_copy_from_object(obj, transform=True, triangulate=False)
253 bm.normal_update()
255 faces_distort = array.array(
256 'i',
257 (i for i, ele in enumerate(bm.faces) if mesh_helpers.face_is_distorted(ele, angle_distort))
260 info.append((f"Non-Flat Faces: {len(faces_distort)}", (bmesh.types.BMFace, faces_distort)))
262 bm.free()
264 def execute(self, context):
265 return execute_check(self, context)
268 class MESH_OT_print3d_check_thick(Operator):
269 bl_idname = "mesh.print3d_check_thick"
270 bl_label = "3D-Print Check Thickness"
271 bl_description = (
272 "Check geometry is above the minimum thickness preference "
273 "(relies on correct normals)"
276 @staticmethod
277 def main_check(obj, info):
278 from . import mesh_helpers
280 scene = bpy.context.scene
281 print_3d = scene.print_3d
283 faces_error = mesh_helpers.bmesh_check_thick_object(obj, print_3d.thickness_min)
284 info.append((f"Thin Faces: {len(faces_error)}", (bmesh.types.BMFace, faces_error)))
286 def execute(self, context):
287 return execute_check(self, context)
290 class MESH_OT_print3d_check_sharp(Operator):
291 bl_idname = "mesh.print3d_check_sharp"
292 bl_label = "3D-Print Check Sharp"
293 bl_description = "Check edges are below the sharpness preference"
295 @staticmethod
296 def main_check(obj, info):
297 from . import mesh_helpers
299 scene = bpy.context.scene
300 print_3d = scene.print_3d
301 angle_sharp = print_3d.angle_sharp
303 bm = mesh_helpers.bmesh_copy_from_object(obj, transform=True, triangulate=False)
304 bm.normal_update()
306 edges_sharp = [
307 ele.index for ele in bm.edges
308 if ele.is_manifold and ele.calc_face_angle_signed() > angle_sharp
311 info.append((f"Sharp Edge: {len(edges_sharp)}", (bmesh.types.BMEdge, edges_sharp)))
312 bm.free()
314 def execute(self, context):
315 return execute_check(self, context)
318 class MESH_OT_print3d_check_overhang(Operator):
319 bl_idname = "mesh.print3d_check_overhang"
320 bl_label = "3D-Print Check Overhang"
321 bl_description = "Check faces don't overhang past a certain angle"
323 @staticmethod
324 def main_check(obj, info):
325 from mathutils import Vector
326 from . import mesh_helpers
328 scene = bpy.context.scene
329 print_3d = scene.print_3d
330 angle_overhang = (math.pi / 2.0) - print_3d.angle_overhang
332 if angle_overhang == math.pi:
333 info.append(("Skipping Overhang", ()))
334 return
336 bm = mesh_helpers.bmesh_copy_from_object(obj, transform=True, triangulate=False)
337 bm.normal_update()
339 z_down = Vector((0, 0, -1.0))
340 z_down_angle = z_down.angle
342 # 4.0 ignores zero area faces
343 faces_overhang = [
344 ele.index for ele in bm.faces
345 if z_down_angle(ele.normal, 4.0) < angle_overhang
348 info.append((f"Overhang Face: {len(faces_overhang)}", (bmesh.types.BMFace, faces_overhang)))
349 bm.free()
351 def execute(self, context):
352 return execute_check(self, context)
355 class MESH_OT_print3d_check_all(Operator):
356 bl_idname = "mesh.print3d_check_all"
357 bl_label = "3D-Print Check All"
358 bl_description = "Run all checks"
360 check_cls = (
361 MESH_OT_print3d_check_solid,
362 MESH_OT_print3d_check_intersections,
363 MESH_OT_print3d_check_degenerate,
364 MESH_OT_print3d_check_distorted,
365 MESH_OT_print3d_check_thick,
366 MESH_OT_print3d_check_sharp,
367 MESH_OT_print3d_check_overhang,
370 def execute(self, context):
371 obj = context.active_object
373 info = []
374 for cls in self.check_cls:
375 cls.main_check(obj, info)
377 report.update(*info)
379 multiple_obj_warning(self, context)
381 return {'FINISHED'}
384 class MESH_OT_print3d_clean_distorted(Operator):
385 bl_idname = "mesh.print3d_clean_distorted"
386 bl_label = "3D-Print Clean Distorted"
387 bl_description = "Tessellate distorted faces"
388 bl_options = {'REGISTER', 'UNDO'}
390 angle: FloatProperty(
391 name="Angle",
392 description="Limit for checking distorted faces",
393 subtype='ANGLE',
394 default=math.radians(45.0),
395 min=0.0,
396 max=math.radians(180.0),
399 def execute(self, context):
400 from . import mesh_helpers
402 obj = context.active_object
403 bm = mesh_helpers.bmesh_from_object(obj)
404 bm.normal_update()
405 elems_triangulate = [ele for ele in bm.faces if mesh_helpers.face_is_distorted(ele, self.angle)]
407 if elems_triangulate:
408 bmesh.ops.triangulate(bm, faces=elems_triangulate)
409 mesh_helpers.bmesh_to_object(obj, bm)
411 self.report({'INFO'}, f"Triangulated {len(elems_triangulate)} faces")
413 return {'FINISHED'}
415 def invoke(self, context, event):
416 print_3d = context.scene.print_3d
417 self.angle = print_3d.angle_distort
419 return self.execute(context)
422 class MESH_OT_print3d_clean_non_manifold(Operator):
423 bl_idname = "mesh.print3d_clean_non_manifold"
424 bl_label = "3D-Print Clean Non-Manifold"
425 bl_description = "Cleanup problems, like holes, non-manifold vertices and inverted normals"
426 bl_options = {'REGISTER', 'UNDO'}
428 threshold: FloatProperty(
429 name="Merge Distance",
430 description="Minimum distance between elements to merge",
431 default=0.0001,
433 sides: IntProperty(
434 name="Sides",
435 description="Number of sides in hole required to fill (zero fills all holes)",
436 default=0,
439 def execute(self, context):
440 self.context = context
441 mode_orig = context.mode
443 self.setup_environment()
444 bm_key_orig = self.elem_count(context)
446 self.delete_loose()
447 self.delete_interior()
448 self.remove_doubles(self.threshold)
449 self.dissolve_degenerate(self.threshold)
450 self.fix_non_manifold(context, self.sides) # may take a while
451 self.make_normals_consistently_outwards()
453 bm_key = self.elem_count(context)
455 if mode_orig != 'EDIT_MESH':
456 bpy.ops.object.mode_set(mode='OBJECT')
458 verts = bm_key[0] - bm_key_orig[0]
459 edges = bm_key[1] - bm_key_orig[1]
460 faces = bm_key[2] - bm_key_orig[2]
462 self.report({'INFO'}, f"Modified: {verts:+} vertices, {edges:+} edges, {faces:+} faces")
464 return {'FINISHED'}
466 @staticmethod
467 def elem_count(context):
468 bm = bmesh.from_edit_mesh(context.edit_object.data)
469 return len(bm.verts), len(bm.edges), len(bm.faces)
471 @staticmethod
472 def setup_environment():
473 """set the mode as edit, select mode as vertices, and reveal hidden vertices"""
474 bpy.ops.object.mode_set(mode='EDIT')
475 bpy.ops.mesh.select_mode(type='VERT')
476 bpy.ops.mesh.reveal()
478 @staticmethod
479 def remove_doubles(threshold):
480 """remove duplicate vertices"""
481 bpy.ops.mesh.select_all(action='SELECT')
482 bpy.ops.mesh.remove_doubles(threshold=threshold)
484 @staticmethod
485 def delete_loose():
486 """delete loose vertices/edges/faces"""
487 bpy.ops.mesh.select_all(action='SELECT')
488 bpy.ops.mesh.delete_loose(use_verts=True, use_edges=True, use_faces=True)
490 @staticmethod
491 def delete_interior():
492 """delete interior faces"""
493 bpy.ops.mesh.select_all(action='DESELECT')
494 bpy.ops.mesh.select_interior_faces()
495 bpy.ops.mesh.delete(type='FACE')
497 @staticmethod
498 def dissolve_degenerate(threshold):
499 """dissolve zero area faces and zero length edges"""
500 bpy.ops.mesh.select_all(action='SELECT')
501 bpy.ops.mesh.dissolve_degenerate(threshold=threshold)
503 @staticmethod
504 def make_normals_consistently_outwards():
505 """have all normals face outwards"""
506 bpy.ops.mesh.select_all(action='SELECT')
507 bpy.ops.mesh.normals_make_consistent()
509 @classmethod
510 def fix_non_manifold(cls, context, sides):
511 """naive iterate-until-no-more approach for fixing manifolds"""
512 total_non_manifold = cls.count_non_manifold_verts(context)
514 if not total_non_manifold:
515 return
517 bm_states = set()
518 bm_key = cls.elem_count(context)
519 bm_states.add(bm_key)
521 while True:
522 cls.fill_non_manifold(sides)
523 cls.delete_newly_generated_non_manifold_verts()
525 bm_key = cls.elem_count(context)
526 if bm_key in bm_states:
527 break
528 else:
529 bm_states.add(bm_key)
531 @staticmethod
532 def select_non_manifold_verts(
533 use_wire=False,
534 use_boundary=False,
535 use_multi_face=False,
536 use_non_contiguous=False,
537 use_verts=False,
539 """select non-manifold vertices"""
540 bpy.ops.mesh.select_non_manifold(
541 extend=False,
542 use_wire=use_wire,
543 use_boundary=use_boundary,
544 use_multi_face=use_multi_face,
545 use_non_contiguous=use_non_contiguous,
546 use_verts=use_verts,
549 @classmethod
550 def count_non_manifold_verts(cls, context):
551 """return a set of coordinates of non-manifold vertices"""
552 cls.select_non_manifold_verts(use_wire=True, use_boundary=True, use_verts=True)
554 bm = bmesh.from_edit_mesh(context.edit_object.data)
555 return sum((1 for v in bm.verts if v.select))
557 @classmethod
558 def fill_non_manifold(cls, sides):
559 """fill in any remnant non-manifolds"""
560 bpy.ops.mesh.select_all(action='SELECT')
561 bpy.ops.mesh.fill_holes(sides=sides)
563 @classmethod
564 def delete_newly_generated_non_manifold_verts(cls):
565 """delete any newly generated vertices from the filling repair"""
566 cls.select_non_manifold_verts(use_wire=True, use_verts=True)
567 bpy.ops.mesh.delete(type='VERT')
570 class MESH_OT_print3d_clean_thin(Operator):
571 bl_idname = "mesh.print3d_clean_thin"
572 bl_label = "3D-Print Clean Thin"
573 bl_description = "Ensure minimum thickness"
574 bl_options = {'REGISTER', 'UNDO'}
576 def execute(self, context):
577 # TODO
579 return {'FINISHED'}
582 # -------------
583 # Select Report
584 # ... helper function for info UI
586 class MESH_OT_print3d_select_report(Operator):
587 bl_idname = "mesh.print3d_select_report"
588 bl_label = "3D-Print Select Report"
589 bl_description = "Select the data associated with this report"
590 bl_options = {'INTERNAL'}
592 index: IntProperty()
594 _type_to_mode = {
595 bmesh.types.BMVert: 'VERT',
596 bmesh.types.BMEdge: 'EDGE',
597 bmesh.types.BMFace: 'FACE',
600 _type_to_attr = {
601 bmesh.types.BMVert: "verts",
602 bmesh.types.BMEdge: "edges",
603 bmesh.types.BMFace: "faces",
606 def execute(self, context):
607 obj = context.edit_object
608 info = report.info()
609 _text, data = info[self.index]
610 bm_type, bm_array = data
612 bpy.ops.mesh.reveal()
613 bpy.ops.mesh.select_all(action='DESELECT')
614 bpy.ops.mesh.select_mode(type=self._type_to_mode[bm_type])
616 bm = bmesh.from_edit_mesh(obj.data)
617 elems = getattr(bm, MESH_OT_print3d_select_report._type_to_attr[bm_type])[:]
619 try:
620 for i in bm_array:
621 elems[i].select_set(True)
622 except:
623 # possible arrays are out of sync
624 self.report({'WARNING'}, "Report is out of date, re-run check")
626 return {'FINISHED'}
629 # -----------
630 # Scale to...
632 def _scale(scale, report=None, report_suffix=""):
633 if scale != 1.0:
634 bpy.ops.transform.resize(value=(scale,) * 3)
635 if report is not None:
636 scale_fmt = clean_float(scale, 6)
637 report({'INFO'}, f"Scaled by {scale_fmt}{report_suffix}")
640 class MESH_OT_print3d_scale_to_volume(Operator):
641 bl_idname = "mesh.print3d_scale_to_volume"
642 bl_label = "Scale to Volume"
643 bl_description = "Scale edit-mesh or selected-objects to a set volume"
644 bl_options = {'REGISTER', 'UNDO'}
646 volume_init: FloatProperty(
647 options={'HIDDEN'},
649 volume: FloatProperty(
650 name="Volume",
651 unit='VOLUME',
652 min=0.0,
653 max=100000.0,
656 def execute(self, context):
657 scale = math.pow(self.volume, 1 / 3) / math.pow(self.volume_init, 1 / 3)
658 scale_fmt = clean_float(scale, 6)
659 self.report({'INFO'}, f"Scaled by {scale_fmt}")
660 _scale(scale, self.report)
661 return {'FINISHED'}
663 def invoke(self, context, event):
665 def calc_volume(obj):
666 from . import mesh_helpers
668 bm = mesh_helpers.bmesh_copy_from_object(obj, apply_modifiers=True)
669 volume = bm.calc_volume(signed=True)
670 bm.free()
671 return volume
673 if context.mode == 'EDIT_MESH':
674 volume = calc_volume(context.edit_object)
675 else:
676 volume = sum(calc_volume(obj) for obj in context.selected_editable_objects if obj.type == 'MESH')
678 if volume == 0.0:
679 self.report({'WARNING'}, "Object has zero volume")
680 return {'CANCELLED'}
682 self.volume_init = self.volume = abs(volume)
684 wm = context.window_manager
685 return wm.invoke_props_dialog(self)
688 class MESH_OT_print3d_scale_to_bounds(Operator):
689 bl_idname = "mesh.print3d_scale_to_bounds"
690 bl_label = "Scale to Bounds"
691 bl_description = "Scale edit-mesh or selected-objects to fit within a maximum length"
692 bl_options = {'REGISTER', 'UNDO'}
694 length_init: FloatProperty(
695 options={'HIDDEN'},
697 axis_init: IntProperty(
698 options={'HIDDEN'},
700 length: FloatProperty(
701 name="Length Limit",
702 unit='LENGTH',
703 min=0.0,
704 max=100000.0,
707 def execute(self, context):
708 scale = self.length / self.length_init
709 axis = "XYZ"[self.axis_init]
710 _scale(scale, report=self.report, report_suffix=f", Clamping {axis}-Axis")
711 return {'FINISHED'}
713 def invoke(self, context, event):
714 from mathutils import Vector
716 def calc_length(vecs):
717 return max(((max(v[i] for v in vecs) - min(v[i] for v in vecs)), i) for i in range(3))
719 if context.mode == 'EDIT_MESH':
720 length, axis = calc_length(
721 [Vector(v) @ obj.matrix_world for obj in [context.edit_object] for v in obj.bound_box]
723 else:
724 length, axis = calc_length(
726 Vector(v) @ obj.matrix_world for obj in context.selected_editable_objects
727 if obj.type == 'MESH'
728 for v in obj.bound_box
732 if length == 0.0:
733 self.report({'WARNING'}, "Object has zero bounds")
734 return {'CANCELLED'}
736 self.length_init = self.length = length
737 self.axis_init = axis
739 wm = context.window_manager
740 return wm.invoke_props_dialog(self)
743 # ------
744 # Export
746 class MESH_OT_print3d_export(Operator):
747 bl_idname = "mesh.print3d_export"
748 bl_label = "3D-Print Export"
749 bl_description = "Export selected objects using 3D-Print settings"
751 def execute(self, context):
752 from . import export
754 ret = export.write_mesh(context, self.report)
756 if ret:
757 return {'FINISHED'}
759 return {'CANCELLED'}