Source code for ddg.visualization.blender.bmesh

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 transform(bm, M): """ Transform vertices of a BMesh by a matrix. Parameters ---------- bm : BMesh M : ndarray An array of shape `(3, 3)` or `(4, 4)`. Returns ------- BMesh The input BMesh `bm` with transformed vertices. Raises ------ ValueError If shape of `M` is not (3, 3) or (4, 4). """ if M.shape == (3, 3): for vert in bm.verts: vert.co = np.dot(M, np.array(vert.co)) elif M.shape == (4, 4): for vert in bm.verts: 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 bm
[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)