from typing import Optional, Union, Dict
import numpy as np
from collections import defaultdict
import bpy
import bmesh
from bpy.types import Modifier, ClothModifier, CollisionModifier
from mathutils import Vector
from goo.force import *
from goo.utils import *
from goo.molecule import Molecule
[docs]
class Cell(BlenderObject):
"""A cell.
Cells are represented in Blender by mesh objects. They can interact with
physics by adding Blender modifiers, and the forces that influence its
motion is determined by an associated collection.
Args:
obj: Blender object to be used as the representation of the cell.
Attributes:
celltype (CellType): The cell type to which the cell belongs.
"""
def __init__(self, obj: bpy.types.Object, mat=None):
super(Cell, self).__init__(obj)
# Set up effector collections
self._effectors = bpy.data.collections.new(f"{obj.name}_effectors")
bpy.context.scene.collection.children.link(self._effectors)
# self.add_effector(ForceCollection.global_forces()) # link global forces
self._mat = mat
self._color = mat.diffuse_color[:3] if mat else None
self.obj.data.materials.append(mat)
self.celltype: CellType = None
self._physics_enabled = False
self.mod_settings = []
self._homo_adhesion: AdhesionForce = None
self._hetero_adhesions: list[AdhesionForce] = []
self._motion_force: MotionForce = None
# concentrations of molecules in interstitial space
self._molecules_conc: Dict[str, float] = {}
@property
def name(self) -> str:
"""Name of the cell. Also defines the name of related forces and
collections of effectors.
"""
return self.obj.name
@name.setter
def name(self, name: str):
old_name = self.obj.name
self.obj.name = name
self.obj.data.name = f"{name}_mesh"
self._effectors.name = f"{name}_effectors"
if self._mat:
self._mat.name = f"{name}_material"
for force in self._hetero_adhesions:
force.name = force.name.replace(old_name, name, 1)
if self.motion_force:
self.motion_force.name = self.motion_force.name.replace(old_name, name, 1)
[docs]
def copy(self) -> "Cell":
"""Copies the cell.
The underlying Blender object, object data, and material if applicable.
Warning:
Any settings that use custom collections will not be updated. It is
advised that physics modifiers are set up again for the daughter
cell after calling `copy()`.
Returns:
A new cell with copied object and mesh data.
"""
# Set up object, mesh, and material for copy
obj_copy = self.obj.copy()
obj_copy.data = self.obj.data.copy()
bpy.context.scene.collection.objects.link(obj_copy)
if self._mat is not None:
obj_copy.data.materials.clear()
mat_copy = self._mat.copy()
else:
mat_copy = None
cell_copy = Cell(obj_copy, mat_copy)
cell_copy._physics_enabled = self.physics_enabled
cell_copy._update_cloth()
return cell_copy
# ----- CUSTOM PROPERTIES -----
def __setitem__(self, k: str, v: Union[float, list[float], int, list[int], str]):
self.obj.data[k] = v
def __contains__(self, k: str):
return k in self.obj.data.keys()
def __getitem__(self, k):
return self.obj.data[k]
# ----- BASIC FUNCTIONS -----
@property
def obj_eval(self) -> bpy.types.ID:
"""The evaluated object.
Note:
See the `Blender API Documentation for evaluated_get(depsgraph)
<https://docs.blender.org/api/current/bpy.types.ID.html?highlight=evaluated_get#bpy.types.ID.evaluated_get>`__.
"""
dg = bpy.context.evaluated_depsgraph_get()
obj_eval = self.obj.evaluated_get(dg)
return obj_eval
[docs]
def vertices(self, local_coords: bool = False) -> list[Vector]:
"""Returns the vertices of the mesh representation of the cell.
Args:
local_coords: if `True`, coordinates are returned in local object space
rather than world space.
Returns:
List of coordinates of vertices.
"""
verts = self.obj_eval.data.vertices
if local_coords:
return [v.co.copy() for v in verts]
else:
matrix_world = self.obj_eval.matrix_world
return [matrix_world @ v.co for v in verts]
[docs]
def volume(self) -> float:
"""Calculates the volume of the cell.
Returns:
The signed volume of the cell (with physics evaluated).
"""
bm = bmesh.new()
bm.from_mesh(self.obj_eval.to_mesh())
bm.transform(self.obj_eval.matrix_world)
volume = bm.calc_volume()
bm.free()
return volume
[docs]
def COM(self, local_coords: bool = False) -> Vector:
"""Calculates the center of mass of a cell.
Args:
local_coords: if `True`, coordinates are returned in local object
space rather than world space.
Returns:
The vector representing the center of mass of the cell.
"""
vert_coords = self.vertices(local_coords)
com = Vector(np.mean(vert_coords, axis=0))
return com
def _get_eigenvector(self, n: int) -> Axis:
"""Returns the nth eigenvector (axis) in object space as a line defined
by two Vectors.
This function calculates the nth eigenvector of the covariance matrix
defined by the cell's vertices. The eigenvector axis is defined by two
points: the vertices in the direction of the eigenvector with the
smallest and largest projections.
Args:
n: The index of the eigenvector to return.
Returns:
An axis defined by the eigenvector and the vertices at the
extreme projections along this vector.
"""
verts = self.vertices()
# Calculate the eigenvectors and eigenvalues of the covariance matrix
covariance_matrix = np.cov(verts, rowvar=False)
eigenvalues, eigenvectors = np.linalg.eigh(covariance_matrix)
eigenvectors = eigenvectors[:, eigenvalues.argsort()[::-1]]
axis = eigenvectors[:, n]
projections = verts @ axis
min_index = np.argmin(projections)
max_index = np.argmax(projections)
first_vertex = Vector(verts[min_index])
last_vertex = Vector(verts[max_index])
return Axis(Vector(axis), first_vertex, last_vertex, self.obj_eval.matrix_world)
[docs]
def major_axis(self) -> Axis:
"""Returns the major axis of the cell."""
return self._get_eigenvector(0)
[docs]
def minor_axis(self) -> Axis:
"""Returns the minor axis of the cell."""
return self._get_eigenvector(1)
[docs]
def get_radius(self):
"""Calculate the radius based on the major and minor axes of the cell."""
major_axis = self.major_axis()
length_major = np.linalg.norm(major_axis._start - major_axis._end)
minor_axis = self.minor_axis()
length_minor = np.linalg.norm(minor_axis._start - minor_axis._end)
return (length_major + length_minor) / 2
[docs]
def divide(self, division_logic) -> tuple["Cell", "Cell"]:
"""Cause the cell to divide into two daughter cells.
This function causes the cell to divide into two daughter cells according
to the provided division logic. The daughter cells inherit the cell type
of the mother cell.
Args:
division_logic: The division logic to use, which handles the
creation of two cells from the original cell.
Returns:
A tuple of two daughter cells, resulting from the division of the
mother cell.
"""
# TODO: rewrite code to make it clearer that there are two daughter
# cells splitting from a mother cell.
mother, daughter = division_logic.make_divide(self)
if mother.celltype:
mother.celltype.add_cell(daughter)
return mother, daughter
[docs]
def recenter(self):
"""Recenter the cell origin to the center of mass of the cell."""
bm = bmesh.new()
bm.from_mesh(self.obj_eval.to_mesh())
com = self.COM()
bmesh.ops.translate(bm, verts=bm.verts, vec=-self.COM(local_coords=True))
bm.to_mesh(self.obj.data)
bm.free()
self.loc = com
[docs]
def remesh(self, voxel_size: float = 0.55, smooth: bool = False):
"""Remesh the underlying mesh representation of the cell.
Remeshing is done using the built-in `voxel_remesh()`.
Args:
voxel_size: The resolution used for the remesher (smaller means more
polygons). smooth: If true, the final cell faces will appear
smooth.
"""
# use of object ops is 2x faster than remeshing with modifiers
self.obj.data.remesh_mode = "VOXEL"
self.obj.data.remesh_voxel_size = voxel_size
with bpy.context.temp_override(active_object=self.obj, object=self.obj):
bpy.ops.object.voxel_remesh()
for f in self.obj.data.polygons:
f.use_smooth = smooth
[docs]
def recolor(self, color: tuple[float, float, float]):
"""Recolors the material of the cell.
This function changes the diffuse color of the cell's material to the
specified color while preserving the alpha value. If the material uses
nodes, it also updates the 'Base Color' input of any nodes that have it.
Args:
color: A tuple (r, g, b) representing the new color to apply.
"""
r, g, b = color
_, _, _, a = self._mat.diffuse_color
self._mat.diffuse_color = (r, g, b, a)
self.celltype.color = color
self.color = color
if self._mat.use_nodes:
for node in self._mat.node_tree.nodes:
if "Base Color" in node.inputs:
_, _, _, a = node.inputs["Base Color"].default_value
node.inputs["Base Color"].default_value = r, g, b, a
[docs]
def calculate_dist_to_voxel(self, voxel_loc: Vector) -> float:
"""Calculate the distance from a cell to a voxel.
Args:
voxel_loc: The location of the voxel.
"""
com = self.COM()
return (com - voxel_loc).length
# ----- PHYSICS -----
[docs]
def get_modifier(self, type) -> Optional[Modifier]:
"""Retrieves the first modifier of the specified type from the
underlying object representation of the cell.
Args:
type: The type of the modifier to search for.
Returns:
The first modifier of the specified type if found, otherwise None.
"""
return next((m for m in self.obj.modifiers if m.type == type), None)
@property
def color(self) -> tuple[float, float, float]:
"""Color of the cell."""
return self._color
@color.setter
def color(self, color: tuple[float, float, float]):
self._color = color
@property
def cloth_mod(self) -> Optional[ClothModifier]:
"""The cloth modifier of the cell if it exists, otherwise None."""
return self.get_modifier("CLOTH")
@property
def collision_mod(self) -> Optional[CollisionModifier]:
"""The collision modifier of the cell if it exists, otherwise None."""
return self.get_modifier("COLLISION")
@property
def physics_enabled(self) -> bool:
"""Whether physics is enabled for this cell."""
return self._physics_enabled
def _update_cloth(self):
"""Update the cloth modifier is correctly set to be affected by forces
acting upon the cell.
"""
if self.cloth_mod:
self.cloth_mod.settings.effector_weights.collection = self._effectors
[docs]
def setup_physics(self, physics_constructor: PhysicsConstructor):
"""Set up the physics properties for the cell.
This function initializes the physics properties of the cell using the given
physics constructor. It then marks the physics as enabled.
Args:
physics_constructor: A function or callable that sets up the physics
properties for the object.
"""
physics_constructor(self.obj)
self._update_cloth()
self._physics_enabled = True
[docs]
def enable_physics(self):
"""Enable the physics simulation for the cell.
This function re-enables the physics simulation for the cell by recreating
the modifier stack from stored settings, updating the cloth modifier,
and enabling any adhesion forces.
Raises:
RuntimeError: If physics is already enabled.
"""
if self._physics_enabled:
raise RuntimeError(
f"{self.name}: physics must be disabled before enabling."
)
# recreate modifier stack
for name, type, settings in self.mod_settings:
mod = self.obj.modifiers.new(name=name, type=type)
declare_settings(mod, settings)
self.mod_settings.clear()
# ensure cloth mod is set correctly
self._update_cloth()
for force in self.adhesion_forces:
force.enable()
self._physics_enabled = True
[docs]
def disable_physics(self):
"""
Disable the physics simulation for the cell.
This function disables the physics simulation for the cell by storing the
current modifier settings, removing all modifiers, and disabling any adhesion
forces.
Raises:
RuntimeError: If physics is not enabled.
"""
if not self._physics_enabled:
raise RuntimeError(
f"{self.name}: physics must be set up and/or enabled before disabling."
)
for mod in self.obj.modifiers:
name, type = mod.name, mod.type
settings = store_settings(mod)
self.mod_settings.append((name, type, settings))
self.obj.modifiers.remove(mod)
for force in self.adhesion_forces:
force.disable()
self._physics_enabled = False
@property
def stiffness(self) -> float:
"""Stiffness of the membrane of the cell."""
return self.cloth_mod.settings.tension_stiffness
@stiffness.setter
def stiffness(self, stiffness: float):
self.cloth_mod.settings.tension_stiffness = stiffness
self.cloth_mod.settings.compression_stiffness = stiffness
self.cloth_mod.settings.shear_stiffness = stiffness
@property
def pressure(self) -> float:
"""Internal pressure of the cell."""
return self.cloth_mod.settings.uniform_pressure_force
@pressure.setter
def pressure(self, pressure: float):
self.cloth_mod.settings.uniform_pressure_force = pressure
# ----- FORCES -----
[docs]
def add_effector(self, force: Force | ForceCollection):
"""Add a force or a collection of forces that affects this cell.
Args:
force: The force or collection of forces to add.
"""
if isinstance(force, Force):
self._effectors.objects.link(force.obj)
elif isinstance(force, ForceCollection):
self._effectors.children.link(force.collection)
[docs]
def remove_effector(self, force: Force | ForceCollection):
"""Remove a force or a collection of forces that affects this cell.
Args:
force: The force or collection of forces to remove.
"""
if isinstance(force, Force):
self._effectors.objects.unlink(force.obj)
elif isinstance(force, ForceCollection):
self._effectors.children.unlink(force.collection)
[docs]
def link_adhesion_force(self, force: AdhesionForce):
"""Set this cell as the origin of an adhesion force.
Args:
force: The adhesion force which originates from this cell.
"""
self._hetero_adhesions.append(force)
@property
def homo_adhesion(self) -> AdhesionForce:
"""Homotypic adhesion force of the cell."""
return self._homo_adhesion
@homo_adhesion.setter
def homo_adhesion(self, force: AdhesionForce):
self._homo_adhesion = force
@property
def adhesion_forces(self) -> list[AdhesionForce]:
"""Heterotypic adhesion forces of the cell."""
return [self._homo_adhesion] + self._hetero_adhesions
@property
def motion_force(self) -> Force:
"""Motion force of the cell."""
return self._motion_force
@motion_force.setter
def motion_force(self, force: MotionForce):
if self.motion_force:
self.remove_effector(self.motion_force)
self.add_effector(force)
self._motion_force = force
[docs]
def move_towards(self, dir: Vector):
"""Sets the motion force to move the cell in a specified direction.
This function sets the motion force to move the cell in the specified
direction. If the cell does not have an associated motion force, it raises
a RuntimeError.
Args:
dir (Vector): The direction in which to set the motion force.
Raises:
RuntimeError: If the cell does not have an associated motion force.
"""
if not self._motion_force:
raise RuntimeError(
f"Cell {self.name} does not have an associated motion force!"
)
motion_loc = self.loc + dir.normalized() * (2 + self.major_axis().length())
self._motion_force.set_loc(motion_loc, self.loc)
# ----- MOLECULES -----
@property
def molecules_conc(self):
return self._molecules_conc
@molecules_conc.setter
def molecules_conc(self, conc: defaultdict[float]):
self._molecules_conc.update(conc)
[docs]
def create_cell(
name: str,
loc: tuple,
color: tuple = None,
physics_constructor: PhysicsConstructor = None,
physics_on: bool = True,
**kwargs,
) -> Cell:
"""Creates a new cell using the default cell type.
Args:
name: The name of the cell.
loc: The location of the cell.
color: The color of the cell.
physics_constructor: A generator of physics properties of the cell.
physics_on: Whether to enable physics for the cell.
**kwargs: keyword arguments passed to the Blender mesh generator function.
Returns:
The newly created cell.
"""
cell = CellType.default_celltype().create_cell(
name, loc, color, physics_constructor, physics_on, **kwargs
)
return cell
[docs]
def store_settings(mod: bpy.types.bpy_struct) -> dict:
"""Store the settings of a Blender modifier in a dictionary.
Args:
mod: The Blender modifier.
Returns:
A dictionary with the stored settings.
"""
settings = {}
for p in mod.bl_rna.properties:
id = p.identifier
if not p.is_readonly:
settings[id] = getattr(mod, id)
elif id in ["settings", "collision_settings", "effector_weights"]:
settings[id] = store_settings(getattr(mod, id))
return settings
[docs]
def declare_settings(mod: bpy.types.bpy_struct, settings: dict):
"""Recursively apply stored settings to a Blender modifier.
Args:
mod: The Blender modifier to which the settings are applied.
settings: A dictionary containing the settings.
"""
for id, setting in settings.items():
if isinstance(setting, dict):
declare_settings(getattr(mod, id), settings[id])
else:
setattr(mod, id, setting)
[docs]
class CellType:
"""A cell type.
Cell types are represented in Blender by collection. Cells of the same cell
type interact through homotypic adhesion forces, and interact with cells of
different cell types through heterotypic adhesion forces. Cell types have
default mesh creation settings (including color) and default physics
settings (including cloth, collision, homotypic adhesion forces, and motion
forces).
Args:
name: The name of the cell type.
physics_enabled: Whether cells of this cell type are responsive to physics.
Attributes:
homo_adhesion_strength (int): Default homotypic adhesion strength
between cells of this type.
motion_strength (int): Default motion strength of
cells of this type.
"""
_mesh_kwargs = {}
_physics_constructor = PhysicsConstructor(
SubsurfConstructor,
ClothConstructor,
CollisionConstructor,
# RemeshConstructor,
)
color = (0.007, 0.021, 0.3)
_default_celltype = None
def __init__(self, name: str, physics_enabled: bool = True):
self._homo_adhesions = ForceCollection(name)
self._cells = set()
self._physics_enabled = physics_enabled
self._hetero_adhesions = {}
self.homo_adhesion_strength: int = 2000
self.motion_strength: int = 0
[docs]
@staticmethod
def default_celltype() -> "CellType":
"""Get the default cell type."""
if CellType._default_celltype is None:
CellType._default_celltype = CellType("default")
return CellType._default_celltype
@property
def name(self) -> str:
"""Name of the cell type."""
return self._homo_adhesions.name
[docs]
def add_cell(self, cell: Cell):
"""Add a cell to the cell type, activating its physics and constructing
appropriate forces.
This method sets the cell type of the added cell to this cell type and
activates physics for the cell. It constructs and adds homotypic
adhesion forces, heterotypic adhesion forces, and motion forces defined
for this cell type.
Args:
cell: The cell to add to the cell type.
"""
cell.celltype = self
self._cells.add(cell)
if not self._physics_enabled:
return
# add homotypic adhesion force
homo_adhesion = create_adhesion(self.homo_adhesion_strength, obj=cell.obj)
cell.homo_adhesion = homo_adhesion
self._homo_adhesions.add_force(homo_adhesion)
cell.add_effector(self._homo_adhesions)
# add heterotypic adhesion forces
for celltype, item in self._hetero_adhesions.items():
outgoing_forces, incoming_forces, strength = item
hetero_adhesion = create_adhesion(
strength, name=f"{cell.name}_to_{celltype.name}", loc=cell.loc
)
cell.add_effector(incoming_forces)
outgoing_forces.add_force(hetero_adhesion)
cell.link_adhesion_force(hetero_adhesion)
# add motion force
motion = create_motion(
name=f"{cell.name}_motion",
loc=(0, 0, 0),
strength=self.motion_strength,
)
cell.motion_force = motion
motion.hide()
# TODO: implement correctly
def _remove_cell(self, cell: Cell):
pass
# cell.celltype = None
# self._cells.remove(cell)
[docs]
def create_cell(
self,
name: str,
loc: tuple,
color: tuple = None,
physics_constructor: PhysicsConstructor = None,
physics_on: bool = True,
**mesh_kwargs,
) -> Cell:
"""Creates a new cell, then adds it to this cell type.
Args:
name: The name of the cell.
loc: The location of the cell.
color: The color of the cell.
physics_constructor: A generator of physics properties of the cell.
physics_on: Whether to enable physics for the cell.
**mesh_kwargs: keyword arguments passed to the Blender mesh
generator function.
Returns:
The newly created cell.
Note:
`create_cell` does not directly activate physics, which is handled
by the `add_cell` function. This is significant for division, which
will use the default `CellType` settings to set up cell physics,
rather than any custom settings of the dividing cell.
"""
mesh_kwargs = dict(self.__class__._mesh_kwargs, **mesh_kwargs)
if color is None:
color = self.__class__.color
obj = create_mesh(name, loc, mesh="icosphere", **mesh_kwargs)
bpy.context.scene.collection.objects.link(obj)
mat = create_material(f"{name}_material", color=color) if color else None
cell = Cell(obj, mat)
cell.color = color
cell.remesh()
# enable physics for cell
if physics_constructor is None:
physics_constructor = self.__class__._physics_constructor
if self._physics_enabled and physics_on:
cell.setup_physics(physics_constructor)
self.add_cell(cell)
return cell
# TODO: create cells in shape or in specified locations of specified cell types
[docs]
def create_cells(celltype, locs=None, shape=None, size=None):
pass
@property
def cells(self) -> list[Cell]:
"""The list of cells associated with this cell type."""
return list(self._cells)
[docs]
def set_hetero_adhesion(self, other_celltype: "CellType", strength: float):
"""Set the default strength of heterotypic adhesion forces between this
cell type and another.
Args:
other_celltype: The other cell type in relation to which heterotypic
adhesion forces will be defined.
strength: The force of the adhesion forces.
"""
outgoing_forces = ForceCollection(f"{self.name}_to_{other_celltype.name}")
incoming_forces = ForceCollection(f"{other_celltype.name}_to_{self.name}")
self._hetero_adhesions[other_celltype] = (
outgoing_forces,
incoming_forces,
strength,
)
other_celltype._hetero_adhesions[self] = (
incoming_forces,
outgoing_forces,
strength,
)
[docs]
class SimpleType(CellType):
"""A cell type that is reduced in complexity for faster rendering."""
color = None
physics_constructor = PhysicsConstructor(
ClothConstructor,
CollisionConstructor,
)
[docs]
class YolkType(CellType):
"""A larger cell type used for modeling embryonic yolks."""
mesh_kwargs = {
"size": 10,
"subdivisions": 4,
}
physics_constructor = PhysicsConstructor(
YolkClothConstructor,
CollisionConstructor,
)
color = (0.64, 0.64, 0.64)