Source code for ddg.visualization.blender.mesh

"""
Collection of functions for Blender Mesh objects.

The related functions on the level of bmesh are found in bmeshutils.py
module.

"""
import io
import textwrap
from array import array
from contextlib import redirect_stdout

import bmesh
import bpy
import numpy as np

import ddg.math.projective as putils
from ddg.visualization.blender._validate import validate_points_array
from ddg.visualization.blender.bmesh import bmesh_from_mesh
from ddg.visualization.blender.context import mode
from ddg.visualization.blender.material import set_material
from ddg.visualization.blender.object import (
    blender_object,
    copy,
    delete,
    from_data,
    get_data,
    link,
    set_prop,
)


[docs]def mesh(name, points, faces): """Create a mesh from points and faces. Parameters ---------- name : str The `name` attribute of the mesh. points : numpy.ndarray of shape (num_points, 3) The points of the mesh. The dtype must be some kind of floating point dtype. faces : numpy.ndarray of shape (num_faces, k) The faces of the mesh, where `k` is the number of vertices of each face. The dtype must be some kind of integer dtype. Returns ------- bpy.types.Mesh Raises ------ ValueError If there already exists a mesh with the same name. See also -------- ddg.visualization.curve_as_mesh """ _ = validate_points_array(points) if name in bpy.data.meshes: raise ValueError( f"bpy.data.meshes already contains a mesh whose name is {name}" ) me = bpy.data.meshes.new(name) me.from_pydata(points, [], list(faces)) return me
[docs]def mesh_object(points, faces, mesh_and_object_name, collection): """Create a Blender mesh from points and faces and wrap it in a Blender object. Parameters ---------- points : numpy.ndarray of shape (num_points, 3) The points of the mesh. The dtype must be some kind of floating point dtype. faces : numpy.ndarray of shape (num_faces, k) The faces of the mesh, where `k` is the number of vertices of each face. The dtype must be some kind of integer dtype. mesh_and_object_name : str or tuple[str, str] The `name` attribute of the mesh and the object. If only one string is passed, then use the same name for both. collection : bpy.types.Collection, str, None If `None`, then the Blender object is not linked. Otherwise, the Collection or name of collection to link the object to. Returns ------- bpy.types.Object Raises ------ ValueError If there already exists a Blender mesh or Blender object with the same name. """ _ = validate_points_array(points) if isinstance(mesh_and_object_name, str): me_name = mesh_and_object_name object_name = mesh_and_object_name else: me_name, object_name = mesh_and_object_name if object_name in bpy.data.objects: raise ValueError( f"bpy.data.objects already contains an object whose name is {object_name}" ) if me_name in bpy.data.meshes: raise ValueError( f"bpy.data.meshes already contains a mesh whose name is {me_name}" ) me = mesh(me_name, points, faces) bobj = blender_object(object_name, me) if collection is not None: link(bobj, collection) return bobj
def _redirect_logs(f): """Run a function and capture its output to stdout. Motivation: Some Blender operators print useful warnings to stdout yet don't return them. For example if the intersection is empty, ``bpy.ops.mesh.intersect`` returns {'FINISHED'} and prints Warning: No intersections found to stdout. Parameters ---------- f : Callable with no arguments and return type T Returns ------- tuple of T, str First value is the return value of `f`, second value is everything that was printed to stdout while running `f`. """ # FIXME: breaks pytest -s, which probably does its own recapturing, # log is printed if the test passes. # Not a huge deal, but still... stdout = io.StringIO() with redirect_stdout(stdout): return_value = f() log = stdout.getvalue() if log: print(log) return return_value, log
[docs]def from_bmesh(bm, name, free=False): """Write a BMesh to a new mesh. Parameters ---------- bm : bmesh.types.BMesh name : str The `.name` of the new mesh. free : bool (default=False) If True, `bm` is freed. Returns ------- bpy.types.Mesh """ me = bpy.data.meshes.new(name) bm.to_mesh(me) if free: bm.free() return me
[docs]def duplicate_by_properties( bobj, properties, collection=None, material=None, unlink_initial_object=True ): """Create duplicate Blender objects with certain properties Parameters ---------- bobj : bpy.types.Object Blender object to duplicate. properties : List List of dictionaries that define the properties that are each applied to a duplicate collection : bpy.types.Collection or List if bpy.types.Collection, links all duplicate object to single given collection if List, expects list of bpy.types.Collection, links one duplicate to each collection given material : bpy.types.Material or str , optional Name of Material or bpy.types.Material to be applied to the duplicate objects Returns ------- duplicates : list A list of Blender objects : the created linked and transformed duplicates. """ n = len(properties) duplicates = duplicate_linked(bobj, n, collection) for j, objprops in enumerate(properties): for prop in objprops: set_prop(duplicates[j], prop, objprops[prop]) if material is not None: if not isinstance(material, array): for i, mat in enumerate(material): set_material(duplicates[i], mat) else: for i in range(n): set_material(duplicates[i], material[i]) if unlink_initial_object: bpy.data.objects.remove(bobj, do_unlink=True) return duplicates
[docs]def duplicate_by_transformation_matrices( bobj, transformation_matrices, collection=None, material=None, unlink_initial_object=True, ): """Function creating duplicates of a Blender object. each duplicated has its properties changed given by properties. Parameters ---------- bobj : bpy.types.Object Blender object to duplicate. transformation_matrices : List List of 4x4 matrices corresponding to the transformations. collection : bpy.types.Collection or List, (default=None) bpy.types.Collection: links all duplicate object to single given collection List: expects list of bpy.types.Collection, links one duplicate to each collection given materials : bpy.types.Material or str or List, (default=None) Name of the material or bpy.types.Material or List same length as properties populted with str (Name of the material) or bpy.types.Material (actual material object) unlink_initial_object : bool, (default=True) unlinks the given originally Blender object from the scene Returns ------- duplicates : list A list of Blender objects : the created linked and transformed duplicates. """ properties = [] for matrix in transformation_matrices: properties.append({"matrix_world": matrix}) return duplicate_by_properties( bobj, properties, collection, material, unlink_initial_object )
[docs]def duplicate_linked(bobj, n, collection=None): """Creates n duplicate Blender objects. Parameters ---------- bobj : bpy.types.Object Blender object to duplicate. n : Integer Integer of wanted number of duplicates collection : bpy.types.Collection or List of length n of bpy.types.Collection a single bpy.types.Collection: links all duplicate object to single given collection List of length n populated with bpy.types.Collection : links one duplicate to each collection given Returns ------- duplicates : list List of Blender objects : Created linked duplicates. """ duplicates = [] if isinstance(collection, bpy.types.Collection): for i in range(n): new_mesh = bpy.data.meshes.new("tmp") duplicate = bpy.data.objects.new(bobj.name, new_mesh) duplicate.data = bobj.data if collection: collection.objects.link(duplicate) duplicates.append(duplicate) del new_mesh else: for i in range(n): new_mesh = bpy.data.meshes.new("tmp") duplicate = bpy.data.objects.new(bobj.name, new_mesh) duplicate.data = bobj.data if collection: collection[i].objects.link(duplicate) duplicates.append(duplicate) del new_mesh return duplicates
# ##################### # Modify mesh functions # #####################
[docs]def transform(bobj_or_mesh, M): """ Transform vertices by a matrix. Parameters ---------- bobj_or_mesh : bpy.types.Object or bpy.types.Mesh A Blender object (whose data is a mesh) or a mesh. M : numpy.ndarray of shape (3, 3) or (4, 4) Returns ------- bpy.types.Object or bpy.types.Mesh The input Blender object or mesh. """ mesh = get_data(bobj_or_mesh) if M.shape == (3, 3): for vert in mesh.vertices: vert.co = np.dot(M, np.array(vert.co)) elif M.shape == (4, 4): for vert in mesh.vertices: vert.co = putils.dehomogenize( np.dot(M, putils.homogenize(np.array(vert.co))) ) else: raise ValueError("Shape of matrix must be (3, 3) or (4, 4).") return bobj_or_mesh
[docs]def join( bobjs_or_meshes, name=None, keep_original=True, collection=None, remove_doubles_dist=None, ): """Join Blender objects or meshes into a single Blender object. Parameters ---------- bobjs_or_meshes : Sequence of bpy.types.Object or bpy.types.Mesh Must be non-empty. Objects must have mesh data. name : str (default=None) Name of the new object. keep_original : bool (default=True) If `False`, elements of `bobjs_or_meshes` including their data are deleted. collection : bpy.types.Collection (default=None) Collection to link the object to. If `None`, the object is not linked. remove_doubles_dist : float or None (default=None) If this parameter isn't `None`, redundant vertices are removed with `bmesh.ops.remove_doubles` where `dist=remove_doubles_dist`. Returns ------- bpy.types.Object """ if not bobjs_or_meshes: raise ValueError("bobjs_or_meshes needs to be non-empty.") bm = bmesh.new() if name is None: name = bobjs_or_meshes[0].name for bobj_or_mesh in bobjs_or_meshes: if isinstance(bobj_or_mesh, bpy.types.Object): bobj = bobj_or_mesh mesh = bobj.data transform(mesh, np.array(bobj.matrix_world)) bm.from_mesh(mesh) if not keep_original: delete(bobj) bpy.data.meshes.remove(mesh) else: mesh = bobj_or_mesh bm.from_mesh(mesh) if not keep_original: bpy.data.meshes.remove(mesh) if remove_doubles_dist is not None: bmesh.ops.remove_doubles(bm, verts=bm.verts, dist=remove_doubles_dist) mesh = from_bmesh(bm, name=name, free=True) ob = from_data(mesh, name=name, collection=collection) return ob
[docs]def connected_components(bobj, keep_original=True): """Separates a Blender object into its connected components. This is a fancier version of ``bpy.ops.mesh.separate`` which takes care of mode switches and can handle objects which are hidden from the viewport. Parameters ---------- bobj : bpy.types.Object Object to be separated. Must have mesh data. keep_original : bool (default=True) If ``True``, then ``bobj`` is copied before separation. Returns ------- list of bpy.types.Object The connected components of the Blender object. See Also -------- bpy.ops.mesh.separate """ # This function essentially wraps bpy.ops.mesh.separate. In interactive # Blender, it is found in EDIT mode -> Mesh -> Separate. Note that if # accessed via the Python API, it also works in OBJECT mode, which is what # we do below. I'm not sure why OBJECT mode was originally chosen, so I # left it as it is. # If hide_viewport == True, then bpy.ops.mesh.separate doesn't raise an # exception, but it won't do anything either. Of course, in interactive # Blender it wouldn't even be possible to switch to EDIT mode if the object # isn't visible in the viewport, but one has to be more careful when using # the Python API. # The solution is to store the original hide_viewport state, set it to # False, separate the mesh and then restore the original hide_viewport # state at the end (assuming we keep the original object). hide_viewport = bobj.hide_viewport bobj.hide_viewport = False bobj_ = copy(bobj, bpy.context.collection) if keep_original else bobj with mode(bobj_, mode="OBJECT"): # Make sure that ONLY bobj_ is selected and thus separated. bpy.ops.object.select_all(action="DESELECT") bobj_.select_set(True) bpy.ops.mesh.separate(type="LOOSE") components = list(bpy.context.selected_objects) bpy.ops.object.select_all(action="DESELECT") # This loop has to be outside of the mode context above. The reason # is: if bobj_ was in components and hide_viewport == False, then # bobj_.hide_viewport would be set to False by the loop. This would # result in an exception upon exiting the context, because the # context manager will attempt to set the mode for a hidden object. for component in components: component.hide_viewport = hide_viewport if keep_original: bobj.hide_viewport = hide_viewport return components
[docs]def shade_smooth(bobj_or_mesh, smooth=True): """Apply smooth or flat shading to each polygon of `bobj_or_mesh`. Parameters ---------- bobj_or_mesh : bpy.types.Object or bpy.types.Mesh An object (whose data is a mesh) or a mesh. smooth : bool (default=True) The `use_smooth` attribute of every polygon is set to this value. Returns ------- bpy.types.Object or bpy.types.Mesh The input Blender object or mesh. """ mesh = get_data(bobj_or_mesh) for p in mesh.polygons: p.use_smooth = smooth mesh.update() return bobj_or_mesh
[docs]def clear(meshes=None, do_unlink=True, only_unused=False): """Delete all given meshes. Parameters ---------- meshes : Iterable of bpy.type.Mesh (default=None) Meshes to be deleted. If the argument is not provided or None all meshes will be deleted. do_unlink : bool (default=True) Unlink meshes from their objects and scenes, if needed, before deleting them. only_unused : bool (default=False) Removes all unused meshes. If an Iterable of meshes is given as first parameter only those are effected, otherwise all meshes. Returns ------- None """ if meshes is None: meshes = bpy.data.meshes meshes = list(meshes) if only_unused: while meshes: mesh = meshes.pop() if mesh.users == 0: bpy.data.meshes.remove(mesh, do_unlink=do_unlink) else: while meshes: bpy.data.meshes.remove(meshes.pop(), do_unlink=do_unlink)
def _self_intersect(bobj, intersection_operator): """Self intersect a Blender object. Parameters ---------- bobj : bpy.types.Object Must have mesh data. intersection_operator : Callable with no parameters Called internally to compute the intersection. Returns ------- bpy.types.Object or None The self-intersected input ``bobj`` or ``None`` if the self-intersection is empty. """ with mode(bobj, mode="EDIT", mesh_select_mode={"VERT"}, exit_mode="OBJECT"): bpy.ops.mesh.select_all(action="SELECT") _, warning = _redirect_logs(intersection_operator) if warning == "Warning: No intersections found\n": mesh = bobj.data bpy.data.objects.remove(bobj) bpy.data.meshes.remove(mesh) return None else: # It isn't true that an intersection_operator based on # bpy.ops.mesh.intersect will select precisely the intersection. # For example, copy a cube, then self intersect their join. # With solver="EXACT", no vertices are selected. # With solver="FAST", all vertices are selected (and interestingly, the # quads get triangulated). # There is no warning in either case, i.e. the operator finds an # intersection in both cases. # # So how do we determine which vertices we need to delete? # It seems that if an intersection is mathematically a curve - in # Blender it is still of type `bpy.type.Mesh` - then # bpy.ops.mesh.intersect will select exactly the curve's vertices for # both solvers. In that case, there should be many deselected vertices # belonging to the original mesh and it should be safe to delete these. with bmesh_from_mesh(bobj.data) as bm: if any(v.select for v in bm.verts) and not all(v.select for v in bm.verts): bmesh.ops.delete(bm, geom=[v for v in bm.verts if not v.select]) return bobj
[docs]def intersection( bobj1, bobj2, collection, name="Intersection", intersection_operator=lambda: bpy.ops.mesh.intersect(mode="SELECT"), remove_doubles_dist=0.001, attempt_curve_conversion=True, ): """Intersects two mesh objects. This function relies on Blender's intersection operators. The `intersection_operator` parameter allows customization of the operator options, which usually need to be adjusted on a case by case basis. Note that the Blender's intersection operators aren't always reliable, especially for 2-dimensional intersections. Parameters ---------- bobj1 : bpy.types.Object First object to intersect. Must have mesh data. bobj2 : bpy.types.Object Second object to intersect. Must have mesh data. collection : bpy.types.Collection Collection to add the intersection object to. name : str (default="Intersection") Name of the intersection object. intersection_operator : Callable (default=lambda: bpy.ops.mesh.intersect(mode="SELECT")) Must have no input arguments. Called internally to compute the intersection. remove_doubles_dist : float or None (default=0.001) If this parameter isn't ``None``, redundant vertices are removed with ``bmesh.ops.remove_doubles`` where ``dist=remove_doubles_dist``. attempt_curve_conversion : bool (default=True) If ``True``, then ``bpy.ops.object.convert(target="CURVE")`` is applied to the intersection. This operator tries to convert the intersection's ``data`` from ``bpy.types.Mesh`` to ``bpy.types.Curve``, but it may fail silently. If it does succeed, it may have unexpected results. For example, it converts a mesh consisting of a single triangle to the triangle's boundary curve. Returns ------- bpy.types.Object or None The intersection or ``None`` if it is empty. If the intersection is non-empty, its data attribute is of type `bpy.types.Curve` or `bpy.types.Mesh`. Notes ----- The intersection is computed as follows: - join ``bobj1`` and ``bobj2`` - for each connected component - select all vertices in edit mode - call ``intersection_operator`` which should compute the intersection and select its vertices - delete unselected vertices, leaving just the intersection - join the intersections (if there are any) - optionally, delete redundant vertices - optionally, attempt to convert the intersection's data to a curve using ``bpy.ops.convert`` Joining relies on :py:func:`ddg.visualization.blender.mesh.join` which avoids ``bpy.ops`` including ``bpy.ops.object.join``. """ # noqa: E501 # Previous versions of intersection_curve allowed for collection to be # None. This is no longer the case, because the use of # bpy.ops.mesh.intersect necessitates edit mode and thus a linked object # and a collection. if collection is None: raise ValueError("collection must not be None") if not isinstance(bobj1.data, bpy.types.Mesh) or not isinstance( bobj2.data, bpy.types.Mesh ): raise ValueError("Both bobj1 and bobj2 must have meshes as data.") bobj1_components = connected_components(bobj1) bobj2_components = connected_components(bobj2) # Unfortunately, it is critical that the joined components are # linked to a collection, because _self_intersect uses # bpy.ops.mesh.intersect. joined_components = [ join( (comp_1, comp_2), name="joined_component", keep_original=False, collection=collection, ) for comp_1 in bobj1_components for comp_2 in bobj2_components ] intersections = [ _self_intersect(bobj, intersection_operator) for bobj in joined_components ] non_empty_intersections = [bobj for bobj in intersections if bobj is not None] if non_empty_intersections: intersection = join( intersections, collection=collection, name=name, keep_original=False, remove_doubles_dist=remove_doubles_dist, ) mesh = intersection.data if attempt_curve_conversion: with mode(intersection, mode="OBJECT"): bpy.ops.object.select_all(action="DESELECT") intersection.select_set(True) bpy.ops.object.convert(target="CURVE") intersection.select_set(False) if isinstance(intersection.data, bpy.types.Curve): # bpy.ops.object.convert managed to convert intersection.data to a curve. # bpy.ops.object.convert doesn't mesh though, so we need # to do it ourselves. clear([mesh], do_unlink=False, only_unused=True) elif isinstance(intersection.data, bpy.types.Mesh): # bpy.ops.convert wasn't able to convert intersection.data to a curve. pass else: raise ValueError( textwrap.dedent( f""" {intersection.data = } is neither a mesh or a curve. This should never happen, please write a bug report. """ ) ) return intersection else: return None