"""Scene class for adding, removing, and slicing actors and brain regions in brainrender."""
import sys
from pathlib import Path
from typing import Any
import pyinspect as pi
from loguru import logger
from myterial import amber, orange, orange_darker, salmon
from rich import print
from vedo import Assembly, Mesh, Plane, Plotter, Text2D
from brainrender import settings
from brainrender._io import load_mesh_from_file
from brainrender._jupyter import JupyterMixIn, not_on_jupyter
from brainrender._utils import listify, return_list_smart
from brainrender.actor import Actor
from brainrender.actors import Volume
from brainrender.atlas import Atlas
from brainrender.render import Render
[docs]
class Scene(JupyterMixIn, Render):
"""
Main scene in brainrender.
Coordinates the actors and the overall appearance.
"""
def __init__(
self,
root: bool = True,
atlas_name: str | None = None,
check_latest: bool = True,
inset: bool = True,
title: str | None = None,
screenshots_folder: str | Path | None = None,
plotter: Plotter | None = None,
title_color: str = "k",
) -> None:
"""
Parameters
----------
root
If true the brain root mesh is added.
atlas_name
Name of the brainglobe atlas to be used.
check_latest
If True checks that the atlas is the latest version.
inset
If true an inset is shown with the brain's outline.
title
If given, a title is added to the top of the window.
screenshots_folder
Where the screenshots will be saved. Defaults to cwd.
plotter
Existing vedo Plotter to use. A new one is created if None.
title_color
Colour of the title text.
"""
logger.debug(
f"Creating scene with parameters: root: {root}, atlas_name: '{atlas_name}'', inset: {inset}, screenshots_folder: {screenshots_folder}"
)
JupyterMixIn.__init__(self)
self.actors = [] # stores all actors in the scene
self.labels = [] # stores all `labels` actors in scene
self.atlas = Atlas(atlas_name=atlas_name, check_latest=check_latest)
self.screenshots_folder = (
Path(screenshots_folder)
if screenshots_folder is not None
else Path().cwd()
)
self.screenshots_folder.mkdir(exist_ok=True)
# Initialise render class
Render.__init__(self, plotter)
root_name = self.atlas.structures[self.atlas.hierarchy.root]["acronym"]
# Get root mesh
self.root = self.add_brain_region(
root_name,
alpha=settings.ROOT_ALPHA,
color=settings.ROOT_COLOR,
silhouette=bool(root and settings.SHADER_STYLE == "cartoon"),
)
self.atlas.root = self.root # give atlas access to root
self._root_mesh = self.root.mesh.clone()
if not root:
self.remove(self.root)
# keep track if we need to make an inset
self.inset = inset
# add title
if title:
self.add(
Text2D(title, pos="top-center", s=2.5, c=title_color, alpha=1),
names="title",
classes="title",
)
def __str__(self) -> str:
return f"A `brainrender.scene.Scene` with {len(self.actors)} actors."
def __repr__(self) -> str: # pragma: no cover
return f"A `brainrender.scene.Scene` with {len(self.actors)} actors."
def __repr_html__(self) -> str: # pragma: no cover
return f"A `brainrender.scene.Scene` with {len(self.actors)} actors."
def __del__(self) -> None:
self.close()
@not_on_jupyter
def _get_inset(self) -> None:
"""
Create a small inset showing the brain's orientation.
"""
if settings.OFFSCREEN:
return
inset = self._root_mesh.clone()
inset.alpha(1) # scale(0.5)
self.plotter.add_inset(inset, pos=(0.95, 0.1), draggable=False)
if settings.SHADER_STYLE == "cartoon":
inset.lighting("off")
[docs]
def add(
self,
*items: Mesh | Assembly | Text2D | Actor | str | Path,
names: str | list[str | None] | None = None,
classes: str | list[str | None] | None = None,
transform: bool = True,
**kwargs: Any,
) -> Actor | list[Actor]:
"""
General method to add Actors to the scene.
Whatever the input, it's turned into an instance of Actor before
being added to the scene.
Parameters
----------
*items
vedo.Mesh, Actor, or (str, Path). If str/path it should be a
path to a .obj or .stl file.
names
Names to be assigned to the Actors.
classes
br_classes to be assigned to the Actors.
transform
If True, apply the axes-orientation transform to new actors.
**kwargs
Parameters to be passed to the individual loading functions
(e.g. to load from file and specify the color).
Returns
-------
Actor or list of Actor
The actor(s) added to the scene.
"""
names = names or [None for a in items]
classes = classes or [None for a in items]
# turn items into Actors
actors = []
for item, name, _class in zip(items, listify(names), listify(classes)):
if item is None:
continue
if isinstance(item, (Mesh, Assembly)):
actors.append(Actor(item, name=name, br_class=_class))
elif isinstance(item, Text2D):
# Mark text actors differently because they don't behave like
# other 3d actors
actors.append(
Actor(
item,
name=name,
br_class=_class,
is_text=True,
**kwargs,
)
)
elif pi.utils._class_name(item) == "Volume" and not isinstance(
item, Volume
):
actors.append(
Volume(item, name=name, br_class=_class, **kwargs)
)
elif isinstance(item, Actor):
actors.append(item)
elif isinstance(item, (str, Path)):
mesh = load_mesh_from_file(item, **kwargs)
name = name or Path(item).name
_class = _class or "from file"
actors.append(Actor(mesh, name=name, br_class=_class))
else:
raise ValueError(
f"Unrecognized argument: {item} [{pi.utils._class_name(item)}]"
)
# transform actors
if transform:
for actor in actors:
self._prepare_actor(actor)
# add actors to plotter
for actor in actors:
try:
self.plotter.add(actor._mesh)
except AttributeError: # e.g. for titles
self.plotter.add(actor.mesh)
# Add to the lists actors
self.actors.extend(actors)
return return_list_smart(actors)
[docs]
def remove(self, *actors: Actor) -> None:
"""
Remove actors from the scene.
Parameters
----------
*actors
Actors to remove.
"""
logger.debug(f"Removing {len(actors)} actors from scene")
for act in actors:
try:
self.actors.pop(self.actors.index(act))
except Exception:
print(
f"Could not remove ({act}, {pi.utils._class_name(act)}) from actors"
)
else:
# remove from plotter
try:
self.plotter.remove(act._mesh)
except AttributeError:
pass
if act.silhouette is not None:
self.plotter.remove(act.silhouette.mesh)
for label in act.labels:
self.plotter.remove(label.mesh)
[docs]
def get_actors(
self,
name: str | int | list[str | int] | None = None,
br_class: str | list[str] | None = None,
) -> list[Actor]:
"""
Return the scene's actors that match some search criteria.
Parameters
----------
name
Actor name(s) to match.
br_class
Actor br_class(es) to match.
Returns
-------
list of Actor
The actors in the scene that match the specified search criteria.
"""
matches = self.actors
if name is not None:
name = listify(name)
matches = [m for m in matches if m.name in name]
if br_class is not None:
br_class = listify(br_class)
matches = [m for m in matches if m.br_class in br_class]
return matches
[docs]
def add_brain_region(
self,
*regions: str | int,
alpha: float = 1,
color: str | None = None,
silhouette: bool | None = None,
hemisphere: str = "both",
force: bool = False,
) -> Actor | list[Actor] | None:
"""
Dedicated method to add brain regions to render.
Parameters
----------
*regions
Region names or IDs.
alpha
How opaque the regions are rendered.
color
Uses the atlas default colour if None.
silhouette
If true, region Actors will have a silhouette.
hemisphere
``"both"`` returns the complete mesh; ``"left"``/``"right"``
return only the corresponding half of the mesh.
force
If true, force adding of region even if already rendered.
Returns
-------
Actor or list of Actor or None
The actors added to the scene. None if no actors added.
"""
if silhouette is None:
silhouette = (
silhouette or True
if settings.SHADER_STYLE == "cartoon"
else False
)
# avoid adding regions already rendered
if not force:
already_in = [
r.name for r in self.get_actors(br_class="brain region")
]
regions = [r for r in regions if r not in already_in]
if not regions: # they were all already rendered
logger.debug(
"Not adding any region because they are all already in the scene"
)
return None
logger.debug(
f"SCENE: Adding {len(regions)} brain regions to scene: {regions}"
)
# get regions actors from atlas
regions = self.atlas.get_region(*regions, alpha=alpha, color=color)
regions = listify(regions) or []
# add actors
actors = self.add(*regions)
# slice to keep only one hemisphere
if hemisphere in ("left", "right"):
if self.atlas.metadata["symmetric"]:
mesh_center = (
self.root._mesh.bounds().reshape((3, 2)).mean(axis=1)
)
else:
mesh_center = self.root._mesh.center_of_mass()
normal = (0, 0, 1) if hemisphere == "right" else (0, 0, -1)
plane = self.atlas.get_plane(pos=mesh_center, norm=normal)
if not isinstance(actors, list):
actors._mesh.cut_with_plane(
origin=plane.center,
normal=plane.normal,
)
actors.cap()
else:
for actor in actors:
actor._mesh.cut_with_plane(
origin=plane.center,
normal=plane.normal,
)
actor.cap()
# make silhouettes
if silhouette and regions and alpha:
self.add_silhouette(*regions, lw=2)
return actors
@not_on_jupyter
def add_silhouette(
self, *actors: Actor, lw: float = 1, color: str = "k"
) -> None:
"""
Dedicated method to add silhouette to actors.
Parameters
----------
*actors
Actors to silhouette.
lw
Line weight.
color
Silhouette colour.
"""
for actor in actors:
if actor is None:
continue
actor._needs_silhouette = True
actor._silhouette_kwargs = dict(
lw=lw or settings.LW,
color=color,
)
@not_on_jupyter
def add_label(self, actor: Actor, label: str, **kwargs: Any) -> None:
"""
Dedicated method to add labels to actors.
Parameters
----------
actor
Actor to label.
label
Text of the label.
**kwargs
See ``brainrender._actor.make_actor_label`` for kwargs.
"""
actor._needs_label = True
actor._label_str = label
actor._label_kwargs = kwargs
[docs]
def slice(
self,
plane: str | Plane,
actors: Actor | list[Actor] | None = None,
close_actors: bool = False,
invert: bool = False,
) -> None:
"""
Slice actors with a plane.
Parameters
----------
plane
If a string it needs to be a supported plane from brainglobe's
atlas api (e.g. ``"frontal"``), otherwise a vedo.Plane mesh.
actors
Actors to be sliced. If None, all actors will be sliced.
close_actors
If true, the openings in the actors' meshes caused by the cut
will be closed.
invert
Invert the slice direction.
"""
if isinstance(plane, str):
if invert is False:
norm = self.atlas.space.plane_normals[plane]
elif invert is True:
norm = tuple(
x * -1 for x in self.atlas.space.plane_normals[plane]
)
plane = self.atlas.get_plane(plane=plane, norm=norm)
if not actors or actors is None:
actors = self.clean_actors.copy()
for actor in listify(actors):
actor._mesh = actor._mesh.cut_with_plane(
origin=plane.center,
normal=plane.normal,
)
if close_actors:
actor.cap()
if actor.silhouette is not None:
self.plotter.remove(actor.silhouette.mesh)
self.plotter.add(actor.make_silhouette().mesh)
@property
def content(self) -> None:
"""
Print an overview of the Actors in the scene.
"""
actors = pi.Report(
"Scene actors", accent=salmon, dim=orange, color=orange
)
for act in self.actors:
actors.add(
f"[bold][{amber}]- {act.name}[/bold][{orange_darker}] (type: [{orange}]{act.br_class}[/{orange}])"
)
if sys.platform != "win32":
actors.print()
else:
print(pi.utils.stringify(actors, maxlen=-1))
@property
def renderables(self) -> list[Mesh]:
"""
Return the meshes for all actors.
"""
if not self.backend:
return [a.mesh for a in self.actors + self.labels]
else:
return [a.mesh for a in self.actors if not a.is_text]
@property
def clean_actors(self) -> list[Actor]:
"""
Return only actors that are not Text objects and similar.
"""
return [a for a in self.actors if not a.is_text]
@property
def clean_renderables(self) -> list[Mesh]:
"""
Return meshes only for 'clean actors' (i.e. not text).
``_mesh`` is returned to account for internal rotations.
"""
return [a._mesh for a in self.actors if not a.is_text]