Update scripts to account for removal of the context override to bpy.ops
[blender-addons.git] / object_print3d_utils / operators.py
blob80ca4b3edfca614a69dee699047125e42cb0be25
1 # SPDX-License-Identifier: GPL-2.0-or-later
3 # All Operator
6 import math
8 import bpy
9 from bpy.types import Operator
10 from bpy.props import (
11 IntProperty,
12 FloatProperty,
14 import bmesh
16 from bpy.app.translations import pgettext_tip as tip_
18 from . import report
21 def clean_float(value: float, precision: int = 0) -> str:
22 # Avoid scientific notation and strip trailing zeros: 0.000 -> 0.0
24 text = f"{value:.{precision}f}"
25 index = text.rfind(".")
27 if index != -1:
28 index += 2
29 head, tail = text[:index], text[index:]
30 tail = tail.rstrip("0")
31 text = head + tail
33 return text
36 def get_unit(unit_system: str, unit: str) -> tuple[float, str]:
37 # Returns unit length relative to meter and unit symbol
39 units = {
40 "METRIC": {
41 "KILOMETERS": (1000.0, "km"),
42 "METERS": (1.0, "m"),
43 "CENTIMETERS": (0.01, "cm"),
44 "MILLIMETERS": (0.001, "mm"),
45 "MICROMETERS": (0.000001, "µm"),
47 "IMPERIAL": {
48 "MILES": (1609.344, "mi"),
49 "FEET": (0.3048, "\'"),
50 "INCHES": (0.0254, "\""),
51 "THOU": (0.0000254, "thou"),
55 try:
56 return units[unit_system][unit]
57 except KeyError:
58 fallback_unit = "CENTIMETERS" if unit_system == "METRIC" else "INCHES"
59 return units[unit_system][fallback_unit]
62 # ---------
63 # Mesh Info
66 class MESH_OT_print3d_info_volume(Operator):
67 bl_idname = "mesh.print3d_info_volume"
68 bl_label = "3D-Print Info Volume"
69 bl_description = "Report the volume of the active mesh"
71 def execute(self, context):
72 from . import mesh_helpers
74 scene = context.scene
75 unit = scene.unit_settings
76 scale = 1.0 if unit.system == 'NONE' else unit.scale_length
77 obj = context.active_object
79 bm = mesh_helpers.bmesh_copy_from_object(obj, apply_modifiers=True)
80 volume = bm.calc_volume()
81 bm.free()
83 if unit.system == 'NONE':
84 volume_fmt = clean_float(volume, 8)
85 else:
86 length, symbol = get_unit(unit.system, unit.length_unit)
88 volume_unit = volume * (scale ** 3.0) / (length ** 3.0)
89 volume_str = clean_float(volume_unit, 4)
90 volume_fmt = f"{volume_str} {symbol}"
92 report.update((tip_("Volume: {}³").format(volume_fmt), None))
94 return {'FINISHED'}
97 class MESH_OT_print3d_info_area(Operator):
98 bl_idname = "mesh.print3d_info_area"
99 bl_label = "3D-Print Info Area"
100 bl_description = "Report the surface area of the active mesh"
102 def execute(self, context):
103 from . import mesh_helpers
105 scene = context.scene
106 unit = scene.unit_settings
107 scale = 1.0 if unit.system == 'NONE' else unit.scale_length
108 obj = context.active_object
110 bm = mesh_helpers.bmesh_copy_from_object(obj, apply_modifiers=True)
111 area = mesh_helpers.bmesh_calc_area(bm)
112 bm.free()
114 if unit.system == 'NONE':
115 area_fmt = clean_float(area, 8)
116 else:
117 length, symbol = get_unit(unit.system, unit.length_unit)
119 area_unit = area * (scale ** 2.0) / (length ** 2.0)
120 area_str = clean_float(area_unit, 4)
121 area_fmt = f"{area_str} {symbol}"
123 report.update((tip_("Area: {}²").format(area_fmt), None))
125 return {'FINISHED'}
128 # ---------------
129 # Geometry Checks
131 def execute_check(self, context):
132 obj = context.active_object
134 info = []
135 self.main_check(obj, info)
136 report.update(*info)
138 multiple_obj_warning(self, context)
140 return {'FINISHED'}
143 def multiple_obj_warning(self, context):
144 if len(context.selected_objects) > 1:
145 self.report({"INFO"}, "Multiple selected objects. Only the active one will be evaluated")
148 class MESH_OT_print3d_check_solid(Operator):
149 bl_idname = "mesh.print3d_check_solid"
150 bl_label = "3D-Print Check Solid"
151 bl_description = "Check for geometry is solid (has valid inside/outside) and correct normals"
153 @staticmethod
154 def main_check(obj, info):
155 import array
156 from . import mesh_helpers
158 bm = mesh_helpers.bmesh_copy_from_object(obj, transform=False, triangulate=False)
160 edges_non_manifold = array.array('i', (i for i, ele in enumerate(bm.edges) if not ele.is_manifold))
161 edges_non_contig = array.array(
162 'i',
163 (i for i, ele in enumerate(bm.edges) if ele.is_manifold and (not ele.is_contiguous)),
166 info.append(
167 (tip_("Non Manifold Edges: {}").format(
168 len(edges_non_manifold)),
169 (bmesh.types.BMEdge,
170 edges_non_manifold)))
171 info.append((tip_("Bad Contiguous Edges: {}").format(len(edges_non_contig)), (bmesh.types.BMEdge, edges_non_contig)))
173 bm.free()
175 def execute(self, context):
176 return execute_check(self, context)
179 class MESH_OT_print3d_check_intersections(Operator):
180 bl_idname = "mesh.print3d_check_intersect"
181 bl_label = "3D-Print Check Intersections"
182 bl_description = "Check geometry for self intersections"
184 @staticmethod
185 def main_check(obj, info):
186 from . import mesh_helpers
188 faces_intersect = mesh_helpers.bmesh_check_self_intersect_object(obj)
189 info.append((tip_("Intersect Face: {}").format(len(faces_intersect)), (bmesh.types.BMFace, faces_intersect)))
191 def execute(self, context):
192 return execute_check(self, context)
195 class MESH_OT_print3d_check_degenerate(Operator):
196 bl_idname = "mesh.print3d_check_degenerate"
197 bl_label = "3D-Print Check Degenerate"
198 bl_description = (
199 "Check for degenerate geometry that may not print properly "
200 "(zero area faces, zero length edges)"
203 @staticmethod
204 def main_check(obj, info):
205 import array
206 from . import mesh_helpers
208 scene = bpy.context.scene
209 print_3d = scene.print_3d
210 threshold = print_3d.threshold_zero
212 bm = mesh_helpers.bmesh_copy_from_object(obj, transform=False, triangulate=False)
214 faces_zero = array.array('i', (i for i, ele in enumerate(bm.faces) if ele.calc_area() <= threshold))
215 edges_zero = array.array('i', (i for i, ele in enumerate(bm.edges) if ele.calc_length() <= threshold))
217 info.append((tip_("Zero Faces: {}").format(len(faces_zero)), (bmesh.types.BMFace, faces_zero)))
218 info.append((tip_("Zero Edges: {}").format(len(edges_zero)), (bmesh.types.BMEdge, edges_zero)))
220 bm.free()
222 def execute(self, context):
223 return execute_check(self, context)
226 class MESH_OT_print3d_check_distorted(Operator):
227 bl_idname = "mesh.print3d_check_distort"
228 bl_label = "3D-Print Check Distorted Faces"
229 bl_description = "Check for non-flat faces"
231 @staticmethod
232 def main_check(obj, info):
233 import array
234 from . import mesh_helpers
236 scene = bpy.context.scene
237 print_3d = scene.print_3d
238 angle_distort = print_3d.angle_distort
240 bm = mesh_helpers.bmesh_copy_from_object(obj, transform=True, triangulate=False)
241 bm.normal_update()
243 faces_distort = array.array(
244 'i',
245 (i for i, ele in enumerate(bm.faces) if mesh_helpers.face_is_distorted(ele, angle_distort))
248 info.append((tip_("Non-Flat Faces: {}").format(len(faces_distort)), (bmesh.types.BMFace, faces_distort)))
250 bm.free()
252 def execute(self, context):
253 return execute_check(self, context)
256 class MESH_OT_print3d_check_thick(Operator):
257 bl_idname = "mesh.print3d_check_thick"
258 bl_label = "3D-Print Check Thickness"
259 bl_description = (
260 "Check geometry is above the minimum thickness preference "
261 "(relies on correct normals)"
264 @staticmethod
265 def main_check(obj, info):
266 from . import mesh_helpers
268 scene = bpy.context.scene
269 print_3d = scene.print_3d
271 faces_error = mesh_helpers.bmesh_check_thick_object(obj, print_3d.thickness_min)
272 info.append((tip_("Thin Faces: {}").format(len(faces_error)), (bmesh.types.BMFace, faces_error)))
274 def execute(self, context):
275 return execute_check(self, context)
278 class MESH_OT_print3d_check_sharp(Operator):
279 bl_idname = "mesh.print3d_check_sharp"
280 bl_label = "3D-Print Check Sharp"
281 bl_description = "Check edges are below the sharpness preference"
283 @staticmethod
284 def main_check(obj, info):
285 from . import mesh_helpers
287 scene = bpy.context.scene
288 print_3d = scene.print_3d
289 angle_sharp = print_3d.angle_sharp
291 bm = mesh_helpers.bmesh_copy_from_object(obj, transform=True, triangulate=False)
292 bm.normal_update()
294 edges_sharp = [
295 ele.index for ele in bm.edges
296 if ele.is_manifold and ele.calc_face_angle_signed() > angle_sharp
299 info.append((tip_("Sharp Edge: {}").format(len(edges_sharp)), (bmesh.types.BMEdge, edges_sharp)))
300 bm.free()
302 def execute(self, context):
303 return execute_check(self, context)
306 class MESH_OT_print3d_check_overhang(Operator):
307 bl_idname = "mesh.print3d_check_overhang"
308 bl_label = "3D-Print Check Overhang"
309 bl_description = "Check faces don't overhang past a certain angle"
311 @staticmethod
312 def main_check(obj, info):
313 from mathutils import Vector
314 from . import mesh_helpers
316 scene = bpy.context.scene
317 print_3d = scene.print_3d
318 angle_overhang = (math.pi / 2.0) - print_3d.angle_overhang
320 if angle_overhang == math.pi:
321 info.append(("Skipping Overhang", ()))
322 return
324 bm = mesh_helpers.bmesh_copy_from_object(obj, transform=True, triangulate=False)
325 bm.normal_update()
327 z_down = Vector((0, 0, -1.0))
328 z_down_angle = z_down.angle
330 # 4.0 ignores zero area faces
331 faces_overhang = [
332 ele.index for ele in bm.faces
333 if z_down_angle(ele.normal, 4.0) < angle_overhang
336 info.append((tip_("Overhang Face: {}").format(len(faces_overhang)), (bmesh.types.BMFace, faces_overhang)))
337 bm.free()
339 def execute(self, context):
340 return execute_check(self, context)
343 class MESH_OT_print3d_check_all(Operator):
344 bl_idname = "mesh.print3d_check_all"
345 bl_label = "3D-Print Check All"
346 bl_description = "Run all checks"
348 check_cls = (
349 MESH_OT_print3d_check_solid,
350 MESH_OT_print3d_check_intersections,
351 MESH_OT_print3d_check_degenerate,
352 MESH_OT_print3d_check_distorted,
353 MESH_OT_print3d_check_thick,
354 MESH_OT_print3d_check_sharp,
355 MESH_OT_print3d_check_overhang,
358 def execute(self, context):
359 obj = context.active_object
361 info = []
362 for cls in self.check_cls:
363 cls.main_check(obj, info)
365 report.update(*info)
367 multiple_obj_warning(self, context)
369 return {'FINISHED'}
372 class MESH_OT_print3d_clean_distorted(Operator):
373 bl_idname = "mesh.print3d_clean_distorted"
374 bl_label = "3D-Print Clean Distorted"
375 bl_description = "Tessellate distorted faces"
376 bl_options = {'REGISTER', 'UNDO'}
378 angle: FloatProperty(
379 name="Angle",
380 description="Limit for checking distorted faces",
381 subtype='ANGLE',
382 default=math.radians(45.0),
383 min=0.0,
384 max=math.radians(180.0),
387 def execute(self, context):
388 from . import mesh_helpers
390 obj = context.active_object
391 bm = mesh_helpers.bmesh_from_object(obj)
392 bm.normal_update()
393 elems_triangulate = [ele for ele in bm.faces if mesh_helpers.face_is_distorted(ele, self.angle)]
395 if elems_triangulate:
396 bmesh.ops.triangulate(bm, faces=elems_triangulate)
397 mesh_helpers.bmesh_to_object(obj, bm)
399 self.report({'INFO'}, tip_("Triangulated {} faces").format(len(elems_triangulate)))
401 return {'FINISHED'}
403 def invoke(self, context, event):
404 print_3d = context.scene.print_3d
405 self.angle = print_3d.angle_distort
407 return self.execute(context)
410 class MESH_OT_print3d_clean_non_manifold(Operator):
411 bl_idname = "mesh.print3d_clean_non_manifold"
412 bl_label = "3D-Print Clean Non-Manifold"
413 bl_description = "Cleanup problems, like holes, non-manifold vertices and inverted normals"
414 bl_options = {'REGISTER', 'UNDO'}
416 threshold: FloatProperty(
417 name="Merge Distance",
418 description="Minimum distance between elements to merge",
419 default=0.0001,
421 sides: IntProperty(
422 name="Sides",
423 description="Number of sides in hole required to fill (zero fills all holes)",
424 default=0,
427 def execute(self, context):
428 self.context = context
429 mode_orig = context.mode
431 self.setup_environment()
432 bm_key_orig = self.elem_count(context)
434 self.delete_loose()
435 self.delete_interior()
436 self.remove_doubles(self.threshold)
437 self.dissolve_degenerate(self.threshold)
438 self.fix_non_manifold(context, self.sides) # may take a while
439 self.make_normals_consistently_outwards()
441 bm_key = self.elem_count(context)
443 if mode_orig != 'EDIT_MESH':
444 bpy.ops.object.mode_set(mode='OBJECT')
446 verts = bm_key[0] - bm_key_orig[0]
447 edges = bm_key[1] - bm_key_orig[1]
448 faces = bm_key[2] - bm_key_orig[2]
450 self.report({'INFO'}, tip_("Modified: {:+} vertices, {:+} edges, {:+} faces").format(verts, edges, faces))
452 return {'FINISHED'}
454 @staticmethod
455 def elem_count(context):
456 bm = bmesh.from_edit_mesh(context.edit_object.data)
457 return len(bm.verts), len(bm.edges), len(bm.faces)
459 @staticmethod
460 def setup_environment():
461 """set the mode as edit, select mode as vertices, and reveal hidden vertices"""
462 bpy.ops.object.mode_set(mode='EDIT')
463 bpy.ops.mesh.select_mode(type='VERT')
464 bpy.ops.mesh.reveal()
466 @staticmethod
467 def remove_doubles(threshold):
468 """remove duplicate vertices"""
469 bpy.ops.mesh.select_all(action='SELECT')
470 bpy.ops.mesh.remove_doubles(threshold=threshold)
472 @staticmethod
473 def delete_loose():
474 """delete loose vertices/edges/faces"""
475 bpy.ops.mesh.select_all(action='SELECT')
476 bpy.ops.mesh.delete_loose(use_verts=True, use_edges=True, use_faces=True)
478 @staticmethod
479 def delete_interior():
480 """delete interior faces"""
481 bpy.ops.mesh.select_all(action='DESELECT')
482 bpy.ops.mesh.select_interior_faces()
483 bpy.ops.mesh.delete(type='FACE')
485 @staticmethod
486 def dissolve_degenerate(threshold):
487 """dissolve zero area faces and zero length edges"""
488 bpy.ops.mesh.select_all(action='SELECT')
489 bpy.ops.mesh.dissolve_degenerate(threshold=threshold)
491 @staticmethod
492 def make_normals_consistently_outwards():
493 """have all normals face outwards"""
494 bpy.ops.mesh.select_all(action='SELECT')
495 bpy.ops.mesh.normals_make_consistent()
497 @classmethod
498 def fix_non_manifold(cls, context, sides):
499 """naive iterate-until-no-more approach for fixing manifolds"""
500 total_non_manifold = cls.count_non_manifold_verts(context)
502 if not total_non_manifold:
503 return
505 bm_states = set()
506 bm_key = cls.elem_count(context)
507 bm_states.add(bm_key)
509 while True:
510 cls.fill_non_manifold(sides)
511 cls.delete_newly_generated_non_manifold_verts()
513 bm_key = cls.elem_count(context)
514 if bm_key in bm_states:
515 break
516 else:
517 bm_states.add(bm_key)
519 @staticmethod
520 def select_non_manifold_verts(
521 use_wire=False,
522 use_boundary=False,
523 use_multi_face=False,
524 use_non_contiguous=False,
525 use_verts=False,
527 """select non-manifold vertices"""
528 bpy.ops.mesh.select_non_manifold(
529 extend=False,
530 use_wire=use_wire,
531 use_boundary=use_boundary,
532 use_multi_face=use_multi_face,
533 use_non_contiguous=use_non_contiguous,
534 use_verts=use_verts,
537 @classmethod
538 def count_non_manifold_verts(cls, context):
539 """return a set of coordinates of non-manifold vertices"""
540 cls.select_non_manifold_verts(use_wire=True, use_boundary=True, use_verts=True)
542 bm = bmesh.from_edit_mesh(context.edit_object.data)
543 return sum((1 for v in bm.verts if v.select))
545 @classmethod
546 def fill_non_manifold(cls, sides):
547 """fill in any remnant non-manifolds"""
548 bpy.ops.mesh.select_all(action='SELECT')
549 bpy.ops.mesh.fill_holes(sides=sides)
551 @classmethod
552 def delete_newly_generated_non_manifold_verts(cls):
553 """delete any newly generated vertices from the filling repair"""
554 cls.select_non_manifold_verts(use_wire=True, use_verts=True)
555 bpy.ops.mesh.delete(type='VERT')
558 class MESH_OT_print3d_clean_thin(Operator):
559 bl_idname = "mesh.print3d_clean_thin"
560 bl_label = "3D-Print Clean Thin"
561 bl_description = "Ensure minimum thickness"
562 bl_options = {'REGISTER', 'UNDO'}
564 def execute(self, context):
565 # TODO
567 return {'FINISHED'}
570 # -------------
571 # Select Report
572 # ... helper function for info UI
574 class MESH_OT_print3d_select_report(Operator):
575 bl_idname = "mesh.print3d_select_report"
576 bl_label = "3D-Print Select Report"
577 bl_description = "Select the data associated with this report"
578 bl_options = {'INTERNAL'}
580 index: IntProperty()
582 _type_to_mode = {
583 bmesh.types.BMVert: 'VERT',
584 bmesh.types.BMEdge: 'EDGE',
585 bmesh.types.BMFace: 'FACE',
588 _type_to_attr = {
589 bmesh.types.BMVert: "verts",
590 bmesh.types.BMEdge: "edges",
591 bmesh.types.BMFace: "faces",
594 def execute(self, context):
595 obj = context.edit_object
596 info = report.info()
597 _text, data = info[self.index]
598 bm_type, bm_array = data
600 bpy.ops.mesh.reveal()
601 bpy.ops.mesh.select_all(action='DESELECT')
602 bpy.ops.mesh.select_mode(type=self._type_to_mode[bm_type])
604 bm = bmesh.from_edit_mesh(obj.data)
605 elems = getattr(bm, MESH_OT_print3d_select_report._type_to_attr[bm_type])[:]
607 try:
608 for i in bm_array:
609 elems[i].select_set(True)
610 except:
611 # possible arrays are out of sync
612 self.report({'WARNING'}, "Report is out of date, re-run check")
614 return {'FINISHED'}
617 # -----------
618 # Scale to...
620 def _scale(scale, report=None, report_suffix=""):
621 if scale != 1.0:
622 bpy.ops.transform.resize(value=(scale,) * 3)
623 if report is not None:
624 scale_fmt = clean_float(scale, 6)
625 report({'INFO'}, tip_("Scaled by {}{}").format(scale_fmt, report_suffix))
628 class MESH_OT_print3d_scale_to_volume(Operator):
629 bl_idname = "mesh.print3d_scale_to_volume"
630 bl_label = "Scale to Volume"
631 bl_description = "Scale edit-mesh or selected-objects to a set volume"
632 bl_options = {'REGISTER', 'UNDO'}
634 volume_init: FloatProperty(
635 options={'HIDDEN'},
637 volume: FloatProperty(
638 name="Volume",
639 unit='VOLUME',
640 min=0.0,
641 max=100000.0,
644 def execute(self, context):
645 scale = math.pow(self.volume, 1 / 3) / math.pow(self.volume_init, 1 / 3)
646 scale_fmt = clean_float(scale, 6)
647 self.report({'INFO'}, tip_("Scaled by {}").format(scale_fmt))
648 _scale(scale, self.report)
649 return {'FINISHED'}
651 def invoke(self, context, event):
653 def calc_volume(obj):
654 from . import mesh_helpers
656 bm = mesh_helpers.bmesh_copy_from_object(obj, apply_modifiers=True)
657 volume = bm.calc_volume(signed=True)
658 bm.free()
659 return volume
661 if context.mode == 'EDIT_MESH':
662 volume = calc_volume(context.edit_object)
663 else:
664 volume = sum(calc_volume(obj) for obj in context.selected_editable_objects if obj.type == 'MESH')
666 if volume == 0.0:
667 self.report({'WARNING'}, "Object has zero volume")
668 return {'CANCELLED'}
670 self.volume_init = self.volume = abs(volume)
672 wm = context.window_manager
673 return wm.invoke_props_dialog(self)
676 class MESH_OT_print3d_scale_to_bounds(Operator):
677 bl_idname = "mesh.print3d_scale_to_bounds"
678 bl_label = "Scale to Bounds"
679 bl_description = "Scale edit-mesh or selected-objects to fit within a maximum length"
680 bl_options = {'REGISTER', 'UNDO'}
682 length_init: FloatProperty(
683 options={'HIDDEN'},
685 axis_init: IntProperty(
686 options={'HIDDEN'},
688 length: FloatProperty(
689 name="Length Limit",
690 unit='LENGTH',
691 min=0.0,
692 max=100000.0,
695 def execute(self, context):
696 scale = self.length / self.length_init
697 axis = "XYZ"[self.axis_init]
698 _scale(scale, report=self.report, report_suffix=tip_(", Clamping {}-Axis").format(axis))
699 return {'FINISHED'}
701 def invoke(self, context, event):
702 from mathutils import Vector
704 def calc_length(vecs):
705 return max(((max(v[i] for v in vecs) - min(v[i] for v in vecs)), i) for i in range(3))
707 if context.mode == 'EDIT_MESH':
708 length, axis = calc_length(
709 [Vector(v) @ obj.matrix_world for obj in [context.edit_object] for v in obj.bound_box]
711 else:
712 length, axis = calc_length(
714 Vector(v) @ obj.matrix_world for obj in context.selected_editable_objects
715 if obj.type == 'MESH'
716 for v in obj.bound_box
720 if length == 0.0:
721 self.report({'WARNING'}, "Object has zero bounds")
722 return {'CANCELLED'}
724 self.length_init = self.length = length
725 self.axis_init = axis
727 wm = context.window_manager
728 return wm.invoke_props_dialog(self)
731 class MESH_OT_print3d_align_to_xy(Operator):
732 bl_idname = "mesh.print3d_align_to_xy"
733 bl_label = "Align (rotate) object to XY plane"
734 bl_description = (
735 "Rotates entire object (not mesh) so the selected faces/vertices lie, on average, parallel to the XY plane "
736 "(it does not adjust Z location)"
738 bl_options = {'REGISTER', 'UNDO'}
740 def execute(self, context):
741 # FIXME: Undo is inconsistent.
742 # FIXME: Would be nicer if rotate could pick some object-local axis.
744 from mathutils import Vector
746 print_3d = context.scene.print_3d
747 face_areas = print_3d.use_alignxy_face_area
749 self.context = context
750 mode_orig = context.mode
751 skip_invalid = []
753 for obj in context.selected_objects:
754 orig_loc = obj.location.copy()
755 orig_scale = obj.scale.copy()
757 # When in edit mode, do as the edit mode does.
758 if mode_orig == 'EDIT_MESH':
759 bm = bmesh.from_edit_mesh(obj.data)
760 faces = [f for f in bm.faces if f.select]
761 else:
762 faces = [p for p in obj.data.polygons if p.select]
764 if not faces:
765 skip_invalid.append(obj.name)
766 continue
768 # Rotate object so average normal of selected faces points down.
769 normal = Vector((0.0, 0.0, 0.0))
770 if face_areas:
771 for face in faces:
772 if mode_orig == 'EDIT_MESH':
773 normal += (face.normal * face.calc_area())
774 else:
775 normal += (face.normal * face.area)
776 else:
777 for face in faces:
778 normal += face.normal
779 normal = normal.normalized()
780 normal.rotate(obj.matrix_world) # local -> world.
781 offset = normal.rotation_difference(Vector((0.0, 0.0, -1.0)))
782 offset = offset.to_matrix().to_4x4()
783 obj.matrix_world = offset @ obj.matrix_world
784 obj.scale = orig_scale
785 obj.location = orig_loc
787 if len(skip_invalid) > 0:
788 for name in skip_invalid:
789 print(tip_("Align to XY: Skipping object {}. No faces selected").format(name))
790 if len(skip_invalid) == 1:
791 self.report({'WARNING'}, tip_("Skipping object {}. No faces selected").format(skip_invalid[0]))
792 else:
793 self.report({'WARNING'}, "Skipping some objects. No faces selected. See terminal")
794 return {'FINISHED'}
796 def invoke(self, context, event):
797 if context.mode not in {'EDIT_MESH', 'OBJECT'}:
798 return {'CANCELLED'}
799 return self.execute(context)
802 # ------
803 # Export
805 class MESH_OT_print3d_export(Operator):
806 bl_idname = "mesh.print3d_export"
807 bl_label = "3D-Print Export"
808 bl_description = "Export selected objects using 3D-Print settings"
810 def execute(self, context):
811 from . import export
813 ret = export.write_mesh(context, self.report)
815 if ret:
816 return {'FINISHED'}
818 return {'CANCELLED'}