Fix add-ons with Python 3.12 by replacing "imp" with "importlib"
[blender-addons.git] / object_print3d_utils / operators.py
blobf1dd26842ad5a0ebed97cc42d4d460e83e27a25c
1 # SPDX-FileCopyrightText: 2013-2022 Blender Foundation
3 # SPDX-License-Identifier: GPL-2.0-or-later
5 # All Operator
8 import math
10 import bpy
11 from bpy.types import Operator
12 from bpy.props import (
13 IntProperty,
14 FloatProperty,
16 import bmesh
18 from bpy.app.translations import pgettext_tip as tip_
20 from . import report
23 def clean_float(value: float, precision: int = 0) -> str:
24 # Avoid scientific notation and strip trailing zeros: 0.000 -> 0.0
26 text = f"{value:.{precision}f}"
27 index = text.rfind(".")
29 if index != -1:
30 index += 2
31 head, tail = text[:index], text[index:]
32 tail = tail.rstrip("0")
33 text = head + tail
35 return text
38 def get_unit(unit_system: str, unit: str) -> tuple[float, str]:
39 # Returns unit length relative to meter and unit symbol
41 units = {
42 "METRIC": {
43 "KILOMETERS": (1000.0, "km"),
44 "METERS": (1.0, "m"),
45 "CENTIMETERS": (0.01, "cm"),
46 "MILLIMETERS": (0.001, "mm"),
47 "MICROMETERS": (0.000001, "µm"),
49 "IMPERIAL": {
50 "MILES": (1609.344, "mi"),
51 "FEET": (0.3048, "\'"),
52 "INCHES": (0.0254, "\""),
53 "THOU": (0.0000254, "thou"),
57 try:
58 return units[unit_system][unit]
59 except KeyError:
60 fallback_unit = "CENTIMETERS" if unit_system == "METRIC" else "INCHES"
61 return units[unit_system][fallback_unit]
64 # ---------
65 # Mesh Info
68 class MESH_OT_print3d_info_volume(Operator):
69 bl_idname = "mesh.print3d_info_volume"
70 bl_label = "3D-Print Info Volume"
71 bl_description = "Report the volume of the active mesh"
73 def execute(self, context):
74 from . import mesh_helpers
76 scene = context.scene
77 unit = scene.unit_settings
78 scale = 1.0 if unit.system == 'NONE' else unit.scale_length
79 obj = context.active_object
81 bm = mesh_helpers.bmesh_copy_from_object(obj, apply_modifiers=True)
82 volume = bm.calc_volume()
83 bm.free()
85 if unit.system == 'NONE':
86 volume_fmt = clean_float(volume, 8)
87 else:
88 length, symbol = get_unit(unit.system, unit.length_unit)
90 volume_unit = volume * (scale ** 3.0) / (length ** 3.0)
91 volume_str = clean_float(volume_unit, 4)
92 volume_fmt = f"{volume_str} {symbol}"
94 report.update((tip_("Volume: {}³").format(volume_fmt), None))
96 return {'FINISHED'}
99 class MESH_OT_print3d_info_area(Operator):
100 bl_idname = "mesh.print3d_info_area"
101 bl_label = "3D-Print Info Area"
102 bl_description = "Report the surface area of the active mesh"
104 def execute(self, context):
105 from . import mesh_helpers
107 scene = context.scene
108 unit = scene.unit_settings
109 scale = 1.0 if unit.system == 'NONE' else unit.scale_length
110 obj = context.active_object
112 bm = mesh_helpers.bmesh_copy_from_object(obj, apply_modifiers=True)
113 area = mesh_helpers.bmesh_calc_area(bm)
114 bm.free()
116 if unit.system == 'NONE':
117 area_fmt = clean_float(area, 8)
118 else:
119 length, symbol = get_unit(unit.system, unit.length_unit)
121 area_unit = area * (scale ** 2.0) / (length ** 2.0)
122 area_str = clean_float(area_unit, 4)
123 area_fmt = f"{area_str} {symbol}"
125 report.update((tip_("Area: {}²").format(area_fmt), None))
127 return {'FINISHED'}
130 # ---------------
131 # Geometry Checks
133 def execute_check(self, context):
134 obj = context.active_object
136 info = []
137 self.main_check(obj, info)
138 report.update(*info)
140 multiple_obj_warning(self, context)
142 return {'FINISHED'}
145 def multiple_obj_warning(self, context):
146 if len(context.selected_objects) > 1:
147 self.report({"INFO"}, "Multiple selected objects. Only the active one will be evaluated")
150 class MESH_OT_print3d_check_solid(Operator):
151 bl_idname = "mesh.print3d_check_solid"
152 bl_label = "3D-Print Check Solid"
153 bl_description = "Check for geometry is solid (has valid inside/outside) and correct normals"
155 @staticmethod
156 def main_check(obj, info):
157 import array
158 from . import mesh_helpers
160 bm = mesh_helpers.bmesh_copy_from_object(obj, transform=False, triangulate=False)
162 edges_non_manifold = array.array('i', (i for i, ele in enumerate(bm.edges) if not ele.is_manifold))
163 edges_non_contig = array.array(
164 'i',
165 (i for i, ele in enumerate(bm.edges) if ele.is_manifold and (not ele.is_contiguous)),
168 info.append(
169 (tip_("Non Manifold Edges: {}").format(
170 len(edges_non_manifold)),
171 (bmesh.types.BMEdge,
172 edges_non_manifold)))
173 info.append((tip_("Bad Contiguous Edges: {}").format(len(edges_non_contig)), (bmesh.types.BMEdge, edges_non_contig)))
175 bm.free()
177 def execute(self, context):
178 return execute_check(self, context)
181 class MESH_OT_print3d_check_intersections(Operator):
182 bl_idname = "mesh.print3d_check_intersect"
183 bl_label = "3D-Print Check Intersections"
184 bl_description = "Check geometry for self intersections"
186 @staticmethod
187 def main_check(obj, info):
188 from . import mesh_helpers
190 faces_intersect = mesh_helpers.bmesh_check_self_intersect_object(obj)
191 info.append((tip_("Intersect Face: {}").format(len(faces_intersect)), (bmesh.types.BMFace, faces_intersect)))
193 def execute(self, context):
194 return execute_check(self, context)
197 class MESH_OT_print3d_check_degenerate(Operator):
198 bl_idname = "mesh.print3d_check_degenerate"
199 bl_label = "3D-Print Check Degenerate"
200 bl_description = (
201 "Check for degenerate geometry that may not print properly "
202 "(zero area faces, zero length edges)"
205 @staticmethod
206 def main_check(obj, info):
207 import array
208 from . import mesh_helpers
210 scene = bpy.context.scene
211 print_3d = scene.print_3d
212 threshold = print_3d.threshold_zero
214 bm = mesh_helpers.bmesh_copy_from_object(obj, transform=False, triangulate=False)
216 faces_zero = array.array('i', (i for i, ele in enumerate(bm.faces) if ele.calc_area() <= threshold))
217 edges_zero = array.array('i', (i for i, ele in enumerate(bm.edges) if ele.calc_length() <= threshold))
219 info.append((tip_("Zero Faces: {}").format(len(faces_zero)), (bmesh.types.BMFace, faces_zero)))
220 info.append((tip_("Zero Edges: {}").format(len(edges_zero)), (bmesh.types.BMEdge, edges_zero)))
222 bm.free()
224 def execute(self, context):
225 return execute_check(self, context)
228 class MESH_OT_print3d_check_distorted(Operator):
229 bl_idname = "mesh.print3d_check_distort"
230 bl_label = "3D-Print Check Distorted Faces"
231 bl_description = "Check for non-flat faces"
233 @staticmethod
234 def main_check(obj, info):
235 import array
236 from . import mesh_helpers
238 scene = bpy.context.scene
239 print_3d = scene.print_3d
240 angle_distort = print_3d.angle_distort
242 bm = mesh_helpers.bmesh_copy_from_object(obj, transform=True, triangulate=False)
243 bm.normal_update()
245 faces_distort = array.array(
246 'i',
247 (i for i, ele in enumerate(bm.faces) if mesh_helpers.face_is_distorted(ele, angle_distort))
250 info.append((tip_("Non-Flat Faces: {}").format(len(faces_distort)), (bmesh.types.BMFace, faces_distort)))
252 bm.free()
254 def execute(self, context):
255 return execute_check(self, context)
258 class MESH_OT_print3d_check_thick(Operator):
259 bl_idname = "mesh.print3d_check_thick"
260 bl_label = "3D-Print Check Thickness"
261 bl_description = (
262 "Check geometry is above the minimum thickness preference "
263 "(relies on correct normals)"
266 @staticmethod
267 def main_check(obj, info):
268 from . import mesh_helpers
270 scene = bpy.context.scene
271 print_3d = scene.print_3d
273 faces_error = mesh_helpers.bmesh_check_thick_object(obj, print_3d.thickness_min)
274 info.append((tip_("Thin Faces: {}").format(len(faces_error)), (bmesh.types.BMFace, faces_error)))
276 def execute(self, context):
277 return execute_check(self, context)
280 class MESH_OT_print3d_check_sharp(Operator):
281 bl_idname = "mesh.print3d_check_sharp"
282 bl_label = "3D-Print Check Sharp"
283 bl_description = "Check edges are below the sharpness preference"
285 @staticmethod
286 def main_check(obj, info):
287 from . import mesh_helpers
289 scene = bpy.context.scene
290 print_3d = scene.print_3d
291 angle_sharp = print_3d.angle_sharp
293 bm = mesh_helpers.bmesh_copy_from_object(obj, transform=True, triangulate=False)
294 bm.normal_update()
296 edges_sharp = [
297 ele.index for ele in bm.edges
298 if ele.is_manifold and ele.calc_face_angle_signed() > angle_sharp
301 info.append((tip_("Sharp Edge: {}").format(len(edges_sharp)), (bmesh.types.BMEdge, edges_sharp)))
302 bm.free()
304 def execute(self, context):
305 return execute_check(self, context)
308 class MESH_OT_print3d_check_overhang(Operator):
309 bl_idname = "mesh.print3d_check_overhang"
310 bl_label = "3D-Print Check Overhang"
311 bl_description = "Check faces don't overhang past a certain angle"
313 @staticmethod
314 def main_check(obj, info):
315 from mathutils import Vector
316 from . import mesh_helpers
318 scene = bpy.context.scene
319 print_3d = scene.print_3d
320 angle_overhang = (math.pi / 2.0) - print_3d.angle_overhang
322 if angle_overhang == math.pi:
323 info.append(("Skipping Overhang", ()))
324 return
326 bm = mesh_helpers.bmesh_copy_from_object(obj, transform=True, triangulate=False)
327 bm.normal_update()
329 z_down = Vector((0, 0, -1.0))
330 z_down_angle = z_down.angle
332 # 4.0 ignores zero area faces
333 faces_overhang = [
334 ele.index for ele in bm.faces
335 if z_down_angle(ele.normal, 4.0) < angle_overhang
338 info.append((tip_("Overhang Face: {}").format(len(faces_overhang)), (bmesh.types.BMFace, faces_overhang)))
339 bm.free()
341 def execute(self, context):
342 return execute_check(self, context)
345 class MESH_OT_print3d_check_all(Operator):
346 bl_idname = "mesh.print3d_check_all"
347 bl_label = "3D-Print Check All"
348 bl_description = "Run all checks"
350 check_cls = (
351 MESH_OT_print3d_check_solid,
352 MESH_OT_print3d_check_intersections,
353 MESH_OT_print3d_check_degenerate,
354 MESH_OT_print3d_check_distorted,
355 MESH_OT_print3d_check_thick,
356 MESH_OT_print3d_check_sharp,
357 MESH_OT_print3d_check_overhang,
360 def execute(self, context):
361 obj = context.active_object
363 info = []
364 for cls in self.check_cls:
365 cls.main_check(obj, info)
367 report.update(*info)
369 multiple_obj_warning(self, context)
371 return {'FINISHED'}
374 class MESH_OT_print3d_clean_distorted(Operator):
375 bl_idname = "mesh.print3d_clean_distorted"
376 bl_label = "3D-Print Clean Distorted"
377 bl_description = "Tessellate distorted faces"
378 bl_options = {'REGISTER', 'UNDO'}
380 angle: FloatProperty(
381 name="Angle",
382 description="Limit for checking distorted faces",
383 subtype='ANGLE',
384 default=math.radians(45.0),
385 min=0.0,
386 max=math.radians(180.0),
389 def execute(self, context):
390 from . import mesh_helpers
392 obj = context.active_object
393 bm = mesh_helpers.bmesh_from_object(obj)
394 bm.normal_update()
395 elems_triangulate = [ele for ele in bm.faces if mesh_helpers.face_is_distorted(ele, self.angle)]
397 if elems_triangulate:
398 bmesh.ops.triangulate(bm, faces=elems_triangulate)
399 mesh_helpers.bmesh_to_object(obj, bm)
401 self.report({'INFO'}, tip_("Triangulated {} faces").format(len(elems_triangulate)))
403 return {'FINISHED'}
405 def invoke(self, context, event):
406 print_3d = context.scene.print_3d
407 self.angle = print_3d.angle_distort
409 return self.execute(context)
412 class MESH_OT_print3d_clean_non_manifold(Operator):
413 bl_idname = "mesh.print3d_clean_non_manifold"
414 bl_label = "3D-Print Clean Non-Manifold"
415 bl_description = "Cleanup problems, like holes, non-manifold vertices and inverted normals"
416 bl_options = {'REGISTER', 'UNDO'}
418 threshold: FloatProperty(
419 name="Merge Distance",
420 description="Minimum distance between elements to merge",
421 default=0.0001,
423 sides: IntProperty(
424 name="Sides",
425 description="Number of sides in hole required to fill (zero fills all holes)",
426 default=0,
429 def execute(self, context):
430 self.context = context
431 mode_orig = context.mode
433 self.setup_environment()
434 bm_key_orig = self.elem_count(context)
436 self.delete_loose()
437 self.delete_interior()
438 self.remove_doubles(self.threshold)
439 self.dissolve_degenerate(self.threshold)
440 self.fix_non_manifold(context, self.sides) # may take a while
441 self.make_normals_consistently_outwards()
443 bm_key = self.elem_count(context)
445 if mode_orig != 'EDIT_MESH':
446 bpy.ops.object.mode_set(mode='OBJECT')
448 verts = bm_key[0] - bm_key_orig[0]
449 edges = bm_key[1] - bm_key_orig[1]
450 faces = bm_key[2] - bm_key_orig[2]
452 self.report({'INFO'}, tip_("Modified: {:+} vertices, {:+} edges, {:+} faces").format(verts, edges, faces))
454 return {'FINISHED'}
456 @staticmethod
457 def elem_count(context):
458 bm = bmesh.from_edit_mesh(context.edit_object.data)
459 return len(bm.verts), len(bm.edges), len(bm.faces)
461 @staticmethod
462 def setup_environment():
463 """set the mode as edit, select mode as vertices, and reveal hidden vertices"""
464 bpy.ops.object.mode_set(mode='EDIT')
465 bpy.ops.mesh.select_mode(type='VERT')
466 bpy.ops.mesh.reveal()
468 @staticmethod
469 def remove_doubles(threshold):
470 """remove duplicate vertices"""
471 bpy.ops.mesh.select_all(action='SELECT')
472 bpy.ops.mesh.remove_doubles(threshold=threshold)
474 @staticmethod
475 def delete_loose():
476 """delete loose vertices/edges/faces"""
477 bpy.ops.mesh.select_all(action='SELECT')
478 bpy.ops.mesh.delete_loose(use_verts=True, use_edges=True, use_faces=True)
480 @staticmethod
481 def delete_interior():
482 """delete interior faces"""
483 bpy.ops.mesh.select_all(action='DESELECT')
484 bpy.ops.mesh.select_interior_faces()
485 bpy.ops.mesh.delete(type='FACE')
487 @staticmethod
488 def dissolve_degenerate(threshold):
489 """dissolve zero area faces and zero length edges"""
490 bpy.ops.mesh.select_all(action='SELECT')
491 bpy.ops.mesh.dissolve_degenerate(threshold=threshold)
493 @staticmethod
494 def make_normals_consistently_outwards():
495 """have all normals face outwards"""
496 bpy.ops.mesh.select_all(action='SELECT')
497 bpy.ops.mesh.normals_make_consistent()
499 @classmethod
500 def fix_non_manifold(cls, context, sides):
501 """naive iterate-until-no-more approach for fixing manifolds"""
502 total_non_manifold = cls.count_non_manifold_verts(context)
504 if not total_non_manifold:
505 return
507 bm_states = set()
508 bm_key = cls.elem_count(context)
509 bm_states.add(bm_key)
511 while True:
512 cls.fill_non_manifold(sides)
513 cls.delete_newly_generated_non_manifold_verts()
515 bm_key = cls.elem_count(context)
516 if bm_key in bm_states:
517 break
518 else:
519 bm_states.add(bm_key)
521 @staticmethod
522 def select_non_manifold_verts(
523 use_wire=False,
524 use_boundary=False,
525 use_multi_face=False,
526 use_non_contiguous=False,
527 use_verts=False,
529 """select non-manifold vertices"""
530 bpy.ops.mesh.select_non_manifold(
531 extend=False,
532 use_wire=use_wire,
533 use_boundary=use_boundary,
534 use_multi_face=use_multi_face,
535 use_non_contiguous=use_non_contiguous,
536 use_verts=use_verts,
539 @classmethod
540 def count_non_manifold_verts(cls, context):
541 """return a set of coordinates of non-manifold vertices"""
542 cls.select_non_manifold_verts(use_wire=True, use_boundary=True, use_verts=True)
544 bm = bmesh.from_edit_mesh(context.edit_object.data)
545 return sum((1 for v in bm.verts if v.select))
547 @classmethod
548 def fill_non_manifold(cls, sides):
549 """fill in any remnant non-manifolds"""
550 bpy.ops.mesh.select_all(action='SELECT')
551 bpy.ops.mesh.fill_holes(sides=sides)
553 @classmethod
554 def delete_newly_generated_non_manifold_verts(cls):
555 """delete any newly generated vertices from the filling repair"""
556 cls.select_non_manifold_verts(use_wire=True, use_verts=True)
557 bpy.ops.mesh.delete(type='VERT')
560 class MESH_OT_print3d_clean_thin(Operator):
561 bl_idname = "mesh.print3d_clean_thin"
562 bl_label = "3D-Print Clean Thin"
563 bl_description = "Ensure minimum thickness"
564 bl_options = {'REGISTER', 'UNDO'}
566 def execute(self, context):
567 # TODO
569 return {'FINISHED'}
572 # -------------
573 # Select Report
574 # ... helper function for info UI
576 class MESH_OT_print3d_select_report(Operator):
577 bl_idname = "mesh.print3d_select_report"
578 bl_label = "3D-Print Select Report"
579 bl_description = "Select the data associated with this report"
580 bl_options = {'INTERNAL'}
582 index: IntProperty()
584 _type_to_mode = {
585 bmesh.types.BMVert: 'VERT',
586 bmesh.types.BMEdge: 'EDGE',
587 bmesh.types.BMFace: 'FACE',
590 _type_to_attr = {
591 bmesh.types.BMVert: "verts",
592 bmesh.types.BMEdge: "edges",
593 bmesh.types.BMFace: "faces",
596 def execute(self, context):
597 obj = context.edit_object
598 info = report.info()
599 _text, data = info[self.index]
600 bm_type, bm_array = data
602 bpy.ops.mesh.reveal()
603 bpy.ops.mesh.select_all(action='DESELECT')
604 bpy.ops.mesh.select_mode(type=self._type_to_mode[bm_type])
606 bm = bmesh.from_edit_mesh(obj.data)
607 elems = getattr(bm, MESH_OT_print3d_select_report._type_to_attr[bm_type])[:]
609 try:
610 for i in bm_array:
611 elems[i].select_set(True)
612 except:
613 # possible arrays are out of sync
614 self.report({'WARNING'}, "Report is out of date, re-run check")
616 return {'FINISHED'}
619 # -----------
620 # Scale to...
622 def _scale(scale, report=None, report_suffix=""):
623 if scale != 1.0:
624 bpy.ops.transform.resize(value=(scale,) * 3)
625 if report is not None:
626 scale_fmt = clean_float(scale, 6)
627 report({'INFO'}, tip_("Scaled by {}{}").format(scale_fmt, report_suffix))
630 class MESH_OT_print3d_scale_to_volume(Operator):
631 bl_idname = "mesh.print3d_scale_to_volume"
632 bl_label = "Scale to Volume"
633 bl_description = "Scale edit-mesh or selected-objects to a set volume"
634 bl_options = {'REGISTER', 'UNDO'}
636 volume_init: FloatProperty(
637 options={'HIDDEN'},
639 volume: FloatProperty(
640 name="Volume",
641 unit='VOLUME',
642 min=0.0,
643 max=100000.0,
646 def execute(self, context):
647 scale = math.pow(self.volume, 1 / 3) / math.pow(self.volume_init, 1 / 3)
648 scale_fmt = clean_float(scale, 6)
649 self.report({'INFO'}, tip_("Scaled by {}").format(scale_fmt))
650 _scale(scale, self.report)
651 return {'FINISHED'}
653 def invoke(self, context, event):
655 def calc_volume(obj):
656 from . import mesh_helpers
658 bm = mesh_helpers.bmesh_copy_from_object(obj, apply_modifiers=True)
659 volume = bm.calc_volume(signed=True)
660 bm.free()
661 return volume
663 if context.mode == 'EDIT_MESH':
664 volume = calc_volume(context.edit_object)
665 else:
666 volume = sum(calc_volume(obj) for obj in context.selected_editable_objects if obj.type == 'MESH')
668 if volume == 0.0:
669 self.report({'WARNING'}, "Object has zero volume")
670 return {'CANCELLED'}
672 self.volume_init = self.volume = abs(volume)
674 wm = context.window_manager
675 return wm.invoke_props_dialog(self)
678 class MESH_OT_print3d_scale_to_bounds(Operator):
679 bl_idname = "mesh.print3d_scale_to_bounds"
680 bl_label = "Scale to Bounds"
681 bl_description = "Scale edit-mesh or selected-objects to fit within a maximum length"
682 bl_options = {'REGISTER', 'UNDO'}
684 length_init: FloatProperty(
685 options={'HIDDEN'},
687 axis_init: IntProperty(
688 options={'HIDDEN'},
690 length: FloatProperty(
691 name="Length Limit",
692 unit='LENGTH',
693 min=0.0,
694 max=100000.0,
697 def execute(self, context):
698 scale = self.length / self.length_init
699 axis = "XYZ"[self.axis_init]
700 _scale(scale, report=self.report, report_suffix=tip_(", Clamping {}-Axis").format(axis))
701 return {'FINISHED'}
703 def invoke(self, context, event):
704 from mathutils import Vector
706 def calc_length(vecs):
707 return max(((max(v[i] for v in vecs) - min(v[i] for v in vecs)), i) for i in range(3))
709 if context.mode == 'EDIT_MESH':
710 length, axis = calc_length(
711 [Vector(v) @ obj.matrix_world for obj in [context.edit_object] for v in obj.bound_box]
713 else:
714 length, axis = calc_length(
716 Vector(v) @ obj.matrix_world for obj in context.selected_editable_objects
717 if obj.type == 'MESH'
718 for v in obj.bound_box
722 if length == 0.0:
723 self.report({'WARNING'}, "Object has zero bounds")
724 return {'CANCELLED'}
726 self.length_init = self.length = length
727 self.axis_init = axis
729 wm = context.window_manager
730 return wm.invoke_props_dialog(self)
733 class MESH_OT_print3d_align_to_xy(Operator):
734 bl_idname = "mesh.print3d_align_to_xy"
735 bl_label = "Align (rotate) object to XY plane"
736 bl_description = (
737 "Rotates entire object (not mesh) so the selected faces/vertices lie, on average, parallel to the XY plane "
738 "(it does not adjust Z location)"
740 bl_options = {'REGISTER', 'UNDO'}
742 def execute(self, context):
743 # FIXME: Undo is inconsistent.
744 # FIXME: Would be nicer if rotate could pick some object-local axis.
746 from mathutils import Vector
748 print_3d = context.scene.print_3d
749 face_areas = print_3d.use_alignxy_face_area
751 self.context = context
752 mode_orig = context.mode
753 skip_invalid = []
755 for obj in context.selected_objects:
756 orig_loc = obj.location.copy()
757 orig_scale = obj.scale.copy()
759 # When in edit mode, do as the edit mode does.
760 if mode_orig == 'EDIT_MESH':
761 bm = bmesh.from_edit_mesh(obj.data)
762 faces = [f for f in bm.faces if f.select]
763 else:
764 faces = [p for p in obj.data.polygons if p.select]
766 if not faces:
767 skip_invalid.append(obj.name)
768 continue
770 # Rotate object so average normal of selected faces points down.
771 normal = Vector((0.0, 0.0, 0.0))
772 if face_areas:
773 for face in faces:
774 if mode_orig == 'EDIT_MESH':
775 normal += (face.normal * face.calc_area())
776 else:
777 normal += (face.normal * face.area)
778 else:
779 for face in faces:
780 normal += face.normal
781 normal = normal.normalized()
782 normal.rotate(obj.matrix_world) # local -> world.
783 offset = normal.rotation_difference(Vector((0.0, 0.0, -1.0)))
784 offset = offset.to_matrix().to_4x4()
785 obj.matrix_world = offset @ obj.matrix_world
786 obj.scale = orig_scale
787 obj.location = orig_loc
789 if len(skip_invalid) > 0:
790 for name in skip_invalid:
791 print(tip_("Align to XY: Skipping object {}. No faces selected").format(name))
792 if len(skip_invalid) == 1:
793 self.report({'WARNING'}, tip_("Skipping object {}. No faces selected").format(skip_invalid[0]))
794 else:
795 self.report({'WARNING'}, "Skipping some objects. No faces selected. See terminal")
796 return {'FINISHED'}
798 def invoke(self, context, event):
799 if context.mode not in {'EDIT_MESH', 'OBJECT'}:
800 return {'CANCELLED'}
801 return self.execute(context)
804 # ------
805 # Export
807 class MESH_OT_print3d_export(Operator):
808 bl_idname = "mesh.print3d_export"
809 bl_label = "3D-Print Export"
810 bl_description = "Export selected objects using 3D-Print settings"
812 def execute(self, context):
813 from . import export
815 ret = export.write_mesh(context, self.report)
817 if ret:
818 return {'FINISHED'}
820 return {'CANCELLED'}