Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2,204 changes: 1,220 additions & 984 deletions poetry.lock

Large diffs are not rendered by default.

3 changes: 2 additions & 1 deletion src/bsr/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,9 @@
)
from .frame import FrameManager
from .geometry.composite.pose import Pose
from .geometry.composite.rod import Rod
from .geometry.composite.rod import Rod, RodWithBox, RodWithCylinder
from .geometry.composite.stack import RodStack, create_rod_collection
from .geometry.primitives.pipe import BezierSplinePipe
from .geometry.primitives.simple import Cylinder, Sphere
from .viewport import find_area, set_view_distance

Expand Down
3 changes: 3 additions & 0 deletions src/bsr/blender_commands/macros.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ def clear_mesh_objects() -> None:
bpy.ops.object.select_all(action="DESELECT")
bpy.ops.object.select_by_type(type="MESH")
bpy.ops.object.delete()
bpy.ops.object.select_all(action="DESELECT")
bpy.ops.object.select_by_type(type="CURVE")
bpy.ops.object.delete()


def scene_update() -> None:
Expand Down
5 changes: 5 additions & 0 deletions src/bsr/frame.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
from typing import Iterable, Optional

import logging

import bpy

from .utilities.singleton import SingletonMeta

logger = logging.getLogger(__name__)


class FrameManager(metaclass=SingletonMeta):
"""
Expand Down Expand Up @@ -103,6 +107,7 @@ def frame_end(self, frame: int) -> None:
isinstance(frame, int) and frame >= 0
), "frame must be a nonnegative integer"
bpy.context.scene.frame_end = frame
logger.info(f"Timeline redefined to {frame} frames.")

@property
def frame_rate(self) -> float:
Expand Down
151 changes: 149 additions & 2 deletions src/bsr/geometry/composite/rod.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
__doc__ = """
Rod class for creating and updating rods in Blender
"""
__all__ = ["RodWithSphereAndCylinder", "Rod"]
__all__ = ["RodWithSphereAndCylinder", "Rod", "RodWithCylinder", "RodWithBox"]

from typing import TYPE_CHECKING, Any

Expand All @@ -11,7 +11,7 @@
import numpy as np
from numpy.typing import NDArray

from bsr.geometry.primitives.simple import Cylinder, Sphere
from bsr.geometry.primitives.simple import Box, Cylinder, Sphere
from bsr.geometry.protocol import CompositeProtocol
from bsr.tools.keyframe_mixin import KeyFrameControlMixin

Expand Down Expand Up @@ -173,6 +173,153 @@ def update_keyframe(self, keyframe: int) -> None:
cylinder.update_keyframe(keyframe)


class RodWithCylinder(RodWithSphereAndCylinder):
"""
Rod class for managing visualization and rendering in Blender

This class only creates cylinder objects

Parameters
----------
positions : NDArray
The positions of the sphere objects. Expected shape is (n_dim, n_nodes).
n_dim = 3
radii : NDArray
The radii of the sphere objects. Expected shape is (n_nodes-1,).

"""

input_states = {"positions", "radii"}

def __init__(self, positions: NDArray, radii: NDArray) -> None:
# create cylinder objects
self.cylinders: list[Cylinder] = []
self._bpy_objs: dict[str, list[bpy.types.Object]] = {
"cylinder": self.cylinders,
}

self._build(positions, radii)

def _build(self, positions: NDArray, radii: NDArray) -> None:
for j in range(radii.shape[-1]):
cylinder = Cylinder(
positions[:, j],
positions[:, j + 1],
radii[j],
)
self.cylinders.append(cylinder)

def update_states(self, positions: NDArray, radii: NDArray) -> None:
"""
Update the states of the rod object

Parameters
----------
positions : NDArray
The positions of the sphere objects. Expected shape is (n_nodes, 3).
radii : NDArray
The radii of the sphere objects. Expected shape is (n_nodes-1,).
"""
# check shape of positions and radii
assert positions.ndim == 2, "positions must be 2D array"
assert positions.shape[0] == 3, "positions must have 3 rows"
assert radii.ndim == 1, "radii must be 1D array"
assert (
positions.shape[-1] == radii.shape[-1] + 1
), "radii must have n_nodes-1 elements"

for idx, cylinder in enumerate(self.cylinders):
cylinder.update_states(
positions[:, idx], positions[:, idx + 1], radii[idx]
)

def set_keyframe(self, keyframe: int) -> None:
"""
Set keyframe for the rod object
"""
for idx, cylinder in enumerate(self.cylinders):
cylinder.set_keyframe(keyframe)


class RodWithBox(RodWithSphereAndCylinder):
"""
Rod class for managing visualization and rendering in Blender

This class creates sphere objects to represent position and cube to represent director

Parameters
----------
positions : NDArray
The positions of the sphere objects. Expected shape is (n_dim, n_nodes).
n_dim = 3
radii : NDArray
The radii of the sphere objects. Expected shape is (n_nodes-1,).

"""

input_states = {"positions", "radii", "directors"}

def __init__(
self, positions: NDArray, radii: NDArray, directors: NDArray
) -> None:
# create cylinder objects
self.boxes: list[Box] = []
self._bpy_objs: dict[str, list[bpy.types.Object]] = {
"box": self.boxes,
}

self._build(positions, radii, directors)

def _build(
self, positions: NDArray, radii: NDArray, directors: NDArray
) -> None:
n_elems = directors.shape[-1]
for j in range(n_elems):
box = Box(
positions[:, j],
positions[:, j + 1],
radii[j],
directors[..., j],
)
self.boxes.append(box)

def update_states(
self, positions: NDArray, radii: NDArray, directors: NDArray
) -> None:
"""
Update the states of the rod object

