"""Base class for all BlenderSynth objects."""
from .utils import (
handle_vec,
SelectObjects,
_euler_add,
animatable_property,
_euler_invert,
)
from mathutils import Vector, Euler, Matrix
import numpy as np
import bpy
from typing import Union
from ..utils import types
from typing import TYPE_CHECKING
# for documentation, import these types
if TYPE_CHECKING:
from .curve import Curve
[docs]
class BsynObject:
"""Generic class for BlenderSynth objects.
Assigned an .obj (eg bpy.types.Mesh for a Mesh) which is the main Blender object it represents.
"""
_object: bpy.types.Object = None # corresponding blender object
@property
def obj(self) -> bpy.types.Object:
if self._object is None:
raise ValueError(
"self._object not set. Ensure it is set in the Object's __init__ function."
)
return self._object
@property
def object(self) -> bpy.types.Object:
return self.obj
@property
def data(self) -> bpy.types.ID:
return self.object.data
[docs]
def remove(self):
"""Delete .obj from bpy.data if it exists"""
try:
bpy.data.objects.remove(self.obj)
except ReferenceError:
pass
def update(self):
# ---> not currently needed, may be needed in the future
# """On any update, run this. For most objects, this is a no-op, but for some objects,
# this is necessary to update the object's state. e.g. Camera"""
return
def _keyframe_delete(self, *args, **kwargs):
self._object.keyframe_delete(*args, **kwargs)
def _keyframe_insert(self, *args, **kwargs):
self._object.keyframe_insert(*args, **kwargs)
@property
def name(self):
return self.obj.name
@property
def _all_objects(self):
"""List of all objects associated with this object."""
return [self.object]
@property
def origin(self) -> Vector:
return self.location
@property
def location(self) -> Vector:
"""Location of object"""
return self.obj.location
@location.setter
def location(self, value):
self.set_location(value)
@property
def rotation_euler(self) -> Euler:
"""Rotation in euler XYZ angles"""
return self.obj.rotation_euler
@rotation_euler.setter
def rotation_euler(self, value):
self.set_rotation_euler(value)
@property
def scale(self):
return self.obj.scale
@scale.setter
def scale(self, scale):
self.set_scale(scale)
@property
def dimensions(self):
return self.obj.dimensions
@dimensions.setter
def dimensions(self, dimensions):
self.set_dimensions(dimensions)
@animatable_property("location")
def set_location(self, location: types.VectorLike):
"""Set location of object.
:param location: Location vector to set"""
location = handle_vec(location, 3)
translation = location - self.location
with SelectObjects(self._all_objects):
bpy.ops.transform.translate(value=translation)
def _apply_rotation(self, rot):
for ax, val in zip("XYZ", rot):
if val != 0:
bpy.ops.transform.rotate(
value=val,
orient_axis=ax,
orient_type="GLOBAL",
constraint_axis=[ax == "X", ax == "Y", ax == "Z"],
)
@animatable_property("rotation_euler")
def set_rotation_euler(self, rotation: types.VectorLike):
"""Set euler rotation of object.
:param rotation: Rotation vector"""
assert (
len(rotation) == 3
), f"Rotation must be a tuple of length 3, got {len(rotation)}"
rotation = Euler(rotation, "XYZ")
# to avoid dealing with rotation calculations, we first rotate to the origin, then rotate to the new rotation
with SelectObjects(self._all_objects):
self._apply_rotation(
_euler_invert(self.rotation_euler)
) # invert current rotation
self._apply_rotation(rotation) # apply new rotation
@animatable_property("scale")
def set_scale(self, scale: types.VectorLikeOrScalar):
"""Set scale of object.
:param scale: Scale to set. Either single value or 3 long vector"""
if isinstance(scale, (int, float)):
scale = (scale, scale, scale)
resize_fac = np.array(scale) / np.array(self.scale)
with SelectObjects(self._all_objects):
bpy.ops.transform.resize(value=resize_fac)
@animatable_property("dimensions")
def set_dimensions(self, dimensions: types.VectorLikeOrScalar):
"""Set dimensions of object.
:param dimensions: Dimensions to set. Either single value or 3 long vector"""
if isinstance(dimensions, (int, float)):
dimensions = (dimensions, dimensions, dimensions)
resize_fac = np.array(dimensions) / np.array(self.dimensions)
with SelectObjects(self._all_objects):
bpy.ops.transform.resize(value=resize_fac)
[docs]
def translate(self, translation):
"""Translate object"""
translation = handle_vec(translation, 3)
self.location = self.location + translation
[docs]
def rotate_by(self, rotation):
"""Add a rotation to the object. Must be in XYZ order, euler angles, radians."""
rotation = handle_vec(rotation, 3)
new_rotation = _euler_add(self.rotation_euler, Euler(rotation, "XYZ"))
self.rotation_euler = new_rotation
[docs]
def scale_by(self, scale):
"""Scale object"""
if isinstance(scale, (int, float)):
scale = (scale, scale, scale)
scale = handle_vec(scale, 3)
self.scale *= scale
@property
def matrix_world(self):
"""Return world matrix of object(s)."""
bpy.context.evaluated_depsgraph_get() # required to update object matrix
return self.object.matrix_world
@property
def axes(self) -> np.ndarray:
"""Return 3x3 rotation matrix (normalized) to represent axes"""
mat = np.array(self.matrix_world)[:3, :3]
mat = mat / np.linalg.norm(mat, axis=0)
return mat
[docs]
def track_to(self, obj: Union["BsynObject", bpy.types.Object]):
"""Track to object.
:param obj: BsynObject or Blender Object to track to
"""
if isinstance(obj, BsynObject):
obj = obj.obj
constraint = self.object.constraints.new("TRACK_TO")
constraint.target = obj
constraint.track_axis = "TRACK_NEGATIVE_Z"
constraint.up_axis = "UP_Y"
[docs]
def untrack(self):
"""Remove track to constraint from object"""
constraint = self.object.constraints.get("Track To")
self.object.constraints.remove(constraint)
[docs]
def follow_path(
self,
path: "Curve",
zero: bool = True,
animate: bool = True,
frames: tuple = (0, 250),
fracs: tuple = (0, 1),
):
"""Follow path, with optional animation setting.
:param path: Curve object
:param zero: If True, set camera location to (0, 0, 0) [aligns camera with path]
:param animate: If True, animate camera along path
:param frames: tuple of keyframes to animate at - length N
:param fracs: tuple of fractions along path to animate at - length N
"""
constraint = self.object.constraints.new("FOLLOW_PATH")
constraint.target = path.path
constraint.forward_axis = "TRACK_NEGATIVE_Z"
constraint.up_axis = "UP_Y"
constraint.use_fixed_location = (
True # ensures that offset factor is in 0-1 range
)
if zero:
self.location = (0, 0, 0)
if animate:
self.animate_path(frames, fracs)
# if there are any track constraints, place this constraint first
# so that the object is not rotated by the track constraint
track_constraint_idx = self.object.constraints.find("Track To")
if track_constraint_idx > -1:
self.object.constraints.move(track_constraint_idx, track_constraint_idx + 1)
[docs]
def animate_path(self, frames: tuple = (0, 250), fracs: tuple = (0, 1)):
"""Animate object along path.
:param frames: tuple of keyframes to animate at - length N
:param fracs: tuple of fractions along path to animate at - length N
"""
assert len(frames) == len(
fracs
), f"frames and fracs must be same length - got {len(frames)} and {len(fracs)}"
for frame, frac in zip(frames, fracs):
self.path_keyframe(frame, frac)
[docs]
def path_keyframe(self, frame: int, offset: float):
"""Set keyframe for camera path offset
:param frame: Frame number
:param offset: Offset fraction (0-1)
"""
constraint = self.object.constraints.get("Follow Path")
if constraint is None:
raise ValueError(
f"Object `{self.__class__}` does not have a 'Follow Path' constraint"
)
constraint.offset_factor = offset
constraint.keyframe_insert(data_path="offset_factor", frame=frame)
@animatable_property("hide_viewport")
def viewport_visibility(self, value: bool):
"""Show/hide object in viewport
:param value: True to show, False to hide
"""
self.object.hide_viewport = not value
@animatable_property("hide_render")
def render_visibility(self, value: bool):
"""Show/hide object in render
:param value: True to show, False to hide
"""
self.object.hide_render = not value