import contextlib
import bmesh
import bpy
import numpy as np
import ddg.math.projective as putils
##########################
# Generate bmesh functions
##########################
[docs]def from_pydata(verts, edges, faces, bm=None, doubles=False):
"""
Add vertices, edges and faces to a BMesh.
Parameters
----------
verts : sequence of vertices
edges : sequence of edges
faces : sequence of faces
bm : bmesh (default=None)
If None, a new BMesh is created.
Otherwise `bm` is cleared before the vertices, edges and faces are
added.
doubles : bool (default=False)
If `True`, duplicate edges or faces are filtered out.
Returns
-------
BMesh
A BMesh with the given vertices, edges and faces.
"""
if bm is None:
bm = bmesh.new()
else:
bm.clear()
for v in verts:
bm.verts.new(v)
bm.verts.ensure_lookup_table()
if not doubles:
for e in edges:
e = [bm.verts[i] for i in e]
bm.edges.new(e)
for f in faces:
f = [bm.verts[i] for i in f]
bm.faces.new(f)
else:
found = set()
for e in edges:
temp = list(e)
temp.sort()
temp = tuple(temp)
if temp not in found:
e = [bm.verts[i] for i in e]
bm.edges.new(e)
found.add(temp)
found = set()
for f in faces:
temp = list(f)
temp.sort()
temp = tuple(temp)
if temp not in found:
f = [bm.verts[i] for i in f]
bm.faces.new(f)
found.add(temp)
return bm
[docs]@contextlib.contextmanager
def bmesh_from_mesh(mesh):
"""Context manager to create a BMesh.
Provides a BMesh `bm`.
If `mesh` belongs to an object in edit mode, then
- `bm` results from `bmesh.from_edit_mesh(mesh)` and
- `bmesh.update_edit_mesh(mesh)` is called upon exiting the context.
Otherwise
- `bm` is a new BMesh created from `mesh` and
- upon exiting the context, `bm.to_mesh(mesh)` is called and `bm` is
freed.
Parameters
----------
mesh : bpy.types.Mesh
See Also
--------
ddg.visualization.blender.context.mode
A context manager to switch between modes, e.g. OBJECT and EDIT
mode.
Examples
--------
The following examples assume that the currently selected object is
the default cube.
We also use the :py:func:`ddg.visualization.blender.context.mode`
context manager to set a mode for a context and switch to a
different mode upon exiting said context.
>>> import numpy as np
>>> from ddg.visualization.blender.context import mode
>>> from ddg.visualization.blender.bmesh import bmesh_from_mesh
>>>
>>> bpy.ops.mesh.primitive_cube_add()
{'FINISHED'}
>>> cube = bpy.context.object
>>>
>>> def maximum_norm(x):
... '''Determine if the ||v||_max = x for all cube mesh vertices v.'''
... return all(
... np.isclose(np.linalg.norm(v.co, np.inf), x) for v in cube.data.vertices
... )
...
>>> assert maximum_norm(1.0)
>>> with mode(cube, mode="EDIT", exit_mode="OBJECT"):
... # The cube is in edit mode.
... with bmesh_from_mesh(cube.data) as bm:
... # bm is the edit mesh.
... for v in bm.verts:
... v.co *= 3.0
... # After exiting the inner context, changes to the edit mesh have
... # yet to be written to the cube!
... assert maximum_norm(1.0)
...
... # After exiting the outer context, the cube is in object mode
... # and changes to the edit mesh have been written to the cube.
...
>>> assert cube.mode == "OBJECT"
>>> assert maximum_norm(3.0)
For performance reasons, Blender doesn't write changes to the edit mesh to
the underlying mesh itself until exiting edit mode. For example,
>>> bpy.ops.object.mode_set(mode="EDIT")
{'FINISHED'}
>>> with bmesh_from_mesh(cube.data) as bm:
... for v in bm.verts:
... v.co *= 2.0
...
>>> # Maximum norm of all MESH vertices is still 3.0 as before.
>>> # However, if running this example interactively, the cube
>>> # should appear much larger in the viewport.
>>> assert maximum_norm(3.0)
After exiting edit mode, Blender writes the changes to `cube.data`. The
maximum norm of all vertices is now equal to 6 = 3 * 2, as expected.
>>> bpy.ops.object.mode_set(mode="OBJECT")
{'FINISHED'}
>>> assert maximum_norm(6.0)
This is one reason why :py:func:`ddg.visualization.blender.context.mode` is
useful - it makes it more difficult to forget exiting edit mode when using
Blender programmatically.
"""
if (
hasattr(bpy.context.object, "data")
and mesh == bpy.context.object.data
and bpy.context.object.mode == "EDIT"
):
bm = bmesh.from_edit_mesh(mesh)
edit_mode = True
else:
bm = bmesh.new()
bm.from_mesh(mesh)
edit_mode = False
try:
yield bm
finally:
if edit_mode:
bmesh.update_edit_mesh(mesh)
# Do not free bm in this branch! The edit mesh needs to stay
# alive.
else:
bm.to_mesh(mesh)
bm.free()
# TODO: is this actually necessary? The existing tests
# still pass without me.update().
mesh.update()
########################
# Modify bmesh functions
########################
[docs]def join(*bmeshes, free=False, bm=None):
"""Join BMeshes.
Parameters
----------
*bmeshes : BMesh
BMeshes to join.
free : bool (default=False)
If `True`, the input BMeshes are freed.
bm : BMesh (default=None)
If None, a new BMesh is created.
Otherwise `bm` will be cleared before the joined BMeshes are written to
`bm`.
Returns
-------
BMesh
BMesh 'containing' the input BMeshes.
"""
if bm is None:
combined = bmesh.new()
else:
combined = bm
combined.clear()
for _bm in bmeshes:
_bm.verts.ensure_lookup_table()
_bm.verts.index_update()
offset = len(combined.verts)
for vert in _bm.verts:
combined.verts.new(vert.co)
combined.verts.index_update()
combined.verts.ensure_lookup_table()
for edge in _bm.edges:
seq = tuple(combined.verts[i.index + offset] for i in edge.verts)
combined.edges.new(seq)
combined.edges.index_update()
for face in _bm.faces:
seq = tuple(combined.verts[i.index + offset] for i in face.verts)
combined.faces.new(seq)
combined.faces.index_update()
if free:
_bm.free()
return combined
[docs]def cut_half_space(bm, normal, dist):
r"""Intersects a BMesh with a half space.
Let n := normal / ||normal||. The half space is defined by::
<x, n> < dist
A picture::
discarded
<n, v> > dist
──────────────▲───────────────── <n, v> = dist
│ <n, v> < dist
dist * n│
│ not discarded
x
origin
Parameters
----------
bm : BMesh
normal : numpy.ndarray of shape (3,)
Outward normal of the half space.
dist : float
Distance of the boundary of the half space to the origin.
Raises
------
ValueError
If `normal.shape != (3,)`.
"""
if normal.shape != (3,):
raise ValueError(f"{normal = } must have shape (3,).")
n = normal / np.linalg.norm(normal)
bmesh.ops.bisect_plane(
bm,
dist=0.0,
geom=bm.verts[:] + bm.edges[:] + bm.faces[:],
plane_co=dist * n,
plane_no=n,
clear_inner=False,
clear_outer=True,
)
[docs]def cut_between_coordinate_half_spaces(bm, axis, dist, center=np.zeros(3)):
r"""Intersect a BMesh with two half spaces.
Let e_0, e_1, e_2 be the standard basis of R^3.
The BMesh is intersected with the region defined by::
dist < <x - center, e_axis> < dist
which is the intersection of two coordinate half spaces.
A picture where `axis` is 1, which corresponds to the y-axis::
▲
│
y-axis │
│ discarded
│
───────┼────────────────────▲────────────
│ │
│ │dist
│ │
│ not discarded *center
│ │
│ │dist
│ │
───────┼────────────────────▼────────────
│
│ discarded
│
└────────────────────────────►
z-axis points at eye x-axis
Parameters
----------
bm : BMesh
axis : {0, 1, 2}
dist : float
center : numpy.ndarray of shape (3,)
Raises
------
ValueError
If `axis` is not 0, 1 or 2.
If `center.shape != (3,)`.
"""
if axis not in (0, 1, 2):
raise ValueError(f"{axis = } must be one of 0, 1 or 2.")
if center.shape != (3,):
raise ValueError(f"{center = } must have shape (3,).")
n = np.zeros(3)
n[axis] = 1
cut_half_space(bm, n, dist + np.inner(center, n))
cut_half_space(bm, -n, dist - np.inner(center, n))
[docs]def cut_bounding_box(bm, distances, center=np.zeros(3)):
r"""Intersect a BMesh with an axis aligned bounding box.
Let e_0, e_1, e_2 be the standard basis of R^3.
The BMesh is intersected with the axis aligned bounding box centered
at `center`, whose distances to the sides are given by `distances`.
If a distance is None, it is assumed to be infinity, so the box
isn't required to be bounded. More precisely, the box is given by::
{x in R^3 | - distances[i] < <x - center, e_i> < distances[i]}.
A picture of the box::
y-axis
▲
│ ┌───────────▲───────────┐
│ │ d_0│ │ d_0 = distances[0]
│ │ │ d_1 │ d_1 = distances[1]
│ │ └───────────►
│ │ center │
│ │ │
│ └───────────────────────┘
│
└────────────────────────────►
z-axis points at eye x-axis
Parameters
----------
bm : BMesh
distances : sequence of floats or None of length 3
center : numpy.ndarray of shape (3,)
Raises
------
ValueError
If `len(distances) != (3,)`.
If `center.shape != (3,)`.
"""
if len(distances) != 3:
raise ValueError(f"{distances = } must have length 3.")
if center.shape != (3,):
raise ValueError(f"{center = } must have shape (3,).")
for i, dist in enumerate(distances):
if dist is not None:
cut_between_coordinate_half_spaces(bm, i, dist, center)