Parameters
----------
positions : NDArray
The positions of the sphere objects. Expected shape is (n_nodes, 3).
radii : NDArray
The radii of the sphere objects. Expected shape is (n_nodes-1,).
"""
# check shape of positions and radii
assert positions.ndim == 2, "positions must be 2D array"
assert positions.shape[0] == 3, "positions must have 3 rows"
assert radii.ndim == 1, "radii must be 1D array"
assert (
positions.shape[-1] == radii.shape[-1] + 1
), "radii must have n_nodes-1 elements"

for idx, box in enumerate(self.boxes):
box.update_states(
positions[:, idx],
positions[:, idx + 1],
radii[idx],
directors[..., idx],
)

def set_keyframe(self, keyframe: int) -> None:
"""
Set keyframe for the rod object
"""
for idx, box in enumerate(self.boxes):
box.set_keyframe(keyframe)


# Alias
Rod = RodWithSphereAndCylinder

Expand Down
167 changes: 167 additions & 0 deletions src/bsr/geometry/primitives/pipe.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
# TODO: documentation
__doc__ = """
"""
__all__ = ["BezierSplinePipe"]

from typing import TYPE_CHECKING, cast

import warnings
from numbers import Number

import bpy
import numpy as np
from numpy.typing import NDArray

from bsr.geometry.protocol import BlenderMeshInterfaceProtocol, SplineDataType
from bsr.tools.keyframe_mixin import KeyFrameControlMixin

from .utils import _validate_position, _validate_radii


class BezierSplinePipe(KeyFrameControlMixin):
"""
TODO: Documentation

Parameters
----------
positions : NDArray
The position of the spline object. (3, n)
radii : float
The radius of the spline object. (3, n)

"""

input_states = {"positions", "radii"}
name = "bspline"

def __init__(self, positions: NDArray, radii: NDArray) -> None:
"""
Spline constructor
"""

self._obj = self._create_bezier_spline(radii.size)
self._obj.name = self.name
self.update_states(positions, radii)

@classmethod
def create(cls, states: SplineDataType) -> "BezierSplinePipe":
"""
Basic factory method to create a new spline object.
"""

# TODO: Refactor this part: never copy-paste code. Make separate function in utils.py
remaining_keys = set(states.keys()) - cls.input_states
if len(remaining_keys) > 0:
warnings.warn(
f"{list(remaining_keys)} are not used as a part of the state definition."
)
return cls(states["positions"], states["radii"])

@property
def object(self) -> bpy.types.Object:
"""
Access the Blender object.
"""

return self._obj

def update_states(
self, positions: NDArray | None = None, radii: float | None = None
) -> None:
"""
Updates the position and radius of the spline object.

Parameters
----------
positioni : NDArray
The new position of the spline object.
radii : float
The new radius of the spline object.

Raises
------
ValueError
If the shape of the position or radius is incorrect, or if the data is NaN.
"""

spline = self.object.splines[0]
if positions is not None:
_validate_position(positions)
for i, point in enumerate(spline.bezier_points):
x, y, z = positions[:, i]
point.co = (x, y, z)
if radii is not None:
_validate_radii(radii)
for i, point in enumerate(spline.bezier_points):
point.radius = radii[i]

def _create_bezier_spline(self, number_of_points: int) -> bpy.types.Object:
"""
Creates a new pipe object.

Parameters
----------
number_of_points : int
The number of points in the pipe.
"""
# Create a new curve
curve_data = bpy.data.curves.new(name="spline_curve", type="CURVE")
curve_data.dimensions = "3D"

spline = curve_data.splines.new(type="BEZIER")
spline.bezier_points.add(
number_of_points - 1
) # First point is already there

# Set the spline points and radii
for i in range(number_of_points):
point = spline.bezier_points[i]
point.handle_left_type = point.handle_right_type = "AUTO"

# Create a new object with the curve data
curve_object = bpy.data.objects.new("spline_curve_object", curve_data)
curve_object.data.resolution_u = 1
bpy.context.collection.objects.link(curve_object)

# Create a bevel object for the pipe profile
bpy.ops.curve.primitive_bezier_circle_add(
radius=1,
enter_editmode=False,
align="WORLD",
location=(0, 0, 0),
scale=(1, 1, 1),
)
bevel_circle = bpy.context.object
bevel_circle.name = "bevel_circle"
# Hide the bevel circle object in the viewport and render
bevel_circle.hide_viewport = True
bevel_circle.hide_render = True

# Set the bevel object to the curve
curve_data.bevel_mode = "OBJECT"
curve_data.bevel_object = bevel_circle
curve_data.use_fill_caps = True

return curve_data

def update_keyframe(self, keyframe: int) -> None:
"""
Sets a keyframe at the given frame.

Parameters
----------
keyframe : int
"""
spline = self.object.splines[0]
for i, point in enumerate(spline.bezier_points):
point.keyframe_insert(data_path="co", frame=keyframe)
point.keyframe_insert(data_path="radius", frame=keyframe)


if TYPE_CHECKING:
# This is required for explicit type-checking
data = {
"positions": np.array([[0, 0, 0], [1, 1, 1]]).T,
"radii": np.array([1.0, 1.0]),
}
_: BlenderMeshInterfaceProtocol = BezierSplinePipe.create(data)
Loading
Loading