OpenXRLab Synthetic Data Rendering Toolbox
"""
From:
https://github.com/Delgan/loguru/blob/master/docs/_extensions/autodoc_stub_file.py

Small Sphinx extension intended to generate documentation for stub files.
"""
import sys
import types
from pathlib import Path


def get_module_docstring(filepath: Path):
    with open(filepath) as file:
        source = file.read()

    co = compile(source, filepath, 'exec')

    if co.co_consts and isinstance(co.co_consts[0], str):
        docstring = co.co_consts[0]
    else:
        docstring = None

    return docstring


def setup(app):
    module_name = 'autodoc_stub_file.xrfeitoria'
    file = Path(__file__).resolve()
    stub_path = file.parents[3] / 'xrfeitoria' / '__init__.pyi'
    docstring = get_module_docstring(stub_path)
    module = types.ModuleType(module_name, docstring)
    sys.modules[module_name] = module

docs/en/_ext/autodoc_stub_file.py
openxrlab/xrfeitoria
from typing import Any, Dict, List, Optional, Tuple

import sphinx
from docutils.nodes import Node
from docutils.parsers.rst import directives
from sphinx.application import Sphinx
from sphinx.ext.graphviz import figure_wrapper
from sphinx.ext.inheritance_diagram import (
    InheritanceDiagram,
    InheritanceException,
    InheritanceGraph,
    inheritance_diagram,
    py_builtins,
)
from sphinx.util.typing import OptionSpec

# sphinx==7.2.5


class CustomInheratanceGraph(InheritanceGraph):
    def __init__(
        self,
        class_names: List[str],
        currmodule: str,
        show_builtins: bool = False,
        private_bases: bool = False,
        parts: int = 0,
        aliases: Optional[Dict[str, str]] = None,
        top_classes: List[Any] = [],
        skip_classes: List[Any] = [],
    ) -> None:
        self.skip_classes = skip_classes
        super().__init__(class_names, currmodule, show_builtins, private_bases, parts, aliases, top_classes)

    def _class_info(
        self,
        classes: List[Any],
        show_builtins: bool,
        private_bases: bool,
        parts: int,
        aliases: Dict[str, str],
        top_classes: List[Any],
    ) -> List[Tuple[str, str, List[str], str]]:
        """Return name and bases for all classes that are ancestors of *classes*.

        *parts* gives the number of dotted name parts to include in the
        displayed node names, from right to left. If given as a negative, the
        number of parts to drop from the left. A value of 0 displays the full
        dotted name. E.g. ``sphinx.ext.inheritance_diagram.InheritanceGraph``
        with ``parts=2`` or ``parts=-2`` gets displayed as
        ``inheritance_diagram.InheritanceGraph``, and as
        ``ext.inheritance_diagram.InheritanceGraph`` with ``parts=3`` or
        ``parts=-1``.

        *top_classes* gives the name(s) of the top most ancestor class to
        traverse to. Multiple names can be specified separated by comma.
        """
        all_classes = {}

        def recurse(cls: Any) -> None:
            if not show_builtins and cls in py_builtins:
                return
            if not private_bases and cls.__name__.startswith('_'):
                return

            nodename = self.class_name(cls, parts, aliases)
            fullname = self.class_name(cls, 0, aliases)

            # Skip classes pre-defined in the options
            if fullname in self.skip_classes:
                return

            # Use first line of docstring as tooltip, if available
            tooltip = None
            try:
                if cls.__doc__:
                    doc = cls.__doc__.strip().split('\n')[0]
                    if doc:
                        tooltip = '"%s"' % doc.replace('"', '\\"')
            except Exception:  # might raise AttributeError for strange classes
                pass

            baselist: List[str] = []
            all_classes[cls] = (nodename, fullname, baselist, tooltip)

            if fullname in top_classes:
                return

            for base in cls.__bases__:
                if not show_builtins and base in py_builtins:
                    continue
                if not private_bases and base.__name__.startswith('_'):
                    continue

                # Skip classes pre-defined in the options
                nodename = self.class_name(base, parts, aliases)
                fullname = self.class_name(base, 0, aliases)
                if fullname in self.skip_classes:
                    continue

                baselist.append(nodename)
                if base not in all_classes:
                    recurse(base)

        for cls in classes:
            recurse(cls)

        return list(all_classes.values())


class CustomInheritanceDiagram(InheritanceDiagram):
    """Run when the inheritance_diagram directive is first encountered."""

    has_content = False
    required_arguments = 1
    optional_arguments = 0
    final_argument_whitespace = True
    option_spec: OptionSpec = {
        'parts': int,
        'private-bases': directives.flag,
        'caption': directives.unchanged,
        'top-classes': directives.unchanged_required,
        'skip-classes': directives.unchanged_required,
        'align': lambda arg: directives.choice(arg, ('left', 'center', 'right')),
    }

    def run(self) -> List[Node]:
        node = inheritance_diagram()
        node.document = self.state.document
        class_names = self.arguments[0].split()
        class_role = self.env.get_domain('py').role('class')
        # Store the original content for use as a hash
        node['parts'] = self.options.get('parts', 0)
        node['content'] = ', '.join(class_names)
        node['top-classes'] = []
        if 'align' in self.options:
            node['align'] = self.options['align']
        for cls in self.options.get('top-classes', '').split(','):
            cls = cls.strip()
            if cls:
                node['top-classes'].append(cls)

        # Create a graph starting with the list of classes
        try:
            graph = CustomInheratanceGraph(
                class_names,
                self.env.ref_context.get('py:module'),
                parts=node['parts'],
                private_bases='private-bases' in self.options,
                aliases=self.config.inheritance_alias,
                top_classes=node['top-classes'],
                skip_classes=self.options.get('skip-classes', '').split(','),
            )
        except InheritanceException as err:
            return [node.document.reporter.warning(err, line=self.lineno)]

        if len(graph.get_all_class_names()) <= 1:
            return [
                node.document.reporter.warning(
                    'skip inheritance diagram for no parent class',
                    line=self.lineno,
                )
            ]

        # Create xref nodes for each target of the graph's image map and
        # add them to the doc tree so that Sphinx can resolve the
        # references to real URLs later.  These nodes will eventually be
        # removed from the doctree after we're done with them.
        for name in graph.get_all_class_names():
            refnodes, x = class_role(  # type: ignore
                'class', ':class:`%s`' % name, name, 0, self.state
            )  # type: ignore
            node.extend(refnodes)
        # Store the graph object so we can use it to generate the
        # dot file later
        node['graph'] = graph
        if 'caption' not in self.options:
            self.add_name(node)
            return [node]
        else:
            figure = figure_wrapper(self, node, self.options['caption'])
            self.add_name(figure)
            return [figure]


def setup(app: Sphinx) -> Dict[str, Any]:
    app.setup_extension('sphinx.ext.graphviz')
    app.add_directive('custom-inheritance-diagram', CustomInheritanceDiagram)
    return {'version': sphinx.__display_version__, 'parallel_read_safe': True}

docs/en/_ext/custom_diagram.py
openxrlab/xrfeitoria
# Configuration file for the Sphinx documentation builder.
#
# This file only contains a selection of the most common options. For a full
# list see the documentation:
# https://www.sphinx-doc.org/en/master/usage/configuration.html

# -- Path setup --------------------------------------------------------------

# If extensions (or modules to document with autodoc) are in another directory,
# add these directories to sys.path here. If the directory is relative to the
# documentation root, use os.path.abspath to make it absolute, like shown here.
#
import os
import sys

import sphinx_rtd_theme

# sys.path.insert(0, os.path.abspath('../../src/XRFeitoriaUnreal/Content/Python'))
# sys.path.insert(0, os.path.abspath('../../src/XRFeitoriaBpy'))
sys.path.insert(0, os.path.abspath('../..'))
sys.path.append(os.path.abspath('./_ext'))

# -- Project information -----------------------------------------------------

project = 'XRFeitoria'
copyright = '2023, OpenXRLab'
author = 'XRFeitoria Authors'
from xrfeitoria import __version__

# The full version, including alpha/beta/rc tags
version = __version__
release = __version__

# -- General configuration ---------------------------------------------------

# Add any Sphinx extension module names here, as strings. They can be
# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom
# ones.
extensions = [
    'sphinx.ext.autodoc',
    'sphinx.ext.autosummary',
    'sphinx.ext.inheritance_diagram',
    'sphinx.ext.napoleon',
    'sphinx.ext.viewcode',
    'sphinx.ext.graphviz',
    'sphinx_copybutton',
    'sphinx_gallery.load_style',
    'sphinx_tabs.tabs',
    'sphinxcontrib.autodoc_pydantic',
    'enum_tools.autoenum',
    'myst_parser',
    'nbsphinx',
    'custom_diagram',
]
autodoc_mock_imports = ['']
autosummary_generate = True
# autodoc_typehints = 'none'  # shorten typehints
autodoc_pydantic_model_show_json = True
autodoc_pydantic_model_show_config_summary = False

# notebook
nbsphinx_allow_errors = True
nbsphinx_execute = 'never'
nbsphinx_prolog = """
.. raw:: html

    <style>

        .rst-content code.literal, .rst-content tt.literal {
            color: #000000;
        }

        div.nbinput.container div.prompt,
        div.nboutput.container div.prompt {
            padding-top: 11px;
        }
        div.nbinput.container div.input_area div[class*=highlight] > pre,
        div.nboutput.container div.output_area div[class*=highlight] > pre,
        div.nboutput.container div.output_area div[class*=highlight].math,
        div.nboutput.container div.output_area.rendered_html,
        div.nboutput.container div.output_area > div.output_javascript,
        div.nboutput.container div.output_area:not(.rendered_html) > img{
            padding: 10px;
        }
    </style>

    <div class="admonition note">
        <p class="admonition-title">
            Note
        </p>
        <p>
            <a href="{{ env.docname.split('/')|last|e + '.ipynb' }}" download>
                Download notebook
            </a>
            and run it offline.
        </p>
    </div>
"""

# Parse `Returns` in docstr with parameter style
napoleon_custom_sections = [('Returns', 'params_style')]

# Ignore >>> when copying code
copybutton_prompt_text = r'>>> |\.\.\. '
copybutton_prompt_is_regexp = True

# Add any paths that contain templates here, relative to this directory.
templates_path = ['_templates']

# The suffix(es) of source filenames.
# You can specify multiple suffix as a list of string:
#
source_suffix = {
    '.rst': 'restructuredtext',
    '.md': 'markdown',
}

# List of patterns, relative to source directory, that match files and
# directories to ignore when looking for source files.
# This pattern also affects html_static_path and html_extra_path.
exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store']
autodoc_default_options = {
    'member-order': 'groupwise',
    'special-members': '__init__',
}

# -- GraphViz configuration --------------------------------------------------
graphviz_output_format = 'svg'

# -- Options for HTML output -------------------------------------------------
# The theme to use for HTML and HTML Help pages.  See the documentation for
# a list of builtin themes.
#
html_theme = 'sphinx_rtd_theme'
html_static_path = ['_static']
html_theme_path = [sphinx_rtd_theme.get_html_theme_path()]
html_css_files = ['override.css']  # override py property
html_theme_options = {
    'navigation_depth': 3,
    # 'style_external_links': True,
    'analytics_id': 'G-F7W2BTNGLW',
}


# Enable ::: for my_st
myst_enable_extensions = ['colon_fence']

master_doc = 'index'

docs/en/conf.py
openxrlab/xrfeitoria
"""
>>> python -m samples.blender.01_add_shapes

This is a script to add shapes in blender.
"""
from pathlib import Path

import xrfeitoria as xf

from ..config import blender_exec
from ..utils import setup_logger

root = Path(__file__).parents[2].resolve()
# output_path = '~/xrfeitoria/output/samples/blender/{file_name}'
output_path = root / 'output' / Path(__file__).relative_to(root).with_suffix('')
log_path = output_path / 'blender.log'
output_blend_file = output_path / 'source.blend'


def main(debug=False, background=False):
    logger = setup_logger(debug=debug, log_path=log_path)

    # The blender will start in a separate process in RPC Server mode automatically,
    # and close when calling `xf_runner.close()`.
    xf_runner = xf.init_blender(exec_path=blender_exec, background=background, new_process=True)

    # add 4 types of shapes
    for i in range(4):
        xf_runner.Shape.spawn_cube(name=f'cube_{i}', location=(0, i, 0), size=0.2)
    for i in range(4):
        xf_runner.Shape.spawn_sphere(name=f'sphere_{i}', location=(1, i, 0), radius=0.1)
    for i in range(4):
        xf_runner.Shape.spawn_cylinder(name=f'cylinder_{i}', location=(2, i, 0), radius=0.1, depth=0.2)
    for i in range(4):
        xf_runner.Shape.spawn_cone(name=f'cone_{i}', location=(3, i, 0), radius1=0.1, depth=0.2)

    # Save the blender file to the current directory.
    xf_runner.utils.save_blend(save_path=output_blend_file)

    logger.info('🎉 [bold green]Success!')
    input('Press Any Key to Exit...')

    # close the blender process
    xf_runner.close()

    logger.info(f'You can check the result with Blender in: "{output_blend_file}"')


if __name__ == '__main__':
    from ..utils import parse_args

    args = parse_args()

    main(debug=args.debug, background=args.background)

samples/blender/01_add_shapes.py
openxrlab/xrfeitoria
"""
>>> python -m samples.blender.02_add_cameras

This is a script to add cameras in blender.
"""
import math
from pathlib import Path

import xrfeitoria as xf

from ..config import blender_exec
from ..utils import setup_logger

root = Path(__file__).parents[2].resolve()
# output_path = '~/xrfeitoria/output/samples/blender/{file_name}'
output_path = root / 'output' / Path(__file__).relative_to(root).with_suffix('')
log_path = output_path / 'blender.log'
output_blend_file = output_path / 'source.blend'


def main(debug=False, background=False):
    logger = setup_logger(debug=debug, log_path=log_path)

    # Open blender
    xf_runner = xf.init_blender(exec_path=blender_exec, background=background, new_process=True)

    # Add 32 circled cameras around the center
    center = (1, 2, 3)
    distance = 1
    fov = 90

    for i in range(32):
        azimuth = 360 / 32 * i
        azimuth_radians = math.radians(azimuth)

        x = distance * math.cos(azimuth_radians) + center[0]
        y = distance * math.sin(azimuth_radians) + center[1]
        z = 0.0 + center[2]
        location = (x, y, z)

        camera = xf_runner.Camera.spawn(
            camera_name=f'camera_{i}',
            location=location,
            fov=fov,
        )
        camera.look_at(target=center)

    # Save the blender file to the current directory.
    xf_runner.utils.save_blend(save_path=output_blend_file)

    logger.info('🎉 [bold green]Success!')
    input('Press Any Key to Exit...')

    # close the blender process
    xf_runner.close()

    logger.info(f'You can check the result with Blender in: "{output_blend_file}"')


if __name__ == '__main__':
    from ..utils import parse_args

    args = parse_args()

    main(debug=args.debug, background=args.background)

samples/blender/02_add_cameras.py
openxrlab/xrfeitoria
"""
>>> python -m samples.blender.03_basic_render

This is a script to render a preset level in blender.
"""
from pathlib import Path

import xrfeitoria as xf
from xrfeitoria.data_structure.models import RenderPass

from ..config import assets_path, blender_exec
from ..utils import setup_logger

root = Path(__file__).parents[2].resolve()
# output_path = '~/xrfeitoria/output/samples/blender/{file_name}'
output_path = root / 'output' / Path(__file__).relative_to(root).with_suffix('')
log_path = output_path / 'blender.log'
output_blend_file = output_path / 'source.blend'

seq_name = 'Sequence'


def main(debug=False, background=False):
    logger = setup_logger(debug=debug, log_path=log_path)

    ################################################################################################################################
    # In XRFeitoria, a `level` is an editable space that can be used to place objects(3D models, lights, cameras, etc.),
    # and a `sequence` is a collection of objects in `level` that can be rendered together.

    # The objects in a `level` are shared by all `sequence`s in this `level`,
    # and the objects in a `sequence` are independent of other `sequence`s in this `level`.
    # Therefore, rendering should be performed on a `sequence` basis.

    # In blender, a `level` is a `scene`, and a `sequence` is a `collection` in the `scene`.
    # The default level is named `XRFeitoria`, and it is automatically created when the blender is started.
    # The operations in the previous examples are all performed in the default level.
    # You can also use other preset levels downloaded from the internet, or create your own level by xf_runner.utils.new_level().

    # The following example demonstrates how to use a preset level to render a scene.
    ################################################################################################################################

    # open the specified .blend file given by `project_path`, and the scene `Scene` is a `level`.
    xf_runner = xf.init_blender(
        exec_path=blender_exec,
        background=background,
        new_process=True,
        cleanup=False,
        project_path=assets_path['blend_sample'],
    )

    # by creating a new `sequence`, XRFeitoria links a new collection named `{seq_name}` to the level.
    with xf_runner.Sequence.new(seq_name=seq_name, level='Scene') as seq:
        # The cameras in the level will not be used for rendering by default,
        # but you can activate them by calling `seq.use_camera(camera)`.
        # There is a camera named `Camera` in the level, and we use it for rendering.
        camera = xf_runner.Camera('Camera')
        camera_name = camera.name
        seq.use_camera(camera)

        # add render job to renderer
        seq.add_to_renderer(
            output_path=output_path,
            render_passes=[RenderPass('img', 'png')],
            resolution=[512, 512],
            render_engine='CYCLES',
            render_samples=32,
            transparent_background=False,
            arrange_file_structure=True,
        )

    # render
    xf_runner.render()

    # Save the blender file to the current directory.
    xf_runner.utils.save_blend(save_path=output_blend_file)

    logger.info('🎉 [bold green]Success!')
    input('Press Any Key to Exit...')

    # close the blender process
    xf_runner.close()

    output_img = output_path / seq_name / 'img' / camera_name / '0000.png'
    if output_img.exists():
        logger.info(f'Check the output in "{output_img}"')


if __name__ == '__main__':
    from ..utils import parse_args

    args = parse_args()

    main(debug=args.debug, background=args.background)

samples/blender/03_basic_render.py
openxrlab/xrfeitoria
"""
>>> python -m samples.blender.04_staticmesh_render

This is a script to render static meshes in blender.
"""
import math
from pathlib import Path

import xrfeitoria as xf
from xrfeitoria.data_structure.models import RenderPass
from xrfeitoria.data_structure.models import SequenceTransformKey as SeqTransKey

from ..config import assets_path, blender_exec
from ..utils import setup_logger, visualize_vertices

root = Path(__file__).parents[2].resolve()
# output_path = '~/xrfeitoria/output/samples/blender/{file_name}'
output_path = root / 'output' / Path(__file__).relative_to(root).with_suffix('')
log_path = output_path / 'blender.log'
output_blend_file = output_path / 'source.blend'

seq_1_name = 'Sequence_static_mesh_1'
seq_2_name = 'Sequence_static_mesh_2'


def main(debug=False, background=False):
    logger = setup_logger(debug=debug, log_path=log_path)

    #############################
    #### use default level ######
    #############################

    # open blender and automatically create a default level named `XRFeitoria`
    xf_runner = xf.init_blender(exec_path=blender_exec, background=background, new_process=True)

    # set hdr map
    xf_runner.utils.set_hdr_map(hdr_map_path=assets_path['hdr_sample'])

    # import an actor to the level
    actor_bunny = xf_runner.Actor.import_from_file(file_path=assets_path['bunny'], stencil_value=128)
    # set the origin of bunny to its center
    actor_bunny.set_origin_to_center()

    # set bunny's size to 0.4m
    bunny_size = 0.4
    bunny_max_dimensions = max(actor_bunny.dimensions)
    actor_bunny.scale = (bunny_size / bunny_max_dimensions,) * 3

    # add a new camera looking at the bunny, and the distance between them is 2m
    distance_to_bunny = 2
    camera = xf_runner.Camera.spawn(
        location=(
            actor_bunny.location[0],
            actor_bunny.location[1] - distance_to_bunny,
            actor_bunny.location[2],
        )
    )
    camera.look_at(actor_bunny.location)

    # set camera's fov to make the bunny fill the 10% of screen
    camera.fov = math.degrees(math.atan(((bunny_size * 0.5) / 0.1) / distance_to_bunny)) * 2

    ##################################################
    #### Create a new sequence with default level ####
    ##################################################
    with xf_runner.Sequence.new(seq_name=seq_1_name) as seq:
        # use the `camera` in level to render
        seq.use_camera(camera=camera)

        # add render job to renderer
        seq.add_to_renderer(
            output_path=output_path / seq_1_name,
            render_passes=[
                RenderPass('img', 'png'),
                RenderPass('mask', 'png'),
            ],
            resolution=[512, 512],
            render_engine='CYCLES',
            render_samples=32,
            transparent_background=False,
            arrange_file_structure=True,
        )

    ########################################################
    #### Create another new sequence with default level ####
    ########################################################
    with xf_runner.Sequence.new(seq_name=seq_2_name) as seq:
        # import an actor to the sequence, and add transform keys to make it rotate around the bunny and grow bigger and bigger
        actor_kc = seq.import_actor(file_path=assets_path['koupen_chan'], stencil_value=255)
        actor_kc.set_origin_to_center()

        # get koupen_chan's rotation and size in the first frame
        kc_rotation = actor_kc.rotation
        kc_size = max(actor_kc.dimensions)
        bunny_location = actor_bunny.location

        # set koupen_chan's transform keys
        transform_keys = []
        for i in range(6):
            azimuth = 360 / 6 * i
            azimuth_radians = math.radians(azimuth)

            x = math.cos(azimuth_radians) + bunny_location[0]
            y = math.sin(azimuth_radians) + bunny_location[1]
            z = 0.0 + bunny_location[2]

            transform_keys.append(
                SeqTransKey(
                    frame=i,
                    location=(x, y, z),
                    rotation=(kc_rotation[0], kc_rotation[1], kc_rotation[2] + azimuth),
                    scale=(0.1 / kc_size * (i + 1),) * 3,
                    interpolation='AUTO',
                )
            )
        actor_kc.set_transform_keys(transform_keys=transform_keys)

        # use the `camera` in level to render
        seq.use_camera(camera=camera)

        # get keys range of all objects in the scene
        keys_range = xf_runner.utils.get_keys_range()
        # set frame range of the scene according to keys range
        xf_runner.utils.set_frame_range(start=keys_range[0], end=keys_range[1])

        # add render job to renderer
        seq.add_to_renderer(
            output_path=output_path,
            render_passes=[
                RenderPass('img', 'png'),
                RenderPass('mask', 'png'),
            ],
            resolution=[512, 512],
            render_engine='CYCLES',
            render_samples=32,
            transparent_background=False,
            arrange_file_structure=True,
        )

        # export verts of meshes in this sequence and its level
        xf_runner.utils.export_vertices(export_path=output_path / seq_2_name / 'vertices')

    # render
    xf_runner.render()

    # Save the blender file to the current directory.
    xf_runner.utils.save_blend(save_path=output_blend_file)

    logger.info('🎉 [bold green]Success!')
    input('Press Any Key to Exit...')

    # collect the names for visualization
    camera_name = camera.name
    actor_names = [actor_bunny.name, actor_kc.name]

    # close the blender process
    xf_runner.close()

    seq1_out = output_path / seq_1_name / 'img' / camera_name / '0000.png'
    seq2_out = output_path / seq_2_name / 'img' / camera_name / '0003.png'
    logger.info(f'Check the output of seq_1 in "{seq1_out.as_posix()}"')
    logger.info(f'Check the output of seq_2 in "{seq2_out.as_posix()}"')

    visualize_vertices(
        camera_name=camera_name,
        actor_names=actor_names,
        seq_output_path=output_path / seq_2_name,
        frame_idx=5,
    )


if __name__ == '__main__':
    from ..utils import parse_args

    args = parse_args()

    main(debug=args.debug, background=args.background)

samples/blender/04_staticmesh_render.py
openxrlab/xrfeitoria
"""
>>> python -m samples.blender.05_skeletalmesh_render

This is a script to render skeletal meshes in blender.
"""
from pathlib import Path

import xrfeitoria as xf
from xrfeitoria.data_structure.models import RenderPass
from xrfeitoria.data_structure.models import SequenceTransformKey as SeqTransKey

from ..config import assets_path, blender_exec
from ..utils import setup_logger

root = Path(__file__).parents[2].resolve()
# output_path = '~/xrfeitoria/output/samples/blender/{file_name}'
output_path = root / 'output' / Path(__file__).relative_to(root).with_suffix('')
log_path = output_path / 'blender.log'
output_blend_file = output_path / 'source.blend'

seq_name = 'Sequence_skeletal_mesh'


def main(debug=False, background=False):
    logger = setup_logger(debug=debug, log_path=log_path)

    #################################
    #### Define your own level ######
    #################################

    # open blender
    xf_runner = xf.init_blender(exec_path=blender_exec, background=background, new_process=False)

    # define a new level named `MyLevel`
    xf_runner.utils.new_level(name='MyLevel')

    ####################################################
    ### Create a new sequence with the defined level ###
    ####################################################
    seq_length = 200
    with xf_runner.Sequence.new(seq_name=seq_name, level='MyLevel', seq_length=seq_length) as seq:
        # import an skeletal mesh and set an animation to it
        actor = seq.import_actor(file_path=assets_path['SMPL_XL'], stencil_value=128)
        actor.setup_animation(animation_path=assets_path['motion_2'])

        # use a list to store the transform keys of the cameras
        transform_keys_tracking_camera = []

        for i in range(seq_length):
            # set current frame
            xf_runner.utils.set_frame_current(i)

            # get actor's bounding box at each frame
            bbox_min, bbox_max = actor.bound_box

            # set camera's location and rotation to track the actor
            actor_location = (
                (bbox_min[0] + bbox_max[0]) / 2,
                (bbox_min[1] + bbox_max[1]) / 2,
                (bbox_min[2] + bbox_max[2]) / 2,
            )
            camera_location = (actor_location[0], actor_location[1] - 2, actor_location[2])
            camera_rotation = xf_runner.utils.get_rotation_to_look_at(location=camera_location, target=actor_location)
            transform_keys_tracking_camera.append(
                SeqTransKey(
                    frame=i,
                    location=camera_location,
                    rotation=camera_rotation,
                    interpolation='AUTO',
                )
            )
            logger.info(f'Frame {i}: {camera_location}, {camera_rotation}')

        # add a camera in sequence to render
        camera = seq.spawn_camera_with_keys(transform_keys=transform_keys_tracking_camera, fov=90)

        # set environment color
        xf_runner.utils.set_env_color(color=(1, 1, 1, 1))

        # add render job to renderer
        seq.add_to_renderer(
            output_path=output_path,
            render_passes=[
                RenderPass('img', 'png'),
                RenderPass('mask', 'png'),
            ],
            resolution=[512, 512],
            render_engine='CYCLES',
            render_samples=128,
            transparent_background=False,
            arrange_file_structure=True,
        )

    # render all jobs
    xf_runner.render()

    # Save the blender file to the current directory.
    xf_runner.utils.save_blend(save_path=output_blend_file)

    logger.info('🎉 [bold green]Success!')
    input('Press Any Key to Exit...')

    # close the blender process
    xf_runner.close()

    output_img = output_path / seq_name / 'img' / camera.name / '0000.png'
    if output_img.exists():
        logger.info(f'Check the output in "{output_img.as_posix()}"')


if __name__ == '__main__':
    from ..utils import parse_args

    args = parse_args()

    main(debug=args.debug, background=args.background)

samples/blender/05_skeletalmesh_render.py
openxrlab/xrfeitoria
"""
>>> python -m samples.blender.06_custom_usage

This is a script to demonstrate the custom use of xrfeitoria.
"""
from pathlib import Path

import xrfeitoria as xf
from xrfeitoria.rpc import remote_blender

from ..config import blender_exec
from ..utils import setup_logger

root = Path(__file__).parents[2].resolve()
# output_path = '~/xrfeitoria/output/samples/blender/{file_name}'
output_path = root / 'output' / Path(__file__).relative_to(root).with_suffix('')
log_path = output_path / 'blender.log'
output_blend_file = output_path / 'source.blend'


@remote_blender()
def add_cubes_in_blender():
    """This function will be executed in blender.

    Write your own blender code here, and decorate it with `@remote_blender`. The code
    of the functions decorated with `@remote_blender` would be sent to blender by RPC
    protocols.
    """
    import bpy

    # add 100 cubes marching in a 10x10 grid
    for i in range(10):
        for j in range(10):
            bpy.ops.mesh.primitive_cube_add(size=0.1, location=(i, j, 0))


def main(debug=False, background=False):
    logger = setup_logger(debug=debug, log_path=log_path)

    xf_runner = xf.init_blender(exec_path=blender_exec, background=background, new_process=True)
    # The function `add_cubes_in_blender` decorated with `@remote_blender` will be executed in blender.
    add_cubes_in_blender()

    # Save the blender file to the current directory.
    xf_runner.utils.save_blend(save_path=output_blend_file)

    logger.info('🎉 [bold green]Success!')
    input('Press Any Key to Exit...')

    # close the blender process
    xf_runner.close()

    logger.info(f'You can check the result with Blender in: "{output_blend_file}"')


if __name__ == '__main__':
    from ..utils import parse_args

    args = parse_args()

    main(debug=args.debug, background=args.background)

samples/blender/06_custom_usage.py
openxrlab/xrfeitoria
import subprocess
from typing import Literal


def main(engine: Literal['unreal', 'blender'], debug: bool = False, background: bool = False):
    args = []
    if debug:
        args += ['--debug']
    if background:
        args += ['--background']

    scripts = [
        '01_add_shapes',
        '02_add_cameras',
        '03_basic_render',
        '04_staticmesh_render',
        '05_skeletalmesh_render',
        '06_custom_usage',
    ]
    for script in scripts:
        subprocess.check_call(['python', '-m', f'samples.{engine}.{script}'] + args)


if __name__ == '__main__':
    from .utils import parse_args

    args = parse_args()
    main(engine=args.engine, debug=args.debug, background=args.background)

samples/run_all.py
openxrlab/xrfeitoria
import json
import shutil
from pathlib import Path
from textwrap import dedent
from typing import Literal, Optional

from loguru import logger
from rich.prompt import Prompt

from xrfeitoria.data_structure.constants import tmp_dir
from xrfeitoria.utils.downloader import download
from xrfeitoria.utils.setup import Config, get_exec_path, guess_exec_path
from xrfeitoria.utils.tools import Logger

# XXX: Hard-coded assets url
assets_url = dict(
    bunny='https://openxrlab-share.oss-cn-hongkong.aliyuncs.com/xrfeitoria/assets/stanford-bunny.obj',
    koupen_chan='https://openxrlab-share.oss-cn-hongkong.aliyuncs.com/xrfeitoria/assets/koupen_chan.fbx',
    SMPL_XL='https://openxrlab-share.oss-cn-hongkong.aliyuncs.com/xrfeitoria/assets/SMPL-XL-001.fbx',
    motion_1='https://openxrlab-share.oss-cn-hongkong.aliyuncs.com/xrfeitoria/assets/motion-greeting.fbx',
    motion_2='https://openxrlab-share.oss-cn-hongkong.aliyuncs.com/xrfeitoria/assets/motion-stand_to_walk_back.fbx',
    blend_sample='https://openxrlab-share.oss-cn-hongkong.aliyuncs.com/xrfeitoria/assets/Tree1.blend',
    hdr_sample='https://openxrlab-share.oss-cn-hongkong.aliyuncs.com/xrfeitoria/assets/hdr-sample.hdr',
)
# XXX: hard-coded unreal project url
unreal_sample_url = (
    'https://openxrlab-share.oss-cn-hongkong.aliyuncs.com/xrfeitoria/unreal_project/XRFeitoriaUnreal_Sample.zip'
)
asset_dir = tmp_dir / 'assets'


def get_exec(engine: Literal['blender', 'unreal'], exec_from_config: Optional[Path] = None) -> str:
    """Ask for blender executable path.

    Args:
        engine (Literal['blender', 'unreal']): Engine name.
        engine_path_from_config (Optional[Path], optional): path of engine executable read from config. Defaults to None.
    """
    txt = f'Please input the path to the {engine} executable'

    # priority: exec_from_config > system_config (with guess) > ask > install
    if exec_from_config is not None:
        engine_path = Path(exec_from_config).resolve().as_posix()
    else:
        try:
            # system_config, if empty, guess
            engine_path = get_exec_path(engine=engine, to_ask=False, to_install=False).as_posix()
        except FileNotFoundError:
            engine_path = None
            txt += f' (e.g. {guess_exec_path(engine)})'
            if engine == 'blender':
                txt += ', or press [bold]enter[/bold] to install it'

    # ask
    path = Prompt.ask(txt, default=engine_path)
    if path is not None:
        path = Path(path.strip('"').strip("'")).resolve()
    else:
        if engine == 'unreal':
            raise FileNotFoundError('Please specify the path to the unreal executable.')
        # auto install for blender
        path = get_exec_path(engine=engine, to_ask=False, to_install=True)

    assert path.exists(), f'{engine} path {path.as_posix()} does not exist!'
    return path.as_posix()


def get_unreal_project(unreal_project: str) -> str:
    """Ask for unreal project path."""
    txt = 'Please input the path to the unreal project'
    if unreal_project is None:
        txt += ', or press [bold]enter[/bold] to download a sample project\n' '\[Enter]'
    unreal_project = Prompt.ask(txt, default=unreal_project)
    if unreal_project is None:
        unreal_project_zip = download(url=unreal_sample_url, dst_dir=tmp_dir / 'unreal_project')
        shutil.unpack_archive(filename=unreal_project_zip, extract_dir=tmp_dir / 'unreal_project')
        unreal_project_dir = unreal_project_zip.parent / unreal_project_zip.stem
        unreal_project = next(unreal_project_dir.glob('*.uproject')).as_posix()
    unreal_project = unreal_project.strip('"').strip("'")
    if not Path(unreal_project).exists():
        config_file = Path(__file__).parent / 'config.py'
        logger.error(
            '[red]Error:[/red]\n'
            f'Unreal project "{unreal_project}" does not exist! \n'
            f'Config not set correctly, please [red]delete "{config_file.as_posix()}"[/red], and setup again.'
        )
        exit(1)
    return unreal_project


def main():
    try:
        from .config import blender_exec, unreal_exec, unreal_project
    except ImportError:
        blender_exec = unreal_exec = unreal_project = None

    Logger.setup_logging()
    engine = Prompt.ask('Which engine do you want to use?', choices=['blender', 'unreal'], default='blender')
    if engine == 'blender':
        blender_exec = get_exec('blender', exec_from_config=blender_exec)
        Config.update(engine=engine, exec_path=blender_exec)
    elif engine == 'unreal':
        unreal_exec = get_exec('unreal', exec_from_config=unreal_exec)
        unreal_project = get_unreal_project(unreal_project=unreal_project)
        Config.update(engine=engine, exec_path=unreal_exec)

    assets_path = {}
    for idx, (name, url) in enumerate(assets_url.items()):
        logger.info(f'Downloading {idx+1}/{len(assets_url)}: {name}')
        save_path = download(url, dst_dir=asset_dir / name)
        # save the path
        assets_path[name] = save_path.as_posix()

    # write the config file
    config_path = Path(__file__).parent / 'config.py'
    with open(config_path, 'w') as f:
        _keys = {key: 'str' for key in assets_path.keys()}
        _config = dedent(
            f"""\
            from typing import TypedDict

            blender_exec = {repr(blender_exec)}
            unreal_exec = {repr(unreal_exec)}
            unreal_project = {repr(unreal_project)}

            assets_path_type = TypedDict('assets_path', {json.dumps(_keys)})
            assets_path: assets_path_type = {json.dumps(assets_path)}"""
        )
        f.write(_config)
    logger.info(f'Saved config to "{config_path}"')


if __name__ == '__main__':
    main()

samples/setup.py
openxrlab/xrfeitoria
"""
>>> python -m samples.unreal.01_add_shapes

This is a script to add shapes in unreal.
"""

from pathlib import Path

from loguru import logger

import xrfeitoria as xf

from ..config import unreal_exec, unreal_project
from ..utils import setup_logger

root = Path(__file__).parents[2].resolve()
# output_path = '~/xrfeitoria/output/samples/unreal/{file_name}'
output_path = root / 'output' / Path(__file__).relative_to(root).with_suffix('')
default_level = '/Game/Levels/Default'


def main(debug=False, background=False):
    setup_logger(debug=debug)

    # unreal will start in a separate process in RPC Server mode automatically,
    # and close using `xf_runner.close()`
    xf_runner = xf.init_unreal(exec_path=unreal_exec, project_path=unreal_project, background=background)

    # open a level, this can be omitted if you don't need to open a level
    xf_runner.utils.open_level(default_level)

    # We provide many useful functions that you can accomplish complex tasks without knowing unreal API.
    # like spawn a shape
    scale = 0.4
    height = 0.4

    # spawn cube
    for idx in range(4):
        xf_runner.Shape.spawn(
            type='cube',
            name=f'cube_{idx}',
            location=(0, idx, height),
            scale=(scale, scale, scale),
        )
    # spawn sphere
    for idx in range(4):
        xf_runner.Shape.spawn(
            type='sphere',
            name=f'sphere_{idx}',
            location=(1, idx, height),
            scale=(scale, scale, scale),
        )
    # spawn cylinder
    for idx in range(4):
        xf_runner.Shape.spawn(
            type='cylinder',
            name=f'cylinder_{idx}',
            location=(2, idx, height),
            scale=(scale, scale, scale),
        )
    # spawn cone
    for idx in range(4):
        xf_runner.Shape.spawn(
            type='cone',
            name=f'cone_{idx}',
            location=(3, idx, height),
            scale=(scale, scale, scale),
        )

    # Save the unreal project
    # xf_runner.utils.save_current_level()

    logger.info('🎉 [bold green]Success!')
    input('Press Any Key to Exit...')

    # close the unreal process
    xf_runner.close()

    logger.info(f'You can check the result with Unreal in: "{unreal_project}"')


if __name__ == '__main__':
    from ..utils import parse_args

    args = parse_args()

    main(debug=args.debug, background=args.background)

samples/unreal/01_add_shapes.py
openxrlab/xrfeitoria
"""
>>> python -m samples.unreal.02_add_cameras

This is a script to add cameras in unreal.
"""
import math
from pathlib import Path

import xrfeitoria as xf

from ..config import unreal_exec, unreal_project
from ..utils import setup_logger

root = Path(__file__).parents[2].resolve()
# output_path = '~/xrfeitoria/output/samples/unreal/{file_name}'
output_path = root / 'output' / Path(__file__).relative_to(root).with_suffix('')
log_path = output_path / 'blender.log'
default_level = '/Game/Levels/Default'


def main(debug=False, background=False):
    logger = setup_logger(debug=debug, log_path=log_path)
    xf_runner = xf.init_unreal(exec_path=unreal_exec, project_path=unreal_project, background=background)

    # open a level, this can be omitted if you don't need to open a level
    xf_runner.utils.open_level(default_level)

    # add 32 circled cameras around the center
    center = (1, 2, 1)
    distance = 2
    fov = 90

    for idx in range(32):
        azimuth = 360 / 32 * idx
        azimuth_radians = math.radians(azimuth)

        x = distance * math.cos(azimuth_radians) + center[0]
        y = distance * math.sin(azimuth_radians) + center[1]
        z = 0.0 + center[2]
        location = (x, y, z)

        camera = xf_runner.Camera.spawn(
            camera_name=f'camera_{idx}',
            location=location,
            fov=fov,
        )
        camera.look_at(target=center)

    logger.info('🎉 [bold green]Success!')
    input('Press Any Key to Exit...')

    # close the unreal process
    xf_runner.close()

    logger.info(f'You can check the result with Unreal in: "{unreal_project}"')


if __name__ == '__main__':
    from ..utils import parse_args

    args = parse_args()

    main(debug=args.debug, background=args.background)

samples/unreal/02_add_cameras.py
openxrlab/xrfeitoria
"""
>>> python -m samples.unreal.03_basic_render

This is a script to render a preset level in unreal.
"""
from pathlib import Path

import xrfeitoria as xf
from xrfeitoria.data_structure.models import RenderPass

from ..config import unreal_exec, unreal_project
from ..utils import setup_logger

root = Path(__file__).parents[2].resolve()
# output_path = '~/xrfeitoria/output/samples/unreal/{file_name}'
output_path = root / 'output' / Path(__file__).relative_to(root).with_suffix('')
log_path = output_path / 'blender.log'

seq_name = 'seq_preset'


def main(debug=False, background=False):
    logger = setup_logger(debug=debug, log_path=log_path)
    xf_runner = xf.init_unreal(exec_path=unreal_exec, project_path=unreal_project, background=background)

    # There are many assets already made by others, including levels, sequences, cameras, meshes, etc.
    # Using xrfeitoria enables convenient utilization of these assets.

    # open a pre-made sequence, which has a corresponding level
    # the sequence data is under /Game/XRFeitoriaUnreal/Sequences/{seq_name}_data
    with xf_runner.Sequence.open(seq_name=seq_name) as seq:
        # use the camera in the level to render
        camera = xf_runner.Camera('Camera')
        camera_name = camera.name

        # clear the render jobs in the renderer
        xf_runner.Renderer.clear()
        # add render job to renderer
        seq.add_to_renderer(
            output_path=output_path,
            render_passes=[RenderPass('img', 'png')],
            resolution=[1280, 720],
        )

    # render all jobs
    xf_runner.render()

    logger.info('🎉 [bold green]Success!')
    input('Press Any Key to Exit...')

    # close the unreal process
    xf_runner.close()

    output_img = output_path / seq_name / 'img' / camera_name / '0000.png'
    if output_img.exists():
        logger.info(f'Check the output in "{output_img.as_posix()}"')


if __name__ == '__main__':
    from ..utils import parse_args

    args = parse_args()

    main(debug=args.debug, background=args.background)

samples/unreal/03_basic_render.py
openxrlab/xrfeitoria
"""
>>> python -m samples.unreal.04_staticmesh_render

This is a script to render static meshes in unreal.
"""
import math
from pathlib import Path

import xrfeitoria as xf
from xrfeitoria.data_structure.models import RenderPass
from xrfeitoria.data_structure.models import SequenceTransformKey as SeqTransKey

from ..config import assets_path, unreal_exec, unreal_project
from ..utils import setup_logger, visualize_vertices

root = Path(__file__).parents[2].resolve()
# output_path = '~/xrfeitoria/output/samples/unreal/{file_name}'
output_path = root / 'output' / Path(__file__).relative_to(root).with_suffix('')
log_path = output_path / 'blender.log'

# pre-defined level
default_level_path = '/Game/Levels/Default'
level_path = '/Game/Levels/Playground'
seq_1_name = 'seq_staticmesh_1'
seq_2_name = 'seq_staticmesh_2'


def main(debug=False, background=False):
    logger = setup_logger(debug=debug, log_path=log_path)
    xf_runner = xf.init_unreal(exec_path=unreal_exec, project_path=unreal_project, background=background)

    # duplicate the level to a new level
    xf_runner.utils.open_level(default_level_path)  # in case {dst_level_path} already opened
    dst_level_path = f'{level_path}_staticmesh'

    xf_runner.utils.duplicate_asset(src_path=level_path, dst_path=dst_level_path, replace=True)
    xf_runner.utils.open_level(dst_level_path)

    # Usually, assets are pre-imported from files before rendering.
    koupen_chan_path = xf_runner.utils.import_asset(path=assets_path['koupen_chan'])
    # Or you can import assets from files directly to the current level.
    actor_bunny = xf_runner.Actor.import_from_file(file_path=assets_path['bunny'])

    # If you want to place assets and cameras, mostly you need to set them manually or based on logic.
    # It's easier to get more infos of the assets using blender, please refer to the blender samples if interested.
    # Hardcode: In this example, we hardcode the transform of the bunny and the camera.
    actor_bunny.scale = (10, 10, 10)  # set scale to 10x
    actor_bunny.rotation = (0, 0, 90)  # rotate 90 degrees around z axis

    # add a new camera looking at the bunny
    camera = xf_runner.Camera.spawn(location=(-5, 0, 2))
    camera.look_at(actor_bunny.location)

    # save the level
    xf_runner.utils.save_current_level()

    # clear the renderer
    xf_runner.Renderer.clear()

    # If you want to add new objects(cameras, actors, shapes) with transform keys, use sequence.
    ####################################################
    ######## Create a new sequence with level   ########
    ####################################################
    with xf_runner.Sequence.new(level=dst_level_path, seq_name=seq_1_name, replace=True) as seq:
        # show the sequence in unreal editor
        seq.show()
        # use the `camera` in level to render
        seq.use_camera(camera=camera, location=(-5, 0, 2))

        # use the `bunny` in level
        seq.use_actor(actor_bunny, scale=(10, 10, 10), rotation=(0, 0, 90), stencil_value=1)

        # add render job to renderer
        seq.add_to_renderer(
            output_path=output_path,
            render_passes=[
                RenderPass('img', 'png'),
                RenderPass('mask', 'exr'),  # in png format, annotations would apply gamma correction (2.2)
            ],
            console_variables={'r.MotionBlurQuality': 0},  # disable motion blur
            resolution=[1280, 720],
            export_vertices=True,
        )

    ####################################################
    ##### Create another new sequence with level   #####
    ####################################################
    with xf_runner.Sequence.new(level=dst_level_path, seq_name=seq_2_name, seq_length=6, replace=True) as seq:
        # show the sequence in unreal editor
        seq.show()

        # define transform keys of rotating around the bunny
        transform_keys = []
        bunny_location = actor_bunny.location
        for idx in range(6):
            radius = 2.0
            azimuth = 360 / 6 * idx
            azimuth_radians = math.radians(azimuth)

            x = radius * math.cos(azimuth_radians) + bunny_location[0]
            y = radius * math.sin(azimuth_radians) + bunny_location[1]
            z = 0.0 + bunny_location[2]

            scale = 0.05
            transform_keys.append(
                SeqTransKey(frame=idx, location=(x, y, z), scale=(scale, scale, scale), interpolation='AUTO')
            )

        # spawn koupen_chan with transform keys to the sequence
        actor_kc = seq.spawn_actor_with_keys(koupen_chan_path, transform_keys=transform_keys, stencil_value=2)

        # use the `camera` in level to render
        seq.use_camera(camera=camera, location=(-5, 0, 2))

        # use the `bunny` in level
        seq.use_actor(actor_bunny, scale=(10, 10, 10), rotation=(0, 0, 90), stencil_value=3)

        # add render job to renderer
        seq.add_to_renderer(
            output_path=output_path,
            render_passes=[
                RenderPass('img', 'png'),
                RenderPass('mask', 'exr'),  # in png format, annotations would apply gamma correction (2.2)
            ],
            console_variables={'r.MotionBlurQuality': 0},  # disable motion blur
            resolution=[1280, 720],
            export_vertices=True,
        )

        # collect the names for visualization
        camera_name = camera.name
        actor_names = [actor_bunny.name, actor_kc.name]

    # render jobs
    xf_runner.render()

    logger.info('🎉 [bold green]Success!')
    input('Press Any Key to Exit...')

    xf_runner.close()

    # visualize vertices of the actors
    visualize_vertices(
        camera_name=camera_name,
        actor_names=actor_names,
        seq_output_path=output_path / seq_2_name,
        frame_idx=0,
    )


if __name__ == '__main__':
    from ..utils import parse_args

    args = parse_args()

    main(debug=args.debug, background=args.background)

samples/unreal/04_staticmesh_render.py
openxrlab/xrfeitoria
"""
>>> python -m samples.unreal.05_skeletalmesh_render

This is a script to render skeletal meshes in unreal.
"""
from pathlib import Path

import xrfeitoria as xf
from xrfeitoria.data_structure.models import RenderPass

from ..config import assets_path, unreal_exec, unreal_project
from ..utils import setup_logger

root = Path(__file__).parents[2].resolve()
# output_path = '~/xrfeitoria/output/samples/unreal/{file_name}'
output_path = root / 'output' / Path(__file__).relative_to(root).with_suffix('')
log_path = output_path / 'blender.log'

level_path = '/Game/Levels/Playground'  # pre-defined level
seq_name = 'seq_skeletalmesh'


def main(debug=False, background=False):
    logger = setup_logger(debug=debug, log_path=log_path)
    xf_runner = xf.init_unreal(exec_path=unreal_exec, project_path=unreal_project, background=background)

    # import actor from file and its animation
    SMPL_XL_path = xf_runner.utils.import_asset(path=assets_path['SMPL_XL'])
    animation_path = xf_runner.utils.import_anim(
        path=assets_path['motion_1'], skeleton_path=f'{SMPL_XL_path}_Skeleton'
    )[0]

    with xf_runner.Sequence.new(seq_name=seq_name, level=level_path, seq_length=200, replace=True) as seq:
        seq.show()

        # add a camera in sequence to render
        camera = seq.spawn_camera(location=(0, -2, 1), rotation=(0, 0, 90), fov=90)
        camera_name = camera.name

        actor = seq.spawn_actor(
            SMPL_XL_path,
            anim_asset_path=animation_path,
            location=(0, 0, 0),
            rotation=(0, 0, 180),
            scale=(100, 100, 100),
            stencil_value=1,
        )

        # add render job to renderer
        seq.add_to_renderer(
            output_path=output_path,
            render_passes=[
                RenderPass('img', 'png'),
                RenderPass('mask', 'exr'),  # in png format, annotations would apply gamma correction (2.2)
            ],
            resolution=[1280, 720],
        )

    # render all jobs
    xf_runner.render()

    logger.info('🎉 [bold green]Success!')
    input('Press Any Key to Exit...')

    xf_runner.close()

    output_img = output_path / seq_name / 'img' / camera_name / '0000.png'
    if output_img.exists():
        logger.info(f'Check the output in "{output_img.as_posix()}"')


if __name__ == '__main__':
    from ..utils import parse_args

    args = parse_args()

    main(debug=args.debug, background=args.background)

samples/unreal/05_skeletalmesh_render.py
openxrlab/xrfeitoria
"""
>>> python -m samples.unreal.06_custom_usage

This is a script to demonstrate the custom use of xrfeitoria.
"""
from pathlib import Path

import xrfeitoria as xf
from xrfeitoria.rpc import remote_unreal

from ..config import unreal_exec, unreal_project
from ..utils import setup_logger

root = Path(__file__).parents[2].resolve()
# output_path = '~/xrfeitoria/output/samples/unreal/{file_name}'
output_path = root / 'output' / Path(__file__).relative_to(root).with_suffix('')
log_path = output_path / 'blender.log'


@remote_unreal()
def add_cubes_in_unreal():
    """This function will be executed in unreal.

    Write your own unreal code here, and decorate it with `@remote_unreal`. The code of
    the functions decorated with `@remote_unreal` would be sent to unreal by RPC
    protocols.
    """
    import unreal

    # add 100 cubes marching in a 10x10 grid
    cube = unreal.load_asset('/Engine/BasicShapes/Cube')
    size = 0.2
    for i in range(10):
        for j in range(10):
            _location = unreal.Vector(i, j, size / 2)
            # convert from meters to centimeters
            _location *= 100.0
            _actor = unreal.EditorLevelLibrary().spawn_actor_from_object(object_to_use=cube, location=_location)
            _actor.set_actor_label(f'cube_{i:02d}_{j:02d}')
            _actor.set_actor_scale3d((size, size, size))


def main(debug=False, background=False):
    logger = setup_logger(debug=debug, log_path=log_path)
    xf_runner = xf.init_unreal(exec_path=unreal_exec, project_path=unreal_project, background=background)

    # The function `add_cubes_in_unreal` decorated with `@remote_unreal` will be executed in blender.
    add_cubes_in_unreal()

    logger.info('🎉 [bold green]Success!')
    input('Press Any Key to Exit...')

    xf_runner.close()


if __name__ == '__main__':
    from ..utils import parse_args

    args = parse_args()

    main(debug=args.debug, background=args.background)

samples/unreal/06_custom_usage.py
openxrlab/xrfeitoria
import os
import time
from contextlib import contextmanager
from functools import partial
from pathlib import Path
from typing import List

import numpy as np
from loguru import logger

import xrfeitoria as xf
from xrfeitoria.camera.camera_parameter import CameraParameter
from xrfeitoria.utils import projector
from xrfeitoria.utils.tools import Logger

try:
    from .config import blender_exec, unreal_exec, unreal_project
except ModuleNotFoundError:
    logger.error('config.py file not found. Run `python -m samples.setup` or `python -m tests.setup` to create it.')
    raise SystemExit(1)


def visualize_vertices(camera_name, actor_names: List[str], seq_output_path: Path, frame_idx=0):
    """Visualize vertices in the first frame of the first sequence.

    Args:
        camera_name (str): camera name
        actor_names (List[str]): actor names
        seq_name (str, optional): sequence name. Defaults to {seq_name}
    """
    # TODO: move to utils.viewer
    try:
        from PIL import Image
    except ImportError:
        logger.error('[red]Please install "Pillow" to visualize vertices:[/red] [bold]pip install Pillow[/bold]')
        exit(1)

    logger.info('Visualizing vertices')
    # fixed file structure
    img_path = seq_output_path / 'img' / camera_name / f'{frame_idx:04d}.png'
    camera_param_json = seq_output_path / 'camera_params' / f'{camera_name}.json'

    # load img and camera parameters
    img = np.array(Image.open(img_path.as_posix()))
    camera_param = CameraParameter.fromfile(camera_param_json.as_posix())

    # load and draw vertices looping over actors
    vis_dir = seq_output_path / 'vertices_vis'
    vis_dir.mkdir(exist_ok=True, parents=True)
    colors = [
        (255, 0, 0),
        (0, 255, 0),
        (0, 0, 255),
    ]
    for idx, actor_name in enumerate(actor_names):
        vert_path = seq_output_path / 'vertices' / f'{actor_name}.npz'
        verts = np.load(vert_path, allow_pickle=True)['verts']
        # draw vertices on image
        img = projector.draw_points3d(verts[frame_idx], camera_param, image=img, color=colors[idx])

    save_path = vis_dir / f'{frame_idx:04d}-overlap.png'
    Image.fromarray(img).save(save_path.as_posix())
    logger.info(f'Original image: "{img_path.as_posix()}"')
    logger.info(f'Overlap image saved to: "{save_path.as_posix()}"')


def setup_logger(debug: bool = False, log_path: str = None, reload: bool = True):
    if reload:
        os.environ['RPC_RELOAD'] = '1'  # reload modules on every call
    logger = Logger.setup_logging(level='DEBUG' if debug else 'INFO', log_path=log_path)
    if debug:
        os.environ['RPC_DEBUG'] = '1'
    return logger


@contextmanager
def __timer__(step_name: str):
    t1 = time.time()
    yield
    t2 = time.time()
    logger.info(f'⌛ {step_name} executed in {(t2-t1):.4f} s')


def parse_args():
    import argparse

    args = argparse.ArgumentParser()
    args.add_argument('--engine', '-e', choices=['unreal', 'blender'], default='unreal', help='engine to run')
    args.add_argument('--debug', action='store_true', help='log debug info')
    args.add_argument('--background', '-b', action='store_true', help='run in background')
    args = args.parse_args()
    return args


_init_blender = partial(
    xf.init_blender,
    exec_path=blender_exec,
    new_process=False,
    replace_plugin=True,
    dev_plugin=False,
)
_init_unreal = partial(
    xf.init_unreal,
    exec_path=unreal_exec,
    project_path=unreal_project,
    new_process=False,
    replace_plugin=True,
    dev_plugin=False,
)

samples/utils.py
openxrlab/xrfeitoria
from .utils_logger import logger  # isort:skip
from . import constants, operators, properties, ui  # isort:skip

bl_info = {
    'name': 'XRFeitoriaBpy',
    'author': 'OpenXRLab',
    'version': (0, 5, 1),
    'blender': (3, 3, 0),
    'category': 'Tools',
}


def register():
    operators.register()
    properties.register()
    ui.register()


def unregister():
    operators.unregister()
    properties.unregister()
    ui.unregister()

src/XRFeitoriaBpy/__init__.py
openxrlab/xrfeitoria
from enum import Enum
from pathlib import Path
from typing import List, Optional, Tuple, Type, TypeVar, Union

import bpy

default_level_blender = 'XRFeitoria'

Tuple3 = Tuple[float, float, float]
PathLike = Union[str, Path]


class EnumBase(str, Enum):
    @classmethod
    def get(cls, name_or_value: str) -> 'EnumBase':
        """Get enum member by name or value.

        Args:
            name_or_value (str): Name or value of the enum member.

        Returns:
            EnumBase: Self
        """
        try:
            return cls[name_or_value]
        except KeyError:
            for member in cls:
                if member.value == name_or_value:
                    return member
            raise ValueError(f'{name_or_value} is not supported in {cls.__name__}')


class DeviceTypeEnum(EnumBase):
    """Device type enum."""

    cuda = 'CUDA'
    optix = 'OPTIX'


class ImportFileFormatEnum(EnumBase):
    fbx = 'fbx'
    obj = 'obj'
    abc = 'abc'
    # TODO: Add more


class ImageFileFormatEnum(EnumBase):
    png = 'PNG'
    bmp = 'BMP'
    jpg = 'JPEG'
    jpeg = 'JPEG'
    exr = 'OPEN_EXR'
    open_exr = 'OPEN_EXR'


class RenderEngineEnum(EnumBase):
    cycles = 'CYCLES'
    eevee = 'BLENDER_EEVEE'
    workbench = 'BLENDER_WORKBENCH'


class RenderMethodEnum(EnumBase):
    default = 0
    multiview_in_step = 1
    singleview_in_step = 2


class RenderLayerEnum(EnumBase):
    """Render layer enum."""

    img = 'Image'
    mask = 'IndexOB'
    depth = 'Depth'
    denoising_depth = 'Denoising Depth'
    flow = 'Vector'
    normal = 'Normal'
    diffuse = 'DiffCol'
    # TODO: add more


class BSDFNodeLinkEnum(EnumBase):
    """Shader node link enum."""

    diffuse = 'Base Color'
    normal = 'Normal'
    roughness = 'Roughness'
    # TODO: Add more
    # specular = 'specular'
    # metallic = 'metallic'
    # emission = 'emission'
    # alpha = 'alpha'
    # ambient_occlusion = 'ambient_occlusion'


# class RenderPassesData(BaseModel):
#     render_layer: RenderLayerEnum
#     image_format: ImageFileFormatEnum


# class CameraParams(BaseModel):
#     K: Tuple[Tuple3, Tuple3, Tuple3]
#     R: Tuple[Tuple3, Tuple3, Tuple3]
#     T: Tuple3

src/XRFeitoriaBpy/constants.py
openxrlab/xrfeitoria
import json
import math
from contextlib import contextmanager
from pathlib import Path
from typing import Any, Dict, List, Literal, Optional, Tuple, Union

import bpy
import numpy as np

from .. import logger
from ..constants import Tuple3


class XRFeitoriaBlenderFactory:
    #####################################
    ######## Scene & Collection #########
    #####################################

    # --------- NEW  -----------
    def new_collection(name: str, replace: bool = False) -> 'bpy.types.Collection':
        """Create a new collection.

        Args:
            name (str): Name of the new collection.
            replace (bool, optional): Replace the exist same-name collection. Defaults to False.

        Raises:
            ValueError: If the collection already exists and 'repalce=False'.

        Returns:
            bpy.types.Collection: New collection.
        """
        if replace:
            XRFeitoriaBlenderFactory.delete_sequence(name)
        if name in bpy.data.collections:
            raise ValueError(f"Collection '{name}' already exists.")
        collection = bpy.data.collections.new(name)
        collection.use_fake_user = True
        return collection

    def new_scene(name: str, replace: bool = False) -> 'bpy.types.Scene':
        """Create a new scene.

        Args:
            name (str): Name of the new scene.
            replace (bool, optional): Replace the exist same-name scene. Defaults to False.

        Raises:
            ValueError:  If the scene already exists and 'replace=False'.

        Returns:
            bpy.types.Scene: New scene.
        """
        if replace:
            XRFeitoriaBlenderFactory.delete_sequence(name)
        if name in bpy.data.scenes:
            raise ValueError(f"Scene '{name}' already exists.")
        scene = bpy.data.scenes.new(name)
        return scene

    # --------- GET  -----------
    def get_collection(name: str) -> 'bpy.types.Collection':
        """Get a collection by name.

        Args:
            name (str): Name of the collection.

        Raises:
            ValueError: If the collection does not exists.

        Returns:
            bpy.types.Collection: The collection.
        """
        if name not in bpy.data.collections:
            raise ValueError(f"Collection '{name}' does not exists in this blend file.")
        return bpy.data.collections[name]

    def get_scene(name: str) -> 'bpy.types.Scene':
        """Get a scene by name.

        Args:
            name (str): Name of the scene.

        Raises:
            ValueError: If the scene does not exists.

        Returns:
            bpy.types.Scene: The scene.
        """
        if name not in bpy.data.scenes:
            raise ValueError(f"Scene '{name}' does not exists in this blend file.")
        return bpy.data.scenes[name]

    def get_active_collection(scene: 'bpy.types.Scene' = None) -> 'bpy.types.Collection':
        """Get the active collection.

        Returns:
            bpy.types.Collection: Active collection.
        """
        scene = XRFeitoriaBlenderFactory.get_active_scene() if not scene else scene
        sequence_collection = scene.level_properties.active_sequence
        if sequence_collection:
            return sequence_collection
        elif scene.name in scene.collection.children.keys():
            level_collection = XRFeitoriaBlenderFactory.get_collection(scene.name)
            return level_collection
        else:
            return scene.collection

    def set_level_properties(scene: 'bpy.types.Scene', active_seq: 'bpy.types.Collection') -> None:
        """Set the active sequence.

        Args:
            scene (bpy.types.Scene): Scene.
            active_seq (bpy.types.Collection): Collection of the active sequence.
        """
        scene.level_properties.active_sequence = active_seq

    def set_sequence_properties(
        collection: 'bpy.types.Collection',
        level: 'bpy.types.Scene',
        fps: int,
        frame_start: int,
        frame_end: int,
        frame_current: int,
    ) -> None:
        """Set the sequence properties.

        Args:
            collection (bpy.types.Collection): Collection of the sequence.
            level (bpy.types.Scene): Level(scene) of the sequence.
            fps (int): FPS of the sequence.
            frame_start (int): Start frame of the sequence.
            frame_end (int): End frame of the sequence.
            frame_current (int): Current frame of the sequence.
        """
        collection.sequence_properties.level = level
        collection.sequence_properties.fps = fps
        collection.sequence_properties.frame_start = frame_start
        collection.sequence_properties.frame_end = frame_end
        collection.sequence_properties.frame_current = frame_current

    def get_sequence_properties(collection: 'bpy.types.Collection') -> 'Tuple[bpy.types.Scene, int, int, int, int]':
        """Get the sequence properties.

        Args:
            collection (bpy.types.Collection): Collection of the sequence.

        Returns:
            Tuple[bpy.types.Scene, int, int, int, int]:
                The level(scene), FPS of the sequence, Start frame of the sequence, End frame of the sequence, Current frame of the sequence.
        """
        level = collection.sequence_properties.level
        fps = collection.sequence_properties.fps
        frame_start = collection.sequence_properties.frame_start
        frame_end = collection.sequence_properties.frame_end
        frame_current = collection.sequence_properties.frame_current
        return level, fps, frame_start, frame_end, frame_current

    def open_sequence(seq_name: str) -> 'bpy.types.Scene':
        """Open the given sequence.

        Args:
            seq_name (str): Name of the sequence.
        """
        # get sequence collection
        seq_collection = XRFeitoriaBlenderFactory.get_collection(seq_name)
        # get sequence properties
        level_scene, fps, frame_start, frame_end, frame_current = XRFeitoriaBlenderFactory.get_sequence_properties(
            collection=seq_collection
        )
        # deactivate all cameras in this level
        for obj in level_scene.objects:
            if obj.type == 'CAMERA':
                XRFeitoriaBlenderFactory.set_camera_activity(camera_name=obj.name, scene=level_scene, active=False)

        # clear all sequences in this level
        for collection in level_scene.collection.children:
            if XRFeitoriaBlenderFactory.is_sequence_collecion(collection):
                XRFeitoriaBlenderFactory.unlink_collection_from_scene(collection=collection, scene=level_scene)

        # link sequence(seq_collection) to level(level_scene)
        XRFeitoriaBlenderFactory.link_collection_to_scene(collection=seq_collection, scene=level_scene)

        # assign sequence properties to level scene
        level_scene.frame_start = frame_start
        level_scene.frame_end = frame_end
        level_scene.frame_current = frame_current
        level_scene.render.fps = fps

        # set cameras in this sequence to active
        for obj in seq_collection.objects:
            if obj.type == 'CAMERA':
                XRFeitoriaBlenderFactory.set_camera_activity(camera_name=obj.name, scene=level_scene, active=True)

        # set level cameras been used in this sequence to active
        for camera_data in seq_collection.sequence_properties.level_cameras:
            camera = camera_data.camera
            XRFeitoriaBlenderFactory.set_camera_activity(camera_name=camera.name, scene=level_scene, active=True)
            camera.data.angle = camera_data.sequence_fov
            if camera_data.sequence_animation:
                XRFeitoriaBlenderFactory.apply_action_to_actor(action=camera_data.sequence_animation, actor=camera)

        # set level actors' properties
        for actor_data in seq_collection.sequence_properties.level_actors:
            actor = actor_data.actor
            actor.pass_index = actor_data.sequence_stencil_value
            if actor_data.sequence_animation:
                XRFeitoriaBlenderFactory.apply_action_to_actor(action=actor_data.sequence_animation, actor=actor)

        # set scene to active
        XRFeitoriaBlenderFactory.set_scene_active(level_scene)
        # set collection to active
        XRFeitoriaBlenderFactory.set_collection_active(seq_collection)

        return level_scene

    def close_sequence() -> None:
        """Close the active sequence."""
        level_scene = XRFeitoriaBlenderFactory.get_active_scene()

        # deactivate all cameras in this level
        for obj in level_scene.objects:
            if obj.type == 'CAMERA':
                XRFeitoriaBlenderFactory.set_camera_activity(camera_name=obj.name, scene=level_scene, active=False)

        # clear all sequences in this level
        for collection in level_scene.collection.children:
            if XRFeitoriaBlenderFactory.is_sequence_collecion(collection):
                # unlink the sequence from the level
                XRFeitoriaBlenderFactory.unlink_collection_from_scene(collection=collection, scene=level_scene)
                # restore level actors' properties
                for actor_data in collection.sequence_properties.level_actors:
                    actor = actor_data.actor
                    actor.pass_index = actor_data.level_stencil_value
                    if actor_data.level_animation:
                        XRFeitoriaBlenderFactory.apply_action_to_actor(action=actor_data.level_animation, actor=actor)
                    else:
                        actor.animation_data_clear()
                    actor.location = actor_data.location
                    actor.rotation_euler = actor_data.rotation
                    actor.scale = actor_data.scale
                # restore level cameras' properties
                for camera_data in collection.sequence_properties.level_cameras:
                    camera = camera_data.camera
                    camera.animation_data_clear()
                    camera.data.angle = camera_data.level_fov
                    camera.location = camera_data.location
                    camera.rotation_euler = camera_data.rotation
                    camera.scale = camera_data.scale

    def tag_sequence_collection(collection: 'bpy.types.Collection') -> None:
        """Tag the given collection as the sequence collection.

        Args:
            collection (bpy.types.Collection): Collection to be tag as the sequence collection.
        """
        collection.color_tag = 'COLOR_01'

    def is_sequence_collecion(collection: 'bpy.types.Collection') -> bool:
        """Check if the given collection is a sequence collection.

        Args:
            collection (bpy.types.Collection): Collection to be checked.

        Returns:
            bool: True if the collection is a sequence collection, else False.
        """
        if collection.color_tag == 'COLOR_01':
            return True
        else:
            return False

    def get_active_scene() -> 'bpy.types.Scene':
        """Get the active scene.

        Returns:
            bpy.types.Scene: The active scene.
        """
        return bpy.context.scene

    def get_keys_range(scene: 'bpy.types.Scene') -> 'Tuple[int, int]':
        """Get the max keyframe range of all the objects in the given scene.

        Args:
            scene (bpy.types.Scene): The scene to get the max keyframe range.

        Returns:
            Tuple[int, int]: Max keyframe range.
        """
        key_start = 1e9
        key_end = 0
        for obj in scene.objects:
            key_start = min(key_start, XRFeitoriaBlenderFactory.get_obj_keys_range(obj)[0])
            key_end = max(key_end, XRFeitoriaBlenderFactory.get_obj_keys_range(obj)[1])
        if key_start > key_end:
            key_start, key_end = 0, 0
        return int(key_start), int(key_end)

    def get_obj_keys_range(obj: 'bpy.types.Object') -> 'Tuple[int, int]':
        """Get the keyframe range of the given object.

        Args:
            obj (bpy.types.Object): The object to get the keyframe range.

        Returns:
            Tuple[int, int]: Keyframe range of the object.
        """
        key_start = 1e9
        key_end = 0

        if obj.animation_data and obj.animation_data.action:
            key_start = min(key_start, obj.animation_data.action.fcurves[0].range()[0])
            key_end = max(key_end, obj.animation_data.action.fcurves[0].range()[1])
        elif obj.type == 'Armature' and obj.data.animation_data and obj.data.animation_data.action:
            key_start = min(key_start, obj.data.animation_data.action.fcurves[0].range()[0])
            key_end = max(key_end, obj.data.animation_data.action.fcurves[0].range()[1])

        if key_start > key_end:
            key_start, key_end = 0, 0

        return int(key_start), int(key_end)

    def get_frame_range(scene: 'bpy.types.Scene') -> Tuple[int, int]:
        """Get the frame range of the given scene.

        Args:
            scene (bpy.types.Scene): The scene to get the frame range.

        Returns:
            Tuple[int, int]: Frame range of the scene.
        """
        return scene.frame_start, scene.frame_end

    def get_scene_and_collection_for_new_object(
        collection_name: str = None,
    ) -> Tuple['bpy.types.Scene', 'bpy.types.Collection']:
        """Get scene and collection for adding new object by given 'collection_name'.

        Returns:
            Tuple['bpy.types.Scene', 'bpy.types.Collection']: Scene and collection for adding new object
        """
        # get scene
        scene = XRFeitoriaBlenderFactory.get_active_scene()

        # get collection
        if collection_name:
            collection = XRFeitoriaBlenderFactory.get_collection(collection_name)
            if collection_name not in scene.collection.children.keys():
                raise RuntimeError(
                    f"Collection '{collection_name}' does not exist in the current active scene, please link it to active scene first."
                )
        else:
            collection = XRFeitoriaBlenderFactory.get_active_collection()

        # set 'collection' and 'scene' active
        XRFeitoriaBlenderFactory.set_scene_active(scene)
        XRFeitoriaBlenderFactory.set_collection_active(collection)

        return scene, collection

    def get_rotation_to_look_at(location: 'Tuple3', target: 'Tuple3') -> 'Tuple3':
        """Get the rotation of an object to look at another object.

        Args:
            location (Tuple3): Location of the object.
            target (Tuple3): Location of the target.

        Returns:
            Tuple3: Rotation of the object.
        """
        import mathutils

        direction = mathutils.Vector(target) - mathutils.Vector(location)
        rot_quat = direction.to_track_quat('-Z', 'Y')
        return rot_quat.to_euler()

    def get_all_object_in_seq(seq_name: str, obj_type: Literal['MESH', 'ARMATURE', 'CAMERA']) -> List['str']:
        """Get all objects in the sequence with the given type, and return their names.

        Args:
            obj_type (Literal['MESH', 'ARMATURE', 'CAMERA']): Type of the objects.

        Returns:
            List[str]: Names of the objects.
        """
        seq_collection = XRFeitoriaBlenderFactory.get_collection(seq_name)
        names = [obj.name for obj in seq_collection.objects if obj.type == obj_type]
        return names

    def get_all_object_in_current_level(obj_type: 'Literal["MESH", "ARMATURE", "CAMERA"]') -> 'List[str]':
        """Get all objects in active scene with the given type, and return their names.

        Args:
            obj_type (Literal['MESH', 'ARMATURE', 'CAMERA']): Type of the objects.

        Returns:
            List[str]: Names of the objects.
        """
        return [obj.name for obj in bpy.context.scene.objects if obj.type == obj_type]

    # --------- SET  -----------
    def set_scene_active(scene: 'bpy.types.Scene') -> None:
        """Set the given scene as the active scene.

        Args:
            scene (bpy.types.Scene): The scene to be set as the active scene.
        """
        bpy.context.window.scene = scene

    def set_collection_active(collection: 'bpy.types.Collection') -> None:
        """Set the given collection as the active collection.

        Args:
            collection (bpy.types.Collection): The collection to be set as the active collection.
        """
        layer_collection = bpy.context.view_layer.layer_collection.children[collection.name]
        bpy.context.view_layer.active_layer_collection = layer_collection

    def set_frame_range(scene: 'bpy.types.Scene', start: int, end: int) -> None:
        """Set the frame range of the given scene.

        Args:
            scene (bpy.types.Scene): Scene.
            start (int): Start frame.
            end (int): End frame.
        """
        scene.frame_start = start
        scene.frame_end = end
        sequence_collection = scene.level_properties.active_sequence
        if sequence_collection:
            sequence_collection.sequence_properties.frame_start = start
            sequence_collection.sequence_properties.frame_end = end

    def set_frame_current(scene: 'bpy.types.Scene', frame: int) -> None:
        """Set the current frame of the given scene.

        Args:
            scene (bpy.types.Scene): Scene.
            frame (int): Current frame.
        """
        scene.frame_current = frame
        sequence_collection = scene.level_properties.active_sequence
        if sequence_collection:
            sequence_collection.sequence_properties.frame_current = frame

    def set_hdr_map(
        scene: 'bpy.types.Scene',
        hdr_map_path: str,
    ):
        """Set HDRI background in blender.

        Args:
            hdr_file_path (Path): filepath of the HDRI image
        """

        # Get the environment node tree of the current scene
        scene_world = bpy.data.worlds.new(name=scene.name)
        scene.world = scene_world
        scene.world.use_nodes = True
        node_tree = scene.world.node_tree
        tree_nodes = node_tree.nodes
        tree_nodes.clear()  # Clear all nodes

        node_background = tree_nodes.new(type='ShaderNodeBackground')  # Add Background node
        node_environment = tree_nodes.new('ShaderNodeTexEnvironment')  # Add Environment Texture node
        node_environment.image = bpy.data.images.load(hdr_map_path)  # Load and assign the image to the node property
        node_environment.location = -300, 0

        node_output = tree_nodes.new(type='ShaderNodeOutputWorld')  # Add Output node
        node_output.location = 200, 0

        # Link all nodes
        links = node_tree.links
        links.new(node_environment.outputs['Color'], node_background.inputs['Color'])
        links.new(node_background.outputs['Background'], node_output.inputs['Surface'])

    def set_multiview(scene: 'bpy.types.Scene') -> None:
        """Set the multiview of the scene.

        Args:
            scene (bpy.types.Scene): Scene.
        """
        if not scene.render.use_multiview:
            scene.render.use_multiview = True
            scene.render.views_format = 'MULTIVIEW'
            scene.render.views[0].use = False
            scene.render.views[1].use = False

    # --------- LINK  -----------
    def link_collection_to_scene(
        collection: 'bpy.types.Collection',
        scene: 'bpy.types.Scene',
    ) -> None:
        """Link a collection to a scene.

        Args:
            collection (bpy.types.Collection): Collection.
            scene (bpy.types.Scene): Scene.
        """
        if collection.name not in scene.collection.children.keys():
            scene.collection.children.link(collection)

    def unlink_collection_from_scene(
        collection: 'bpy.types.Collection',
        scene: 'bpy.types.Scene',
    ) -> None:
        """Unlink a collection from a scene.

        Args:
            collection (bpy.types.Collection): Collection.
            scene (bpy.types.Scene): Scene.
        """
        if collection.name in scene.collection.children.keys():
            scene.collection.children.unlink(collection)

    # --------- DELETE  -----------
    def delete_sequence(seq_name: str) -> None:
        """Delete a sequence. (Delete the scene and the collection named 'seq_name')

        Args:
            seq_name (str): Name of the sequence.
        """
        if seq_name in bpy.data.collections.keys():
            # delete all objects in seq_collection
            seq_collection = XRFeitoriaBlenderFactory.get_collection(name=seq_name)
            for obj in seq_collection.objects:
                bpy.data.objects.remove(obj)
            # delete seq_collection
            bpy.data.collections.remove(seq_collection)

    def delete_all():
        """Deletes all objects/collections/scenes in the current blend file."""
        # Delete all objects
        for obj in bpy.data.objects:
            bpy.data.objects.remove(obj)

        # Delete all collections
        for coll in bpy.data.collections:
            bpy.data.collections.remove(coll)

        # Delete all scenes except for the last one
        for scene in bpy.data.scenes[:-1]:
            bpy.data.scenes.remove(scene)

        # Delete all renderviews in the last scene
        for view in bpy.context.scene.render.views:
            if view.name == 'left' or view.name == 'right':
                continue
            bpy.context.scene.render.views.remove(view)

    # --------- EXPORT  -----------
    def export_vertices(scene: 'bpy.types.Scene', export_path: str, use_animation: bool = True) -> None:
        """Export all the meshes' vertices of the scene to npz files.

        Args:
            scene (bpy.types.Scene): Scene to export vertices.
            export_path (str): Path to save the export npz files. (Require a folder)
            use_animation (bool, optional): Export the vertices positions of every frame if 'use_animation=True'. Defaults to True.
        """
        export_path = Path(export_path).resolve()
        export_path.mkdir(exist_ok=True, parents=True)
        for obj in scene.objects:
            if obj.type == 'MESH':
                XRFeitoriaBlenderFactory.triangulate_mesh(obj=obj)
                obj_export_path = export_path / f'{obj.name}.npz'
                logger.info(f'Exporting vertices of {obj.name} to "{obj_export_path.as_posix()}"')
                XRFeitoriaBlenderFactory.export_obj_vertices(
                    objs=obj, scene=scene, export_path=obj_export_path, use_animation=use_animation
                )

    #####################################
    ############### Mesh ################
    #####################################
    def triangulate_mesh(obj: 'bpy.types.Object', quad_method='SHORTEST_DIAGONAL', ngon_method='BEAUTY') -> None:
        """Triangulate mesh, convert all quads to triangles.

        Args:
            obj (bpy.types.Object): The object to be triangulated.
            quad_method (str, optional): Quad Method, Method for splitting the quads into triangles. Defaults to 'SHORTEST_DIAGONAL'.
                Refer detailed explanation to https://docs.blender.org/api/current/bpy.ops.mesh.html#bpy.ops.mesh.quads_convert_to_tris.
            ngon_method (str, optional): N-gon Method, Method for splitting the n-gons into triangles. Defaults to 'BEAUTY'.
                Refer detailed explanation to https://docs.blender.org/api/current/bpy.ops.mesh.html#bpy.ops.mesh.quads_convert_to_tris.
        """
        bpy.ops.object.select_all(action='DESELECT')
        obj.select_set(True)
        bpy.context.view_layer.objects.active = obj
        bpy.ops.object.mode_set(mode='EDIT')
        bpy.ops.mesh.select_all(action='SELECT')
        bpy.ops.mesh.quads_convert_to_tris(quad_method=quad_method, ngon_method=ngon_method)
        bpy.ops.object.mode_set(mode='OBJECT')
        obj.select_set(False)

    def get_bmesh_vertices(obj: 'bpy.types.Object', in_world_coors: bool = False) -> 'Tuple[np.ndarray, np.ndarray]':
        """Get local vertex locations from mesh with armature.

        Args:
            obj (bpy.types.Object): The object.
            in_world_coors (bool, optional): Get the vertices in world coordinates.
                                            Defaults to False.

        Returns:
            np.ndarray: vertices. each row is a vertex with x,y,z coordinates
            np.ndarray: faces. each row is a face with vertex indices
        """
        import bmesh

        depsgraph = bpy.context.evaluated_depsgraph_get()
        bm = bmesh.new()
        bm.from_object(obj, depsgraph)
        bm.verts.ensure_lookup_table()
        bm.faces.ensure_lookup_table()

        verts = [v.co for v in bm.verts]
        if in_world_coors:
            # Get the world matrix
            mat = obj.matrix_world
            # Get the geometry in world coordinates
            verts = [mat @ v for v in verts]
        verts = np.array(verts)
        faces = np.array([[v.index for v in f.verts] for f in bm.faces])

        # convert to opencv coordinate
        blender2opencv = lambda x: np.stack([x[:, 0], -x[:, 2], x[:, 1]], axis=-1)
        verts = blender2opencv(verts)
        return verts, faces

    def get_verts_and_faces(objs: 'List[bpy.types.Object]'):
        """Get verts and faces from objs, in world space. Join all objects into a single
        mesh, and return the vertices and faces.

        Args:
            objs (List[bpy.types.Object]): Object(s) to export
        """
        _verts = []
        _faces = []
        for obj in objs:
            _v, _f = XRFeitoriaBlenderFactory.get_bmesh_vertices(obj, in_world_coors=True)
            _verts.append(_v)
            _faces.append(_f)
        _verts = np.concatenate(_verts, axis=0)
        _faces = np.concatenate(_faces, axis=0)
        return _verts, _faces

    def export_obj_vertices(
        objs: Union['bpy.types.Object', List['bpy.types.Object']],
        scene: 'bpy.types.Scene',
        export_path: str,
        use_animation: bool = True,
    ) -> None:
        """Export verts in world space to npz file, with animation (export N frame) if
        use_animation is True. export_path will be an npz file with a single key 'verts'
        which is a numpy array in shape of (N, V, 3), where N is the number of frames, V
        is the number of vertices.

        Args:
            objs_name (Union[str, List[str]]): Object(s) to export
            export_path (str): Output file path
            use_animation (bool, optional): Export animation. Defaults to True.
        """
        if isinstance(objs, str):
            objs = [objs]
        if isinstance(objs, bpy.types.Object):
            objs = [objs]

        export_path = Path(export_path).resolve()
        export_path.parent.mkdir(exist_ok=True, parents=True)
        if use_animation:
            verts = []
            faces = []
            for frame_idx in range(scene.frame_start, scene.frame_end + 1):
                scene.frame_current = frame_idx
                _verts, _faces = XRFeitoriaBlenderFactory.get_verts_and_faces(objs)
                verts.append(_verts)
                faces.append(_faces)
            scene.frame_current = scene.frame_start
            verts = np.stack(verts, axis=0)
            faces = np.stack(faces, axis=0)
        else:
            _verts, _faces = XRFeitoriaBlenderFactory.get_verts_and_faces(objs)
            verts = np.expand_dims(_verts, axis=0)
            faces = np.expand_dims(_faces, axis=0)
        np.savez_compressed(str(export_path), verts=verts, faces=faces)

    #####################################
    ############## Renderer #############
    #####################################
    def render(scene: str = None) -> None:
        """Render the scene with default settings.

        Args:
            scene (str): Scene.
        """
        # XRFeitoriaBlenderFactory.get_scene(scene).render.use_multiview = True
        if scene is None:
            scene = XRFeitoriaBlenderFactory.get_active_scene().name
        bpy.ops.render.render(write_still=True, animation=True, scene=scene)

    def add_render_passes(output_path: str, render_passes: 'List[Dict[str, str]]', scene: 'bpy.types.Scene') -> None:
        bpy.context.window.scene = scene
        for render_pass in render_passes:
            bpy.ops.wm.add_render_pass(
                output_path=output_path,
                render_layer=render_pass['render_layer'],
                format=render_pass['image_format'],
            )

    def set_background_transparent(scene: 'bpy.types.Scene', transparent: bool = False) -> None:
        """Set the background of the scene to transparent.

        Args:
            scene (bpy.types.Scene): Scene.
            transparent (bool, optional): Transprant image background. Defaults to False.
        """
        if transparent:
            scene.render.dither_intensity = 0.0
        scene.render.film_transparent = transparent

    def enable_gpu(
        device_type: 'Literal["CUDA", "OPTIX", "HIP", "ONEAPI", "METAL", "NONE"]' = 'CUDA',
        gpu_num: int = 1,
    ) -> None:
        """Enable GPU devices for rendering.

        Args:
            device_type (str, optional): Device type. Defaults to 'CUDA'.
            gpu_num (int, optional): Number of GPUs to be used. Defaults to 1.
        """
        preferences = bpy.context.preferences
        cycles_preferences = preferences.addons['cycles'].preferences
        device_orders = ['CUDA', 'OPTIX', 'HIP', 'ONEAPI', 'METAL', 'NONE']
        # newer than 3.0
        if bpy.app.version[0] >= 3:

            def get_devices(device_type: str) -> List[Any]:
                try:
                    devices = cycles_preferences.get_devices_for_type(compute_device_type=device_type)
                    return devices
                except ValueError:
                    pass
                return []

            cycles_preferences.refresh_devices()
            devices = get_devices(device_type)
            if len(devices) == 0:
                logger.warning(f'No `{device_type}` devices found. Trying to use other devices.')
                for device_type in device_orders:
                    devices = get_devices(device_type)
                    if len(devices) > 0:
                        break
        # for version 2.X
        else:
            cuda_devices, opencl_devices = cycles_preferences.get_devices()
            device_type = device_orders[0] if device_type is None else device_type
            if device_type == 'CUDA':
                devices = cuda_devices
            elif device_type == 'OPENCL':
                devices = opencl_devices
            else:
                raise RuntimeError('Unsupported device type')
        # enable devices
        activated_gpus = []
        logger.info(f'Found `{device_type}` devices: {[device.name for device in devices]}')
        cpu_devices = [device for device in devices if device.type == 'CPU']
        gpu_devices = [device for device in devices if device.type == device_type]
        # strip gpu_devices
        gpu_devices = gpu_devices[:gpu_num]
        for device in cpu_devices:
            logger.info(f'CPU Device enabled: {device.name}')
            device.use = True
        for device in gpu_devices:
            logger.info(f'GPU Device enabled: {device.name}')
            device.use = True
            activated_gpus.append(device.name)

        if len(activated_gpus) <= 0:
            logger.warning('Failed to enable GPU. Going to use CPU only')
        else:
            cycles_preferences.compute_device_type = device_type
            bpy.context.scene.cycles.device = 'GPU'

    def set_resolution(scene: 'bpy.types.Scene', resolution: Tuple[int, int]) -> None:
        """Set resolution of the render images in the given scene.

        Args:
            scene (bpy.types.Scene): Scene.
            resolution (Tuple[int, int]): Image resolution.
        """
        scene.render.resolution_x = resolution[0]
        scene.render.resolution_y = resolution[1]
        scene.render.resolution_percentage = 100

    def set_render_engine(engine: str, scene: 'bpy.types.Scene') -> None:
        """Set the render engine of the scene.

        Args:
            scene (bpy.types.Scene): Scene.
            engine (str, enum in ['CYCLES', 'BLENDER_EEVEE']): Render engine.
        """
        assert engine in ['CYCLES', 'BLENDER_EEVEE'], 'Invalid engine, must be `CYCLES` or `BLENDER_EEVEE`'
        scene.render.engine = engine

    def set_render_samples(render_samples: int, scene: 'bpy.types.Scene') -> None:
        """Set the render samples of the given scene.

        Args:
            render_samples (int): Render samples
            scene (bpy.types.Scene): Scene.
        """
        if scene.render.engine == 'CYCLES':
            scene.cycles.samples = render_samples
            # self.scene.cycles.time_limit = 20
        elif scene.render.engine == 'BLENDER_EEVEE':
            scene.eevee.taa_render_samples = render_samples

    #####################################
    ############### Camera ##############
    #####################################
    def get_resolution_from_blender() -> Tuple[int, int]:
        """Get resolution of the camera.

        Returns:
            Tuple[int, int]: Resolution.
        """
        scene = bpy.context.scene
        resolution_x_in_px = scene.render.resolution_x
        resolution_y_in_px = scene.render.resolution_y
        return resolution_x_in_px, resolution_y_in_px

    def get_3x3_K_matrix_from_blender(cam: 'bpy.types.Object') -> 'np.ndarray':
        """Get intrisic matrix (K) of a camera.

        Args:
            cam (bpy.types.Object): Camera.

        Returns:
            np.ndarray: K.
        """
        scene = bpy.context.scene
        fov = cam.data.angle

        resolution_x_in_px = scene.render.resolution_x
        resolution_y_in_px = scene.render.resolution_y

        focal = max(resolution_x_in_px, resolution_y_in_px) / 2 / math.tan(fov / 2)
        u_0 = resolution_x_in_px / 2
        v_0 = resolution_y_in_px / 2

        K = np.array(((focal, 0, u_0), (0, focal, v_0), (0, 0, 1)))
        return K

    def get_R_T_matrix_from_blender(cam: 'bpy.types.Object') -> 'Tuple[np.ndarray, np.ndarray]':
        """Get extrinsic parameters (R, T) of a camera.

        Args:
            cam (bpy.types.Object): Camera.

        Returns:
            Tuple[np.array, np.array]: R, T.
        """
        R_BlenderView_to_OpenCVView = np.diag([1, -1, -1])

        location, rotation = cam.matrix_world.decompose()[:2]
        R_BlenderView = rotation.to_matrix().transposed()

        T_BlenderView = -1.0 * R_BlenderView @ location

        R = R_BlenderView_to_OpenCVView @ R_BlenderView
        T = R_BlenderView_to_OpenCVView @ T_BlenderView

        return R, T

    def get_camera_KRT_from_blender(cam: 'bpy.types.Object') -> 'Tuple[np.ndarray, np.ndarray, np.ndarray]':
        """Get camera KRT from blender camera object.

        Args:
            cam (bpy.types.Object): Camera.

        Returns:
            Tuple[np.ndarray, np.ndarray, np.ndarray]: K, R, T.
        """
        import mathutils

        K = XRFeitoriaBlenderFactory.get_3x3_K_matrix_from_blender(cam)
        R, T = XRFeitoriaBlenderFactory.get_R_T_matrix_from_blender(cam)

        # -90 deg rotation around x axis, to convert from blender to opencv coordinate system
        _R = mathutils.Matrix(R)
        _R_euler = _R.to_euler('XYZ')
        _R_euler.x -= math.pi / 2
        R = np.asarray(_R_euler.to_matrix())
        return K, R, T

    def add_multiview_camera(camera_name: str, scene: 'bpy.types.Scene') -> None:
        """Add a multiview camera to the scene.

        Args:
            camera_name (str): Name of the camera.
            scene (bpy.types.Scene): Scene.
        """
        if camera_name not in scene.render.views.keys():
            render_view = scene.render.views.new(camera_name)
            render_view.file_suffix = camera_name
            render_view.camera_suffix = camera_name

    def set_camera_activity(camera_name: str, scene: 'bpy.types.Scene', active: bool) -> None:
        """Set the activity of the given camera.

        Args:
            camera_name (str): Name of the camera.
            scene (bpy.types.Scene): Scene.
            active (bool, optional): Activity of this multiview camera.
        """
        XRFeitoriaBlenderFactory.set_multiview(scene)
        XRFeitoriaBlenderFactory.add_multiview_camera(camera_name=camera_name, scene=scene)
        render_view = scene.render.views[camera_name]
        render_view.use = active

    def get_active_cameras(scene: 'bpy.types.Scene') -> List[str]:
        """Get all active cameras of the given scene.

        Args:
            scene (bpy.types.Scene): Scene.

        Returns:
            List[str]: Name of the active cameras.
        """
        active_cameras = []
        for cam in scene.render.views:
            if cam.use is True:
                active_cameras.append(cam.camera_suffix)
        return active_cameras

    #####################################
    ############### Import ##############
    #####################################

    def import_fbx(fbx_file: str) -> None:
        """Import an fbx file. Only support binary fbx.
        Ref: https://docs.blender.org/manual/en/3.6/addons/import_export/scene_fbx.html#id4

        Args:
            fbx_file (str): File path of the fbx file.
        """
        try:
            bpy.ops.import_scene.fbx(filepath=str(fbx_file))
        except Exception as e:
            raise Exception(f'Failed to import fbx: {fbx_file}\n{e}')

    def import_obj(obj_file: str) -> None:
        """Import an obj file.

        Args:
            obj_file (str): File path of the obj file.
        """
        try:
            bpy.ops.import_scene.obj(filepath=str(obj_file))
        except Exception:
            raise Exception(f'Failed to import obj: {obj_file}')

    def import_alembic(alembic_file: str) -> None:
        """Import an existing alembic animation into blender."""
        try:
            bpy.ops.wm.alembic_import(filepath=str(alembic_file), relative_path=False, as_background_job=False)
        except Exception as e:
            raise Exception(f'Failed to import alembic: {alembic_file}, error: {e}')

    def import_ply(ply_file: str) -> None:
        """Import an ply file.

        Args:
            ply_file (str): File path of the ply file.
        """
        try:
            bpy.ops.import_mesh.ply(filepath=str(ply_file))
        except Exception as e:
            raise Exception(f'Failed to import ply: {ply_file}\n{e}')

    def import_stl(stl_file: str) -> None:
        """Import an stl file.

        Args:
            stl_file (str): File path of the stl file.
        """
        try:
            bpy.ops.import_mesh.stl(filepath=str(stl_file))
        except Exception as e:
            raise Exception(f'Failed to import stl: {stl_file}\n{e}')

    def import_glb(glb_file: str) -> None:
        """Import an glb file.

        Args:
            glb_file (str): File path of the glb file.
        """
        try:
            bpy.ops.import_scene.gltf(filepath=str(glb_file))
        except Exception as e:
            raise Exception(f'Failed to import glb: {glb_file}\n{e}')

    def import_mo_json(
        cls,
        mo_json_file: Path,
        actor_name: str,
    ) -> None:
        """Import an animation from json, and apply the animation to the given actor.

        Args:
            mo_json_file (Path): json file path.
            actor_name (str): Name of the actor.
        """
        motion_data = json.load(Path(mo_json_file).open('r'))
        action = bpy.data.actions.new('Action')
        actor = bpy.data.objects[actor_name]
        XRFeitoriaBlenderFactory.apply_action_to_actor(action, actor)
        XRFeitoriaBlenderFactory.apply_motion_data_to_action(motion_data=motion_data, action=action)

    def import_mo_blend(
        mo_blend_file: Path,
        action_name: str,
        actor_name: str,
    ) -> None:
        """Import an animation from blend, and apply the animation to the given actor.

        Args:
            mo_json_file (Path): blend file path.
            actor_name (str): Name of the actor.
        """
        with bpy.data.libraries.load(mo_blend_file) as (data_from, data_to):
            data_to.actions.append(action_name)
        action = bpy.data.actions[action_name]
        actor = bpy.data.objects[actor_name]
        XRFeitoriaBlenderFactory.apply_action_to_actor(action, actor)

    def import_mo_fbx(
        mo_fbx_file: Path,
        actor_name: str,
    ) -> None:
        """Import an animation from fbx, and apply the animation to the given actor.

        Args:
            mo_json_file (Path): fbx file path.
            actor_name (str): Name of the actor.
        """
        mo_object_name = 'tmp_import_action'
        with XRFeitoriaBlenderFactory.__judge__(
            name=mo_object_name, import_path=mo_fbx_file, scene=XRFeitoriaBlenderFactory.get_active_scene()
        ):
            XRFeitoriaBlenderFactory.import_fbx(mo_fbx_file)
        action = bpy.data.objects[mo_object_name].animation_data.action
        actor = bpy.data.objects[actor_name]
        XRFeitoriaBlenderFactory.apply_action_to_actor(action, actor)
        bpy.data.objects.remove(bpy.data.objects[mo_object_name])

    def apply_action_to_actor(action: 'bpy.types.Action', actor: 'bpy.types.Object') -> None:
        """Apply action to object.

        Args:
            action (bpy.types.Action): Action
            actor (bpy.types.Object): Actor
        """
        if actor.animation_data:
            actor.animation_data_clear()
        actor.animation_data_create()
        actor.animation_data.action = action

    def apply_motion_data_to_action(
        motion_data: 'List[Dict[str, Dict]]',
        action: 'bpy.types.Action',
        scale: float = 1.0,
    ) -> None:
        """Apply motion data in dict to object.

        Args:
            motion_data (List[Dict[str, Dict]]): Motion data in the form of dict, normally imported from json.
            action (bpy.types.Action): Action.
            scale (float, optional): Scale of movement in location of animation. Defaults to 1.0.
        """
        import numpy as np

        num_frames = len(motion_data)
        fcurves_map = {(fc.data_path, fc.array_index): fc for fc in action.fcurves}

        def _get_fcurve(data_path: str, index: int):
            key_ = (data_path, index)
            if key_ in fcurves_map:
                fcurve = fcurves_map[key_]
            else:
                fcurve = fcurves_map.setdefault(key_, action.fcurves.new(data_path, index=index))
                # fcurve.keyframe_points.add(num_frames)
            return fcurve

        # Set keyframes
        frames_iter = range(num_frames)
        loc0 = [0, 0, 0]
        for f in frames_iter:
            for bone_name in motion_data[0].keys():
                # rotation_quaternion
                data_path = f'pose.bones["{bone_name}"].rotation_quaternion'
                motion = motion_data[f][bone_name]
                for idx, val in enumerate(motion['rotation']):
                    fcurve = _get_fcurve(data_path=data_path, index=idx)
                    # fcurve.keyframe_points[f].co = (f, val)
                    fcurve.keyframe_points.insert(frame=f, value=val, options={'FAST'})
                    # if bone_name == "left_shoulder":
                    #     print(f, [list(x.co) for x in fcurve.keyframe_points])
                # location
                if 'location' in motion:
                    data_path = f'pose.bones["{bone_name}"].location'
                    location_ = motion['location']
                    if f < 1:
                        loc0 = location_
                        location_ = np.zeros(3)
                    else:
                        location_ = np.subtract(location_, loc0) * scale
                    for idx, val in enumerate(location_):
                        fcurve = _get_fcurve(data_path=data_path, index=idx)
                        # fcurve.keyframe_points[f].co = (f, val)
                        fcurve.keyframe_points.insert(frame=f, value=val, options={'FAST'})

    #####################################
    ############# validate ##############
    #####################################

    @contextmanager
    def __judge__(name: str, scene: 'bpy.types.Scene', import_path: 'Optional[str]' = None):
        """Get the new imported/added objects, validate the new objects, and rename the
        new objects. \n If the new objects are not valid, raise ValueError. \n If the
        new objects are valid, rename the new objects to 'name'. \n If the new objects
        contain a mesh and an armature, rename the armature to 'name'.

        Args:
            name (str): Name of the new object in Blender.
            scene (bpy.types.Scene): Scene to add the new object to.
            import_path (Optional[str], optional): Import path. Defaults to None.

        Yields:
            None: None

        Raises:
            ValueError: If the new objects are not valid.

        Examples:
            >>> # in Blender
            >>> from XRFeitoriaBpy.core.factory import XRFeitoriaBlenderFactory
            >>> with XRFeitoriaBlenderFactory.__judge__(name='test'):
            ...    XRFeitoriaBlenderFactory.import_fbx('/usr/home/test.fbx')
            >>> actor = bpy.data.objects['test']
        """
        old_objs = set(scene.objects)
        yield
        new_objs = list(set(scene.objects) - old_objs)

        # record new_obj's matrix_world
        matrices_world = {}
        for new_obj in new_objs:
            if new_obj.type is not 'EMPTY' and new_obj.parent is not None and new_obj.parent.type == 'EMPTY':
                bpy.context.view_layer.update()
                matrices_world[new_obj.name] = new_obj.matrix_world

        # delete empty
        new_objs_without_empty = []
        for new_obj in new_objs:
            if new_obj.type == 'EMPTY':
                bpy.data.objects.remove(new_obj)
            else:
                new_objs_without_empty.append(new_obj)

        # set matrix_world
        for new_obj in new_objs_without_empty:
            if new_obj.name in matrices_world.keys():
                new_obj.matrix_world = matrices_world[new_obj.name]
                bpy.context.view_layer.update()

        # validate and rename
        if not new_objs_without_empty:
            if import_path:
                raise ValueError(f"Failed to import from '{import_path}'")
            else:
                raise ValueError(f"Failed to spawn '{name}'.")
        elif len(new_objs_without_empty) == 1:
            new_obj = new_objs_without_empty[0]
            new_obj.name = name
            new_obj.rotation_mode = 'XYZ'
        elif len(new_objs_without_empty) >= 2:
            obj_types = [obj.type for obj in new_objs_without_empty]
            if 'ARMATURE' not in obj_types:
                raise ValueError('Unsupported file')
            elif obj_types.count('ARMATURE') >= 2:
                raise ValueError('Unsupported file')
            elif 'ARMATURE' in obj_types and 'MESH' in obj_types:
                new_obj = new_objs_without_empty[obj_types.index('ARMATURE')]
                new_obj.name = name
                new_obj.rotation_mode = 'XYZ'

    #####################################
    ############## Object ###############
    #####################################
    def get_bound_box_in_world_space(
        obj: 'bpy.types.Object',
    ) -> 'Tuple[Tuple[float, float, float], Tuple[float, float, float]]':
        """Get the bounding box of the object in world space.

        Args:
            obj (bpy.types.Object): Object.

        Raises:
            ValueError: If the object type is not mesh or armature.

        Returns:
            Tuple[Tuple[float, float, float], Tuple[float, float, float]]: Min and max point of the bounding box.
        """
        if obj.type == 'MESH':
            depsgraph = bpy.context.evaluated_depsgraph_get()
            evaluated_obj = obj.evaluated_get(depsgraph)
            evaluated_mesh = evaluated_obj.data

            vertex_positions = []
            for vertex in evaluated_mesh.vertices:
                vertex_position = obj.matrix_world @ vertex.co
                vertex_positions.append(vertex_position)

            bbox_min = (1e9, 1e9, 1e9)
            bbox_max = (-1e9, -1e9, -1e9)
            bbox_min = (
                min(bbox_min[0], min(pos.x for pos in vertex_positions)),
                min(bbox_min[1], min(pos.y for pos in vertex_positions)),
                min(bbox_min[2], min(pos.z for pos in vertex_positions)),
            )
            bbox_max = (
                max(bbox_max[0], max(pos.x for pos in vertex_positions)),
                max(bbox_max[1], max(pos.y for pos in vertex_positions)),
                max(bbox_max[2], max(pos.z for pos in vertex_positions)),
            )
            return bbox_min, bbox_max
        elif obj.type == 'ARMATURE':
            bbox_min = (1e9, 1e9, 1e9)
            bbox_max = (-1e9, -1e9, -1e9)
            for obj_mesh in obj.children:
                depsgraph = bpy.context.evaluated_depsgraph_get()
                evaluated_obj = obj_mesh.evaluated_get(depsgraph)
                evaluated_mesh = evaluated_obj.data

                vertex_positions = []
                for vertex in evaluated_mesh.vertices:
                    vertex_position = obj_mesh.matrix_world @ vertex.co
                    vertex_positions.append(vertex_position)

                bbox_min = (
                    min(bbox_min[0], min(pos.x for pos in vertex_positions)),
                    min(bbox_min[1], min(pos.y for pos in vertex_positions)),
                    min(bbox_min[2], min(pos.z for pos in vertex_positions)),
                )
                bbox_max = (
                    max(bbox_max[0], max(pos.x for pos in vertex_positions)),
                    max(bbox_max[1], max(pos.y for pos in vertex_positions)),
                    max(bbox_max[2], max(pos.z for pos in vertex_positions)),
                )
            return bbox_min, bbox_max
        else:
            raise ValueError(f'Invalid object type: {obj.type}')

src/XRFeitoriaBpy/core/factory.py
openxrlab/xrfeitoria
from pathlib import Path
from typing import Dict, List, Optional, Tuple, Union

import bpy

from .. import logger
from ..constants import ImageFileFormatEnum, RenderEngineEnum, RenderLayerEnum


class RenderPassUtils:
    @staticmethod
    def reset_tree(scene) -> None:
        tree = scene.node_tree
        tree.nodes.clear()  # clear default nodes

    @staticmethod
    def find_or_add_render_layer_node(context) -> Optional[bpy.types.CompositorNodeRLayers]:
        scene = context.scene
        tree = scene.node_tree
        for node in tree.nodes:
            if node.type == 'R_LAYERS':
                return node
        render_layers = tree.nodes.new(type='CompositorNodeRLayers')  # Define Render Layers
        return render_layers

    @classmethod
    def find_or_add_file_output_node(cls, context, output_path: str) -> bpy.types.CompositorNodeOutputFile:
        scene = context.scene
        tree = scene.node_tree
        # check if file output node already exists
        for node in tree.nodes:
            if node.type == 'OUTPUT_FILE' and Path(node.base_path).resolve() == Path(output_path).resolve():
                return node
        # Define File Output Node
        file_output = tree.nodes.new(type='CompositorNodeOutputFile')
        file_output.base_path = output_path
        file_output.inputs.clear()
        # set location for better view
        render_layer_node = cls.find_or_add_render_layer_node(context)
        file_output.location = (render_layer_node.location.x + 300, 0)
        return file_output

    @staticmethod
    def setup_buffer(context: bpy.types.Context, render_layer: RenderLayerEnum) -> None:
        scene = context.scene
        view_layer = context.view_layer

        scene.render.use_persistent_data = True  # Render Optimizations

        # setup buffers
        if render_layer == RenderLayerEnum.depth:
            view_layer.use_pass_z = True
        elif render_layer == RenderLayerEnum.denoising_depth:
            if not scene.render.engine == RenderEngineEnum.cycles:
                raise TypeError('Denoising depth is only supported in Cycles')
            view_layer.use_pass_z = True
            view_layer.cycles.denoising_store_passes = True  # De-noising Passes like Albedo, Normal, Depth, etc
        elif render_layer == RenderLayerEnum.flow:
            if not scene.render.engine == RenderEngineEnum.cycles:
                raise TypeError('Flow is only supported in Cycles')
            view_layer.use_pass_vector = True
        elif render_layer == RenderLayerEnum.mask:
            if not scene.render.engine == RenderEngineEnum.cycles:
                raise TypeError('Mask is only supported in Cycles')
            view_layer.use_pass_object_index = True
        elif render_layer == RenderLayerEnum.normal:
            view_layer.use_pass_normal = True
        elif render_layer == RenderLayerEnum.diffuse:
            view_layer.use_pass_diffuse_color = True

    @staticmethod
    def set_color_depth(context: bpy.types.Context, color_depth: int) -> None:
        """Set the color depth of the scene."""
        scene = context.scene
        scene.render.image_settings.color_depth = str(color_depth)

    @staticmethod
    def set_file_format(context: bpy.types.Context, file_format: ImageFileFormatEnum) -> None:
        """Set the format of the scene."""
        scene = context.scene
        scene.render.image_settings.file_format = file_format

    ##########################
    # ------- Nodes -------- #
    ##########################
    @staticmethod
    def set_slot_to_jpg(node_slot: bpy.types.NodeOutputFileSlotFile):
        node_slot.use_node_format = False
        node_slot.format.file_format = ImageFileFormatEnum.jpeg

    @staticmethod
    def set_slot_to_png(node_slot: bpy.types.NodeOutputFileSlotFile):
        node_slot.use_node_format = False
        node_slot.format.file_format = ImageFileFormatEnum.png

    @staticmethod
    def set_slot_to_exr(node_slot: bpy.types.NodeOutputFileSlotFile):
        """Set depth to save as float (EXR)"""
        node_slot.use_node_format = False
        node_slot.format.file_format = ImageFileFormatEnum.exr
        node_slot.format.color_depth = '32'

    @staticmethod
    def set_slot_to_gray(node_slot: bpy.types.NodeOutputFileSlotFile):
        node_slot.use_node_format = False
        node_slot.format.color_mode = 'BW'

    @classmethod
    def add_node_cut_off(context, node) -> bpy.types.CompositorNodeMath:
        scene = context.scene
        tree = scene.node_tree
        links = tree.links

        # cut off depth at 1000mm, and set to 0 for invalid depth
        math_node1 = tree.nodes.new('CompositorNodeMath')
        math_node1.operation = 'LESS_THAN'
        math_node1.inputs[1].default_value = 1.0
        links.new(node.outputs[0], math_node1.inputs[0])

        math_node2 = tree.nodes.new('CompositorNodeMath')
        math_node2.operation = 'MULTIPLY'
        links.new(node.outputs[0], math_node2.inputs[0])
        links.new(math_node1.outputs[0], math_node2.inputs[1])
        return math_node2

    @classmethod
    def add_render_pass(
        cls,
        context,
        output_path: str,
        image_format: str,
        render_layer: str,
    ) -> None:
        """Add a render pass to the scene."""
        scene = context.scene
        tree = scene.node_tree
        links = tree.links

        # init
        image_format = ImageFileFormatEnum.get(image_format)
        render_layer = RenderLayerEnum.get(render_layer)
        render_layer_node = cls.find_or_add_render_layer_node(context)

        # check if the pass is available
        cls.setup_buffer(context, render_layer)
        assert render_layer_node.outputs.get(render_layer) is not None, f'{render_layer} is not available'

        file_output_node = cls.find_or_add_file_output_node(context=context, output_path=output_path)
        slot_name = f'{render_layer.name}/'
        if file_output_node.file_slots.get(slot_name) is not None:
            logger.warning(f'Render pass {render_layer.name} already exists, skipping')
            return

        file_output_node.file_slots.new(name=slot_name)
        file_slot = file_output_node.file_slots[slot_name]

        # default to png
        # if image_format == ImageFileFormatEnum.png:
        #     self.set_slot_to_png(file_slot)
        if image_format == ImageFileFormatEnum.exr:
            cls.set_slot_to_exr(file_slot)
        elif image_format == ImageFileFormatEnum.jpeg:
            cls.set_slot_to_jpg(file_slot)

        # link node of render layer to file output (mask and depth need to be processed)
        node_out = render_layer_node.outputs[render_layer]
        if render_layer == RenderLayerEnum.mask:
            if not image_format == ImageFileFormatEnum.exr:
                cls.set_slot_to_gray(file_slot)
            # link mask
            math_node = tree.nodes.new('CompositorNodeMath')
            math_node.operation = 'DIVIDE'
            math_node.inputs[1].default_value = 255.0
            links.new(node_out, math_node.inputs[0])
            node_out = math_node.outputs[0]
        if (
            render_layer == RenderLayerEnum.depth or render_layer == RenderLayerEnum.denoising_depth
        ) and image_format == ImageFileFormatEnum.png:
            cls.set_slot_to_gray(file_slot)
            file_slot.format.color_depth = '16'
            file_slot.format.compression = 100
            # scale depth by 1000 (to get mm), divide by 65535 to scale to 16bit
            math_node = tree.nodes.new('CompositorNodeMath')
            math_node.operation = 'MULTIPLY'
            math_node.inputs[1].default_value = 1000.0 / 65535.0
            links.new(node_out, math_node.inputs[0])
            node_out = math_node.outputs[0]
        links.new(node_out, file_output_node.inputs[slot_name])

src/XRFeitoriaBpy/core/renderer.py
openxrlab/xrfeitoria
import threading

import bpy

from . import logger
from .core.renderer import RenderPassUtils
from .rpc import blender_server
from .utils_logger import setup_logger


class StartRPCServerOperator(bpy.types.Operator):
    """Bootstraps unreal and blender with rpc server threads, so that they are ready for
    remote calls."""

    bl_idname = 'wm.start_rpc_servers'
    bl_label = 'Start RPC Servers'

    block: bpy.props.BoolProperty(name='block', default=False)
    port: bpy.props.IntProperty(name='port', default=9997)
    debug: bpy.props.BoolProperty(name='debug', default=False)

    def execute(self, context):
        # start the blender rpc server if its not already running
        if self.debug:
            setup_logger(level='DEBUG')
        if 'BlenderRPCServer' not in [thread.name for thread in threading.enumerate()]:
            rpc_server = blender_server.RPCServer(port=self.port)
            rpc_server.start(threaded=bool(not self.block))
        else:
            logger.debug('RPC server already running')

        return {'FINISHED'}


class RenderPassesOperator(bpy.types.Operator):
    """Adds render passes to the scene."""

    bl_idname = 'wm.add_render_pass'
    bl_label = 'Add Render Passes'

    output_path: bpy.props.StringProperty(name='Output path', subtype='FILE_PATH', default='//')
    render_layer: bpy.props.EnumProperty(
        name='Render Passes',
        items=[
            ('img', 'Image', 'Image'),
            ('Image', 'Image', 'Image'),
            ('mask', 'IndexOB', 'IndexOB'),
            ('IndexOB', 'IndexOB', 'IndexOB'),
            ('depth', 'Depth', 'Depth'),
            ('Depth', 'Depth', 'Depth'),
            ('denoising_depth', 'Denoising Depth', 'Denoising Depth'),
            ('Denoising Depth', 'Denoising Depth', 'Denoising Depth'),
            ('flow', 'Vector', 'Vector'),
            ('Vector', 'Vector', 'Vector'),
            ('normal', 'Normal', 'Normal'),
            ('Normal', 'Normal', 'Normal'),
            ('diffuse', 'DiffCol', 'DiffCol'),
            ('DiffCol', 'DiffCol', 'DiffCol'),
        ],
    )
    format: bpy.props.EnumProperty(
        name='File Format',
        items=[
            ('png', 'PNG', 'PNG'),
            ('PNG', 'PNG', 'PNG'),
            ('bmp', 'BMP', 'BMP'),
            ('BMP', 'BMP', 'BMP'),
            ('jpg', 'JPEG', 'JPEG'),
            ('JPEG', 'JPEG', 'JPEG'),
            ('jpeg', 'JPEG', 'JPEG'),
            ('JPEG', 'JPEG', 'JPEG'),
            ('exr', 'OPEN_EXR', 'OPEN_EXR'),
            ('OPEN_EXR', 'OPEN_EXR', 'OPEN_EXR'),
        ],
    )

    def execute(self, context):
        context.scene.use_nodes = True
        RenderPassUtils.add_render_pass(
            context,
            output_path=self.output_path,
            render_layer=self.render_layer,
            image_format=self.format,
        )
        return {'FINISHED'}


operator_classes = [
    StartRPCServerOperator,
    RenderPassesOperator,
]


def register():
    for operator_class in operator_classes:
        bpy.utils.register_class(operator_class)


def unregister():
    for operator_class in reversed(operator_classes):
        bpy.utils.unregister_class(operator_class)

src/XRFeitoriaBpy/operators.py
openxrlab/xrfeitoria
import bpy

from .constants import default_level_blender
from .core.factory import XRFeitoriaBlenderFactory


class LevelCameras(bpy.types.PropertyGroup):
    # name: bpy.props.StringProperty()
    camera: bpy.props.PointerProperty(type=bpy.types.Object)
    location: bpy.props.FloatVectorProperty(size=3)
    rotation: bpy.props.FloatVectorProperty(size=3)
    scale: bpy.props.FloatVectorProperty(size=3)
    level_fov: bpy.props.FloatProperty()
    sequence_fov: bpy.props.FloatProperty()
    sequence_animation: bpy.props.PointerProperty(type=bpy.types.Action)


class LevelActors(bpy.types.PropertyGroup):
    actor: bpy.props.PointerProperty(type=bpy.types.Object)
    location: bpy.props.FloatVectorProperty(size=3)
    rotation: bpy.props.FloatVectorProperty(size=3)
    scale: bpy.props.FloatVectorProperty(size=3)
    level_animation: bpy.props.PointerProperty(type=bpy.types.Action)
    level_stencil_value: bpy.props.IntProperty()
    sequence_animation: bpy.props.PointerProperty(type=bpy.types.Action)
    sequence_stencil_value: bpy.props.IntProperty()


## sequence properties for collection
class SequenceProperties(bpy.types.PropertyGroup):
    level: bpy.props.PointerProperty(type=bpy.types.Scene)
    level_cameras: bpy.props.CollectionProperty(type=LevelCameras)
    level_actors: bpy.props.CollectionProperty(type=LevelActors)
    fps: bpy.props.IntProperty()
    frame_start: bpy.props.IntProperty()
    frame_end: bpy.props.IntProperty()
    frame_current: bpy.props.IntProperty()


## level properties for scene
class LevelProperties(bpy.types.PropertyGroup):
    def active_sequence_update(self, context):
        """Open or close sequence when self.active_sequence is changed."""
        if self.active_sequence is None:
            XRFeitoriaBlenderFactory.close_sequence()
        elif XRFeitoriaBlenderFactory.is_sequence_collecion(self.active_sequence):
            level_scene, _, _, _, _ = XRFeitoriaBlenderFactory.get_sequence_properties(collection=self.active_sequence)
            if self.active_sequence.name not in level_scene.collection.children:
                XRFeitoriaBlenderFactory.open_sequence(self.active_sequence.name)
        else:
            pass

    def filter_sequence_collection(self, collection) -> bool:
        """Only sequence collection can be set as active sequence.

        Args:
            collection (bpy.types.Collection): Collection.

        Returns:
            bool: True if the collection is a sequence collection and in the active scene.
        """
        return (
            XRFeitoriaBlenderFactory.is_sequence_collecion(collection)
            and collection.sequence_properties.level == bpy.context.scene
        )

    active_sequence: bpy.props.PointerProperty(
        type=bpy.types.Collection,
        update=active_sequence_update,
        name='Active Sequence',
        poll=filter_sequence_collection,
    )


property_classes = [
    LevelCameras,
    LevelActors,
    LevelProperties,
    SequenceProperties,
]


def register():
    for property_class in property_classes:
        bpy.utils.register_class(property_class)

    bpy.types.Scene.default_level_blender = bpy.props.StringProperty(
        name='Default Level Blender', default=default_level_blender
    )
    bpy.types.Scene.level_properties = bpy.props.PointerProperty(type=LevelProperties)
    bpy.types.Collection.sequence_properties = bpy.props.PointerProperty(type=SequenceProperties)


def unregister():
    for property_class in property_classes:
        bpy.utils.unregister_class(property_class)

    del bpy.types.Scene.default_level_blender
    del bpy.types.Scene.level_properties
    del bpy.types.Collection.sequence_properties

src/XRFeitoriaBpy/properties.py
openxrlab/xrfeitoria
# Ref:
# https://github.com/EpicGames/BlenderTools/blob/main/send2ue/dependencies/rpc/base_server.py

import abc
import os
import queue
import sys
import threading
import time
from http import HTTPStatus

# importlib machinery needs to be available for importing client modules
from importlib.machinery import SourceFileLoader
from xmlrpc.server import SimpleXMLRPCRequestHandler, SimpleXMLRPCServer

from .. import logger

EXECUTION_QUEUE = queue.Queue()
RETURN_VALUE_NAME = 'RPC_SERVER_RETURN_VALUE'
ERROR_VALUE_NAME = 'RPC_SERVER_ERROR_VALUE'


def run_in_main_thread(callable_instance, *args):
    """Runs the provided callable instance in the main thread by added it to a que that
    is processed by a recurring event in an integration like a timer.

    :param call callable_instance: A callable.
    :return: The return value of any call from the client.
    """
    # timeout = int(os.environ.get('RPC_TIME_OUT', 60))

    globals().pop(RETURN_VALUE_NAME, None)
    globals().pop(ERROR_VALUE_NAME, None)
    EXECUTION_QUEUE.put((callable_instance, args))

    # for attempt in range(timeout * 10):
    while True:
        if RETURN_VALUE_NAME in globals():
            return globals().get(RETURN_VALUE_NAME)
        elif ERROR_VALUE_NAME in globals():
            raise globals()[ERROR_VALUE_NAME]
        else:
            time.sleep(0.1)

    # if RETURN_VALUE_NAME not in globals():
    #     raise TimeoutError(
    #         f'The call "{callable_instance.__name__}" timed out because it hit the timeout limit'
    #         f' of {timeout} seconds.'
    #     )


def execute_queued_calls(*extra_args):
    """Runs calls in the execution que till they are gone.

    Designed to be passed to a recurring event in an integration like a timer.
    """
    while not EXECUTION_QUEUE.empty():
        if RETURN_VALUE_NAME not in globals():
            callable_instance, args = EXECUTION_QUEUE.get()
            try:
                globals()[RETURN_VALUE_NAME] = callable_instance(*args)
            except Exception as error:
                # store the error in the globals and re-raise it
                globals()[ERROR_VALUE_NAME] = error
                raise error


class AuthenticatedRequestHandler(SimpleXMLRPCRequestHandler):
    def is_authorized(self):
        """Checks if the Authorization header matches the key generated by the server.

        :returns: Whether the request is authorized.
        :rtype: bool
        """
        # do not allow requests sent cross site
        if self.headers.get('Sec-Fetch-Site') == 'cross-site':
            return False
        # do not allow requests from another origin
        if self.headers.get('Origin'):
            return False
        return True

    def report_401(self):
        """Reports an unauthorized error back to the client."""
        self.send_response(HTTPStatus.UNAUTHORIZED)
        response = b'Not authorized'
        self.send_header('Content-type', 'text/plain')
        self.send_header('Content-length', str(len(response)))
        self.end_headers()
        self.wfile.write(response)

    def do_POST(self):
        """Overrides the post method to implement authentication."""
        if self.is_authorized():
            super(AuthenticatedRequestHandler, self).do_POST()
        else:
            self.report_401()


class BaseServer(SimpleXMLRPCServer):
    def __init__(self, *args, **kwargs):
        kwargs['requestHandler'] = AuthenticatedRequestHandler
        super(BaseServer, self).__init__(*args, **kwargs)

    def serve_until_killed(self):
        """Serves till killed by the client."""
        self.quit = False
        while not self.quit:
            self.handle_request()


class BaseRPCServer:
    def __init__(self, name, port, is_thread=False):
        """Initialize the base server.

        :param str name: The name of the server.
        :param int port: The number of the server port.
        :param bool is_thread: Whether the server is encapsulated in a thread.
        """
        self.server = BaseServer((os.environ.get('RPC_HOST', '127.0.0.1'), port), logRequests=False, allow_none=True)
        self.is_thread = is_thread
        self.server.register_function(self.add_new_callable)
        self.server.register_function(self.kill)
        self.server.register_function(self.is_running)
        self.server.register_function(self.set_env)
        self.server.register_introspection_functions()
        self.server.register_multicall_functions()
        # logger.debug(f'Started RPC server "{name}"')

    @staticmethod
    def is_running():
        """Responds if the server is running."""
        return True

    @staticmethod
    def set_env(name, value):
        """Sets an environment variable in the server's python environment.

        :param str name: The name of the variable.
        :param str value: The value.
        """
        os.environ[name] = str(value)

    def kill(self):
        """Kill the running server from the client.

        Only if running in blocking mode.
        """
        self.server.quit = True
        return True

    def add_new_callable(self, callable_name, code, client_system_path, remap_pairs=None):
        """Adds a new callable defined in the client to the server.

        :param str callable_name: The name of the function that will be added to the
            server.
        :param str code: The code of the callable that will be added to the server.
            :param list[str] client_system_path: The list of python system paths from
            the client.
        :param list(tuple) remap_pairs: A list of tuples with first value being the
            client python path root and the second being the new server path root. This
            can be useful if the client and server are on two different file systems and
            the root of the import paths need to be dynamically replaced.
        :return str: A response message back to the client.
        """
        for path in client_system_path:
            # if a list of remap pairs are provided, they will be remapped before being added to the system path
            for client_path_root, matching_server_path_root in remap_pairs or []:
                if path.startswith(client_path_root):
                    path = os.path.join(
                        matching_server_path_root, path.replace(client_path_root, '').replace(os.sep, '/').strip('/')
                    )

            if path not in sys.path:
                sys.path.append(path)

        # run the function code
        exec(code)
        callable_instance = locals().copy().get(callable_name)

        # grab it from the locals and register it with the server
        if callable_instance:
            if self.is_thread:
                self.server.register_function(self.thread_safe_call(callable_instance), callable_name)
            else:
                self.server.register_function(callable_instance, callable_name)
        return f'The function "{callable_name}" has been successfully registered with the server!'


class BaseRPCServerThread(threading.Thread, BaseRPCServer):
    def __init__(self, name, port):
        """Initialize the base rpc server.

        :param str name: The name of the server.
        :param int port: The number of the server port.
        """
        threading.Thread.__init__(self, name=name, daemon=True)
        BaseRPCServer.__init__(self, name, port, is_thread=True)

    def run(self):
        """Overrides the run method."""
        self.server.serve_forever()

    @abc.abstractmethod
    def thread_safe_call(self, callable_instance, *args):
        """Implements thread safe execution of a call."""
        return


class BaseRPCServerManager:
    @abc.abstractmethod
    def __init__(self):
        """Initialize the server manager.

        Note: when this class is subclassed `name`, `port`, `threaded_server_class` need to be defined.
        """
        self.server_thread = None
        self.server_blocking = None

    def start_server_thread(self):
        """Starts the server in a thread."""
        self.server_thread = self.threaded_server_class(self.name, self.port)
        self.server_thread.start()
        logger.debug(f'Started {self.name} at "{self.port}" in a thread...')

    def start_server_blocking(self):
        """Starts the server in the main thread, which blocks all other processes.

        This can only be killed by the client.
        """
        self.server_blocking = BaseRPCServer(self.name, self.port)
        self.server_blocking.server.serve_until_killed()
        logger.info(f'Started {self.name} at "{self.port}" in a blocking way...')

    def start(self, threaded=True):
        """Starts the server.

        :param bool threaded: Whether to start the server in a thread. If not threaded
            it will block all other processes.
        """
        # start the server in a thread
        if threaded and not self.server_thread:
            self.start_server_thread()

        # start the blocking server
        elif not threaded and not self.server_blocking:
            self.start_server_blocking()

        else:
            logger.debug(f'RPC server "{self.name}" at {self.port} is already running...')

    def shutdown(self):
        """Shuts down the server."""
        if self.server_thread:
            logger.debug(f'RPC server "{self.name}" is shutting down...')

            # kill the server in the thread
            if self.server_thread:
                self.server_thread.server.shutdown()
                self.server_thread.join()

            logger.debug(f'RPC server "{self.name}" has shutdown.')

src/XRFeitoriaBpy/rpc/base_server.py
openxrlab/xrfeitoria
import sys

from . import base_server
from .base_server import BaseRPCServerManager, BaseRPCServerThread


def execute_queued_calls():
    """Adds calls in the execution que that get picked up by blender app timer.

    :return float: The amount of time between timer calls.
    """
    try:
        base_server.execute_queued_calls()
    except Exception as error:
        sys.stderr.write(str(error))
    return 0.1


class BlenderRPCServerThread(BaseRPCServerThread):
    def thread_safe_call(self, callable_instance, *args):
        """Implementation of a thread safe call in Blender."""
        return lambda *args: base_server.run_in_main_thread(callable_instance, *args)


class RPCServer(BaseRPCServerManager):
    def __init__(self, port: int = 9997):
        """Initialize the blender rpc server, with its name and specific port."""
        super(RPCServer, self).__init__()
        self.name = 'BlenderRPCServer'
        self.port = port
        self.threaded_server_class = BlenderRPCServerThread

    def start_server_thread(self):
        """Starts the server thread."""
        import bpy

        bpy.app.timers.register(bpy.app.handlers.persistent(execute_queued_calls), persistent=True)
        super(RPCServer, self).start_server_thread()

src/XRFeitoriaBpy/rpc/blender_server.py
openxrlab/xrfeitoria
import bpy

from .operators import RenderPassesOperator, StartRPCServerOperator


class VIEW3D_PT_XRFeitoria_Panel(bpy.types.Panel):
    bl_space_type = 'VIEW_3D'
    bl_region_type = 'UI'
    bl_category = 'XRFeitoria'
    bl_label = 'XRFeitoria'

    def draw(self, context):
        layout = self.layout

        row = layout.row(align=True)
        op = row.operator(StartRPCServerOperator.bl_idname, text='Start RPC Server')
        op.debug = True

        layout.separator()
        layout.label(text='Manage sequences:')

        # TODO: new sequence
        scene = context.scene
        layout.prop(scene.level_properties, property='active_sequence', text='Active')


panel_classes = [VIEW3D_PT_XRFeitoria_Panel]


def register():
    for panel_class in panel_classes:
        bpy.utils.register_class(panel_class)


def unregister():
    for panel_class in reversed(panel_classes):
        bpy.utils.unregister_class(panel_class)

src/XRFeitoriaBpy/ui.py
openxrlab/xrfeitoria
import logging


class UniqueFilter(logging.Filter):
    def __init__(self):
        super().__init__()
        self.unique_messages = set()

    def filter(self, record):
        # Extract the log message from the record
        log_message = record.getMessage()

        # Check if the message is unique
        if log_message not in self.unique_messages:
            self.unique_messages.add(log_message)
            return True
        else:
            return False


def setup_logging(level: str = 'INFO') -> 'logging.Logger':
    """Setup logging to file and console.

    Args:
        level (str, optional): logging level. Defaults to "INFO", can be "DEBUG", "INFO", "WARNING",
                                "ERROR", "CRITICAL".
    """
    import logging

    logger = logging.getLogger(__name__)
    logger.handlers.clear()
    logger.setLevel(level)
    logger_format = '{asctime} | ' + '{levelname:^8} | ' + '[blender] {message}'
    formatter = logging.Formatter(logger_format, style='{', datefmt='%Y-%m-%d %H:%M:%S')
    console_handler = logging.StreamHandler()
    console_handler.setFormatter(formatter)
    logger.addHandler(console_handler)
    logger.addFilter(UniqueFilter())
    return logger


def setup_logger(level: str = 'INFO'):
    logger = setup_logging(level=level)
    return logger


logger = setup_logger()

src/XRFeitoriaBpy/utils_logger.py
openxrlab/xrfeitoria
from dataclasses import dataclass, field
from enum import Enum
from pathlib import Path
from typing import Dict, List, Optional, Tuple, Union

import unreal

######### Engine Constants #########


def get_plugin_path() -> Tuple[Path, Path, Path]:
    PROJECT_FILE = Path(unreal.Paths.get_project_file_path()).resolve()
    PROJECT_ROOT = PROJECT_FILE.parent
    PLUGIN_ROOT = PROJECT_ROOT / 'Plugins' / PLUGIN_NAME
    PLUGIN_PYTHON_ROOT = PLUGIN_ROOT / 'Content/Python'

    return PROJECT_ROOT, PLUGIN_ROOT, PLUGIN_PYTHON_ROOT


PLUGIN_NAME = 'XRFeitoriaUnreal'
MATERIAL_PATHS = {
    'depth': f'/{PLUGIN_NAME}/Materials/MRQ/PPM_depth_EXR',
    'mask': f'/{PLUGIN_NAME}/Materials/MRQ/PPM_mask_MRQ',
    'flow': f'/{PLUGIN_NAME}/Materials/PPM_velocity',
    'diffuse': f'/{PLUGIN_NAME}/Materials/PPM_diffusecolor',
    'normal': f'/{PLUGIN_NAME}/Materials/PPM_normal_map',
    'metallic': f'/{PLUGIN_NAME}/Materials/PPM_metallic',
    'roughness': f'/{PLUGIN_NAME}/Materials/PPM_roughness',
    'specular': f'/{PLUGIN_NAME}/Materials/PPM_specular',
    'tangent': f'/{PLUGIN_NAME}/Materials/PPM_tangent',
    'basecolor': f'/{PLUGIN_NAME}/Materials/PPM_basecolor',
}
SHAPE_PATHS = {
    'cube': '/Engine/BasicShapes/Cube',
    'sphere': '/Engine/BasicShapes/Sphere',
    'cylinder': '/Engine/BasicShapes/Cylinder',
    'cone': '/Engine/BasicShapes/Cone',
    'plane': '/Engine/BasicShapes/Plane',
}
PROJECT_ROOT, PLUGIN_ROOT, PLUGIN_PYTHON_ROOT = get_plugin_path()
ENGINE_MAJOR_VERSION = int(unreal.SystemLibrary.get_engine_version().split('.')[0])
ENGINE_MINOR_VERSION = int(unreal.SystemLibrary.get_engine_version().split('.')[1])
DEFAULT_PATH = f'/Game/{PLUGIN_NAME}'
DEFAULT_SEQUENCE_PATH = f'{DEFAULT_PATH}/Sequences'
DEFAULT_ASSET_PATH = f'{DEFAULT_PATH}/Assets'
DEFAULT_SEQUENCE_DATA_ASSET = f'/{PLUGIN_NAME}/DefaultSequenceData'
MRQ_JOB_UPPER = 200
data_asset_suffix = '_data'


class SubSystem:
    if ENGINE_MAJOR_VERSION == 5:
        EditorActorSub = unreal.get_editor_subsystem(unreal.EditorActorSubsystem)
        EditorLevelSub = unreal.get_editor_subsystem(unreal.LevelEditorSubsystem)
        EditorSub = unreal.get_editor_subsystem(unreal.UnrealEditorSubsystem)
        EditorLevelSequenceSub = unreal.get_editor_subsystem(unreal.LevelSequenceEditorSubsystem)
        EditorAssetSub = unreal.get_editor_subsystem(unreal.AssetEditorSubsystem)
        MoviePipelineQueueSub = unreal.get_editor_subsystem(unreal.MoviePipelineQueueSubsystem)
    else:
        EditorActorSub = None
        EditorLevelSub = None
        EditorSub = None
        EditorLevelSequenceSub = None
        EditorAssetSub = None
        MoviePipelineQueueSub = None


######### Typing Constants #########

Vector = Tuple[float, float, float]
PathLike = Union[str, Path]


class EnumBase(str, Enum):
    @classmethod
    def get(cls, name_or_value: str) -> 'EnumBase':
        """Get enum member by name or value.

        Args:
            name_or_value (str): Name or value of the enum member.

        Returns:
            EnumBase: Self
        """
        try:
            return cls[name_or_value]
        except KeyError:
            for member in cls:
                if member.value == name_or_value:
                    return member
            raise ValueError(f'{name_or_value} is not supported in {cls.__name__}')


class InterpolationEnum(EnumBase):
    AUTO = 'AUTO'
    LINEAR = 'LINEAR'
    CONSTANT = 'CONSTANT'


class ImageFileFormatEnum(EnumBase):
    png = 'PNG'
    bmp = 'BMP'
    jpg = 'JPEG'
    jpeg = 'JPEG'
    exr = 'EXR'
    open_exr = 'EXR'


class UnrealRenderLayerEnum(EnumBase):
    """Render layer enum of Unreal."""

    img = 'img'
    mask = 'mask'
    depth = 'depth'
    flow = 'flow'
    normal = 'normal'
    diffuse = 'diffuse'

    metallic = 'metallic'
    roughness = 'roughness'
    specular = 'specular'
    tangent = 'tangent'
    basecolor = 'basecolor'


@dataclass
class RenderPass:
    """Render pass model."""

    render_layer: UnrealRenderLayerEnum
    image_format: ImageFileFormatEnum

    def __post_init__(self):
        if isinstance(self.render_layer, str):
            self.render_layer = UnrealRenderLayerEnum.get(self.render_layer.lower())
        if isinstance(self.image_format, str):
            self.image_format = ImageFileFormatEnum.get(self.image_format.lower())


@dataclass
class SequenceTransformKey:
    frame: int
    location: Optional[Vector] = None
    rotation: Optional[Vector] = None
    scale: Optional[Vector] = None
    interpolation: InterpolationEnum = InterpolationEnum.CONSTANT

    def __post_init__(self):
        if not isinstance(self.frame, int):
            raise ValueError('Frame must be an integer')
        if self.location and (not isinstance(self.location, (tuple, list)) or len(self.location) != 3):
            raise ValueError('Location must be a tuple of 3 floats')
        if self.rotation and (not isinstance(self.rotation, (tuple, list)) or len(self.rotation) != 3):
            raise ValueError('Rotation must be a tuple of 3 floats')
        if self.scale and (not isinstance(self.scale, (tuple, list)) or len(self.scale) != 3):
            raise ValueError('Scale must be a tuple of 3 floats')
        if isinstance(self.interpolation, str):
            self.interpolation = InterpolationEnum.get(self.interpolation.upper())

        if self.location:
            # convert meters to centimeters
            self.location = [loc * 100.0 for loc in self.location]


@dataclass
class RenderJobUnreal:
    @dataclass
    class AntiAliasSetting:
        enable: bool = False
        override_anti_aliasing: bool = False
        spatial_samples: int = 8
        temporal_samples: int = 8
        warmup_frames: int = 0
        render_warmup_frame: bool = False

    map_path: str
    sequence_path: str

    output_path: str
    resolution: Tuple[int, int]
    render_passes: List[RenderPass]
    file_name_format: str = '{sequence_name}/{render_pass}/{camera_name}/{frame_number}'
    console_variables: Dict[str, float] = field(default_factory=dict)
    anti_aliasing: AntiAliasSetting = AntiAliasSetting()
    export_vertices: bool = False
    export_skeleton: bool = False

    def __post_init__(self):
        self.render_passes = [RenderPass(**rp) for rp in self.render_passes]
        self.anti_aliasing = self.AntiAliasSetting(**self.anti_aliasing)


TransformKeys = Union[List[SequenceTransformKey], SequenceTransformKey]

src/XRFeitoriaUnreal/Content/Python/constants.py
openxrlab/xrfeitoria
import os
from typing import Callable, Dict, List, Optional, Tuple

import unreal
import utils
from constants import (
    ENGINE_MAJOR_VERSION,
    ENGINE_MINOR_VERSION,
    MATERIAL_PATHS,
    PROJECT_ROOT,
    RenderJobUnreal,
    RenderPass,
    SubSystem,
    UnrealRenderLayerEnum,
)

# define the post process material to the render_pass
material_paths = MATERIAL_PATHS
material_path_keys = [key.lower() for key in material_paths.keys()]


class CustomMoviePipeline:
    """This class contains several class methods, which are used to control the movie
    pipeline (Movie Render Queue), including:

    - clearQueue: Clear the movie render queue.
    - addJobToQueueWithYAMLConfig: Add a job to the movie render queue with yaml config.
    - renderQueue: Render the jobs in movie render queue.
    - ...
    """

    subsystem = SubSystem.MoviePipelineQueueSub
    pipeline_queue = subsystem.get_queue()

    host = '127.0.0.1'
    socket_port = int(os.environ.get('SOCKET_PORT', 9999))
    jobs: List[str] = []
    executor = None

    @classmethod
    def init_executor(cls):
        if cls.executor is None:
            cls.executor = unreal.MoviePipelinePIEExecutor()
        cls.executor.connect_socket(cls.host, cls.socket_port)
        cls.log_msg_with_socket('Socket of Unreal Renderer Connected')

    @classmethod
    def close_executor(cls):
        if cls.executor is not None:
            cls.executor.disconnect_socket()
            cls.executor = None

    @classmethod
    def clear_queue(cls) -> None:
        for job in cls.pipeline_queue.get_jobs():
            unreal.log('Deleting job ' + job.job_name)
            cls.pipeline_queue.delete_job(job)
        cls.jobs.clear()

        if cls.executor is not None:
            unreal.log('Disconnecting socket')
            cls.executor.disconnect_socket()
            cls.executor = None
        unreal.log('Queue cleared')

    @classmethod
    def save_queue(cls, path: str) -> None:
        """Save the movie render queue."""
        unreal.MoviePipelineEditorLibrary.save_queue_to_manifest_file(cls.pipeline_queue)
        manifest_file = PROJECT_ROOT / 'Saved/MovieRenderPipeline/QueueManifest.utxt'
        manifest_str = unreal.MoviePipelineEditorLibrary.convert_manifest_file_to_string(manifest_file.as_posix())
        with open(path, 'w') as f:
            f.write(manifest_str)
        unreal.log(f"Saved queue to '{path}' as a manifest file.")

    @classmethod
    def get_job_length(cls) -> int:
        return len(cls.pipeline_queue.get_jobs())

    @staticmethod
    def get_output_path(movie_preset: unreal.MoviePipelineMasterConfig) -> str:
        """Get output path from a movie preset.

        Args:
            movie_preset (unreal.MoviePipelineMasterConfig): The movie preset to get output path from.

        Returns:
            str: The output path.
        """
        output_config: unreal.MoviePipelineOutputSetting = movie_preset.find_or_add_setting_by_class(
            unreal.MoviePipelineOutputSetting
        )
        return output_config.output_directory.path

    @staticmethod
    def set_render_all_cameras(movie_preset: unreal.MoviePipelineMasterConfig, enable: bool = True) -> None:
        camera_setting: unreal.MoviePipelineCameraSetting = movie_preset.find_or_add_setting_by_class(
            unreal.MoviePipelineCameraSetting
        )
        camera_setting.render_all_cameras = enable

    @staticmethod
    def set_export_vertices(movie_preset: unreal.MoviePipelineMasterConfig, enable: bool = True) -> None:
        export_setting: unreal.MoviePipelineMeshOperator = movie_preset.find_or_add_setting_by_class(
            unreal.MoviePipelineMeshOperator
        )
        export_setting.static_mesh_operator_option.save_vertices_position = enable
        export_setting.skeletal_mesh_operator_option.save_vertices_position = enable

    @staticmethod
    def set_export_skeleton(movie_preset: unreal.MoviePipelineMasterConfig, enable: bool = True) -> None:
        export_setting: unreal.MoviePipelineMeshOperator = movie_preset.find_or_add_setting_by_class(
            unreal.MoviePipelineMeshOperator
        )
        export_setting.skeletal_mesh_operator_option.save_skeleton_position = enable

    @staticmethod
    def add_render_passes(movie_preset: unreal.MoviePipelineMasterConfig, render_passes: List[RenderPass]) -> None:
        """Add render passes to a movie preset.

        Args:
            movie_preset (unreal.MoviePipelineMasterConfig): The movie preset to add render passes to.
            render_passes (List[RenderPass]): The render passes to add.
                The available render passes are defined in `UnrealRenderLayerEnum`: `rgb`, `depth`, `mask`, \
                `flow`, `diffuse`, `normal`, `metallic`, `roughness`, `specular`, `tangent`, `basecolor`
        """

        # find or add setting
        render_pass = movie_preset.find_or_add_setting_by_class(unreal.CustomMoviePipelineDeferredPass)
        render_pass_config = movie_preset.find_or_add_setting_by_class(unreal.CustomMoviePipelineOutput)

        # add render passes
        additional_render_passes = []
        for render_pass in render_passes:
            pass_name = render_pass.render_layer.value
            enable = True
            ext = getattr(unreal.CustomImageFormat, render_pass.image_format.value.upper())  # convert to unreal enum

            if pass_name.lower() == UnrealRenderLayerEnum.img.value.lower():
                render_pass_config.enable_render_pass_rgb = enable
                render_pass_config.render_pass_name_rgb = pass_name
                render_pass_config.extension_rgb = ext
            elif pass_name.lower() in material_path_keys:
                # material = unreal.SoftObjectPath(material_map[pass_name])
                material = unreal.load_asset(material_paths[pass_name.lower()])
                _pass = unreal.CustomMoviePipelineRenderPass(
                    enabled=enable, material=material, render_pass_name=pass_name, extension=ext
                )
                additional_render_passes.append(_pass)

        render_pass_config.additional_render_passes = additional_render_passes

    @staticmethod
    def add_output_config(
        movie_preset: unreal.MoviePipelineMasterConfig,
        resolution: Tuple[int, int] = [1920, 1080],
        file_name_format: Optional[str] = None,
        output_path: Optional[str] = None,
    ) -> None:
        """Add output config to a movie preset.

        Args:
            movie_preset (unreal.MoviePipelineMasterConfig): The movie preset to add output config to.
            resolution (list): Resolution of the output, e.g. [1920, 1080].
            file_name_format (str): Format of the output file name, e.g. '{sequence_name}/{render_pass}/{frame_number}'
            output_path (str): Path of the output, e.g. 'E:/output'
        """
        # find or add setting
        output_config = movie_preset.find_or_add_setting_by_class(unreal.MoviePipelineOutputSetting)

        # add resolution settings
        output_config.output_resolution = unreal.IntPoint(resolution[0], resolution[1])

        # add file name format settings
        if file_name_format:
            output_config.file_name_format = file_name_format

        # set output path
        if output_path is not None:
            output_config.output_directory = unreal.DirectoryPath(output_path)

    @staticmethod
    def add_console_command(
        movie_preset: unreal.MoviePipelineMasterConfig,
        console_variables: Dict[str, float] = {'r.MotionBlurQuality': 0.0},
    ) -> None:
        """Add console command to a movie preset. Now only support motion blur.

        Args:
            movie_preset (unreal.MoviePipelineMasterConfig): The movie preset to add console command to.
            console_variables (dict): Console variables.
        """
        # find or add setting
        console_config: unreal.MoviePipelineConsoleVariableSetting = movie_preset.find_or_add_setting_by_class(
            unreal.MoviePipelineConsoleVariableSetting
        )
        if ENGINE_MAJOR_VERSION >= 5 and ENGINE_MINOR_VERSION >= 2:
            for key, value in console_variables.items():
                console_config.add_or_update_console_variable(key, value)
        else:
            console_config.console_variables = console_variables

    @staticmethod
    def add_anti_alias(
        movie_preset: unreal.MoviePipelineMasterConfig,
        anti_alias: RenderJobUnreal.AntiAliasSetting = RenderJobUnreal.AntiAliasSetting(),
    ) -> None:
        """Add anti-alias settings to a movie preset.

        Args:
            movie_preset (unreal.MoviePipelineMasterConfig): The movie preset to add anti-alias settings to.
            anti_alias (dict): Anti-alias settings.
        """
        if not anti_alias.enable:
            return

        # add anti_alias settings
        anti_alias_config: unreal.MoviePipelineAntiAliasingSetting = movie_preset.find_or_add_setting_by_class(
            unreal.MoviePipelineAntiAliasingSetting
        )
        anti_alias_config.spatial_sample_count = anti_alias.spatial_samples
        anti_alias_config.temporal_sample_count = anti_alias.temporal_samples
        if anti_alias.override_anti_aliasing:
            anti_alias_config.override_anti_aliasing = True
        if anti_alias.warmup_frames:
            anti_alias_config.use_camera_cut_for_warm_up = True
            anti_alias_config.render_warm_up_count = anti_alias['warmup_frames']
        if anti_alias.render_warmup_frame:
            anti_alias_config.render_warm_up_frames = True

    @classmethod
    def add_settings_to_movie_preset(
        cls,
        movie_preset: unreal.MoviePipelineMasterConfig,
        render_passes: List[RenderPass],
        resolution: Tuple[int, int] = [1920, 1080],
        file_name_format: Optional[str] = None,
        output_path: Optional[str] = None,
        anti_alias: RenderJobUnreal.AntiAliasSetting = RenderJobUnreal.AntiAliasSetting(),
        console_variables: dict = {'r.MotionBlurQuality': 0.0},
    ) -> unreal.MoviePipelineMasterConfig:
        """Add settings to a movie preset.

        Args:
            movie_preset (unreal.MoviePipelineMasterConfig): The movie preset to add settings to.
            render_passes (list): definition of render passes.
            resolution (list): Resolution of the output, e.g. [1920, 1080].
            file_name_format (str): Format of the output file name, e.g. '{sequence_name}/{render_pass}/{frame_number}'
            output_path (str): Path of the output, e.g. 'E:/output'
            anti_alias (dict): Anti-alias settings.
            console_variables (dict): Console variables.

        Returns:
            unreal.MoviePipelineMasterConfig: The created movie preset.
        """

        cls.add_render_passes(movie_preset, render_passes)
        cls.add_output_config(movie_preset, resolution, file_name_format, output_path)
        cls.add_anti_alias(movie_preset, anti_alias)
        cls.add_console_command(movie_preset, console_variables)

        unreal.EditorAssetLibrary.save_loaded_asset(movie_preset)
        return movie_preset

    @classmethod
    def create_movie_preset(
        cls,
        render_passes: List[RenderPass],
        resolution: Tuple[int, int] = [1920, 1080],
        file_name_format: Optional[str] = None,
        output_path: Optional[str] = None,
        anti_alias: RenderJobUnreal.AntiAliasSetting = RenderJobUnreal.AntiAliasSetting(),
        console_variables: Dict[str, float] = {'r.MotionBlurQuality': 0.0},
        export_vertices: bool = False,
        export_skeleton: bool = False,
    ) -> unreal.MoviePipelineMasterConfig:
        """
        Create a movie preset from args.
        1. Add render passes which contains the name of the render pass, and the extension of the output.
        2. Set output config which contains the resolution, the file name format, and the output path.
        3. Add anti-alias settings.
        4. Add console command, which contains the console variables like motion blur. (https://docs.unrealengine.com/5.2/en-US/rendering-high-quality-frames-with-movie-render-queue-in-unreal-engine/#step7:configuretheconsolevariables)
        5. Set render all cameras to True, which means multi-view rendering simultaneously.

        Args:
            render_passes (list): definition of render passes.
            resolution (list): Resolution of the output, e.g. [1920, 1080].
            file_name_format (str): Format of the output file name, e.g. '{sequence_name}/{render_pass}/{frame_number}'
            output_path (str): Path of the output, e.g. 'E:/output'
            anti_alias (dict): Anti-alias settings.
            console_variables (bool): Console variables.

        Returns:
            unreal.MoviePipelineMasterConfig: The created movie preset.
        """

        movie_preset = unreal.MoviePipelineMasterConfig()

        cls.add_render_passes(movie_preset, render_passes)
        cls.add_output_config(movie_preset, resolution, file_name_format, output_path)
        cls.add_anti_alias(movie_preset, anti_alias)
        cls.add_console_command(movie_preset, console_variables)
        cls.set_render_all_cameras(movie_preset, enable=True)
        cls.set_export_vertices(movie_preset, enable=export_vertices)
        cls.set_export_skeleton(movie_preset, enable=export_skeleton)

        return movie_preset

    @classmethod
    def create_job(
        cls,
        level: str,
        level_sequence: str,
    ) -> unreal.MoviePipelineExecutorJob:
        # check if assets exist
        if not (
            unreal.EditorAssetLibrary.does_asset_exist(level)
            and unreal.EditorAssetLibrary.does_asset_exist(level_sequence)
        ):
            return False

        # Create a new job and add it to the queue.
        new_job = cls.pipeline_queue.allocate_new_job(unreal.MoviePipelineExecutorJob)
        # TODO: if failed, new_job = False. Need to handle this case.
        new_job.job_name = str(level_sequence.rsplit('/', 1)[-1])
        new_job.map = utils.get_soft_object_path(level)
        new_job.sequence = utils.get_soft_object_path(level_sequence)

        return new_job

    @classmethod
    def add_job_to_queue_with_preset(
        cls,
        level: str,
        level_sequence: str,
        config: str,
    ) -> bool:
        """Add a job to the queue with a pre-defined preset.

        Args:
            level (str): level path in unreal engine.
            level_sequence (str): level sequence path in unreal engine.
            config (str): pre-defined preset path in unreal engine.

        Returns:
            bool: success or not.
        """
        new_job = cls.create_job(level, level_sequence)
        if not new_job:
            return False
        movie_preset = unreal.load_asset(config)
        new_job.set_configuration(movie_preset)
        unreal.log(f'Added new job ({new_job.job_name}) to queue')
        return True

    @classmethod
    def log_msg_with_socket(
        cls,
        msg: str,
        executor: Optional[unreal.MoviePipelinePIEExecutor] = None,
    ):
        """Log message with socket."""
        if executor is None:
            if cls.executor is None:
                cls.init_executor()
            executor = cls.executor
        executor.send_socket_message(msg)
        unreal.log(msg)

    @classmethod
    def add_job_to_queue(cls, job: RenderJobUnreal) -> bool:
        """Add a job to the queue.

        Args:
            job (RenderJobUnreal): a render job.

        Returns:
            bool: success or not.
        """
        new_job = cls.create_job(job.map_path, job.sequence_path)
        if not new_job:
            return False
        movie_preset = cls.create_movie_preset(
            render_passes=job.render_passes,
            resolution=job.resolution,
            file_name_format=job.file_name_format,
            output_path=job.output_path,
            anti_alias=job.anti_aliasing,
            console_variables=job.console_variables,
            export_vertices=job.export_vertices,
            export_skeleton=job.export_skeleton,
        )
        new_job.set_configuration(movie_preset)
        unreal.log(f'Added new job ({new_job.job_name}) to queue')
        return True

    @classmethod
    def add_job_to_queue_with_render_config(
        cls,
        level: str,
        level_sequence: str,
        render_config: Optional[dict] = None,
    ) -> bool:
        """Add a job to the queue with a YAML config loaded from a file.

        Args:
            level (str): level path in unreal engine.
            level_sequence (str): level sequence path in unreal engine.
            render_config (dict): YAML config loaded from a file. You can
                find a template in `data/render_config.yaml`.

        Returns:
            bool: success or not.

        Examples:
            >>> render_config = {
                    'Resolution': [1920, 1080],
                    'Output_Path': 'E:/Datasets/tmp',
                    'File_Name_Format': '{sequence_name}/{render_pass}/{frame_number}',
                    'Console_Variables': {'r.MotionBlurQuality': 0.0},
                    'Anti_Alias': {'enable': False, 'spatial_samples': 8, 'temporal_samples': 8},
                    'Render_Passes': [
                        {'pass_name': 'img', 'enable': True, 'ext': 'jpeg'},
                        {'pass_name': 'depth', 'enable': True, 'ext': 'exr'},
                        {'pass_name': 'mask', 'enable': True, 'ext': 'exr'},
                        {'pass_name': 'flow', 'enable': True, 'ext': 'exr'},
                        {'pass_name': 'normal', 'enable': True, 'ext': 'png'}
                    ]
                }
            >>> # or you can use the following code to load the config from a file
            >>> from data.config import CfgNode
            >>> render_config = CfgNode.load_yaml_with_base(PLUGIN_ROOT / 'misc/render_config_common.yaml')
            >>> #
            >>> CustomMoviePipeline.add_job_to_queue_with_render_config(
                    level='/Game/ThirdPerson/Maps/ThirdPersonMap',
                    level_sequence='/Game/NewLevelSequence',
                    render_config=render_config
                )
        """
        newJob = cls.create_job(level, level_sequence)
        if not newJob:
            return False
        if render_config is None:
            render_config = cls.render_config
        movie_preset = cls.create_movie_preset(
            render_passes=render_config['Render_Passes'],
            resolution=render_config['Resolution'],
            file_name_format=render_config['File_Name_Format'],
            output_path=render_config['Output_Path'],
            anti_alias=render_config['Anti_Alias'],
            console_variables=render_config['Console_Variables'],
        )
        newJob.set_configuration(movie_preset)
        unreal.log(f'Added new job ({newJob.job_name}) to queue')
        return True

    def onQueueFinishedCallback(executor: unreal.MoviePipelineLinearExecutorBase, success: bool):
        """On queue finished callback. This is called when the queue finishes. The args
        are the executor and the success of the queue, and it cannot be modified.

        Args:
            executor (unreal.MoviePipelineLinearExecutorBase): The executor of the queue.
            success (bool): Whether the queue finished successfully.
        """
        # TODO: bug fix
        mss = f'     Render completed. Success: {success}'
        CustomMoviePipeline.log_msg_with_socket(mss, executor)

    def onIndividualJobFinishedCallback(inJob: unreal.MoviePipelineExecutorJob, success: bool):
        """On individual job finished callback. This is called when an individual job
        finishes. The args are the job and the success of the job, and it cannot be
        modified.

        Args:
            inJob (unreal.MoviePipelineExecutorJob): The job that finished.
            success (bool): Whether the job finished successfully.
        """

        # get class variable `Jobs` to get the index of the finished job
        jobs = CustomMoviePipeline.jobs

        job_name = inJob.job_name
        job_index = jobs.index(job_name) + 1
        output_path = CustomMoviePipeline.get_output_path(inJob.get_configuration())

        # XXX: for no reason (only in here), the first 4 characters of mss cannot be sent
        mss = f'    job rendered ({job_index}/{len(jobs)}): seq_name="{job_name}", saved to "{output_path}/{job_name}"'
        CustomMoviePipeline.log_msg_with_socket(mss, CustomMoviePipeline.executor)

    @classmethod
    def render_queue(
        cls,
        queue_finished_callback: Callable = onQueueFinishedCallback,
        individual_job_finished_callback: Callable = onIndividualJobFinishedCallback,
    ) -> None:
        """Render the queue. This will render the queue. You can pass a callback
        function to be called when a job or the queue finishes. You can also pass a
        custom executor to use, or the default one will be used.

        Args:
            queue_finished_callback (Callable): The callback function to be called when the queue finishes.
            individual_job_finished_callback (Callable): The callback function to be called when an individual job finishes.
            executor (unreal.MoviePipelineLinearExecutorBase): The custom executor to use, or the default one will be used.
        """
        # check if there's jobs added to the queue
        if len(cls.pipeline_queue.get_jobs()) == 0:
            unreal.log_error('Open the Window > Movie Render Queue and add at least one job to use this example')
            return

        # add job_name to class variable `Jobs` for monitoring progress in the individual job finished callback
        cls.jobs.clear()
        for job in cls.pipeline_queue.get_jobs():
            cls.jobs.append(job.job_name)

        # set callbacks
        if cls.executor is None:
            cls.init_executor()

        cls.executor.on_executor_finished_delegate.add_callable_unique(queue_finished_callback)
        cls.executor.on_individual_job_finished_delegate.add_callable_unique(individual_job_finished_callback)

        # render the queue
        cls.executor.execute(cls.pipeline_queue)
        cls.log_msg_with_socket('Start Render')


def main():
    CustomMoviePipeline.clear_queue()
    CustomMoviePipeline.add_job_to_queue(
        RenderJobUnreal(
            map_path='/Game/Maps/DefaultMap',
            sequence_path='/Game/Sequences/NewSequence',
            resolution=[1920, 1080],
            output_path='E:/Datasets/tmp',
            file_name_format='{sequence_name}/{render_pass}/{frame_number}',
            console_variables={'r.MotionBlurQuality': 0.0},
            anti_alias={'enable': False},
        )
    )
    CustomMoviePipeline.render_queue()


if __name__ == '__main__':
    # from utils import loadRegistry
    # registry = loadRegistry(RenderQueue)
    # registry.register()

    main()

src/XRFeitoriaUnreal/Content/Python/custom_movie_pipeline.py
openxrlab/xrfeitoria
import constants
import unreal
from rpc import RPC
from utils import Loader


@unreal.uenum()
class PythonEnumSample(unreal.EnumBase):
    FIRST = unreal.uvalue(0)
    SECOND = unreal.uvalue(1)
    FOURTH = unreal.uvalue(3)


@unreal.ustruct()
class PathsStruct(unreal.StructBase):
    project_dirs = unreal.uproperty(str)
    feitoria_plugin_dirs = unreal.uproperty(str)
    feitoria_plugin_python_dirs = unreal.uproperty(str)


@unreal.uclass()
class XRFeitoriaBlueprintFunctionLibrary(unreal.BlueprintFunctionLibrary):
    @unreal.ufunction(static=True, params=[], ret=str, pure=True)
    def GetPluginName():
        return constants.PLUGIN_NAME

    @unreal.ufunction(static=True, params=[], ret=PathsStruct, pure=True)
    def GetFeitoriaPluginDirs():
        PROJECT_ROOT, PLUGIN_ROOT, PLUGIN_PYTHON_ROOT = constants.get_plugin_path()
        struct = PathsStruct()
        struct.project_dirs = str(PROJECT_ROOT)
        struct.feitoria_plugin_dirs = str(PLUGIN_ROOT)
        struct.feitoria_plugin_python_dirs = str(PLUGIN_PYTHON_ROOT)
        return struct


def debug_init(ip: str = '127.0.0.1', port: int = 5678):
    import ptvsd

    ptvsd.enable_attach(address=(ip, port))
    unreal.log('ptvsd.enable_attach execute')


@Loader
def main(rpc_port: int = 9998):
    RPC.start(block=False, port=rpc_port)
    # debug_init()


if __name__ == '__main__':
    rpc_port = 9998

    (cmdTokens, cmdSwitches, cmdParameters) = unreal.SystemLibrary.parse_command_line(
        unreal.SystemLibrary.get_command_line()
    )
    try:
        rpc_port = int(cmdParameters['rpc_port'])
    except Exception:
        unreal.log(f'RPC port not found in command line, using default port {rpc_port}')
    main(rpc_port=rpc_port)

src/XRFeitoriaUnreal/Content/Python/init_unreal.py
openxrlab/xrfeitoria
import threading

import unreal

from .unreal_server import RPCServer


class RPC:
    rpc_server: RPCServer = None

    @classmethod
    def start(cls, block: bool = False, port: int = 9998) -> None:
        """Bootstraps the running unreal editor with the unreal rpc server if it doesn't
        already exist."""
        for thread in threading.enumerate():
            if thread.name == 'UnrealRPCServer':
                thread.kill()

        cls.rpc_server = RPCServer(port=port)
        cls.rpc_server.start(threaded=(not block))

    @classmethod
    def shutdown(cls):
        cls.rpc_server.shutdown()
        unreal.log_warning(
            'RPC server suppose to be shutdown, but it may not be. Please consider restarting the editor.'
        )


__all__ = ['RPC']

src/XRFeitoriaUnreal/Content/Python/rpc/__init__.py
openxrlab/xrfeitoria
# Ref:
# https://github.com/EpicGames/BlenderTools/blob/main/send2ue/dependencies/rpc/base_server.py

import abc
import os
import queue
import sys
import threading
import time
from http import HTTPStatus

# importlib machinery needs to be available for importing client modules
from importlib.machinery import SourceFileLoader
from xmlrpc.server import SimpleXMLRPCRequestHandler, SimpleXMLRPCServer

import unreal

EXECUTION_QUEUE = queue.Queue()
RETURN_VALUE_NAME = 'RPC_SERVER_RETURN_VALUE'
ERROR_VALUE_NAME = 'RPC_SERVER_ERROR_VALUE'


def run_in_main_thread(callable_instance, *args):
    """Runs the provided callable instance in the main thread by added it to a que that
    is processed by a recurring event in an integration like a timer.

    :param call callable_instance: A callable.
    :return: The return value of any call from the client.
    """
    # timeout = int(os.environ.get('RPC_TIME_OUT', 60))

    globals().pop(RETURN_VALUE_NAME, None)
    globals().pop(ERROR_VALUE_NAME, None)
    EXECUTION_QUEUE.put((callable_instance, args))

    # for attempt in range(timeout * 10):
    while True:
        if RETURN_VALUE_NAME in globals():
            return globals().get(RETURN_VALUE_NAME)
        elif ERROR_VALUE_NAME in globals():
            raise globals()[ERROR_VALUE_NAME]
        else:
            time.sleep(0.1)

    # if RETURN_VALUE_NAME not in globals():
    #     raise TimeoutError(
    #         f'The call "{callable_instance.__name__}" timed out because it hit the timeout limit'
    #         f' of {timeout} seconds.'
    #     )


def execute_queued_calls(*extra_args):
    """Runs calls in the execution que till they are gone.

    Designed to be passed to a recurring event in an integration like a timer.
    """
    while not EXECUTION_QUEUE.empty():
        if RETURN_VALUE_NAME not in globals():
            callable_instance, args = EXECUTION_QUEUE.get()
            try:
                globals()[RETURN_VALUE_NAME] = callable_instance(*args)
            except Exception as error:
                # store the error in the globals and re-raise it
                globals()[ERROR_VALUE_NAME] = error
                raise error


class AuthenticatedRequestHandler(SimpleXMLRPCRequestHandler):
    def is_authorized(self):
        """Checks if the Authorization header matches the key generated by the server.

        :returns: Whether the request is authorized.
        :rtype: bool
        """
        # do not allow requests sent cross site
        if self.headers.get('Sec-Fetch-Site') == 'cross-site':
            return False
        # do not allow requests from another origin
        if self.headers.get('Origin'):
            return False
        return True

    def report_401(self):
        """Reports an unauthorized error back to the client."""
        self.send_response(HTTPStatus.UNAUTHORIZED)
        response = b'Not authorized'
        self.send_header('Content-type', 'text/plain')
        self.send_header('Content-length', str(len(response)))
        self.end_headers()
        self.wfile.write(response)

    def do_POST(self):
        """Overrides the post method to implement authentication."""
        if self.is_authorized():
            super(AuthenticatedRequestHandler, self).do_POST()
        else:
            self.report_401()


class BaseServer(SimpleXMLRPCServer):
    def __init__(self, *args, **kwargs):
        kwargs['requestHandler'] = AuthenticatedRequestHandler
        super(BaseServer, self).__init__(*args, **kwargs)

    def serve_until_killed(self):
        """Serves till killed by the client."""
        self.quit = False
        while not self.quit:
            self.handle_request()


class BaseRPCServer:
    def __init__(self, name, port, is_thread=False):
        """Initialize the base server.

        :param str name: The name of the server.
        :param int port: The number of the server port.
        :param bool is_thread: Whether the server is encapsulated in a thread.
        """
        self.server = BaseServer((os.environ.get('RPC_HOST', '127.0.0.1'), port), logRequests=False, allow_none=True)
        self.is_thread = is_thread
        self.server.register_function(self.add_new_callable)
        self.server.register_function(self.kill)
        self.server.register_function(self.is_running)
        self.server.register_function(self.set_env)
        self.server.register_introspection_functions()
        self.server.register_multicall_functions()
        unreal.log(f'Started RPC server "{name}" on port {port}')

    @staticmethod
    def is_running():
        """Responds if the server is running."""
        return True

    @staticmethod
    def set_env(name, value):
        """Sets an environment variable in the server's python environment.

        :param str name: The name of the variable.
        :param str value: The value.
        """
        os.environ[name] = str(value)

    def kill(self):
        """Kill the running server from the client.

        Only if running in blocking mode.
        """
        self.server.quit = True
        return True

    def add_new_callable(self, callable_name, code, client_system_path, remap_pairs=None):
        """Adds a new callable defined in the client to the server.

        :param str callable_name: The name of the function that will be added to the
            server.
        :param str code: The code of the callable that will be added to the server.
            :param list[str] client_system_path: The list of python system paths from
            the client.
        :param list(tuple) remap_pairs: A list of tuples with first value being the
            client python path root and the second being the new server path root. This
            can be useful if the client and server are on two different file systems and
            the root of the import paths need to be dynamically replaced.
        :return str: A response message back to the client.
        """
        for path in client_system_path:
            # if a list of remap pairs are provided, they will be remapped before being added to the system path
            for client_path_root, matching_server_path_root in remap_pairs or []:
                if path.startswith(client_path_root):
                    path = os.path.join(
                        matching_server_path_root, path.replace(client_path_root, '').replace(os.sep, '/').strip('/')
                    )

            if path not in sys.path:
                sys.path.append(path)

        # run the function code
        exec(code)
        callable_instance = locals().copy().get(callable_name)

        # grab it from the locals and register it with the server
        if callable_instance:
            if self.is_thread:
                self.server.register_function(self.thread_safe_call(callable_instance), callable_name)
            else:
                self.server.register_function(callable_instance, callable_name)
        return f'The function "{callable_name}" has been successfully registered with the server!'


class BaseRPCServerThread(threading.Thread, BaseRPCServer):
    def __init__(self, name, port):
        """Initialize the base rpc server.

        :param str name: The name of the server.
        :param int port: The number of the server port.
        """
        threading.Thread.__init__(self, name=name, daemon=True)
        BaseRPCServer.__init__(self, name, port, is_thread=True)

    def run(self):
        """Overrides the run method."""
        self.server.serve_forever()

    @abc.abstractmethod
    def thread_safe_call(self, callable_instance, *args):
        """Implements thread safe execution of a call."""
        return


class BaseRPCServerManager:
    @abc.abstractmethod
    def __init__(self):
        """Initialize the server manager.

        Note: when this class is subclassed `name`, `port`, `threaded_server_class` need to be defined.
        """
        self.server_thread = None
        self.server_blocking = None

    def start_server_thread(self):
        """Starts the server in a thread."""
        self.server_thread = self.threaded_server_class(self.name, self.port)
        self.server_thread.start()

    def start_server_blocking(self):
        """Starts the server in the main thread, which blocks all other processes.

        This can only be killed by the client.
        """
        self.server_blocking = BaseRPCServer(self.name, self.port)
        self.server_blocking.server.serve_until_killed()

    def start(self, threaded=True):
        """Starts the server.

        :param bool threaded: Whether to start the server in a thread. If not threaded
            it will block all other processes.
        """
        # start the server in a thread
        if threaded and not self.server_thread:
            self.start_server_thread()

        # start the blocking server
        elif not threaded and not self.server_blocking:
            self.start_server_blocking()

        else:
            unreal.log(f'RPC server "{self.name}" is already running...')

    def shutdown(self):
        """Shuts down the server."""
        if self.server_thread:
            unreal.log(f'RPC server "{self.name}" is shutting down...')

            # kill the server in the thread
            if self.server_thread:
                self.server_thread.server.shutdown()
                self.server_thread.join()

            unreal.log(f'RPC server "{self.name}" has shutdown')

src/XRFeitoriaUnreal/Content/Python/rpc/base_server.py
openxrlab/xrfeitoria
# Copyright Epic Games, Inc. All Rights Reserved.
# Ref:
# https://github.com/EpicGames/BlenderTools/blob/main/send2ue/dependencies/rpc/unreal_server.py

from . import base_server
from .base_server import BaseRPCServerManager, BaseRPCServerThread


class UnrealRPCServerThread(BaseRPCServerThread):
    def thread_safe_call(self, callable_instance, *args):
        """Implementation of a thread safe call in Unreal."""
        return lambda *args: base_server.run_in_main_thread(callable_instance, *args)


class RPCServer(BaseRPCServerManager):
    def __init__(self, port: int = 9998):
        """Initialize the unreal rpc server, with its name and specific port."""
        super(RPCServer, self).__init__()
        self.name = 'UnrealRPCServer'
        self.port = port
        self.threaded_server_class = UnrealRPCServerThread

    def start_server_thread(self):
        """Starts the server thread."""
        # TODO use a timer exposed from FTSTicker instead of slate tick, less aggressive and safer
        # https://docs.unrealengine.com/4.27/en-US/PythonAPI/class/AutomationScheduler.html?highlight=automationscheduler#unreal.AutomationScheduler
        import unreal

        unreal.register_slate_post_tick_callback(base_server.execute_queued_calls)
        super(RPCServer, self).start_server_thread()

src/XRFeitoriaUnreal/Content/Python/rpc/unreal_server.py
openxrlab/xrfeitoria
from typing import Any, Dict, List, NoReturn, Optional, Tuple, Type, Union

import unreal
import utils_actor
from constants import (
    DEFAULT_SEQUENCE_DATA_ASSET,
    DEFAULT_SEQUENCE_PATH,
    ENGINE_MAJOR_VERSION,
    SequenceTransformKey,
    SubSystem,
    TransformKeys,
    data_asset_suffix,
)
from utils import add_levels, get_levels, get_soft_object_path, get_world, new_world, save_current_level
from utils_actor import get_actor_mesh_component

EditorLevelSequenceSub = SubSystem.EditorLevelSequenceSub
EditorAssetSub = SubSystem.EditorAssetSub
EditorLevelSub = SubSystem.EditorLevelSub
START_FRAME = -1

################################################################################
# misc


def duplicate_binding(binding: unreal.SequencerBindingProxy) -> None:
    """For opening sequencer editor use `unreal.LevelSequenceEditorBlueprintLib
    rary.open_level_sequence(unreal.LevelSequence)` to open."""
    exported_txt = EditorLevelSequenceSub.copy_bindings([binding])
    EditorLevelSequenceSub.paste_bindings(exported_txt, unreal.MovieScenePasteBindingsParams())
    # TODO: the event track would be lost after pasting, need to fix it


def convert_frame_rate_to_fps(frame_rate: unreal.FrameRate) -> float:
    return frame_rate.numerator / frame_rate.denominator


def get_sequence_fps(sequence: unreal.LevelSequence) -> float:
    seq_fps: unreal.FrameRate = sequence.get_display_rate()
    return convert_frame_rate_to_fps(seq_fps)


def get_animation_length(animation_asset: unreal.AnimSequence, seq_fps: float = 30.0) -> int:
    if ENGINE_MAJOR_VERSION == 5:
        # animation fps == sequence fps
        # TODO: check if this is true
        anim_frame_rate = animation_asset.get_editor_property('target_frame_rate')
        anim_frame_rate = convert_frame_rate_to_fps(anim_frame_rate)
        assert (
            anim_frame_rate == seq_fps
        ), f'anim fps {anim_frame_rate} != seq fps {seq_fps}, this would cause animation interpolation.'

        anim_len = animation_asset.get_editor_property('number_of_sampled_frames')

    elif ENGINE_MAJOR_VERSION == 4:
        anim_len = round(animation_asset.get_editor_property('sequence_length') * seq_fps)

    return anim_len


################################################################################
# sequencer session
def find_binding_by_name(sequence: unreal.LevelSequence, name: str) -> unreal.SequencerBindingProxy:
    binding = sequence.find_binding_by_name(name=name)
    if binding.sequence is not None:
        return binding

    for binding in sequence.get_bindings():
        if binding.get_name() == name:
            return binding
    raise RuntimeError(f'Failed to find binding: {name}')


def get_time(sequence: unreal.LevelSequence, frame: int) -> unreal.FrameNumber:
    """Initialize a FrameNumber from the given time and then convert it to a FrameTime
    with no sub-frame.

    Keys can only exist on whole FrameNumbers.
        And transform from our Display Rate to the internal tick resolution.
    Note: equals to multiply 800
    """
    time_as_frame_time = unreal.FrameTime(unreal.FrameNumber(frame))
    frame_time = unreal.TimeManagementLibrary.transform_time(
        time_as_frame_time, sequence.get_display_rate(), sequence.get_tick_resolution()
    )
    return unreal.FrameNumber(frame_time.frame_number.value)


def get_value_from_channel(channel: unreal.MovieSceneScriptingFloatChannel) -> float:
    # XXX: hardcode for first key
    key: unreal.MovieSceneScriptingDoubleAsFloatKey = channel.get_keys()[0]
    return key.get_value()


def get_transform_channels_from_section(
    trans_section: unreal.MovieScene3DTransformSection,
) -> List[unreal.MovieSceneScriptingChannel]:
    (
        channel_x,
        channel_y,
        channel_z,
    ) = (
        channel_roll,
        channel_pitch,
        channel_yaw,
    ) = (
        channel_scale_x,
        channel_scale_y,
        channel_scale_z,
    ) = (None, None, None)
    for channel in trans_section.get_all_channels():
        channel: unreal.MovieSceneScriptingChannel
        if channel.channel_name == 'Location.X':
            channel_x = channel
        elif channel.channel_name == 'Location.Y':
            channel_y = channel
        elif channel.channel_name == 'Location.Z':
            channel_z = channel
        elif channel.channel_name == 'Rotation.X':
            channel_roll = channel
        elif channel.channel_name == 'Rotation.Y':
            channel_pitch = channel
        elif channel.channel_name == 'Rotation.Z':
            channel_yaw = channel
        elif channel.channel_name == 'Scale.X':
            channel_scale_x = channel
        elif channel.channel_name == 'Scale.Y':
            channel_scale_y = channel
        elif channel.channel_name == 'Scale.Z':
            channel_scale_z = channel

    assert channel_x is not None
    assert channel_y is not None
    assert channel_z is not None
    assert channel_roll is not None
    assert channel_pitch is not None
    assert channel_yaw is not None
    assert channel_scale_x is not None
    assert channel_scale_y is not None
    assert channel_scale_z is not None

    return (
        channel_x,
        channel_y,
        channel_z,
        channel_roll,
        channel_pitch,
        channel_yaw,
        channel_scale_x,
        channel_scale_y,
        channel_scale_z,
    )


def set_transforms_by_section(
    trans_section: unreal.MovieScene3DTransformSection,
    trans_keys: Union[Dict, SequenceTransformKey, List[SequenceTransformKey], List[Dict]],
) -> None:
    """Set `loc & rot` keys to given `transform section`

    Args:
        trans_section (unreal.MovieScene3DTransformSection): section
        trans_keys (Union[SequenceTransformKey, List[SequenceTransformKey]]):
            transform keys, contains frame, location, rotation and interpolation

    Examples:
        >>> sequence = unreal.load_asset('/Game/Sequences/NewSequence')
        ... camera_binding = sequence.add_spawnable_from_class(unreal.CameraActor)
        ... transform_track: unreal.MovieScene3DTransformTrack = camera_binding.add_track(unreal.MovieScene3DTransformTrack)
        ... transform_section: unreal.MovieScene3DTransformSection = transform_track.add_section()
        ... set_transforms_by_section(transform_section, [
        ...     SequenceTransformKey(frame=0, location=(0, 0, 0), rotation=(0, 0, 0), interpolation='CONSTANT'),
        ... ])
    """
    (
        channel_x,
        channel_y,
        channel_z,
        channel_roll,
        channel_pitch,
        channel_yaw,
        channel_scale_x,
        channel_scale_y,
        channel_scale_z,
    ) = get_transform_channels_from_section(trans_section)

    if not isinstance(trans_keys, (list, tuple)):
        trans_keys = [trans_keys]
    if isinstance(trans_keys[0], dict):
        trans_keys = [SequenceTransformKey(**k) for k in trans_keys]

    for trans_key in trans_keys:
        trans_key: SequenceTransformKey
        key_frame = trans_key.frame
        key_type = trans_key.interpolation.value
        key_type_ = getattr(unreal.MovieSceneKeyInterpolation, key_type)

        key_time_ = unreal.FrameNumber(key_frame)
        if trans_key.location:
            loc_x, loc_y, loc_z = trans_key.location
            channel_x.add_key(key_time_, loc_x, interpolation=key_type_)
            channel_y.add_key(key_time_, loc_y, interpolation=key_type_)
            channel_z.add_key(key_time_, loc_z, interpolation=key_type_)
        if trans_key.rotation:
            rot_x, rot_y, rot_z = trans_key.rotation
            channel_roll.add_key(key_time_, rot_x, interpolation=key_type_)
            channel_pitch.add_key(key_time_, rot_y, interpolation=key_type_)
            channel_yaw.add_key(key_time_, rot_z, interpolation=key_type_)
        if trans_key.scale:
            scale_x, scale_y, scale_z = trans_key.scale
            channel_scale_x.add_key(key_time_, scale_x, interpolation=key_type_)
            channel_scale_y.add_key(key_time_, scale_y, interpolation=key_type_)
            channel_scale_z.add_key(key_time_, scale_z, interpolation=key_type_)


def set_animation_by_section(
    animation_section: unreal.MovieSceneSkeletalAnimationSection,
    animation_asset: unreal.SkeletalMesh,
    animation_length: Optional[int] = None,
    seq_fps: int = 30,
) -> None:
    animation_length_ = get_animation_length(animation_asset, seq_fps)
    if animation_length is None:
        animation_length = animation_length_
    if animation_length > animation_length_:
        unreal.log_error(f"animation: '{animation_asset.get_name()}' length is too short, it will repeat itself!")

    params = unreal.MovieSceneSkeletalAnimationParams()
    params.set_editor_property('Animation', animation_asset)
    animation_section.set_editor_property('Params', params)
    animation_section.set_range(0, animation_length)


def add_property_bool_track_to_binding(
    binding: unreal.SequencerBindingProxy,
    property_name: str,
    property_value: bool,
) -> Tuple[unreal.MovieSceneBoolTrack, unreal.MovieSceneBoolSection]:
    # add bool track
    bool_track: unreal.MovieSceneBoolTrack = binding.add_track(unreal.MovieSceneBoolTrack)
    bool_track.set_property_name_and_path(property_name, property_name)

    # add bool section, and set it to extend the whole sequence
    bool_section = bool_track.add_section()
    bool_section.set_start_frame_bounded(0)
    bool_section.set_end_frame_bounded(0)

    # set key
    for channel in bool_section.find_channels_by_type(unreal.MovieSceneScriptingBoolChannel):
        channel.set_default(property_value)

    return bool_track, bool_section


def add_property_int_track_to_binding(
    binding: unreal.SequencerBindingProxy,
    property_name: str,
    property_value: int,
) -> Tuple[unreal.MovieSceneIntegerTrack, unreal.MovieSceneIntegerSection]:
    # add int track
    int_track: unreal.MovieSceneIntegerTrack = binding.add_track(unreal.MovieSceneIntegerTrack)
    int_track.set_property_name_and_path(property_name, property_name)

    # add int section, and set it to extend the whole sequence
    int_section = int_track.add_section()
    int_section.set_start_frame_bounded(0)
    int_section.set_end_frame_bounded(0)

    # set key
    for channel in int_section.find_channels_by_type(unreal.MovieSceneScriptingIntegerChannel):
        channel.set_default(property_value)

    return int_track, int_section


def add_property_string_track_to_binding(
    binding: unreal.SequencerBindingProxy,
    property_name: str,
    property_value: Union[str, Dict[int, str]],
) -> Tuple[unreal.MovieSceneStringTrack, unreal.MovieSceneStringSection]:
    # add string track
    string_track: unreal.MovieSceneStringTrack = binding.add_track(unreal.MovieSceneStringTrack)
    string_track.set_property_name_and_path(property_name, property_name)

    # add int section, and set it to extend the whole sequence
    string_section = string_track.add_section()
    string_section.set_start_frame_bounded(0)
    string_section.set_end_frame_bounded(0)

    # set key
    for channel in string_section.find_channels_by_type(unreal.MovieSceneScriptingStringChannel):
        channel: unreal.MovieSceneScriptingStringChannel
        if isinstance(property_value, str):
            channel.set_default(property_value)
        elif isinstance(property_value, dict):
            for key_frame, value in property_value.items():
                channel.add_key(unreal.FrameNumber(key_frame), value)

    return string_track, string_section


def add_property_float_track_to_binding(
    binding: unreal.SequencerBindingProxy,
    property_name: str,
    property_value: float,
) -> Tuple[unreal.MovieSceneFloatTrack, unreal.MovieSceneFloatSection]:
    # add float track
    float_track: unreal.MovieSceneFloatTrack = binding.add_track(unreal.MovieSceneFloatTrack)
    float_track.set_property_name_and_path(property_name, property_name)

    # add float section, and set it to extend the whole sequence
    float_section = float_track.add_section()
    float_section.set_start_frame_bounded(0)
    float_section.set_end_frame_bounded(0)

    # set key
    for channel in float_section.get_channels_by_type(unreal.MovieSceneScriptingFloatChannel):
        channel.set_default(property_value)

    return float_section, float_section


def add_or_find_transform_track_to_binding(
    binding: unreal.SequencerBindingProxy,
) -> Tuple[unreal.MovieScene3DTransformTrack, unreal.MovieScene3DTransformSection]:
    # find transform track
    transform_track = binding.find_tracks_by_type(unreal.MovieScene3DTransformTrack)
    if len(transform_track) > 0:
        transform_track = transform_track[0]
        transform_section = transform_track.get_sections()[0]
        return transform_track, transform_section

    transform_track: unreal.MovieScene3DTransformTrack = binding.add_track(unreal.MovieScene3DTransformTrack)
    transform_section: unreal.MovieScene3DTransformSection = transform_track.add_section()
    # set infinite
    transform_section.set_start_frame_bounded(0)
    transform_section.set_end_frame_bounded(0)

    return transform_track, transform_section


def add_or_find_anim_track_to_binding(
    binding: unreal.SequencerBindingProxy,
) -> Tuple[unreal.MovieSceneSkeletalAnimationTrack, unreal.MovieSceneSkeletalAnimationSection]:
    animation_track = binding.find_tracks_by_type(unreal.MovieSceneSkeletalAnimationTrack)
    if len(animation_track) > 0:
        animation_track = animation_track[0]
        animation_section = animation_track.get_sections()[0]
        return animation_track, animation_section

    animation_track: unreal.MovieSceneSkeletalAnimationTrack = binding.add_track(
        track_type=unreal.MovieSceneSkeletalAnimationTrack
    )
    animation_section: unreal.MovieSceneSkeletalAnimationSection = animation_track.add_section()
    # set infinite
    animation_section.set_start_frame_bounded(0)
    animation_section.set_end_frame_bounded(0)
    return animation_track, animation_section


def add_transforms_to_binding(
    binding: unreal.SequencerBindingProxy,
    actor_trans_keys: Union[SequenceTransformKey, List[SequenceTransformKey]],
):
    _, transform_section = add_or_find_transform_track_to_binding(binding)
    # add keys
    set_transforms_by_section(transform_section, actor_trans_keys)


def add_animation_to_binding(
    binding: unreal.SequencerBindingProxy,
    animation_asset: unreal.AnimSequence,
    animation_length: Optional[int] = None,
    seq_fps: Optional[float] = None,
) -> None:
    _, animation_section = add_or_find_anim_track_to_binding(binding)
    set_animation_by_section(animation_section, animation_asset, animation_length, seq_fps)


def get_spawnable_actor_from_binding(
    sequence: unreal.MovieSceneSequence,
    binding: unreal.SequencerBindingProxy,
) -> unreal.Actor:
    binds = unreal.Array(unreal.SequencerBindingProxy)
    binds.append(binding)

    bound_objects: List[unreal.SequencerBoundObjects] = unreal.SequencerTools.get_bound_objects(
        get_world(), sequence, binds, sequence.get_playback_range()
    )

    actor = bound_objects[0].bound_objects[0]
    return actor


################################################################################
# high level functions


def add_level_visibility_to_sequence(
    sequence: unreal.LevelSequence,
    seq_length: Optional[int] = None,
) -> None:
    if seq_length is None:
        seq_length = sequence.get_playback_end()

    # add master track (level visibility) to sequence
    level_visibility_track: unreal.MovieSceneLevelVisibilityTrack = sequence.add_master_track(
        unreal.MovieSceneLevelVisibilityTrack
    )
    # add level visibility section
    level_visible_section: unreal.MovieSceneLevelVisibilitySection = level_visibility_track.add_section()
    level_visible_section.set_visibility(unreal.LevelVisibility.VISIBLE)
    level_visible_section.set_start_frame(START_FRAME)
    level_visible_section.set_end_frame(seq_length)

    level_hidden_section: unreal.MovieSceneLevelVisibilitySection = level_visibility_track.add_section()
    level_hidden_section.set_row_index(1)
    level_hidden_section.set_visibility(unreal.LevelVisibility.HIDDEN)
    level_hidden_section.set_start_frame(START_FRAME)
    level_hidden_section.set_end_frame(seq_length)
    return level_visible_section, level_hidden_section


def add_level_to_sequence(
    sequence: unreal.LevelSequence,
    persistent_level_path: str,
    new_level_path: str,
    seq_fps: Optional[float] = None,
    seq_length: Optional[int] = None,
) -> None:
    """Creating a new level which contains the persistent level as sub-levels.
    `CAUTION`: this function can't support `World Partition` type level which is new in
    unreal 5. No warning/error would be printed if `World partition` is used, but it
    will not work.

    Args:
        sequence (unreal.LevelSequence): _description_
        persistent_level_path (str): _description_
        new_level_path (str): _description_
        seq_fps (Optional[float], optional): _description_. Defaults to None.
        seq_length (Optional[int], optional): _description_. Defaults to None.
    """

    # get sequence settings
    if seq_fps is None:
        seq_fps = get_sequence_fps(sequence)
    if seq_length is None:
        seq_length = sequence.get_playback_end()

    # create a new level to place actors
    success = new_world(new_level_path)
    print(f"new level: '{new_level_path}' created: {success}")
    assert success, RuntimeError('Failed to create level')

    level_visible_names, level_hidden_names = add_levels(persistent_level_path, new_level_path)
    level_visible_section, level_hidden_section = add_level_visibility_to_sequence(sequence, seq_length)

    # set level visibility
    level_visible_section.set_level_names(level_visible_names)
    level_hidden_section.set_level_names(level_hidden_names)

    # set created level as current level
    world = get_world()
    levels = get_levels(world)
    unreal.XF_BlueprintFunctionLibrary.set_level(world, levels[0])
    save_current_level()


def add_camera_to_sequence(
    sequence: unreal.LevelSequence,
    camera: unreal.CameraActor,
    camera_transform_keys: Optional[Union[SequenceTransformKey, List[SequenceTransformKey]]] = None,
    camera_fov: float = 90.0,
    seq_length: Optional[int] = None,
) -> unreal.CameraActor:
    if seq_length is None:
        seq_length = sequence.get_playback_end()

    # ------- add camera to seq ------ #
    camera_binding = sequence.add_possessable(camera)
    camera_track: unreal.MovieScene3DTransformTrack = camera_binding.add_track(unreal.MovieScene3DTransformTrack)  # type: ignore
    camera_section: unreal.MovieScene3DTransformSection = camera_track.add_section()  # type: ignore
    camera_section.set_start_frame(START_FRAME)
    camera_section.set_end_frame(seq_length)
    camera_component_binding = sequence.add_possessable(camera.camera_component)
    camera_component_binding.set_parent(camera_binding)

    # set the camera FOV
    fov_track, fov_section = add_property_float_track_to_binding(camera_component_binding, 'FieldOfView', camera_fov)

    # ------- add master track ------- #
    camera_cut_track: unreal.MovieSceneCameraCutTrack = sequence.add_master_track(unreal.MovieSceneCameraCutTrack)  # type: ignore

    # add a camera cut track for this camera
    # make sure the camera cut is stretched to the START_FRAME mark
    camera_cut_section: unreal.MovieSceneCameraCutSection = camera_cut_track.add_section()  # type: ignore
    camera_cut_section.set_start_frame(START_FRAME)
    camera_cut_section.set_end_frame(seq_length)

    # set the camera cut to use this camera
    camera_cut_section.set_camera_binding_id(camera_binding.get_binding_id())

    # ------- add transform track ------- #
    transform_track, transform_section = add_or_find_transform_track_to_binding(camera_binding)
    if camera_transform_keys:
        # set the camera location and rotation
        add_transforms_to_binding(camera_binding, camera_transform_keys)

    return {
        'camera': {
            'binding': camera_binding,
            'self': camera,
        },
        'camera_component': {
            'binding': camera_component_binding,
            'self': camera.camera_component,
        },
        'fov': {'track': fov_track, 'section': fov_section},
        'transform': {'track': transform_track, 'section': transform_section},
    }


def add_spawnable_camera_to_sequence(
    sequence: unreal.LevelSequence,
    camera_name: str,
    camera_transform_keys: Optional[Union[SequenceTransformKey, List[SequenceTransformKey]]] = None,
    camera_class: Type[unreal.CameraActor] = unreal.CameraActor,
    camera_fov: float = 90.0,
    seq_length: Optional[int] = None,
) -> unreal.CameraActor:
    """Add a camera actor to the sequence.

    Args:
        sequence (unreal.LevelSequence): a loaded sequence.
        camera_name (str): name of the camera actor to set.
        camera_transform_keys (Optional[Union[SequenceTransformKey, List[SequenceTransformKey]]], optional): transform keys of the camera actor. Defaults to None.
        camera_class (Type[unreal.CameraActor], optional): the camera actor class to spawn. Defaults to unreal.CameraActor.
        camera_fov (float, optional): Filed of view of the camera. Defaults to 90.0.
        seq_length (Optional[int], optional): Sequence length. Defaults to None.

    Returns:
        unreal.CameraActor: _description_
    """
    # get sequence settings
    if seq_length is None:
        seq_length = sequence.get_playback_end()

    # ---------- add camera ---------- #
    # create a camera actor & add it to the sequence
    camera_binding = sequence.add_spawnable_from_class(camera_class)
    camera_actor: unreal.CameraActor = get_spawnable_actor_from_binding(sequence, camera_binding)
    camera_binding.set_name(camera_name)
    camera_actor.set_actor_label(camera_name)
    camera_component_binding = sequence.add_possessable(camera_actor.camera_component)
    camera_component_binding.set_parent(camera_binding)

    # set the camera FOV
    fov_track, fov_section = add_property_float_track_to_binding(camera_component_binding, 'FieldOfView', camera_fov)

    # ------- add master track ------- #
    # add master track (camera) to sequence
    # camera_cut_track = sequence.add_track(unreal.MovieSceneCameraCutTrack)
    camera_cut_track: unreal.MovieSceneCameraCutTrack = sequence.add_master_track(unreal.MovieSceneCameraCutTrack)

    # add a camera cut track for this camera, make sure the camera cut is stretched to the START_FRAME mark
    camera_cut_section: unreal.MovieSceneCameraCutSection = camera_cut_track.add_section()
    camera_cut_section.set_start_frame(START_FRAME)
    camera_cut_section.set_end_frame(seq_length)

    # set the camera cut to use this camera

    # camera_binding_id = unreal.MovieSceneObjectBindingID()
    # camera_binding_id.set_editor_property("Guid", camera_binding.get_id())
    # camera_cut_section.set_editor_property("CameraBindingID", camera_binding_id)

    # camera_binding_id = sequence.make_binding_id(camera_binding, unreal.MovieSceneObjectBindingSpace.LOCAL)
    # camera_cut_section.set_camera_binding_id(camera_binding_id)

    camera_cut_section.set_camera_binding_id(camera_binding.get_binding_id())

    # ------- add transform track ------- #
    transform_track, transform_section = add_or_find_transform_track_to_binding(camera_binding)
    if camera_transform_keys:
        # set the camera location and rotation
        add_transforms_to_binding(camera_binding, camera_transform_keys)

    return {
        'camera': {
            'binding': camera_binding,
            'self': camera_actor,
        },
        'camera_component': {
            'binding': camera_component_binding,
            'self': camera_actor.camera_component,
        },
        'fov': {'track': fov_track, 'section': fov_section},
        'transform': {'track': transform_track, 'section': transform_section},
    }


def add_actor_to_sequence(
    sequence: unreal.LevelSequence,
    actor: unreal.Actor,
    actor_transform_keys: Optional[Union[SequenceTransformKey, List[SequenceTransformKey]]] = None,
    actor_stencil_value: int = 1,
    animation_asset: Optional[unreal.AnimSequence] = None,
    seq_fps: Optional[float] = None,
    seq_length: Optional[int] = None,
) -> Dict[str, Any]:
    # get sequence settings
    if seq_fps is None:
        seq_fps = get_sequence_fps(sequence)
    if seq_length is None:
        if animation_asset:
            seq_length = get_animation_length(animation_asset, seq_fps)
        else:
            seq_length = sequence.get_playback_end()

    # ------- add actor to seq ------ #
    actor_binding = sequence.add_possessable(actor)

    # mesh_component = actor.skeletal_mesh_component
    mesh_component = get_actor_mesh_component(actor)
    mesh_component_binding = sequence.add_possessable(mesh_component)

    # set stencil value
    custom_depth_track, custom_depth_section = add_property_bool_track_to_binding(
        mesh_component_binding, 'bRenderCustomDepth', True
    )
    stencil_value_track, stencil_value_section = add_property_int_track_to_binding(
        mesh_component_binding, 'CustomDepthStencilValue', actor_stencil_value
    )

    # add animation
    if animation_asset:
        add_animation_to_binding(actor_binding, animation_asset, seq_length, seq_fps)

    # ------- add transform track ------- #
    transform_track, transform_section = add_or_find_transform_track_to_binding(actor_binding)
    if actor_transform_keys:
        add_transforms_to_binding(actor_binding, actor_transform_keys)

    return {
        'actor': {'binding': actor_binding, 'self': actor},
        'transform': {'track': transform_track, 'section': transform_section},
        # "animation": {"track": animation_track, "section": animation_section},
        'mesh_component': {
            'binding': mesh_component_binding,
            'self': mesh_component,
            'custom_depth': {'track': custom_depth_track, 'section': custom_depth_section},
            'stencil_value': {'track': stencil_value_track, 'section': stencil_value_section},
        },
    }


def add_spawnable_actor_to_sequence(
    sequence: unreal.LevelSequence,
    actor_name: str,
    actor_asset: Union[unreal.SkeletalMesh, unreal.StaticMesh],
    animation_asset: Optional[unreal.AnimSequence] = None,
    actor_transform_keys: Optional[Union[SequenceTransformKey, List[SequenceTransformKey]]] = None,
    actor_stencil_value: int = 1,
    seq_fps: Optional[float] = None,
    seq_length: Optional[int] = None,
) -> Dict[str, Any]:
    # get sequence settings
    if seq_fps is None:
        seq_fps = get_sequence_fps(sequence)
    if seq_length is None:
        if animation_asset:
            seq_length = get_animation_length(animation_asset, seq_fps)
        else:
            seq_length = sequence.get_playback_end()

    # add actor to sequence
    actor_binding = sequence.add_spawnable_from_instance(actor_asset)
    actor = get_spawnable_actor_from_binding(sequence, actor_binding)
    actor_binding.set_name(actor_name)
    actor.set_actor_label(actor_name)

    # mesh_component = actor.skeletal_mesh_component
    mesh_component = get_actor_mesh_component(actor)
    mesh_component_binding = sequence.add_possessable(mesh_component)

    # set stencil value
    custom_depth_track, custom_depth_section = add_property_bool_track_to_binding(
        mesh_component_binding, 'bRenderCustomDepth', True
    )
    stencil_value_track, stencil_value_section = add_property_int_track_to_binding(
        mesh_component_binding, 'CustomDepthStencilValue', actor_stencil_value
    )

    # add animation
    if animation_asset:
        add_animation_to_binding(actor_binding, animation_asset, seq_length, seq_fps)

    # ------- add transform track ------- #
    transform_track, transform_section = add_or_find_transform_track_to_binding(actor_binding)
    if actor_transform_keys:
        add_transforms_to_binding(actor_binding, actor_transform_keys)

    return {
        'actor': {'binding': actor_binding, 'self': actor},
        'transform': {'track': transform_track, 'section': transform_section},
        # "animation": {"track": animation_track, "section": animation_section},
        'mesh_component': {
            'binding': mesh_component_binding,
            'self': mesh_component,
            'custom_depth': {'track': custom_depth_track, 'section': custom_depth_section},
            'stencil_value': {'track': stencil_value_track, 'section': stencil_value_section},
        },
    }


def generate_sequence(
    sequence_dir: str,
    sequence_name: str,
    seq_fps: Optional[float] = None,
    seq_length: Optional[int] = None,
) -> unreal.LevelSequence:
    asset_tools: unreal.AssetTools = unreal.AssetToolsHelpers.get_asset_tools()  # type: ignore
    new_sequence: unreal.LevelSequence = asset_tools.create_asset(
        sequence_name,
        sequence_dir,
        unreal.LevelSequence,
        unreal.LevelSequenceFactoryNew(),
    )
    assert new_sequence is not None, f'Failed to create LevelSequence: {sequence_dir}/{sequence_name}'
    # Set sequence config
    if seq_fps:
        new_sequence.set_display_rate(unreal.FrameRate(seq_fps))
    if seq_length:
        new_sequence.set_playback_end(seq_length)
    return new_sequence


class Sequence:
    map_path = None
    sequence_path = None
    sequence_data_asset: unreal.DataAsset = None  # contains sequence_path and map_path
    sequence: unreal.LevelSequence = None
    bindings: Dict[str, Dict[str, Any]] = {}

    def __init__(self) -> NoReturn:
        raise Exception('Sequence (XRFeitoriaUnreal/Python) should not be instantiated')

    def __del__(self) -> NoReturn:
        # TODO: when error was raised from spawn_xxx, the sequence is not closed, which would cause unable to delete the sequence asset
        del self.map_path
        del self.sequence_path
        del self.sequence
        del self.sequence_data_asset
        del self.bindings

    @classmethod
    def save(cls) -> None:
        if cls.sequence is not None:
            unreal.EditorAssetLibrary.save_loaded_asset(cls.sequence, False)

    @classmethod
    def close(cls) -> None:
        if cls.sequence is not None:
            unreal.LevelSequenceEditorBlueprintLibrary.close_level_sequence()
            del cls.sequence
        cls.map_path = None
        cls.sequence_path = None
        cls.sequence = None
        cls.bindings = {}

    @classmethod
    def open(
        cls,
        map_path: str,
        seq_path: str,
    ) -> None:
        assert unreal.EditorAssetLibrary.does_asset_exist(map_path), f'Map `{map_path}` does not exist'
        assert unreal.EditorAssetLibrary.does_asset_exist(
            seq_path,
        ), f'Sequence `{seq_path}` does not exist'

        unreal.EditorLoadingAndSavingUtils.load_map(map_path)
        cls.map_path = map_path
        cls.sequence_path = seq_path
        cls.sequence: unreal.LevelSequence = unreal.load_asset(cls.sequence_path)

    @classmethod
    def new(
        cls,
        seq_name: str,
        map_path: 'Optional[str]' = None,
        seq_dir: 'Optional[str]' = None,
        seq_fps: 'Optional[float]' = None,
        seq_length: 'Optional[int]' = None,
        replace: bool = False,
    ) -> str:
        """Create a new sequence.

        Args:
            seq_name (str): name of the sequence.
            map_path (Optional[str], optional): path of the map asset. Defaults to None. When None, the current map will be used.
            seq_dir (Optional[str], optional): path of the sequence asset. Defaults to None.
            seq_fps (Optional[float], optional): FPS of the sequence. Defaults to None.
            seq_length (Optional[int], optional): length of the sequence. Defaults to None.
            replace (bool, optional): whether to replace the sequence if it already exists. Defaults to False.

        Returns:
            str: path of the data asset of sequence data, containing sequence_path and map_path.
        """
        if map_path is None or map_path == '' or map_path == 'None':
            map_path = EditorLevelSub.get_current_level().get_path_name().split('.')[0]
        assert unreal.EditorAssetLibrary.does_asset_exist(map_path), f'Map `{map_path}` does not exist'
        if seq_dir is None:
            seq_dir = DEFAULT_SEQUENCE_PATH

        data_asset_path = f'{seq_dir}/{seq_name}{data_asset_suffix}'
        if unreal.EditorAssetLibrary.does_asset_exist(f'{seq_dir}/{seq_name}'):
            if replace:
                unreal.EditorAssetLibrary.delete_asset(f'{seq_dir}/{seq_name}')
                unreal.EditorAssetLibrary.delete_asset(data_asset_path)
            else:
                raise Exception(f'Sequence `{seq_dir}/{seq_name}` already exists, use `replace=True` to replace it')

        unreal.EditorLoadingAndSavingUtils.load_map(map_path)
        cls.map_path = map_path
        cls.sequence = generate_sequence(
            sequence_dir=seq_dir,
            sequence_name=seq_name,
            seq_fps=seq_fps,
            seq_length=seq_length,
        )
        cls.sequence_path = f'{seq_dir}/{seq_name}'

        cls.sequence_data_asset = cls.new_data_asset(
            asset_path=data_asset_path,
            map_path=cls.map_path,
            sequence_path=cls.sequence_path,
        )
        return data_asset_path

    @classmethod
    def show(cls) -> None:
        assert cls.sequence is not None, 'Sequence not initialized'
        unreal.LevelSequenceEditorBlueprintLibrary.open_level_sequence(cls.sequence)

    @staticmethod
    def new_data_asset(
        asset_path: str,
        sequence_path: str,
        map_path: str,
    ) -> unreal.DataAsset:
        """Create a new data asset of sequence data.

        Args:
            asset_path (str): path of the data asset.
            sequence_path (str): path of the sequence asset.
            map_path (str): path of the map asset.

        Returns:
            unreal.DataAsset: the created data asset.

        Notes:
            SequenceData Properties:
                - "SequencePath": str
                - "MapPath": str
        """
        # maybe `unreal.EditorAssetLibrary.load_blueprint_class`
        unreal.EditorAssetLibrary.duplicate_asset(
            source_asset_path=DEFAULT_SEQUENCE_DATA_ASSET,
            destination_asset_path=asset_path,
        )
        if not unreal.EditorAssetLibrary.does_asset_exist(asset_path):
            raise RuntimeError(f'Failed to create data asset: {asset_path}')
        asset = unreal.load_asset(asset_path)
        assert asset is not None, f'Failed to create data asset: {asset_path}'
        asset.set_editor_property('SequencePath', get_soft_object_path(sequence_path))
        asset.set_editor_property('MapPath', get_soft_object_path(map_path))
        unreal.EditorAssetLibrary.save_asset(asset_path)
        return asset

    @staticmethod
    def get_data_asset_info(data_asset_path: str) -> Tuple[str, str]:
        """Get sequence path and map path from data asset.

        Args:
            data_asset_path (str): path of the data asset.
        """
        seq_data_asset = unreal.load_asset(data_asset_path)
        assert seq_data_asset is not None, f'Failed to load data asset: {data_asset_path}'
        seq_path = seq_data_asset.get_editor_property('SequencePath').export_text()
        map_path = seq_data_asset.get_editor_property('MapPath').export_text()
        return seq_path.split('.')[0], map_path.split('.')[0]

    # ------ add actor and camera -------- #

    @classmethod
    def add_camera(
        cls,
        camera_name: str,
        transform_keys: 'Optional[TransformKeys]' = None,
        fov: float = 90.0,
        spawnable: bool = False,
    ):
        """Spawn a camera in sequence.

        Args:
            transform_keys (Optional[TransformKeys], optional): List of transform keys. Defaults to None.
            fov (float, optional): Field of view of camera. Defaults to 90.0.
            camera_name (str, optional): Name of camera to set in sequence. Defaults to "Camera".
        """
        assert cls.sequence is not None, 'Sequence not initialized'
        if spawnable:
            bindings = add_spawnable_camera_to_sequence(
                sequence=cls.sequence,
                camera_name=camera_name,
                camera_transform_keys=transform_keys,
                camera_fov=fov,
            )
            cls.bindings[camera_name] = bindings
        else:
            camera = utils_actor.get_actor_by_name(camera_name)
            bindings = add_camera_to_sequence(
                sequence=cls.sequence,
                camera=camera,
                camera_transform_keys=transform_keys,
                camera_fov=fov,
            )
            cls.bindings[camera_name] = bindings

    @classmethod
    def add_actor(
        cls,
        actor_name: str,
        actor: 'Optional[Union[str, unreal.Actor]]' = None,
        transform_keys: 'Optional[TransformKeys]' = None,
        stencil_value: int = 1,
        animation_asset: 'Optional[Union[str, unreal.AnimSequence]]' = None,
    ) -> None:
        """Spawn an actor in sequence.

        Args:
            actor (Union[str, unreal.Actor]): actor path (e.g. '/Game/Cube') / loaded asset (via `unreal.load_asset('/Game/Cube')`)
            animation_asset (Union[str, unreal.AnimSequence]): animation path (e.g. '/Game/Anim') / loaded asset (via `unreal.load_asset('/Game/Anim')`). Can be None which means no animation.
            actor_name (str, optional): Name of actor to set in sequence. Defaults to "Actor".
            transform_keys (TransformKeys, optional): List of transform keys. Defaults to None.
            actor_stencil_value (int, optional): Stencil value of actor, used for specifying the mask color for this actor (mask id). Defaults to 1.
        """
        assert cls.sequence is not None, 'Sequence not initialized'
        if animation_asset and isinstance(animation_asset, str):
            animation_asset = unreal.load_asset(animation_asset)
        if isinstance(actor, str):
            actor = unreal.load_asset(actor)

        if actor:
            bindings = add_spawnable_actor_to_sequence(
                sequence=cls.sequence,
                actor_name=actor_name,
                actor_asset=actor,
                animation_asset=animation_asset,
                actor_transform_keys=transform_keys,
                actor_stencil_value=stencil_value,
            )
            cls.bindings[actor_name] = bindings

        else:
            actor = utils_actor.get_actor_by_name(actor_name)
            bindings = add_actor_to_sequence(
                sequence=cls.sequence,
                actor=actor,
                actor_transform_keys=transform_keys,
                actor_stencil_value=stencil_value,
                animation_asset=animation_asset,
            )
            cls.bindings[actor_name] = bindings


if __name__ == '__main__':
    Sequence.new('/Game/NewMap', 'test1')
    Sequence.spawn_camera(transform_keys=SequenceTransformKey(frame=0, location=(0, 0, 0), rotation=(0, 0, 0)))
    Sequence.spawn_actor(
        '/Game/StarterContent/Props/SM_Chair',
        transform_keys=SequenceTransformKey(frame=0, location=(0, 0, 0), rotation=(0, 0, 0)),
    )

src/XRFeitoriaUnreal/Content/Python/sequence.py
openxrlab/xrfeitoria
import constants
import utils
import utils_actor
from custom_movie_pipeline import CustomMoviePipeline
from sequence import Sequence


class XRFeitoriaUnrealFactory:
    utils = utils
    utils_actor = utils_actor
    constants = constants
    SubSystem = constants.SubSystem
    CustomMoviePipeline = CustomMoviePipeline
    Sequence = Sequence

src/XRFeitoriaUnreal/Content/Python/unreal_factory.py
openxrlab/xrfeitoria
import time
from pathlib import Path
from typing import Dict, List, Optional, Tuple, Union

import unreal
from constants import DEFAULT_ASSET_PATH, SubSystem

EditorAssetSub = SubSystem.EditorAssetSub
EditorSub = SubSystem.EditorSub
EditorLevelSub = SubSystem.EditorLevelSub

################################################################################
# define decorators


class Loader:
    """A decorator to load the persistent level and content browser before running the
    main function.

    example_usage:
        >>> @Loader
        >>> def main():
        >>>     ...
    Caution: Function decorated by this decorator cannot return anything.
    """

    def __init__(self, func):
        self.func = func
        self.args: Tuple = None
        self.kwargs: Dict = None

        self.asset_registry = unreal.AssetRegistryHelpers.get_asset_registry()
        self.tickhandle = unreal.register_slate_pre_tick_callback(self.timer)
        unreal.log_warning('registered tick handle')

    def __call__(self, *args, **kwargs):
        if args is None:
            args = []
        self.args = args

        if kwargs is None:
            kwargs = {}
        self.kwargs = kwargs

    def timer(self, deltaTime):
        if self.asset_registry.is_loading_assets():
            unreal.log_warning('loading assets...')
        else:
            unreal.log_warning('ready!')
            unreal.unregister_slate_pre_tick_callback(self.tickhandle)
            self.func(*self.args, **self.kwargs)


def timer_func(func):
    # This function shows the execution time of
    # the function object passed
    def wrap_func(*args, **kwargs):
        t1 = time.time()
        result = func(*args, **kwargs)
        t2 = time.time()
        unreal.log(f'[Timer] Function {repr(func.__name__)} executed in {(t2-t1):.4f}s')
        return result

    return wrap_func


################################################################################
# assets


def import_asset(path: Union[str, List[str]], dst_dir_in_engine: Optional[str] = None) -> List[str]:
    """Import assets to the default asset path.

    Args:
        path (Union[str, List[str]]): a file path or a list of file paths to import, e.g. "D:/assets/SMPL_XL.fbx"
        dst_dir_in_engine (str, optional): destination directory in the engine.
            Defaults to None falls back to DEFAULT_ASSET_PATH.

    Returns:
        List[str]: a list of paths to the imported assets, e.g. ["/Game/XRFeitoriaUnreal/Assets/SMPL_XL"]
    """
    if dst_dir_in_engine is None:
        dst_dir_in_engine = DEFAULT_ASSET_PATH
    if not isinstance(path, list):
        paths = [path]
    else:
        paths = path.copy()

    asset_paths = []
    for path in paths:
        name = Path(path).stem
        dst_dir = unreal.Paths.combine([dst_dir_in_engine, Path(path).stem])
        # check if asset exists
        dst_path = unreal.Paths.combine([dst_dir, name])
        if unreal.EditorAssetLibrary.does_asset_exist(dst_path):
            asset_paths.append(dst_path)
            continue

        unreal.log(f'Importing asset: {path}')
        # assetsTools = unreal.AssetToolsHelpers.get_asset_tools()
        # assetImportData = unreal.AutomatedAssetImportData()
        # assetImportData.destination_path = dst_dir
        # assetImportData.filenames = [p]
        # assets: List[unreal.Object] = assetsTools.import_assets_automated(assetImportData)
        # asset_paths.extend([asset.get_path_name().split('.')[0] for asset in assets])

        asset_tools = unreal.AssetToolsHelpers.get_asset_tools()
        import_options = unreal.FbxImportUI()
        import_options.set_editor_property('import_animations', True)

        import_task = unreal.AssetImportTask()
        import_task.set_editor_property('automated', True)
        import_task.set_editor_property('destination_name', '')
        import_task.set_editor_property('destination_path', dst_dir)
        import_task.set_editor_property('filename', path)
        import_task.set_editor_property('replace_existing', True)
        import_task.set_editor_property('options', import_options)

        import_tasks = [import_task]
        asset_tools.import_asset_tasks(import_tasks)
        asset_paths.extend([path.split('.')[0] for path in import_task.get_editor_property('imported_object_paths')])

        unreal.EditorAssetLibrary.save_directory(dst_dir, False, True)  # save assets
        unreal.log(f'Imported asset: {path}')
    return asset_paths


def import_anim(path: str, skeleton_path: str) -> List[str]:
    """Import animation to the default asset path.

    Args:
        path (str): a file path to import, e.g. "D:/assets/SMPL_XL.fbx"
        skeleton_path (str): a path to the skeleton, e.g. "/Game/XRFeitoriaUnreal/Assets/SMPL_XL"

    Returns:
        str: a path to the imported animation, e.g. "/Game/XRFeitoriaUnreal/Assets/SMPL_XL"
    """
    # init task
    import_task = unreal.AssetImportTask()
    import_task.set_editor_property('filename', path)
    # set destination path to {skeleton_path}/Animation
    dst_path = unreal.Paths.combine([unreal.Paths.get_path(skeleton_path), 'Animation'])
    import_task.set_editor_property('destination_path', dst_path)
    import_task.set_editor_property('replace_existing', True)
    import_task.set_editor_property('replace_existing_settings', True)
    import_task.set_editor_property('automated', True)
    # options for importing animation
    options = unreal.FbxImportUI()
    options.mesh_type_to_import = unreal.FBXImportType.FBXIT_ANIMATION
    options.skeleton = unreal.load_asset(skeleton_path)
    import_data = unreal.FbxAnimSequenceImportData()
    import_data.set_editor_properties(
        {
            'animation_length': unreal.FBXAnimationLengthImportType.FBXALIT_EXPORTED_TIME,
            'remove_redundant_keys': True,
        }
    )
    options.anim_sequence_import_data = import_data
    # set options
    import_task.set_editor_property('options', options)
    unreal.AssetToolsHelpers.get_asset_tools().import_asset_tasks([import_task])

    # save assets
    unreal.EditorAssetLibrary.save_directory(dst_path, False, True)
    # return paths
    return [path.split('.')[0] for path in import_task.get_editor_property('imported_object_paths')]


def delete_asset(asset_path: str) -> None:
    """Delete asset.

    Args:
        asset_path (str): asset path in unreal, e.g. "/Game/Resources/Mat0"
    """
    unreal.EditorAssetLibrary.delete_asset(asset_path)


def open_asset(asset_path: str) -> None:
    """Open asset.

    Args:
        asset_path (str): asset path in unreal, e.g. "/Game/Resources/Mat0"
    """
    asset = unreal.load_asset(asset_path)
    unreal.AssetToolsHelpers.get_asset_tools().open_editor_for_assets([asset])


################################################################################
# misc


class PathUtils:
    asset_registry: unreal.AssetRegistry = unreal.AssetRegistryHelpers.get_asset_registry()

    @classmethod
    def get_sub_paths(cls, path, recurse=True) -> List[str]:
        return cls.asset_registry.get_sub_paths(path, recurse=recurse)

    @staticmethod
    def list_dir(path: str, recursive: bool = True) -> List[str]:
        return sorted(unreal.EditorAssetLibrary.list_assets(path, recursive=recursive))


def get_asset_class(asset_path: str) -> str:
    asset_class_path = unreal.EditorAssetLibrary.find_asset_data(asset_path).asset_class_path
    return str(asset_class_path.asset_name)


def create_material_instance(asset_path: str) -> unreal.MaterialInstance:
    """Create material instance.

    Args:
        asset_path (str): asset path in unreal, e.g. "/Game/Resources/Mat0"
    Returns:
        my_new_asset (unreal.MaterialInstance): a new material instance
    """
    package_path, asset_name = asset_path.rsplit('/', 1)
    factory = unreal.MaterialInstanceConstantFactoryNew()
    asset_tools = unreal.AssetToolsHelpers.get_asset_tools()
    my_new_asset = asset_tools.create_asset(asset_name, package_path, None, factory)
    unreal.EditorAssetLibrary.save_loaded_asset(my_new_asset)
    return my_new_asset


def get_world() -> unreal.World:
    if EditorSub:
        world = EditorSub.get_editor_world()
    else:
        world = unreal.EditorLevelLibrary.get_editor_world()
    return world


def new_world(new_level_path: str) -> bool:
    if EditorLevelSub:
        success = EditorLevelSub.new_level(new_level_path)
    else:
        success = unreal.EditorLevelLibrary().new_level(new_level_path)
    return success


def save_current_level() -> None:
    """Save current level."""
    if EditorLevelSub:
        EditorLevelSub.save_current_level()
    else:
        unreal.EditorLevelLibrary.save_current_level()


def get_soft_object_path(path: str) -> unreal.SoftObjectPath:
    """Get soft object path from a path string, e.g. "/Game/Maps/PersistentLevel" ->
    "/Game/Maps/PersistentLevel.PersistentLevel.

    Args:
        path (str): a path to the persistent level, e.g. "/Game/Maps/PersistentLevel"

    Returns:
        soft_object_path (unreal.SoftObjectPath): a soft object path, e.g. "/Game/Maps/PersistentLevel.PersistentLevel
    """
    path = f'{path}.{path.split("/")[-1]}'
    return unreal.SoftObjectPath(path)


def get_levels(world: Optional[unreal.World] = None) -> List[unreal.Level]:
    if not world:
        world = get_world()
    levels = unreal.EditorLevelUtils.get_levels(world)
    return levels


def get_sublevels(persistent_level_path: str) -> List[Dict]:
    """Get sublevels of persistent level, this step would load the persistent level.

    Args:
        persistent_level_path (str): a path to the persistent level, e.g. "/Game/Maps/PersistentLevel"

    Returns:
        level_configs (List(Dict)): a list of level_configs (dict) containing keys of
            `level_name`, `is_visible`, and `streaming_class`.
    """
    asset_registry: unreal.AssetRegistry = unreal.AssetRegistryHelpers.get_asset_registry()

    # load persistent level
    world = unreal.EditorLoadingAndSavingUtils.load_map(persistent_level_path)
    # unreal.GameplayStatics.open_level(self.get_last_loaded_world(), mapPackagePath, True, gameOverrideClassPath)

    # get all sub-levels of persistent level and their config
    level_configs = []
    persistent_level_asset_data = asset_registry.get_asset_by_object_path(persistent_level_path)
    persistent_level_name = str(persistent_level_asset_data.package_name)
    level_configs.append(
        {
            'level_name': persistent_level_name,
            'is_visible': True,
            'streaming_class': 'LevelStreamingAlwaysLoaded',
        }
    )

    levels = get_levels(world)
    for level in levels:
        asset_data = asset_registry.get_asset_by_object_path(level.get_path_name())
        sub_level_name = str(asset_data.package_name)
        streaming_level = unreal.GameplayStatics.get_streaming_level(world, sub_level_name)
        if streaming_level is not None:
            level_configs.append(
                {
                    'level_name': sub_level_name,
                    'is_visible': streaming_level.is_level_visible(),
                    'streaming_class': type(streaming_level).__name__,
                }
            )

    unreal.log(level_configs)
    return level_configs


def add_levels(persistent_level_path: str, new_level_path: str) -> Tuple[List, List]:
    """Add levels from persistent level to the current world.

    Args:
        persistent_level_path (str): a path to the persistent level, e.g. "/Game/Maps/PersistentLevel"

    Returns:
        level_visible_names (list): a list of visible level names
        level_hidden_names (list): a list of hidden level names
    """

    # check if persistent level exists
    is_level_exist = unreal.EditorAssetLibrary.does_asset_exist(persistent_level_path)
    assert is_level_exist, RuntimeError('Persistent level does not exist', persistent_level_path)

    # get sublevels of persistent level
    level_configs = []
    level_infos = {}

    # get sublevels of persistent level, this step would load the persistent level
    level_configs = get_sublevels(persistent_level_path)
    # reload new levels
    world = unreal.EditorLoadingAndSavingUtils.load_map(new_level_path)
    level_infos[persistent_level_path] = level_configs

    # add persistent level and its sub levels, set visibility
    level_visible_names, level_hidden_names = [], []
    world = get_world()

    for level_config in level_configs:
        level_name: str = level_config['level_name']
        is_visible: bool = level_config['is_visible']
        streaming_class: str = level_config['streaming_class']
        streaming_class_cls = getattr(unreal, streaming_class)
        streaming_level: unreal.LevelStreaming = unreal.EditorLevelUtils.add_level_to_world(
            world, level_name, streaming_class_cls
        )

        assert streaming_class_cls is not None, f'Unknown unreal class: {streaming_class}'
        assert streaming_level is not None, f'Unable to add level to the world: {level_name}'

        unreal.EditorLevelUtils.set_level_visibility(streaming_level.get_loaded_level(), is_visible, False)

        level_basename = level_name.rpartition('/')[-1]
        if is_visible:
            level_visible_names.append(level_basename)
        else:
            level_hidden_names.append(level_basename)

    return level_visible_names, level_hidden_names

src/XRFeitoriaUnreal/Content/Python/utils.py
openxrlab/xrfeitoria
import json
from pathlib import Path
from typing import Dict, List, Tuple, TypedDict, Union

import unreal
from constants import SubSystem
from utils import get_world

root = Path(__file__).parent.resolve()
color_type = TypedDict(
    'color',
    {
        'name': str,
        'hex': str,
        'rgb': Tuple[int, int, int],
    },
)
mask_colors: List[color_type] = json.load((root / 'data' / 'mask_colors.json').open())


def get_stencil_value(actor: unreal.Actor) -> int:
    # skeletal mesh component
    for skeletal_mesh_component in actor.get_components_by_class(unreal.SkeletalMeshComponent):
        stencil_value = skeletal_mesh_component.get_editor_property('custom_depth_stencil_value')
        return stencil_value
    # static mesh component
    for static_mesh_component in actor.get_components_by_class(unreal.StaticMeshComponent):
        stencil_value = static_mesh_component.get_editor_property('custom_depth_stencil_value')
        return stencil_value


def set_stencil_value(actor: unreal.Actor, stencil_value: int, receives_decals: bool = False) -> None:
    # skeletal mesh component
    for skeletal_mesh_component in actor.get_components_by_class(unreal.SkeletalMeshComponent):
        skeletal_mesh_component.set_render_custom_depth(True)
        skeletal_mesh_component.set_custom_depth_stencil_value(stencil_value)
        skeletal_mesh_component.set_editor_property('receives_decals', receives_decals)
    # static mesh component
    for static_mesh_component in actor.get_components_by_class(unreal.StaticMeshComponent):
        static_mesh_component.set_render_custom_depth(True)
        static_mesh_component.set_custom_depth_stencil_value(stencil_value)
        static_mesh_component.set_editor_property('receives_decals', receives_decals)


def get_mask_color(stencil_value: int) -> Tuple[int, int, int]:
    return mask_colors[stencil_value]['rgb']


def get_actor_mask_color(actor: unreal.Actor) -> Tuple[int, int, int]:
    stencil_value = get_stencil_value(actor)
    return get_mask_color(stencil_value)


def get_actor_mesh_component(
    actor: Union[unreal.SkeletalMeshActor, unreal.StaticMeshActor]
) -> Union[unreal.SkeletalMeshComponent, unreal.StaticMeshComponent]:
    if isinstance(actor, unreal.SkeletalMeshActor):
        mesh_component = actor.skeletal_mesh_component
    elif isinstance(actor, unreal.StaticMeshActor):
        mesh_component = actor.static_mesh_component
    else:
        raise TypeError(f'{actor.get_name()} is not a SkeletalMeshActor or StaticMeshActor')
    return mesh_component


def get_z_by_raycast(x: float, y: float, debug: bool = False):
    z_infinity_positive = 100000
    z_infinity_negative = -100000
    start = unreal.Vector(x, y, z_infinity_positive)
    end = unreal.Vector(x, y, z_infinity_negative)
    trace_channel = unreal.TraceTypeQuery.TRACE_TYPE_QUERY2
    actors_to_ignore = unreal.Array(unreal.Actor)

    object_types = unreal.Array(unreal.ObjectTypeQuery)
    _object_types = [
        unreal.ObjectTypeQuery.OBJECT_TYPE_QUERY1,
        unreal.ObjectTypeQuery.OBJECT_TYPE_QUERY2,
        unreal.ObjectTypeQuery.OBJECT_TYPE_QUERY3,
        unreal.ObjectTypeQuery.OBJECT_TYPE_QUERY4,
        unreal.ObjectTypeQuery.OBJECT_TYPE_QUERY5,
        unreal.ObjectTypeQuery.OBJECT_TYPE_QUERY6,
    ]
    object_types.extend(_object_types)

    if debug:
        DrawDebugTrace = unreal.DrawDebugTrace.FOR_DURATION
    else:
        DrawDebugTrace = unreal.DrawDebugTrace.NONE

    hits = unreal.SystemLibrary.line_trace_multi_for_objects(
        get_world(),
        start,
        end,
        object_types=object_types,
        trace_complex=True,
        actors_to_ignore=actors_to_ignore,
        draw_debug_type=DrawDebugTrace,
        ignore_self=False,
        trace_color=unreal.LinearColor(r=1, g=0, b=0, a=1),
        trace_hit_color=unreal.LinearColor(r=0, g=0, b=1, a=1),
        draw_time=60,
    )

    return hits


def hit_to_z(hit) -> float:
    z = hit.to_tuple()[4].z
    return z


def draw_actor_bbox(
    actor: unreal.Actor,
    color: unreal.LinearColor = unreal.LinearColor(1, 0, 0, 1),
    duration: int = 10,
    thickness: int = 1,
) -> None:
    bbox: Tuple[unreal.Vector, unreal.Vector] = actor.get_actor_bounds(False, include_from_child_actors=True)
    world = get_world()

    actor_name = actor.get_fname()
    print(f'{actor_name} center: {bbox[0].to_tuple()}')
    print(f'{actor_name} extend: {bbox[1].to_tuple()}')

    unreal.SystemLibrary.draw_debug_box(world, bbox[0], bbox[1], color, duration=duration, thickness=thickness)


def draw_selected_actors_bbox(
    color: unreal.LinearColor = unreal.LinearColor(1, 0, 0, 1), duration: int = 10, thickness: int = 1
) -> None:
    actors = get_selected_actors()
    for actor in actors:
        draw_actor_bbox(actor, color, duration, thickness)


def spawn_actor_from_object(
    actor_object: unreal.Object,
    location: Tuple[float, float, float] = (0, 0, 0),
    rotation: Tuple[float, float, float] = (0, 0, 0),
    scale: Tuple[float, float, float] = (1, 1, 1),
) -> unreal.Actor:
    """Spawn an actor from an object.

    Args:
        actor_object (unreal.Object): Actor loaded from ``unreal.load_asset(path)``
        location (Tuple[float, float, float]): Location of the actor. Units in meters. Defaults to (0, 0, 0).
        rotation (Tuple[float, float, float], optional): Rotation of the actor. Units in degrees. Defaults to (0, 0, 0).
        scale (Tuple[float, float, float], optional): Scale of the actor. Defaults to (1, 1, 1).

    Returns:
        unreal.Actor: Spawned actor

    Examples:
        >>> import unreal
        >>> import utils_actor
        >>> actor_object = unreal.load_asset('/Engine/BasicShapes/Cube')
        >>> actor = utils_actor.spawn_actor_from_object(actor_object, location=(1, 1, 1))
    """
    location = unreal.Vector(location[0], location[1], location[2])
    rotation = unreal.Rotator(rotation[0], rotation[1], rotation[2])
    scale = unreal.Vector(scale[0], scale[1], scale[2])
    # convert from meters to centimeters
    location *= 100.0

    if SubSystem.EditorActorSub:
        actor = SubSystem.EditorActorSub.spawn_actor_from_object(
            object_to_use=actor_object, location=location, rotation=rotation
        )
    else:
        actor = unreal.EditorLevelLibrary().spawn_actor_from_object(
            actor_class=actor_object, location=location, rotation=rotation
        )
    actor.set_actor_scale3d(scale)
    return actor


def spawn_actor_from_class(
    actor_class: unreal.Actor,
    location: Tuple[float, float, float] = (0, 0, 0),
    rotation: Tuple[float, float, float] = (0, 0, 0),
    scale: Tuple[float, float, float] = (1, 1, 1),
) -> unreal.Actor:
    location = unreal.Vector(location[0], location[1], location[2])
    rotation = unreal.Rotator(rotation[0], rotation[1], rotation[2])
    scale = unreal.Vector(scale[0], scale[1], scale[2])
    # convert from meters to centimeters
    location *= 100.00

    if SubSystem.EditorActorSub:
        actor = SubSystem.EditorActorSub.spawn_actor_from_class(
            actor_class=actor_class, location=location, rotation=rotation
        )
    else:
        actor = unreal.EditorLevelLibrary().spawn_actor_from_class(
            actor_class=actor_class, location=location, rotation=rotation
        )
    actor.set_actor_scale3d(scale)
    return actor


def get_actor_by_name(actor_name: str) -> unreal.Actor:
    actors = get_all_actors_dict()
    if actor_name not in actors:
        raise Exception(f"Actor '{actor_name}' not found")
    return actors[actor_name]


def get_all_actors_name() -> List[str]:
    actors = get_all_actors_dict()
    return list(actors.keys())


def get_all_actors_dict() -> Dict[str, unreal.Actor]:
    actors = {}
    for actor in get_all_actors():
        if actor.get_actor_label() in actors:
            raise Exception(f'Multiple Actors named {actor.get_actor_label()}')
        actors[actor.get_actor_label()] = actor
    return actors


def get_selected_actors():
    # ! Selected
    if SubSystem.EditorActorSub:
        selected_actors = SubSystem.EditorActorSub.get_selected_level_actors()
    else:
        selected_actors = unreal.EditorLevelLibrary.get_selected_level_actors()
    return selected_actors


def get_class_actor(actor_class) -> unreal.Actor:
    # ! Class
    world = get_world()
    class_actor = unreal.GameplayStatics.get_actor_of_class(world, actor_class)
    return class_actor


def get_class_actors(actor_class) -> List[unreal.Actor]:
    # ! Class
    world = get_world()
    class_actors = unreal.GameplayStatics.get_all_actors_of_class(world, actor_class)
    return class_actors


def get_tag_actors(actor_tag) -> List[unreal.Actor]:
    # ! Tag
    world = get_world()
    tag_actors = unreal.GameplayStatics.get_all_actors_with_tag(world, actor_tag)
    return tag_actors


def get_all_actors() -> List[unreal.Actor]:
    # ! All
    world = get_world()
    all_actors = unreal.GameplayStatics.get_all_actors_of_class(world, unreal.Actor)
    return all_actors


def get_components_of_class(actor, component_class) -> List:
    # ! Component
    components = actor.get_components_by_class(component_class)
    return components


def destroy_actor(actor: unreal.Actor):
    if SubSystem.EditorActorSub:
        SubSystem.EditorActorSub.destroy_actor(actor)
    else:
        unreal.EditorLevelLibrary().destroy_actor(actor)

src/XRFeitoriaUnreal/Content/Python/utils_actor.py
openxrlab/xrfeitoria
"""
>>> python -m tests.blender.actor
"""
from pathlib import Path

import numpy as np

from xrfeitoria.data_structure.constants import xf_obj_name
from xrfeitoria.data_structure.models import SequenceTransformKey as SeqTransKey

from ..config import assets_path
from ..utils import __timer__, _init_blender, setup_logger

root = Path(__file__).parents[2].resolve()
# output_path = '~/xrfeitoria/output/tests/blender/{file_name}'
output_path = root / 'output' / Path(__file__).relative_to(root).with_suffix('')
bunny_obj = assets_path['bunny']


def actor_test(debug: bool = False, background: bool = False):
    logger = setup_logger(debug=debug)
    with _init_blender(background=background) as xf_runner:
        with __timer__('import_actor'):
            actor = xf_runner.Actor.import_from_file(
                actor_name='bunny',
                file_path=bunny_obj,
                location=(0, 0, 0),
                rotation=(90, 0, 0),
                scale=(2, 1, 1),
            )
            # test properties
            assert actor.name == 'bunny', f'name not match, actor.name={actor.name}'
            assert np.allclose(actor.location, (0, 0, 0)), f'location: {actor.location}'
            assert np.allclose(actor.rotation, (90, 0, 0)), f'rotation: {actor.rotation}'
            assert np.allclose(actor.scale, (2, 1, 1)), f'scale: {actor.scale}'

            # test set properties
            actor.location = (3, 0, 0)
            actor.rotation = (40, 0, 0)
            actor.scale = (3, 3, 3)
            assert np.allclose(actor.location, (3, 0, 0)), f'location: {actor.location}'
            assert np.allclose(actor.rotation, (40, 0, 0)), f'rotation: {actor.rotation}'
            assert np.allclose(actor.scale, (3, 3, 3)), f'scale: {actor.scale}'

            # test functions
            # test get_transform/set_transform
            actor.set_transform(location=(0, 0, 1), rotation=(0, 0, 90), scale=(1, 3, 1))
            assert np.allclose(actor.location, (0, 0, 1)), f'location: {actor.location}'
            assert np.allclose(actor.rotation, (0, 0, 90)), f'rotation: {actor.rotation}'
            assert np.allclose(actor.scale, (1, 3, 1)), f'scale: {actor.scale}'

            location, rotation, scale = actor.get_transform()
            assert np.allclose(location, (0, 0, 1)), f'location: {location}'
            assert np.allclose(rotation, (0, 0, 90)), f'rotation: {rotation}'
            assert np.allclose(scale, (1, 3, 1)), f'scale: {scale}'

            # test set_transform_keys
            xf_runner.ObjectUtils.set_transform_keys(
                name=actor.name,
                transform_keys=[
                    SeqTransKey(frame=0, location=(-1, 0, 1.8), rotation=(0, 0, 0), interpolation='AUTO'),
                    SeqTransKey(frame=30, location=(-1, 0, -1.1), rotation=(0, 0, 0), interpolation='AUTO'),
                    SeqTransKey(frame=40, location=(1, 0, -1.1), rotation=(40, 30, 20), interpolation='AUTO'),
                ],
            )
            if not xf_runner.utils.is_background_mode():
                xf_runner.utils.set_frame_current(0)
                assert np.allclose(actor.location, (-1, 0, 1.8)), f'location: {actor.location}'
                assert np.allclose(actor.rotation, (0, 0, 0)), f'rotation: {actor.rotation}'
                xf_runner.utils.set_frame_current(30)
                assert np.allclose(actor.location, (-1, 0, -1.1)), f'location: {actor.location}'
                assert np.allclose(actor.rotation, (0, 0, 0)), f'rotation: {actor.rotation}'
            xf_runner.utils.set_frame_current(40)
            assert np.allclose(actor.location, (1, 0, -1.1)), f'location: {actor.location}'
            assert np.allclose(actor.rotation, (40, 30, 20)), f'rotation: {actor.rotation}'
            xf_runner.utils.set_frame_current(0)

            # test set origin
            logger.info(f':rabbit: actor location: {actor.location}')
            actor.set_origin_to_center()
            logger.info(f':rabbit: actor location after set origin: {actor.location}')
            actor.delete()

        with __timer__('spawn shape'):
            for _type in ['cube', 'sphere', 'cylinder', 'cone', 'plane']:
                actor = xf_runner.Shape.spawn(type=_type)
                assert actor.name == xf_obj_name.format(
                    obj_type=_type, obj_idx=1
                ), f'name not match, actor.name={actor.name}'
                actor.delete()

    logger.info('🎉 [bold green]actor tests passed!')


if __name__ == '__main__':
    import argparse

    args = argparse.ArgumentParser()
    args.add_argument('--debug', action='store_true')
    args.add_argument('--background', '-b', action='store_true')
    args = args.parse_args()

    actor_test(debug=args.debug, background=args.background)

tests/blender/actor.py
openxrlab/xrfeitoria
"""
>>> python -m tests.blender.camera
"""
from pathlib import Path

import numpy as np

from xrfeitoria.data_structure.constants import xf_obj_name

from ..utils import __timer__, _init_blender, setup_logger

root = Path(__file__).parents[2].resolve()
# output_path = '~/xrfeitoria/output/tests/blender/{file_name}'
output_path = root / 'output' / Path(__file__).relative_to(root).with_suffix('')


def camera_test(debug: bool = False, background: bool = False):
    logger = setup_logger(debug=debug)
    with _init_blender(background=background) as xf_runner:
        with __timer__('spawn camera'):
            ## test spawn camera
            cam0 = xf_runner.Camera.spawn()
            assert cam0.name == xf_obj_name.format(obj_type='camera', obj_idx=1), f'❌ cam1.name={cam0.name} wrong'
            cam0.delete()

            cam = xf_runner.Camera.spawn(
                camera_name='new cam',
                location=(5, 6, 7),
                rotation=(30, 0, 0),
                fov=20,
            )
            assert cam.name == 'new cam', f'❌ cam.name={cam.name} wrong'
            assert np.allclose(cam.location, (5, 6, 7)), f'❌ cam.location={cam.location} wrong'
            assert np.allclose(cam.rotation, (30, 0, 0)), f'❌ cam.rotation={cam.rotation} wrong'

        with __timer__('move camera'):
            ## test camera location & rotation setters
            cam.location = (2, 3, 4)
            cam.rotation = (45, 25, 135)
            assert np.allclose(cam.location, (2, 3, 4)), '❌ cam.location wrong'
            assert np.allclose(cam.rotation, (45, 25, 135)), '❌ cam.rotation wrong'

        with __timer__('get camera'):
            cam = xf_runner.Camera('new cam')
            assert np.allclose(cam.location, (2, 3, 4)), '❌ get cam wrong'

        with __timer__('dump camera params'):
            param_json = output_path / 'camera_params.json'
            cam.dump_params(param_json)
            assert param_json.exists(), '❌ dump camera params failed'
            # param_json.unlink()
            # logger.info('🗑️ delete camera params file')

        with __timer__('delete camera'):
            cam.delete()

    logger.info('🎉 [bold green]camera tests passed!')


if __name__ == '__main__':
    import argparse

    args = argparse.ArgumentParser()
    args.add_argument('--debug', action='store_true')
    args.add_argument('--background', '-b', action='store_true')
    args = args.parse_args()

    camera_test(debug=args.debug, background=args.background)

tests/blender/camera.py
openxrlab/xrfeitoria
"""
>>> python -m tests.blender.init
"""

from loguru import logger

from xrfeitoria.rpc import remote_blender

from ..utils import __timer__, _init_blender, setup_logger


@remote_blender()
def test_blender():
    import bpy  # fmt: skip
    print('test')


def init_test(debug: bool = False, dev: bool = False, background: bool = False):
    setup_logger(debug=debug)
    with __timer__('init_blender'):
        with _init_blender(dev_plugin=dev, background=background) as xf_runner:
            test_blender()

    logger.info('🎉 [bold green]init tests passed!')


if __name__ == '__main__':
    import argparse

    args = argparse.ArgumentParser()
    args.add_argument('--debug', action='store_true')
    args.add_argument('--dev', action='store_true')
    args.add_argument('--background', '-b', action='store_true')
    args = args.parse_args()

    init_test(debug=args.debug, dev=args.dev, background=args.background)

tests/blender/init.py
openxrlab/xrfeitoria
"""
>>> python -m tests.blender.level
"""
from pathlib import Path

from loguru import logger

import xrfeitoria as xf
from xrfeitoria.data_structure.models import RenderPass
from xrfeitoria.factory import XRFeitoriaBlender

from ..config import assets_path
from ..utils import __timer__, _init_blender, setup_logger

root = Path(__file__).parents[2].resolve()
# output_path = '~/xrfeitoria/output/tests/blender/{file_name}'
output_path = root / 'output' / Path(__file__).relative_to(root).with_suffix('')

hdr_map_path = assets_path['hdr_sample']
bunny_obj_path = assets_path['bunny']
actor_path = assets_path['SMPL_XL']
motion_path = assets_path['motion_1']
blend_sample = assets_path['blend_sample']


def seq_simple(xf_runner: XRFeitoriaBlender, seq_name: str = 'seq_simple'):
    with xf_runner.Sequence.new(seq_name=seq_name, level='Scene', seq_length=1) as seq:
        camera = xf_runner.Camera('Camera')
        seq.use_camera(camera)

        seq.add_to_renderer(
            output_path=output_path / f'{seq.name}',
            render_passes=[
                RenderPass('img', 'png'),
                RenderPass('mask', 'exr'),
            ],
            resolution=[512, 512],
            render_engine='CYCLES',
            render_samples=16,
            transparent_background=False,
            arrange_file_structure=True,
        )


def level_test(debug=False, background=False):
    setup_logger(debug=debug)
    with _init_blender(background=background, new_process=True, cleanup=False, project_path=blend_sample) as xf_runner:
        with __timer__('create and sequence'):
            seq_simple(xf_runner)

        # save blend
        # blend_file = output_path / 'source.blend'
        # xf_runner.utils.save_blend(save_path=blend_file)

        with __timer__('render'):
            xf_runner.render()

    logger.info('🎉 [bold green]level render tests passed!')


if __name__ == '__main__':
    from ..utils import parse_args

    args = parse_args()

    level_test(debug=args.debug, background=args.background)

tests/blender/level.py
openxrlab/xrfeitoria
"""
>>> python -m tests.blender.main
"""
from pathlib import Path

from ..utils import _init_blender, setup_logger
from .actor import actor_test
from .camera import camera_test
from .init import init_test
from .level import level_test
from .sequence import sequence_test

root = Path(__file__).parents[2].resolve()
# output_path = '~/xrfeitoria/output/tests/blender'
output_path = root / 'output' / Path(__file__).parent.relative_to(root)


def main(debug: bool = False, background: bool = False):
    logger = setup_logger(debug=debug, log_path=output_path / 'blender.log')
    with _init_blender(background=background) as xf_runner:
        init_test(debug=debug)
        actor_test(debug=debug)
        camera_test(debug=debug)
        sequence_test(debug=debug)

    level_test(debug=debug, background=background)

    logger.info('🎉 [bold green]All tests passed!')


if __name__ == '__main__':
    import argparse

    args = argparse.ArgumentParser()
    args.add_argument('--debug', action='store_true')
    args.add_argument('--background', '-b', action='store_true')
    args = args.parse_args()

    main(debug=args.debug, background=args.background)

tests/blender/main.py
openxrlab/xrfeitoria
"""
>>> python -m tests.blender.sequence
"""
from pathlib import Path

import numpy as np
from loguru import logger

from xrfeitoria.data_structure.models import RenderPass
from xrfeitoria.data_structure.models import SequenceTransformKey as SeqTransKey
from xrfeitoria.factory import XRFeitoriaBlender

from ..config import assets_path
from ..utils import __timer__, _init_blender, setup_logger

root = Path(__file__).parents[2].resolve()
# output_path = '~/xrfeitoria/output/tests/blender/{file_name}'
output_path = root / 'output' / Path(__file__).relative_to(root).with_suffix('')

# define testing assets
hdr_map_path = assets_path['hdr_sample']
bunny_obj_path = assets_path['bunny']
actor_path = assets_path['SMPL_XL']
motion_path = assets_path['motion_1']


def seq_actor(xf_runner: XRFeitoriaBlender, seq_name: str = 'seq_actor'):
    xf_runner.utils.set_hdr_map(hdr_map_path=hdr_map_path)

    camera_level = xf_runner.Camera.spawn(location=(0, 10, 0))
    camera_level.look_at(target=(0, 0, 0))

    with xf_runner.Sequence.new(seq_name=seq_name, seq_length=2) as seq:
        seq.use_camera(camera_level)
        camera = seq.spawn_camera(camera_name='camera', location=(-10, 0, 0), rotation=(90, 0, -90), fov=39.6)

        assert np.allclose(camera.location, (-10, 0, 0)), f'❌ cam.location={camera.location} wrong'
        assert np.allclose(camera.rotation, (90, 0, -90)), f'❌ cam.rotation={camera.rotation} wrong'
        assert np.allclose(camera.fov, 39.6), f'❌ cam.fov={camera.fov} wrong'

        camera1 = seq.spawn_camera_with_keys(
            transform_keys=[
                SeqTransKey(frame=0, location=(-1, 0, 1.8), rotation=(0, 0, 0), interpolation='AUTO'),
                SeqTransKey(frame=30, location=(-1, 0, -1.1), rotation=(0, 0, 0), interpolation='AUTO'),
                SeqTransKey(frame=40, location=(1, 0, -1.1), rotation=(40, 30, 20), interpolation='AUTO'),
            ],
        )
        # assert np.allclose(camera1.location, (-1, 0, 1.8)), f'❌ cam.location={camera1.location} wrong'
        # assert np.allclose(camera1.rotation, (0, 0, 0)), f'❌ cam.rotation={camera1.rotation} wrong'

        actor1 = seq.import_actor(file_path=actor_path, stencil_value=128)
        actor1.setup_animation(animation_path=motion_path)
        actor2 = seq.import_actor_with_keys(
            file_path=actor_path,
            transform_keys=[
                SeqTransKey(
                    frame=0, location=(-1, 0, 1.8), rotation=(0, 0, 0), scale=(0.01, 0.01, 0.01), interpolation='AUTO'
                ),
                SeqTransKey(frame=30, location=(-1, 0, -1.1), rotation=(0, 0, 0), interpolation='AUTO'),
                SeqTransKey(frame=40, location=(1, 0, -1.1), rotation=(40, 30, 20), interpolation='AUTO'),
            ],
            stencil_value=255,
        )

        # get keys range
        keys_range = xf_runner.utils.get_keys_range()
        logger.info(f'keys_range={keys_range}')

        logger.info('Override frame range to [0, 2] for testing')
        xf_runner.utils.set_frame_range(start=0, end=2)

        seq.add_to_renderer(
            output_path=output_path / f'{seq.name}',
            render_passes=[
                RenderPass('img', 'png'),
                RenderPass('mask', 'png'),
            ],
            resolution=[128, 128],
            render_engine='CYCLES',
            render_samples=1,
            transparent_background=False,
            arrange_file_structure=True,
        )


def seq_shape(xf_runner: XRFeitoriaBlender, seq_name='seq_shape'):
    with xf_runner.Sequence.new(seq_name=seq_name, seq_length=6) as seq:
        camera = seq.spawn_camera(location=(-10, 0, 0), rotation=(90, 0, -90), fov=39.6)

        xf_runner.utils.set_frame_current(frame=3)

        cube = seq.spawn_shape(type='cube', size=1.0, stencil_value=128)
        cube1 = seq.spawn_shape_with_keys(
            type='cube',
            transform_keys=[
                SeqTransKey(
                    frame=0,
                    location=(-1, 0, 1.8),
                    rotation=(0, 0, 0),
                    interpolation='AUTO',
                ),
                SeqTransKey(
                    frame=3,
                    location=(-1, 0, -1.1),
                    rotation=(0, 0, 0),
                    interpolation='AUTO',
                ),
                SeqTransKey(
                    frame=6,
                    location=(1, 0, -1.1),
                    rotation=(40, 30, 20),
                    interpolation='AUTO',
                ),
            ],
            size=0.1,
            stencil_value=255,
        )
        seq.add_to_renderer(
            output_path=output_path / f'{seq.name}',
            render_passes=[
                RenderPass('img', 'png'),
                RenderPass('mask', 'exr'),
            ],
            resolution=[128, 128],
            render_engine='CYCLES',
            render_samples=1,
            transparent_background=False,
            arrange_file_structure=True,
        )


def sequence_test(debug=False, background=False):
    logger = setup_logger(debug=debug)
    with _init_blender(background=background) as xf_runner:
        with __timer__('seq_actor'):
            seq_actor(xf_runner, seq_name='seq_actor')

        with __timer__('seq_shape'):
            seq_shape(xf_runner, seq_name='seq_shape')

        # save blend
        blend_file = output_path / 'source.blend'
        xf_runner.utils.save_blend(save_path=blend_file)

        with __timer__('render'):
            xf_runner.render()

    logger.info('🎉 [bold green]sequence & render tests passed!')


if __name__ == '__main__':
    from ..utils import parse_args

    args = parse_args()

    sequence_test(debug=args.debug, background=args.background)

tests/blender/sequence.py
openxrlab/xrfeitoria
from pathlib import Path

import cv2
import numpy as np

from xrfeitoria.camera.camera_parameter import CameraParameter
from xrfeitoria.utils import projector

root = Path(__file__).parent


def test_blender():
    output_root = root.parent.parent / 'samples' / 'blender' / 'output'
    seq_root = output_root / '03_basic_usage'
    camera_name = 'level_camera'
    img_path = seq_root / 'img' / camera_name / '0000.png'
    verts_path = seq_root / 'verts' / 'SMPLX-mesh-neutral.npz'
    img = cv2.imread(str(img_path))
    verts = np.load(str(verts_path), allow_pickle=True)['verts']
    camera_param_json = seq_root / 'camera_param' / f'{camera_name}.json'

    camera_param = CameraParameter.fromfile(camera_param_json.as_posix())
    overlap_img = projector.draw_points3d(verts[0], camera_param, image=img, color=(0, 255, 255))
    cv2.imwrite(str(root / 'overlap.png'), overlap_img)
    print(f'Overlap image saved to: "{root / "overlap.png"}"')


def test_unreal():
    output_root = root.parent / 'unreal' / 'output'
    seq_root = output_root / 'test'
    camera_name = 'Camera'
    img_path = seq_root / 'img' / camera_name / '0000.png'
    verts_path = seq_root / 'vertices' / 'Actor_4.npz'
    img = cv2.imread(str(img_path))
    verts = np.load(str(verts_path), allow_pickle=True)['verts']
    camera_param_json = seq_root / 'camera_params' / f'{camera_name}Actor1.json'
    camera_param = CameraParameter.fromfile(camera_param_json.as_posix())
    overlap_img = projector.draw_points3d(verts[0], camera_param, image=img, color=(0, 255, 255))
    cv2.imwrite(str(root / 'overlap.png'), overlap_img)
    print(f'Overlap image saved to: "{root / "overlap.png"}"')


if __name__ == '__main__':
    # test_blender()
    test_unreal()

tests/others/projector.py
openxrlab/xrfeitoria
import json
import shutil
from pathlib import Path
from textwrap import dedent
from typing import Literal, Optional

from loguru import logger
from rich.prompt import Prompt

from xrfeitoria.data_structure.constants import tmp_dir
from xrfeitoria.utils.downloader import download
from xrfeitoria.utils.setup import Config, get_exec_path, guess_exec_path
from xrfeitoria.utils.tools import Logger

# XXX: Hard-coded assets url
assets_url = dict(
    bunny='https://openxrlab-share.oss-cn-hongkong.aliyuncs.com/xrfeitoria/assets/stanford-bunny.obj',
    koupen_chan='https://openxrlab-share.oss-cn-hongkong.aliyuncs.com/xrfeitoria/assets/koupen_chan.fbx',
    SMPL_XL='https://openxrlab-share.oss-cn-hongkong.aliyuncs.com/xrfeitoria/assets/SMPL-XL-001.fbx',
    motion_1='https://openxrlab-share.oss-cn-hongkong.aliyuncs.com/xrfeitoria/assets/motion-greeting.fbx',
    motion_2='https://openxrlab-share.oss-cn-hongkong.aliyuncs.com/xrfeitoria/assets/motion-stand_to_walk_back.fbx',
    blend_sample='https://openxrlab-share.oss-cn-hongkong.aliyuncs.com/xrfeitoria/assets/Tree1.blend',
    hdr_sample='https://openxrlab-share.oss-cn-hongkong.aliyuncs.com/xrfeitoria/assets/hdr-sample.hdr',
)
# XXX: hard-coded unreal project url
unreal_sample_url = (
    'https://openxrlab-share.oss-cn-hongkong.aliyuncs.com/xrfeitoria/unreal_project/XRFeitoriaUnreal_Sample.zip'
)
asset_dir = tmp_dir / 'assets'


def get_exec(engine: Literal['blender', 'unreal'], exec_from_config: Optional[Path] = None) -> str:
    """Ask for blender executable path.

    Args:
        engine (Literal['blender', 'unreal']): Engine name.
        engine_path_from_config (Optional[Path], optional): path of engine executable read from config. Defaults to None.
    """
    txt = f'Please input the path to the {engine} executable'

    # priority: exec_from_config > system_config (with guess) > ask > install
    if exec_from_config is not None:
        engine_path = Path(exec_from_config).resolve().as_posix()
    else:
        try:
            # system_config, if empty, guess
            engine_path = get_exec_path(engine=engine, to_ask=False, to_install=False).as_posix()
        except FileNotFoundError:
            engine_path = None
            txt += f' (e.g. {guess_exec_path(engine)})'
            if engine == 'blender':
                txt += ', or press [bold]enter[/bold] to install it'

    # ask
    path = Prompt.ask(txt, default=engine_path)
    if path is not None:
        path = Path(path.strip('"').strip("'")).resolve()
    else:
        if engine == 'unreal':
            raise FileNotFoundError('Please specify the path to the unreal executable.')
        # auto install for blender
        path = get_exec_path(engine=engine, to_ask=False, to_install=True)

    assert path.exists(), f'{engine} path {path.as_posix()} does not exist!'
    return path.as_posix()


def get_unreal_project(unreal_project: str) -> str:
    """Ask for unreal project path."""
    txt = 'Please input the path to the unreal project'
    if unreal_project is None:
        txt += ', or press [bold]enter[/bold] to download a sample project\n' '\[Enter]'
    unreal_project = Prompt.ask(txt, default=unreal_project)
    if unreal_project is None:
        unreal_project_zip = download(url=unreal_sample_url, dst_dir=tmp_dir / 'unreal_project')
        shutil.unpack_archive(filename=unreal_project_zip, extract_dir=tmp_dir / 'unreal_project')
        unreal_project_dir = unreal_project_zip.parent / unreal_project_zip.stem
        unreal_project = next(unreal_project_dir.glob('*.uproject')).as_posix()
    unreal_project = unreal_project.strip('"').strip("'")
    if not Path(unreal_project).exists():
        config_file = Path(__file__).parent / 'config.py'
        logger.error(
            '[red]Error:[/red]\n'
            f'Unreal project "{unreal_project}" does not exist! \n'
            f'Config not set correctly, please [red]delete "{config_file.as_posix()}"[/red], and setup again.'
        )
        exit(1)
    return unreal_project


def main():
    try:
        from .config import blender_exec, unreal_exec, unreal_project
    except ImportError:
        blender_exec = unreal_exec = unreal_project = None

    Logger.setup_logging()
    engine = Prompt.ask('Which engine do you want to use?', choices=['blender', 'unreal'], default='blender')
    if engine == 'blender':
        blender_exec = get_exec('blender', exec_from_config=blender_exec)
        Config.update(engine=engine, exec_path=blender_exec)
    elif engine == 'unreal':
        unreal_exec = get_exec('unreal', exec_from_config=unreal_exec)
        unreal_project = get_unreal_project(unreal_project=unreal_project)
        Config.update(engine=engine, exec_path=unreal_exec)

    assets_path = {}
    for idx, (name, url) in enumerate(assets_url.items()):
        logger.info(f'Downloading {idx+1}/{len(assets_url)}: {name}')
        save_path = download(url, dst_dir=asset_dir / name)
        # save the path
        assets_path[name] = save_path.as_posix()

    # write the config file
    config_path = Path(__file__).parent / 'config.py'
    with open(config_path, 'w') as f:
        _keys = {key: 'str' for key in assets_path.keys()}
        _config = dedent(
            f"""\
            from typing import TypedDict

            blender_exec = {repr(blender_exec)}
            unreal_exec = {repr(unreal_exec)}
            unreal_project = {repr(unreal_project)}

            assets_path_type = TypedDict('assets_path', {json.dumps(_keys)})
            assets_path: assets_path_type = {json.dumps(assets_path)}"""
        )
        f.write(_config)
    logger.info(f'Saved config to "{config_path}"')


if __name__ == '__main__':
    main()

tests/setup.py
openxrlab/xrfeitoria
"""
>>> python -m tests.unreal.init_test
"""
import numpy as np
from loguru import logger

from xrfeitoria.data_structure.constants import xf_obj_name

from ..config import assets_path
from ..utils import __timer__, _init_unreal, setup_logger

bunny_obj = assets_path['bunny']
kc_fbx = assets_path['koupen_chan']
smpl_xl_fbx = assets_path['SMPL_XL']


def actor_test(debug: bool = False, background: bool = False):
    setup_logger(debug=debug)
    with _init_unreal(background=background) as xf_runner:
        with __timer__('import actor'):
            kc_path = xf_runner.utils.import_asset(path=kc_fbx)
            actor_kc = xf_runner.Actor.spawn_from_engine_path(kc_path, name='KoupenChan')
            assert actor_kc.name == 'KoupenChan', f'name not match, actor.name={actor_kc.name}'
            actor_kc.delete()

            actor_bunny = xf_runner.Actor.import_from_file(file_path=bunny_obj)
            actor_smpl_xl = xf_runner.Actor.import_from_file(file_path=smpl_xl_fbx)

            actor_bunny.delete()
            actor_smpl_xl.delete()

        with __timer__('spawn actor'):
            actor = xf_runner.Actor.spawn_from_engine_path(
                engine_path='/Engine/BasicShapes/Cube',
                name='Cube',
                location=(5, 3, 2),
                rotation=(30, 0, 0),
                scale=(0.1, 0.1, 0.1),
            )
            assert actor.name == 'Cube', f'name not match, actor.name={actor.name}'
            assert np.allclose(actor.location, (5, 3, 2)), f'location not match, actor.location={actor.location}'
            assert np.allclose(actor.rotation, (30, 0, 0)), f'rotation not match, actor.rotation={actor.rotation}'
            assert np.allclose(actor.scale, (0.1, 0.1, 0.1)), f'scale not match, actor.scale={actor.scale}'

        # set properties
        actor.location = (1, 2, 3)
        actor.rotation = (20, 30, 50)
        actor.scale = (2, 2, 2)
        assert np.allclose(actor.rotation, (20, 30, 50)), f'rotation not match, actor.rotation={actor.rotation}'
        assert np.allclose(actor.location, (1, 2, 3)), f'location not match, actor.location={actor.location}'
        assert np.allclose(actor.scale, (2, 2, 2)), f'scale not match, actor.scale={actor.scale}'

        # set/get transform
        actor.set_transform(location=(3, 4, 5), rotation=(10, 20, 30), scale=(3, 3, 3))
        loc, rot, scale = actor.get_transform()
        assert np.allclose(loc, (3, 4, 5)), f'location not match, loc={loc}'
        assert np.allclose(rot, (10, 20, 30)), f'rotation not match, rot={rot}'
        assert np.allclose(scale, (3, 3, 3)), f'scale not match, scale={scale}'

        actor.delete()

        with __timer__('spawn shape'):
            for _type in ['cube', 'sphere', 'cylinder', 'cone', 'plane']:
                actor = xf_runner.Shape.spawn(type=_type)
                assert actor.name == xf_obj_name.format(
                    obj_type=_type, obj_idx=1
                ), f'name not match, actor.name={actor.name}'
                actor.delete()

    logger.info('🎉 [bold green]actor tests passed!')


if __name__ == '__main__':
    import argparse

    args = argparse.ArgumentParser()
    args.add_argument('--debug', action='store_true')
    args.add_argument('--background', '-b', action='store_true')
    args = args.parse_args()

    actor_test(debug=args.debug, background=args.background)

tests/unreal/actor.py
openxrlab/xrfeitoria
"""
>>> python -m tests.unreal.init_test
"""

import numpy as np

from xrfeitoria.data_structure.constants import xf_obj_name

from ..utils import __timer__, _init_unreal, setup_logger


def camera_test(debug: bool = False, background: bool = False):
    logger = setup_logger(debug=debug)
    with _init_unreal(background=background) as xf_runner:
        with __timer__('spawn camera'):
            camera0 = xf_runner.Camera.spawn()
            assert camera0.name == xf_obj_name.format(
                obj_type='camera', obj_idx=1
            ), f'name not match, camera0.name={camera0.name}'
            camera0.delete()

            camera = xf_runner.Camera.spawn(
                camera_name='new cam',
                location=(5, 3, 2),
                rotation=(30, 0, 0),
                fov=90.0,
            )
            assert camera.name == 'new cam', f'name not match, camera.name={camera.name}'
            assert np.allclose(camera.location, (5, 3, 2)), f'location not match, camera.location={camera.location}'
            assert np.allclose(camera.rotation, (30, 0, 0)), f'rotation not match, camera.rotation={camera.rotation}'
            assert np.allclose(camera.fov, 90.0), f'fov not match, camera.fov={camera.fov}'

        with __timer__('set location'):
            camera.location = (1, 2, 3)
            assert np.allclose(camera.location, (1, 2, 3)), f'location not match, camera.location={camera.location}'

        with __timer__('set rotation'):
            camera.rotation = (20, 30, 50)
            assert np.allclose(camera.rotation, (20, 30, 50)), f'rotation not match, camera.rotation={camera.rotation}'

        with __timer__('set fov'):
            camera.fov = 60.0
            assert np.allclose(camera.fov, 60.0), f'fov not match, camera.fov={camera.fov}'

        with __timer__('delete camera'):
            camera.delete()

    logger.info('🎉 [bold green]camera tests passed!')


if __name__ == '__main__':
    import argparse

    args = argparse.ArgumentParser()
    args.add_argument('--debug', action='store_true')
    args.add_argument('--background', '-b', action='store_true')
    args = args.parse_args()

    camera_test(debug=args.debug, background=args.background)

tests/unreal/camera.py
openxrlab/xrfeitoria
"""
>>> python -m tests.unreal.init_test
"""

from loguru import logger

from xrfeitoria.rpc import remote_unreal

from ..utils import __timer__, _init_unreal, setup_logger


@remote_unreal()
def test_unreal():
    import unreal  # fmt: skip
    unreal.log('test')


def init_test(debug: bool = False, dev: bool = False, background: bool = False):
    setup_logger(debug=debug)
    with __timer__('init unreal'):
        with _init_unreal(dev_plugin=dev, background=background) as xf_runner:
            test_unreal()

    logger.info('🎉 [bold green]init tests passed!')


if __name__ == '__main__':
    import argparse

    args = argparse.ArgumentParser()
    args.add_argument('--debug', action='store_true')
    args.add_argument('--dev', action='store_true')
    args.add_argument('--background', '-b', action='store_true')
    args = args.parse_args()

    init_test(debug=args.debug, dev=args.dev, background=args.background)

tests/unreal/init.py
openxrlab/xrfeitoria
"""
>>> python -m tests.unreal.main
"""
from pathlib import Path

from ..utils import _init_unreal, setup_logger
from .actor import actor_test
from .camera import camera_test
from .init import init_test
from .sequence import sequence_test

root = Path(__file__).parent


def main(debug: bool = False, background: bool = False):
    logger = setup_logger(debug=debug, log_path=root / 'unreal.log')
    with _init_unreal(background=background) as xf_runner:
        init_test(debug=debug, background=background)
        actor_test(debug=debug, background=background)
        camera_test(debug=debug, background=background)
        sequence_test(debug=debug, background=background)

    logger.info('🎉 [bold green]All tests passed!')


if __name__ == '__main__':
    import argparse

    args = argparse.ArgumentParser()
    args.add_argument('--debug', action='store_true')
    args = args.parse_args()

    main(debug=args.debug)

tests/unreal/main.py
openxrlab/xrfeitoria
"""
>>> python -m tests.unreal.init_test
"""

from pathlib import Path

from xrfeitoria.data_structure.models import RenderPass
from xrfeitoria.data_structure.models import SequenceTransformKey as SeqTransKey
from xrfeitoria.factory import XRFeitoriaUnreal

from ..config import assets_path
from ..utils import __timer__, _init_unreal, setup_logger, visualize_vertices

root = Path(__file__).parents[2].resolve()
# output_path = '~/xrfeitoria/output/tests/unreal/{file_name}'
output_path = root / 'output' / Path(__file__).relative_to(root).with_suffix('')
seq_name = 'seq_test'

kc_fbx = assets_path['koupen_chan']


def new_seq(xf_runner: XRFeitoriaUnreal, level_path: str, seq_name: str):
    kc_path = xf_runner.utils.import_asset(path=kc_fbx)

    with xf_runner.Sequence.new(level=level_path, seq_name=seq_name, seq_length=30, replace=True) as seq:
        seq.show()
        seq.spawn_camera(location=(-5, 0, 1), rotation=(0, 0, 0), fov=90.0, camera_name='Camera')
        seq.spawn_camera_with_keys(
            transform_keys=[
                SeqTransKey(frame=0, location=(-2, 0, 1), rotation=(0, 0, 0), interpolation='AUTO'),
                SeqTransKey(frame=30, location=(-5, 0, 1), rotation=(0, 0, 0), interpolation='AUTO'),
            ],
            fov=90.0,
            camera_name='Camera2',
        )
        seq.spawn_actor(
            actor_asset_path='/Engine/BasicShapes/Cube',
            actor_name='Actor',
            location=[3, 0, 0],
            rotation=[0, 0, 0],
            stencil_value=2,
        )
        seq.spawn_actor_with_keys(
            actor_asset_path='/Engine/BasicShapes/Cone',
            transform_keys=[
                SeqTransKey(frame=0, location=(-1, 0, 0), rotation=(0, 0, 0), interpolation='AUTO'),
                SeqTransKey(frame=30, location=(0, 3, 5), rotation=(0, 0, 360), interpolation='AUTO'),
            ],
            actor_name='Actor2',
            stencil_value=3,
        )
        seq.spawn_actor(
            actor_asset_path='/Engine/BasicShapes/Cylinder',
            location=[0, 0, 0],
            rotation=[0, 0, 0],
            stencil_value=4,
        )

        seq.spawn_actor_with_keys(
            actor_asset_path=kc_path,
            transform_keys=[
                SeqTransKey(
                    frame=0, location=(0, 0, 0), rotation=(0, 0, 0), scale=(0.05, 0.05, 0.05), interpolation='AUTO'
                ),
                SeqTransKey(frame=5, location=(2, 0, 3), rotation=(0, 180, 0), interpolation='AUTO'),
                SeqTransKey(frame=10, location=(0, 3, 0), rotation=(180, 0, 0), interpolation='AUTO'),
                SeqTransKey(frame=15, location=(0, 0, 3), rotation=(0, 0, 180), interpolation='AUTO'),
                SeqTransKey(frame=20, location=(0, 0, 0), rotation=(0, 0, 0), interpolation='AUTO'),
            ],
            actor_name='KoupenChan',
            stencil_value=5,
        )

        seq.add_to_renderer(
            output_path=output_path,
            resolution=(1920, 1080),
            render_passes=[
                RenderPass('img', 'png'),
                RenderPass('mask', 'exr'),
            ],
            export_vertices=True,
            export_skeleton=True,
        )

        xf_runner.utils.save_current_level()


def sequence_test(debug: bool = False, background: bool = False):
    logger = setup_logger(debug=debug)
    with _init_unreal(background=background) as xf_runner:
        xf_runner.Renderer.clear()

        default_level = '/Game/Levels/Default'
        level_template = '/Game/Levels/Playground'
        level_test = '/Game/Levels/SequenceTest'

        xf_runner.utils.open_level(default_level)
        xf_runner.utils.duplicate_asset(level_template, level_test, replace=True)

        with __timer__("create seq 'test'"):
            new_seq(xf_runner, level_path=level_test, seq_name=seq_name)
        with __timer__('render jobs'):
            xf_runner.Renderer.render_jobs()

    for frame_idx in range(0, 30, 5):
        visualize_vertices(
            camera_name='Camera',
            actor_names=['KoupenChan'],
            seq_output_path=output_path / seq_name,
            frame_idx=frame_idx,
        )

    logger.info('🎉 [bold green]sequence tests passed!')


if __name__ == '__main__':
    import argparse

    args = argparse.ArgumentParser()
    args.add_argument('--debug', action='store_true')
    args.add_argument('--background', '-b', action='store_true')
    args = args.parse_args()

    sequence_test(debug=args.debug, background=args.background)

tests/unreal/sequence.py
openxrlab/xrfeitoria
import os
import time
from contextlib import contextmanager
from functools import partial
from pathlib import Path
from typing import List

import numpy as np
from loguru import logger

import xrfeitoria as xf
from xrfeitoria.camera.camera_parameter import CameraParameter
from xrfeitoria.utils import projector
from xrfeitoria.utils.tools import Logger

try:
    from .config import blender_exec, unreal_exec, unreal_project
except ModuleNotFoundError:
    logger.error('config.py file not found. Run `python -m samples.setup` or `python -m tests.setup` to create it.')
    raise SystemExit(1)


def visualize_vertices(camera_name, actor_names: List[str], seq_output_path: Path, frame_idx=0):
    """Visualize vertices in the first frame of the first sequence.

    Args:
        camera_name (str): camera name
        actor_names (List[str]): actor names
        seq_name (str, optional): sequence name. Defaults to {seq_name}
    """
    # TODO: move to utils.viewer
    try:
        from PIL import Image
    except ImportError:
        logger.error('[red]Please install "Pillow" to visualize vertices:[/red] [bold]pip install Pillow[/bold]')
        exit(1)

    logger.info('Visualizing vertices')
    # fixed file structure
    img_path = seq_output_path / 'img' / camera_name / f'{frame_idx:04d}.png'
    camera_param_json = seq_output_path / 'camera_params' / f'{camera_name}.json'

    # load img and camera parameters
    img = np.array(Image.open(img_path.as_posix()))
    camera_param = CameraParameter.fromfile(camera_param_json.as_posix())

    # load and draw vertices looping over actors
    vis_dir = seq_output_path / 'vertices_vis'
    vis_dir.mkdir(exist_ok=True, parents=True)
    colors = [
        (255, 0, 0),
        (0, 255, 0),
        (0, 0, 255),
    ]
    for idx, actor_name in enumerate(actor_names):
        vert_path = seq_output_path / 'vertices' / f'{actor_name}.npz'
        verts = np.load(vert_path, allow_pickle=True)['verts']
        # draw vertices on image
        img = projector.draw_points3d(verts[frame_idx], camera_param, image=img, color=colors[idx])

    save_path = vis_dir / f'{frame_idx:04d}-overlap.png'
    Image.fromarray(img).save(save_path.as_posix())
    logger.info(f'Original image: "{img_path.as_posix()}"')
    logger.info(f'Overlap image saved to: "{save_path.as_posix()}"')


def setup_logger(debug: bool = False, log_path: str = None, reload: bool = True):
    if reload:
        os.environ['RPC_RELOAD'] = '1'  # reload modules on every call
    logger = Logger.setup_logging(level='DEBUG' if debug else 'INFO', log_path=log_path)
    if debug:
        os.environ['RPC_DEBUG'] = '1'
    return logger


@contextmanager
def __timer__(step_name: str):
    t1 = time.time()
    yield
    t2 = time.time()
    logger.info(f'⌛ {step_name} executed in {(t2-t1):.4f} s')


def parse_args():
    import argparse

    args = argparse.ArgumentParser()
    args.add_argument('--engine', '-e', choices=['unreal', 'blender'], default='unreal', help='engine to run')
    args.add_argument('--debug', action='store_true', help='log debug info')
    args.add_argument('--background', '-b', action='store_true', help='run in background')
    args = args.parse_args()
    return args


_init_blender = partial(
    xf.init_blender,
    exec_path=blender_exec,
    new_process=False,
    replace_plugin=True,
    dev_plugin=False,
)
_init_unreal = partial(
    xf.init_unreal,
    exec_path=unreal_exec,
    project_path=unreal_project,
    new_process=False,
    replace_plugin=True,
    dev_plugin=False,
)

tests/utils.py
openxrlab/xrfeitoria
import threading

_tls = threading.local()
_tls.cache = {'platform': None, 'engine_process': None, 'unreal_project_path': None}

__all__ = ['__version__', 'init_blender', 'init_unreal']


def _get_version() -> str:
    from importlib.metadata import PackageNotFoundError, version

    try:
        __version__ = version(__package__)
    except PackageNotFoundError:
        __version__ = 'unknown'

    return __version__


__version__ = _get_version()
from .factory import init_blender, init_unreal

xrfeitoria/__init__.py
openxrlab/xrfeitoria
from abc import ABC, abstractmethod
from pathlib import Path
from typing import Optional, Tuple

from loguru import logger
from typing_extensions import Self

from ..data_structure.constants import PathLike, Vector
from ..object.object_base import ObjectBase
from ..object.object_utils import ObjectUtilsBase
from ..utils import Validator


class ActorBase(ABC, ObjectBase):
    """Base class for all actors in the world."""

    _object_utils = ObjectUtilsBase

    @property
    def dimensions(self) -> Vector:
        """Dimensions of the actor in the world space."""
        return self._object_utils.get_dimensions(self.name)

    @property
    def bound_box(self) -> Tuple[Vector, Vector]:
        """Bounding box of the actor in the world space."""
        return self._object_utils.get_bound_box(self.name)

    @property
    def stencil_value(self) -> int:
        """Get the stencil value of the actor."""
        return self._get_stencil_value_in_engine(self.name)

    @stencil_value.setter
    def stencil_value(self, value: int):
        """Set the stencil value of the actor.

        Args:
            value (int in [0, 255]): Stencil value.
        """
        self._set_stencil_value_in_engine(self.name, value)

    @property
    def mask_color(self) -> Vector:
        """Get the mask color of the actor.

        RGB values (int) in [0, 255].
        """
        return self._get_mask_color_in_engine(self.name)

    @classmethod
    def import_from_file(
        cls,
        file_path: PathLike,
        actor_name: Optional[str] = None,
        location: 'Vector' = None,
        rotation: 'Vector' = None,
        scale: 'Vector' = None,
        stencil_value: int = 1,
    ) -> Self:
        """Imports an actor from a file and returns its corresponding actor.

        For Blender, support files in types: fbx, obj, abc, ply, stl.

        Note:
            For fbx file, Blender only support binary format. ASCII format is not supported. (`Ref`_)

            .. _Ref: https://docs.blender.org/manual/en/3.6/addons/import_export/scene_fbx.html#id4

        Args:
            path (PathLike): the path to the actor file.
            name (str, optional): the name of the actor. Defaults to None.
            location (Vector, optional): the location of the actor. Defaults to None. unit: meter
            rotation (Vector, optional): the rotation of the actor. Defaults to None. unit: degree
            scale (Vector, optional): the scale of the actor. Defaults to None.
            stencil_value (int in [0, 255], optional): Stencil value of the actor. Defaults to 1.
                Ref to :ref:`FAQ-stencil-value` for details.

        Returns:
            Self: the actor object.
        """
        if actor_name is None:
            actor_name = cls._object_utils.generate_obj_name(obj_type='actor')
        cls._object_utils.validate_new_name(actor_name)
        if location:
            Validator.validate_vector(location, 3)
        if rotation:
            Validator.validate_vector(rotation, 3)
        if scale:
            Validator.validate_vector(scale, 3)

        # judge file path
        file_path = Path(file_path).resolve()
        if not file_path.exists():
            raise FileNotFoundError(f'File "{file_path.as_posix()}" is not found')

        cls._import_actor_from_file_in_engine(file_path=file_path, actor_name=actor_name)
        actor = cls(actor_name)
        if location:
            actor.location = location
        if rotation:
            actor.rotation = rotation
        if scale:
            actor.scale = scale
        actor.stencil_value = stencil_value
        logger.info(f'[cyan]Imported[/cyan] actor "{actor_name}" from "{Path(file_path).as_posix()}"')
        return actor

    def setup_animation(self, animation_path: 'PathLike', action_name: 'Optional[str]' = None) -> None:
        """Load an animation from a file and setup for the actor.

        For Blender, support files in types: fbx, blend, json.

        Note:
            For fbx file, Blender only support binary format. ASCII format is not supported. (`Ref`_)

            .. _Ref: https://docs.blender.org/manual/en/3.6/addons/import_export/scene_fbx.html#id4

        Args:
            animation_path (PathLike): Animation file path.
            action_name (Optional[str], optional): Name of the action in the animation file. Only required when the file type is `.blend `. Defaults to None.
        """
        Validator.validate_argument_type(animation_path, [str, Path])
        self._import_animation_from_file_in_engine(
            animation_path=animation_path, actor_name=self.name, action_name=action_name
        )
        logger.info(
            f'[cyan]Imported[/cyan] animation '
            f'{action_name if action_name is not None else ""}'
            f'from "{animation_path}" and setup for actor "{self.name}"'
        )

    #####################################
    ###### RPC METHODS (Private) ########
    #####################################
    @staticmethod
    @abstractmethod
    def _get_stencil_value_in_engine(actor_name: str) -> int:
        raise NotImplementedError

    @staticmethod
    @abstractmethod
    def _get_mask_color_in_engine(actor_name: str) -> 'Vector':
        raise NotImplementedError

    @staticmethod
    @abstractmethod
    def _set_stencil_value_in_engine(actor_name: str, value: int) -> int:
        raise NotImplementedError

    @staticmethod
    @abstractmethod
    def _import_actor_from_file_in_engine(
        file_path: str,
        actor_name: str,
        stencil_value: int = 1,
    ) -> None:
        pass

    @staticmethod
    @abstractmethod
    def _import_animation_from_file_in_engine(animation_path: str, actor_name: str, action_name: str) -> None:
        raise NotImplementedError

xrfeitoria/actor/actor_base.py
openxrlab/xrfeitoria
from pathlib import Path
from typing import Dict, Literal, Optional, Tuple

from loguru import logger

from ..data_structure.constants import Vector, default_level_blender
from ..object.object_utils import ObjectUtilsBlender
from ..rpc import remote_blender
from ..utils import Validator
from ..utils.functions import blender_functions
from .actor_base import ActorBase

try:
    import bpy  # isort:skip
    from XRFeitoriaBpy.core.factory import XRFeitoriaBlenderFactory  # defined in src/XRFeitoriaBpy/core/factory.py
except ModuleNotFoundError:
    pass

try:
    from ..data_structure.models import TransformKeys  # isort:skip
except ModuleNotFoundError:
    pass


@remote_blender(dec_class=True, suffix='_in_engine')
class ActorBlender(ActorBase):
    """Actor class for Blender."""

    _object_utils = ObjectUtilsBlender

    def set_origin_to_center(self) -> None:
        """Set origin of the object to its center."""
        self._object_utils.set_origin(self.name)

    def set_transform_keys(self, transform_keys: 'TransformKeys') -> None:
        """Set transform keys of actor.

        Args:
            transform_keys (List[Dict]): Keyframes of transform (frame, location, rotation, scale, and interpolation).
        """
        if not isinstance(transform_keys, list):
            transform_keys = [transform_keys]
        transform_keys = [i.model_dump() for i in transform_keys]
        self._object_utils.set_transform_keys(name=self.name, transform_keys=transform_keys)

    #####################################
    ###### RPC METHODS (Private) ########
    #####################################

    @staticmethod
    def _get_stencil_value_in_engine(actor_name: str) -> int:
        """Get stencil value (pass index) of the actor in Blender.

        Args:
            actor_name (str): Name of the actor.

        Returns:
            int: Stencil value (pass index).
        """
        return bpy.data.objects[actor_name].pass_index

    @staticmethod
    def _get_mask_color_in_engine(actor_name: str) -> 'Vector':
        """Get mask color of the actor in Blender.

        Args:
            actor_name (str): Name of the actor.

        Returns:
            Vector: Mask color. (r, g, b) in [0, 255].
        """
        pass_index = ActorBlender._get_stencil_value_in_engine(actor_name=actor_name)
        return (pass_index, pass_index, pass_index)

    @staticmethod
    def _set_stencil_value_in_engine(actor_name: str, value: int) -> int:
        """Set pass index (stencil value) of the actor in Blender.

        Args:
            actor_name (str): Name of the actor.
            value (int in [0, 255]): Pass index (stencil value).
        """
        object = bpy.data.objects[actor_name]
        object.pass_index = value
        for child in object.children:
            child.pass_index = value

    @staticmethod
    def _import_actor_from_file_in_engine(file_path: str, actor_name: str, collection_name: str = None) -> None:
        """Import actor from file.

        Args:
            path (str): File path used of the actor. Support: fbx | obj | alembic.
            name (str): Name of the actor in Blender.
            collection_name (str, optional): Name of the collection to import the actor to.
        Raises:
            TypeError: If 'path' is not `fbx | obj | alembic | ply | stl` file.
        """
        ## get scene and collection
        scene, collection = XRFeitoriaBlenderFactory.get_scene_and_collection_for_new_object(collection_name)

        # import
        with XRFeitoriaBlenderFactory.__judge__(name=actor_name, import_path=file_path, scene=scene):
            blender_functions.import_file(file_path=file_path)

    @staticmethod
    def _import_animation_from_file_in_engine(animation_path: str, actor_name: str, action_name: str = None) -> None:
        """Import an animation file.

        Args:
            animation_path (PathLike): File path used of the actor. Support: json | blend | fbx.
            name (str): Name of the actor to apply this animation.
            action_name (str, optional): Name of the action, only need when importing from a blend file.
                Defaults to None.

        Raises:
            TypeError: If 'animation_path' is not `json | blend | fbx` file.
        """
        anim_file_ext = Path(animation_path).suffix
        if anim_file_ext.lower() == '.json':
            XRFeitoriaBlenderFactory.import_mo_json(mo_json_file=animation_path, actor_name=actor_name)
        elif anim_file_ext.lower() == '.blend':
            XRFeitoriaBlenderFactory.import_mo_blend(
                mo_blend_file=animation_path, actor_name=actor_name, action_name=action_name
            )
        elif anim_file_ext.lower() == '.fbx':
            XRFeitoriaBlenderFactory.import_mo_fbx(mo_fbx_file=animation_path, actor_name=actor_name)
        else:
            raise TypeError(f"Invalid anim file, expected 'json', 'blend', or 'fbx' (got {anim_file_ext[1:]} instead).")


@remote_blender(dec_class=True, suffix='_in_engine')
class ShapeBlenderWrapper:
    """Wrapper class for shapes in Blender."""

    _object_utils = ObjectUtilsBlender

    @classmethod
    def spawn_cube(
        cls,
        name: str,
        size: float = 1.0,
        location: Vector = (0, 0, 0),
        rotation: Vector = (0, 0, 0),
        scale: Vector = (1, 1, 1),
        stencil_value: int = 1,
    ) -> 'ActorBlender':
        """Spawn a cube in the engine.

        Args:
            name (str): Name of the new added cube.
            size (float in [0, inf], optional): Size of the cube. Defaults to 1.0. (unit: meter)
            location (Vector, optional): Location of the cube. Defaults to (0, 0, 0).
            rotation (Vector, optional): Rotation of the cube. Defaults to (0, 0, 0).
            scale (Vector, optional): Scale of the cube. Defaults to (1, 1, 1).
            stencil_value (int in [0, 255], optional): Stencil value of the cube. Defaults to 1.
                Ref to :ref:`FAQ-stencil-value` for details.


        Returns:
            ActorBlender: New added cube.
        """
        return cls.spawn(
            name=name,
            type='cube',
            location=location,
            rotation=rotation,
            scale=scale,
            stencil_value=stencil_value,
            size=size,
        )

    @classmethod
    def spawn_plane(
        cls,
        name: str,
        size: float = 1.0,
        location: Vector = (0, 0, 0),
        rotation: Vector = (0, 0, 0),
        scale: Vector = (1, 1, 1),
        stencil_value: int = 1,
    ) -> 'ActorBlender':
        """Spawn a plane in the engine.

        Args:
            name (str): Name of the new added plane.
            size (float in [0, inf], optional): Size of the plane. Defaults to 1.0. (unit: meter)
            location (Vector, optional): Location of the plane. Defaults to (0, 0, 0).
            rotation (Vector, optional): Rotation of the plane. Defaults to (0, 0, 0).
            scale (Vector, optional): Scale of the plane. Defaults to (1, 1, 1).
            stencil_value (int in [0, 255], optional): Stencil value of the plane. Defaults to 1.
                Ref to :ref:`FAQ-stencil-value` for details.

        Returns:
            ActorBlender: New added plane.
        """
        return cls.spawn(
            name=name,
            type='plane',
            location=location,
            rotation=rotation,
            scale=scale,
            stencil_value=stencil_value,
            size=size,
        )

    @classmethod
    def spawn_sphere(
        cls,
        name: str,
        radius: float = 1.0,
        segments: int = 32,
        ring_count: int = 16,
        location: Vector = (0, 0, 0),
        rotation: Vector = (0, 0, 0),
        scale: Vector = (1, 1, 1),
        stencil_value: int = 1,
    ) -> 'ActorBlender':
        """Spawn a `UV sphere <https://docs.blender.org/manual/en/latest/modeling/meshes/primitives.html#uv-sphere>`_ in the engine.

        Args:
            name (str): Name of the new added UV sphere.
            radius (float in [0, inf], optional): Radius of the UV sphere. Defaults to 1.0. (unit: meter)
            segments (int in [3, 100000], optional): Number of vertical segments on the sphere. Defaults to 32.
            ring_count (int in [3, 100000], optional): Number of horizontal segmentson the sphere. Defaults to 16.
            location (Vector, optional): Location of the UV sphere. Defaults to (0, 0, 0).
            rotation (Vector, optional): Rotation of the UV sphere. Defaults to (0, 0, 0).
            scale (Vector, optional): Scale of the UV sphere. Defaults to (1, 1, 1).
            stencil_value (int in [0, 255], optional): Stencil value of the UV sphere. Defaults to 1.
                Ref to :ref:`FAQ-stencil-value` for details.

        Returns:
            ActorBlender: New added UV sphere.
        """
        return cls.spawn(
            name=name,
            type='sphere',
            location=location,
            rotation=rotation,
            scale=scale,
            stencil_value=stencil_value,
            segments=segments,
            ring_count=ring_count,
            radius=radius,
        )

    @classmethod
    def spawn_ico_sphere(
        cls,
        name: str,
        radius: float = 1.0,
        subdivisions: int = 2,
        location: Vector = (0, 0, 0),
        rotation: Vector = (0, 0, 0),
        scale: Vector = (1, 1, 1),
        stencil_value: int = 1,
    ) -> 'ActorBlender':
        """Spawn an `icosphere <https://docs.blender.org/manual/en/latest/modeling/meshes/primitives.html#icosphere>`_ in the engine.

        Args:
            name (str): Name of the new added icosphere.
            radius (float in [0, inf], optional): Radius of the icosphere. Defaults to 1.0. (unit: meter)
            subdivisions (int in [1, 10], optional): Number of times of splitting each triangular face on the sphere into four triangles. Defaults to 2.
            location (Vector, optional): Location of the icosphere. Defaults to (0, 0, 0).
            rotation (Vector, optional): Rotation of the icosphere. Defaults to (0, 0, 0).
            scale (Vector, optional): Scale of the icosphere. Defaults to (1, 1, 1).
            stencil_value (int in [0, 255], optional): Stencil value of the icosphere. Defaults to 1.
                Ref to :ref:`FAQ-stencil-value` for details.

        Returns:
            ActorBlender: New added icosphere.
        """
        return cls.spawn(
            name=name,
            type='ico_sphere',
            location=location,
            rotation=rotation,
            scale=scale,
            stencil_value=stencil_value,
            subdivisions=subdivisions,
            radius=radius,
        )

    @classmethod
    def spawn_cylinder(
        cls,
        name: str,
        radius: float = 1.0,
        depth: float = 2.0,
        vertices: int = 32,
        location: Vector = (0, 0, 0),
        rotation: Vector = (0, 0, 0),
        scale: Vector = (1, 1, 1),
        stencil_value: int = 1,
    ) -> 'ActorBlender':
        """Spawn a cylinder in the engine.

        Args:
            name (str): Name of the new added cylinder.
            radius (float in [0, inf], optional): Radius of the cylinder's bases. Defaults to 1.0. (unit: meter)
            depth (float in [0, inf], optional): Height of the cylinder. Defaults to 2.0. (unit: meter)
            vertices (int in [3, 10000000], optional): Number of vertices on the circumference of the base of the cylinder. Defaults to 32.
            location (Vector, optional): Location of the cylinder. Defaults to (0, 0, 0).
            rotation (Vector, optional): Rotation of the cylinder. Defaults to (0, 0, 0).
            scale (Vector, optional): Scale of the cylinder. Defaults to (1, 1, 1).
            stencil_value (int in [0, 255], optional): Stencil value of the cylinder. Defaults to 1.
                Ref to :ref:`FAQ-stencil-value` for details.

        Returns:
            ActorBlender: New added cylinder.
        """
        return cls.spawn(
            name=name,
            type='cylinder',
            location=location,
            rotation=rotation,
            scale=scale,
            stencil_value=stencil_value,
            vertices=vertices,
            radius=radius,
            depth=depth,
        )

    @classmethod
    def spawn_cone(
        cls,
        name: str,
        radius1: float = 1.0,
        radius2: float = 0.0,
        depth: float = 2.0,
        vertices: int = 32,
        location: Vector = (0, 0, 0),
        rotation: Vector = (0, 0, 0),
        scale: Vector = (1, 1, 1),
        stencil_value: int = 1,
    ) -> 'ActorBlender':
        """Spawn a cone in the engine.

        Args:
            name (str): Name of the new added cone.
            radius1 (float in [0, inf], optional): Radius of the circular base of the cone. Defaults to 1.0. (unit: meter).
            radius2 (float in [0, inf], optional): Radius of the tip of the cone. Defaults to 0.0. (unit: meter).
            depth (float in [0, inf], optional): Height of the cone. Defaults to 2.0. (unit: meter)
            vertices (int in [3, 10000000], optional): Number of vertices on the circumference of the base of the cone. Defaults to 32.
            location (Vector, optional): Location of the cone. Defaults to (0, 0, 0).
            rotation (Vector, optional): Rotation of the cone. Defaults to (0, 0, 0).
            scale (Vector, optional): Scale of the cone. Defaults to (1, 1, 1).
            stencil_value (int in [0, 255], optional): Stencil value of the cone. Defaults to 1.
                Ref to :ref:`FAQ-stencil-value` for details.

        Returns:
            ActorBlender: New added cone.
        """
        return cls.spawn(
            name=name,
            type='cone',
            location=location,
            rotation=rotation,
            scale=scale,
            stencil_value=stencil_value,
            vertices=vertices,
            radius1=radius1,
            radius2=radius2,
            depth=depth,
        )

    @classmethod
    def spawn(
        cls,
        type: Literal['plane', 'cube', 'sphere', 'ico_sphere', 'cylinder', 'cone'],
        name: Optional[str] = None,
        location: Vector = (0, 0, 0),
        rotation: Vector = (0, 0, 0),
        scale: Vector = (1, 1, 1),
        stencil_value: int = 1,
        **kwargs,
    ) -> 'ActorBlender':
        """Spawn a shape(plane, cube, UV sphere, icosphere, cylinder or cone) in the
        scene.

        Args:
            name (str): Name of the new added shape.
            mesh_type (enum in ['plane', 'cube', 'sphere', 'icosphere', 'cylinder', 'cone']): Type of new spawn shape.
            location (Vector, optional): Location of the shape. Defaults to (0, 0, 0).
            rotation (Vector, optional): Rotation of the shape. Defaults to (0, 0, 0).
            scale (Vector, optional): Scale of the shape. Defaults to (1, 1, 1).
            stencil_value (int in [0, 255], optional): Stencil value of the shape. Defaults to 1.
                Ref to :ref:`FAQ-stencil-value` for details.
            **kwargs
                - ``plane``: size. Ref to :meth:`ShapeBlenderWrapper.spawn_plane <xrfeitoria.actor.actor_blender.ShapeBlenderWrapper.spawn_plane>` for details.
                - ``cube``: size. Ref to :meth:`ShapeBlenderWrapper.spawn_cube <xrfeitoria.actor.actor_blender.ShapeBlenderWrapper.spawn_cube>` for details.
                - ``sphere``: radius, segments, ring_count. Ref to :meth:`ShapeBlenderWrapper.spawn_sphere <xrfeitoria.actor.actor_blender.ShapeBlenderWrapper.spawn_sphere>` for details.
                - ``icosphere``: radius, subdivisions. Ref to :meth:`ShapeBlenderWrapper.spawn_ico_sphere <xrfeitoria.actor.actor_blender.ShapeBlenderWrapper.spawn_ico_sphere>` for details.
                - ``cylinder``: radius, depth, vertices. Ref to :meth:`ShapeBlenderWrapper.spawn_cylinder <xrfeitoria.actor.actor_blender.ShapeBlenderWrapper.spawn_cylinder>` for details.
                - ``cone``: radius1, radius2, depth, vertices. Ref to :meth:`ShapeBlenderWrapper.spawn_cone <xrfeitoria.actor.actor_blender.ShapeBlenderWrapper.spawn_cone>` for details.
        Returns:
            ActorBlender: New added shape.
        """
        if name is None:
            name = cls._object_utils.generate_obj_name(obj_type=type)
        cls._object_utils.validate_new_name(name)
        Validator.validate_vector(location, 3)
        Validator.validate_vector(rotation, 3)
        Validator.validate_vector(scale, 3)

        cls._object_utils.validate_new_name(name)
        cls._spawn_shape_in_engine(
            name=name,
            type=type,
            **kwargs,
        )
        mesh = ActorBlender(name=name)
        mesh.set_transform(location, rotation, scale)
        mesh.stencil_value = stencil_value
        logger.info(f'[cyan]Spawned[/cyan] {type} "{name}"')
        return mesh

    #####################################
    ###### RPC METHODS (Private) ########
    #####################################

    @staticmethod
    def _spawn_shape_in_engine(
        name: str,
        type: str,
        collection_name: str = None,
        size: float = 1.0,
        segments: int = 32,
        ring_count: int = 16,
        radius: float = 1.0,
        subdivisions: int = 2,
        vertices: int = 32,
        depth: float = 2.0,
        radius1: float = 0.0,
        radius2: float = 2.0,
    ) -> None:
        """Spawn a shape in Blender.

        Args:
            name (str): Name of the new added shape.
            mesh_type (enum in ['plane', 'cube', 'UV sphere', 'icosphere', 'cylinder', 'cone']): Type of new spawn shape.
            size (float in [0, inf], optional): Size. Defaults to 1.0. (unit: meter)
            segments (int in [3, 100000], optional): Segments. Defaults to 32.
            ring_count (int in [3, 100000], optional): Ring count. Defaults to 16.
            radius (float in [0, inf], optional): Radius. Defaults to 1.0. (unit: meter)
            subdivisions (int in [1, 10], optional): Subdivisions. Defaults to 2.
            vertices (int in [3, 10000000], optional): Vertices. Defaults to 32.
            depth (float in [0, inf], optional): Depth. Defaults to 2.0. (unit: meter)
            radius1 (float in [0, inf], optional): Radius1. Defaults to 0.0. (unit: meter)
            radius2 (float in [0, inf], optional): Radius2. Defaults to 2.0. (unit: meter)

        Raises:
            TypeError: If `mesh_type` is not in Enum ['plane', 'cube', 'UV sphere', 'icosphere', 'cylinder', 'cone']
        """

        ## get scene and collection
        scene, collection = XRFeitoriaBlenderFactory.get_scene_and_collection_for_new_object(collection_name)

        # spawn mesh
        with XRFeitoriaBlenderFactory.__judge__(name, scene=scene):
            if type == 'plane':
                bpy.ops.mesh.primitive_plane_add(size=size)
            elif type == 'cube':
                bpy.ops.mesh.primitive_cube_add(size=size)
            elif type == 'sphere':
                bpy.ops.mesh.primitive_uv_sphere_add(
                    segments=segments,
                    ring_count=ring_count,
                    radius=radius,
                )
            elif type == 'ico_sphere':
                bpy.ops.mesh.primitive_ico_sphere_add(
                    subdivisions=subdivisions,
                    radius=radius,
                )
            elif type == 'cylinder':
                bpy.ops.mesh.primitive_cylinder_add(
                    vertices=vertices,
                    radius=radius,
                    depth=depth,
                )
            elif type == 'cone':
                bpy.ops.mesh.primitive_cone_add(
                    vertices=vertices,
                    radius1=radius1,
                    radius2=radius2,
                    depth=depth,
                )
            else:
                raise TypeError(
                    f'Unsupported mesh type, expected "plane", "cube", "sphere", '
                    f'"ico_sphere", "cylinder" or "cone", (got "{type}" instead).'
                )

xrfeitoria/actor/actor_blender.py
openxrlab/xrfeitoria
from typing import Literal, Optional

from loguru import logger

from ..data_structure.constants import ShapeTypeEnumUnreal, Vector
from ..object.object_utils import ObjectUtilsUnreal
from ..rpc import remote_unreal
from ..utils import Validator
from ..utils.functions import unreal_functions
from .actor_base import ActorBase

try:
    import unreal
    from unreal_factory import XRFeitoriaUnrealFactory  # defined in src/XRFeitoriaUnreal/Content/Python
except ModuleNotFoundError:
    pass


@remote_unreal(dec_class=True, suffix='_in_engine')
class ActorUnreal(ActorBase):
    """Actor class for Unreal Engine."""

    _object_utils = ObjectUtilsUnreal

    @property
    def engine_path(self) -> str:
        """Engine path of the actor."""
        return self._object_utils._get_engine_path_in_engine(self.name)

    @classmethod
    def spawn_from_engine_path(
        cls,
        engine_path: str,
        name: Optional[str] = None,
        location: 'Vector' = (0, 0, 0),
        rotation: 'Vector' = (0, 0, 0),
        scale: 'Vector' = (1, 1, 1),
    ) -> 'ActorBase':
        """Spawns an actor in the engine and returns its corresponding actor.

        Args:
            engine_path (str): the path to the actor in the engine. For example, '/Game/Engine/BasicShapes/Cube'.
            name (str, optional): the name of the actor. Defaults to None, in which case a name will be generated.
            location (Vector, optional): the location of the actor. Units are in meters. Defaults to (0, 0, 0).
            rotation (Vector, optional): the rotation of the actor. Units are in degrees. Defaults to (0, 0, 0).
            scale (Vector, optional): the scale of the actor. Defaults to (1, 1, 1).

        Returns:
            ActorBase: the actor that was spawned
        """
        Validator.validate_argument_type(engine_path, str)
        if name is None:
            name = cls._object_utils.generate_obj_name(obj_type='actor')
        cls._object_utils.validate_new_name(name)
        Validator.validate_vector(location, 3)
        Validator.validate_vector(rotation, 3)
        Validator.validate_vector(scale, 3)

        _name = cls._spawn_actor_in_engine(engine_path, name=name, location=location, rotation=rotation, scale=scale)
        logger.info(f'[cyan]Spawned[/cyan] actor "{_name}"')
        return cls(_name)

    #####################################
    ###### RPC METHODS (Private) ########
    #####################################
    @staticmethod
    def _get_stencil_value_in_engine(actor_name: str) -> int:
        """Get stencil value of the actor in Unreal Engine.

        Args:
            actor_name (str): Name of the actor.

        Returns:
            int: Stencil value.
        """
        actor = XRFeitoriaUnrealFactory.utils_actor.get_actor_by_name(actor_name)
        return XRFeitoriaUnrealFactory.utils_actor.get_stencil_value(actor)

    @staticmethod
    def _get_mask_color_in_engine(actor_name: str) -> 'Vector':
        """Get mask color of the actor in Unreal Engine.

        Args:
            actor_name (str): Name of the actor.

        Returns:
            Vector: Mask color. (r, g, b) in [0, 255].
        """
        actor = XRFeitoriaUnrealFactory.utils_actor.get_actor_by_name(actor_name)
        return XRFeitoriaUnrealFactory.utils_actor.get_actor_mask_color(actor)

    @staticmethod
    def _set_stencil_value_in_engine(actor_name: str, value: int) -> int:
        """Set pass index of the actor in Unreal Engine.

        Args:
            actor_name (str): Name of the actor.
            value (int in [0, 255]): Pass index (stencil value).
        """
        actor = XRFeitoriaUnrealFactory.utils_actor.get_actor_by_name(actor_name)
        XRFeitoriaUnrealFactory.utils_actor.set_stencil_value(actor, value)

    @staticmethod
    def _import_actor_from_file_in_engine(file_path: str, actor_name: str) -> None:
        """Imports an actor from a file in the Unreal Engine and spawns it in the world
        with a given name.

        Args:
            file_path (str): The path to the file of the actor to import.
            actor_name (str): The name to give to the spawned actor in the world.
        """
        actor_paths = XRFeitoriaUnrealFactory.utils.import_asset(file_path)
        actor_path = [path for path in actor_paths if path.split('/')[-1] == path.split('/')[-2]]
        actor_object = unreal.load_asset(actor_path[0])
        actor = XRFeitoriaUnrealFactory.utils_actor.spawn_actor_from_object(actor_object=actor_object)
        actor.set_actor_label(actor_name)
        if isinstance(actor, unreal.SkeletalMeshActor):
            anim_asset_exist = unreal.EditorAssetLibrary.does_asset_exist(f'{actor_path[0]}_Anim')
            if anim_asset_exist:
                anim_asset = unreal.load_asset(f'{actor_path[0]}_Anim')
                actor.skeletal_mesh_component.override_animation_data(anim_asset)
                actor.skeletal_mesh_component.set_animation_mode(unreal.AnimationMode.ANIMATION_SINGLE_NODE)

    @staticmethod
    def _spawn_actor_in_engine(
        engine_path: str,
        name: str,
        location: 'Vector' = (0, 0, 0),
        rotation: 'Vector' = (0, 0, 0),
        scale: 'Vector' = (1, 1, 1),
    ) -> str:
        """Spawns an actor in the engine and returns its name.

        Args:
            engine_path (str): the path to the actor in the engine. For example, '/Game/Engine/BasicShapes/Cube'.
            name (str, optional): the name of the actor. Defaults to None, in which case a name will be generated.
            location (Vector, optional): the location of the actor. Units are in meters. Defaults to (0, 0, 0).
            rotation (Vector, optional): the rotation of the actor. Units are in degrees. Defaults to (0, 0, 0).
            scale (Vector, optional): the scale of the actor. Defaults to (1, 1, 1).

        Returns:
            str: the name of the actor that was spawned
        """
        unreal_functions.check_asset_in_engine(engine_path, raise_error=True)
        _object = unreal.load_asset(engine_path)
        # location = [loc * 100.0 for loc in location]  # convert from meters to centimeters
        _actor = XRFeitoriaUnrealFactory.utils_actor.spawn_actor_from_object(_object, location, rotation, scale)
        _actor.set_actor_label(name)
        return _actor.get_actor_label()

    @staticmethod
    def _import_animation_from_file_in_engine(animation_path: str, actor_name: str, action_name: str) -> None:
        # get actor
        actor = XRFeitoriaUnrealFactory.utils_actor.get_actor_by_name(actor_name)
        if not isinstance(actor, unreal.SkeletalMeshActor):
            raise TypeError(f'Actor "{actor_name}" is not a skeletal mesh actor.')
        # get actor's engine path
        actor_engine_path = actor.skeletal_mesh_component.skeletal_mesh.get_path_name().split('.')[0]
        # import animation
        animation_engine_path = XRFeitoriaUnrealFactory.utils.import_anim(
            animation_path, f'{actor_engine_path}_Skeleton'
        )
        # set animation
        anim_asset_exist = unreal.EditorAssetLibrary.does_asset_exist(animation_engine_path[0])
        if anim_asset_exist:
            anim_asset = unreal.load_asset(animation_engine_path[0])
            actor.skeletal_mesh_component.override_animation_data(anim_asset)
            actor.skeletal_mesh_component.set_animation_mode(unreal.AnimationMode.ANIMATION_SINGLE_NODE)


@remote_unreal(dec_class=True, suffix='_in_engine')
class ShapeUnrealWrapper:
    """Wrapper class for shapes in Unreal Engine."""

    _object_utils = ObjectUtilsUnreal
    path_mapping = {
        ShapeTypeEnumUnreal.cube.value: '/Engine/BasicShapes/Cube',
        ShapeTypeEnumUnreal.sphere.value: '/Engine/BasicShapes/Sphere',
        ShapeTypeEnumUnreal.cylinder.value: '/Engine/BasicShapes/Cylinder',
        ShapeTypeEnumUnreal.cone.value: '/Engine/BasicShapes/Cone',
        ShapeTypeEnumUnreal.plane.value: '/Engine/BasicShapes/Plane',
    }

    @classmethod
    def spawn(
        cls,
        type: Literal['cube', 'sphere', 'cylinder', 'cone', 'plane'],
        name: Optional[str] = None,
        location: Vector = (0, 0, 0),
        rotation: Vector = (0, 0, 0),
        scale: Vector = (1, 1, 1),
    ) -> 'ActorUnreal':
        """Spawns a shape in the engine and returns its corresponding actor.

        Args:
            mesh_type (Literal['cube', 'sphere', 'cylinder', 'cone', 'plane']): the type of the shape.
            name (Optional[str], optional): the name of the shape. Defaults to None.
            location (Vector, optional): the location of the shape. Units are in meters. Defaults to (0, 0, 0).
            rotation (Vector, optional): the rotation of the shape. Units are in degrees. Defaults to (0, 0, 0).
            scale (Vector, optional): the scale of the shape. Defaults to (1, 1, 1).
        """
        if name is None:
            name = cls._object_utils.generate_obj_name(obj_type=type)

        return ActorUnreal.spawn_from_engine_path(
            engine_path=cls.path_mapping[type],
            name=name,
            location=location,
            rotation=rotation,
            scale=scale,
        )

xrfeitoria/actor/actor_unreal.py
openxrlab/xrfeitoria
from abc import ABC, abstractmethod
from pathlib import Path
from typing import List, Tuple

from loguru import logger

from ..data_structure.constants import Matrix, PathLike, Vector
from ..object.object_base import ObjectBase
from ..object.object_utils import ObjectUtilsBase
from ..utils import Validator


class CameraBase(ABC, ObjectBase):
    """Base camera class."""

    _object_utils = ObjectUtilsBase

    @classmethod
    def spawn(
        cls,
        camera_name: str = None,
        location: Vector = (0, 0, 0),
        rotation: Vector = (0, 0, 0),
        fov: float = 90.0,
    ) -> 'CameraBase':
        """Spawn a camera in the engine.

        Args:
            name (str, optional): name of the camera
            location (Vector, optional): location of the camera. Defaults to (0, 0, 0).
            rotation (Vector, optional): rotation of the camera. Defaults to (0, 0, 0).
            fov (float, optional): field of view of the camera. Defaults to 90.0.
        Returns:
            CameraBase: New camera
        """
        if camera_name is None:
            camera_name = cls._object_utils.generate_obj_name(obj_type='camera')
        cls._object_utils.validate_new_name(camera_name)
        Validator.validate_vector(location, 3)
        Validator.validate_vector(rotation, 3)
        Validator.validate_argument_type(fov, [float, int])
        cls._spawn_in_engine(camera_name=camera_name, location=location, rotation=rotation, fov=fov)
        logger.info(f'[cyan]Spawned[/cyan] camera "{camera_name}"')
        return cls(camera_name)

    @property
    def fov(self) -> float:
        """Field of view of the camera lens, in degrees."""
        return self._get_fov_in_engine(self._name)

    @fov.setter
    def fov(self, value):
        Validator.validate_argument_type(value, [float, int])
        self._set_camera_fov_in_engine(self._name, value)

    def get_KRT(self) -> Tuple[List, List, Vector]:
        """Get the intrinsic and extrinsic parameters of the camera.

        Returns:
            Tuple[Matrix, Matrix, Vector]: K, R, T
        """
        return self._get_KRT_in_engine(self._name)

    def set_KRT(self, K: List, R: List, T: Vector) -> None:
        """Set the intrinsic and extrinsic parameters of the camera.

        Args:
            K (Matrix): 3x3 intrinsic matrix
            R (Matrix): 3x3 rotation matrix
            T (Vector): 3x1 translation vector
        """
        self._set_KRT_in_engine(self._name, K, R, T)

    def dump_params(self, output_path: PathLike) -> None:
        """Dump the intrinsic and extrinsic parameters of the camera to a file.

        Args:
            output_path (PathLike): path to the camera parameter file
        """
        from .camera_parameter import CameraParameter

        # mkdir
        output_path = Path(output_path).resolve()
        output_path.parent.mkdir(parents=True, exist_ok=True)

        # dump
        K, R, T = self.get_KRT()
        camera_param = CameraParameter(K=K, R=R, T=T)
        camera_param.dump(output_path.as_posix())
        logger.debug(f'Camera parameters dumped to "{output_path.as_posix()}"')

    def look_at(self, target: Vector) -> None:
        """Set the camera to look at the target.

        Args:
            target (Vector): [x, y, z] coordinates of the target, in units of meters.
        """
        # TODO: get rid of _look_at_in_engine, calculate the rotation matrix from the direction vector
        # direction = np.array(target) - np.array(self.location)
        # self.rotation = self._object_utils.direction_to_euler(direction)
        self._look_at_in_engine(self._name, target)

    #################################
    ####  RPC METHODS (Private)  ####
    #################################

    # ----- Getter ----- #

    @staticmethod
    @abstractmethod
    def _get_KRT_in_engine(name: str):
        pass

    @staticmethod
    @abstractmethod
    def _get_fov_in_engine(name: str) -> float:
        pass

    # ----- Setter ----- #

    @staticmethod
    @abstractmethod
    def _set_KRT_in_engine(name, K, R, T):
        pass

    @staticmethod
    @abstractmethod
    def _set_camera_fov_in_engine(name: str, value: float):
        pass

    @staticmethod
    @abstractmethod
    def _spawn_in_engine(camera_name, location, rotation, fov):
        pass

    @staticmethod
    @abstractmethod
    def _look_at_in_engine(name, target):
        pass

xrfeitoria/camera/camera_base.py
openxrlab/xrfeitoria
import math
from typing import Dict, List, Optional, Tuple, Union

from ..data_structure.constants import Vector
from ..object.object_utils import ObjectUtilsBlender
from ..rpc import remote_blender
from ..utils import Validator
from .camera_base import CameraBase

try:
    import bpy  # isort:skip
    from XRFeitoriaBpy.core.factory import XRFeitoriaBlenderFactory  # defined in src/XRFeitoriaBpy/core/factory.py
except ModuleNotFoundError:
    pass

try:
    from ..data_structure.models import TransformKeys  # isort:skip
except ModuleNotFoundError:
    pass


@remote_blender(dec_class=True, suffix='_in_engine')
class CameraBlender(CameraBase):
    """Camera class for Blender."""

    _object_utils = ObjectUtilsBlender

    @property
    def active(self) -> bool:
        """Activaty of the camera.

        Only active cameras participate in rendering.
        """
        return self._get_camera_active_in_engine(self._name)

    @active.setter
    def active(self, value):
        Validator.validate_argument_type(value, bool)
        self._set_camera_active_in_engine(self._name, value)

    def set_transform_keys(self, transform_keys: 'TransformKeys') -> None:
        """Set transform keys of actor.

        Args:
            transform_keys (List[Dict]): Keyframes of transform (frame, location, rotation, scale, and interpolation).
        """
        if not isinstance(transform_keys, list):
            transform_keys = [transform_keys]
        transform_keys = [i.model_dump() for i in transform_keys]
        self._object_utils.set_transform_keys(name=self.name, transform_keys=transform_keys)

    #####################################
    ###### RPC METHODS (Private) ########
    #####################################

    ######   Getter   ######
    @staticmethod
    def _get_camera_active_in_engine(name: str) -> bool:
        """Get camera's activity in blender. (Only the active cameras participate in
        rendering.)

        Args:
            name (str): name of the camera.

        Returns:
            bool: activity of the camera.
        """
        view: bpy.types.SceneRenderView = bpy.context.scene.render.views.get(name)
        if view is None:
            return False
        else:
            return view.use

    @staticmethod
    def _get_fov_in_engine(name: str) -> float:
        """Get camera's field of view from blender.

        Args:
            name (str): name of the camera.

        Returns:
            float: camera's field of view. (unit: degree)
        """
        camera = bpy.data.objects[name]
        return math.degrees(camera.data.angle)

    @staticmethod
    def _get_KRT_in_engine(name: str) -> 'Tuple[List, List, List]':
        """Get camrea's intrinsic and extrinsic parameters from blender.

        Args:
            name (str): name of the camera.

        Returns:
            Tuple[List, List, List]: K (3x3), R (3x3), T (3)
        """
        camera = bpy.data.objects[name]
        K, R, T = XRFeitoriaBlenderFactory.get_camera_KRT_from_blender(camera)
        return K.tolist(), R.tolist(), T.tolist()

    ######   Setter   ######

    @staticmethod
    def _set_KRT_in_engine(name, K, R, T):
        raise NotImplementedError

    @staticmethod
    def _spawn_in_engine(
        camera_name: str,
        collection_name: 'Optional[str]' = None,
        location: 'Vector' = (0, 0, 0),
        rotation: 'Vector' = (0, 0, 0),
        fov: float = 90.0,
    ) -> None:
        """Setup a new camera in blender.

        Args:
            name (str): Name of the new camera.
            collection_name (str): Name of the scene where spawn the new camera.
            location (Vector, optional): Location of the camera. Defaults to (0, 0, 0).
            rotation (Vector, optional): Rotation of the camera. Defaults to (0, 0, 0).
            fov (float in (0.0, 180.0), optional): Field of view of the camera. Defaults to 90.0. (unit: degree)
        """
        ## Create new camera datablock
        camera_data = bpy.data.cameras.new(name=camera_name)

        ## set FOV
        camera_data.lens_unit = 'FOV'
        camera_data.angle = math.radians(fov)  # fov in radians

        ## Create new object with the camera datablock
        camera = bpy.data.objects.new(name=camera_name, object_data=camera_data)

        ## set camera location and rotation
        camera.location = location
        camera.rotation_euler = tuple(math.radians(r) for r in rotation)

        ## get scene and collection
        scene, collection = XRFeitoriaBlenderFactory.get_scene_and_collection_for_new_object(collection_name)

        # link the camera to the collection
        collection.objects.link(camera)

        # set camera activity
        active = False if scene.name == collection.name else True
        XRFeitoriaBlenderFactory.set_camera_activity(camera_name=camera_name, scene=scene, active=active)
        scene.camera = camera

    @staticmethod
    def _set_camera_fov_in_engine(name: str, fov: float):
        """Set camera's field of view in blender.

        Args:
            name (str): name of the camera.
            fov (float): field of view of the camera.
        """
        camera = bpy.data.objects[name]
        camera.data.angle = math.radians(fov)

    @staticmethod
    def _set_camera_active_in_engine(name: str, active: bool):
        """Set camera's activity in blender. (Only the active cameras participate in
        rendering.)

        Args:
            name (str): name of the camera.
            active (bool): activity of the camera.
        """
        bpy.context.scene.render.views[name].use = active

    @staticmethod
    def _look_at_in_engine(name: str, target: 'Vector'):
        """Set the camera to look at the target in blender.

        Args:
            name (str): name of the camera.
            target (Vector): the location of the target.
        """
        camera = bpy.data.objects[name]
        loc_camera = camera.location
        camera.rotation_euler = XRFeitoriaBlenderFactory.get_rotation_to_look_at(loc_camera, target)

xrfeitoria/camera/camera_blender.py
openxrlab/xrfeitoria
import copy
import math
from typing import List, Tuple, Union

import numpy as np
import numpy.typing as npt
from loguru import logger
from xrprimer.data_structure.camera import PinholeCameraParameter
from xrprimer.transform.convention.camera import convert_camera_parameter

from ..data_structure.constants import PathLike


class CameraParameter(PinholeCameraParameter):
    """Camera parameter class for pinhole camera model.

    Inherit from XRPrimer.
    """

    def __init__(
        self,
        K: Union[List, np.ndarray],
        R: Union[List, np.ndarray],
        T: Union[List, np.ndarray],
        convention: str = 'opencv',
        world2cam: bool = False,
    ):
        """A camera parameter class for pinhole camera model.

        Args:
            K (Union[list, np.ndarray]):
                Nested list of float32, 4x4 or 3x3 K mat.
            R (Union[list, np.ndarray]):
                Nested list of float32, 3x3 rotation mat.
            T (Union[list, np.ndarray, None]):
                List of float32, T vector.
            convention (str, optional):
                Convention name of this camera.
                Defaults to 'opencv'.
            world2cam (bool, optional):
                Whether the R, T transform points from world space
                to camera space. Defaults to True.
        """
        width, height = int(K[0][2] * 2), int(K[1][2] * 2)
        super().__init__(
            K,
            R,
            T,
            width=width,
            height=height,
            convention=convention,
            world2cam=world2cam,
            logger=logger,
        )
        # type hinting
        self.intrinsic: npt.NDArray[np.float32]  # 4x4
        self.extrinsic_r: npt.NDArray[np.float32]  # 3x3
        self.extrinsic_t: npt.NDArray[np.float32]  # 3
        self.height: int
        self.width: int
        self.convention: str
        self.world2cam: bool

    def clone(self) -> 'CameraParameter':
        """Clone a new CameraParameter instance like self.

        Returns:
            CameraParameter
        """
        new_cam_param = self.__class__(
            K=copy.deepcopy(self.get_intrinsic(k_dim=4)),
            R=copy.deepcopy(self.extrinsic_r),
            T=copy.deepcopy(self.extrinsic_t),
            world2cam=self.world2cam,
            convention=self.convention,
        )
        return new_cam_param

    @property
    def projection_matrix(self) -> npt.NDArray[np.float32]:
        """Get the camera matrix of ``K@RT``.

        Returns:
            ndarray: An ndarray of float32, 3x4 ``K@RT`` mat.
        """
        return self.intrinsic33() @ self.extrinsic

    @property
    def extrinsic(self) -> npt.NDArray[np.float32]:
        """Get the extrinsic matrix of RT.

        Returns:
            ndarray: An ndarray of float32, 3x4 RT mat.
        """
        return np.concatenate([self.extrinsic_r, self.extrinsic_t.reshape(3, 1)], axis=1)

    def get_projection_matrix(self) -> List:
        """Get the camera matrix of ``K@RT``.

        Returns:
            List: A list of float32, 3x4 ``K@RT`` mat.
        """
        return self.projection_matrix.tolist()

    def get_extrinsic(self) -> List:
        """Get the camera matrix of RT.

        Returns:
            List: A list of float32, 3x4 RT mat.
        """
        return self.extrinsic.tolist()

    @classmethod
    def fromfile(cls, file: PathLike) -> 'CameraParameter':
        """Construct a camera parameter data structure from a json file.

        Args:
            filename (PathLike): Path to the dumped json file.

        Returns:
            CameraParameter: An instance of CameraParameter class.
        """
        file = str(file)
        ret_cam = PinholeCameraParameter.fromfile(file)
        ret_cam.load(file)
        return cls._from_pinhole(ret_cam)

    @classmethod
    def from_bin(cls, file: PathLike) -> 'CameraParameter':
        """Construct a camera parameter data structure from a binary file.

        Args:
            file (PathLike): Path to the dumped binary file.

        Returns:
            CameraParameter: An instance of CameraParameter class.
        """
        # read camera parameters
        with open(file, 'rb') as f:
            dat = np.frombuffer(f.read(), np.float32).reshape(9)
        location = dat[:3]
        rotation = dat[3:6]
        camera_fov = dat[6]
        image_size = (dat[7], dat[8])  # (width, height)

        # intrinsic matrix K
        fov = math.radians(camera_fov)
        focal = max(image_size) / 2 / math.tan(fov / 2)
        fx = fy = focal
        K = np.array(
            [
                [fx, 0, image_size[0] / 2],
                [0, fy, image_size[1] / 2],
                [0, 0, 1],
            ]
        )

        # extrinsic matrix RT
        x, y, z = -rotation[1], -rotation[2], -rotation[0]
        R = rotation_matrix([x, y, z], order='xyz', degrees=True)
        T = np.array([location[1], -location[2], location[0]]) / 100.0  # unit: meter

        # construct camera parameter
        cam_param = cls(K=K, R=R, T=T, world2cam=True)
        cam_param.inverse_extrinsic()
        return cam_param

    @classmethod
    def _from_pinhole(cls, pinhole: PinholeCameraParameter) -> 'CameraParameter':
        """Construct a camera parameter data structure from a pinhole camera parameter.

        Args:
            pinhole (PinholeCameraParameter):
                An instance of PinholeCameraParameter class.

        Returns:
            CameraParameter:
                An instance of CameraParameter class.
        """
        ret_cam = cls(
            K=pinhole.get_intrinsic(k_dim=4),
            R=pinhole.extrinsic_r,
            T=pinhole.extrinsic_t,
            convention=pinhole.convention,
            world2cam=pinhole.world2cam,
        )
        return ret_cam

    def convert_convention(self, dst: str):
        """Convert the convention of this camera parameter. In-place.

        Args:
            dst (str): The name of destination convention. One of ['opencv', 'blender', 'unreal'].
        """
        cam_param = convert_camera_parameter(self, dst)
        self.set_KRT(K=cam_param.intrinsic, R=cam_param.extrinsic_r, T=cam_param.extrinsic_t)
        self.convention = dst
        self.world2cam = cam_param.world2cam

    def __repr__(self) -> str:
        return f'CameraParameter(T={self.extrinsic_t}, convention="{self.convention}", world2cam={self.world2cam})'


def rotation_matrix(angles: Tuple[float, float, float], order='xyz', degrees: bool = True) -> npt.NDArray[np.float32]:
    """
    Args:
        angles (Tuple[float, float, float]): Rotation angles in degrees or radians.
        order (str, optional): Rotation order. Defaults to 'xyz'.
        degrees (bool, optional): Whether the input angles are in degrees. Defaults to True.
    Returns:
        ndarray: Rotation matrix 3x3.

    Examples:
        >>> rotation_matrix((0, 0, 0), order='xyz')

    References:
        https://programming-surgeon.com/en/euler-angle-python-en/
    """
    if degrees:
        angles = np.deg2rad(angles)

    theta1, theta2, theta3 = angles
    c1 = np.cos(theta1)
    s1 = np.sin(theta1)
    c2 = np.cos(theta2)
    s2 = np.sin(theta2)
    c3 = np.cos(theta3)
    s3 = np.sin(theta3)

    if order == 'xzx':
        matrix = np.array(
            [
                [c2, -c3 * s2, s2 * s3],
                [c1 * s2, c1 * c2 * c3 - s1 * s3, -c3 * s1 - c1 * c2 * s3],
                [s1 * s2, c1 * s3 + c2 * c3 * s1, c1 * c3 - c2 * s1 * s3],
            ]
        )
    elif order == 'xyx':
        matrix = np.array(
            [
                [c2, s2 * s3, c3 * s2],
                [s1 * s2, c1 * c3 - c2 * s1 * s3, -c1 * s3 - c2 * c3 * s1],
                [-c1 * s2, c3 * s1 + c1 * c2 * s3, c1 * c2 * c3 - s1 * s3],
            ]
        )
    elif order == 'yxy':
        matrix = np.array(
            [
                [c1 * c3 - c2 * s1 * s3, s1 * s2, c1 * s3 + c2 * c3 * s1],
                [s2 * s3, c2, -c3 * s2],
                [-c3 * s1 - c1 * c2 * s3, c1 * s2, c1 * c2 * c3 - s1 * s3],
            ]
        )
    elif order == 'yzy':
        matrix = np.array(
            [
                [c1 * c2 * c3 - s1 * s3, -c1 * s2, c3 * s1 + c1 * c2 * s3],
                [c3 * s2, c2, s2 * s3],
                [-c1 * s3 - c2 * c3 * s1, s1 * s2, c1 * c3 - c2 * s1 * s3],
            ]
        )
    elif order == 'zyz':
        matrix = np.array(
            [
                [c1 * c2 * c3 - s1 * s3, -c3 * s1 - c1 * c2 * s3, c1 * s2],
                [c1 * s3 + c2 * c3 * s1, c1 * c3 - c2 * s1 * s3, s1 * s2],
                [-c3 * s2, s2 * s3, c2],
            ]
        )
    elif order == 'zxz':
        matrix = np.array(
            [
                [c1 * c3 - c2 * s1 * s3, -c1 * s3 - c2 * c3 * s1, s1 * s2],
                [c3 * s1 + c1 * c2 * s3, c1 * c2 * c3 - s1 * s3, -c1 * s2],
                [s2 * s3, c3 * s2, c2],
            ]
        )
    elif order == 'xyz':
        matrix = np.array(
            [
                [c2 * c3, -c2 * s3, s2],
                [c1 * s3 + c3 * s1 * s2, c1 * c3 - s1 * s2 * s3, -c2 * s1],
                [s1 * s3 - c1 * c3 * s2, c3 * s1 + c1 * s2 * s3, c1 * c2],
            ]
        )
    elif order == 'xzy':
        matrix = np.array(
            [
                [c2 * c3, -s2, c2 * s3],
                [s1 * s3 + c1 * c3 * s2, c1 * c2, c1 * s2 * s3 - c3 * s1],
                [c3 * s1 * s2 - c1 * s3, c2 * s1, c1 * c3 + s1 * s2 * s3],
            ]
        )
    elif order == 'yxz':
        matrix = np.array(
            [
                [c1 * c3 + s1 * s2 * s3, c3 * s1 * s2 - c1 * s3, c2 * s1],
                [c2 * s3, c2 * c3, -s2],
                [c1 * s2 * s3 - c3 * s1, c1 * c3 * s2 + s1 * s3, c1 * c2],
            ]
        )
    elif order == 'yzx':
        matrix = np.array(
            [
                [c1 * c2, s1 * s3 - c1 * c3 * s2, c3 * s1 + c1 * s2 * s3],
                [s2, c2 * c3, -c2 * s3],
                [-c2 * s1, c1 * s3 + c3 * s1 * s2, c1 * c3 - s1 * s2 * s3],
            ]
        )
    elif order == 'zyx':
        matrix = np.array(
            [
                [c1 * c2, c1 * s2 * s3 - c3 * s1, s1 * s3 + c1 * c3 * s2],
                [c2 * s1, c1 * c3 + s1 * s2 * s3, c3 * s1 * s2 - c1 * s3],
                [-s2, c2 * s3, c2 * c3],
            ]
        )
    elif order == 'zxy':
        matrix = np.array(
            [
                [c1 * c3 - s1 * s2 * s3, -c2 * s1, c1 * s3 + c3 * s1 * s2],
                [c3 * s1 + c1 * s2 * s3, c1 * c2, s1 * s3 - c1 * c3 * s2],
                [-c2 * s3, s2, c2 * c3],
            ]
        )

    return matrix

xrfeitoria/camera/camera_parameter.py
openxrlab/xrfeitoria
from ..data_structure.constants import Vector
from ..object.object_utils import ObjectUtilsUnreal
from ..rpc import remote_unreal
from .camera_base import CameraBase

try:
    import unreal
    from unreal_factory import XRFeitoriaUnrealFactory  # defined in src/XRFeitoriaUnreal/Content/Python
except ModuleNotFoundError:
    pass


@remote_unreal(dec_class=True, suffix='_in_engine')
class CameraUnreal(CameraBase):
    """Camera class for Unreal."""

    _object_utils = ObjectUtilsUnreal

    def look_at(self, target: Vector) -> None:
        """Set the camera to look at the target.

        Args:
            target (Vector): [x, y, z] coordinates of the target, in units of meters.
        """
        target = [x * 100.0 for x in target]  # convert to cm
        super().look_at(target)

    # ----- Getter ----- #

    @staticmethod
    def _get_KRT_in_engine(name: str):
        raise NotImplementedError

    @staticmethod
    def _get_fov_in_engine(name):
        camera: unreal.CameraActor = XRFeitoriaUnrealFactory.utils_actor.get_actor_by_name(name)
        return camera.camera_component.field_of_view

    # ----- Setter ----- #

    @staticmethod
    def _set_KRT_in_engine(name, K, R, T):
        raise NotImplementedError

    @staticmethod
    def _set_camera_fov_in_engine(name, fov):
        camera: unreal.CameraActor = XRFeitoriaUnrealFactory.utils_actor.get_actor_by_name(name)
        camera.camera_component.field_of_view = fov

    @staticmethod
    def _spawn_in_engine(
        camera_name,
        location: 'Vector' = (0, 0, 0),
        rotation: 'Vector' = (0, 0, 0),
        fov: float = 90,
    ):
        camera: unreal.CameraActor = XRFeitoriaUnrealFactory.utils_actor.spawn_actor_from_class(
            unreal.CameraActor, location, rotation
        )
        camera.camera_component.field_of_view = fov
        camera.set_actor_label(camera_name)
        return camera.get_actor_label()

    @staticmethod
    def _look_at_in_engine(name, target: 'Vector'):
        camera: unreal.CameraActor = XRFeitoriaUnrealFactory.utils_actor.get_actor_by_name(name)
        location = camera.get_actor_location()
        target = unreal.Vector(x=target[0], y=target[1], z=target[2])

        forward = target - location
        z = unreal.Vector(0, 0, -1)
        right = forward.cross(z)
        up = forward.cross(right)
        rotation = unreal.MathLibrary.make_rotation_from_axes(forward, right, up)
        camera.set_actor_rotation(rotation, False)

xrfeitoria/camera/camera_unreal.py
openxrlab/xrfeitoria
"""Modules for command line execution."""

xrfeitoria/cmd/__init__.py
openxrlab/xrfeitoria
"""Render a mesh with blender to output_path.

>>> xf-render --help
>>> xf-render {mesh_path}
    [-o {output_path}]
    [-r {resolution}] [-q {render_quality}]
    [-p {render_pass1}] [-p {render_pass2}] [-p {render_pass3}] ...
    [-f {img_format}] [-h {hdr_map_path}] [-e {render_engine}] [--no-background]
"""

from pathlib import Path
from textwrap import dedent
from typing import List, Optional, Tuple

from click import Choice
from rich.pretty import pretty_repr
from typer import Argument, Option, Typer
from typing_extensions import Annotated

import xrfeitoria as xf
from xrfeitoria.data_structure.constants import ImageFileFormatEnum, RenderEngineEnumBlender, RenderOutputEnumBlender
from xrfeitoria.data_structure.models import RenderPass
from xrfeitoria.utils.tools import Logger

RENDER_SAMPLES = {'low': 64, 'medium': 256, 'high': 1024}
app = Typer(pretty_exceptions_show_locals=False)


@app.command()
def main(
    # path config
    mesh_path: Annotated[
        Path,
        Argument(
            ...,
            exists=True,
            file_okay=True,
            dir_okay=False,
            resolve_path=True,
            help='filepath of the mesh to be renderer',
        ),
    ],
    output_path: Annotated[
        Path,
        Option('--output-path', '-o', resolve_path=True, help='output path of the rendered images'),
    ] = Path('output').resolve(),
    # render config
    resolution: Annotated[
        Tuple[int, int],
        Option('--resolution', '-r', help='resolution of the rendered images'),
    ] = (512, 512),
    render_quality: Annotated[
        str,
        Option(
            '--render-quality',
            '-q',
            case_sensitive=False,
            click_type=Choice(RENDER_SAMPLES.keys()),
            help=(
                'ignored in eevee. '
                f'quality (for cycles) of the rendered images which controlled by render samples: {RENDER_SAMPLES}'
            ),
        ),
    ] = 'low',
    render_passes: Annotated[
        List[str],
        Option(
            '--render-pass',
            '-p',
            case_sensitive=False,
            click_type=Choice([e.name for e in RenderOutputEnumBlender]),
            help='render passes to be rendered',
        ),
    ] = ['img'],
    img_format: Annotated[
        str,
        Option(
            '--img-format',
            '-f',
            case_sensitive=False,
            click_type=Choice([e.name for e in ImageFileFormatEnum]),
            help='image format of the rendered images',
        ),
    ] = 'png',
    transparent: Annotated[
        bool,
        Option('--transparent/--no-transparent', help='render result with transparent background'),
    ] = True,
    # light config
    hdr_map_path: Annotated[
        Optional[Path],
        Option('--hdr-map-path', '-h', exists=True, file_okay=True, dir_okay=False, resolve_path=True),
    ] = None,
    # engine config
    blender_exec: Annotated[
        Path,
        Option('--blender-exec', help='path to blender executable, e.g. /usr/bin/blender'),
    ] = None,
    render_engine: Annotated[
        str,
        Option(
            '--render-engine',
            '-e',
            case_sensitive=False,
            click_type=Choice([e.name for e in RenderEngineEnumBlender]),
            help='render engine to be used',
        ),
    ] = 'cycles',
    background: Annotated[
        bool,
        Option('--background/--no-background', '-b', help='run blender in background mode'),
    ] = True,
    # misc
    debug: Annotated[
        bool,
        Option('--debug/--no-debug', help='log in debug mode'),
    ] = False,
):
    """Render a mesh with blender to output_path."""
    logger = Logger.setup_logging(level='DEBUG' if debug else 'INFO')
    if render_engine == 'eevee':
        render_quality = 'low'

    logger.info(
        dedent(
            f"""\
            :rocket: Starting:
            Executing render with the following parameters:
            ---------------------------------------------------------
            [yellow]# path config[/yellow]
            - mesh_path: {mesh_path}
            - output_path: {output_path}
            [yellow]# render config[/yellow]
            - resolution: {resolution}
            - render_quality: {render_quality} ({RENDER_SAMPLES[render_quality]} render samples)
            - render_passes: {render_passes}
            - img_format: {img_format}
            - transparent: {transparent}
            - hdr_map_path: {hdr_map_path}
            [yellow]# engine config[/yellow]
            - blender_exec: {blender_exec}
            - render_engine: {render_engine}
            - background: {background}
            [yellow]# misc[/yellow]
            - debug: {debug}
            ---------------------------------------------------------"""
        )
    )

    # warning
    if background and RenderEngineEnumBlender.get(render_engine) != RenderEngineEnumBlender.cycles:
        logger.warning(':exclamation_mark: Blender in [u]background[/u] using [u]eevee[/u] may cause unexpected error.')

    seq_name = mesh_path.stem
    camera_name = 'camera'

    with xf.init_blender(exec_path=blender_exec, background=background) as xf_runner:
        with xf_runner.Sequence.new(seq_name=seq_name) as seq:
            actor = seq.import_actor(actor_name='actor', file_path=mesh_path, stencil_value=255)
            actor.set_origin_to_center()
            actor.location = (0, 0, 0)

            radius = max(actor.dimensions)
            actor_bbox = actor.bound_box
            actor_bbox_center = (
                (actor_bbox[0][0] + actor_bbox[1][0]) / 2,
                (actor_bbox[0][1] + actor_bbox[1][1]) / 2,
                (actor_bbox[0][2] + actor_bbox[1][2]) / 2,
            )
            camera_location = (
                actor_bbox_center[0],
                actor_bbox_center[1] - radius,
                actor_bbox_center[2],
            )
            camera_rotation = xf_runner.utils.get_rotation_to_look_at(
                location=camera_location,
                target=actor_bbox_center,
            )
            seq.spawn_camera(
                camera_name=camera_name,
                location=camera_location,
                rotation=camera_rotation,
            )
            # set light
            if hdr_map_path:
                xf_runner.utils.set_hdr_map(hdr_map_path=hdr_map_path)

            # frame range
            frame_start, frame_end = xf_runner.utils.get_keys_range()
            xf_runner.utils.set_frame_range(start=frame_start, end=frame_end)

            # add to renderer
            seq.add_to_renderer(
                output_path=output_path,
                resolution=resolution,
                render_passes=[RenderPass(render_pass, img_format) for render_pass in render_passes],
                render_engine=render_engine,
                render_samples=RENDER_SAMPLES[render_quality],
                transparent_background=transparent,
                arrange_file_structure=True,
            )

        xf_runner.render()

    img_paths = {
        render_pass: (output_path / seq_name / render_pass / camera_name / f'0000.{img_format}').as_posix()
        for render_pass in render_passes
    }
    logger.info(
        f':chequered_flag: [bold green]Finished![/bold green] Check the rendered images in:\n{pretty_repr(img_paths)}'
    )


if __name__ == '__main__':
    app()

xrfeitoria/cmd/render.py
openxrlab/xrfeitoria
from enum import Enum
from pathlib import Path
from typing import Optional, Tuple, TypedDict, Union

##### Typing Constants #####

Vector = Tuple[float, float, float]
Matrix = Tuple[Vector, Vector, Vector]
Transform = Tuple[Vector, Vector, Vector]
PathLike = Union[str, Path]
actor_info_type = TypedDict('actor_info', {'actor_name': str, 'mask_color': Tuple[int, int, int]})

##### Package Constants #####

package_name = 'XRFeitoria'
plugin_name_blender = 'XRFeitoriaBpy'
plugin_name_unreal = 'XRFeitoriaUnreal'
xf_obj_name = '[XF]{obj_type}-{obj_idx:03d}'

##### Path Constants #####

tmp_dir = Path.home() / '.tmp' / package_name
tmp_dir.mkdir(parents=True, exist_ok=True)
actor_infos = 'actor_infos'
cam_param_dir = 'camera_params'

config_dir = Path.home() / '.config' / package_name
config_dir.mkdir(parents=True, exist_ok=True)
config_path = config_dir / 'config.ini'
ConfigDict = TypedDict('ConfigDict', {'blender_exec': Optional[Path], 'unreal_exec': Optional[Path]})

##### Blender Constants #####

default_level_blender = package_name

##### Unreal Constants #####

default_path_unreal = f'/Game/{plugin_name_unreal}'
default_asset_path_unreal = f'{default_path_unreal}/Assets'
default_sequence_path_unreal = f'{default_path_unreal}/Sequences'

##### Enum Constants #####


class EnumBase(str, Enum):
    """Base class for all enums, which can be accessed by name or value."""

    @classmethod
    def get(cls, name_or_value: str) -> 'EnumBase':
        """Get enum member by name or value.

        Args:
            name_or_value (str): Name or value of the enum member.

        Returns:
            EnumBase: Self
        """
        try:
            return cls[name_or_value]
        except KeyError:
            for member in cls:
                if member.value == name_or_value:
                    return member
            raise ValueError(f'{name_or_value} is not supported in {cls.__name__}')


class EngineEnum(Enum):
    """Render engine enum."""

    unreal = 1
    blender = 2


class ImportFileFormatEnum(EnumBase):
    """Import file format enum."""

    fbx = 'fbx'
    obj = 'obj'
    abc = 'abc'
    ply = 'ply'
    stl = 'stl'
    glb = 'glb'
    # TODO: Add more


class ImageFileFormatEnum(EnumBase):
    """Image file format enum."""

    png = 'PNG'
    bmp = 'BMP'
    jpg = 'JPEG'
    jpeg = 'JPEG'
    exr = 'OPEN_EXR'


class RenderOutputEnumBlender(EnumBase):
    """Render layer enum of Blender."""

    img = 'Image'
    mask = 'IndexOB'
    depth = 'Depth'
    denoising_depth = 'Denoising Depth'
    flow = 'Vector'
    normal = 'Normal'
    diffuse = 'DiffCol'

    actor_infos = f'{actor_infos}.json'
    camera_params = cam_param_dir
    # TODO: add more


class RenderOutputEnumUnreal(EnumBase):
    """Render layer enum of Unreal."""

    img = 'img'
    mask = 'mask'
    depth = 'depth'
    flow = 'flow'
    normal = 'normal'
    diffuse = 'diffuse'

    metallic = 'metallic'
    roughness = 'roughness'
    specular = 'specular'
    tangent = 'tangent'
    basecolor = 'basecolor'

    vertices = 'vertices'
    skeleton = 'skeleton'
    actor_infos = 'actor_infos'
    camera_params = cam_param_dir


class InterpolationEnumUnreal(EnumBase):
    """Keyframe interpolation enum of Unreal."""

    AUTO = 'AUTO'
    LINEAR = 'LINEAR'
    CONSTANT = 'CONSTANT'


class InterpolationEnumBlender(EnumBase):
    """Keyframe interpolation enum of Blender."""

    AUTO = 'BEZIER'
    LINEAR = 'LINEAR'
    CONSTANT = 'CONSTANT'


class RenderEngineEnumBlender(EnumBase):
    """Render engine enum of Blender."""

    cycles = 'CYCLES'
    eevee = 'BLENDER_EEVEE'
    workbench = 'BLENDER_WORKBENCH'


class ShapeTypeEnumBlender(EnumBase):
    """Shape type enum of Blender."""

    cone = 'cone'
    cube = 'cube'
    cylinder = 'cylinder'
    plane = 'plane'
    sphere = 'uv_sphere'
    uv_sphere = 'uv_sphere'
    ico_sphere = 'ico_sphere'


class ShapeTypeEnumUnreal(EnumBase):
    """Shape type enum of Unreal."""

    cone = 'cone'
    cube = 'cube'
    cylinder = 'cylinder'
    plane = 'plane'
    sphere = 'sphere'

xrfeitoria/data_structure/constants.py
openxrlab/xrfeitoria
from typing import Dict, List, Literal, Optional, Tuple, Union

from pydantic import BaseModel, Field

from .. import _tls
from .constants import (
    EngineEnum,
    ImageFileFormatEnum,
    InterpolationEnumBlender,
    InterpolationEnumUnreal,
    PathLike,
    RenderEngineEnumBlender,
    RenderOutputEnumBlender,
    RenderOutputEnumUnreal,
    Vector,
)

__all__ = ['RenderPass', 'RenderJobBlender', 'RenderJobUnreal', 'SequenceTransformKey']


class RenderPass(BaseModel):
    """Render pass model contains render layer and image format.

    Supported render layer and image format:

        .. tabs::
            .. tab:: RenderLayerBlender

                :class:`~xrfeitoria.data_structure.constants.RenderOutputEnumBlender`:

                    - img
                    - mask
                    - depth
                    - flow
                    - normal
                    - diffuse
                    - denoising_depth

            .. tab:: RenderLayerUnreal

                :class:`~xrfeitoria.data_structure.constants.RenderOutputEnumUnreal`:

                    - img
                    - mask
                    - depth
                    - flow
                    - normal
                    - diffuse
                    - metallic
                    - roughness
                    - specular
                    - tangent
                    - basecolor

            .. tab:: ImageFormat

                :class:`~xrfeitoria.data_structure.constants.ImageFileFormatEnum`:

                    - png
                    - bmp
                    - jpg
                    - exr

    Used in:
        - :class:`~xrfeitoria.data_structure.models.RenderJobBlender`
        - :class:`~xrfeitoria.data_structure.models.RenderJobUnreal`
        - :meth:`Renderer.add_job <xrfeitoria.renderer.renderer_base.RendererBase.add_job>`
        - :meth:`Sequence.add_to_renderer <xrfeitoria.sequence.sequence_base.SequenceBase.add_to_renderer>`
        - ...

    Examples:

        .. tabs::
            .. tab:: define

                .. code-block:: python
                    :linenos:

                    from xrfeitoria.data_structure.models import RenderPass
                    RenderPass('img', 'png')
                    RenderPass('mask', 'exr')
                    RenderPass('normal', 'jpg')
                    ...

            .. tab:: RenderJobBlender

                .. code-block:: python
                    :linenos:
                    :emphasize-lines: 6

                    from xrfeitoria.data_structure.models import RenderJobBlender, RenderPass
                    RenderJobBlender(
                        sequence_name=...,
                        output_path=...,
                        resolution=...,
                        render_passes=[RenderPass('img', 'png')],
                    )

            .. tab:: RenderJobUnreal

                .. code-block:: python
                    :linenos:
                    :emphasize-lines: 7

                    from xrfeitoria.data_structure.models import RenderJobUnreal, RenderPass
                    RenderJobUnreal(
                        map_path=...,
                        sequence_path=...,
                        output_path=...,
                        resolution=...,
                        render_passes=[RenderPass('img', 'png')],
                    )

            .. tab:: seq.add_to_renderer

                .. code-block:: python
                    :linenos:
                    :emphasize-lines: 9

                    import xrfeitoria as xf
                    from xrfeitoria.data_structure.models import RenderPass

                    with xf.init_blender() as xf_runner:
                        seq = xf_runner.Sequence.new(seq_name='test'):
                            seq.add_to_renderer(
                                output_path=...,
                                resolution=...,
                                render_passes=[RenderPass('img', 'png')],
                            )

                        xf_runner.render()
    """

    render_layer: Union[RenderOutputEnumBlender, RenderOutputEnumUnreal] = Field(
        description='Render layer of the render pass.'
    )
    image_format: ImageFileFormatEnum = Field(description='Image format of the render pass.')

    def __init__(
        self,
        render_layer: Union[
            Literal['img', 'mask', 'depth', 'flow', 'normal', 'diffuse'],
            RenderOutputEnumBlender,
            RenderOutputEnumUnreal,
        ],
        image_format: Union[Literal['png', 'bmp', 'jpg', 'jpeg', 'exr'], ImageFileFormatEnum],
    ) -> None:
        """Render pass for Unreal or Blender, which contains render layer and image
        format.

        Args:
            render_layer (Union[RenderOutputEnumBlender, RenderOutputEnumUnreal, str]):
                Render layer of Unreal or Blender. Should be one of the following enum, or the string of it:
                    `RenderOutputEnumBlender`: `img`, `mask`, `depth`, `denoising_depth`, `flow`, `normal`, `diffuse` \n
                    `RenderOutputEnumUnreal`: `img`, `mask`, `depth`, `flow`, `normal`, `diffuse`,
                    `metallic`, `roughness`, `specular`, `tangent`, `basecolor`
            image_format (Union[ImageFileFormatEnum, str]):
                Image format of Unreal or Blender.  Should be element of the enum, or the string of it:
                    `ImageFileFormatEnum`: `png`, `bmp`, `jpg`, `jpeg`, `exr`
        """
        __platform__ = _tls.cache.get('platform', None)
        assert __platform__, 'xrfeitoria.init_blender() or xrfeitoria.init_unreal() must be called first'

        if isinstance(image_format, str):
            image_format = ImageFileFormatEnum.get(image_format.lower())

        if __platform__ == EngineEnum.unreal:
            if isinstance(render_layer, RenderOutputEnumUnreal):
                pass
            elif isinstance(render_layer, str):
                render_layer = RenderOutputEnumUnreal.get(render_layer.lower())
            elif isinstance(render_layer, RenderEngineEnumBlender):
                raise ValueError('Unreal does not support Blender render layer')
            super().__init__(
                render_layer=render_layer,
                image_format=image_format,
            )

        elif __platform__ == EngineEnum.blender:
            if isinstance(render_layer, RenderOutputEnumBlender):
                pass
            elif isinstance(render_layer, str):
                render_layer = RenderOutputEnumBlender.get(render_layer.lower())
            elif isinstance(render_layer, RenderOutputEnumUnreal):
                raise ValueError('Blender does not support Unreal render layer')
            super().__init__(
                render_layer=render_layer,
                image_format=image_format,
            )

    class Config:
        use_enum_values = True


class RenderJobBlender(BaseModel):
    """Render job model for Blender."""

    sequence_name: str = Field(description='Name of the sequence of the render job.')

    output_path: PathLike = Field(description='Output path of the render job.')
    resolution: Tuple[int, int] = Field(description='Resolution of the images rendered by the render job.')
    render_passes: List[RenderPass] = Field(description='Render passes of the render job.')
    render_engine: RenderEngineEnumBlender = Field(description='Render engine of the render job.')
    render_samples: int = Field(ge=1, description='Render samples of the render job.')
    transparent_background: bool = Field(default=False, description='Whether to render with transparent background.')
    arrange_file_structure: bool = Field(
        default=True, description='Whether to arrange file structure of the output images of the render job.'
    )

    class Config:
        use_enum_values = True


class RenderJobUnreal(BaseModel):
    """Render job model for Unreal."""

    class AntiAliasSetting(BaseModel):
        enable: bool = Field(default=False, description='Whether to enable anti aliasing.')
        override_anti_aliasing: bool = Field(default=False, description='Whether to override anti aliasing.')
        spatial_samples: int = Field(default=8, ge=1, description='Spatial samples of the anti aliasing.')
        temporal_samples: int = Field(default=8, ge=1, description='Temporal samples of the anti aliasing.')
        warmup_frames: int = Field(
            default=0,
            ge=0,
            description=(
                'Warmup frames in engine which would rendered before actual frame range. '
                'This is crucial when particle system (Niagara) is used.'
            ),
        )
        render_warmup_frame: bool = Field(
            default=False, description='Whether to render warmup frame of the anti aliasing.'
        )

    map_path: str = Field(description='Map path of the render job.')
    sequence_path: str = Field(description='Sequence path of the render job.')

    output_path: PathLike = Field(description='Output path of the render job.')
    resolution: Tuple[int, int] = Field(description='Resolution of the images rendered by the render job.')
    render_passes: List[RenderPass] = Field(description='Render passes of the render job.')
    file_name_format: str = Field(
        default='{sequence_name}/{render_pass}/{camera_name}/{frame_number}',
        description='File name format of the render job.',
    )
    console_variables: Dict[str, float] = Field(
        default={},
        description='Additional console variables of the render job. Ref to :ref:`FAQ-console-variables` for details.',
    )
    anti_aliasing: AntiAliasSetting = Field(
        default=AntiAliasSetting(), description='Anti aliasing setting of the render job.'
    )
    export_vertices: bool = Field(default=False, description='Whether to export vertices of the render job.')
    export_skeleton: bool = Field(default=False, description='Whether to export skeleton of the render job.')

    class Config:
        use_enum_values = True


class SequenceTransformKey(BaseModel):
    """Transform key model for object (actor/camera) in sequence.

    Examples:

        .. tabs::

            .. tab:: define

                .. code-block:: python
                    :linenos:

                    from xrfeitoria.data_structure.models import SequenceTransformKey as SeqTransKey
                    key = SeqTransKey(
                        frame=0,
                        location=(0, 0, 0),
                        rotation=(0, 0, 0),
                        scale=(1, 1, 1),
                        interpolation='AUTO'
                    )

    Used in methods with suffix ``_with_keys``, such as
        - :meth:`Sequence.spawn_camera_with_keys <xrfeitoria.sequence.sequence_base.SequenceBase.spawn_camera_with_keys>`
        - :meth:`Sequence.spawn_shape_with_keys <xrfeitoria.sequence.sequence_base.SequenceBase.spawn_shape_with_keys>`
        - :meth:`SequenceUnreal.spawn_actor_with_keys <xrfeitoria.sequence.sequence_unreal.SequenceUnreal.spawn_actor_with_keys>`
        - :meth:`SequenceBlender.import_actor_with_keys <xrfeitoria.sequence.sequence_blender.SequenceBlender.import_actor_with_keys>`
        - :func:`ObjectUtils.set_transform_keys <xrfeitoria.object.object_utils.ObjectUtilsBlender.set_transform_keys>`
        - ...
    """

    frame: int = Field(description='Frame number of the transform key, unit: frame.')
    location: Optional[Vector] = Field(
        default=None, description='Location of the object in the transform key, unit: meter.'
    )
    rotation: Optional[Vector] = Field(
        default=None, description='Rotation of the object in the transform key, unit: degree.'
    )
    scale: Optional[Vector] = Field(default=None, description='Scale of the object in the transform key.')
    interpolation: Union[InterpolationEnumUnreal, InterpolationEnumBlender] = Field(
        description='Interpolation type of the transform key.'
    )

    def __init__(
        self,
        frame: int,
        location: Vector = None,
        rotation: Vector = None,
        scale: Vector = None,
        interpolation: Literal['CONSTANT', 'AUTO', 'LINEAR'] = 'AUTO',
    ) -> None:
        """Transform key for Unreal or Blender, which contains frame, location,
        rotation, scale and interpolation type.

        Args:
            frame (int): Frame number of the transform key. unit: frame
            location (Vector): Location of the object in the transform key. unit: meter
            rotation (Vector): Rotation of the object in the transform key. unit: degree
            scale (Vector, optional): Scale of the object in the transform key.
            interpolation (Literal, optional): Interpolation type of the transform key.
                Should be one of the following enum, or the string of it: \n
                    `CONSTANT`: No interpolation \n
                    `AUTO`: Bezier interpolation with cubic bezier curves \n
                    `LINEAR`: Linear interpolation
        """
        __platform__ = _tls.cache.get('platform', None)
        assert __platform__, 'xrfeitoria.init_blender() or xrfeitoria.init_unreal() must be called first'

        if __platform__ == EngineEnum.unreal:
            if isinstance(interpolation, str):
                interpolation = InterpolationEnumUnreal.get(interpolation.upper())
            elif isinstance(interpolation, InterpolationEnumBlender):
                interpolation = InterpolationEnumUnreal.get(interpolation.name)
                interpolation = InterpolationEnumUnreal.get(interpolation.upper())
            elif isinstance(interpolation, InterpolationEnumBlender):
                interpolation = InterpolationEnumUnreal.get(interpolation.name)
            super().__init__(
                frame=frame,
                location=location,
                rotation=rotation,
                scale=scale,
                interpolation=interpolation,
            )
        elif __platform__ == EngineEnum.blender:
            if isinstance(interpolation, str):
                interpolation = InterpolationEnumBlender.get(interpolation.upper())
            elif isinstance(interpolation, InterpolationEnumUnreal):
                interpolation = InterpolationEnumBlender.get(interpolation.name)
            super().__init__(
                frame=frame,
                location=location,
                rotation=rotation,
                scale=scale,
                interpolation=interpolation,
            )

    class Config:
        use_enum_values = True


TransformKeys = Union[List[SequenceTransformKey], SequenceTransformKey]

xrfeitoria/data_structure/models.py
openxrlab/xrfeitoria
from typing import Optional

from . import _tls
from .data_structure.constants import EngineEnum, PathLike, default_level_blender

__all__ = ['init_blender', 'init_unreal']


class XRFeitoriaBlender:
    """Factory class contains all the classes and functions for Blender.

    Members:
        :class:`ObjectUtils <xrfeitoria.object.object_utils.ObjectUtilsBlender>`: Object utilities.\n
        :class:`Camera <xrfeitoria.camera.camera_blender.CameraBlender>`: Camera class.\n
        :class:`Actor <xrfeitoria.actor.actor_blender.ActorBlender>`: Actor class.\n
        :class:`Shape <xrfeitoria.actor.actor_blender.ShapeBlenderWrapper>`: Shape wrapper class.\n
        :class:`Renderer <xrfeitoria.renderer.renderer_blender.RendererBlender>`: Renderer class.\n
        :class:`Sequence <xrfeitoria.sequence.sequence_wrapper.SequenceWrapperBlender>`: Sequence wrapper class.\n
        :class:`utils <xrfeitoria.utils.functions.blender_functions>`: Utilities functions executed in Blender.\n
        :meth:`render <xrfeitoria.renderer.renderer_blender.RendererBlender.render_jobs>`: Render jobs.\n
    """

    def __init__(
        self,
        engine_exec: Optional[PathLike] = None,
        project_path: Optional[PathLike] = None,
        background: bool = False,
        replace_plugin: bool = False,
        dev_plugin: bool = False,
        new_process: bool = False,
    ) -> None:
        """Giving arguments to initialize.

        Args:
            new_process (bool, optional): Whether to start Blender in a new process. Defaults to False.
            exec_path (Optional[PathLike], optional): Path to Blender executable. Defaults to None.
            project_path (Optional[PathLike], optional): Path to Blender project. Defaults to None.
            background (bool, optional): Whether to start Blender in background. Defaults to False.
            replace_plugin (bool, optional): Whether to replace the plugin. Defaults to False.
            dev_plugin (bool, optional): Whether to use the plugin under local directory.
                If False, would use the plugin downloaded from a remote server. Defaults to False.
            cleanup (bool, optional): Whether to clean up the scene. Defaults to True.
        """
        from .object.object_utils import ObjectUtilsBlender  # isort:skip
        from .camera.camera_blender import CameraBlender  # isort:skip
        from .actor.actor_blender import ActorBlender, ShapeBlenderWrapper  # isort:skip
        from .renderer.renderer_blender import RendererBlender  # isort:skip
        from .sequence.sequence_wrapper import SequenceWrapperBlender  # isort:skip
        from .utils.runner import BlenderRPCRunner  # isort:skip
        from .utils.functions import blender_functions  # isort:skip
        from .utils.tools import Logger  # isort:skip

        self.logger = Logger.setup_logging()  # default level is INFO
        self.ObjectUtils = ObjectUtilsBlender
        self.Camera = CameraBlender
        self.Actor = ActorBlender
        self.Shape = ShapeBlenderWrapper
        self.Renderer = RendererBlender
        self.render = self.Renderer.render_jobs
        self.Sequence = SequenceWrapperBlender
        self.utils = blender_functions
        self._rpc_runner = BlenderRPCRunner(
            engine_exec=engine_exec,
            project_path=project_path,
            replace_plugin=replace_plugin,
            dev_plugin=dev_plugin,
            background=background,
            new_process=new_process,
        )


class XRFeitoriaUnreal:
    """Factory class contains all the classes and functions for Unreal.

    Members:
        :class:`ObjectUtils <xrfeitoria.object.object_utils.ObjectUtilsUnreal>`: Object utilities.\n
        :class:`Camera <xrfeitoria.camera.camera_unreal.CameraUnreal>`: Camera class.\n
        :class:`Actor <xrfeitoria.actor.actor_unreal.ActorUnreal>`: Actor class.\n
        :class:`Shape <xrfeitoria.actor.actor_unreal.ShapeUnrealWrapper>`: Shape wrapper class.\n
        :class:`Renderer <xrfeitoria.renderer.renderer_unreal.RendererUnreal>`: Renderer class.\n
        :class:`Sequence <xrfeitoria.sequence.sequence_wrapper.SequenceWrapperUnreal>`: Sequence wrapper class.\n
        :class:`utils <xrfeitoria.utils.functions.unreal_functions>`: Utilities functions executed in Unreal.\n
        :meth:`render <xrfeitoria.renderer.renderer_unreal.RendererUnreal.render_jobs>`: Render jobs.\n
    """

    def __init__(
        self,
        engine_exec: Optional[PathLike] = None,
        project_path: Optional[PathLike] = None,
        background: bool = False,
        replace_plugin: bool = False,
        dev_plugin: bool = False,
        new_process: bool = False,
    ) -> None:
        """Giving arguments to initialize.

        Args:
            exec_path (Optional[PathLike], optional): Path to Unreal executable. Defaults to None.
            project_path (Optional[PathLike], optional): Path to Unreal project. Defaults to None.
            background (bool, optional): Whether to start Unreal in background. Defaults to False.
            replace_plugin (bool, optional): Whether to replace the plugin. Defaults to False.
            dev_plugin (bool, optional): Whether to use the plugin under local directory.
                If False, would use the plugin downloaded from a remote server. Defaults to False.
            new_process (bool, optional): Whether to start Unreal in a new process. Defaults to False.
        """
        from .object.object_utils import ObjectUtilsUnreal  # isort:skip
        from .camera.camera_unreal import CameraUnreal  # isort:skip
        from .actor.actor_unreal import ActorUnreal, ShapeUnrealWrapper  # isort:skip
        from .renderer.renderer_unreal import RendererUnreal  # isort:skip
        from .sequence.sequence_wrapper import SequenceWrapperUnreal  # isort:skip
        from .utils.runner import UnrealRPCRunner  # isort:skip
        from .utils.functions import unreal_functions  # isort:skip
        from .utils.tools import Logger  # isort:skip

        self.logger = Logger.setup_logging()  # default level is INFO
        self.ObjectUtils = ObjectUtilsUnreal
        self.Camera = CameraUnreal
        self.Actor = ActorUnreal
        self.Shape = ShapeUnrealWrapper
        self.Renderer = RendererUnreal
        self.render = self.Renderer.render_jobs
        self.Sequence = SequenceWrapperUnreal
        self.utils = unreal_functions
        self._rpc_runner = UnrealRPCRunner(
            engine_exec=engine_exec,
            project_path=project_path,
            replace_plugin=replace_plugin,
            dev_plugin=dev_plugin,
            background=background,
            new_process=new_process,
        )


class init_blender(XRFeitoriaBlender):
    """Initialize Blender with ``XRFeitoria``, which would start Blender as RPC server,
    and yield a :class:`~xrfeitoria.factory.XRFeitoriaBlender` object.

    Examples:
        - Use as context manager:

            >>> import xrfeitoria as xf
            >>> with xf.init_blender() as xf_runner:
            >>>     ...

        - Or use directly:

            >>> import xrfeitoria as xf
            >>> xf_runner = xf.init_blender()
            >>> ...
            >>> xf_runner.close()

    Yields:
        :class:`~xrfeitoria.factory.XRFeitoriaBlender`: XRFeitoria Factory class for Blender.
    """

    def __init__(
        self,
        new_process: bool = False,
        exec_path: Optional[PathLike] = None,
        project_path: Optional[PathLike] = None,
        background: bool = False,
        replace_plugin: bool = False,
        dev_plugin: bool = False,
        cleanup: bool = True,
    ) -> None:
        """Giving arguments to initialize.

        Args:
            new_process (bool, optional): Whether to start Blender in a new process. Defaults to False.
            exec_path (Optional[PathLike], optional): Path to Blender executable. Defaults to None.
            project_path (Optional[PathLike], optional): Path to Blender project. Defaults to None.
            background (bool, optional): Whether to start Blender in background. Defaults to False.
            replace_plugin (bool, optional): Whether to replace the plugin. Defaults to False.
            dev_plugin (bool, optional): Whether to use the plugin under local directory.
                If False, would use the plugin downloaded from a remote server. Defaults to False.
            cleanup (bool, optional): Whether to clean up the scene. Defaults to True.

        Note:
            If ``dev_plugin=True``, the plugin under local directory would be used,
            which is under ``src/XRFeitoriaBlender``.
            You should git clone first, and then use this option if you want to develop the plugin.

            .. code-block:: bash
                :linenos:

                git clone https://github.com/openxrlab/xrfeitoria.git
                cd xrfeitoria
                pip install -e .
                python -c "import xrfeitoria as xf; xf.init_blender(replace_plugin=True, dev_plugin=True)"
        """
        _tls.cache['platform'] = EngineEnum.blender
        self._cleanup = cleanup
        super().__init__(
            engine_exec=exec_path,
            project_path=project_path,
            replace_plugin=replace_plugin,
            dev_plugin=dev_plugin,
            background=background,
            new_process=new_process,
        )
        self._rpc_runner.start()
        self.utils.init_scene_and_collection(default_level_blender, self._cleanup)
        self.utils.set_env_color(color=(1.0, 1.0, 1.0, 1.0))

    def __enter__(self) -> 'init_blender':
        return self

    def __exit__(self, exc_type, exc_value, traceback) -> None:
        self.close()

    def close(self) -> None:
        self.Renderer.clear()
        self._rpc_runner.stop()


class init_unreal(XRFeitoriaUnreal):
    """Initialize Unreal with ``XRFeitoria``, which would start Unreal as RPC server,
    and yield a :class:`~xrfeitoria.factory.XRFeitoriaUnreal` object.

    Examples:
        - Use as context manager:

            >>> import xrfeitoria as xf
            >>> with xf.init_unreal() as xf_runner:
            >>>     ...

        - Or use directly:

            >>> import xrfeitoria as xf
            >>> xf_runner = xf.init_unreal()
            >>> ...
            >>> xf_runner.close()

    Yields:
        :class:`~xrfeitoria.factory.XRFeitoriaUnreal`: XRFeitoria Factory class for Unreal.
    """

    def __init__(
        self,
        exec_path: Optional[PathLike] = None,
        project_path: Optional[PathLike] = None,
        background: bool = False,
        replace_plugin: bool = False,
        dev_plugin: bool = False,
        new_process: bool = False,
    ) -> None:
        """Giving arguments to initialize.

        Args:
            exec_path (Optional[PathLike], optional): Path to Unreal executable. Defaults to None.
            project_path (Optional[PathLike], optional): Path to Unreal project. Defaults to None.
            background (bool, optional): Whether to start Unreal in background. Defaults to False.
            replace_plugin (bool, optional): Whether to replace the plugin. Defaults to False.
            dev_plugin (bool, optional): Whether to use the plugin under local directory.
                If False, would use the plugin downloaded from a remote server. Defaults to False.
            new_process (bool, optional): Whether to start Unreal in a new process. Defaults to False.

        Note:
            If ``dev_plugin=True``, the plugin under local directory would be used,
            which is under ``src/XRFeitoriaUnreal``.
            You should git clone first, and then use this option if you want to develop the plugin.

            .. code-block:: bash
                :linenos:

                git clone https://github.com/openxrlab/xrfeitoria.git
                cd xrfeitoria
                pip install -e .
                python -c "import xrfeitoria as xf; xf.init_unreal(replace_plugin=True, dev_plugin=True)"
        """
        _tls.cache['platform'] = EngineEnum.unreal
        _tls.cache['unreal_project_path'] = project_path
        super().__init__(
            engine_exec=exec_path,
            project_path=project_path,
            replace_plugin=replace_plugin,
            dev_plugin=dev_plugin,
            background=background,
            new_process=new_process,
        )
        # xf_runner.Renderer.clear()
        self._rpc_runner.start()

    def __enter__(self) -> 'init_unreal':
        return self

    def __exit__(self, exc_type, exc_value, traceback) -> None:
        self.close()

    def close(self) -> None:
        """Close the RPC server."""
        self.Renderer.clear()
        self._rpc_runner.stop()

xrfeitoria/factory.py
openxrlab/xrfeitoria
from typing import List, Tuple

from loguru import logger

from ..data_structure.constants import Vector
from .object_utils import ObjectUtilsBase


class ObjectBase:
    """Base class for all objects in the world."""

    _object_utils = ObjectUtilsBase

    def __init__(self, name: str) -> None:
        """
        Args:
            name (str): name of the object
        """
        self._name = name

    @property
    def name(self) -> str:
        """Name of the object."""
        return self._name

    @name.setter
    def name(self, value: str):
        """Set new name of the object.

        Args:
            value (str): New name of the object.
        """
        self._object_utils.set_name(self.name, value)
        self._name = value

    @property
    def location(self) -> Vector:
        """Location of the object in the world, in meters."""
        return self._object_utils.get_location(self._name)

    @location.setter
    def location(self, value: Vector):
        """Set location of the object.

        Args:
            value (Vector): Location of object. (unit: meter)
        """
        self._object_utils.set_location(self._name, value)

    @property
    def rotation(self) -> Vector:
        """Rotation of the object in the world, in degrees."""
        return self._object_utils.get_rotation(self._name)

    @rotation.setter
    def rotation(self, value: Vector):
        """Set rotation of the object.

        Args:
            value (Vector): Rotation of the object. (unit: degrees)
        """
        self._object_utils.set_rotation(self._name, value)

    @property
    def scale(self) -> Vector:
        """Scale of the object in the world, no units, 1.0 is default."""
        return self._object_utils.get_scale(self._name)

    @scale.setter
    def scale(self, value: Vector):
        """Set scale of the object.

        Args:
            value (Vector): Scale of the object.
        """
        self._object_utils.set_scale(self._name, value)

    def get_transform(self) -> Tuple[List, List, List]:
        """Get the transform of the object in the world.

        Returns:
            Transform: location, rotation, scale
        """
        return self._object_utils.get_transform(self._name)

    def set_transform(self, location: Vector, rotation: Vector, scale: Vector) -> None:
        """Set the transform of the object in the world.

        Args:
            location (Tuple[float, float, float]): in meters
            rotation (Tuple[float, float, float]): in degrees
            scale (Tuple[float, float, float]): no units, 1.0 is default
        """
        self._object_utils.set_transform(self._name, location, rotation, scale)

    def delete(self):
        """Delete the object from the world."""
        self._object_utils.delete_obj(self._name)
        logger.info(f'[red]Deleted[/red] object "{self.name}"')
        del self

xrfeitoria/object/object_base.py
openxrlab/xrfeitoria
import math
from abc import ABC, abstractmethod
from typing import Dict, List, Literal, Tuple

from ..data_structure.constants import Transform, Vector, xf_obj_name
from ..rpc import remote_blender, remote_unreal
from ..utils import Validator
from ..utils.functions import blender_functions

try:
    # only for linting, not imported in runtime
    import bpy
    import unreal
    from unreal_factory import XRFeitoriaUnrealFactory  # defined in src/XRFeitoriaUnreal/Content/Python
    from XRFeitoriaBpy.core.factory import XRFeitoriaBlenderFactory  # defined in src/XRFeitoriaBpy/core/factory.py
except ModuleNotFoundError:
    pass


class ObjectUtilsBase(ABC):
    """Base class for object utils."""

    ##########################
    # ------- Getter ------- #
    ##########################
    @classmethod
    def get_transform(cls, name: str) -> Tuple[List, List, List]:
        """Get transform (location, rotation, scale) of the object.

        Args:
            name (str): Name of the object.

        Returns:
            Transform: (location 3D vector, rotation 3D vector, scale 3D vector).
                location: Location of the object. unit: meters.
                rotation: Rotation of the object. unit: degrees.
                scale: Scale of the object.
        """
        cls.validate_name(name)
        return cls._get_transform_in_engine(name)

    @classmethod
    def get_location(cls, name: str) -> Vector:
        """Get location of the object.

        Args:
            name (str): Name of the object.

        Returns:
            Vector: Location of the object.
        """
        cls.validate_name(name)
        return cls._get_location_in_engine(name)

    @classmethod
    def get_rotation(cls, name: str) -> Vector:
        """Get rotation of the object.

        Args:
            name (str): Name of the object.

        Returns:
            Vector: Rotation of the object.
        """
        cls.validate_name(name)
        return cls._get_rotation_in_engine(name)

    @classmethod
    def get_scale(cls, name: str) -> Vector:
        """Get scale of the object.

        Args:
            name (str): Name of the object.

        Returns:
            Vector: Scale of the object.
        """
        cls.validate_name(name)
        return cls._get_scale_in_engine(name)

    @classmethod
    def get_dimensions(cls, name: str) -> Vector:
        """Get dimensions of the object.

        Args:
            name (str): Name of the object.

        Returns:
            Vector: Dimensions of the object.
        """
        return cls._get_dimensions_in_engine(name)

    @classmethod
    def get_bound_box(cls, name: str) -> Vector:
        """Get bounding box of the object.

        Args:
            name (str): Name of the object.

        Returns:
            Vector: Bounding box of the object.
        """
        return cls._get_bound_box_in_engine(name)

    ##########################
    # ------- Setter ------- #
    ##########################
    @classmethod
    def set_transform(cls, name: str, location: Vector, rotation: Vector, scale: Vector):
        """Set transform (location, rotation, scale) of the object.

        Args:
            name (str): Name of the object.
            location (Vector): Location of the object.
            rotation (Vector): Rotation of the object.
            scale (Vector): Scale of the object.
        """
        cls.validate_name(name)
        Validator.validate_vector(location, 3)
        Validator.validate_vector(rotation, 3)
        Validator.validate_vector(scale, 3)
        cls._set_transform_in_engine(name, location, rotation, scale)

    @classmethod
    def set_location(cls, name: str, location: Vector):
        """Set location of the object.

        Args:
            name (str): Name of the object.
            location (Vector): Location of the object.
        """
        cls.validate_name(name)
        Validator.validate_vector(location, 3)
        cls._set_location_in_engine(name, location)

    @classmethod
    def set_rotation(cls, name: str, rotation: Vector):
        """Set rotation of the object.

        Args:
            name (str): Name of the object.
            rotation (Vector): Rotation of the object.
        """
        cls.validate_name(name)
        Validator.validate_vector(rotation, 3)
        cls._set_rotation_in_engine(name, rotation)

    @classmethod
    def set_scale(cls, name, scale: Vector):
        """Set scale of the object.

        Args:
            name (str): Name of the object.
            scale (Vector): Scale of the object.
        """
        cls.validate_name(name)
        Validator.validate_vector(scale, 3)
        cls._set_scale_in_engine(name, scale)

    @classmethod
    def set_name(cls, name: str, new_name: str):
        """Set a new name for the object.

        Args:
            name (str): Original name of the object.
            new_name (str): New name of the object.
        """
        if name == new_name:
            return
        cls.validate_name(name)
        cls.validate_new_name(new_name)
        cls._set_name_in_engine(name, new_name)

    @classmethod
    def generate_obj_name(cls, obj_type: 'Literal["camera", "actor"]') -> str:
        return cls._generate_obj_name_in_engine(obj_type)

    ##########################
    # ----- Engine API ----- #
    ##########################

    # ----- Getter ----- #

    @staticmethod
    @abstractmethod
    def _get_transform_in_engine(name: str) -> 'Transform':
        raise NotImplementedError

    @staticmethod
    @abstractmethod
    def _get_location_in_engine(name) -> 'Vector':
        raise NotImplementedError

    @staticmethod
    @abstractmethod
    def _get_rotation_in_engine(name) -> 'Vector':
        raise NotImplementedError

    @staticmethod
    @abstractmethod
    def _get_scale_in_engine(name) -> 'Vector':
        raise NotImplementedError

    @staticmethod
    @abstractmethod
    def _get_all_objects_in_engine() -> 'List[str]':
        raise NotImplementedError

    @staticmethod
    @abstractmethod
    def _generate_obj_name_in_engine(obj_type: str) -> str:
        raise NotImplementedError

    # ----- Setter ----- #

    @staticmethod
    @abstractmethod
    def _set_transform_in_engine(name: str, location: 'Vector', rotation: 'Vector', scale: 'Vector'):
        raise NotImplementedError

    @staticmethod
    @abstractmethod
    def _set_location_in_engine(name: str, location: 'Vector'):
        raise NotImplementedError

    @staticmethod
    @abstractmethod
    def _set_rotation_in_engine(name: str, rotation: 'Vector'):
        raise NotImplementedError

    @staticmethod
    @abstractmethod
    def _set_scale_in_engine(name: str, scale: 'Vector'):
        raise NotImplementedError

    @staticmethod
    @abstractmethod
    def _set_name_in_engine(name: str, new_name: str):
        raise NotImplementedError

    # ------- Delete ------- #
    @classmethod
    def delete_obj(cls, name):
        cls.validate_name(name)
        cls._delete_obj_in_engine(name)

    @staticmethod
    @abstractmethod
    def _delete_obj_in_engine(name):
        raise NotImplementedError

    # ----- Validator ------ #
    @classmethod
    def validate_name(cls, name):
        objects = cls._get_all_objects_in_engine()
        if name not in objects:
            raise ValueError(f"Invalid name, '{name}' does not exist in scene.")

    @classmethod
    def validate_new_name(cls, name):
        objects = cls._get_all_objects_in_engine()
        if name in objects:
            raise ValueError(f"Invalid name, '{name}' already exists in scene.")


@remote_blender(dec_class=True)
class ObjectUtilsBlender(ObjectUtilsBase):
    """Object utils class for Blender."""

    ##########################
    # ------- Setter ------- #
    ##########################
    @classmethod
    def set_transform_keys(cls, name: str, transform_keys: 'List[Dict]'):
        """Set keyframe of the object.

        Args:
            name (str): Name of the object.
            transform_keys (List[Dict]): Keyframes of transform (frame, location, rotation, scale, and interpolation).
        """
        cls.validate_name(name)
        cls._set_transform_keys_in_engine(obj_name=name, transform_keys=transform_keys)

    @classmethod
    def set_origin(cls, name: str) -> None:
        """Set origin of the object to its center.

        Args:
            name (str): Name of the object.
        """
        cls.validate_name(name)
        cls._set_origin_in_engine(name=name)

    @classmethod
    def set_dimensions(cls, name: str, dimensions: 'Vector') -> None:
        """Set dimensions of the object.

        Args:
            name (str): Name of the object.
            dimensions (Vector): Dimensions of the object.
        """
        cls.validate_name(name)
        cls._set_dimensions_in_engine(name=name, dimensions=dimensions)

    ##########################
    # ----- Engine API ----- #
    ##########################

    # ------- Getter ------- #

    @staticmethod
    def _get_dimensions_in_engine(name: str) -> 'Vector':
        """Get dimensions of the object in Blender.

        Args:
            name (str): Name of the object.

        Returns:
            Vector: Dimensions of the object.
        """
        # return bpy.data.objects[name].dimensions.to_tuple()
        obj = bpy.data.objects[name]
        bbox_min, bbox_max = XRFeitoriaBlenderFactory.get_bound_box_in_world_space(obj)
        return (bbox_max[0] - bbox_min[0], bbox_max[1] - bbox_min[1], bbox_max[2] - bbox_min[2])

    @staticmethod
    def _get_bound_box_in_engine(name: str) -> 'Tuple[Vector, Vector]':
        """Get bounding box of the object in Blender.

        Args:
            name (str): Name of the object.

        Returns:
            Vector: Bounding box of the object.
        """
        obj = bpy.data.objects[name]
        return XRFeitoriaBlenderFactory.get_bound_box_in_world_space(obj)

    @staticmethod
    def _get_transform_in_engine(name: str) -> 'Transform':
        """Get transform (location, rotation and scale) of the object in Blender.

        Args:
            name (str): Name of the object.

        Returns:
            Transform: Transform (location, rotation and scale).
        """
        actor = bpy.data.objects[name]
        location = actor.location.to_tuple()
        rotation = tuple(math.degrees(r) for r in actor.rotation_euler)  # convert to degrees
        scale = actor.scale.to_tuple()
        blender_functions.is_background_mode(warning=True)
        return location, rotation, scale

    @staticmethod
    def _get_location_in_engine(name: str) -> 'Vector':
        """Get location of the object in Blender.

        Args:
            name (str): Name of the object.

        Returns:
            Vector: Location.
        """
        blender_functions.is_background_mode(warning=True)
        return bpy.data.objects[name].location.to_tuple()

    @staticmethod
    def _get_rotation_in_engine(name: str) -> 'Vector':
        """Get rotation of the object in Blender.

        Args:
            name (str): Name of the object.

        Returns:
            Vector: Rotation.
        """
        blender_functions.is_background_mode(warning=True)
        return tuple(math.degrees(r) for r in bpy.data.objects[name].rotation_euler)  # convert to degrees

    @staticmethod
    def _get_scale_in_engine(name) -> 'Vector':
        """Get scale of the object in Blender.

        Args:
            name (str): Name of the object.

        Returns:
            Vector: Scale.
        """
        blender_functions.is_background_mode(warning=True)
        return bpy.data.objects[name].scale.to_tuple()

    @staticmethod
    def _get_all_objects_in_engine() -> 'List[str]':
        """Get all objects in this blend file.

        Returns:
            List[str]: Name of all objects.
        """
        return bpy.data.objects.keys()

    @staticmethod
    def _generate_obj_name_in_engine(obj_type: 'Literal["camera", "actor"]') -> str:
        """Generate a name for the new object.

        Args:
            obj_type (str): Type of the object. It can be either 'camera' or 'actor'.

        Returns:
            str: Name of the new object.
        """
        objs = [obj for obj in bpy.data.objects if obj_type in obj.name and obj.name.startswith(xf_obj_name[:4])]
        # return f'[XF]{obj_type}-{collection.name}-{(len(objs)+1):03}'
        return xf_obj_name.format(obj_type=obj_type, obj_idx=(len(objs) + 1))

    ##########################
    # ------- Setter ------- #
    ##########################
    @staticmethod
    def _set_transform_in_engine(name: str, location: 'Vector', rotation: 'Vector', scale: 'Vector'):
        """Set transform (location, rotation and scale) of the object in Blender.

        Args:
            name (str): Name of the object.
            location (Vector): Location of the object.
            rotation (Vector): Rotation of the object.
            scale (Vector): Scale of the object.
        """
        actor = bpy.data.objects[name]
        actor.location = location
        actor.rotation_euler = [math.radians(r) for r in rotation]  # convert to radians
        actor.scale = scale

    @staticmethod
    def _set_location_in_engine(name: str, location: 'Vector'):
        """Set location of the object in Blender.

        Args:
            name (str): Name of the object.
            location (Vector): Location of the object.
        """
        actor = bpy.data.objects[name]
        actor.location = location

    @staticmethod
    def _set_rotation_in_engine(name: str, rotation: 'Vector'):
        """Set rotation of the object in Blender.

        Args:
            name (str): Name of the object.
            rotation (Vector): Rotation of the object.
        """
        actor = bpy.data.objects[name]
        actor.rotation_euler = [math.radians(r) for r in rotation]  # convert to radians

    @staticmethod
    def _set_scale_in_engine(name: str, scale: 'Vector'):
        """Set scale of the object in Blender.

        Args:
            name (str): Name of the object.
            rotation (Vector): Scale of the object.
        """
        bpy.data.objects[name].scale = scale

    @staticmethod
    def _set_name_in_engine(name: str, new_name: str):
        """Set name of the object in Blender.

        Args:
            name (str): Name of the object.
            new_name (str): New name of the object.
        """
        bpy.data.objects[name].name = new_name

    @staticmethod
    def _set_transform_keys_in_engine(
        obj_name: str,
        transform_keys: 'List[Dict]',
    ) -> None:
        """Set keyframe of the object in Blender.

        Args:
            obj_name (str): Name of the object.
            transform_keys (List[Dict]): Keyframes of transform (location, rotation, scale, and interpolation).
        """
        obj = bpy.data.objects[obj_name]
        for i, key in enumerate(transform_keys):
            ## insert keyframes
            # https://docs.blender.org/api/current/bpy.types.bpy_struct.html
            if key['location']:
                obj.location = key['location']
                obj.keyframe_insert(data_path='location', frame=key['frame'])
            if key['rotation']:
                obj.rotation_euler = [math.radians(i) for i in key['rotation']]
                obj.keyframe_insert(data_path='rotation_euler', frame=key['frame'])
            if key['scale']:
                obj.scale = key['scale']
                obj.keyframe_insert(data_path='scale', frame=key['frame'])

            ## set interpolation mode
            # https://blender.stackexchange.com/questions/260149/set-keyframe-interpolation-constant-while-setting-a-keyframe-in-blender-python
            if obj.animation_data and obj.animation_data.action:
                obj_action = bpy.data.actions.get(obj.animation_data.action.name)
                if key['location']:
                    obj_location_fcurve = obj_action.fcurves.find('location')
                    obj_location_fcurve.keyframe_points[i].interpolation = transform_keys[i]['interpolation']
                if key['rotation']:
                    obj_rotation_fcurve = obj_action.fcurves.find('rotation_euler')
                    obj_rotation_fcurve.keyframe_points[i].interpolation = transform_keys[i]['interpolation']
                if key['scale']:
                    obj_scale_fcurve = obj_action.fcurves.find('scale')
                    obj_scale_fcurve.keyframe_points[i].interpolation = transform_keys[i]['interpolation']

    @staticmethod
    def _set_origin_in_engine(name: str) -> None:
        obj = bpy.data.objects[name]
        # select this obj in  blender
        bpy.ops.object.select_all(action='DESELECT')
        obj.select_set(True)
        # set origin to center of mass
        bpy.ops.object.origin_set(type='ORIGIN_CENTER_OF_MASS', center='BOUNDS')

    @staticmethod
    def _set_dimensions_in_engine(name: str, dimensions: 'Vector') -> None:
        obj = bpy.data.objects[name]
        obj.dimensions = dimensions

    ##########################
    # ------- Delete ------- #
    ##########################
    @staticmethod
    def _delete_obj_in_engine(name: str):
        """Delete the object in Blender.

        Args:
            name (str): Name of the object.
        """
        bpy.data.objects.remove(bpy.data.objects[name])


@remote_unreal(dec_class=True)
class ObjectUtilsUnreal(ObjectUtilsBase):
    """Object utils class for Unreal."""

    ##########################
    # ----- Engine API ----- #
    ##########################

    # ------- Getter ------- #
    @staticmethod
    def _get_transform_in_engine(name: str) -> 'Transform':
        actor = XRFeitoriaUnrealFactory.utils_actor.get_actor_by_name(name)
        location = actor.get_actor_location()
        rotation = actor.get_actor_rotation()
        scale = actor.get_actor_scale3d()
        # convert from centimeters to meters
        location /= 100.0
        return location.to_tuple(), rotation.to_tuple(), scale.to_tuple()

    @staticmethod
    def _get_location_in_engine(name: str) -> 'Vector':
        actor = XRFeitoriaUnrealFactory.utils_actor.get_actor_by_name(name)
        location = actor.get_actor_location()
        # convert from centimeters to meters
        location /= 100.0
        return location.to_tuple()

    @staticmethod
    def _get_rotation_in_engine(name: str) -> 'Vector':
        actor = XRFeitoriaUnrealFactory.utils_actor.get_actor_by_name(name)
        rotation = actor.get_actor_rotation()
        return rotation.to_tuple()

    @staticmethod
    def _get_scale_in_engine(name: str) -> 'Vector':
        actor = XRFeitoriaUnrealFactory.utils_actor.get_actor_by_name(name)
        scale = actor.get_actor_scale3d()
        return scale.to_tuple()

    @staticmethod
    def _get_dimensions_in_engine(name: str) -> 'Vector':
        actor = XRFeitoriaUnrealFactory.utils_actor.get_actor_by_name(name)
        origin, box_extent = actor.get_actor_bounds(only_colliding_components=False)
        return (box_extent.x * 2 / 100.0, box_extent.y * 2 / 100.0, box_extent.z * 2 / 100.0)

    @staticmethod
    def _get_bound_box_in_engine(name: str) -> 'Tuple[Vector, Vector]':
        actor = XRFeitoriaUnrealFactory.utils_actor.get_actor_by_name(name)
        origin, box_extent = actor.get_actor_bounds(only_colliding_components=False)
        return (
            (
                (origin.x - box_extent.x) / 100.0,
                (origin.y - box_extent.y) / 100.0,
                (origin.z - box_extent.z) / 100.0,
            ),
            (
                (origin.x + box_extent.x) / 100.0,
                (origin.y + box_extent.y) / 100.0,
                (origin.z + box_extent.z) / 100.0,
            ),
        )

    @staticmethod
    def _get_engine_path_in_engine(name: str) -> str:
        actor = XRFeitoriaUnrealFactory.utils_actor.get_actor_by_name(name)
        if isinstance(actor, unreal.StaticMeshActor):
            return actor.static_mesh_component.static_mesh.get_path_name().split('.')[0]
        elif isinstance(actor, unreal.SkeletalMeshActor):
            return actor.skeletal_mesh_component.skeletal_mesh.get_path_name().split('.')[0]

    @staticmethod
    def _get_all_objects_in_engine() -> 'List[str]':
        return XRFeitoriaUnrealFactory.utils_actor.get_all_actors_name()

    # ------- Setter ------- #

    @staticmethod
    def _set_transform_in_engine(name: str, location: 'Vector', rotation: 'Vector', scale: 'Vector') -> None:
        actor = XRFeitoriaUnrealFactory.utils_actor.get_actor_by_name(name)
        location = unreal.Vector(location[0], location[1], location[2])
        rotation = unreal.Rotator(rotation[0], rotation[1], rotation[2])
        # convert from meters to centimeters
        location *= 100.0
        actor.set_actor_transform(unreal.Transform(location, rotation, scale), False, False)

    @staticmethod
    def _set_location_in_engine(name: str, location: 'Vector') -> None:
        actor = XRFeitoriaUnrealFactory.utils_actor.get_actor_by_name(name)
        location = unreal.Vector(location[0], location[1], location[2])
        # convert from meters to centimeters
        location *= 100.0
        actor.set_actor_location(location, False, False)

    @staticmethod
    def _set_rotation_in_engine(name: str, rotation: 'Vector') -> None:
        actor = XRFeitoriaUnrealFactory.utils_actor.get_actor_by_name(name)
        rotation = unreal.Rotator(rotation[0], rotation[1], rotation[2])
        actor.set_actor_rotation(rotation, False)

    @staticmethod
    def _set_scale_in_engine(name, scale: 'Vector') -> None:
        actor = XRFeitoriaUnrealFactory.utils_actor.get_actor_by_name(name)
        actor.set_actor_scale3d(scale)

    @staticmethod
    def _set_name_in_engine(name: str, new_name: str) -> None:
        actor = XRFeitoriaUnrealFactory.utils_actor.get_actor_by_name(name)
        actor.set_actor_label(new_name)

    @staticmethod
    def _delete_obj_in_engine(name):
        actor = XRFeitoriaUnrealFactory.utils_actor.get_actor_by_name(name)
        XRFeitoriaUnrealFactory.utils_actor.destroy_actor(actor)

    @staticmethod
    @abstractmethod
    def _generate_obj_name_in_engine(obj_type: 'Literal["camera", "actor"]') -> str:
        """Generate a name for the new object.

        Args:
            obj_type (str): Type of the object. It can be either 'camera' or 'actor'.

        Returns:
            str: Name of the new object.
        """
        actors = XRFeitoriaUnrealFactory.utils_actor.get_class_actors(unreal.Actor)
        actors = [
            actor
            for actor in actors
            if obj_type in actor.get_actor_label() and actor.get_actor_label().startswith(xf_obj_name[:4])
        ]
        return xf_obj_name.format(obj_type=obj_type, obj_idx=(len(actors) + 1))


##########################
# ------- Tools -------- #
##########################
# def direction_to_euler(cls, direction_vector: np.ndarray) -> Tuple[float, float, float]:
#     """Convert a direction vector to euler angles (yaw, pitch, roll) in degrees.

#     Args:
#         direction_vector (np.ndarray): [x, y, z] direction vector, in units of meters.

#     Returns:
#         Tuple[float, float, float]: yaw, pitch, roll in degrees.
#     """
#     if np.allclose(direction_vector, 0):
#         logger.warning('Camera is on the top of the target, cannot look at it.')
#         return (0.0, 0.0, 0.0)
#     if not isinstance(direction_vector, np.ndarray):
#         direction_vector = np.array(direction_vector)
#     direction_vector = direction_vector / np.linalg.norm(direction_vector)  # Normalize the direction vector
#     rotation = R.align_vectors([direction_vector], [cls.axis_zero_direction])[0].as_euler('xyz', degrees=True)
#     return rotation.tolist()

xrfeitoria/object/object_utils.py
openxrlab/xrfeitoria
from abc import ABC, abstractmethod
from functools import wraps
from typing import List

from loguru import logger
from rich import get_console


# decorator
def render_status(func):
    """Decorator for monitoring rendering status."""
    console = get_console()

    @wraps(func)
    def wrapper(*args, **kwargs):
        with console.status('[bold green]:rocket: Rendering...[/bold green]'):
            ret = func(*args, **kwargs)
        logger.info('[bold green]:white_check_mark: Rendering done![/bold green]')
        return ret

    return wrapper


class RendererBase(ABC):
    """Base renderer class."""

    render_queue: List = []

    @classmethod
    @abstractmethod
    def add_job(cls):
        """Add a rendering job to the renderer queue.

        This method should be implemented in subclasses, e.g.,
        :meth:`RenderBlender <xrfeitoria.renderer.renderer_blender.RendererBlender.add_job>` and
        :meth:`RenderUnreal <xrfeitoria.renderer.renderer_unreal.RendererUnreal.add_job>`.
        """
        pass

    @classmethod
    @abstractmethod
    @render_status
    def render_jobs(cls):
        """Render all jobs in the renderer queue.

        This method should be implemented in subclasses, e.g.,
        :meth:`RenderBlender <xrfeitoria.renderer.renderer_blender.RendererBlender.render_jobs>` and
        :meth:`RenderUnreal <xrfeitoria.renderer.renderer_unreal.RendererUnreal.render_jobs>`.
        """
        pass

    @classmethod
    def clear(cls):
        """Clear all rendering jobs in the renderer queue."""
        logger.warning('[red] Clearing Renderer jobs[/red]')
        cls._clear_queue_in_engine()
        cls.render_queue.clear()

    @staticmethod
    @abstractmethod
    def _clear_queue_in_engine():
        pass

xrfeitoria/renderer/renderer_base.py
openxrlab/xrfeitoria
import json
import re
import shutil
import subprocess
import threading
from pathlib import Path
from typing import Any, Dict, List, Literal, Optional, Tuple, Union

from loguru import logger

from .. import _tls
from ..actor.actor_blender import ActorBlender
from ..camera.camera_blender import CameraBlender
from ..data_structure.constants import (
    ImageFileFormatEnum,
    PathLike,
    RenderEngineEnumBlender,
    RenderOutputEnumBlender,
    actor_info_type,
    tmp_dir,
)
from ..rpc import remote_blender
from ..utils.functions import blender_functions
from .renderer_base import RendererBase, render_status

try:
    from ..data_structure.models import RenderJobBlender, RenderPass  # isort:skip
except ModuleNotFoundError:
    pass

try:
    # only for linting, not imported in runtime
    from XRFeitoriaBpy.core.factory import XRFeitoriaBlenderFactory  # defined in src/XRFeitoriaBpy/core/factory.py
except ModuleNotFoundError:
    pass


def receive_stdout(
    process: subprocess.Popen, in_background: bool, frame_range: Tuple[int, int], job_idx: Optional[int] = None
) -> None:
    """Receive output from the subprocess, and update the spinner.

    Args:
        process (subprocess.Popen): Subprocess of the blender process.
        frame_range (Tuple[int, int]): Frame range of the rendering job.
        job_range (Tuple[int, int]): Job range of all jobs in render queue.
    """
    from rich import get_console
    from rich.spinner import Spinner

    frame_count = 0
    frame_current = frame_range[0] - 1
    frame_length = frame_range[1] - frame_range[0] + 1
    job_info = f' {job_idx}' if job_idx else ''

    console = get_console()
    # TODO: change to progress bar
    try:
        spinner: Spinner = console._live.renderable
    except AttributeError:
        status = console.status('[bold green]:rocket: Rendering...[/bold green]')
        status.start()
        spinner: Spinner = status.renderable

    # init
    __frame_current__ = frame_current
    first_trigger = second_trigger = False
    # start receiving
    while True:
        try:
            data = process.stdout.readline().decode()
        except AttributeError:
            break

        if not data:
            break

        if in_background:
            # Fra:{idx}
            matched_frame_info = re.match(r'.*Fra:(\d+).*', data)
            if matched_frame_info:
                first_trigger = True
                __frame_current__ = int(matched_frame_info.group(1))
            if frame_current != __frame_current__:
                second_trigger = True
            if first_trigger and second_trigger:
                frame_current = __frame_current__
                frame_count += 1
                # XXX: can't leave process early,
                # must have on process reading stdout,
                # otherwise the process will hang
                if frame_count > frame_length:
                    break
                text = f'[bold green]:rocket: Rendering Job{job_info}: frame {frame_count}/{frame_length}[/bold green]'
                spinner.update(text=text)
                # reset
                first_trigger = second_trigger = False
        else:
            # Saved: ...
            matched_save_info = re.match(re.compile(r'.*Saved: .*', flags=re.DOTALL), data)
            #   Time: ...
            matched_time_info = re.match(re.compile(r'.*Time: .*', flags=re.DOTALL), data)
            matched_all_info = re.match(re.compile(r'.*Saved: .*Time: .*', flags=re.DOTALL), data)
            if matched_save_info:
                first_trigger = True
            if matched_time_info:
                second_trigger = True
            if matched_all_info:
                first_trigger = second_trigger = True

            if first_trigger and second_trigger:
                frame_count += 1
                if frame_count > frame_length:
                    break
                text = f'[bold green]:rocket: Rendering Job{job_info}: frame {frame_count}/{frame_length}[/bold green]'
                spinner.update(text=text)
                # reset
                first_trigger = second_trigger = False


@remote_blender(dec_class=True, suffix='_in_engine')
class RendererBlender(RendererBase):
    """Renderer class for Blender."""

    render_queue: 'List[RenderJobBlender]' = []

    @classmethod
    def add_job(
        cls,
        sequence_name: str,
        output_path: PathLike,
        resolution: Tuple[int, int],
        render_passes: 'List[RenderPass]',
        render_engine: Union[RenderEngineEnumBlender, Literal['cycles', 'eevee', 'workbench']] = 'cycles',
        render_samples: int = 128,
        transparent_background: bool = False,
        arrange_file_structure: bool = True,
    ) -> None:
        """Add a rendering job with specific settings.

        Args:
            output_path (PathLike): Output path of the rendered images.
            resolution (Tuple[int, int]): Resolution of the rendered images.
            render_passes (List[RenderPass]): Render passes.
            scene_name (str, optional): Name of the scene be rendered. Defaults to 'XRFeitoria'.
            render_engine (Union[RenderEngineEnumBlender, Literal['cycles', 'eevee', 'workbench']], optional): Render engine.
                Defaults to cycles.
            render_samples (int, optional): Render samples. Defaults to 128.
            transparent_background (bool, optional): Transparent background. Defaults to False.
            arrange_file_structure (bool, optional): Arrange output images to every camera's folder. Defaults to True.
        """
        if not isinstance(render_passes, list) or len(render_passes) == 0:
            raise ValueError('render_passes must be a list of RenderPass')

        if not isinstance(render_engine, RenderEngineEnumBlender):
            render_engine = RenderEngineEnumBlender.get(render_engine.lower())
        job = RenderJobBlender(
            sequence_name=sequence_name,
            output_path=Path(output_path).resolve() / sequence_name,
            resolution=resolution,
            render_passes=render_passes,
            render_engine=render_engine,
            render_samples=render_samples,
            transparent_background=transparent_background,
            arrange_file_structure=arrange_file_structure,
        )
        cls.render_queue.append(job)

    @classmethod
    @render_status
    def render_jobs(cls, use_gpu: bool = True) -> None:
        """Render all jobs in the render queue, and this method will clear the render
        queue after rendering.

        Args:
            use_gpu (bool, optional): Use GPU to render. Defaults to True.
        """
        if len(cls.render_queue) == 0:
            logger.warning(
                '[bold red]Skip rendering[/bold red], no render job in the queue. \n'
                ':bulb: Please add a job first via: \n'
                '>>> with xf_runner.Sequence.open(sequence_name=...) as seq:\n'
                '...     seq.add_to_renderer(...)'
            )
            return

        process: subprocess.Popen = _tls.cache.get('engine_process')
        in_background = blender_functions.is_background_mode()
        if use_gpu:
            blender_functions.enable_gpu()

        # render
        for job_idx, job in enumerate(cls.render_queue):
            if len(job.render_passes) == 0:
                raise RuntimeError('No render pass in the render job')

            logger.info(
                f'Job rendering {job_idx + 1}/{len(cls.render_queue)}: '
                f'seq_name="{job.sequence_name}", saving to "{job.output_path.as_posix()}"'
            )
            job.output_path.mkdir(exist_ok=True, parents=True)
            # set tmp path
            tmp_render_path = (tmp_dir / 'blender_render_tmp').as_posix() + '/'
            # set renderer
            active_cameras = cls._set_renderer_in_engine(
                job=job.model_dump(mode='json'),
                tmp_render_path=tmp_render_path,
            )
            # export actor infos
            actor_infos: List[actor_info_type] = []
            for actor_name in blender_functions.get_all_object_in_current_level(obj_type='MESH'):
                actor = ActorBlender(actor_name)
                mask_color = actor.mask_color
                actor_infos.append({'actor_name': actor_name, 'mask_color': mask_color})
            with (job.output_path / RenderOutputEnumBlender.actor_infos.value).open('w') as f:
                json.dump(actor_infos, f, indent=4)

            # start a thread to receive stdout
            output_thread = threading.Thread(
                target=receive_stdout,
                kwargs={
                    'process': process,
                    'in_background': in_background,
                    'frame_range': blender_functions.get_frame_range(),
                    'job_idx': job_idx + 1,
                },
            )
            output_thread.start()

            # render
            cls._render_in_engine()
            # delete tmp path folder
            shutil.rmtree(tmp_render_path)

            # ------ post-processing ------ #

            # export camera parameters
            for camera_name in active_cameras:
                camera = CameraBlender(name=camera_name)
                camera.dump_params(
                    output_path=job.output_path / RenderOutputEnumBlender.camera_params.value / f'{camera_name}.json'
                )

            # arrange output
            if job.arrange_file_structure:
                logger.debug(f'Arranging outputs for {job.output_path}')
                cls._arrange_output(job.render_passes, job.output_path, active_cameras)

        # clear render queue
        cls.clear()

    @classmethod
    def _arrange_output(cls, render_passes: 'List[RenderPass]', output_path: PathLike, camera_names: List[str]) -> None:
        """Arrange output images to every camera's folder.

        Args:
            render_passes (List[RenderPass): Render passes of the render job.
            output_path (PathLike): Output path of the render job.
            camera_names (List[str]): all active camera names in the rendered scene.
        """
        import glob

        output_path = Path(output_path)

        for render_pass in render_passes:
            pass_name = RenderOutputEnumBlender.get(render_pass.render_layer).name
            pass_ext = ImageFileFormatEnum.get(render_pass.image_format).name

            if len(camera_names) == 0:
                idx = f'{0:04d}'
                camera_names = [f.name.replace(idx, '') for f in output_path.glob(f'{idx}*.{pass_ext}')]

            pass_dir = output_path / pass_name
            for camera_name in camera_names:
                camera_pass_dir = pass_dir / camera_name
                camera_pass_dir.mkdir(exist_ok=True, parents=True)
                camera_name_escape = glob.escape(camera_name)
                camera_pass_files = pass_dir.glob(f'*{camera_name_escape}.*')
                for camera_pass_file in camera_pass_files:
                    new_camera_pass_file = camera_pass_dir / camera_pass_file.name.replace(camera_name, '')
                    if new_camera_pass_file.exists():
                        new_camera_pass_file.unlink()
                    camera_pass_file.rename(new_camera_pass_file)

    #####################################
    ###### RPC METHODS (Private) ########
    #####################################

    @staticmethod
    def _set_renderer_in_engine(job: 'Dict[str, Any]', tmp_render_path: str) -> None:
        """Set renderer in Blender.

        Args:
            job (Dict[str, Any]): Rendering job.

        Raises:
            RuntimeError: If no active camera in the scene.
        """
        scene = XRFeitoriaBlenderFactory.open_sequence(seq_name=job['sequence_name'])

        # clear compositing nodes
        if scene.node_tree:
            scene.node_tree.nodes.clear()

        # get active cameras in the scene
        active_cameras = XRFeitoriaBlenderFactory.get_active_cameras(scene=scene)
        if len(active_cameras) == 0:
            raise RuntimeError(f'Cannot render, no active camera in "{scene.name}"')

        # set renderer
        XRFeitoriaBlenderFactory.set_render_engine(engine=job['render_engine'], scene=scene)
        XRFeitoriaBlenderFactory.add_render_passes(
            output_path=job['output_path'],
            render_passes=job['render_passes'],
            scene=scene,
        )
        XRFeitoriaBlenderFactory.set_background_transparent(transparent=job['transparent_background'], scene=scene)
        XRFeitoriaBlenderFactory.set_render_samples(render_samples=job['render_samples'], scene=scene)
        XRFeitoriaBlenderFactory.set_resolution(resolution=job['resolution'], scene=scene)

        # set tmp path
        scene.render.filepath = tmp_render_path

        return active_cameras

    @staticmethod
    def _render_in_engine() -> 'List[str]':
        """Render a rendering job.

        Args:
            render_job (Dict[str, Any]): Rendering job.
        """
        # render
        XRFeitoriaBlenderFactory.render()
        # close sequence
        XRFeitoriaBlenderFactory.close_sequence()

    @staticmethod
    def _clear_queue_in_engine():
        # nothing to do
        pass

xrfeitoria/renderer/renderer_blender.py
openxrlab/xrfeitoria
import json
import shutil
import socket
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple

from loguru import logger

from ..data_structure.constants import PathLike, RenderOutputEnumUnreal, actor_info_type
from ..rpc import remote_unreal
from ..utils.functions import unreal_functions
from .renderer_base import RendererBase, render_status

try:
    import unreal
    from unreal_factory import XRFeitoriaUnrealFactory
except ModuleNotFoundError:
    pass

try:
    from ..data_structure.models import RenderJobUnreal as RenderJob
    from ..data_structure.models import RenderPass
except ModuleNotFoundError:
    pass


@remote_unreal(dec_class=True, suffix='_in_engine')
class RendererUnreal(RendererBase):
    """Renderer class for Unreal."""

    render_queue: 'List[RenderJob]' = []

    @classmethod
    def add_job(
        cls,
        map_path: str,
        sequence_path: str,
        output_path: PathLike,
        resolution: Tuple[int, int],
        render_passes: 'List[RenderPass]',
        file_name_format: str = '{sequence_name}/{render_pass}/{camera_name}/{frame_number}',
        console_variables: Dict[str, float] = {},
        anti_aliasing: 'Optional[RenderJob.AntiAliasSetting]' = None,
        export_vertices: bool = False,
        export_skeleton: bool = False,
    ) -> None:
        """Add a rendering job to the renderer queue.

        Args:
            map_path (str): Path to the map file.
            sequence_path (str): Path to the sequence file.
            output_path (PathLike): Path to the output folder.
            resolution (Tuple[int, int]): Resolution of the output image.
            render_passes (List[RenderPass]): Render passes to render.
            file_name_format (str, optional): File name format of the output image. Defaults to ``{sequence_name}/{render_pass}/{camera_name}/{frame_number}``.
            console_variables (Dict[str, float], optional): Console variables to set. Defaults to {}.
                Ref to :ref:`FAQ-console-variables` for details.
            anti_aliasing (Optional[RenderJob.AntiAliasSetting], optional): Anti aliasing setting. Defaults to None.
            export_vertices (bool, optional): Whether to export vertices. Defaults to False.
            export_skeleton (bool, optional): Whether to export skeleton. Defaults to False.

        Note:
            The motion blur is turned off by default. If you want to turn it on, please set ``r.MotionBlurQuality`` to a non-zero value in ``console_variables``.
        """
        if anti_aliasing is None:
            anti_aliasing = RenderJob.AntiAliasSetting()

        # turn off motion blur by default
        if 'r.MotionBlurQuality' not in console_variables.keys():
            console_variables['r.MotionBlurQuality'] = 0

        job = RenderJob(
            map_path=map_path,
            sequence_path=sequence_path,
            output_path=Path(output_path).resolve(),
            resolution=resolution,
            render_passes=render_passes,
            file_name_format=file_name_format,
            console_variables=console_variables,
            anti_aliasing=anti_aliasing,
            export_vertices=export_vertices,
            export_skeleton=export_skeleton,
        )
        cls._add_job_in_engine(job.model_dump(mode='json'))
        cls.render_queue.append(job)

    @classmethod
    def save_queue(cls, path: PathLike) -> None:
        """Saves the current render queue to a manifest file at the specified path.

        Args:
            path (PathLike): The path to save the manifest file to.
        """
        # save queue to manifest file
        manifest_file = Path(path)  # ext: .txt
        manifest_file.parent.mkdir(parents=True, exist_ok=True)
        cls._save_queue_in_engine(path)
        logger.info(f'Queue saved at {manifest_file} as a manifest file')

    @classmethod
    @render_status
    def render_jobs(cls) -> None:
        """Render all jobs in the renderer queue.

        This method starts the rendering process by setting up a socket connection with
        the Unreal Engine, rendering the all the render jobs, and then waiting for the
        engine to send a message indicating that the rendering is complete. If the
        engine crashes during the rendering process, an error message is logged and the
        program exits with an error code.

        After the rendering is complete, the renderer will perform post-processing in
        the output_path, including converting camera parameters, vertices, and
        actor_infos.

        Also, this method will clear the render queue after rendering.
        """
        if len(cls.render_queue) == 0:
            logger.warning(
                '[bold red]Skip rendering[/bold red], no render job in the queue. \n'
                ':bulb: Please add a job first via: \n'
                '>>> with xf_runner.Sequence.open(sequence_name=...) as seq:\n'
                '...     seq.add_to_renderer(...)'
            )
            return
        # start render
        socket_port = cls._get_socket_port_in_engine()
        server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
        server.bind(('127.0.0.1', socket_port))
        server.listen(8)
        server.setblocking(False)

        cls._render_in_engine()
        conn, _ = server.accept()
        while True:
            try:
                data_size = conn.recv(4)
                data_size = int.from_bytes(data_size, 'little')
                # data_size = 1024
                data = conn.recv(data_size).decode()
                if not data:
                    break
                if 'Render completed. Success: True' in data:
                    break
                logger.info(f'\[unreal] {data}')
            except BlockingIOError:
                pass
            except ConnectionResetError:
                from .. import _tls

                error_txt = '[red]Unreal Engine crashed![/red]'

                project_path = _tls.cache.get('unreal_project_path')
                if project_path is not None:
                    log_path = Path(project_path).parent / 'Saved/Logs/Pipeline.log'
                    if log_path.exists():
                        error_txt += f' Check unreal log: "{log_path.as_posix()}"'

                logger.error(error_txt)

        # cls.clear()
        server.close()

        # post process, including: convert cam params.
        cls._post_process()

        # clear render queue
        cls.clear()

    @classmethod
    def _post_process(cls) -> None:
        import numpy as np  # isort:skip
        from ..camera.camera_parameter import CameraParameter  # isort:skip

        def convert_camera(camera_file: Path) -> None:
            """Convert camera parameters from `.dat` to `.json` with `xrprimer`.

            Args:
                camera_file (Path): Path to the camera file.
            """
            cam_param = CameraParameter.from_bin(camera_file)
            cam_param.dump(camera_file.with_suffix('.json').as_posix())
            camera_file.unlink()

        def convert_vertices(folder: Path) -> None:
            """Convert vertices from `.bin` to `.npz`. Merge all vertices files into one
            `.npz` file.

            Args:
                folder (Path): Path to the folder containing vertices files.
            """
            # Get all vertices files in the folder and sort them
            vertices_files = sorted(folder.glob('*.dat'))
            # Read all vertices files into a list
            vertices = [
                np.frombuffer(vertices_file.read_bytes(), np.float32).reshape(-1, 3) for vertices_file in vertices_files
            ]
            if not vertices:
                return

            # Stack all vertices into one array with shape (frame, verts, 3)
            vertices = np.stack(vertices)
            # Convert convention from unreal to opencv, [x, y, z] -> [y, -z, x]
            vertices = np.stack([vertices[:, :, 1], -vertices[:, :, 2], vertices[:, :, 0]], axis=-1)
            vertices /= 100  # convert from cm to m

            # Save the vertices in a compressed `.npz` file
            np.savez_compressed(folder.with_suffix('.npz'), verts=vertices, faces=None)
            # Remove the folder
            shutil.rmtree(folder)

        def convert_actor_infos(folder: Path) -> None:
            """Convert stencil value from `.dat` to `.json`.

            Args:
                folder (Path): Path to the folder contains ``actor_infos``.
            """
            # Get all stencil value files in the folder and sort them
            actor_info_files = sorted(folder.glob('*.dat'))
            # Read all actor info files into a list
            actor_infos: List[actor_info_type] = []
            for actor_info_file in actor_info_files:
                stencil_value = np.frombuffer(actor_info_file.read_bytes(), np.float32)
                stencil_value = int(stencil_value)
                mask_color = unreal_functions.get_mask_color(stencil_value)
                actor_infos.append({'actor_name': actor_info_file.stem, 'mask_color': mask_color})

            if not actor_infos:
                return

            # Save the actor infos in a `.json` file
            with (folder.parent / f'{folder.name}.json').open('w') as f:
                json.dump(actor_infos, f, indent=4)

            # Remove the folder
            shutil.rmtree(folder)

        for job in cls.render_queue:
            seq_name = job.sequence_path.split('/')[-1]
            seq_path = Path(job.output_path).resolve() / seq_name

            # 1. convert camera parameters from `.bat` to `.json` with xrprimer
            # glob camera files in {seq_path}/{cam_param_dir}/*
            camera_files = sorted(seq_path.glob(f'{RenderOutputEnumUnreal.camera_params.value}/*.dat'))
            for camera_file in camera_files:
                convert_camera(camera_file)

            # 2. convert actor infos from `.dat` to `.json`
            convert_actor_infos(folder=seq_path / RenderOutputEnumUnreal.actor_infos.value)

            # 3. convert vertices from `.bin` to `.npz`
            if job.export_vertices:
                # glob actors in {seq_path}/vertices/*
                actor_folders = sorted(seq_path.glob(f'{RenderOutputEnumUnreal.vertices.value}/*'))
                for actor_folder in actor_folders:
                    convert_vertices(actor_folder)

    @staticmethod
    def _add_job_in_engine(job: 'Dict[str, Any]') -> None:
        _job = XRFeitoriaUnrealFactory.constants.RenderJobUnreal(**job)
        XRFeitoriaUnrealFactory.CustomMoviePipeline.add_job_to_queue(_job)

    @staticmethod
    def _render_in_engine() -> None:
        """Render the scene with default settings.

        Returns:
            int: The port used to communicate with the engine
        """
        XRFeitoriaUnrealFactory.CustomMoviePipeline.render_queue()

    @staticmethod
    def _get_socket_port_in_engine() -> int:
        return XRFeitoriaUnrealFactory.CustomMoviePipeline.socket_port

    @staticmethod
    def _clear_queue_in_engine() -> None:
        XRFeitoriaUnrealFactory.CustomMoviePipeline.clear_queue()

    @staticmethod
    def _save_queue_in_engine(path: str) -> None:
        XRFeitoriaUnrealFactory.CustomMoviePipeline.save_queue(path)

xrfeitoria/renderer/renderer_unreal.py
openxrlab/xrfeitoria
import os
from functools import partial

from . import factory

__all__ = ['remote_blender', 'remote_unreal']

# blender
REMAP_PAIRS = []
BLENDER_PORT = int(os.environ.get('BLENDER_PORT', 9997))
blender_default_imports = [
    'import bpy',
    'default_level_blender = bpy.context.scene.default_level_blender',
    'from XRFeitoriaBpy.core.factory import XRFeitoriaBlenderFactory',
]

# use different remap pairs when inside a container
if os.environ.get('TEST_ENVIRONMENT'):
    BLENDER_PORT = int(os.environ.get('BLENDER_PORT', 8997))
    REMAP_PAIRS = [
        (
            os.environ.get('HOST_REPO_FOLDER'),
            os.environ.get('CONTAINER_REPO_FOLDER'),
        )
    ]

remote_blender = partial(
    factory.remote_call,
    port=BLENDER_PORT,
    default_imports=blender_default_imports,
    remap_pairs=REMAP_PAIRS,
)
###############################################


# unreal
UNREAL_PORT = int(os.environ.get('UNREAL_PORT', 9998))
unreal_default_imports = [
    'import unreal',
    'from unreal_factory import XRFeitoriaUnrealFactory',
]

# use a different remap pairs when inside a container
if os.environ.get('TEST_ENVIRONMENT'):
    UNREAL_PORT = int(os.environ.get('UNREAL_PORT', 8998))
    REMAP_PAIRS = [
        (
            os.environ.get('HOST_REPO_FOLDER'),
            os.environ.get('CONTAINER_REPO_FOLDER'),
        )
    ]

remote_unreal = partial(
    factory.remote_call,
    port=UNREAL_PORT,
    default_imports=unreal_default_imports,
    remap_pairs=REMAP_PAIRS,
)

xrfeitoria/rpc/__init__.py
openxrlab/xrfeitoria
# Ref:
# https://github.com/EpicGames/BlenderTools/blob/main/send2ue/dependencies/rpc/client.py

import inspect
import os
import re
from xmlrpc.client import ExpatParser, ResponseError, ServerProxy, Transport, Unmarshaller


class RPCUnmarshaller(Unmarshaller):
    def __init__(self, *args, **kwargs):
        Unmarshaller.__init__(self, *args, **kwargs)
        self.error_pattern = re.compile(r'(?P<exception>[^:]*):(?P<exception_message>.*$)')
        self.builtin_exceptions = self._get_built_in_exceptions()

    @staticmethod
    def _get_built_in_exceptions():
        """Gets a list of the built-in exception classes in python.

        :return list[BaseException] A list of the built in exception classes in python:
        """
        builtin_exceptions = []
        for builtin_name, builtin_class in globals().get('__builtins__').items():
            if builtin_class and inspect.isclass(builtin_class) and issubclass(builtin_class, BaseException):
                builtin_exceptions.append(builtin_class)

        return builtin_exceptions

    def close(self):
        """Override so we redefine the unmarshaller.

        :return tuple: A tuple of marshallables.
        """
        from loguru import logger

        if self._type is None or self._marks:
            raise ResponseError()

        if self._type == 'fault':
            marshallables = self._stack[0]
            match = self.error_pattern.match(marshallables.get('faultString', ''))
            if match:
                exception_name = match.group('exception').strip("<class '").strip("'>")
                exception_message = match.group('exception_message')

                if exception_name:
                    for exception in self.builtin_exceptions:
                        if exception.__name__ == exception_name:
                            raise exception(exception_message)

            # if all else fails just raise the fault
            logger.error(
                f"RPC Fault <error code: {marshallables.get('faultCode')}>:\n{marshallables.get('faultString')}"
            )
            # raise Fault(**marshallables)
            # raise RuntimeError('RPC Fault')
            exit(1)
        return tuple(self._stack)


class RPCTransport(Transport):
    def getparser(self):
        """Override so we can redefine our transport to use its own custom unmarshaller.

        :return tuple(ExpatParser, RPCUnmarshaller): The parser and unmarshaller
        instances.
        """
        unmarshaller = RPCUnmarshaller()
        parser = ExpatParser(unmarshaller)
        return parser, unmarshaller


class RPCServerProxy(ServerProxy):
    auth_key = None

    def __init__(self, *args, **kwargs):
        """Override so we can redefine the ServerProxy to use our custom transport."""
        kwargs['transport'] = RPCTransport()
        ServerProxy.__init__(self, *args, **kwargs)


class RPCClient:
    def __init__(self, port, marshall_exceptions=True):
        """Initializes the rpc client.

        :param int port: A port number the client should connect to.
        :param bool marshall_exceptions: Whether the exceptions should be marshalled.
        """
        server_ip = os.environ.get('RPC_SERVER_IP', '127.0.0.1')

        self.proxy = RPCServerProxy(
            f'http://{server_ip}:{port}',
            allow_none=True,
        )
        self.marshall_exceptions = marshall_exceptions
        self.port = port

xrfeitoria/rpc/client.py
openxrlab/xrfeitoria
# Ref:
# https://github.com/EpicGames/BlenderTools/blob/main/send2ue/dependencies/rpc/exceptions.py


class BaseRPCException(Exception):
    """Raised when a rpc class method is not authored as a static method."""

    def __init__(self, message=None, line_link=''):
        self.message = message + line_link
        super().__init__(self.message)


class InvalidClassMethod(BaseRPCException):
    """Raised when a rpc class method is not authored as a static method."""

    def __init__(self, cls, method, message=None, line_link=''):
        self.message = message

        if message is None:
            self.message = (
                f'\n  {cls.__name__}.{method.__name__} is not a static method. Please decorate with @staticmethod.'
            )
        BaseRPCException.__init__(self, self.message, line_link)


class InvalidTestCasePort(BaseRPCException):
    """Raised when a rpc test case class does not have a port defined."""

    def __init__(self, cls, message=None, line_link=''):
        self.message = message

        if message is None:
            self.message = f'\n  You must set {cls.__name__}.port to a supported RPC port.'
        BaseRPCException.__init__(self, self.message, line_link)


class InvalidKeyWordParameters(BaseRPCException):
    """Raised when a rpc function has key word arguments in its parameters."""

    def __init__(self, function, kwargs, message=None, line_link=''):
        self.message = message

        if message is None:
            self.message = (
                f'\n  Keyword arguments "{kwargs}" were found on "{function.__name__}". The RPC client does not '
                f'support key word arguments . Please change your code to use only arguments.'
            )
        BaseRPCException.__init__(self, self.message, line_link)


class UnsupportedArgumentType(BaseRPCException):
    """Raised when a rpc function's argument type is not supported."""

    def __init__(self, function, arg, supported_types, message=None, line_link=''):
        self.message = message

        if message is None:
            self.message = (
                f'\n  "{function.__name__}" has an argument of type "{arg.__class__.__name__}". The only types that are'
                f' supported by the RPC client are {[supported_type.__name__ for supported_type in supported_types]}.'
            )
        BaseRPCException.__init__(self, self.message, line_link)


class FileNotSavedOnDisk(BaseRPCException):
    """Raised when a rpc function is called in a context where it is not a saved file on
    disk."""

    def __init__(self, function, message=None):
        self.message = message

        if message is None:
            self.message = (
                f'\n  "{function.__name__}" is not being called from a saved file. The RPC client does not '
                f'support code that is not saved. Please save your code to a file on disk and re-run it.'
            )
        BaseRPCException.__init__(self, self.message)

xrfeitoria/rpc/exceptions.py
openxrlab/xrfeitoria
# Ref:
# https://github.com/EpicGames/BlenderTools/blob/main/send2ue/dependencies/rpc/factory.py

import ast
import inspect
import os
import re
import sys
import textwrap
import types
from functools import wraps
from inspect import BoundArguments, signature
from pathlib import Path
from typing import Any, Callable, List, Optional, Tuple
from xmlrpc.client import Fault

from .client import RPCClient
from .validations import (
    get_line_link,
    get_source_file_path,
    validate_arguments,
    validate_file_is_saved,
    validate_key_word_parameters,
)


class RPCFactory:
    rpc_client: RPCClient = None
    file_path = None
    remap_pairs = []
    default_imports = []
    registered_function_names = []

    @classmethod
    def setup(cls, port: int, remap_pairs: List[str] = None, default_imports: List[str] = None):
        """Sets up the RPC factory.

        Args:
            port (int): The port to connect to.
            remap_pairs (List[str], optional): A list of remap pairs. Defaults to None.
            default_imports (List[str], optional): A list of default imports. Defaults to None.
        """
        if cls.rpc_client is None or cls.rpc_client.port != port:
            cls.rpc_client = RPCClient(port)
        cls.remap_pairs = remap_pairs
        cls.default_imports = default_imports or []
        if os.environ.get('RPC_RELOAD'):
            # clear the registered functions, so they can be re-registered
            cls.registered_function_names.clear()

    @staticmethod
    def _get_docstring(code: List[str], function_name: str) -> str:
        """Gets the docstring value from the functions code.

        Args:
            code (List[str]): A list of code lines.
            function_name (str): The name of the function.

        Returns:
            str: The docstring text.
        """
        # run the function code
        exec('\n'.join(code))
        # get the function from the locals
        function_instance = locals().copy().get(function_name)
        # get the doc strings from the function
        return function_instance.__doc__

    @classmethod
    def _get_callstack_references(cls, code, function):
        """Gets all references for the given code.

        Args:
            code (List[str]): A list of code lines.
            function (Callable): A callable.
        """
        import_code = cls.default_imports.copy()

        client_module = inspect.getmodule(function)
        cls.file_path = get_source_file_path(function)

        # if a list of remap pairs have been set, the file path will be remapped to the new server location
        # Note: The is useful when the server and client are not on the same machine.
        server_module_path = cls.file_path
        for client_path_root, matching_server_path_root in cls.remap_pairs or []:
            if cls.file_path.startswith(client_path_root):
                server_module_path = os.path.join(
                    matching_server_path_root,
                    cls.file_path.replace(client_path_root, '').replace(os.sep, '/').strip('/'),
                )
                break

        for key in dir(client_module):
            # skip default imports (e.g. bpy, unreal, etc)
            if key in [_key.split()[-1] for _key in cls.default_imports]:
                continue

            for line_number, line in enumerate(code):
                if line.startswith('def '):
                    continue

                # if the key is in the line, then add it to the import code
                # this re.split is used to split the line by the following characters: . ( ) [ ] =
                # e.g. ret = bpy.data.objects['Cube'] -> ["bpy", "data", "objects", "'Cube'""]
                if key in re.split('\.|\(|\)|\[|\]|\=|\ = | ', line.strip()):
                    relative_path = function.__module__.replace('.', os.path.sep)
                    import_dir = cls.file_path.strip('.py').replace(relative_path, '').strip(os.sep)
                    # add the source file to the import code
                    source_import_code = f'sys.path.append(r"{import_dir}")'
                    if source_import_code not in import_code:
                        import_code.append(source_import_code)
                    # relatively import the module from the source file
                    relative_import_code = f'from {function.__module__} import {key}'
                    if relative_import_code not in import_code:
                        import_code.append(relative_import_code)

        return textwrap.indent('\n'.join(import_code), ' ' * 4)

    @classmethod
    def _get_code(cls, function: Callable) -> List[str]:
        """Gets the code from a callable.

        Args:
            function (Callable): A callable.

        Returns:
            List[str]: A list of code lines.
        """
        import astunparse

        code = textwrap.dedent(inspect.getsource(function)).split('\n')
        code = [line for line in code if not line.strip().startswith(('@', '#'))]
        # set definition part of function into a single line
        code = astunparse.unparse(ast.parse('\n'.join(code))).split('\n')
        code = [line for line in code if line != '']  # remove empty lines

        # get the docstring from the code
        doc_string = cls._get_docstring(code, function.__name__)

        # get import code and insert them inside the function
        import_code = cls._get_callstack_references(code, function)
        code.insert(1, import_code)

        # remove the doc string
        if doc_string:
            code = '\n'.join(code).replace(doc_string, '')
            code = [line for line in code.split('\n') if not all([char == '"' or char == "'" for char in line.strip()])]

        return code

    @classmethod
    def _register(cls, function: Callable) -> List[str]:
        """Registers a given callable with the server.

        Args:
            function (Callable): A callable.

        Returns:
            List[str]: A list of code lines.
        """
        from loguru import logger

        # if function registered, skip it
        if function.__name__ in cls.registered_function_names:
            logger.debug(f'Function "{function.__name__}" has already been registered with the server!')
            return []

        code = cls._get_code(function)
        try:
            # if additional paths are explicitly set, then use them. This is useful with the client is on another
            # machine and the python paths are different
            additional_paths = list(filter(None, os.environ.get('RPC_ADDITIONAL_PYTHON_PATHS', '').split(',')))

            if not additional_paths:
                # otherwise use the current system path
                additional_paths = sys.path

            response = cls.rpc_client.proxy.add_new_callable(function.__name__, '\n'.join(code), additional_paths)
            cls.registered_function_names.append(function.__name__)
            if os.environ.get('RPC_DEBUG'):
                _code = '\n'.join(code)
                logger.debug(f'code:\n{_code}')
                logger.debug(f'response: {response}')

        except ConnectionRefusedError:
            server_name = os.environ.get(f'RPC_SERVER_{cls.rpc_client.port}', cls.rpc_client.port)
            raise ConnectionRefusedError(f'No connection could be made with "{server_name}"')

        return code

    @classmethod
    def run_function_remotely(cls, function: Callable, args: Tuple[Any]) -> Any:
        """Handles running the given function on remotely.

        Args:
            function (Callable): A callable.
            args (Tuple[Any]): A tuple of arguments.

        Returns:
            Any: The return value of the function.
        """
        validate_arguments(function, args)

        # get the remote function instance
        code = cls._register(function)
        remote_function = getattr(cls.rpc_client.proxy, function.__name__)

        current_frame = inspect.currentframe()
        outer_frame_info = inspect.getouterframes(current_frame)
        # step back 2 frames in the callstack
        caller_frame = outer_frame_info[2][0]
        # create a trace back that is relevant to the remote code rather than the code transporting it
        call_traceback = types.TracebackType(None, caller_frame, caller_frame.f_lasti, caller_frame.f_lineno)
        # call the remote function
        if not cls.rpc_client.marshall_exceptions:
            # if exceptions are not marshalled then receive the default Fault
            return remote_function(*args)

        # otherwise catch them and add a line link to them
        try:
            return remote_function(*args)
        except Exception as exception:
            stack_trace = str(exception) + get_line_link(function)
            if isinstance(exception, Fault):
                raise Fault(exception.faultCode, exception.faultString)
            raise exception.__class__(stack_trace).with_traceback(call_traceback)


def is_in_engine() -> bool:
    """Returns True if the code is running in the engine.

    Args:
        True if the code is running in the engine.
    """
    try:
        import bpy  # isort:skip

        assert bpy.context.scene.default_level_blender
        return True
    except Exception:
        pass

    # check if the code is running in unreal
    try:
        from unreal_factory import XRFeitoriaUnrealFactory  # defined in src/XRFeitoriaUnreal/Content/Python

        return True
    except Exception:
        pass

    # not running in the engine
    return False


def remote_call(
    port: int,
    default_imports: Optional[List[str]] = None,
    remap_pairs: Optional[List[Tuple]] = None,
    dec_class: bool = False,
    prefix: str = '',
    suffix: str = '',
) -> Callable:
    """A decorator that makes this function (or class) run remotely.

    Args:
        port (int): The port to connect to.
        default_imports (List[str], optional): A list of default imports. Defaults to None.
        remap_pairs (List[Tuple], optional): A list of remap pairs. Defaults to None.
        dec_class (bool, False): If True, the ``staticmethod`` with following
            prefix and suffix of class will be decorated. Defaults to False.
        prefix (str, optional): This is used when `dec_class=True`.
            Functions that start with this prefix in the class will be decorated. Defaults to ''.
        suffix (str, optional): This is used when `dec_class=True`.
            Functions that end with this suffix in the class will be decorated. Defaults to ''.

    Returns:
        Callable: A decorated function.

    Note:
        When ``dec_class=True``, only the ``staticmethod`` with ``prefix`` and ``suffix`` will be decorated.
    """

    def decorator(function):
        @wraps(function)
        def wrapper(*args, **kwargs):
            if is_in_engine():
                return function(*args, **kwargs)

            # remove the self argument (args[0]) if it is the same as the class name
            if len(args) > 0 and args[0].__class__.__name__ == function.__qualname__.split('.')[0]:
                args = args[1:]

            # convert kwargs arguments to positional
            # https://stackoverflow.com/questions/33448997/convert-kwargs-arguments-to-positional
            func_signature = signature(function)
            bound_arguments: BoundArguments = func_signature.bind(*args, **kwargs)
            bound_arguments.apply_defaults()
            args = bound_arguments.args
            kwargs = bound_arguments.kwargs

            # convert the args
            args = list(args)  # convert tuple to list
            for index, arg in enumerate(args):
                # convert Path to string
                if isinstance(arg, Path):
                    args[index] = arg.as_posix()

            validate_file_is_saved(function)
            validate_key_word_parameters(function, kwargs)
            RPCFactory.setup(port=port, remap_pairs=remap_pairs, default_imports=default_imports)
            return RPCFactory.run_function_remotely(function, args)

        return wrapper

    if dec_class:

        def decorator_class(cls):
            for attribute, value in cls.__dict__.items():
                if (
                    isinstance(value, staticmethod)
                    and callable(getattr(cls, attribute))
                    and attribute.startswith(prefix)
                    and attribute.endswith(suffix)
                ):
                    setattr(cls, attribute, decorator(getattr(cls, attribute)))
            return cls

        return decorator_class
    else:
        return decorator

xrfeitoria/rpc/factory.py
openxrlab/xrfeitoria
# Ref:
# https://github.com/EpicGames/BlenderTools/blob/main/send2ue/dependencies/rpc/validations.py

import inspect

from .exceptions import (
    FileNotSavedOnDisk,
    InvalidClassMethod,
    InvalidKeyWordParameters,
    InvalidTestCasePort,
    UnsupportedArgumentType,
)


def get_source_file_path(function):
    """Gets the full path to the source code.

    :param callable function: A callable.
    :return str: A file path.
    """
    client_module = inspect.getmodule(function)
    return client_module.__file__


def get_line_link(function):
    """Gets the line number of a function.

    :param callable function: A callable.
    :return int: The line number
    """
    lines, line_number = inspect.getsourcelines(function)
    file_path = get_source_file_path(function)
    return f'  File "{file_path}", line {line_number}'


def validate_arguments(function, args):
    """Validates arguments to ensure they are a supported type.

    :param callable function: A function reference.
    :param tuple(Any) args: A list of arguments.
    """
    supported_types = [str, int, float, tuple, list, dict, bool]
    line_link = get_line_link(function)
    for arg in args:
        if arg is None:
            continue

        if type(arg) not in supported_types:
            raise UnsupportedArgumentType(function, arg, supported_types, line_link=line_link)


def validate_test_case_class(cls):
    """This is use to validate a subclass of RPCTestCase. While building your test suite
    you can call this method on each class preemptively to validate that it was defined
    correctly.

    :param RPCTestCase cls: A class.
    :param str file_path: Optionally, a file path to the test case can be passed to give
        further context into where the error is occurring.
    """
    line_link = get_line_link(cls)
    if not cls.__dict__.get('port'):
        raise InvalidTestCasePort(cls, line_link=line_link)

    for attribute, method in cls.__dict__.items():
        if callable(method) and not isinstance(method, staticmethod):
            if method.__name__.startswith('test'):
                raise InvalidClassMethod(cls, method, line_link=line_link)


def validate_class_method(cls, method):
    """Validates a method on a class.

    :param Any cls: A class.
    :param callable method: A callable.
    """
    if callable(method) and not isinstance(method, staticmethod):
        line_link = get_line_link(method)
        raise InvalidClassMethod(cls, method, line_link=line_link)


def validate_key_word_parameters(function, kwargs):
    """Validates a method on a class.

    :param callable function: A callable.
    :param dict kwargs: A dictionary of key word arguments.
    """
    if kwargs:
        line_link = get_line_link(function)
        raise InvalidKeyWordParameters(function, kwargs, line_link=line_link)


def validate_file_is_saved(function):
    """Validates that the file that the function is from is saved on disk.

    :param callable function: A callable.
    """
    try:
        inspect.getsourcelines(function)
    except OSError:
        raise FileNotSavedOnDisk(function)

xrfeitoria/rpc/validations.py
openxrlab/xrfeitoria
from abc import ABC, abstractmethod
from pathlib import Path
from typing import Dict, List, Literal, Optional, Tuple, Union

from loguru import logger

from .. import _tls
from ..actor.actor_base import ActorBase
from ..camera.camera_base import CameraBase
from ..data_structure.constants import EngineEnum, PathLike, Vector
from ..object.object_utils import ObjectUtilsBase
from ..renderer.renderer_base import RendererBase
from ..utils import Validator

try:
    from ..data_structure.models import SequenceTransformKey, TransformKeys, RenderPass  # isort:skip
except ModuleNotFoundError:
    SequenceTransformKey = TransformKeys = RenderPass = None


class SequenceBase(ABC):
    """Base sequence class."""

    name: str = None

    _actor = ActorBase
    _camera = CameraBase
    _object_utils = ObjectUtilsBase
    _renderer = RendererBase
    __platform__: EngineEnum = _tls.cache.get('platform', None)

    @classmethod
    def _new(
        cls,
        seq_name: str,
        level: Optional[str] = None,
        seq_fps: int = 60,
        seq_length: int = 1,
        replace: bool = False,
        **kwargs,
    ) -> None:
        """Create a new sequence.

        Args:
            seq_name (str): Name of the sequence.
            level (str): Level to link to the sequence.
            seq_fps (int, optional): Frame per second of the new sequence. Defaults to 60.
            seq_length (int, optional): Frame length of the new sequence. Defaults to 60.
            replace (bool, optional): Replace the exist same-name sequence. Defaults to False.
        """
        if level:
            Validator.validate_argument_type(level, str)
        cls._new_seq_in_engine(
            seq_name=seq_name, level=level, seq_fps=seq_fps, seq_length=seq_length, replace=replace, **kwargs
        )
        cls.name = seq_name
        # TODO: info sequence infos like path, name, corresponding level, etc.
        logger.info(f'>>>> [cyan]Created[/cyan] sequence "{cls.name}" >>>>')

    @classmethod
    def _open(cls, seq_name: str) -> None:
        """Open an exist sequence.

        Args:
            seq_name (str): Name of the sequence.
        """
        cls._open_seq_in_engine(seq_name=seq_name)
        cls.name = seq_name
        logger.info(f'>>>> [cyan]Opened[/cyan] sequence "{cls.name}" >>>>')

    @classmethod
    def close(cls) -> None:
        """Close the opened sequence."""
        cls._close_seq_in_engine()
        logger.info(f'<<<< [red]Closed[/red] sequence "{cls.name}" <<<<')
        cls.name = None

    @classmethod
    def save(cls) -> None:
        """Save the sequence."""
        cls._save_seq_in_engine()
        logger.info(f'++++  [cyan]Saved[/cyan] sequence "{cls.name}" ++++')

    @classmethod
    def show(cls) -> None:
        """Show the sequence in the engine."""
        cls._show_seq_in_engine()

    # ------ import actor ------ #
    @classmethod
    def import_actor(
        cls,
        file_path: PathLike,
        actor_name: Optional[str] = None,
        location: 'Vector' = None,
        rotation: 'Vector' = None,
        scale: 'Vector' = None,
        stencil_value: int = 1,
    ) -> ActorBase:
        if actor_name is None:
            actor_name = cls._object_utils.generate_obj_name(obj_type='actor')
        # judge file path
        file_path = Path(file_path).resolve()
        if not file_path.exists():
            raise FileNotFoundError(f'File "{file_path.as_posix()}" is not found')
        # set transform keys
        transform_keys = SequenceTransformKey(
            frame=0, location=location, rotation=rotation, scale=scale, interpolation='CONSTANT'
        )
        cls._import_actor_in_engine(
            file_path=file_path,
            actor_name=actor_name,
            transform_keys=transform_keys.model_dump(),
            stencil_value=stencil_value,
        )
        logger.info(f'[cyan]Imported[/cyan] actor "{actor_name}" in sequence "{cls.name}"')
        return cls._actor(name=actor_name)

    # ------ spawn actor and camera ------ #

    @classmethod
    def spawn_camera(
        cls,
        location: 'Vector' = None,
        rotation: 'Vector' = None,
        fov: float = 90.0,
        camera_name: Optional[str] = None,
    ) -> CameraBase:
        """Spawn a new camera in the sequence.

        Args:
            location (Vector): Location of the camera.
            rotation (Vector): Rotation of the camera.
            fov (float in (0.0, 180.0), optional): Field of view of the camera len. Defaults to 90.0. (unit: degrees)
            camera_name (str, optional): Name of the camera. Defaults to None.
        """
        if camera_name is None:
            camera_name = cls._object_utils.generate_obj_name(obj_type='camera')
        transform_keys = SequenceTransformKey(frame=0, location=location, rotation=rotation, interpolation='CONSTANT')
        cls._spawn_camera_in_engine(
            transform_keys=transform_keys.model_dump(),
            fov=fov,
            camera_name=camera_name,
        )
        logger.info(f'[cyan]Spawned[/cyan] camera "{camera_name}" in sequence "{cls.name}"')
        return cls._camera(name=camera_name)

    @classmethod
    def spawn_camera_with_keys(
        cls,
        transform_keys: 'TransformKeys',
        fov: float = 90.0,
        camera_name: str = None,
    ) -> CameraBase:
        """Spawn a new camera with keyframes in the sequence.

        Args:
            transform_keys (TransformKeys): Keyframes of transform (location, rotation, and scale).
            fov (float in (0.0, 180.0), optional): Field of view of the camera len. Defaults to 90.0. (unit: degrees)
            camera_name (str, optional): Name of the camera. Defaults to 'Camera'.
        """
        if not isinstance(transform_keys, list):
            transform_keys = [transform_keys]
        transform_keys = [i.model_dump() for i in transform_keys]
        if camera_name is None:
            camera_name = cls._object_utils.generate_obj_name(obj_type='camera')
        cls._spawn_camera_in_engine(
            transform_keys=transform_keys,
            fov=fov,
            camera_name=camera_name,
        )
        logger.info(
            f'[cyan]Spawned[/cyan] camera "{camera_name}" with {len(transform_keys)} keys in sequence "{cls.name}"'
        )
        return cls._camera(name=camera_name)

    @classmethod
    def spawn_shape(
        cls,
        type: 'Literal["plane", "cube", "sphere", "cylinder", "cone"]',
        shape_name: str = None,
        location: Vector = None,
        rotation: Vector = None,
        scale: Vector = None,
        stencil_value: int = 1,
        **kwargs,
    ) -> ActorBase:
        """Spawn a shape in the sequence.

        Args:
            type (str): Type of new spawn shape. One of ["plane", "cube", "sphere", "cylinder", "cone"]
            shape_name (str): Name of the new added shape. Defaults to None.
            location (Vector, optional): Location of the shape. Defaults to (0, 0, 0).
            rotation (Vector, optional): Rotation of the shape. Defaults to (0, 0, 0).
            scale (Vector, optional): Scale of the shape. Defaults to (1, 1, 1).
            stencil_value (int in [0, 255], optional): Stencil value of the shape. Defaults to 1.
                Ref to :ref:`FAQ-stencil-value` for details.

        Returns:
            ShapeBlender or ShapeUnreal: New added shape.
        """
        transform_keys = SequenceTransformKey(
            frame=0, location=location, rotation=rotation, scale=scale, interpolation='CONSTANT'
        )
        if shape_name is None:
            shape_name = cls._object_utils.generate_obj_name(obj_type=type)
        cls._spawn_shape_in_engine(
            type=type,
            transform_keys=transform_keys.model_dump(),
            shape_name=shape_name,
            stencil_value=stencil_value,
            **kwargs,
        )
        logger.info(f'[cyan]Spawned[/cyan] {type.capitalize()} "{shape_name}" in sequence "{cls.name}"')
        return cls._actor(name=shape_name)

    @classmethod
    def spawn_shape_with_keys(
        cls,
        transform_keys: 'TransformKeys',
        type: 'Literal["plane", "cube", "sphere", "cylinder", "cone"]',
        shape_name: str = None,
        stencil_value: int = 1,
        **kwargs,
    ) -> ActorBase:
        """Spawn a shape with keyframes in the sequence.

        Args:
            shape_name (str): Name of the new added shape.
            type (str): Type of new spawn shape. One of ["plane", "cube", "sphere", "cylinder", "cone"]
            transform_keys (TransformKeys): Keyframes of transform (location, rotation, and scale).
            stencil_value (int in [0, 255], optional): Stencil value of the cone. Defaults to 1.
                Ref to :ref:`FAQ-stencil-value` for details.

        Returns:
            ShapeBlender or ShapeUnreal: New added shape.
        """
        if not isinstance(transform_keys, list):
            transform_keys = [transform_keys]
        transform_keys = [i.model_dump() for i in transform_keys]
        if shape_name is None:
            shape_name = cls._object_utils.generate_obj_name(obj_type=type)
        cls._spawn_shape_in_engine(
            type=type,
            transform_keys=transform_keys,
            shape_name=shape_name,
            stencil_value=stencil_value,
            **kwargs,
        )
        logger.info(
            f"[cyan]Spawned[/cyan] {type.capitalize()} '{shape_name}' "
            f"with {len(transform_keys)} keys in sequence '{cls.name}'."
        )
        return cls._actor(name=shape_name)

    # ------ use actor and camera ------ #
    @classmethod
    def use_camera(
        cls,
        camera: _camera,
        location: 'Optional[Vector]' = None,
        rotation: 'Optional[Vector]' = None,
        fov: float = None,
    ) -> None:
        """Use the specified level camera in the sequence. The location, rotation and
        fov set in this method are only used in the sequence. The location, rotation and
        fov of the camera in the level will be restored after the sequence is closed.

        Args:
            camera (CameraUnreal or CameraBlender): The camera to use in the sequence.
            location (Optional[Vector], optional): The location of the camera. Defaults to None. unit: meter.
            rotation (Optional[Vector], optional): The rotation of the camera. Defaults to None. unit: degree.
            fov (float, optional): The field of view of the camera. Defaults to None. unit: degree.
        """
        camera_name = camera.name
        location = camera.location if location is None else location
        rotation = camera.rotation if rotation is None else rotation
        fov = camera.fov if fov is None else fov

        transform_keys = SequenceTransformKey(frame=0, location=location, rotation=rotation, interpolation='CONSTANT')
        cls._use_camera_in_engine(
            transform_keys=transform_keys.model_dump(),
            fov=fov,
            camera_name=camera_name,
        )
        logger.info(f'[cyan]Used[/cyan] camera "{camera_name}" in sequence "{cls.name}"')

    @classmethod
    def use_camera_with_keys(
        cls,
        camera: _camera,
        transform_keys: 'TransformKeys',
        fov: float = None,
    ) -> None:
        """Use the specified level camera in the sequence. The transform_keys and fov
        set in this method are only used in the sequence. The location, rotation and fov
        of the camera in the level will be restored after the sequence is closed.

        Args:
            camera (CameraUnreal or CameraBlender): The camera to use.
            transform_keys (TransformKeys): The transform keys to use.
            fov (float, optional): The field of view to use. Defaults to None. unit: degree.
        """
        camera_name = camera.name
        if not isinstance(transform_keys, list):
            transform_keys = [transform_keys]
        transform_keys = [i.model_dump() for i in transform_keys]
        fov = camera.fov if fov is None else fov
        cls._use_camera_in_engine(
            transform_keys=transform_keys,
            fov=fov,
            camera_name=camera_name,
        )
        logger.info(
            f'[cyan]Used[/cyan] camera "{camera_name}" with {len(transform_keys)} keys in sequence "{cls.name}"'
        )

    @classmethod
    def use_actor(
        cls,
        actor: _actor,
        location: 'Optional[Vector]' = None,
        rotation: 'Optional[Vector]' = None,
        scale: 'Optional[Vector]' = None,
        stencil_value: int = None,
        anim_asset_path: 'Optional[str]' = None,
    ) -> None:
        """Use the specified level actor in the sequence. The location, rotation, scale,
        stencil_value and anim_asset set in this method are only used in the sequence.
        These peoperties of the actor in the level will be restored after the sequence
        is closed.

        Args:
            actor (ActorUnreal or ActorBlender): The actor to add to the sequence.
            location (Optional[Vector]): The initial location of the actor. If None, the actor's current location is used. unit: meter.
            rotation (Optional[Vector]): The initial rotation of the actor. If None, the actor's current rotation is used. unit: degree.
            scale (Optional[Vector]): The initial scale of the actor. If None, the actor's current scale is used.
            stencil_value (int in [0, 255], optional): The stencil value to use for the spawned actor. Defaults to None.
                Ref to :ref:`FAQ-stencil-value` for details.
            anim_asset_path (Optional[str]): For blender, this argument is the name of an action(bpy.types.Action).
            For Unreal Engine, this argument is the engine path to the animation asset to use for the actor.
            Default to None. If None, the actor's current animation is used, else the specified animation is used.
        """
        actor_name = actor.name
        location = actor.location if location is None else location
        rotation = actor.rotation if rotation is None else rotation
        scale = actor.scale if scale is None else scale
        stencil_value = actor.stencil_value if stencil_value is None else stencil_value

        transform_keys = SequenceTransformKey(
            frame=0, location=location, rotation=rotation, scale=scale, interpolation='CONSTANT'
        )
        cls._use_actor_in_engine(
            actor_name=actor_name,
            transform_keys=transform_keys.model_dump(),
            stencil_value=stencil_value,
            anim_asset_path=anim_asset_path,
        )
        logger.info(f'[cyan]Used[/cyan] actor "{actor_name}" in sequence "{cls.name}"')

    @classmethod
    def use_actor_with_keys(
        cls,
        actor: _actor,
        transform_keys: 'TransformKeys',
        stencil_value: int = None,
        anim_asset_path: 'Optional[str]' = None,
    ) -> None:
        """Use the specified level actor in the sequence. The transform_keys,
        stencil_value and anim_asset set in this method are only used in the sequence.
        These peoperties of the actor in the level will be restored after the sequence
        is closed.

        Args:
            actor (ActorUnreal or ActorBlender): The actor to use in the sequence.
            transform_keys (Union[TransformKeys, List[TransformKeys]]): The transform keys to use with the actor.
            stencil_value (int in [0, 255], optional): The stencil value to use for the spawned actor. Defaults to None.
                Ref to :ref:`FAQ-stencil-value` for details.
            anim_asset_path (Optional[str]): For blender, this argument is the name of an action(bpy.types.Action).
            For Unreal Engine, this argument is the engine path to the animation asset to use for the actor.
            Default to None. If None, the actor's current animation is used, else the specified animation is used.
        """
        actor_name = actor.name
        if not isinstance(transform_keys, list):
            transform_keys = [transform_keys]
        transform_keys = [i.model_dump() for i in transform_keys]
        stencil_value = actor.stencil_value if stencil_value is None else stencil_value

        cls._use_actor_in_engine(
            actor_name=actor_name,
            transform_keys=transform_keys,
            stencil_value=stencil_value,
            anim_asset_path=anim_asset_path,
        )
        logger.info(f'[cyan]Used[/cyan] actor "{actor_name}" with {len(transform_keys)} keys in sequence "{cls.name}"')

    @classmethod
    def add_to_renderer(
        cls,
        output_path: PathLike,
        resolution: Tuple[int, int],
        render_passes: List[RenderPass],
        **kwargs,
    ):
        """Add a rendering job with specific settings.

        Args:
            output_path (PathLike): Output path of the rendered images.
            resolution (Tuple[int, int]): Resolution of the rendered images.
            render_passes (List[RenderPass]): Render passes.
            kwargs: Other engine specific arguments.
        """
        cls._renderer.add_job(
            sequence_name=cls.name,
            output_path=output_path,
            resolution=resolution,
            render_passes=render_passes,
            **kwargs,
        )
        logger.info(
            f'[cyan]Added[/cyan] sequence "{cls.name}" to [bold]`Renderer`[/bold] '
            f'(jobs to render: {len(cls._renderer.render_queue)})'
        )
        # return render_job

    #####################################
    ###### RPC METHODS (Private) ########
    #####################################

    @staticmethod
    @abstractmethod
    def _import_actor_in_engine(
        file_path: str,
        transform_keys: 'Union[List[Dict], Dict]',
        actor_name: str = 'Actor',
        stencil_value: int = 1,
    ) -> None:
        pass

    @staticmethod
    @abstractmethod
    def _spawn_camera_in_engine(
        transform_keys: 'Union[List[Dict], Dict]',
        fov: float = 90.0,
        camera_name: str = 'Camera',
    ) -> None:
        pass

    @staticmethod
    @abstractmethod
    def _spawn_shape_in_engine(
        type: 'Literal["plane", "cube", "sphere", "cylinder", "cone"]',
        transform_keys: 'Union[List[Dict], Dict]',
        shape_name: str = 'Shape',
        stencil_value: int = 1,
    ) -> None:
        pass

    @staticmethod
    @abstractmethod
    def _use_camera_in_engine(camera_name: str) -> None:
        pass

    @staticmethod
    @abstractmethod
    def _new_seq_in_engine(
        level: Union[str, List[str]],
        seq_name: str = 'Sequence',
        seq_fps: int = 60,
        seq_length: int = 60,
        replace: bool = False,
    ) -> None:
        pass

    @staticmethod
    @abstractmethod
    def _open_seq_in_engine(*args, **kwargs) -> None:
        pass

    @staticmethod
    @abstractmethod
    def _close_seq_in_engine() -> None:
        pass

    @staticmethod
    @abstractmethod
    def _save_seq_in_engine() -> None:
        pass

    @staticmethod
    @abstractmethod
    def _show_seq_in_engine() -> None:
        pass

xrfeitoria/sequence/sequence_base.py
openxrlab/xrfeitoria
from typing import Dict, List, Optional, Tuple, Union

from ..actor.actor_blender import ActorBlender, ShapeBlenderWrapper
from ..camera.camera_blender import CameraBlender
from ..data_structure.constants import PathLike, Vector
from ..object.object_utils import ObjectUtilsBlender
from ..renderer.renderer_blender import RendererBlender
from ..rpc import remote_blender
from .sequence_base import SequenceBase

try:
    import bpy  # isort:skip
    from ..data_structure.models import TransformKeys  # isort:skip
    from XRFeitoriaBpy.core.factory import XRFeitoriaBlenderFactory  # defined in src/XRFeitoriaBpy/core/factory.py
except ModuleNotFoundError:
    pass


@remote_blender(dec_class=True, suffix='_in_engine')
class SequenceBlender(SequenceBase):
    """Sequence class for Blender."""

    _actor = ActorBlender
    _camera = CameraBlender
    _renderer = RendererBlender
    _object_utils = ObjectUtilsBlender

    # -------- spawn methods -------- #

    @classmethod
    def import_actor_with_keys(
        cls,
        file_path: PathLike,
        transform_keys: 'TransformKeys',
        actor_name: str = None,
        stencil_value: int = 1,
    ) -> 'ActorBlender':
        """Import an actor from a file to the sequence and add transform keyframes to
        the it.

        Args:
            name (str): Name of the actor in Blender.
            path (PathLike): File path used for importing the actor.
            transform_keys (TransformKeys):Keyframes of transform (location, rotation, and scale).
            stencil_value (int in [0, 255], optional): Stencil value of the actor. Defaults to 1.
                Ref to :ref:`FAQ-stencil-value` for details.

        Returns:
            ActorBlender: New imported actor.
        """
        actor = cls.import_actor(
            actor_name=actor_name,
            file_path=file_path,
            stencil_value=stencil_value,
        )
        cls._object_utils.set_transform_keys(name=actor.name, transform_keys=transform_keys)
        return actor

    #####################################
    ###### RPC METHODS (Private) ########
    #####################################

    @staticmethod
    def _new_seq_in_engine(
        level: str = None,
        seq_name: str = 'Sequence',
        seq_fps: int = 60,
        seq_length: int = 1,
        replace: bool = False,
    ) -> None:
        """Create a new sequence in Blender. (Create a collection named 'seq_name' and
        link it to the scene named 'level').

        Args:
            seq_name (str): _description_
            link_levels (Optional[Union[List[str],str]], optional): _description_. Defaults to [].
            seq_fps (Optional[float], optional): _description_. Defaults to None.
            seq_length (Optional[int], optional): _description_. Defaults to None.
            replace (bool, optional): _description_. Defaults to False.
        """
        # get level scene
        level_scene = (
            XRFeitoriaBlenderFactory.get_scene(level) if level else XRFeitoriaBlenderFactory.get_active_scene()
        )
        # new collection named seq_name
        new_seq_collection = XRFeitoriaBlenderFactory.new_collection(name=seq_name, replace=replace)
        # link sequence(new_seq_collection) to level(level_scene)
        XRFeitoriaBlenderFactory.link_collection_to_scene(collection=new_seq_collection, scene=level_scene)
        XRFeitoriaBlenderFactory.set_level_properties(scene=level_scene, active_seq=new_seq_collection)

        # set sequence properties
        XRFeitoriaBlenderFactory.set_sequence_properties(
            collection=new_seq_collection,
            level=level_scene,
            fps=seq_fps,
            frame_start=0,
            frame_end=seq_length - 1,
            frame_current=0,
        )
        level_scene.frame_start = 0
        level_scene.frame_end = seq_length - 1
        level_scene.frame_current = 0
        level_scene.render.fps = seq_fps
        # set scene to active
        XRFeitoriaBlenderFactory.set_scene_active(level_scene)
        # set collection to active
        XRFeitoriaBlenderFactory.set_collection_active(new_seq_collection)
        # tag the collection as sequence collection
        XRFeitoriaBlenderFactory.tag_sequence_collection(collection=new_seq_collection)

    @staticmethod
    def _open_seq_in_engine(seq_name: str) -> None:
        """Open an exist sequence in Blender. (Link the collection of the sequence to
        its level scene.)

        Args:
            seq_name (str): Name of the sequence.
        """
        # XRFeitoriaBlenderFactory.open_sequence(seq_name)
        level_scene = XRFeitoriaBlenderFactory.get_active_scene()
        active_sequence = XRFeitoriaBlenderFactory.get_collection(seq_name)
        XRFeitoriaBlenderFactory.set_level_properties(scene=level_scene, active_seq=active_sequence)

    @staticmethod
    def _close_seq_in_engine() -> None:
        """Close the opened sequence in Blender.

        (Unlink the collection of the sequence from the active scene.)
        """
        # XRFeitoriaBlenderFactory.close_sequence()
        level_scene = XRFeitoriaBlenderFactory.get_active_scene()
        XRFeitoriaBlenderFactory.set_level_properties(scene=level_scene, active_seq=None)

    @staticmethod
    def _show_seq_in_engine() -> None:
        raise NotImplementedError

    # -------- spawn methods -------- #
    @staticmethod
    def _import_actor_in_engine(
        file_path: str,
        transform_keys: 'Union[List[Dict], Dict]',
        actor_name: str = 'Actor',
        stencil_value: int = 1,
    ) -> None:
        """Import.

        Args:
            file_path (PathLike): _description_
            transform_keys (Union[List[Dict], Dict]): _description_
            actor_name (str, optional): _description_. Defaults to 'Actor'.
            stencil_value (int, optional): _description_. Defaults to 1.
        """
        if not isinstance(transform_keys, list):
            transform_keys = [transform_keys]

        ActorBlender._import_actor_from_file_in_engine(file_path=file_path, actor_name=actor_name)
        ObjectUtilsBlender._set_transform_keys_in_engine(obj_name=actor_name, transform_keys=transform_keys)
        # XXX: set stencil value. may use actor property
        bpy.data.objects[actor_name].pass_index = stencil_value

    @staticmethod
    def _spawn_camera_in_engine(
        transform_keys: 'Union[List[Dict], Dict]',
        fov: float = 90.0,
        camera_name: str = 'Camera',
    ) -> None:
        """Spawn a camera in the engine.

        Args:
            transform_keys (Union[List[Dict], Dict]): Keyframes of transform (location, rotation, and scale).
            fov (float, optional): Field of view of the camera lens, in degrees. Defaults to 90.0.
            camera_name (str, optional): Name of the camera. Defaults to 'Camera'.
        """
        if not isinstance(transform_keys, list):
            transform_keys = [transform_keys]

        CameraBlender._spawn_in_engine(camera_name=camera_name, fov=fov)
        ObjectUtilsBlender._set_transform_keys_in_engine(obj_name=camera_name, transform_keys=transform_keys)

    @staticmethod
    def _spawn_shape_in_engine(
        type: str,
        transform_keys: 'Union[List[Dict], Dict]',
        shape_name: str = 'Shape',
        stencil_value: int = 1,
        size: float = 1.0,
        segments: int = 32,
        ring_count: int = 16,
        radius: float = 1.0,
        subdivisions: int = 2,
        vertices: int = 32,
        depth: float = 2.0,
        radius1: float = 0.0,
        radius2: float = 2.0,
    ) -> None:
        """Spawn a shape in the engine.

        Args:
            type (str, enum in ['cube', 'plane', 'cone', 'cylinder', 'sphere', 'ico_sphere']): Type of the shape.
            transform_keys (Union[List[Dict], Dict]): Keyframes of transform (location, rotation, and scale).
            shape_name (str, optional): Name of the shape. Defaults to 'Shape'.
            stencil_value (int, optional): Stencil value of the shape. Defaults to 1.
            size (float in [0, inf], optional): Size. Defaults to 1.0. (unit: meter)
            segments (int in [3, 100000], optional): Segments. Defaults to 32.
            ring_count (int in [3, 100000], optional): Ring count. Defaults to 16.
            radius (float in [0, inf], optional): Radius. Defaults to 1.0. (unit: meter)
            subdivisions (int in [1, 10], optional): Subdivisions. Defaults to 2.
            vertices (int in [3, 10000000], optional): Vertices. Defaults to 32.
            depth (float in [0, inf], optional): Depth. Defaults to 2.0. (unit: meter)
            radius1 (float in [0, inf], optional): Radius1. Defaults to 0.0. (unit: meter)
            radius2 (float in [0, inf], optional): Radius2. Defaults to 2.0. (unit: meter)
        """
        if not isinstance(transform_keys, list):
            transform_keys = [transform_keys]

        ShapeBlenderWrapper._spawn_shape_in_engine(
            name=shape_name,
            type=type,
            size=size,
            segments=segments,
            ring_count=ring_count,
            radius=radius1,
            subdivisions=subdivisions,
            vertices=vertices,
            depth=depth,
            radius1=radius1,
            radius2=radius2,
        )
        ObjectUtilsBlender._set_transform_keys_in_engine(obj_name=shape_name, transform_keys=transform_keys)
        # XXX: set stencil value. may use actor property
        bpy.data.objects[shape_name].pass_index = stencil_value

    # -------- use methods -------- #
    @staticmethod
    def _use_camera_in_engine(
        transform_keys: 'Union[List[Dict], Dict]',
        fov: float = 90.0,
        camera_name: str = 'Camera',
    ) -> None:
        """Use level camera to render in the sequence in the engine.

        Args:
            camera_name (str): Name of the camera.
        """
        import math

        if not isinstance(transform_keys, list):
            transform_keys = [transform_keys]

        # get actor by name
        camera = bpy.data.objects[camera_name]

        # set sequence properties
        collection = XRFeitoriaBlenderFactory.get_active_collection()
        level_camera_data = collection.sequence_properties.level_cameras.add()
        level_camera_data.camera = camera
        level_camera_data.location = camera.location
        level_camera_data.rotation = camera.rotation_euler
        level_camera_data.scale = camera.scale
        level_camera_data.level_fov = camera.data.angle
        level_camera_data.sequence_fov = math.radians(fov)

        # set level camera's properties
        CameraBlender._set_camera_fov_in_engine(name=camera_name, fov=fov)
        ObjectUtilsBlender._set_transform_keys_in_engine(obj_name=camera_name, transform_keys=transform_keys)
        level_camera_data.sequence_animation = camera.animation_data.action if camera.animation_data else None

        # set camera activity
        scene = XRFeitoriaBlenderFactory.get_active_scene()
        XRFeitoriaBlenderFactory.set_camera_activity(camera_name=camera_name, scene=scene, active=True)

    @staticmethod
    def _use_actor_in_engine(
        actor_name: str,
        transform_keys: 'Union[List[Dict], Dict]',
        stencil_value: int,
        anim_asset_path: 'Optional[str]' = None,
    ):
        if not isinstance(transform_keys, list):
            transform_keys = [transform_keys]

        # get actor by name
        actor = bpy.data.objects[actor_name]

        # if anim_asset_path is None, use the current action of the actor if it has one
        # else, use the action of the anim_asset_path
        if anim_asset_path is None:
            if actor.animation_data and actor.animation_data.action:
                action = actor.animation_data.action
            else:
                action = None
        else:
            action = bpy.data.actions[anim_asset_path]

        # set sequence properties
        collection = XRFeitoriaBlenderFactory.get_active_collection()
        level_actor_data = collection.sequence_properties.level_actors.add()
        level_actor_data.actor = actor
        level_actor_data.level_stencil_value = actor.pass_index
        level_actor_data.sequence_stencil_value = stencil_value
        level_actor_data.level_animation = actor.animation_data.action if actor.animation_data else None
        level_actor_data.sequence_animation = action
        level_actor_data.location = actor.location
        level_actor_data.rotation = actor.rotation_euler
        level_actor_data.scale = actor.scale

        # set level actor's properties
        actor.pass_index = stencil_value
        if action:
            XRFeitoriaBlenderFactory.apply_action_to_actor(action=action, actor=actor)
        ObjectUtilsBlender._set_transform_keys_in_engine(obj_name=actor_name, transform_keys=transform_keys)
        level_actor_data.sequence_animation = actor.animation_data.action if actor.animation_data else None

xrfeitoria/sequence/sequence_blender.py
openxrlab/xrfeitoria
from typing import Dict, List, Literal, Optional, Tuple, Union

from loguru import logger

from ..actor.actor_unreal import ActorUnreal
from ..camera.camera_unreal import CameraUnreal
from ..data_structure.constants import PathLike, Vector
from ..object.object_utils import ObjectUtilsUnreal
from ..renderer.renderer_unreal import RendererUnreal
from ..rpc import remote_unreal
from ..utils.functions import unreal_functions
from .sequence_base import SequenceBase

try:
    import unreal
    from unreal_factory import XRFeitoriaUnrealFactory  # defined in src/XRFeitoriaUnreal/Content/Python
except ModuleNotFoundError:
    pass

try:
    from ..data_structure.models import RenderJobUnreal, RenderPass
    from ..data_structure.models import SequenceTransformKey as SeqTransKey
    from ..data_structure.models import TransformKeys
except ModuleNotFoundError:
    pass


@remote_unreal(dec_class=True, suffix='_in_engine')
class SequenceUnreal(SequenceBase):
    """Sequence class for Unreal."""

    _actor = ActorUnreal
    _camera = CameraUnreal
    _object_utils = ObjectUtilsUnreal
    _renderer = RendererUnreal

    @classmethod
    def add_to_renderer(
        cls,
        output_path: PathLike,
        resolution: Tuple[int, int],
        render_passes: 'List[RenderPass]',
        file_name_format: str = '{sequence_name}/{render_pass}/{camera_name}/{frame_number}',
        console_variables: Dict[str, float] = {},
        anti_aliasing: 'Optional[RenderJobUnreal.AntiAliasSetting]' = None,
        export_vertices: bool = False,
        export_skeleton: bool = False,
    ) -> None:
        """Add the sequence to the renderer's job queue. Can only be called after the
        sequence is instantiated using
        :meth:`~xrfeitoria.sequence.sequence_wrapper.SequenceWrapperUnreal.new` or
        :meth:`~xrfeitoria.sequence.sequence_wrapper.SequenceWrapperUnreal.o pen`.

        Args:
            output_path (PathLike): The path where the rendered output will be saved.
            resolution (Tuple[int, int]): The resolution of the output.
            render_passes (List[RenderPass]): The list of render passes to be rendered.
            file_name_format (str, optional): The format of the output file name.
                Defaults to ``{sequence_name}/{render_pass}/{camera_name}/{frame_number}``.
            console_variables (Dict[str, float], optional): The console variables to be set before rendering. Defaults to {}.
                Ref to :ref:`FAQ-stencil-value` for details.
            anti_aliasing (Optional[RenderJobUnreal.AntiAliasSetting], optional):
                The anti-aliasing settings for the render job. Defaults to None.
            export_vertices (bool, optional): Whether to export vertices. Defaults to False.
            export_skeleton (bool, optional): Whether to export the skeleton. Defaults to False.

        Examples:
            >>> import xrfeitoria as xf
            >>> from xrfeitoria.data_structure.models import RenderPass
            >>> with xf.init_blender() as xf_runner:
            ...     seq = xf_runner.Sequence.new(seq_name='test'):
            ...         seq.add_to_renderer(
            ...             output_path=...,
            ...             resolution=...,
            ...             render_passes=[RenderPass('img', 'png')],
            ...         )
            ...     xf_runner.render()
        """
        map_path = SequenceUnreal._get_map_path_in_engine()
        sequence_path = SequenceUnreal._get_seq_path_in_engine()
        if anti_aliasing is None:
            anti_aliasing = RenderJobUnreal.AntiAliasSetting()
        cls._renderer.add_job(
            map_path=map_path,
            sequence_path=sequence_path,
            output_path=output_path,
            resolution=resolution,
            render_passes=render_passes,
            file_name_format=file_name_format,
            console_variables=console_variables,
            anti_aliasing=anti_aliasing,
            export_vertices=export_vertices,
            export_skeleton=export_skeleton,
        )
        logger.info(
            f'[cyan]Added[/cyan] sequence "{cls.name}" to [bold]`Renderer`[/bold] '
            f'(jobs to render: {len(cls._renderer.render_queue)})'
        )

    @classmethod
    def spawn_actor(
        cls,
        actor_asset_path: str,
        location: 'Vector',
        rotation: 'Vector',
        scale: 'Optional[Vector]' = None,
        actor_name: Optional[str] = None,
        stencil_value: int = 1,
        anim_asset_path: 'Optional[str]' = None,
    ) -> ActorUnreal:
        """Spawns an actor in the Unreal Engine at the specified location, rotation, and
        scale.

        Args:
            cls: The class object.
            actor_asset_path (str): The actor asset path in engine to spawn.
            location (Vector): The location to spawn the actor at. unit: meter.
            rotation (Vector): The rotation to spawn the actor with. unit: degree.
            scale (Optional[Vector], optional): The scale to spawn the actor with. Defaults to None.
            actor_name (Optional[str], optional): The name to give the spawned actor. Defaults to None.
            stencil_value (int in [0, 255], optional): The stencil value to use for the spawned actor. Defaults to 1.
                Ref to :ref:`FAQ-stencil-value` for details.
            anim_asset_path (Optional[str], optional): The engine path to the animation asset of the actor. Defaults to None.

        Returns:
            ActorUnreal: The spawned actor object.
        """
        transform_keys = SeqTransKey(
            frame=0, location=location, rotation=rotation, scale=scale, interpolation='CONSTANT'
        )
        if actor_name is None:
            actor_name = cls._object_utils.generate_obj_name(obj_type='actor')
        cls._spawn_actor_in_engine(
            actor_asset_path=actor_asset_path,
            transform_keys=transform_keys.model_dump(),
            anim_asset_path=anim_asset_path,
            actor_name=actor_name,
            stencil_value=stencil_value,
        )
        logger.info(f'[cyan]Spawned[/cyan] actor "{actor_name}" in sequence "{cls.name}"')
        return ActorUnreal(actor_name)

    @classmethod
    def spawn_actor_with_keys(
        cls,
        actor_asset_path: str,
        transform_keys: 'TransformKeys',
        actor_name: Optional[str] = None,
        stencil_value: int = 1,
        anim_asset_path: 'Optional[str]' = None,
    ) -> ActorUnreal:
        """Spawns an actor in the Unreal Engine with the given asset path, transform
        keys, actor name, stencil value, and animation asset path.

        Args:
            actor_asset_path (str): The actor asset path in engine to spawn.
            transform_keys (TransformKeys): The transform keys of the actor.
            actor_name (Optional[str], optional): The name of the actor. Defaults to None.
            stencil_value (int in [0, 255], optional): The stencil value to use for the spawned actor. Defaults to 1.
                Ref to :ref:`FAQ-stencil-value` for details.
            anim_asset_path (Optional[str], optional): The engine path to the animation asset of the actor. Defaults to None.

        Returns:
            ActorUnreal: The spawned actor.
        """
        if not isinstance(transform_keys, list):
            transform_keys = [transform_keys]
        transform_keys = [i.model_dump() for i in transform_keys]

        if actor_name is None:
            actor_name = cls._object_utils.generate_obj_name(obj_type='actor')

        cls._spawn_actor_in_engine(
            actor_asset_path=actor_asset_path,
            transform_keys=transform_keys,
            anim_asset_path=anim_asset_path,
            actor_name=actor_name,
            stencil_value=stencil_value,
        )
        logger.info(
            f'[cyan]Spawned[/cyan] actor "{actor_name}" with {len(transform_keys)} keys in sequence "{cls.name}"'
        )
        return ActorUnreal(actor_name)

    @classmethod
    def get_map_path(cls) -> str:
        """Returns the path to the map corresponding to the sequence in the Unreal
        Engine.

        Returns:
            str: engine path to the map corresponding to the sequence.
        """
        return cls._get_map_path_in_engine()

    @classmethod
    def get_seq_path(cls) -> str:
        """Returns the path to the sequence in the Unreal Engine.

        Returns:
            str: engine path to the sequence.
        """
        return cls._get_seq_path_in_engine()

    #####################################
    ###### RPC METHODS (Private) ########
    #####################################

    @staticmethod
    def _get_seq_info_in_engine(
        seq_name: str,
        seq_dir: 'Optional[str]' = None,
        map_path: 'Optional[str]' = None,
    ) -> 'Tuple[str, str]':
        _suffix = XRFeitoriaUnrealFactory.constants.data_asset_suffix
        default_sequence_path = XRFeitoriaUnrealFactory.constants.DEFAULT_SEQUENCE_PATH
        seq_dir = seq_dir or default_sequence_path  # default sequence path
        if map_path is None:
            seq_data_path = f'{seq_dir}/{seq_name}{_suffix}'
            unreal_functions.check_asset_in_engine(seq_data_path, raise_error=True)
            seq_path, map_path = XRFeitoriaUnrealFactory.Sequence.get_data_asset_info(seq_data_path)
        else:
            seq_path = f'{seq_dir}/{seq_name}'

        return seq_path, map_path

    @staticmethod
    def _get_map_path_in_engine() -> str:
        return XRFeitoriaUnrealFactory.Sequence.map_path

    @staticmethod
    def _get_seq_path_in_engine() -> str:
        return XRFeitoriaUnrealFactory.Sequence.sequence_path

    @staticmethod
    def _new_seq_in_engine(
        seq_name: str,
        level: 'Optional[str]' = None,
        seq_dir: 'Optional[str]' = None,
        seq_fps: 'Optional[float]' = None,
        seq_length: 'Optional[int]' = None,
        replace: bool = False,
    ) -> str:
        """Create a new sequence.

        Args:
            seq_name (str): name of the sequence.
            level (Optional[str], optional): path of the map asset. Defaults to None.
            seq_dir (Optional[str], optional): path of the sequence asset. Defaults to None.
            seq_fps (Optional[float], optional): FPS of the sequence. Defaults to None.
            seq_length (Optional[int], optional): length of the sequence. Defaults to None.
            replace (bool, optional): whether to replace the sequence if it already exists. Defaults to False.

        Returns:
            str: path of the data asset of sequence data, containing sequence_path and map_path.
        """
        return XRFeitoriaUnrealFactory.Sequence.new(
            map_path=level,
            seq_name=seq_name,
            seq_dir=seq_dir,
            seq_fps=seq_fps,
            seq_length=seq_length,
            replace=replace,
        )

    @staticmethod
    def _open_seq_in_engine(
        seq_name: str,
        seq_dir: 'Optional[str]' = None,
        map_path: 'Optional[str]' = None,
    ) -> None:
        seq_path, map_path = SequenceUnreal._get_seq_info_in_engine(
            seq_name=seq_name, seq_dir=seq_dir, map_path=map_path
        )
        XRFeitoriaUnrealFactory.Sequence.open(
            map_path=map_path,
            seq_path=seq_path,
        )

    @staticmethod
    def _save_seq_in_engine() -> None:
        XRFeitoriaUnrealFactory.Sequence.save()

    @staticmethod
    def _close_seq_in_engine() -> None:
        XRFeitoriaUnrealFactory.Sequence.close()

    @staticmethod
    def _show_seq_in_engine() -> None:
        XRFeitoriaUnrealFactory.Sequence.show()

    # ------ add actor and camera -------- #

    @staticmethod
    def _use_camera_in_engine(
        transform_keys: 'Union[List[Dict], Dict]',
        fov: float = 90.0,
        camera_name: str = 'Camera',
    ) -> None:
        if not isinstance(transform_keys, list):
            transform_keys = [transform_keys]
        if isinstance(transform_keys[0], dict):
            transform_keys = [XRFeitoriaUnrealFactory.constants.SequenceTransformKey(**k) for k in transform_keys]

        XRFeitoriaUnrealFactory.Sequence.add_camera(
            transform_keys=transform_keys,
            fov=fov,
            camera_name=camera_name,
        )

    @staticmethod
    def _use_actor_in_engine(
        actor_name: str,
        transform_keys: 'Union[List[Dict], Dict]',
        stencil_value: int = 1,
        anim_asset_path: 'Optional[str]' = None,
    ):
        if not isinstance(transform_keys, list):
            transform_keys = [transform_keys]
        if isinstance(transform_keys[0], dict):
            transform_keys = [XRFeitoriaUnrealFactory.constants.SequenceTransformKey(**k) for k in transform_keys]

        XRFeitoriaUnrealFactory.Sequence.add_actor(
            actor_name=actor_name,
            transform_keys=transform_keys,
            stencil_value=stencil_value,
            animation_asset=anim_asset_path,
        )

    # ------ spawn actor and camera ------ #
    @staticmethod
    def _import_actor_in_engine(
        file_path: str,
        transform_keys: 'Union[List[Dict], Dict]',
        actor_name: str = 'Actor',
        stencil_value: int = 1,
    ) -> None:
        if not isinstance(transform_keys, list):
            transform_keys = [transform_keys]
        if isinstance(transform_keys[0], dict):
            transform_keys = [XRFeitoriaUnrealFactory.constants.SequenceTransformKey(**k) for k in transform_keys]

        actor_path = XRFeitoriaUnrealFactory.utils.import_asset(file_path)
        logger.info(f'actor_path: {actor_path}')
        animation_asset_path = f'{actor_path[0]}_Anim'
        if not unreal.EditorAssetLibrary.does_asset_exist(animation_asset_path):
            animation_asset_path = None
        XRFeitoriaUnrealFactory.Sequence.add_actor(
            actor=actor_path[0],
            animation_asset=animation_asset_path,
            actor_name=actor_name,
            transform_keys=transform_keys,
            stencil_value=stencil_value,
        )

    @staticmethod
    def _spawn_camera_in_engine(
        transform_keys: 'Union[List[Dict], Dict]',
        fov: float = 90.0,
        camera_name: str = 'Camera',
    ) -> None:
        if not isinstance(transform_keys, list):
            transform_keys = [transform_keys]
        if isinstance(transform_keys[0], dict):
            transform_keys = [XRFeitoriaUnrealFactory.constants.SequenceTransformKey(**k) for k in transform_keys]

        XRFeitoriaUnrealFactory.Sequence.add_camera(
            transform_keys=transform_keys,
            fov=fov,
            camera_name=camera_name,
            spawnable=True,
        )

    @staticmethod
    def _spawn_actor_in_engine(
        actor_asset_path: str,
        transform_keys: 'Union[List[Dict], Dict]',
        anim_asset_path: 'Optional[str]' = None,
        actor_name: str = 'Actor',
        stencil_value: int = 1,
    ) -> None:
        # check asset
        unreal_functions.check_asset_in_engine(actor_asset_path, raise_error=True)
        if anim_asset_path is not None:
            unreal_functions.check_asset_in_engine(anim_asset_path, raise_error=True)

        if not isinstance(transform_keys, list):
            transform_keys = [transform_keys]
        if isinstance(transform_keys[0], dict):
            transform_keys = [XRFeitoriaUnrealFactory.constants.SequenceTransformKey(**k) for k in transform_keys]

        XRFeitoriaUnrealFactory.Sequence.add_actor(
            actor=actor_asset_path,
            animation_asset=anim_asset_path,
            actor_name=actor_name,
            transform_keys=transform_keys,
            stencil_value=stencil_value,
        )

    @staticmethod
    def _spawn_shape_in_engine(
        type: 'Literal["plane", "cube", "sphere", "cylinder", "cone"]',
        transform_keys: 'Union[List[Dict], Dict]',
        shape_name: str = 'Shape',
        stencil_value: int = 1,
    ) -> None:
        if not isinstance(transform_keys, list):
            transform_keys = [transform_keys]
        if isinstance(transform_keys[0], dict):
            transform_keys = [XRFeitoriaUnrealFactory.constants.SequenceTransformKey(**k) for k in transform_keys]

        shape_path = XRFeitoriaUnrealFactory.constants.SHAPE_PATHS[type]
        XRFeitoriaUnrealFactory.Sequence.add_actor(
            actor=shape_path,
            animation_asset=None,
            actor_name=shape_name,
            transform_keys=transform_keys,
            stencil_value=stencil_value,
        )

xrfeitoria/sequence/sequence_unreal.py
openxrlab/xrfeitoria
from contextlib import contextmanager
from typing import ContextManager, List, Optional, Tuple, Union

from ..data_structure.constants import default_level_blender
from .sequence_base import SequenceBase
from .sequence_blender import SequenceBlender
from .sequence_unreal import SequenceUnreal


class SequenceWrapperBase:
    """Sequence utils class."""

    _seq = SequenceBase

    @classmethod
    @contextmanager
    def new(
        cls,
        seq_name: str,
        level: Optional[str] = None,
        seq_fps: int = 30,
        seq_length: int = 1,
        replace: bool = False,
    ) -> ContextManager[SequenceUnreal]:
        """Create a new sequence and close the sequence after exiting the it.

        Args:
            seq_name (str): Name of the sequence.
            level (str, optional): Name of the level. Defaults to None.
            seq_fps (int, optional): Frame per second of the new sequence. Defaults to 30.
            seq_length (int, optional): Frame length of the new sequence. Defaults to 1.
            replace (bool, optional): Replace the exist same-name sequence. Defaults to False.
        Yields:
            SequenceBase: Sequence object.
        """
        cls._seq._new(
            seq_name=seq_name,
            level=level,
            seq_fps=seq_fps,
            seq_length=seq_length,
            replace=replace,
        )
        yield cls._seq
        cls._seq.save()
        cls._seq.close()

    @classmethod
    @contextmanager
    def open(cls, seq_name: str) -> ContextManager[SequenceBase]:
        """Open a sequence and close the sequence after existing it.

        Args:
            seq_name (str): Name of the sequence.

        Yields:
            SequenceBase: Sequence object.
        """
        cls._seq._open(seq_name=seq_name)
        yield cls._seq
        cls._seq.save()
        cls._seq.close()


class SequenceWrapperBlender(SequenceWrapperBase):
    """Sequence utils class for Blender."""

    _seq = SequenceBlender

    @classmethod
    @contextmanager
    def new(
        cls,
        seq_name: str,
        level: Optional[str] = None,
        seq_fps: int = 30,
        seq_length: int = 1,
        replace: bool = False,
    ) -> ContextManager[SequenceBlender]:
        """Create a new sequence and close the sequence after exiting the it.

        Args:
            seq_name (str): Name of the sequence.
            level (Optional[str], optional): Name of the level. Defaults to None. If None, use the default level named 'XRFeitoria'.
            seq_fps (int, optional): Frame per second of the new sequence. Defaults to 30.
            seq_length (int, optional): Frame length of the new sequence. Defaults to 1.
            replace (bool, optional): Replace the exist same-name sequence. Defaults to False.
        Yields:
            SequenceBase: Sequence object.
        """
        if level is None:
            level = default_level_blender
        cls._seq._new(
            seq_name=seq_name,
            level=level,
            seq_fps=seq_fps,
            seq_length=seq_length,
            replace=replace,
        )
        yield cls._seq
        cls._seq.save()
        cls._seq.close()

    @classmethod
    @contextmanager
    def open(cls, seq_name: str) -> ContextManager[SequenceBase]:
        """Open a sequence and close the sequence after existing it.

        Args:
            seq_name (str): Name of the sequence.

        Yields:
            SequenceBase: Sequence object.
        """
        cls._seq._open(seq_name=seq_name)
        yield cls._seq
        cls._seq.save()
        cls._seq.close()


class SequenceWrapperUnreal(SequenceWrapperBase):
    """Sequence utils class for Unreal."""

    _seq = SequenceUnreal

    @classmethod
    @contextmanager
    def new(
        cls,
        seq_name: str,
        level: 'Optional[str]' = None,
        seq_fps: 'Optional[float]' = 30,
        seq_length: 'Optional[int]' = 1,
        replace: bool = False,
        seq_dir: 'Optional[str]' = None,
    ) -> ContextManager[SequenceUnreal]:
        """Create a new sequence and close the sequence after exiting the it.

        Args:
            seq_name (str): Name of the sequence.
            level (Optional[str], optional): Path of the level in the unreal project. e.g. '/Game/DefaultLevel'.
                Defaults to None. If None, use the current level.
            seq_fps (Optional[float], optional): Frame per second of the new sequence. Defaults to 30.
            seq_length (Optional[int], optional): Frame length of the new sequence. Defaults to 1.
            replace (bool, optional): Replace the exist same-name sequence. Defaults to False.
            seq_dir (Optional[str], optional): Path of the sequence.
                Defaults to None and fallback to the default path '/Game/XRFeitoriaUnreal/Sequences'.
        Yields:
            SequenceUnreal: Sequence object.
        """
        cls._seq._new(
            seq_name=seq_name,
            level=level,
            seq_fps=seq_fps,
            seq_length=seq_length,
            replace=replace,
            seq_dir=seq_dir,
        )
        yield cls._seq
        cls._seq.save()
        cls._seq.close()

xrfeitoria/sequence/sequence_wrapper.py
openxrlab/xrfeitoria
from .validations import Validator

__all__ = ['Validator']

xrfeitoria/utils/__init__.py
openxrlab/xrfeitoria
"""
From:
https://github.com/Textualize/rich/blob/master/examples/downloader.py

A rudimentary URL downloader (like wget or curl) to demonstrate Rich progress bars.
"""

from functools import partial
from pathlib import Path
from urllib.request import urlopen

from loguru import logger
from rich.progress import BarColumn, DownloadColumn, Progress, TextColumn, TimeRemainingColumn, TransferSpeedColumn


def download(url: str, dst_dir: Path, verbose: bool = True) -> Path:
    """Download file to the given directory.

    Args:
        url (str): The URL to download.
        dst_dir (Path): The directory to save the file to.
        verbose (bool, optional): Whether to print progress. Defaults to True.

    Returns:
        Path: The path to the downloaded file.
    """
    dst_dir = Path(dst_dir).resolve()
    dst_dir.mkdir(parents=True, exist_ok=True)
    filename = url.split('/')[-1]
    dst_path = Path(dst_dir).resolve() / filename
    if dst_path.exists():
        logger.debug(f'{dst_path.as_posix()} already exists.')
        return dst_path

    try:
        progress = Progress(
            TextColumn('[bold blue]{task.fields[filename]}', justify='right'),
            BarColumn(bar_width=None),
            '[progress.percentage]{task.percentage:>3.1f}%',
            '•',
            DownloadColumn(),
            '•',
            TransferSpeedColumn(),
            '•',
            TimeRemainingColumn(),
            disable=not verbose,
        )
        with progress:
            task_id = progress.add_task('download', filename=filename, start=False)
            logger.info(f'Requesting {url} to "{dst_path.as_posix()}"')
            response = urlopen(url)
            # This will break if the response doesn't contain content length
            progress.update(task_id, total=int(response.info()['Content-length']))
            with open(dst_path, 'wb') as dst_file:
                progress.start_task(task_id)
                # logger.info(f'Downloading to "{dst_path.as_posix()}"')
                for data in iter(partial(response.read, 32768), b''):
                    dst_file.write(data)
                    progress.update(task_id, advance=len(data))
            logger.info(f'Downloaded "{dst_path.as_posix()}"')

    except KeyboardInterrupt:
        logger.info('[red]Cancelling download...[/red]')
        logger.info('[red]Deleting incomplete download...[/red]')
        dst_path.unlink(missing_ok=True)
        exit(1)

    return dst_path


if __name__ == '__main__':
    url = 'https://releases.ubuntu.com/20.04/ubuntu-20.04.6-desktop-amd64.iso'
    download(url, './')

xrfeitoria/utils/downloader.py
openxrlab/xrfeitoria
"""Remote functions for blender."""

from pathlib import Path
from typing import List, Literal, Tuple

from ...data_structure.constants import ImportFileFormatEnum, PathLike, Vector
from ...rpc import remote_blender

try:
    # only for linting, not imported in runtime
    import bpy
    from XRFeitoriaBpy import logger  # defined in src/XRFeitoriaBpy/__init__.py
    from XRFeitoriaBpy.core.factory import XRFeitoriaBlenderFactory  # defined in src/XRFeitoriaBpy/core/factory.py
except ImportError:
    pass


@remote_blender()
def render_viewport(animation: bool = False) -> None:
    """Render the current viewport. Disable multiview when rendering, and restore it
    after rendering.

    Args:
        animation (bool, optional): Render animation. Defaults to False.
    """
    # disable multiview to avoid error
    _multiview = bpy.context.scene.render.use_multiview
    bpy.context.scene.render.use_multiview = False

    if bpy.app.background:
        bpy.ops.render.render(animation=animation)
    else:
        bpy.ops.render.render('INVOKE_DEFAULT', animation=animation)

    # restore multiview
    bpy.context.scene.render.use_multiview = _multiview


@remote_blender()
def import_file(file_path: 'PathLike') -> None:
    """Import file to blender. This function will not return an instance of the imported actor.
    The file type is determined by the file extension.
    Supported file types: fbx, obj, abc, ply, stl.

    Note:
        For fbx file, only support binary format. ASCII format is not supported.
        Ref: https://docs.blender.org/manual/en/3.6/addons/import_export/scene_fbx.html#id4
    """
    file_type = Path(file_path).suffix[1:].lower()  # remove dot, lower case
    try:
        file_type = ImportFileFormatEnum[file_type]
    except KeyError:
        raise TypeError(f'File type {file_type} not supported')

    if file_type == ImportFileFormatEnum.fbx:
        XRFeitoriaBlenderFactory.import_fbx(fbx_file=file_path)
    elif file_type == ImportFileFormatEnum.obj:
        XRFeitoriaBlenderFactory.import_obj(obj_file=file_path)
    elif file_type == ImportFileFormatEnum.abc:
        XRFeitoriaBlenderFactory.import_alembic(alembic_file=file_path)
    elif file_type == ImportFileFormatEnum.ply:
        XRFeitoriaBlenderFactory.import_ply(ply_file=file_path)
    elif file_type == ImportFileFormatEnum.stl:
        XRFeitoriaBlenderFactory.import_stl(stl_file=file_path)
    elif file_type == ImportFileFormatEnum.glb:
        XRFeitoriaBlenderFactory.import_glb(glb_file=file_path)


@remote_blender()
def is_background_mode(warning: bool = False) -> bool:
    """Check whether Blender is running in background mode.

    Returns:
        bool: True if Blender is running in background mode.
    """
    _mode = bpy.app.background
    if warning and _mode:
        from XRFeitoriaBpy import logger

        logger.warning('Properties (location, rotation, scale, etc.) cannot be guaranteed in background mode.')
    return _mode


@remote_blender()
def cleanup_unused():
    """Cleanup all the unused data in the current blend file."""
    bpy.ops.outliner.orphans_purge(do_local_ids=True)


@remote_blender()
def save_blend(save_path: 'PathLike' = None, pack: bool = False):
    """Save the current blend file to the given path.

    Args:
        save_path (PathLike, optional): Path to save the blend file. Defaults to None.
        pack (bool, optional): Automatically pack all external data into .blend file. Defaults to False.

    Raises:
        Exception: Failed to set autopack.
    """
    try:
        bpy.data.use_autopack = pack
    except Exception as e:
        raise Exception(f'Failed to set autopack: {e}')

    # fallback to current file path
    if save_path is None:
        save_path = bpy.data.filepath

    # set suffix to .blend
    save_path = Path(save_path).resolve()
    if save_path.suffix != '.blend':
        save_path = save_path.with_suffix('.blend')

    save_path.parent.mkdir(parents=True, exist_ok=True)
    bpy.ops.wm.save_as_mainfile(filepath=save_path.as_posix())


@remote_blender()
def set_env_color(color: 'Tuple[float, float, float, float]', intensity: float = 1.0):
    """Set the color of the environment light.

    Args:
        color (Tuple[float, float, float, float]): RGBA color of the environment light.
        intensity (float, optional): Intensity of the environment light. Defaults to 1.0.
    """
    scene = XRFeitoriaBlenderFactory.get_active_scene()
    scene_world = bpy.data.worlds.new(name=scene.name)
    scene.world = scene_world
    scene.world.use_nodes = True
    node = scene.world.node_tree.nodes['Background']
    node.inputs['Color'].default_value = color
    node.inputs['Strength'].default_value = intensity


@remote_blender()
def set_hdr_map(hdr_map_path: 'PathLike') -> None:
    """Set HDR map of the active scene.

    Args:
        hdr_map_path ('PathLike'): Path of the HDR map file.
    """
    scene = XRFeitoriaBlenderFactory.get_active_scene()
    XRFeitoriaBlenderFactory.set_hdr_map(scene=scene, hdr_map_path=hdr_map_path)


@remote_blender()
def get_frame_range() -> 'Tuple[int, int]':
    """Get the frame range of the active scene.

    Returns:
        Tuple[int, int]: Start frame and End frame.
    """
    scene = XRFeitoriaBlenderFactory.get_active_scene()
    return XRFeitoriaBlenderFactory.get_frame_range(scene)


@remote_blender()
def set_frame_range(start: int, end: int) -> None:
    """Set frame range of the active scene.

    Args:
        start (int): Start frame.
        end (int): End frame.
    """
    logger.info(f'Override frame range to [{start}, {end}]')
    scene = XRFeitoriaBlenderFactory.get_active_scene()
    XRFeitoriaBlenderFactory.set_frame_range(scene=scene, start=start, end=end)


@remote_blender()
def set_frame_current(frame: int) -> None:
    """Set current frame of the active scene.

    Args:
        frame (int): Frame number.
    """
    scene = XRFeitoriaBlenderFactory.get_active_scene()
    XRFeitoriaBlenderFactory.set_frame_current(scene=scene, frame=frame)


@remote_blender()
def get_keys_range() -> 'Tuple[int, int]':
    """Get the max keyframe range of all the objects in the active scene.

    Returns:
        Tuple[int, int]: Start frame and End frame.
    """
    scene = XRFeitoriaBlenderFactory.get_active_scene()
    return XRFeitoriaBlenderFactory.get_keys_range(scene)


@remote_blender()
def get_all_object_in_seq(seq_name: str, obj_type: 'Literal["MESH", "ARMATURE", "CAMERA"]') -> 'List[str]':
    """Get all the objects in the given sequence.

    Args:
        seq_name (str): Name of the sequence.
        obj_type (Literal[&quot;MESH&quot;, &quot;ARMATURE&quot;, &quot;CAMERA&quot;]): Object type.

    Returns:
        List[str]: List of object names.
    """
    return XRFeitoriaBlenderFactory.get_all_object_in_seq(seq_name=seq_name, obj_type=obj_type)


@remote_blender()
def get_all_object_in_current_level(obj_type: 'Literal["MESH", "ARMATURE", "CAMERA"]') -> 'List[str]':
    """Get all the objects in the current level.

    Args:
        obj_type (Literal["MESH";, "ARMATURE";, "CAMERA";]): Object type.

    Returns:
        List[str]: List of object names.
    """
    return XRFeitoriaBlenderFactory.get_all_object_in_current_level(obj_type=obj_type)


@remote_blender()
def new_level(name: str) -> None:
    """Create a new level.

    Args:
        name (str): Name of the level.
    """
    level_scene = XRFeitoriaBlenderFactory.new_scene(name)
    level_collection = XRFeitoriaBlenderFactory.new_collection(name)
    XRFeitoriaBlenderFactory.link_collection_to_scene(level_collection, level_scene)


@remote_blender()
def export_vertices(export_path: 'PathLike', use_animation: bool = True) -> None:
    """Export vertices in the world space of all objects in the active scene to npz
    file, with animation (export N frame) if use_animation is True. export_path will be
    an npz file with a single key 'verts' which is a numpy array in shape of (N, V, 3),
    where N is the number of frames, V is the number of vertices.

    Args:
        export_path (PathLike): Path to export vertices. (Require a folder)
        use_animation (bool, optional): Export with animation. Defaults to True.
    """
    scene = XRFeitoriaBlenderFactory.get_active_scene()
    XRFeitoriaBlenderFactory.export_vertices(scene=scene, export_path=export_path, use_animation=use_animation)


@remote_blender()
def get_rotation_to_look_at(location: 'Vector', target: 'Vector') -> 'Vector':
    """Get the rotation of an object to look at another object.

    Args:
        location (Vector): Location of the object.
        target (Vector): Location of the target object.

    Returns:
        Vector: Rotation of the object.
    """
    import math

    rotation = XRFeitoriaBlenderFactory.get_rotation_to_look_at(location=location, target=target)
    return tuple(math.degrees(r) for r in rotation)


@remote_blender()
def init_scene_and_collection(name: str, cleanup: bool = False) -> None:
    """Init the default scene and default collection.

    Args:
        name (str): Name of the default scene and default collection.
        cleanup (bool, optional): Clean up the all the scenes, collections and objects. Defaults to False.
    """
    if cleanup:
        XRFeitoriaBlenderFactory.delete_all()
        cleanup_unused()
    # get or create collection
    if name not in bpy.data.collections.keys():
        _collection = XRFeitoriaBlenderFactory.new_collection(name)
    else:
        _collection = XRFeitoriaBlenderFactory.get_collection(name)

    # get or create scene
    if name not in bpy.data.scenes.keys():
        if cleanup:
            _scene = XRFeitoriaBlenderFactory.get_active_scene()
            _scene.name = name
        else:
            _scene = XRFeitoriaBlenderFactory.new_scene(name=name)
    else:
        _scene = XRFeitoriaBlenderFactory.get_scene(name)

    # link collection to scene
    XRFeitoriaBlenderFactory.link_collection_to_scene(_collection, _scene)
    # set scene and collection active
    XRFeitoriaBlenderFactory.set_scene_active(_scene)
    XRFeitoriaBlenderFactory.set_collection_active(_collection)


@remote_blender()
def enable_gpu(gpu_num: int = 1):
    """Enable GPU in blender.

    Args:
        gpu_num (int, optional): Number of GPUs to use. Defaults to 1.
    """
    XRFeitoriaBlenderFactory.enable_gpu(gpu_num=gpu_num)

xrfeitoria/utils/functions/blender_functions.py
openxrlab/xrfeitoria
"""Remote functions for unreal."""

from typing import List, Optional, Tuple, Union

from ...data_structure.constants import Vector
from ...rpc import remote_unreal

try:
    import unreal
    from unreal_factory import XRFeitoriaUnrealFactory  # defined in src/XRFeitoriaUnreal/Content/Python
except ImportError:
    pass


@remote_unreal()
def get_mask_color(stencil_value: int) -> 'Tuple[int, int, int]':
    """Get mask color from stencil value.

    Args:
        stencil_value (int): stencil value

    Returns:
        Tuple[int, int, int]: mask color. (r, g, b) in [0, 255]
    """
    # TODO: move this to local, not remote
    return XRFeitoriaUnrealFactory.utils_actor.get_mask_color(stencil_value)


@remote_unreal()
def check_asset_in_engine(path: str, raise_error: bool = False) -> bool:
    """Check if an asset exists in the engine.

    Args:
        path (str): asset path in unreal, e.g. "/Game/Resources/Mat0"
        raise_error (bool): raise error if the asset does not exist

    Returns:
        bool: True if the asset exists, False otherwise

    Raises:
        ValueError: if the asset does not exist and raise_error is True
    """
    is_exist = unreal.EditorAssetLibrary.does_asset_exist(path)
    if not is_exist and raise_error:
        raise ValueError(f'Asset `{path}` does not exist')
    return is_exist


@remote_unreal()
def open_level(asset_path: str) -> None:
    """Open a level.

    Args:
        asset_path (str): asset path in unreal, e.g. "/Game/Maps/Map0"
    """
    check_asset_in_engine(asset_path, raise_error=True)
    XRFeitoriaUnrealFactory.SubSystem.EditorLevelSub.load_level(asset_path)


@remote_unreal()
def save_current_level(asset_path: 'Optional[str]' = None) -> None:
    """Save the current opened level."""
    if asset_path is not None:
        # BUG: save_map to a new path does not work
        world = XRFeitoriaUnrealFactory.SubSystem.EditorLevelSub.get_world()
        unreal.EditorLoadingAndSavingUtils.save_map(world, asset_path)
    else:
        XRFeitoriaUnrealFactory.SubSystem.EditorLevelSub.save_current_level()


@remote_unreal()
def import_asset(path: 'Union[str, List[str]]', dst_dir_in_engine: 'Optional[str]' = None) -> 'Union[str, List[str]]':
    """Import assets to the default asset path.

    Args:
        path (Union[str, List[str]]): a file path or a list of file paths to import, e.g. "D:/assets/SMPL_XL.fbx"
        dst_dir_in_engine (Optional[str], optional): destination directory in the engine.
            Defaults to None falls back to '/Game/XRFeitoriaUnreal/Assets'

    Returns:
        Union[str, List[str]]: a path or a list of paths to the imported assets, e.g. "/Game/XRFeitoriaUnreal/Assets/SMPL_XL"
    """
    paths = XRFeitoriaUnrealFactory.utils.import_asset(path, dst_dir_in_engine)
    if len(paths) == 1:
        return paths[0]
    return paths


@remote_unreal()
def import_anim(path: str, skeleton_path: str) -> 'List[str]':
    """Import animation to the default asset path.

    Args:
        path (str): a file path to import, e.g. "D:/assets/SMPL_XL-Animation.fbx"
        skeleton_path (str): a path to the skeleton, e.g. "/Game/XRFeitoriaUnreal/Assets/SMPL_XL_Skeleton"

    Returns:
        str: a path to the imported animation, e.g. "/Game/XRFeitoriaUnreal/Assets/SMPL_XL-Animation"
    """
    return XRFeitoriaUnrealFactory.utils.import_anim(path, skeleton_path)


@remote_unreal()
def duplicate_asset(src_path: str, dst_path: str, replace: bool = False) -> None:
    """Duplicate asset in unreal.

    Args:
        src_path (str): source asset path in unreal, e.g. "/Game/Resources/Mat0"
        dst_path (str): destination asset path in unreal, e.g. "/Game/Resources/Mat1"
    """
    check_asset_in_engine(src_path, raise_error=True)
    if check_asset_in_engine(dst_path) and replace:
        delete_asset(dst_path)
    unreal.EditorAssetLibrary.duplicate_asset(
        source_asset_path=src_path,
        destination_asset_path=dst_path,
    )
    check_asset_in_engine(dst_path, raise_error=True)
    unreal.EditorAssetLibrary.save_asset(dst_path)


@remote_unreal()
def delete_asset(asset_path: str) -> None:
    """Delete asset.

    Args:
        asset_path (str): asset path in unreal, e.g. "/Game/Resources/Mat0"
    """
    XRFeitoriaUnrealFactory.utils.delete_asset(asset_path)


@remote_unreal()
def open_asset(asset_path: str) -> None:
    """Open asset.

    Args:
        asset_path (str): asset path in unreal, e.g. "/Game/Resources/Mat0"
    """
    XRFeitoriaUnrealFactory.utils.open_asset(asset_path)


@remote_unreal()
def get_rotation_to_look_at(location: 'Vector', target: 'Vector') -> 'Vector':
    """Get the rotation of an object to look at another object.

    Args:
        location (Vector): Location of the object.
        target (Vector): Location of the target object.

    Returns:
        Vector: Rotation of the object.
    """
    target = unreal.Vector(x=target[0], y=target[1], z=target[2])
    forward = target - location
    z = unreal.Vector(0, 0, -1)
    right = forward.cross(z)
    up = forward.cross(right)
    rotation = unreal.MathLibrary.make_rotation_from_axes(forward, right, up)
    return rotation.to_tuple()

xrfeitoria/utils/functions/unreal_functions.py
openxrlab/xrfeitoria
"""Project 3D points to 2D points, and draw 3D points on image, using XRPrimer
camera."""


from typing import Optional, Tuple, Union

import numpy as np

from ..camera.camera_parameter import CameraParameter


def project_points3d(points3d: np.ndarray, camera_param: CameraParameter) -> np.ndarray:
    """Project 3D point to 2D point.

    Args:
        point3d (np.ndarray): [N, 3] points to project, where N is the number of points,
            and 3 is the location of each point. In convention of 'opencv'.
        camera_param (PinholeCameraParameter): camera parameter in convention of xrprimer

    Returns:
        np.ndarray: [N, 2] projected 2d points, dtype=np.float32
    """
    points3d = np.concatenate([points3d, np.ones((points3d.shape[0], 1))], axis=1)  # [N, 4]

    # convert to opencv convention, and cam2world
    _camera_param = camera_param.clone()
    if _camera_param.world2cam:
        _camera_param.inverse_extrinsic()
    if _camera_param.convention != 'opencv':
        _camera_param.convert_convention(dst='opencv')
    point2d = _camera_param.projection_matrix @ points3d.T  # [3, N]
    point2d = point2d[:2] / point2d[2]  # [2, N]
    return point2d.T


def points2d_to_canvas(points2d: np.ndarray, resolution: Tuple[int, int]) -> np.ndarray:
    """Draw 2d points on canvas. The canvas is a binary image, where 1 means the points
    are drawn.

    Args:
        points2d (np.ndarray): [N, 2] points to draw
        resolution (Tuple[int, int]): [height, width] of the canvas

    Returns:
        np.ndarray: [height, width] binary image, where 1 means the points are drawn, dtype=np.bool
    """
    height, width = resolution
    x = points2d[:, 1].astype('int').clip(0, height - 1)
    y = points2d[:, 0].astype('int').clip(0, width - 1)
    canvas = np.zeros((height, width), dtype=bool)
    canvas[x, y] = True
    return canvas


def draw_points3d(
    points3d: np.ndarray,
    camera_param: CameraParameter,
    image: Optional[np.ndarray] = None,
    color: Tuple[int, int, int] = (255, 0, 0),
) -> np.ndarray:
    """Draw 3d points on canvas. The 3d points will be projected to 2d points first.
    Then draw the 2d points on a canvas of the same size as the image. If image is not
    None, the canvas will be drawn on the image. Otherwise, the canvas will be returned
    which is a binary image, where 1 means the points are drawn.

    Args:
        point3d (np.ndarray): [N, 3] points to project, where N is the number of points,
            and 3 is the location of each point. In convention of opencv.
        camera_param (PinholeCameraParameter): camera parameter
        image (Optional[np.ndarray], optional): [height, width, channel]. Defaults to None.
            If not None, the canvas will be drawn on the image with the given color.
        color (Tuple[int, int, int], optional): color of the points. Defaults to (255, 0, 0).

    Returns:
        np.ndarray:
            If image is None, return a binary image [H, W], where 1 means the points are drawn, dtype=np.bool.
            Otherwise, return a image [H, W, C] with the points drawn on it, dtype=np.uint8.
    """
    points2d = project_points3d(points3d, camera_param)
    canvas = points2d_to_canvas(points2d, [camera_param.height, camera_param.width])
    if image is not None:
        if image.shape[-1] == 4:
            color += (255,)
        elif image.shape[-1] == 1:
            color = (color[0],)
        image[canvas] = color
        return image
    return canvas

xrfeitoria/utils/projector.py
openxrlab/xrfeitoria
"""Publish plugins to zip files.

>>> python -m xrfeitoria.utils.publish_plugins --unreal -s 0.5.0-Unreal5.2-Windows
>>> python -m xrfeitoria.utils.publish_plugins --blender -s 0.5.0-None-None
"""
from pathlib import Path
from typing import Literal, Optional

from loguru import logger

from .. import __version__
from ..data_structure.constants import PathLike, plugin_name_blender, plugin_name_unreal

root = Path(__file__).parent.resolve()
project_root = root.parents[1]


def _make_archive(
    plugin_folder: PathLike,
    zip_name: Optional[str] = None,
    folder_name: Optional[str] = None,
) -> Path:
    """Make archive of plugin folder.

    Args:
        plugin_folder (PathLike): path to plugin folder.
        zip_name (Optional[str], optional): name of the archive file.
            E.g. dst_name='plugin', the archive file would be ``plugin.zip``.
            Defaults to None, fallback to {plugin_folder.name}.
    """
    import zipfile

    if zip_name is None:
        zip_name = plugin_folder.name
    if folder_name is None:
        folder_name = zip_name

    plugin_folder = Path(plugin_folder).resolve()
    plugin_zip = plugin_folder.parent / f'{zip_name}.zip'
    if plugin_zip.exists():
        plugin_zip.unlink()

    filter_names = ['.git', '.idea', '.vscode', '.gitignore', '.DS_Store', '__pycache__', 'Intermediate']
    with zipfile.ZipFile(plugin_zip, 'w', compression=zipfile.ZIP_DEFLATED) as zipf:
        for file in plugin_folder.rglob('*'):
            # filter
            if any([folder in file.parts for folder in filter_names]):
                continue

            # in zip, the folder name is the root folder
            # {folder_name}/a/b/c
            arcname = folder_name + '/' + file.relative_to(plugin_folder).as_posix()
            zipf.write(file, arcname=arcname)

    # plugin_folder = shutil.make_archive(plugin_zip.with_suffix(''), 'zip', plugin_folder.parent, plugin_folder.name)
    logger.debug(f'Compressed {plugin_folder} => {plugin_zip}')
    return plugin_zip


def main(engine: Literal['unreal', 'blender'], suffix: Optional[str] = None):
    if engine == 'blender':
        plugin_name = plugin_name_blender
        folder_name = plugin_name  # in zip, {XRFeitoriaBpy} is the root folder
    elif engine == 'unreal':
        # TODO: auto detect binaries
        plugin_name = plugin_name_unreal
        folder_name = None  # in zip, {XRFeitoriaUnreal-x.x.x-UE5.x-Windows} is the root folder

    dir_plugin = project_root / 'src' / plugin_name

    name = plugin_name
    if suffix is not None:
        name += f'-{suffix}'
    else:
        name += f'-{__version__}'
    plugin_zip = _make_archive(dir_plugin, zip_name=name, folder_name=folder_name)
    logger.info(f'Plugin for {engine}: {plugin_zip}')


if __name__ == '__main__':
    from typer import Option, run

    def wrapper(
        unreal: bool = Option(False, '--unreal', '-u', help='Build plugin for Unreal'),
        blender: bool = Option(False, '--blender', '-b', help='Build plugin for Blender'),
        suffix: str = Option(
            None,
            '--suffix',
            '-s',
            help='Suffix of the compressed file, e.g. "XRFeitoriaUnreal-{suffix}.zip". if None, use version number',
        ),
    ):
        if unreal:
            main(engine='unreal', suffix=suffix)
        if blender:
            main(engine='blender', suffix=suffix)

    run(wrapper)

xrfeitoria/utils/publish_plugins.py
openxrlab/xrfeitoria
"""Runner for starting blender or unreal as a rpc server."""
import json
import os
import platform
import re
import shutil
import subprocess
import threading
import time
from abc import ABC, abstractmethod
from bisect import bisect_left
from functools import lru_cache
from http.client import RemoteDisconnected
from pathlib import Path
from textwrap import dedent
from typing import Dict, Optional, Tuple
from urllib.error import HTTPError, URLError
from xmlrpc.client import ProtocolError

from loguru import logger
from packaging.version import parse
from rich import get_console
from rich.prompt import Confirm

from .. import __version__, _tls
from ..data_structure.constants import EngineEnum, PathLike, plugin_name_blender, plugin_name_unreal, tmp_dir
from ..rpc import BLENDER_PORT, UNREAL_PORT, remote_blender, remote_unreal
from .downloader import download
from .setup import get_exec_path

# XXX: hardcode download url
oss_root = 'https://openxrlab-share.oss-cn-hongkong.aliyuncs.com/xrfeitoria'
plugin_infos_json = Path(__file__).parent.resolve() / 'plugin_infos.json'


def _rmtree(path: Path) -> None:
    """Remove tmp output."""
    if path.is_symlink():
        path.unlink(missing_ok=True)
    elif path.is_file():
        path.unlink(missing_ok=True)
    elif path.is_dir():
        shutil.rmtree(path, ignore_errors=True)


def _get_user_addon_path(version: str) -> Path:
    """Get user addon path depending on platform.

    Args:
        version (str): blender version, e.g. '3.3'

    Returns:
        Path: user addon path
    """
    user_path = Path.home()
    if platform.system() == 'Windows':
        user_addon_path = user_path / f'AppData/Roaming/Blender Foundation/Blender/{version}/scripts/addons'
    elif platform.system() == 'Linux':
        user_addon_path = user_path / f'.config/blender/{version}/scripts/addons'
    elif platform.system() == 'Darwin':
        user_addon_path = user_path / f'Library/Application Support/Blender/{version}/scripts/addons'
    return user_addon_path


class RPCRunner(ABC):
    """A wrapper for starting blender as a rpc server."""

    def __init__(
        self,
        new_process: bool = False,
        engine_exec: Optional[PathLike] = None,
        project_path: PathLike = '',
        replace_plugin: bool = False,
        dev_plugin: bool = False,
        background: bool = True,
    ) -> None:
        """Initialize RPCRunner.

        Args:
            new_process (bool, optional): whether to start a new process. Defaults to False.
            engine_exec (Optional[PathLike], optional): path to engine executable. Defaults to None.
            project_path (PathLike, optional): path to project. Defaults to ''.
            replace_plugin (bool, optional): whether to replace the plugin installed for the engine. Defaults to False.
            dev_plugin (bool, optional): Whether to use the plugin under local directory.
                If False, would use the plugin downloaded from a remote server. Defaults to False.
            background (bool, optional): whether to run the engine in background without GUI. Defaults to True.
        """
        self.console = get_console()
        self.engine_type: EngineEnum = _tls.cache.get('platform', None)
        self.engine_process: Optional[subprocess.Popen] = None
        self.engine_running: bool = False
        self.new_process = new_process
        self.replace_plugin = replace_plugin
        self.dev_plugin = dev_plugin
        self.background = background
        self.debug = os.environ.get('RPC_DEBUG', '0') == '1'  # logger.level('DEBUG')

        if self.dev_plugin:
            self.replace_plugin = True
            logger.warning(
                '`dev_plugin=True` would force `replace_plugin=True`, replacing the plugin with local source code'
            )
        # Try to reuse existing engine process
        is_reuse = self.reuse()
        if is_reuse:
            return
        else:
            logger.debug('No existing engine process found, `new_process` is set to True to start a new process')
            self.new_process = True

        if not self.new_process and self.replace_plugin:
            logger.warning('`replace_plugin=True` will be ignored when `new_process=False`')
        # Initialize for new process
        if engine_exec:
            self.engine_exec = Path(engine_exec).resolve()
        else:
            self.engine_exec = get_exec_path(engine=self.engine_type)
        if not self.engine_exec.exists() or not self.engine_exec.is_file():
            raise FileExistsError(f'Engine executable not valid: {self.engine_exec}')
        logger.info(f'Using engine executable: "{self.engine_exec.as_posix()}"')
        if project_path:
            self.project_path = Path(project_path).resolve()
            assert self.project_path.exists(), (
                f'Project path is not valid: "{self.project_path.as_posix()}"\n'
                'Please check `xf.init_blender(project_path=...)` or `xf.init_unreal(project_path=...)`'
            )
            if self.engine_type == EngineEnum.blender:
                assert self.project_path.suffix == '.blend', (
                    f'Project path is not valid: "{self.project_path.as_posix()}"\n'
                    'Please use a blender project file (.blend) file as project path in `xf.init_blender(project_path=...)`'
                )
            elif self.engine_type == EngineEnum.unreal:
                assert self.project_path.suffix == '.uproject', (
                    f'Project path is not valid: "{self.project_path.as_posix()}"\n'
                    'Please use a unreal project file (.uproject) as project path in `xf.init_unreal(project_path=...)`'
                )
        else:
            assert (
                self.engine_type != EngineEnum.unreal
            ), 'Please specify a project path in `xf.init_unreal(project_path=...)` when using unreal engine'
            self.project_path = None
        self.engine_info: Tuple[str, str] = self._get_engine_info(self.engine_exec)

    @property
    def port(self) -> int:
        """Get RPC port depending on engine type."""
        if self.engine_type == EngineEnum.blender:
            return BLENDER_PORT
        elif self.engine_type == EngineEnum.unreal:
            return UNREAL_PORT
        else:
            raise NotImplementedError

    def __enter__(self) -> 'RPCRunner':
        self.start()
        return self

    def __exit__(self, exc_type, exc_value, traceback) -> None:
        self.stop()

    # deprecated, use `with` instead
    # def __del__(self) -> None:
    #     if self.engine_process is not None:
    #         self.stop()

    def stop(self) -> None:
        """Stop rpc server."""
        import psutil

        process = self.engine_process
        if process is not None:
            logger.info(':bell: [bold red]Exiting RPC Server[/bold red], killing engine process')
            self.engine_running = False
            for child in psutil.Process(process.pid).children(recursive=True):
                if child.is_running():
                    logger.debug(f'Killing child process {child.pid}')
                    child.kill()
            process.kill()
            self.engine_process = None
        else:
            logger.info(':bell: [bold red]Exiting runner[/bold red], reused engine process remains')

    def reuse(self) -> bool:
        """Try to reuse existing engine process.

        Returns:
            bool: whether the engine process is reused.

        Raises:
            RuntimeError: if `new_process=True` but an existing engine process is found.
        """
        try:
            with self.console.status('[bold green]Try to reuse existing engine process...[/bold green]'):
                self.test_connection(debug=self.debug)
            logger.info(':direct_hit: [bold cyan]Reuse[/bold cyan] existing engine process')
            # raise an error if new_process is True
            if self.new_process:
                raise RuntimeError(
                    f'RPC server in `RPC_PORT={self.port}` already started! '
                    'This is raised when calling `init_blender` or `init_unreal` with `new_progress=True`'
                    'when an existing server (blender or unreal) is already running. \n'
                    '3 ways to get around this: \n'
                    '   - set `new_process=False` for using the existing server. \n'
                    '   - stop the server (engine process) and try again; \n'
                    "   - change the rpc port via system env 'RPC_PORT' and try again."
                )
        except ConnectionRefusedError:
            return False

        return True

    @staticmethod
    def _popen(cmd: str) -> subprocess.Popen:
        """Execute command.

        Args:
            cmd (str): command to execute.

        Returns:
            subprocess.Popen: process
        """
        logger.debug(f'Start engine, executing command:\n{cmd}')
        process = subprocess.Popen(cmd, shell=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
        return process

    def start(self):
        """Start rpc server."""
        if not self.new_process:
            return

        with self.console.status('Initializing RPC server...') as status:
            status.update(status='[green bold]Installing plugin...')
            self._install_plugin()
            status.update(status=f'[green bold]Starting {" ".join(self.engine_info)} as RPC server...')
            self.engine_process = self._start_rpc(background=self.background, project_path=self.project_path)
            self.engine_running = True
            _tls.cache['engine_process'] = self.engine_process
        logger.info(f'RPC server started at port {self.port}')

        # check if engine process is alive in a separate thread
        threading.Thread(target=self.check_engine_alive, daemon=True).start()

    def check_engine_alive(self) -> bool:
        """Check if the engine process is alive."""
        while self.engine_running:
            if self.engine_process.poll() is not None:
                logger.error(self.get_process_output(self.engine_process))
                logger.error('[red]RPC server stopped unexpectedly, check the engine output above[/red]')
                os._exit(1)  # exit main thread
            time.sleep(1)

    @staticmethod
    def get_process_output(process: subprocess.Popen) -> str:
        """Get process output when process is exited with non-zero code."""
        out = (
            f'Engine process exited with code {process.poll()}\n\n'
            '>>>> Engine output >>>>\n\n'
            f'{process.stdout.read().decode("utf-8")}'
            '\n\n<<<< Engine output <<<<\n'
        )
        return out

    def wait_for_start(self, process: subprocess.Popen) -> None:
        """Wait 3 minutes for RPC server to start.

        After 3 minutes, ask user if they want to quit if it takes too long.

        Args:
            process (subprocess.Popen): process to wait for.
        """
        tryout_time = 180  # 3 minutes
        tryout_sec = 1
        tryout_num = tryout_time // tryout_sec

        _num = 0
        while True:
            if process.poll() is not None:
                logger.error(self.get_process_output(process))
                raise RuntimeError('RPC server failed to start. Check the engine output above.')
            try:
                self.test_connection(debug=self.debug)
                break
            except (RemoteDisconnected, ConnectionRefusedError, ProtocolError):
                logger.debug(f'Waiting for RPC server to start [tryout {_num}/{tryout_num}]')
                _num += 1
                time.sleep(tryout_sec)  # wait for 5 seconds
            if _num >= tryout_num:  # 3 minutes
                _quit = Confirm.ask(
                    f'{" ".join(self.engine_info)} loading seems to take a long time, do you want to quit?',
                    default=False,
                )
                if _quit:
                    self.stop()
                else:
                    _num = 0  # reset tryout times, wait for another 3 minutes

    @abstractmethod
    def get_src_plugin_path(self) -> Path:
        """Get plugin source path, including downloading or symlinking/copying from
        local directory.

        priority:
            if `self.dev_plugin=False`: download from url > build from source

            if `self.dev_plugin=True`: build from source

        Returns:
            Path: plugin source path
        """
        pass

    @property
    @lru_cache
    def dst_plugin_dir(self) -> Path:
        """Get plugin directory."""
        if self.engine_type == EngineEnum.blender:
            dst_plugin_dir = _get_user_addon_path(version=self.engine_info[1]) / plugin_name_blender
        elif self.engine_type == EngineEnum.unreal:
            dst_plugin_dir = self.project_path.parent / 'Plugins' / plugin_name_unreal
        else:
            raise NotImplementedError
        dst_plugin_dir.parent.mkdir(exist_ok=True, parents=True)
        return dst_plugin_dir

    def _download(self, url: str, dst_dir: Path) -> Path:
        """Check if the url is valid and download the plugin to the given directory."""
        try:
            dst_path = download(url=url, dst_dir=dst_dir, verbose=False)
        except HTTPError as e:
            raise HTTPError(
                url=e.url,
                code=e.code,
                msg=(
                    'Failed to download plugin.\n'
                    f'Sorry, pre-built plugin for {"".join(self.engine_info)} in {platform.system()} is not supported.\n'
                    'Set `dev_plugin=True` in init_blender/init_unreal to build the plugin from source.\n'
                    'Clone the source code from https://github.com/openxrlab/xrfeitoria.git'
                ),
                hdrs=e.hdrs,
                fp=e.fp,
            )
        except URLError:
            raise RuntimeError(
                'Network unreachable. Please check your internet connection and try again later.\n'
                f'Or manually download the plugin from {url} and put it in "{dst_dir.as_posix()}"\n'
            )
        except Exception as e:
            raise RuntimeError(f'Failed to download plugin from {url} to "{dst_dir.as_posix()}"\n{e}')
        return dst_path

    # ----- abstracts ----- #
    ##########################

    @staticmethod
    @abstractmethod
    def _get_engine_info(engine_exec: Path) -> Tuple[str, str]:
        """Get engine type and version.

        Args:
            engine_exec (Path): path to engine executable.

        Returns:
            Tuple[str, str]: engine type and version.
        """
        pass

    @abstractmethod
    def _get_cmd(self) -> str:
        pass

    @abstractmethod
    def _start_rpc(self, background: bool = True, project_path: Optional[Path] = '') -> None:
        pass

    def _get_plugin_url(self) -> Optional[str]:
        # plugin_infos = { "0.5.0": { "XRFeitoria": "0.5.0", "XRFeitoriaBpy": "0.5.0", "XRFeitoriaUnreal": "0.5.0" }, ... }
        plugin_infos: Dict[str, Dict[str, str]] = json.loads(plugin_infos_json.read_text())
        plugin_versions = sorted((map(parse, plugin_infos.keys())))
        _version = parse(__version__)

        # find compatible version, lower bound, e.g. 0.5.1 => 0.5.0
        if _version in plugin_versions:
            compatible_version = _version
        else:
            _idx = bisect_left(plugin_versions, parse(__version__)) - 1
            compatible_version = plugin_versions[_idx]
        logger.debug(f'Compatible plugin version: {compatible_version}')

        # get link
        if self.engine_type == EngineEnum.unreal:
            _plugin_name = plugin_name_unreal
            _platform = f'{"".join(self.engine_info)}-{platform.system()}'  # e.g. Unreal5.1-Windows
        elif self.engine_type == EngineEnum.blender:
            _plugin_name = plugin_name_blender
            _platform = 'None-None'
        _plugin_version = plugin_infos[str(compatible_version)][_plugin_name]
        # e.g. https://openxrlab-share.oss-cn-hongkong.aliyuncs.com/xrfeitoria/plugins/XRFeitoriaBpy-0.5.0-None-None.zip
        # e.g. https://openxrlab-share.oss-cn-hongkong.aliyuncs.com/xrfeitoria/plugins/XRFeitoriaUnreal-0.5.0-Unreal5.1-Windows.zip
        return f'{oss_root}/plugins/{_plugin_name}-{_plugin_version}-{_platform}.zip'

    def _install_plugin(self) -> None:
        """Install plugin."""
        if self.dst_plugin_dir.exists():
            if not self.replace_plugin:
                logger.debug(f'Plugin "{self.dst_plugin_dir.as_posix()}" already exists')
                return
            elif self.engine_type == EngineEnum.unreal:
                logger.warning(f'Removing existing plugin "{self.dst_plugin_dir.as_posix()}"')
                _rmtree(self.dst_plugin_dir)

        src_plugin_path = self.get_src_plugin_path()
        logger.info(f'Installing plugin from "{src_plugin_path.as_posix()}" to "{self.dst_plugin_dir.as_posix()}"')

        if self.engine_type == EngineEnum.blender:
            _script = f"""
                import bpy;
                bpy.ops.preferences.addon_install(filepath='{src_plugin_path.as_posix()}');
                bpy.ops.preferences.addon_enable(module='{plugin_name_blender}');
                bpy.context.preferences.view.show_splash = False;
                bpy.ops.wm.save_userpref()"""

            cmd = self._get_cmd(python_scripts=dedent(_script).replace('\n', ''))
            process = self._popen(cmd)
            _code = process.wait()
            if _code != 0:
                logger.error(self.get_process_output(process))
                raise RuntimeError('Failed to install plugin for blender. Check the engine output above.')

        elif self.engine_type == EngineEnum.unreal:
            try:
                self.dst_plugin_dir.symlink_to(src_plugin_path, target_is_directory=True)
                logger.debug(f'Symlink "{src_plugin_path.as_posix()}" => "{self.dst_plugin_dir.as_posix()}"')
            except (OSError, PermissionError):
                logger.warning(
                    'Failed to create symlink: [u]permission denied[/u]. '
                    'Please consider running on [u]administrator[/u] mode. '
                    'Fallback to copy the plugin.'
                )
                shutil.copytree(src=src_plugin_path, dst=self.dst_plugin_dir)
                logger.debug(f'Copy "{src_plugin_path.as_posix()}" => "{self.dst_plugin_dir.as_posix()}"')

        logger.info(f'Plugin installed in "{self.dst_plugin_dir.as_posix()}"')

    @staticmethod
    @abstractmethod
    def test_connection(debug: bool = False) -> None:
        pass


class BlenderRPCRunner(RPCRunner):
    def get_src_plugin_path(self) -> Path:
        """Get plugin source zip path."""
        if self.dev_plugin:
            from .publish_plugins import _make_archive

            src_plugin_dir = Path(__file__).resolve().parents[2] / 'src' / plugin_name_blender
            src_plugin_path = _make_archive(src_plugin_dir)
        else:
            url = self._get_plugin_url()
            src_plugin_root = tmp_dir / 'plugins'
            src_plugin_path = src_plugin_root / Path(url).name  # with suffix (.zip)
            if src_plugin_path.exists():
                logger.debug(f'Downloaded Plugin "{src_plugin_path.as_posix()}" exists')
                return src_plugin_path

            plugin_path = self._download(url=url, dst_dir=src_plugin_root)
            if plugin_path != src_plugin_path:
                shutil.move(plugin_path, src_plugin_path)
        return src_plugin_path

    @staticmethod
    def _get_engine_info(engine_exec: Path) -> Tuple[str, str]:
        _version = None
        if platform.system() == 'Darwin':
            root_dir = engine_exec.parent.parent / 'Resources'
        else:
            root_dir = engine_exec.parent
        for file in root_dir.iterdir():
            if not file.is_dir():
                continue
            # e.g. 3.3, 2.93
            if re.match(r'\d+\.\d+', file.name):
                _version = file.name
                break

        if _version is None:
            raise FileNotFoundError(f'Cannot find blender executable in {engine_exec.parent}')

        return 'Blender', _version

    def _get_cmd(
        self,
        background: bool = True,
        project_path: Optional[Path] = '',
        python_scripts: Optional[str] = '',
    ) -> str:
        """Get blender command line.

        Args:
            background (bool, optional): Run blender in background without GUI. Defaults to True.
            project_path (Optional[Path], optional): Path to blender project. Defaults to ''.
            python_scripts (Optional[str], optional): Python scripts to run when blender starts. Defaults to ''.

        Returns:
            str: _description_
        """
        cmd = [
            f'"{self.engine_exec}"',
            '--background' if background else '',
            f'"{project_path}"' if project_path else '',
            '--python-exit-code 1',
            f'--python-expr "{python_scripts}"' if python_scripts else '',
        ]
        return ' '.join(map(str, cmd))

    def _start_rpc(self, background: bool = True, project_path: Optional[Path] = '') -> subprocess.Popen:
        """Start rpc server.

        Args:
            background (bool, optional): Run blender in background without GUI. Defaults to True.
            project_path (Optional[Path], optional): Path to blender project. Defaults to ''.

        Returns:
            subprocess.Popen: process of the engine started as rpc server.
        """
        # logger.info(f'Starting {" ".join(self.engine_info)} with RPC server at port {self.port}')

        # run server in blocking mode if background
        rpc_scripts = f'import bpy; bpy.ops.wm.start_rpc_servers(block={background}, port={self.port})'
        cmd = self._get_cmd(background=background, project_path=project_path, python_scripts=rpc_scripts)
        process = self._popen(cmd)

        self.wait_for_start(process=process)
        # logger.info(f'Started {" ".join(self.engine_info)} with RPC server at port {self.port}')
        return process

    @staticmethod
    @remote_blender(default_imports=[])
    def test_connection(debug: bool = False) -> bool:
        """Test connection."""
        try:
            from XRFeitoriaBpy import utils_logger

            level = 'DEBUG' if debug else 'INFO'
            _logger = utils_logger.setup_logger(level=level)
            _logger.debug('Connection test passed')
        except Exception:
            pass


class UnrealRPCRunner(RPCRunner):
    """UnrealRPCRunner."""

    def get_src_plugin_path(self) -> Path:
        """Get plugin source directory."""
        if self.dev_plugin:
            src_plugin_path = Path(__file__).resolve().parents[2] / 'src' / plugin_name_unreal
            if not src_plugin_path.exists():
                raise FileNotFoundError(
                    f'Plugin source code not found in {src_plugin_path}, '
                    'please set `dev_plugin=False` to download the pre-built plugin. '
                    'Or clone the source code and build the plugin from source. '
                    'https://github.com/openxrlab/xrfeitoria.git'
                )
        else:
            url = self._get_plugin_url()
            src_plugin_root = tmp_dir / 'plugins'
            src_plugin_compress = src_plugin_root / Path(url).name  # with suffix (.zip)
            src_plugin_path = src_plugin_compress.with_suffix('')  # without suffix (.zip)
            if src_plugin_path.exists():
                logger.debug(f'Downloaded Plugin "{src_plugin_path.as_posix()}" exists')
                return src_plugin_path
            elif src_plugin_compress.exists():
                logger.debug(f'Downloaded Plugin "{src_plugin_compress.as_posix()}" exists')
                shutil.unpack_archive(src_plugin_compress, src_plugin_root)
                assert src_plugin_path.exists(), f'Failed to unzip {src_plugin_compress} to {src_plugin_path}'
                return src_plugin_path

            plugin_compress = self._download(url=url, dst_dir=src_plugin_root)
            shutil.unpack_archive(plugin_compress, src_plugin_root)
            assert src_plugin_path.exists(), f'Failed to download plugin to {src_plugin_path}'
        return src_plugin_path

    @staticmethod
    def _get_engine_info(engine_exec: Path) -> Tuple[str, str]:
        try:
            _version = re.findall(r'UE_(\d+\.\d+)', engine_exec.as_posix())[0]
        except IndexError:
            raise FileNotFoundError(f'Cannot find unreal executable in {engine_exec}')
        return 'Unreal', _version

    def _get_cmd(
        self,
        project_path: PathLike,
        background: bool = True,
        # python_scripts: Optional[str] = "",
        rpc_port: int = 9998,
    ) -> str:
        """Get unreal command line.

        Args:
            project_path (PathLike): Path to unreal project.
            background (bool, optional): Run unreal in background without GUI. Defaults to True.
            rpc_port (int, optional): RPC port. Defaults to 9998.

        Returns:
            str: _description_
        """
        assert not background, NotImplementedError('UnrealRPCRunner only support foreground mode for now')
        rpc_scripts = 'import unreal; bootstrap_unreal_with_rpc_server(block=True)'
        cmd = [
            f'"{self.engine_exec}"',
            f'"{project_path}"',
            f'-run=pythonscript -script="{rpc_scripts}"' if background else '',
            # f'-ExecutePythonScript="{python_script_path}"',
            # f'-ExecCmds="py {python_script_path}"',
            f'-rpc_port={rpc_port}',
            '-notexturestreaming',
            '-silent',
            'LOG=Pipeline.log',
            '-LOCALLOGTIMES',
        ]
        return ' '.join(map(str, cmd))

    def _start_rpc(self, background: bool = True, project_path: Optional[Path] = '') -> subprocess.Popen:
        """Start rpc server.

        Args:
            background (bool, optional): Run unreal in background without GUI. Defaults to True.
            project_path (Optional[Path], optional): Path to unreal project. Defaults to ''.

        Returns:
            subprocess.Popen: process of the engine started as rpc server.
        """
        # logger.info(f'Starting {" ".join(self.engine_info)} with RPC server at port {self.port}')

        # run server in blocking mode if background
        cmd = self._get_cmd(
            background=background,
            project_path=project_path,
            rpc_port=self.port,
        )
        process = self._popen(cmd)

        # TODO: check if process is running
        self.wait_for_start(process=process)
        # logger.info(f'Started {" ".join(self.engine_info)} with RPC server at port {self.port}')
        return process

    @staticmethod
    @remote_unreal(default_imports=[])
    def test_connection(debug: bool = False) -> None:
        """Test connection."""
        import unreal

        unreal.log('Connection test passed')

xrfeitoria/utils/runner.py
openxrlab/xrfeitoria
import configparser
import platform
import shutil
from pathlib import Path
from typing import Literal, Union

from loguru import logger
from rich.prompt import Prompt

from ..data_structure.constants import ConfigDict, EngineEnum, PathLike
from ..data_structure.constants import config_path as default_config_path
from ..data_structure.constants import tmp_dir
from .downloader import download

# XXX: hard-coded blender download url
blender_compressed_urls = {
    'Windows': 'https://download.blender.org/release/Blender3.6/blender-3.6.4-windows-x64.zip',
    'Darwin': None,
    'Linux': 'https://download.blender.org/release/Blender3.6/blender-3.6.4-linux-x64.tar.xz',
}


class Config:
    def __init__(self, engine: EngineEnum, path: PathLike = default_config_path) -> None:
        """Read config from config file.

        Args:
            path (Path, optional): Path to the config file. Defaults to config_path.
        """
        self.config: ConfigDict = {'blender_exec': None, 'unreal_exec': None}
        self.parser = configparser.ConfigParser()
        self.section_key = 'global'
        self.path = Path(path).resolve()
        self.engine = engine
        self.key = f'{self.engine.name.lower()}_exec'

        self.parser.read(self.path)
        if self.section_key not in self.parser.sections():
            # Create a new section, with default values
            self.parser.add_section(self.section_key)
            for key, value in self.config.items():
                self.parser.set(self.section_key, key, str(value))
        else:
            # Read config from file
            self.config = dict(self.parser[self.section_key])

    @property
    def exec_path(self) -> Path:
        """Path to engine executable."""
        if self.config.get(self.key) is None:
            path = Path(guess_exec_path(self.engine.name, raise_error=True)).resolve()
        else:
            path = Path(self.config[self.key]).resolve()
            if not path.exists():
                raise FileNotFoundError(f'"{self.path.as_posix()}": {self.key} invalid')
        return path

    @exec_path.setter
    def exec_path(self, value: PathLike) -> None:
        """Set the path to engine executable.

        Args:
            value (PathLike): Path to engine executable.

        Raises:
            FileNotFoundError: If the given executable is not valid.
        """
        value = Path(value).resolve()
        if not value.exists() or not value.is_file():
            raise FileNotFoundError(f'The given {self.engine.name} executable is not valid')
        self.config[self.key] = str(value)
        self.parser.set(self.section_key, self.key, value.as_posix())

    def write_config(self) -> None:
        """Write config to file, overwriting the original file.

        Raises:
            ValueError: If the config is not initialized or not valid.
        """
        if self.section_key not in self.parser.sections():
            raise ValueError('Config is not initialized or not valid')

        with open(self.path, 'w') as f:
            self.parser.write(f)
        logger.info(f'System config saved to "{self.path.as_posix()}"')

    @classmethod
    def update(cls, engine: Literal['blender', 'unreal'], exec_path: str) -> None:
        """Update a property in config file.

        Args:
            key (str): The key of the property.
            value (str): The value of the property.
        """
        config = cls(engine=EngineEnum[engine])
        config.exec_path = exec_path
        config.write_config()

    def __repr__(self) -> str:
        return f'Config(path="{self.path.as_posix()}", config={self.config})'


def download_engine(engine: Union[Literal['unreal', 'blender'], EngineEnum]) -> Path:
    """Download the engine executable.

    Args:
        engine (Union[Literal['unreal', 'blender'], EngineEnum]): The engine to download.

    Returns:
        Path: The path to the downloaded executable.
    """
    if not isinstance(engine, EngineEnum):
        engine = EngineEnum[engine]

    if engine == EngineEnum.unreal:
        raise NotImplementedError(
            'Sorry, automatic installing of unreal engine is not supported. \n'
            'Please download it manually from https://www.unrealengine.com/en-US/download'
        )
    elif engine == EngineEnum.blender:
        if platform.system() == 'Darwin':
            # not supported in MacOS
            raise NotImplementedError(
                'Sorry, automatic installing of blender engine on MacOS is not supported. \n'
                'Please download it manually from https://www.blender.org/download/'
            )

        dst_file = download(blender_compressed_urls[platform.system()], tmp_dir / 'programs')

        # get dst executable path
        filename = dst_file.stem.replace('.tar', '')  # remove .tar for linux
        dst_dir = dst_file.parent / filename
        # dst_file.unlink()  # remove compressed file
        exec_path = dst_dir / 'blender'
        if platform.system() == 'Windows':
            exec_path = exec_path.with_suffix('.exe')
        if exec_path.exists():
            return exec_path

        # uncompressed
        logger.info(f'Uncompressing "{dst_file.as_posix()}"')
        shutil.unpack_archive(dst_file, dst_file.parent)
        assert exec_path.exists(), f'Cannot find blender executable at {exec_path.as_posix()}'
        return exec_path


def guess_exec_path(engine: Union[Literal['unreal', 'blender'], EngineEnum], raise_error: bool = False) -> str:
    """Guess the executable path of the engine.

    Args:
        engine (Union[Literal['unreal', 'blender'], EngineEnum]): The engine to guess.
        raise_error (bool, optional): Whether to raise error if the executable is not found. Defaults to False.

    Raises:
        FileNotFoundError: If the executable is not found and `raise_error` is True.

    Returns:
        str: The executable path.
    """
    if not isinstance(engine, EngineEnum):
        engine = EngineEnum[engine]

    if engine == EngineEnum.unreal:
        path = shutil.which('UnrealEditor-Cmd')
        if path is None:
            # Hard-coded path
            if platform.system() == 'Windows':
                path = 'C:/Program Files/Epic Games/UE_5.2/Engine/Binaries/Win64/UnrealEditor-Cmd.exe'
            elif platform.system() == 'Linux':
                path = '/usr/bin/UnrealEditor-Cmd'
            elif platform.system() == 'Darwin':
                path = '/Applications/UnrealEngine/Engine/Binaries/Mac/UnrealEditor-Cmd'

    elif engine == EngineEnum.blender:
        path = shutil.which('blender')
        if path is None:
            # Hard-coded path
            if platform.system() == 'Windows':
                path = 'C:/Program Files/Blender Foundation/Blender 3.3/blender.exe'
            elif platform.system() == 'Linux':
                path = '/usr/bin/blender'
            elif platform.system() == 'Darwin':
                path = '/Applications/Blender.app/Contents/MacOS/Blender'

    if not Path(path).exists() and raise_error:
        raise FileNotFoundError(f'Cannot guess {engine.name} executable, please specify it manually')

    # logger.info(f'Guessed {engine.name} executable path: "{path}"')
    return path


def get_exec_path(
    engine: Union[Literal['unreal', 'blender'], EngineEnum],
    config_path: PathLike = default_config_path,
    to_install: bool = True,
    to_ask: bool = True,
) -> Path:
    """Get the executable path of the engine.
    Priority: system_config > guess > ask > install

    Args:
        engine (Union[Literal['unreal', 'blender'], EngineEnum]): The engine to get.
        to_install (bool, optional): Whether to install the engine if not found. Defaults to True.
        to_ask (bool, optional): Whether to ask for the executable path if not found. Defaults to True.


    Raises:
        FileNotFoundError: If the executable is not valid.

    Returns:
        Path: The executable path.
    """
    if not isinstance(engine, EngineEnum):
        engine = EngineEnum[engine]

    config = Config(engine=engine, path=config_path)

    # read from system config, if empty, guess
    try:
        return config.exec_path
    except FileNotFoundError:
        if not to_install and not to_ask:
            raise FileNotFoundError(f'{engine.name} executable not found')

    exec_path = ''
    # ask for path
    if to_ask:
        txt = f'Found no [bold]{engine.name}[/bold] executable in default config file ("{config.path.as_posix()}").\n'
        if engine == EngineEnum.blender:
            txt += (
                'Ways to solve this: \n'
                f'1. Press [bold]Enter[/bold] to download [bold]{engine.name}[/bold] automatically\n'
                f'2. Input path to executable (e.g. {guess_exec_path(engine)}) \n'
                '\[Enter]'
            )
        elif engine == EngineEnum.unreal:
            txt += f'Please input path to executable (e.g. {guess_exec_path(engine)}) \n'
        # input manually
        exec_path = Prompt.ask(txt)

    if to_install and exec_path == '':
        # auto download
        logger.info(f':robot: Automatically setup for {engine.name}')
        exec_path = download_engine(engine)
        config.exec_path = exec_path
        config.write_config()
    else:
        # from input
        config.exec_path = exec_path
        config.write_config()

    return config.exec_path


if __name__ == '__main__':
    # print(get_exec_path('blender'))
    Config.update('blender', 'C:/Program Files/Blender Foundation/Blender 3.3/blender.exe')

xrfeitoria/utils/setup.py
openxrlab/xrfeitoria
"""Utils tools for logging and progress bar."""


from pathlib import Path
from typing import Iterable, Optional, Sequence, Tuple, Union

import loguru
from loguru import logger
from rich import print as rprint
from rich.progress import (
    BarColumn,
    MofNCompleteColumn,
    Progress,
    ProgressColumn,
    ProgressType,
    Task,
    TaskProgressColumn,
    TextColumn,
    TimeElapsedColumn,
    TimeRemainingColumn,
)
from rich.text import Text

from ..data_structure.constants import PathLike

__all__ = ['Logger']


class Logger:
    """A wrapper for logger tools."""

    is_setup = False
    logger_record = set()
    # ^8 means center align, 8 characters
    logger_format = '{time:YYYY-MM-DD HH:mm:ss} | ' + '{level:^8} | ' + '{message}'

    @staticmethod
    def find_logger_file() -> str:
        """Find the logger file."""
        from loguru._file_sink import FileSink

        for _, _handler in logger._core.handlers.items():
            _sink = _handler._sink
            if isinstance(_sink, FileSink):
                return _sink._path

    @classmethod
    def filter_unique(cls, record: 'loguru.Record', level_name: str = 'WARNING') -> bool:
        """Filter unique warning massage.

        Args:
            record (loguru.Record): Loguru record.
            level_name (str, optional): logging level. Defaults to "WARNING".

        Returns:
            bool: _description_
        """
        msg = record['message']
        level = record['level'].name
        if level != level_name:
            return True

        msg = f'{level}: {msg}'
        if msg not in cls.logger_record:
            cls.logger_record.add(msg)
            return True
        return False

    @classmethod
    def setup_logging(
        cls, level: str = 'INFO', log_path: 'Optional[PathLike]' = None, replace: bool = True
    ) -> 'loguru.Logger':
        """Setup logging to file and console.

        Args:
            level (str, optional): logging level. Defaults to "INFO", can be "DEBUG", "INFO", "WARNING",
                                    "ERROR", "CRITICAL".
            log_path (Path, optional): path to save the log file. Defaults to None.
            replace (bool, optional): replace the log file if exists. Defaults to True.
        """
        if cls.is_setup:
            return logger

        logger.remove()  # remove default logger
        logger.add(sink=lambda msg: rprint(msg, end=''), level=level, format=cls.logger_format)
        # logger.add(RichHandler(level=level, rich_tracebacks=True, markup=True), level=level, format='{message}')
        if log_path:
            # add file logger
            log_path = Path(log_path).resolve()
            log_path.parent.mkdir(parents=True, exist_ok=True)
            if replace and log_path.exists():
                log_path.unlink(missing_ok=True)
            logger.add(log_path, level='DEBUG', filter=cls.filter_unique, format=cls.logger_format, encoding='utf-8')
            logger.info(f'Python Logging to "{log_path.as_posix()}"')
        cls.is_setup = True
        return logger


#### (rich) progress bar ####
class SpeedColumn(ProgressColumn):
    """Renders the speed of a task."""

    def render(self, task: 'Task') -> Text:
        speed = task.speed
        if speed is None:
            return Text('N/A', style='progress.data.speed')
        return Text(f'{1/speed:.1f} s/step', style='progress.data.speed')


class TimeProgress(Progress):
    @classmethod
    def get_default_columns(cls) -> Tuple[Union[ProgressColumn, str], ...]:
        return (
            TextColumn('{task.description}'),
            MofNCompleteColumn(),
            BarColumn(),
            TaskProgressColumn(show_speed=True),
            '• Remaining',
            TimeRemainingColumn(),
            '• Elapsed',
            TimeElapsedColumn(),
            '•',
            SpeedColumn(),
        )


def track(
    sequence: Union[Sequence[ProgressType], Iterable[ProgressType]],
    description: str = 'Working...',
    total: Optional[float] = None,
    disable: bool = False,
) -> Iterable[ProgressType]:
    """Custom Track progress by iterating over a sequence."""
    progress = TimeProgress(
        speed_estimate_period=24 * 60 * 60,
        refresh_per_second=1,
        disable=disable,
    )  # 1 day as speed estimate period for long task
    with progress:
        yield from progress.track(
            sequence,
            total=total,
            description=description,
        )

xrfeitoria/utils/tools.py
openxrlab/xrfeitoria
"""Validations for the arguments."""

from inspect import BoundArguments, signature
from typing import Any, Callable, List, Tuple, Type, Union, get_args, get_origin

__all__ = ['Validator']


class Validator:
    @classmethod
    def validate_argument_type(cls, value, typelist: List[Type]) -> None:
        """Validate the type of an argument.

        Args:
            value (Any): The value to be validated.
            typelist (List[Type]): The list of types to be validated.

        Raises:
            TypeError: If the type of the argument is not in the typelist.
        """
        if not isinstance(typelist, list):
            typelist = [typelist]
        if any([isinstance(value, tp) for tp in typelist]):
            return
        raise TypeError(
            f'Invalid argument type, expected {[tp.__name__ for tp in typelist]} (got {value.__class__.__name__} instead).'
        )

    @classmethod
    def validate_vector(cls, value, length: int):
        """Validate the type and length of a vector.

        Args:
            value (Any): The value to be validated.
            length (int): The length of the vector.

        Raises:
            TypeError: If the type of the argument is not a vector,
                or the length of the vector is not equal to the given length.
        """
        cls.validate_argument_type(value=value, typelist=[list, tuple])
        if len(value) != length:
            raise ValueError(f'Invalid vector length, expected 3 (got {len(value)} instead)')
        for val in value:
            if not isinstance(val, float) and not isinstance(val, int):
                raise TypeError(
                    f"Invalid argument type, expected 'float' vector or 'int' vector (got '{val.__class__.__name__}' in vector instead)."
                )


def get_variable_name(_type: Any) -> str:
    """Get the variable name as a string."""
    try:
        return _type.__name__
    except AttributeError:
        return str(_type)


def get_function_signature(func: Callable) -> str:
    """Get the function signature as a string."""
    _signature = signature(func)
    parameters = [f'{param.name}: {get_variable_name(param.annotation)}' for param in _signature.parameters.values()]
    return f"def {func.__name__}({', '.join(parameters)}) -> {get_variable_name(_signature.return_annotation)}:"


# TODO: finish this decorator, right now not support typing quoted as string
def validate(func: Callable) -> Callable:
    """Decorator to validate the types of arguments and return value of a function."""

    def wrapper(*args, **kwargs):
        # convert kwargs arguments to positional
        # https://stackoverflow.com/questions/33448997/convert-kwargs-arguments-to-positional
        func_signature = signature(func)
        bound_arguments: BoundArguments = func_signature.bind(*args, **kwargs)
        bound_arguments.apply_defaults()
        # Check the types of arguments before calling the decorated function
        for arg_name, arg_type in func.__annotations__.items():
            if arg_name == 'return':
                continue
            _arg = bound_arguments.arguments[arg_name]
            # List or Tuple
            if any([get_origin(arg_type) is _type for _type in [list, List, tuple, Tuple]]):
                # is list or tuple
                if not isinstance(_arg, (list, tuple)):
                    raise TypeError(
                        f'{get_function_signature(func)}\n'
                        f'    Argument "{arg_name}" must be of type "{get_variable_name(arg_type)}", '
                        f'got `{arg_name}={_arg}` (type "{get_variable_name(_arg.__class__)}") instead'
                    )
                # length for tuple
                if get_origin(arg_type) is tuple and len(_arg) != len(get_args(arg_type)):
                    raise TypeError(
                        f'{get_function_signature(func)}\n'
                        f'    Argument "{arg_name}" must be of type "{get_variable_name(arg_type)}" in length {len(get_args(arg_type))}, '
                        f'got `{arg_name}={_arg}` (length {len(_arg)}) instead'
                    )

                # type
                if not all([isinstance(_a, get_args(arg_type)) for _a in _arg]):
                    raise TypeError(
                        f'{get_function_signature(func)}\n'
                        f'    Argument "{arg_name}" must be of type "{get_variable_name(arg_type)}", '
                        f'got `{arg_name}={_arg}` (type "{get_variable_name(_arg.__class__)}") instead'
                    )

            # Union
            elif get_origin(arg_type) is Union:
                if not isinstance(_arg, get_args(arg_type)):
                    raise TypeError(
                        f'{get_function_signature(func)}\n'
                        f'    Argument "{arg_name}" must be one of type '
                        f'{[get_variable_name(_type) for _type in get_args(arg_type)]}, '
                        f'got `{arg_name}={_arg}` (type "{get_variable_name(_arg.__class__)}") instead'
                    )

            # Other types
            elif not isinstance(_arg, arg_type):
                raise TypeError(
                    f'{get_function_signature(func)}\n'
                    f'    Argument "{arg_name}" must be of type "{get_variable_name(arg_type)}", '
                    f'got `{arg_name}={_arg}` (type "{get_variable_name(_arg.__class__)}") instead'
                )

        result = func(*args, **kwargs)

        # Check the type of the return value after calling the decorated function
        return_type = func.__annotations__.get('return')
        if return_type is not None and not isinstance(result, return_type):
            raise TypeError(
                f'{get_function_signature(func)}\n'
                f'Return value must be of type "{get_variable_name(return_type)}", '
                f'got {result} (type "{get_variable_name(result.__class__)}") instead'
            )

        return result

    return wrapper

xrfeitoria/utils/validations.py
openxrlab/xrfeitoria
"""Utils for loading images and annotations."""
import os
from pathlib import Path
from typing import List, Tuple, Union

import numpy as np

from ..data_structure.constants import PathLike

os.environ['OPENCV_IO_ENABLE_OPENEXR'] = '1'

try:
    import cv2
    import flow_vis
except ImportError:
    raise ImportError('Failed to import necessary packages. Please install by: \npip install "xrfeitoria[vis]"')

__all__ = ['Viewer']


class ExrReader:
    """Utils for exr format data.

    Load `.exr` format file.
    """

    def __init__(self, exr_path: Union[str, Path]):
        """Initialize with a `.exr` format file.

        Args:
            exr_path (PathLike): path to `.exr` format file
        """
        if isinstance(exr_path, Path):
            exr_path = exr_path.as_posix()
        exr_mat = cv2.imread(exr_path, cv2.IMREAD_ANYCOLOR | cv2.IMREAD_ANYDEPTH)
        exr_mat = cv2.cvtColor(exr_mat, cv2.COLOR_BGR2RGB)
        self.exr_mat = exr_mat

    @staticmethod
    def float2int(array: np.ndarray) -> np.ndarray:
        """Convert ndarray to uint8 that can be display as image. Values greater than
        255 will be clipped.

        Args:
            array (np.ndarray): input ndarray

        Returns:
            np.ndarray: array of dtype=unit8
        """
        array = np.round(array * 255)
        array = np.clip(array, 0, 255)
        return array.astype(np.uint8)

    def get_rgb(self) -> np.ndarray:
        """Get RGB channel in `.exr` format.

        Returns:
            np.ndarray: masks of shape (H, W, 3)
        """
        rgb = self.exr_mat
        img = self.float2int(rgb)
        return img

    def get_flow(self) -> np.ndarray:
        """Get optical flow in `.exr` format.

        Returns:
            np.ndarray: optical flow data of (H, W, 3) converted to colors
        """
        flow = self.exr_mat[..., :2]
        img = flow_vis.flow_to_color(flow, convert_to_bgr=False)
        return img

    def get_depth(self, depth_rescale: float = 1.0) -> np.ndarray:
        """Get depth in `.exr` format.

        Args:
            depth_rescale (float, optional): scaling the depth
                to map it into (0, 255). Depth values great than
                `depth_rescale` will be clipped. Defaults to 1.0.

        Returns:
            np.ndarray: depth data of shape (H, W, 3)
        """
        depth = self.exr_mat
        img = self.float2int(depth / depth_rescale)
        img[img == 0] = 255
        return img


class Viewer:
    """Utils for loading images and annotations.

    Examples:
        >>> viewer = Viewer('/path/to/sequence')
        >>> camera_name = 'cam'
        >>> frame = 0
        >>> img = viewer.get_img(camera_name=camera_name, frame=frame)
        >>> mask = viewer.get_mask(camera_name=camera_name, frame=frame)
        >>> depth = viewer.get_depth(camera_name=camera_name, frame=frame)
        >>> flow = viewer.get_flow(camera_name=camera_name, frame=frame)
        >>> normal = viewer.get_normal(camera_name=camera_name, frame=frame)
        >>> diffuse = viewer.get_diffuse(camera_name=camera_name, frame=frame)
    """

    # folder names of each data modal
    IMG = 'img'
    MASK = 'mask'
    DEPTH = 'depth'
    FLOW = 'flow'
    NORMAL = 'normal'
    DIFFUSE = 'diffuse'

    def __init__(self, sequence_dir: PathLike) -> None:
        """Initialize with the sequence directory.

        Args:
            sequence_dir (PathLike): path to the sequence directory
        """
        self.sequence_dir = Path(sequence_dir)

    def get_img(self, camera_name: str, frame: int) -> np.ndarray:
        """Get rgb image of the given frame ('img/{frame:04d}.*')

        Args:
            camera_name (str): the camera name
            frame (int): the frame number

        Returns:
            np.ndarray: image of shape (H, W, 3)
        """
        folder = self.sequence_dir / self.IMG / camera_name
        if not folder.exists():
            raise ValueError(f'Folder of rgb images not found: {folder}')
        file_path = next(folder.glob(f'{frame:04d}.*')).resolve()
        if not file_path.exists():
            raise ValueError(f'Image of {frame}-frame not found: {file_path}')
        if file_path.suffix == '.exr':
            return ExrReader(file_path).get_rgb()
        else:
            img = cv2.imread(str(file_path))
            img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB)
            return img

    def get_diffuse(self, camera_name: str, frame: int) -> np.ndarray:
        """Get diffuse image of the given frame ('diffuse/{frame:04d}.*')

        Args:
            camera_name (str): the camera name
            frame (int): the frame number

        Returns:
            np.ndarray: image of shape (H, W, 3)
        """
        folder = self.sequence_dir / self.DIFFUSE / camera_name
        if not folder.exists():
            raise ValueError(f'Folder of diffuse images not found: {folder}')
        file_path = next(folder.glob(f'{frame:04d}.*')).resolve()
        if not file_path.exists():
            raise ValueError(f'Diffuse image of {frame}-frame not found: {file_path}')
        if file_path.suffix == '.exr':
            return ExrReader(file_path).get_rgb()
        else:
            img = cv2.imread(str(file_path))
            img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB)
            return img

    def get_mask(self, camera_name: str, frame: int) -> np.ndarray:
        """Get mask of the given frame ('mask/{frame:04d}.*')

        Args:
            camera_name (str): the camera name
            frame (int): the frame number

        Returns:
            np.ndarray: image of shape (H, W, 3)
        """
        folder = self.sequence_dir / self.MASK / camera_name
        if not folder.exists():
            raise ValueError(f'Folder of masks not found: {folder}')
        file_path = next(folder.glob(f'{frame:04d}.*')).resolve()
        if not file_path.exists():
            raise ValueError(f'Mask of {frame}-frame not found: {file_path}')
        if file_path.suffix == '.exr':
            return ExrReader(file_path).get_rgb()
        else:
            img = cv2.imread(str(file_path))
            img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB)
            return img

    def get_depth(self, camera_name: str, frame: int, depth_rescale=1.0) -> np.ndarray:
        """Get depth of the given frame ('depth/{frame:04d}.*')

        Args:
            camera_name (str): the camera name
            frame (int): the frame number
            depth_rescale (float, optional): scaling the depth
                to map it into (0, 255). Depth values great than
                `depth_rescale` will be clipped. Defaults to 1.0.

        Returns:
            np.ndarray: depth of shape (H, W, 3)
        """
        folder = self.sequence_dir / self.DEPTH / camera_name
        if not folder.exists():
            raise ValueError(f'Folder of depth not found: {folder}')
        file_path = next(folder.glob(f'{frame:04d}.*')).resolve()
        if not file_path.exists():
            raise ValueError(f'Depth of {frame}-frame not found: {file_path}')
        if file_path.suffix == '.exr':
            return ExrReader(file_path).get_depth(depth_rescale=depth_rescale)
        else:
            img = cv2.imread(str(file_path))
            img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB)
            return img

    def get_flow(self, camera_name: str, frame: int) -> np.ndarray:
        """Get optical flow of the given frame ('flow/{frame:04d}.*')

        Args:
            camera_name (str): the camera name
            frame (int): the frame number
            depth_rescale (float, optional): scaling the depth
                to map it into (0, 255). Depth values great than
                `depth_rescale` will be clipped. Defaults to 1.0.

        Returns:
            np.ndarray: optical flow of shape (H, W, 3)
        """
        folder = self.sequence_dir / self.FLOW / camera_name
        if not folder.exists():
            raise ValueError(f'Folder of depth not found: {folder}')
        file_path = next(folder.glob(f'{frame:04d}.*')).resolve()
        if not file_path.exists():
            raise ValueError(f'Depth of {frame}-frame not found: {file_path}')
        if file_path.suffix == '.exr':
            return ExrReader(file_path).get_flow()
        else:
            img = cv2.imread(str(file_path))
            img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB)
            return img

    def get_normal(self, camera_name: str, frame: int) -> np.ndarray:
        """Get normal map of the given frame ('normal/{frame:04d}.*')

        Args:
            camera_name (str): the camera name
            frame (int): the frame number

        Returns:
            np.ndarray: normal map of shape (H, W, 3)
        """
        folder = self.sequence_dir / self.NORMAL / camera_name
        if not folder.exists():
            raise ValueError(f'Folder of normal mpa not found: {folder}')
        file_path = next(folder.glob(f'{frame:04d}.*')).resolve()
        if not file_path.exists():
            raise ValueError(f'Normal map of {frame}-frame not found: {file_path}')
        if file_path.suffix == '.exr':
            return ExrReader(file_path).get_rgb()
        else:
            img = cv2.imread(str(file_path))
            img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB)
            return img

xrfeitoria/utils/viewer.py
openxrlab/xrfeitoria