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 #####
27 from bpy
.types
import Operator
28 from bpy
.props
import (
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(".")
45 head
, tail
= text
[:index
], text
[index
:]
46 tail
= tail
.rstrip("0")
52 def get_unit(unit_system
: str, unit
: str) -> tuple[float, str]:
53 # Returns unit length relative to meter and unit symbol
57 "KILOMETERS": (1000.0, "km"),
59 "CENTIMETERS": (0.01, "cm"),
60 "MILLIMETERS": (0.001, "mm"),
61 "MICROMETERS": (0.000001, "µm"),
64 "MILES": (1609.344, "mi"),
65 "FEET": (0.3048, "\'"),
66 "INCHES": (0.0254, "\""),
67 "THOU": (0.0000254, "thou"),
72 return units
[unit_system
][unit
]
74 fallback_unit
= "CENTIMETERS" if unit_system
== "METRIC" else "INCHES"
75 return units
[unit_system
][fallback_unit
]
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
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()
99 if unit
.system
== 'NONE':
100 volume_fmt
= clean_float(volume
, 8)
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))
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
)
130 if unit
.system
== 'NONE':
131 area_fmt
= clean_float(area
, 8)
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))
147 def execute_check(self
, context
):
148 obj
= context
.active_object
151 self
.main_check(obj
, info
)
154 multiple_obj_warning(self
, context
)
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"
170 def main_check(obj
, info
):
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(
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
)))
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"
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"
211 "Check for degenerate geometry that may not print properly "
212 "(zero area faces, zero length edges)"
216 def main_check(obj
, info
):
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
)))
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"
244 def main_check(obj
, info
):
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)
255 faces_distort
= array
.array(
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
)))
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"
272 "Check geometry is above the minimum thickness preference "
273 "(relies on correct normals)"
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"
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)
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
)))
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"
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", ()))
336 bm
= mesh_helpers
.bmesh_copy_from_object(obj
, transform
=True, triangulate
=False)
339 z_down
= Vector((0, 0, -1.0))
340 z_down_angle
= z_down
.angle
342 # 4.0 ignores zero area faces
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
)))
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"
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
374 for cls
in self
.check_cls
:
375 cls
.main_check(obj
, info
)
379 multiple_obj_warning(self
, context
)
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(
392 description
="Limit for checking distorted faces",
394 default
=math
.radians(45.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
)
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")
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",
435 description
="Number of sides in hole required to fill (zero fills all holes)",
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
)
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")
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
)
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()
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
)
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)
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')
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
)
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()
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
:
518 bm_key
= cls
.elem_count(context
)
519 bm_states
.add(bm_key
)
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
:
529 bm_states
.add(bm_key
)
532 def select_non_manifold_verts(
535 use_multi_face
=False,
536 use_non_contiguous
=False,
539 """select non-manifold vertices"""
540 bpy
.ops
.mesh
.select_non_manifold(
543 use_boundary
=use_boundary
,
544 use_multi_face
=use_multi_face
,
545 use_non_contiguous
=use_non_contiguous
,
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
))
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
)
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
):
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'}
595 bmesh
.types
.BMVert
: 'VERT',
596 bmesh
.types
.BMEdge
: 'EDGE',
597 bmesh
.types
.BMFace
: 'FACE',
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
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
])[:]
621 elems
[i
].select_set(True)
623 # possible arrays are out of sync
624 self
.report({'WARNING'}, "Report is out of date, re-run check")
632 def _scale(scale
, report
=None, report_suffix
=""):
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(
649 volume
: FloatProperty(
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
)
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)
673 if context
.mode
== 'EDIT_MESH':
674 volume
= calc_volume(context
.edit_object
)
676 volume
= sum(calc_volume(obj
) for obj
in context
.selected_editable_objects
if obj
.type == 'MESH')
679 self
.report({'WARNING'}, "Object has zero volume")
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(
697 axis_init
: IntProperty(
700 length
: FloatProperty(
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")
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
]
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
733 self
.report({'WARNING'}, "Object has zero bounds")
736 self
.length_init
= self
.length
= length
737 self
.axis_init
= axis
739 wm
= context
.window_manager
740 return wm
.invoke_props_dialog(self
)
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
):
754 ret
= export
.write_mesh(context
, self
.report
)