"""
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_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 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