Merge branch 'develop' into 'master'

v0.0.2

Closes #69

See merge request slumber/multi-user!19
This commit is contained in:
Swann Martinez
2020-02-28 10:39:22 +00:00
60 changed files with 2019 additions and 777 deletions

5
.gitignore vendored
View File

@ -6,4 +6,7 @@ __pycache__/
.vscode
cache
config
*.code-workspace
*.code-workspace
# sphinx build folder
_build

2
.gitmodules vendored
View File

@ -1,3 +1,3 @@
[submodule "multi_user/libs/replication"]
path = multi_user/libs/replication
url = git@gitlab.com:slumber/replication.git
url = https://gitlab.com/slumber/replication.git

38
CHANGELOG.md Normal file
View File

@ -0,0 +1,38 @@
# Changelog
All notable changes to this project will be documented in this file.
## [0.0.2] - 2020-02-28
### Added
- Blender animation features support (alpha).
- Action.
- Armature (Unstable).
- Shape key.
- Drivers.
- Constraints.
- Snap to user timeline tool.
- Light probes support (only since 2.83).
- Metaballs support.
- Improved modifiers support.
- Online documentation.
- Improved Undo handling.
- Improved overall session handling:
- Time To Leave : ensure clients/server disconnect automatically on connection lost.
- Ping: show clients latency.
- Non-blocking connection.
- Connection state tracking.
- Service communication layer to manage background daemons.
### Changed
- UI revamp:
- Show users frame.
- Expose IPC(inter process communication) port.
- New user list.
- Progress bar to track connection status.
- Right management takes view-layer in account for object selection.
- Use a basic BFS approach for replication graph pre-load.
- Serialization is now based on marshal (2x performance improvements).
- Let pip chose python dependencies install path.

View File

@ -9,7 +9,7 @@
This tool aims to allow multiple users to work on the same scene over the network. Based on a Clients / Server architecture, the data-oriented replication schema replicate blender data-blocks across the wire.
## Installation
## Quick installation
1. Download latest release [multi_user.zip](/uploads/8aef79c7cf5b1d9606dc58307fd9ad8b/multi_user.zip).
2. Run blender as administrator (dependencies installation).
@ -19,31 +19,32 @@ This tool aims to allow multiple users to work on the same scene over the networ
## Usage
See [how to](https://gitlab.com/slumber/multi-user/wikis/User/Quickstart) section.
See the [documentation](https://multi-user.readthedocs.io/en/latest/) for details.
## Current development status
Currently, not all data-block are supported for replication over the wire. The following list summarizes the status for each ones.
| Name | Status | Comment |
| ---------- | :----------------: | :------------: |
| action | :x: | WIP |
| armature | :x: | WIP |
| camera | :white_check_mark: | |
| collection | :white_check_mark: | |
| curve | :white_check_mark: | Not tested |
| gpencil | :white_check_mark: | |
| image | :exclamation: | Not stable yet |
| mesh | :white_check_mark: | |
| material | :white_check_mark: | |
| metaball | :x: | |
| object | :white_check_mark: | |
| scene | :white_check_mark: | |
| world | :white_check_mark: | |
| Name | Status | Comment |
| ----------- | :----------------: | :------------: |
| action | :exclamation: | Not stable |
| armature | :exclamation: | Not stable |
| camera | :white_check_mark: | |
| collection | :white_check_mark: | |
| curve | :white_check_mark: | Not tested |
| gpencil | :white_check_mark: | |
| image | :exclamation: | Not stable yet |
| mesh | :white_check_mark: | |
| material | :white_check_mark: | |
| metaball | :white_check_mark: | |
| object | :white_check_mark: | |
| scene | :white_check_mark: | |
| world | :white_check_mark: | |
| lightprobes | :white_check_mark: | |
### Performance issues
Since this addon is written in pure python for a prototyping purpose, performances could be better from all perspective.
Since this addon is written in pure python for a research purpose, performances could be better from all perspective.
I'm working on it.
## Dependencies
@ -58,12 +59,10 @@ I'm working on it.
## Contributing
1. Fork it (<https://gitlab.com/yourname/yourproject/fork>)
2. Create your feature branch (`git checkout -b feature/fooBar`)
3. Commit your changes (`git commit -am 'Add some fooBar'`)
4. Push to the branch (`git push origin feature/fooBar`)
5. Create a new Pull Request
See [contributing section](https://multi-user.readthedocs.io/en/latest/ways_to_contribute.html) of the documentation.
## Licensing
See [license](LICENSE)
See [license](LICENSE)
[![Documentation Status](https://readthedocs.org/projects/multi-user/badge/?version=latest)](https://multi-user.readthedocs.io/en/latest/?badge=latest)

20
docs/Makefile Normal file
View File

@ -0,0 +1,20 @@
# Minimal makefile for Sphinx documentation
#
# You can set these variables from the command line, and also
# from the environment for the first two.
SPHINXOPTS ?=
SPHINXBUILD ?= sphinx-build
SOURCEDIR = .
BUILDDIR = _build
# Put it first so that "make" without argument is like "make help".
help:
@$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
.PHONY: help Makefile
# Catch-all target: route all unknown targets to Sphinx using the new
# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS).
%: Makefile
@$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)

8
docs/about/index.rst Normal file
View File

@ -0,0 +1,8 @@
About
=====
.. toctree::
:maxdepth: 1
:name: toc-about
introduction

View File

@ -0,0 +1,10 @@
============
Introduction
============
A film is an idea carved along the whole production process by many different peoples. A traditional animation pipeline involve a linear succession of tasks. From storyboard to compositing by passing upon different step, its fundamental work flow is similar to an industrial assembly line. Since each step is almost a department, its common that one person on department B doesn't know what another person did on a previous step in a department A. This lack of visibility/communication could be a source of problems which could produce a bad impact on the final production result.
Nowadays it's a known fact that real-time rendering technologies allows to speedup traditional linear production by reducing drastically the iteration time across different steps. All majors industrial CG solutions are moving toward real-time horizons to bring innovative interactive workflows. But this is a microscopic, per-task/solution vision of real-time rendering benefits for the animation production. What if we step-back, get a macroscopic picture of an animation movie pipeline and ask ourself how real-time could change our global workflow ? Could-it bring better ways of working together by giving more visibility between departments during the whole production ?
The multi-user addon is an attempt to experiment real-time parallelism between different production stage. By replicating blender data blocks over the networks, it allows different artists to collaborate on a same scene in real-time.

98
docs/conf.py Normal file
View File

@ -0,0 +1,98 @@
# 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
# sys.path.insert(0, os.path.abspath('.'))
# -- Project information -----------------------------------------------------
project = 'multi-user'
copyright = '2020, Swann Martinez'
author = 'Swann Martinez'
# The full version, including alpha/beta/rc tags
release = '0.0.1'
# -- 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 = [
]
# Add any paths that contain templates here, relative to this directory.
templates_path = ['_templates']
# The suffix of source filenames.
source_suffix = ".rst"
# The master toctree document.
master_doc = "index"
# The language for content autogenerated by Sphinx. Refer to documentation
# for a list of supported languages.
#
# This is also used if you do content translation via gettext catalogs.
# Usually you set "language" from the command line for these cases.
language = 'python'
# 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']
# -- Options for HTML output -------------------------------------------------
# on_rtd is whether we are on readthedocs.org, this line of code grabbed from docs.readthedocs.org
on_rtd = os.environ.get('READTHEDOCS', None) == 'True'
import sphinx_rtd_theme
html_theme = 'sphinx_rtd_theme'
html_theme_path = [sphinx_rtd_theme.get_html_theme_path()]
if on_rtd:
using_rtd_theme = True
# Theme options
html_theme_options = {
# 'typekit_id': 'hiw1hhg',
# 'analytics_id': '',
# 'sticky_navigation': True # Set to False to disable the sticky nav while scrolling.
# 'logo_only': True, # if we have a html_logo below, this shows /only/ the logo with no title text
'collapse_navigation': False, # Collapse navigation (False makes it tree-like)
# 'display_version': True, # Display the docs version
# 'navigation_depth': 4, # Depth of the headers shown in the navigation bar
}
# -- Options for HTMLHelp output ------------------------------------------
# Output file base name for HTML help builder.
htmlhelp_basename = 'multiusrdoc'
# sphinx-notfound-page
# https://github.com/readthedocs/sphinx-notfound-page
notfound_context = {
'title': 'Page Not Found',
'body': '''
<h1>Page Not Found</h1>
<p>Sorry, we couldn't find that page.</p>
<p>Try using the search box or go to the homepage.</p>
''',
}
# Enable directives that insert the contents of external files
file_insertion_enabled = False

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.1 KiB

View File

@ -0,0 +1,9 @@
Getting started
===============
.. toctree::
:maxdepth: 1
:name: toc-getting-started
install
quickstart

View File

@ -0,0 +1,9 @@
============
Installation
============
*The process is the same for linux, mac and windows.*
1. Download latest release `multi_user.zip <https://gitlab.com/slumber/multi-user/uploads/8aef79c7cf5b1d9606dc58307fd9ad8b/multi_user.zip>`_.
2. Run blender as administrator (to allow python dependencies auto-installation).
3. Install last_version.zip from your addon preferences.

View File

@ -0,0 +1,111 @@
===========
Quick start
===========
*All settings are located under: `View3D -> Sidebar -> Multiuser panel`*
Session setup
=============
This section describe how to create or join a collaborative session.
---------------------
1. User information's
---------------------
.. image:: img/quickstart_user_infos.png
- **name**: username.
- **color**: color used to represent the user into other user workspace.
----------
2. Network
----------
.. note:: If you host a session over internet, special network configuration is needed.
Hosting and connection are done from this panel.
+-----------------------------------+-------------------------------------+
| Host | Join |
+===================================+=====================================+
|.. image:: img/quickstart_host.png | .. image:: img/quickstart_join.png |
+-----------------------------------+-------------------------------------+
| | Start empty: Cleanup the file | | IP: server ip |
| | before hosting | | Port: server port |
+-----------------------------------+-------------------------------------+
| **HOST**: Host a session | **CONNECT**: Join a session |
+-----------------------------------+-------------------------------------+
**Port configuration:**
For now, a session use 4 ports to run.
If 5555 is given in host settings, it will use 5555, 5556 (5555+1), 5557 (5555+2), 5558 (5555+3).
------------
2.1 Advanced
------------
.. image:: img/quickstart_advanced.png
**Right strategy** (only host) enable you to choose between a strict and a relaxed pattern:
- **Strict**: Host is the king, by default the host own each properties, only him can grant modification rights.
- **Common**: Each properties are under common rights by default, on selection, a property is only modifiable by the owner.
On each strategy, when a user is the owner he can choose to pass his rights to someone else.
**Properties frequency gird** allow to set a custom replication frequency for each type of data-block:
- **Refresh**: pushed data update rate (in second)
- **Apply**: pulled data update rate (in second)
.. note:: Per-data type settings will soon be revamped for simplification purposes
Session Management
==================
This section describe tools available during a collaborative session.
---------------
Connected users
---------------
.. image:: img/quickstart_users.png
This panel displays all connected users information's, including yours.
By selecting a user in the list you'll have access to different **actions**:
- The **camera button** allow you to snap on the user viewpoint.
- The **time button** allow you to snap on the user time.
---------------------
Replicated properties
---------------------
.. image:: img/quickstart_properties.png
The **replicated properties** panel shows all replicated properties status and associated actions.
Since the replication architecture is based on commit/push/pull mechanisms, a replicated properties can be pushed/pull or even committed manually from this panel.
+---------------------------------------+-------------------+------------------------------------------------------------------------------------+
| icon | Action | Description |
+=======================================+===================+====================================================================================+
| .. image:: img/quickstart_push.png | **Push** | push data-block to other clients |
+---------------------------------------+-------------------+------------------------------------------------------------------------------------+
| .. image:: img/quickstart_pull.png | **Pull** | pull last version into blender |
+---------------------------------------+-------------------+------------------------------------------------------------------------------------+
| .. image:: img/quickstart_refresh.png | **Reset** | Reset local change to the server version |
+---------------------------------------+-------------------+------------------------------------------------------------------------------------+
| .. image:: img/quickstart_unlock.png | **Lock/Unlock** | If locked, does nothing. If unlocked, grant modification rights to another user. |
+---------------------------------------+-------------------+------------------------------------------------------------------------------------+
| .. image:: img/quickstart_remove.png | **Delete** | Remove the data-block from network replication |
+---------------------------------------+-------------------+------------------------------------------------------------------------------------+

BIN
docs/img/homepage_ban.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 409 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 274 KiB

59
docs/index.rst Normal file
View File

@ -0,0 +1,59 @@
=====================================
Welcome to Multi-user's documentation
=====================================
.. image:: img/homepage_ban.png
The multi-user addon is a free and open source blender plugin. It tool aims to bring multiple users to work on the same .blend over the network.
.. warning:: Under development, use it at your own risks.
Main Features
=============
- Collaborative workflow in blender
- Viewport users presence (active selection, POV)
- Datablocks right managment
- Tested under Windows
Status
======
.. image:: img/homepage_roadmap.png
Follow the `roadmap <https://gitlab.com/slumber/multi-user/-/boards/929107>`_ to be aware of last news.
Documentation is organized into the following sections:
.. toctree::
:maxdepth: 1
:caption: About
:name: sec-about
about/introduction
.. toctree::
:maxdepth: 1
:caption: Getting started
:name: sec-learn
getting_started/install
getting_started/quickstart
.. toctree::
:maxdepth: 1
:caption: Tutorials
:name: sec-tutorials
tutorials/hosting_guide
.. toctree::
:maxdepth: 1
:caption: Community
:name: sec-community
ways_to_contribute

35
docs/make.bat Normal file
View File

@ -0,0 +1,35 @@
@ECHO OFF
pushd %~dp0
REM Command file for Sphinx documentation
if "%SPHINXBUILD%" == "" (
set SPHINXBUILD=sphinx-build
)
set SOURCEDIR=.
set BUILDDIR=_build
if "%1" == "" goto help
%SPHINXBUILD% >NUL 2>NUL
if errorlevel 9009 (
echo.
echo.The 'sphinx-build' command was not found. Make sure you have Sphinx
echo.installed, then set the SPHINXBUILD environment variable to point
echo.to the full path of the 'sphinx-build' executable. Alternatively you
echo.may add the Sphinx directory to PATH.
echo.
echo.If you don't have Sphinx installed, grab it from
echo.http://sphinx-doc.org/
exit /b 1
)
%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O%
goto end
:help
%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O%
:end
popd

View File

@ -0,0 +1,47 @@
================
Advanced hosting
================
This tutorial aims to guide you to host a collaborative Session on internet.
.. note::
This tutorial will change soon with the new dedicated server.
The multi-user network architecture is based on a clients-server model. The communication protocol use four ports to communicate with client:
* Commands: command transmission (such as **snapshots**, **change_rights**, etc.)
* Subscriber: pull data
* Publisher: push data
* TTL (time to leave): used to ping each clients
.. warning::
Until now, those communications are not encrypted but are planned to be in a mid-term future (`Status <https://gitlab.com/slumber/multi-user/issues/62>`_).
To know which ports will be used, you just have to read the port in your preference.
.. image:: img/hosting_guide_port.png
:align: center
:alt: Port
In the picture below we have setup our port to **5555** so it will be:
* Commands: 5555 (**5555** +0)
* Subscriber: 5556 (**5555** +1)
* Publisher: 5557 (**5555** +2)
* TTL: 5558 (**5555** +3)
Now that we know which port are needed to communicate we need to allow other computer to communicate with our one.
By default your router shall block those ports. In order grant server access to people from internet you have multiple options:
1. Simple: use a third party software like `HAMACHI <https://vpn.net/>`_ (Free until 5 users) or `ZEROTIER <https://www.zerotier.com/download/>`_ to handle network sharing.
2. Harder: Setup a VPN server and allow distant user to connect to your VPN.
3. **Not secure** but simple: Setup port forwarding for each ports (for example 5555,5556,5557 and 5558 in our case). You can follow this `guide <https://www.wikihow.com/Set-Up-Port-Forwarding-on-a-Router>`_ for example.
Once you have setup the network, you can run **HOST** in order to start the server. Then other users could join your session in the regular way.

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

8
docs/tutorials/index.rst Normal file
View File

@ -0,0 +1,8 @@
Tutorials
=========
.. toctree::
:maxdepth: 1
:name: toc-tutorial
hosting_guide

View File

@ -0,0 +1,42 @@
==================
Ways to contribute
==================
.. Note:: Work in progress
Testing and reporting issues
============================
A great way of contributing to the multi-user addon is to test development branch and to report issues.
It is also helpful to report issues discovered in releases, so that they can be fixed in the development branch and in future releases.
----------------------------
Testing development versions
----------------------------
In order to help with the testing, you have several possibilities:
- Test `latest release <https://gitlab.com/slumber/multi-user/-/tags>`_
- Test `development branch <https://gitlab.com/slumber/multi-user/-/branches>`_
--------------------------
Filling an issue on Gitlab
--------------------------
The `gitlab issue tracker <https://gitlab.com/slumber/multi-user/issues>`_ is used for bug report and enhancement suggestion.
You will need a Gitlab account to be able to open a new issue there and click on "New issue" button.
Here are some useful information you should provide in a bug report:
- **Multi-user version** such as *lastest*, *commit-hash*, *branch*. This is a must have. Some issues might be relevant in the current stable release, but fixed in the development branch.
- **How to reproduce the bug**. In the majority of cases, bugs are reproducible, i.e. it is possible to trigger them reliably by following some steps. Please always describe those steps as clearly as possible, so that everyone can try to reproduce the issue and confirm it. It could also take the form of a screen capture.
Contributing code
=================
1. Fork it (https://gitlab.com/yourname/yourproject/fork)
2. Create your feature branch (git checkout -b feature/fooBar)
3. Commit your changes (git commit -am 'Add some fooBar')
4. Push to the branch (git push origin feature/fooBar)
5. Create a new Pull Request

View File

@ -1,11 +1,15 @@
bl_info = {
"name": "Multi-User",
"author": "Swann Martinez",
"description": "",
"version": (0, 0, 2),
"description": "Enable real-time collaborative workflow inside blender",
"blender": (2, 80, 0),
"location": "",
"location": "3D View > Sidebar > Multi-User tab",
"warning": "Unstable addon, use it at your own risks",
"category": "Collaboration"
"category": "Collaboration",
"wiki_url": "https://multi-user.readthedocs.io/en/develop/index.html",
"tracker_url": "https://gitlab.com/slumber/multi-user/issues",
"support": "COMMUNITY"
}
@ -31,45 +35,45 @@ DEPENDENCIES = {
logger = logging.getLogger(__name__)
logger.setLevel(logging.ERROR)
logger.setLevel(logging.WARNING)
#TODO: refactor config
# UTILITY FUNCTIONS
def generate_supported_types():
stype_dict = {'supported_types':{}}
for type in bl_types.types_to_register():
_type = getattr(bl_types, type)
props = {}
props['bl_delay_refresh']=_type.bl_delay_refresh
props['bl_delay_apply']=_type.bl_delay_apply
props['use_as_filter'] = False
props['icon'] = _type.bl_icon
props['auto_push']=_type.bl_automatic_push
props['bl_name']=_type.bl_id
type_module = getattr(bl_types, type)
type_impl_name = "Bl{}".format(type.split('_')[1].capitalize())
type_module_class = getattr(type_module, type_impl_name)
stype_dict['supported_types'][_type.bl_rep_class.__name__] = props
props = {}
props['bl_delay_refresh']=type_module_class.bl_delay_refresh
props['bl_delay_apply']=type_module_class.bl_delay_apply
props['use_as_filter'] = False
props['icon'] = type_module_class.bl_icon
props['auto_push']=type_module_class.bl_automatic_push
props['bl_name']=type_module_class.bl_id
stype_dict['supported_types'][type_impl_name] = props
return stype_dict
def client_list_callback(scene, context):
from . import operators
from .bl_types.bl_user import BlUser
items = [(RP_COMMON, RP_COMMON, "")]
username = bpy.context.window_manager.session.username
cli = operators.client
if cli:
client_keys = cli.list(filter=BlUser)
for k in client_keys:
name = cli.get(uuid=k).data["name"]
name_desc = name
if name == username:
client_ids = cli.online_users.keys()
for id in client_ids:
name_desc = id
if id == username:
name_desc += " (self)"
items.append((name, name_desc, ""))
items.append((id, name_desc, ""))
return items
@ -90,6 +94,15 @@ class ReplicatedDatablock(bpy.types.PropertyGroup):
auto_push: bpy.props.BoolProperty(default=True)
icon: bpy.props.StringProperty()
class SessionUser(bpy.types.PropertyGroup):
"""Session User
Blender user information property
"""
username: bpy.props.StringProperty(name="username")
current_frame: bpy.props.IntProperty(name="current_frame")
class SessionProps(bpy.types.PropertyGroup):
username: bpy.props.StringProperty(
name="Username",
@ -109,25 +122,19 @@ class SessionProps(bpy.types.PropertyGroup):
description='Distant host port',
default=5555
)
add_property_depth: bpy.props.IntProperty(
name="add_property_depth",
default=1
ipc_port: bpy.props.IntProperty(
name="ipc_port",
description='internal ttl port(only usefull for multiple local instances)',
default=5561
)
outliner_filter: bpy.props.StringProperty(name="None")
is_admin: bpy.props.BoolProperty(
name="is_admin",
default=False
)
init_scene: bpy.props.BoolProperty(
name="init_scene",
default=True
)
start_empty: bpy.props.BoolProperty(
name="start_empty",
default=True
)
active_object: bpy.props.PointerProperty(
name="active_object", type=bpy.types.Object)
session_mode: bpy.props.EnumProperty(
name='session_mode',
description='session mode',
@ -179,10 +186,11 @@ class SessionProps(bpy.types.PropertyGroup):
description='Show only owned datablocks',
default=True
)
use_select_right: bpy.props.BoolProperty(
name="Selection right",
description='Change right on selection',
default=True
user_snap_running: bpy.props.BoolProperty(
default=False
)
time_snap_running: bpy.props.BoolProperty(
default=False
)
def load(self):
@ -239,12 +247,13 @@ class SessionProps(bpy.types.PropertyGroup):
classes = (
SessionUser,
ReplicatedDatablock,
SessionProps,
)
libs = os.path.dirname(os.path.abspath(__file__))+"\\libs\\replication"
libs = os.path.dirname(os.path.abspath(__file__))+"\\libs\\replication\\replication"
@persistent
def load_handler(dummy):
@ -267,7 +276,10 @@ def register():
bpy.types.WindowManager.session = bpy.props.PointerProperty(
type=SessionProps)
bpy.types.ID.uuid = bpy.props.StringProperty(default="")
bpy.types.WindowManager.online_users = bpy.props.CollectionProperty(
type=SessionUser
)
bpy.types.WindowManager.user_index = bpy.props.IntProperty()
bpy.context.window_manager.session.load()
presence.register()

View File

@ -1,5 +1,4 @@
__all__ = [
'bl_user',
'bl_object',
'bl_mesh',
'bl_camera',

View File

@ -1,5 +1,6 @@
import bpy
import mathutils
import copy
from .. import utils
from .bl_datablock import BlDatablock
@ -7,32 +8,109 @@ from .bl_datablock import BlDatablock
# WIP
class BlAction(BlDatablock):
def load(self, data, target):
utils.dump_anything.load(target, data)
bl_id = "actions"
bl_class = bpy.types.Action
bl_delay_refresh = 1
bl_delay_apply = 1
bl_automatic_push = True
bl_icon = 'ACTION_TWEAK'
def construct(self, data):
return bpy.data.actions.new(data["name"])
def load(self, data, target):
pass
# # find target object
# object_ = bpy.context.scene.objects.active
# if object_ is None:
# raise RuntimeError("Nothing is selected.")
# if object_.mode != 'POSE': # object must be in pose mode
# raise RuntimeError("Object must be in pose mode.")
# if object_.animation_data.action is None:
# raise RuntimeError("Object needs an active action.")
begin_frame = 100000
end_frame = -100000
for dumped_fcurve in data["fcurves"]:
begin_frame = min(
begin_frame,
min(
[begin_frame] + [dkp["co"][0] for dkp in dumped_fcurve["keyframe_points"]]
)
)
end_frame = max(
end_frame,
max(
[end_frame] + [dkp["co"][0] for dkp in dumped_fcurve["keyframe_points"]]
)
)
begin_frame = 0
loader = utils.dump_anything.Loader()
for dumped_fcurve in data["fcurves"]:
dumped_data_path = dumped_fcurve["data_path"]
dumped_array_index = dumped_fcurve["dumped_array_index"]
# create fcurve if needed
fcurve = target.fcurves.find(dumped_data_path, index=dumped_array_index)
if fcurve is None:
fcurve = target.fcurves.new(dumped_data_path, index=dumped_array_index)
# remove keyframes within dumped_action range
for keyframe in reversed(fcurve.keyframe_points):
if end_frame >= (keyframe.co[0] + begin_frame ) >= begin_frame:
fcurve.keyframe_points.remove(keyframe, fast=True)
# paste dumped keyframes
for dumped_keyframe_point in dumped_fcurve["keyframe_points"]:
if dumped_keyframe_point['type'] == '':
dumped_keyframe_point['type'] = 'KEYFRAME'
new_kf = fcurve.keyframe_points.insert(
dumped_keyframe_point["co"][0] - begin_frame,
dumped_keyframe_point["co"][1],
options={'FAST', 'REPLACE'}
)
keycache = copy.copy(dumped_keyframe_point)
keycache = utils.dump_anything.remove_items_from_dict(
keycache,
["co", "handle_left", "handle_right",'type']
)
loader.load(
new_kf,
keycache
)
new_kf.type = dumped_keyframe_point['type']
new_kf.handle_left = [
dumped_keyframe_point["handle_left"][0] - begin_frame,
dumped_keyframe_point["handle_left"][1]
]
new_kf.handle_right = [
dumped_keyframe_point["handle_right"][0] - begin_frame,
dumped_keyframe_point["handle_right"][1]
]
# clearing (needed for blender to update well)
if len(fcurve.keyframe_points) == 0:
target.fcurves.remove(fcurve)
target.id_root= data['id_root']
def dump(self, pointer=None):
assert(pointer)
data = utils.dump_datablock(pointer, 1)
dumper = utils.dump_anything.Dumper()
dumper.depth = 2
dumper.exclude_filter =[
'name_full',
'original',
'use_fake_user',
'user',
'is_library_indirect',
'select_control_point',
'select_right_handle',
'select_left_handle',
'uuid',
'users'
]
dumper.depth = 1
data = dumper.dump(pointer)
data["fcurves"] = []
dumper.depth = 2
for fcurve in self.pointer.fcurves:
fc = {
"data_path": fcurve.data_path,
@ -48,21 +126,8 @@ class BlAction(BlDatablock):
data["fcurves"].append(fc)
return data
def resolve(self):
assert(self.data)
self.pointer = bpy.data.actions.get(self.data['name'])
def diff(self):
return False
def is_valid(self):
return bpy.data.actions.get(self.data['name'])
bl_id = "actions"
bl_class = bpy.types.Action
bl_rep_class = BlAction
bl_delay_refresh = 1
bl_delay_apply = 1
bl_automatic_push = True
bl_icon = 'ACTION_TWEAK'

View File

@ -3,104 +3,129 @@ import mathutils
from ..libs.overrider import Overrider
from .. import utils
from .. import presence
from .. import presence, operators
from .bl_datablock import BlDatablock
# WIP
class BlArmature(BlDatablock):
bl_id = "armatures"
bl_class = bpy.types.Armature
bl_delay_refresh = 1
bl_delay_apply = 0
bl_automatic_push = True
bl_icon = 'ARMATURE_DATA'
def construct(self, data):
return bpy.data.armatures.new(data["name"])
def load(self, data, target):
def load_implementation(self, data, target):
# Load parent object
if data['user'] not in bpy.data.objects.keys():
parent_object = bpy.data.objects.new(data['user'], self.pointer)
else:
parent_object = bpy.data.objects[data['user']]
parent_object = utils.find_from_attr(
'uuid',
data['user'],
bpy.data.objects
)
is_object_in_master = (data['user_collection'][0] == "Master Collection")
#TODO: recursive parent collection loading
if parent_object is None:
parent_object = bpy.data.objects.new(
data['user_name'], self.pointer)
parent_object.uuid = data['user']
is_object_in_master = (
data['user_collection'][0] == "Master Collection")
# TODO: recursive parent collection loading
# Link parent object to the collection
if is_object_in_master:
parent_collection = bpy.data.scenes[data['user_scene'][0]].collection
elif data['user_collection'][0] not in bpy.data.collections.keys():
parent_collection = bpy.data.collections.new(data['user_collection'][0])
parent_collection = bpy.data.scenes[data['user_scene']
[0]].collection
elif data['user_collection'][0] not in bpy.data.collections.keys():
parent_collection = bpy.data.collections.new(
data['user_collection'][0])
else:
parent_collection = bpy.data.collections[data['user_collection'][0]]
parent_collection = bpy.data.collections[data['user_collection'][0]]
if parent_object.name not in parent_collection.objects:
parent_collection.objects.link(parent_object)
# Link parent collection to the scene master collection
if not is_object_in_master and parent_collection.name not in bpy.data.scenes[data['user_scene'][0]].collection.children:
bpy.data.scenes[data['user_scene'][0]].collection. children.link(parent_collection)
bpy.data.scenes[data['user_scene'][0]
].collection. children.link(parent_collection)
current_mode = bpy.context.mode
current_active_object = bpy.context.view_layer.objects.active
# utils.dump_anything.load(target, data)
# with Overrider(name="bpy_",parent=bpy.context) as bpy_:
area, region, rv3d = presence.view3d_find()
# LOAD ARMATURE BONES
if bpy.context.mode != 'OBJECT':
bpy.ops.object.mode_set(mode='OBJECT')
bpy.context.view_layer.objects.active = parent_object
bpy.context.view_layer.objects.active = parent_object
# override = bpy.context.copy()
# override['window'] = bpy.data.window_managers[0].windows[0]
# override['mode'] = 'EDIT_ARMATURE'
# override['window_manager'] = bpy.data.window_managers[0]
# override['area'] = area
# override['region'] = region
# override['screen'] = bpy.data.window_managers[0].windows[0].screen
bpy.ops.object.mode_set(mode='EDIT')
for bone in data['bones']:
if bone not in self.pointer.edit_bones:
new_bone = self.pointer.edit_bones.new(bone)
else:
new_bone = self.pointer.edit_bones[bone]
new_bone.tail = data['bones'][bone]['tail_local']
new_bone.head = data['bones'][bone]['head_local']
new_bone.tail_radius = data['bones'][bone]['tail_radius']
new_bone.head_radius = data['bones'][bone]['head_radius']
bone_data = data['bones'].get(bone)
if 'parent' in data['bones'][bone]:
new_bone.parent = self.pointer.edit_bones[data['bones'][bone]['parent']['name']]
new_bone.use_connect = data['bones'][bone]['use_connect']
bpy.ops.object.mode_set(mode='OBJECT')
# bpy_.mode = 'EDIT_ARMATURE'
new_bone.tail = bone_data['tail_local']
new_bone.head = bone_data['head_local']
new_bone.tail_radius = bone_data['tail_radius']
new_bone.head_radius = bone_data['head_radius']
# bpy_.active_object = armature
# bpy_.selected_objects = [armature]
if 'parent' in bone_data:
new_bone.parent = self.pointer.edit_bones[data['bones']
[bone]['parent']]
new_bone.use_connect = bone_data['use_connect']
def dump(self, pointer=None):
utils.dump_anything.load(new_bone, bone_data)
if bpy.context.mode != 'OBJECT':
bpy.ops.object.mode_set(mode='OBJECT')
bpy.context.view_layer.objects.active = current_active_object
# TODO: clean way to restore previous context
if 'EDIT' in current_mode:
bpy.ops.object.mode_set(mode='EDIT')
def dump_implementation(self, data, pointer=None):
assert(pointer)
data = utils.dump_datablock(pointer, 4)
#get the parent Object
dumper = utils.dump_anything.Dumper()
dumper.depth = 4
dumper.include_filter = [
'bones',
'tail_local',
'head_local',
'tail_radius',
'head_radius',
'use_connect',
'parent',
'name',
'layers'
]
data = dumper.dump(pointer)
for bone in pointer.bones:
if bone.parent:
data['bones'][bone.name]['parent'] = bone.parent.name
# get the parent Object
object_users = utils.get_datablock_users(pointer)[0]
data['user'] = object_users.name
data['user'] = object_users.uuid
data['user_name'] = object_users.name
#get parent collection
# get parent collection
container_users = utils.get_datablock_users(object_users)
data['user_collection'] = [item.name for item in container_users if isinstance(item,bpy.types.Collection)]
data['user_scene'] = [item.name for item in container_users if isinstance(item,bpy.types.Scene)]
data['user_collection'] = [
item.name for item in container_users if isinstance(item, bpy.types.Collection)]
data['user_scene'] = [
item.name for item in container_users if isinstance(item, bpy.types.Scene)]
return data
def resolve(self):
assert(self.data)
self.pointer = bpy.data.armatures.get(self.data['name'])
def diff(self):
False
def is_valid(self):
return bpy.data.armatures.get(self.data['name'])
bl_id = "armatures"
bl_class = bpy.types.Armature
bl_rep_class = BlArmature
bl_delay_refresh = 1
bl_delay_apply = 0
bl_automatic_push = True
bl_icon = 'ARMATURE_DATA'

View File

@ -6,6 +6,13 @@ from .bl_datablock import BlDatablock
class BlCamera(BlDatablock):
bl_id = "cameras"
bl_class = bpy.types.Camera
bl_delay_refresh = 1
bl_delay_apply = 1
bl_automatic_push = True
bl_icon = 'CAMERA_DATA'
def load(self, data, target):
utils.dump_anything.load(target, data)
@ -18,7 +25,7 @@ class BlCamera(BlDatablock):
def construct(self, data):
return bpy.data.cameras.new(data["name"])
def dump(self, pointer=None):
def dump_implementation(self, data, pointer=None):
assert(pointer)
dumper = utils.dump_anything.Dumper()
@ -45,17 +52,5 @@ class BlCamera(BlDatablock):
]
return dumper.dump(pointer)
def resolve(self):
self.pointer = utils.find_from_attr('uuid', self.uuid, bpy.data.cameras)
def is_valid(self):
return bpy.data.cameras.get(self.data['name'])
bl_id = "cameras"
bl_class = bpy.types.Camera
bl_rep_class = BlCamera
bl_delay_refresh = 1
bl_delay_apply = 1
bl_automatic_push = True
bl_icon = 'CAMERA_DATA'

View File

@ -6,6 +6,13 @@ from .bl_datablock import BlDatablock
class BlCollection(BlDatablock):
bl_id = "collections"
bl_icon = 'FILE_FOLDER'
bl_class = bpy.types.Collection
bl_delay_refresh = 1
bl_delay_apply = 1
bl_automatic_push = True
def construct(self, data):
if self.is_library:
with bpy.data.libraries.load(filepath=bpy.data.libraries[self.data['library']].filepath, link=True) as (sourceData, targetData):
@ -47,7 +54,7 @@ class BlCollection(BlDatablock):
if collection.uuid not in data["children"]:
target.children.unlink(collection)
def dump(self, pointer=None):
def dump_implementation(self, data, pointer=None):
assert(pointer)
data = {}
data['name'] = pointer.name
@ -68,19 +75,8 @@ class BlCollection(BlDatablock):
data['children'] = collection_children
# dumper = utils.dump_anything.Dumper()
# dumper.depth = 2
# dumper.include_filter = ['name','objects', 'children']
# return dumper.dump(pointer)
return data
def resolve(self):
self.pointer = utils.find_from_attr(
'uuid',
self.uuid,
bpy.data.collections)
def resolve_dependencies(self):
deps = []
@ -94,11 +90,3 @@ class BlCollection(BlDatablock):
def is_valid(self):
return bpy.data.collections.get(self.data['name'])
bl_id = "collections"
bl_icon = 'FILE_FOLDER'
bl_class = bpy.types.Collection
bl_rep_class = BlCollection
bl_delay_refresh = 1
bl_delay_apply = 1
bl_automatic_push = True

View File

@ -5,6 +5,13 @@ from .. import utils
from .bl_datablock import BlDatablock
class BlCurve(BlDatablock):
bl_id = "curves"
bl_class = bpy.types.Curve
bl_delay_refresh = 1
bl_delay_apply = 1
bl_automatic_push = True
bl_icon = 'CURVE_DATA'
def construct(self, data):
return bpy.data.curves.new(data["name"], 'CURVE')
@ -29,7 +36,7 @@ class BlCurve(BlDatablock):
utils.dump_anything.load(
new_spline.points[point_index], data['splines'][spline]["points"][point_index])
def dump(self, pointer=None):
def dump_implementation(self, data, pointer=None):
assert(pointer)
data = utils.dump_datablock(pointer, 1)
data['splines'] = {}
@ -52,15 +59,5 @@ class BlCurve(BlDatablock):
data['type'] = 'CURVE'
return data
def resolve(self):
self.pointer = utils.find_from_attr('uuid', self.uuid, bpy.data.curves)
def is_valid(self):
return bpy.data.curves.get(self.data['name'])
bl_id = "curves"
bl_class = bpy.types.Curve
bl_rep_class = BlCurve
bl_delay_refresh = 1
bl_delay_apply = 1
bl_automatic_push = True
bl_icon = 'CURVE_DATA'

View File

@ -4,26 +4,84 @@ import mathutils
from .. import utils
from ..libs.replication.replication.data import ReplicatedDatablock
from ..libs.replication.replication.constants import UP
from ..libs.replication.replication.constants import DIFF_BINARY
def dump_driver(driver):
dumper = utils.dump_anything.Dumper()
dumper.depth = 6
data = dumper.dump(driver)
return data
def load_driver(target_datablock, src_driver):
drivers = target_datablock.animation_data.drivers
src_driver_data = src_driver['driver']
new_driver = drivers.new(src_driver['data_path'])
# Settings
new_driver.driver.type = src_driver_data['type']
new_driver.driver.expression = src_driver_data['expression']
utils.dump_anything.load(new_driver, src_driver)
# Variables
for src_variable in src_driver_data['variables']:
src_var_data = src_driver_data['variables'][src_variable]
new_var = new_driver.driver.variables.new()
new_var.name = src_var_data['name']
new_var.type = src_var_data['type']
for src_target in src_var_data['targets']:
src_target_data = src_var_data['targets'][src_target]
new_var.targets[src_target].id = utils.resolve_from_id(
src_target_data['id'], src_target_data['id_type'])
utils.dump_anything.load(
new_var.targets[src_target], src_target_data)
# Fcurve
new_fcurve = new_driver.keyframe_points
for p in reversed(new_fcurve):
new_fcurve.remove(p, fast=True)
new_fcurve.add(len(src_driver['keyframe_points']))
for index, src_point in enumerate(src_driver['keyframe_points']):
new_point = new_fcurve[index]
utils.dump_anything.load(
new_point, src_driver['keyframe_points'][src_point])
class BlDatablock(ReplicatedDatablock):
"""BlDatablock
bl_id : blender internal storage identifier
bl_class : blender internal type
bl_delay_refresh : refresh rate in second for observers
bl_delay_apply : refresh rate in sec for apply
bl_automatic_push : boolean
bl_icon : type icon (blender icon name)
"""
bl_id = "scenes"
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
pointer = kwargs.get('pointer', None)
# TODO: use is_library_indirect
self.is_library = (pointer and hasattr(pointer, 'library') and
pointer.library) or \
(self.data and 'library' in self.data)
(self.data and 'library' in self.data)
if self.is_library:
self.load = self.load_library
self.dump = self.dump_library
self.diff = self.diff_library
self.resolve_dependencies = self.resolve_dependencies_library
if self.pointer and hasattr(self.pointer, 'uuid'):
self.pointer.uuid = self.uuid
self.diff_method = DIFF_BINARY
def library_apply(self):
"""Apply stored data
@ -50,13 +108,71 @@ class BlDatablock(ReplicatedDatablock):
def resolve_dependencies_library(self):
return [self.pointer.library]
def resolve(self):
datablock_ref = None
datablock_root = getattr(bpy.data, self.bl_id)
datablock_ref = utils.find_from_attr('uuid', self.uuid, datablock_root)
# In case of lost uuid (ex: undo), resolve by name and reassign it
# TODO: avoid reference storing
if not datablock_ref:
datablock_ref = getattr(
bpy.data, self.bl_id).get(self.data['name'])
if datablock_ref:
setattr(datablock_ref, 'uuid', self.uuid)
self.pointer = datablock_ref
def dump(self, pointer=None):
data = {}
if utils.has_action(pointer):
dumper = utils.dump_anything.Dumper()
dumper.include_filter = ['action']
data['animation_data'] = dumper.dump(pointer.animation_data)
if utils.has_driver(pointer):
dumped_drivers = {'animation_data': {'drivers': []}}
for driver in pointer.animation_data.drivers:
dumped_drivers['animation_data']['drivers'].append(
dump_driver(driver))
data.update(dumped_drivers)
data.update(self.dump_implementation(data, pointer=pointer))
return data
def dump_implementation(self, data, target):
raise NotImplementedError
def load(self, data, target):
# Load animation data
if 'animation_data' in data.keys():
if target.animation_data is None:
target.animation_data_create()
for d in target.animation_data.drivers:
target.animation_data.drivers.remove(d)
if 'drivers' in data['animation_data']:
for driver in data['animation_data']['drivers']:
load_driver(target, driver)
if 'action' in data['animation_data']:
target.animation_data.action = bpy.data.actions[data['animation_data']['action']]
self.load_implementation(data, target)
def load_implementation(self, data, target):
raise NotImplementedError
def resolve_dependencies(self):
dependencies = []
if hasattr(self.pointer,'animation_data') and self.pointer.animation_data:
if utils.has_action(self.pointer):
dependencies.append(self.pointer.animation_data.action)
return dependencies
def is_valid(self):
raise NotImplementedError
raise NotImplementedError

View File

@ -13,7 +13,7 @@ def load_gpencil_layer(target=None, data=None, create=False):
for frame in data["frames"]:
tframe = target.frames.new(frame)
tframe = target.frames.new(data["frames"][frame]['frame_number'])
# utils.dump_anything.load(tframe, data["frames"][frame])
for stroke in data["frames"][frame]["strokes"]:
@ -34,6 +34,13 @@ def load_gpencil_layer(target=None, data=None, create=False):
class BlGpencil(BlDatablock):
bl_id = "grease_pencils"
bl_class = bpy.types.GreasePencil
bl_delay_refresh = 5
bl_delay_apply = 5
bl_automatic_push = True
bl_icon = 'GREASEPENCIL'
def construct(self, data):
return bpy.data.grease_pencils.new(data["name"])
@ -57,16 +64,13 @@ class BlGpencil(BlDatablock):
for mat in data['materials']:
target.materials.append(bpy.data.materials[mat])
def dump(self, pointer=None):
def dump_implementation(self, data, pointer=None):
assert(pointer)
data = utils.dump_datablock(pointer, 2)
utils.dump_datablock_attibutes(
pointer, ['layers'], 9, data)
return data
def resolve(self):
self.pointer = utils.find_from_attr('uuid', self.uuid, bpy.data.grease_pencils)
def resolve_dependencies(self):
deps = []
@ -76,12 +80,4 @@ class BlGpencil(BlDatablock):
return deps
def is_valid(self):
return bpy.data.grease_pencils.get(self.data['name'])
bl_id = "grease_pencils"
bl_class = bpy.types.GreasePencil
bl_rep_class = BlGpencil
bl_delay_refresh = 5
bl_delay_apply = 5
bl_automatic_push = True
bl_icon = 'GREASEPENCIL'
return bpy.data.grease_pencils.get(self.data['name'])

View File

@ -15,8 +15,11 @@ def dump_image(image):
image.save()
if image.source == "FILE":
image_path = bpy.path.abspath(image.filepath_raw)
image_directory = os.path.dirname(image_path)
os.makedirs(image_directory, exist_ok=True)
image.save()
file = open(image.filepath_raw, "rb")
file = open(image_path, "rb")
pixels = file.read()
file.close()
else:
@ -24,6 +27,13 @@ def dump_image(image):
return pixels
class BlImage(BlDatablock):
bl_id = "images"
bl_class = bpy.types.Image
bl_delay_refresh = 0
bl_delay_apply = 1
bl_automatic_push = False
bl_icon = 'IMAGE_DATA'
def construct(self, data):
return bpy.data.images.new(
name=data['name'],
@ -44,32 +54,30 @@ class BlImage(BlDatablock):
image.source = 'FILE'
image.filepath = img_path
image.colorspace_settings.name = data["colorspace_settings"]["name"]
def dump(self, pointer=None):
def dump_implementation(self, data, pointer=None):
assert(pointer)
data = {}
data['pixels'] = dump_image(pointer)
utils.dump_datablock_attibutes(pointer, [], 2, data)
data = utils.dump_datablock_attibutes(
pointer,
["name", 'size', 'height', 'alpha', 'float_buffer', 'filepath', 'source'],
2,
data)
dumper = utils.dump_anything.Dumper()
dumper.depth = 2
dumper.include_filter = [
"name",
'size',
'height',
'alpha',
'float_buffer',
'filepath',
'source',
'colorspace_settings']
data.update(dumper.dump(pointer))
return data
def resolve(self):
self.pointer = utils.find_from_attr('uuid', self.uuid, bpy.data.images)
def diff(self):
return False
def is_valid(self):
return bpy.data.images.get(self.data['name'])
bl_id = "images"
bl_class = bpy.types.Image
bl_rep_class = BlImage
bl_delay_refresh = 0
bl_delay_apply = 0
bl_automatic_push = False
bl_icon = 'IMAGE_DATA'

View File

@ -6,6 +6,13 @@ from .bl_datablock import BlDatablock
class BlLattice(BlDatablock):
bl_id = "lattices"
bl_class = bpy.types.Lattice
bl_delay_refresh = 1
bl_delay_apply = 1
bl_automatic_push = True
bl_icon = 'LATTICE_DATA'
def load(self, data, target):
utils.dump_anything.load(target, data)
@ -38,17 +45,8 @@ class BlLattice(BlDatablock):
return data
def resolve(self):
self.pointer = utils.find_from_attr('uuid', self.uuid, bpy.data.lattices)
def is_valid(self):
return bpy.data.lattices.get(self.data['name'])
bl_id = "lattices"
bl_class = bpy.types.Lattice
bl_rep_class = BlLattice
bl_delay_refresh = 1
bl_delay_apply = 1
bl_automatic_push = True
bl_icon = 'LATTICE_DATA'

View File

@ -6,6 +6,13 @@ from .bl_datablock import BlDatablock
class BlLibrary(BlDatablock):
bl_id = "libraries"
bl_class = bpy.types.Library
bl_delay_refresh = 1
bl_delay_apply = 1
bl_automatic_push = True
bl_icon = 'LIBRARY_DATA_DIRECT'
def construct(self, data):
with bpy.data.libraries.load(filepath=data["filepath"], link=True) as (sourceData, targetData):
targetData = sourceData
@ -17,16 +24,5 @@ class BlLibrary(BlDatablock):
assert(pointer)
return utils.dump_datablock(pointer, 1)
def resolve(self):
self.pointer = utils.find_from_attr('uuid', self.uuid, bpy.data.libraries)
def is_valid(self):
return bpy.data.libraries.get(self.data['name'])
bl_id = "libraries"
bl_class = bpy.types.Library
bl_rep_class = BlLibrary
bl_delay_refresh = 1
bl_delay_apply = 1
bl_automatic_push = True
bl_icon = 'LIBRARY_DATA_DIRECT'
return bpy.data.libraries.get(self.data['name'])

View File

@ -6,13 +6,20 @@ from .bl_datablock import BlDatablock
class BlLight(BlDatablock):
bl_id = "lights"
bl_class = bpy.types.Light
bl_delay_refresh = 1
bl_delay_apply = 1
bl_automatic_push = True
bl_icon = 'LIGHT_DATA'
def construct(self, data):
return bpy.data.lights.new(data["name"], data["type"])
def load(self, data, target):
utils.dump_anything.load(target, data)
def dump(self, pointer=None):
def dump_implementation(self, data, pointer=None):
assert(pointer)
dumper = utils.dump_anything.Dumper()
dumper.depth = 3
@ -39,16 +46,6 @@ class BlLight(BlDatablock):
data = dumper.dump(pointer)
return data
def resolve(self):
self.pointer = utils.find_from_attr('uuid', self.uuid, bpy.data.lights)
def is_valid(self):
return bpy.data.lights.get(self.data['name'])
bl_id = "lights"
bl_class = bpy.types.Light
bl_rep_class = BlLight
bl_delay_refresh = 1
bl_delay_apply = 1
bl_automatic_push = True
bl_icon = 'LIGHT_DATA'

View File

@ -1,24 +1,44 @@
import bpy
import mathutils
import logging
from .. import utils
from .bl_datablock import BlDatablock
logger = logging.getLogger(__name__)
class BlLightprobe(BlDatablock):
bl_id = "lightprobes"
bl_class = bpy.types.LightProbe
bl_delay_refresh = 1
bl_delay_apply = 1
bl_automatic_push = True
bl_icon = 'LIGHTPROBE_GRID'
class BlLightProbe(BlDatablock):
def load(self, data, target):
utils.dump_anything.load(target, data)
def construct(self, data):
return bpy.data.lightprobes.new(data["name"])
type = 'CUBE' if data['type'] == 'CUBEMAP' else data['type']
# See https://developer.blender.org/D6396
if bpy.app.version[1] >= 83:
return bpy.data.lightprobes.new(data["name"], type)
else:
logger.warning("Lightprobe replication only supported since 2.83. See https://developer.blender.org/D6396")
def dump(self, pointer=None):
assert(pointer)
if bpy.app.version[1] < 83:
logger.warning("Lightprobe replication only supported since 2.83. See https://developer.blender.org/D6396")
dumper = utils.dump_anything.Dumper()
dumper.depth = 1
dumper.include_filter = [
"name",
'type',
'influence_type',
'influence_distance',
'falloff',
@ -29,21 +49,15 @@ class BlLightProbe(BlDatablock):
'use_custom_parallax',
'parallax_type',
'parallax_distance',
'grid_resolution_x',
'grid_resolution_y',
'grid_resolution_z',
'visibility_buffer_bias',
'visibility_bleed_bias',
'visibility_blur'
]
return dumper.dump(pointer)
def resolve(self):
self.pointer = utils.find_from_attr('uuid', self.uuid, bpy.data.lattices)
def is_valid(self):
return bpy.data.lattices.get(self.data['name'])
bl_id = "lightprobes"
bl_class = bpy.types.LightProbe
bl_rep_class = BlLightProbe
bl_delay_refresh = 1
bl_delay_apply = 1
bl_automatic_push = True
bl_icon = 'LIGHTPROBE_GRID'

View File

@ -68,10 +68,17 @@ def load_link(target_node_tree, source):
class BlMaterial(BlDatablock):
bl_id = "materials"
bl_class = bpy.types.Material
bl_delay_refresh = 10
bl_delay_apply = 10
bl_automatic_push = True
bl_icon = 'MATERIAL_DATA'
def construct(self, data):
return bpy.data.materials.new(data["name"])
def load(self, data, target):
def load_implementation(self, data, target):
target.name = data['name']
if data['is_grease_pencil']:
if not target.is_grease_pencil:
@ -88,6 +95,8 @@ class BlMaterial(BlDatablock):
target.node_tree.nodes.clear()
utils.dump_anything.load(target,data)
# Load nodes
for node in data["node_tree"]["nodes"]:
load_node(target.node_tree, data["node_tree"]["nodes"][node])
@ -98,7 +107,7 @@ class BlMaterial(BlDatablock):
for link in data["node_tree"]["links"]:
load_link(target.node_tree, data["node_tree"]["links"][link])
def dump(self, pointer=None):
def dump_implementation(self, data, pointer=None):
assert(pointer)
mat_dumper = utils.dump_anything.Dumper()
mat_dumper.depth = 2
@ -115,6 +124,7 @@ class BlMaterial(BlDatablock):
node_dumper.depth = 1
node_dumper.exclude_filter = [
"dimensions",
"show_expanded"
"select",
"bl_height_min",
"bl_height_max",
@ -133,7 +143,12 @@ class BlMaterial(BlDatablock):
input_dumper.include_filter = ["default_value"]
links_dumper = utils.dump_anything.Dumper()
links_dumper.depth = 3
links_dumper.exclude_filter = ["dimensions"]
links_dumper.include_filter = [
"name",
"to_node",
"from_node",
"from_socket",
"to_socket"]
data = mat_dumper.dump(pointer)
if pointer.use_nodes:
@ -175,9 +190,6 @@ class BlMaterial(BlDatablock):
utils.dump_datablock_attibutes(pointer, ["grease_pencil"], 3, data)
return data
def resolve(self):
self.pointer = utils.find_from_attr('uuid', self.uuid, bpy.data.materials)
def resolve_dependencies(self):
# TODO: resolve node group deps
deps = []
@ -194,11 +206,3 @@ class BlMaterial(BlDatablock):
def is_valid(self):
return bpy.data.materials.get(self.data['name'])
bl_id = "materials"
bl_class = bpy.types.Material
bl_rep_class = BlMaterial
bl_delay_refresh = 10
bl_delay_apply = 10
bl_automatic_push = True
bl_icon = 'MATERIAL_DATA'

View File

@ -3,14 +3,17 @@ import bmesh
import mathutils
from .. import utils
from ..libs.replication.replication.constants import DIFF_BINARY
from .bl_datablock import BlDatablock
def dump_mesh(mesh, data={}):
import bmesh
mesh_data = data
mesh_buffer = bmesh.new()
# https://blog.michelanders.nl/2016/02/copying-vertices-to-numpy-arrays-in_4.html
mesh_buffer.from_mesh(mesh)
uv_layer = mesh_buffer.loops.layers.uv.verify()
@ -72,23 +75,28 @@ def dump_mesh(mesh, data={}):
uv_layers.append(uv_layer.name)
mesh_data["uv_layers"] = uv_layers
return mesh_data
# return mesh_data
class BlMesh(BlDatablock):
bl_id = "meshes"
bl_class = bpy.types.Mesh
bl_delay_refresh = 10
bl_delay_apply = 10
bl_automatic_push = True
bl_icon = 'MESH_DATA'
def construct(self, data):
instance = bpy.data.meshes.new(data["name"])
instance.uuid = self.uuid
return instance
def load(self, data, target):
def load_implementation(self, data, target):
if not target or not target.is_editmode:
# 1 - LOAD MATERIAL SLOTS
material_to_load = []
material_to_load = utils.revers(data["materials"])
target.materials.clear()
# SLots
i = 0
for m in data["material_list"]:
target.materials.append(bpy.data.materials[m])
@ -99,23 +107,25 @@ class BlMesh(BlDatablock):
v = mesh_buffer.verts.new(data["verts"][i]["co"])
v.normal = data["verts"][i]["normal"]
mesh_buffer.verts.ensure_lookup_table()
for i in data["edges"]:
verts = mesh_buffer.verts
v1 = data["edges"][i]["verts"][0]
v2 = data["edges"][i]["verts"][1]
edge = mesh_buffer.edges.new([verts[v1], verts[v2]])
edge.smooth = data["edges"][i]["smooth"]
mesh_buffer.edges.ensure_lookup_table()
for p in data["faces"]:
verts = []
for v in data["faces"][p]["verts"]:
verts.append(mesh_buffer.verts[v])
if len(verts) > 0:
f = mesh_buffer.faces.new(verts)
uv_layer = mesh_buffer.loops.layers.uv.verify()
f.smooth = data["faces"][p]["smooth"]
f.normal = data["faces"][p]["normal"]
f.index = data["faces"][p]["index"]
@ -129,21 +139,25 @@ class BlMesh(BlDatablock):
# 3 - LOAD METADATA
# uv's
for uv_layer in data['uv_layers']:
target.uv_layers.new(name=uv_layer)
utils.dump_anything.load(target.uv_layers, data['uv_layers'])
bevel_layer = mesh_buffer.verts.layers.bevel_weight.verify()
skin_layer = mesh_buffer.verts.layers.skin.verify()
utils.dump_anything.load(target, data)
def dump(self, pointer=None):
utils.dump_anything.load(target, data)
def dump_implementation(self, data, pointer=None):
assert(pointer)
data = utils.dump_datablock(pointer, 2)
data = dump_mesh(pointer, data)
dumper = utils.dump_anything.Dumper()
dumper.depth = 2
dumper.include_filter = [
'name',
'use_auto_smooth',
'auto_smooth_angle'
]
data = dumper.dump(pointer)
dump_mesh(pointer, data)
# Fix material index
m_list = []
for material in pointer.materials:
@ -154,25 +168,14 @@ class BlMesh(BlDatablock):
return data
def resolve(self):
self.pointer = utils.find_from_attr('uuid', self.uuid, bpy.data.meshes)
def resolve_dependencies(self):
deps = []
for material in self.pointer.materials:
if material:
deps.append(material)
return deps
def is_valid(self):
return bpy.data.meshes.get(self.data['name'])
bl_id = "meshes"
bl_class = bpy.types.Mesh
bl_rep_class = BlMesh
bl_delay_refresh = 10
bl_delay_apply = 10
bl_automatic_push = True
bl_icon = 'MESH_DATA'

View File

@ -6,6 +6,13 @@ from .bl_datablock import BlDatablock
class BlMetaball(BlDatablock):
bl_id = "metaballs"
bl_class = bpy.types.MetaBall
bl_delay_refresh = 1
bl_delay_apply = 1
bl_automatic_push = True
bl_icon = 'META_BALL'
def construct(self, data):
return bpy.data.metaballs.new(data["name"])
@ -17,7 +24,7 @@ class BlMetaball(BlDatablock):
new_element = target.elements.new(type=data["elements"][element]['type'])
utils.dump_anything.load(new_element, data["elements"][element])
def dump(self, pointer=None):
def dump_implementation(self, data, pointer=None):
assert(pointer)
dumper = utils.dump_anything.Dumper()
dumper.depth = 3
@ -26,16 +33,5 @@ class BlMetaball(BlDatablock):
data = dumper.dump(pointer)
return data
def resolve(self):
self.pointer = utils.find_from_attr('uuid', self.uuid, bpy.data.metaballs)
def is_valid(self):
return bpy.data.metaballs.get(self.data['name'])
bl_id = "metaballs"
bl_class = bpy.types.MetaBall
bl_rep_class = BlMetaball
bl_delay_refresh = 1
bl_delay_apply = 1
bl_automatic_push = True
bl_icon = 'META_BALL'

View File

@ -1,11 +1,43 @@
import bpy
import mathutils
import logging
from .. import utils
from .bl_datablock import BlDatablock
logger = logging.getLogger(__name__)
def load_constraints(target, data):
for local_constraint in target.constraints:
if local_constraint.name not in data:
target.constraints.remove(local_constraint)
for constraint in data:
target_constraint = target.constraints.get(constraint)
if not target_constraint:
target_constraint = target.constraints.new(
data[constraint]['type'])
utils.dump_anything.load(
target_constraint, data[constraint])
def load_pose(target_bone, data):
target_bone.rotation_mode = data['rotation_mode']
utils.dump_anything.load(target_bone, data)
class BlObject(BlDatablock):
bl_id = "objects"
bl_class = bpy.types.Object
bl_delay_refresh = 1
bl_delay_apply = 1
bl_automatic_push = True
bl_icon = 'OBJECT_DATA'
def construct(self, data):
pointer = None
@ -13,7 +45,7 @@ class BlObject(BlDatablock):
with bpy.data.libraries.load(filepath=bpy.data.libraries[self.data['library']].filepath, link=True) as (sourceData, targetData):
targetData.objects = [
name for name in sourceData.objects if name == self.data['name']]
instance = bpy.data.objects[self.data['name']]
instance.uuid = self.uuid
return instance
@ -42,24 +74,31 @@ class BlObject(BlDatablock):
elif data["data"] in bpy.data.speakers.keys():
pointer = bpy.data.speakers[data["data"]]
elif data["data"] in bpy.data.lightprobes.keys():
pass
# bpy need to support correct lightprobe creation from python
# pointer = bpy.data.lightprobes[data["data"]]
instance = bpy.data.objects.new(data["name"], pointer)
# Only supported since 2.83
if bpy.app.version[1] >= 83:
pointer = bpy.data.lightprobes[data["data"]]
else:
logger.warning(
"Lightprobe replication only supported since 2.83. See https://developer.blender.org/D6396")
instance = bpy.data.objects.new(data["name"], pointer)
instance.uuid = self.uuid
return instance
def load(self, data, target):
target.matrix_world = mathutils.Matrix(data["matrix_world"])
def load_implementation(self, data, target):
# Load transformation data
rot_mode = 'rotation_quaternion' if data['rotation_mode'] == 'QUATERNION' else 'rotation_euler'
target.rotation_mode = data['rotation_mode']
target.location = data['location']
setattr(target, rot_mode, data[rot_mode])
target.scale = data['scale']
target.name = data["name"]
# Load modifiers
if hasattr(target, 'modifiers'):
for local_modifier in target.modifiers:
if local_modifier.name not in data['modifiers']:
target.modifiers.remove(local_modifier)
# TODO: smarter selective update
target.modifiers.clear()
for modifier in data['modifiers']:
target_modifier = target.modifiers.get(modifier)
@ -70,6 +109,40 @@ class BlObject(BlDatablock):
utils.dump_anything.load(
target_modifier, data['modifiers'][modifier])
# Load constraints
# Object
if hasattr(target, 'constraints') and 'constraints' in data:
load_constraints(target, data['constraints'])
# Pose
if 'pose' in data:
if not target.pose:
raise Exception('No pose data yet (Fixed in a near futur)')
# Bone groups
for bg_name in data['pose']['bone_groups']:
bg_data = data['pose']['bone_groups'].get(bg_name)
bg_target = target.pose.bone_groups.get(bg_name)
if not bg_target:
bg_target = target.pose.bone_groups.new(name=bg_name)
utils.dump_anything.load(bg_target, bg_data)
# target.pose.bone_groups.get
# Bones
for bone in data['pose']['bones']:
target_bone = target.pose.bones.get(bone)
bone_data = data['pose']['bones'].get(bone)
if 'constraints' in bone_data.keys():
load_constraints(
target_bone, bone_data['constraints'])
load_pose(target_bone, bone_data)
if 'bone_index' in bone_data.keys():
target_bone.bone_group = target.pose.bone_group[bone_data['bone_group_index']]
# Load relations
if 'children' in data.keys():
for child in data['children']:
@ -80,17 +153,47 @@ class BlObject(BlDatablock):
target.empty_display_type = data['empty_display_type']
# Instancing
target.instance_type = data['instance_type']
target.instance_type = data['instance_type']
if data['instance_type'] == 'COLLECTION':
target.instance_collection = bpy.data.collections[data['instance_collection']]
def dump(self, pointer=None):
# vertex groups
if 'vertex_groups' in data:
target.vertex_groups.clear()
for vg in data['vertex_groups']:
vertex_group = target.vertex_groups.new(name=vg['name'])
for vert in vg['vertices']:
vertex_group.add(
[vert['index']], vert['weight'], 'REPLACE')
# SHAPE KEYS
if 'shape_keys' in data:
target.shape_key_clear()
object_data = target.data
# Create keys and load vertices coords
for key_block in data['shape_keys']['key_blocks']:
key_data = data['shape_keys']['key_blocks'][key_block]
target.shape_key_add(name=key_block)
utils.dump_anything.load(
target.data.shape_keys.key_blocks[key_block], key_data)
for vert in key_data['data']:
target.data.shape_keys.key_blocks[key_block].data[vert].co = key_data['data'][vert]['co']
# Load relative key after all
for key_block in data['shape_keys']['key_blocks']:
reference = data['shape_keys']['key_blocks'][key_block]['relative_key']
target.data.shape_keys.key_blocks[key_block].relative_key = target.data.shape_keys.key_blocks[reference]
def dump_implementation(self, data, pointer=None):
assert(pointer)
dumper = utils.dump_anything.Dumper()
dumper.depth = 1
dumper.include_filter = [
"name",
"matrix_world",
"rotation_mode",
"parent",
"data",
@ -99,8 +202,12 @@ class BlObject(BlDatablock):
"empty_display_type",
"empty_display_size",
"instance_collection",
"instance_type"
"instance_type",
"location",
"scale",
'rotation_quaternion' if pointer.rotation_mode == 'QUATERNION' else 'rotation_euler',
]
data = dumper.dump(pointer)
if self.is_library:
@ -109,24 +216,120 @@ class BlObject(BlDatablock):
# MODIFIERS
if hasattr(pointer, 'modifiers'):
dumper.include_filter = None
dumper.depth = 2
data["modifiers"] = {}
for index, modifier in enumerate(pointer.modifiers):
data["modifiers"][modifier.name] = dumper.dump(modifier)
data["modifiers"][modifier.name]['m_index'] = index
# CONSTRAINTS
# OBJECT
if hasattr(pointer, 'constraints'):
dumper.depth = 3
data["modifiers"] = dumper.dump(pointer.modifiers)
data["constraints"] = dumper.dump(pointer.constraints)
# POSE
if hasattr(pointer, 'pose') and pointer.pose:
# BONES
bones = {}
for bone in pointer.pose.bones:
bones[bone.name] = {}
dumper.depth = 1
rotation = 'rotation_quaternion' if bone.rotation_mode == 'QUATERNION' else 'rotation_euler'
group_index = 'bone_group_index' if bone.bone_group else None
dumper.include_filter = [
'rotation_mode',
'location',
'scale',
'custom_shape',
'use_custom_shape_bone_size',
'custom_shape_scale',
group_index,
rotation
]
bones[bone.name] = dumper.dump(bone)
dumper.include_filter = []
dumper.depth = 3
bones[bone.name]["constraints"] = dumper.dump(bone.constraints)
data['pose'] = {'bones': bones}
# GROUPS
bone_groups = {}
for group in pointer.pose.bone_groups:
dumper.depth = 3
dumper.include_filter = [
'name',
'color_set'
]
bone_groups[group.name] = dumper.dump(group)
data['pose']['bone_groups'] = bone_groups
# CHILDS
if len(pointer.children) > 0:
childs = []
for child in pointer.children:
childs.append(child.name)
data["children"] = childs
# VERTEx GROUP
if len(pointer.vertex_groups) > 0:
vg_data = []
for vg in pointer.vertex_groups:
vg_idx = vg.index
dumped_vg = {}
dumped_vg['name'] = vg.name
vertices = []
for v in pointer.data.vertices:
for vg in v.groups:
if vg.group == vg_idx:
vertices.append({
'index': v.index,
'weight': vg.weight
})
dumped_vg['vertices'] = vertices
vg_data.append(dumped_vg)
data['vertex_groups'] = vg_data
# SHAPE KEYS
pointer_data = pointer.data
if hasattr(pointer_data, 'shape_keys') and pointer_data.shape_keys:
dumper = utils.dump_anything.Dumper()
dumper.depth = 2
dumper.include_filter = [
'reference_key',
'use_relative'
]
data['shape_keys'] = dumper.dump(pointer_data.shape_keys)
data['shape_keys']['reference_key'] = pointer_data.shape_keys.reference_key.name
key_blocks = {}
for key in pointer_data.shape_keys.key_blocks:
dumper.depth = 3
dumper.include_filter = [
'name',
'data',
'mute',
'value',
'slider_min',
'slider_max',
'data',
'co'
]
key_blocks[key.name] = dumper.dump(key)
key_blocks[key.name]['relative_key'] = key.relative_key.name
data['shape_keys']['key_blocks'] = key_blocks
return data
def resolve(self):
self.pointer = utils.find_from_attr('uuid', self.uuid, bpy.data.objects)
def resolve_dependencies(self):
deps = []
deps = super().resolve_dependencies()
# Avoid Empty case
if self.pointer.data:
@ -138,19 +341,10 @@ class BlObject(BlDatablock):
deps.append(self.pointer.library)
if self.pointer.instance_type == 'COLLECTION':
#TODO: uuid based
# TODO: uuid based
deps.append(self.pointer.instance_collection)
return deps
def is_valid(self):
return bpy.data.objects.get(self.data['name'])
bl_id = "objects"
bl_class = bpy.types.Object
bl_rep_class = BlObject
bl_delay_refresh = 1
bl_delay_apply = 1
bl_automatic_push = True
bl_icon = 'OBJECT_DATA'

View File

@ -5,6 +5,13 @@ from .. import utils
from .bl_datablock import BlDatablock
class BlScene(BlDatablock):
bl_id = "scenes"
bl_class = bpy.types.Scene
bl_delay_refresh = 1
bl_delay_apply = 1
bl_automatic_push = True
bl_icon = 'SCENE_DATA'
def construct(self, data):
instance = bpy.data.scenes.new(data["name"])
instance.uuid = self.uuid
@ -42,7 +49,7 @@ class BlScene(BlDatablock):
if 'grease_pencil' in data.keys():
target.grease_pencil = bpy.data.grease_pencils[data['grease_pencil']]
def dump(self, pointer=None):
def dump_implementation(self, data, pointer=None):
assert(pointer)
data = {}
@ -58,12 +65,6 @@ class BlScene(BlDatablock):
return data
def resolve(self):
scene_name = self.data['name']
self.pointer = bpy.data.scenes.get(scene_name)
# self.pointer = utils.find_from_attr('uuid', self.uuid, bpy.data.objects)
def resolve_dependencies(self):
deps = []
@ -86,11 +87,4 @@ class BlScene(BlDatablock):
return deps
def is_valid(self):
return bpy.data.scenes.get(self.data['name'])
bl_id = "scenes"
bl_class = bpy.types.Scene
bl_rep_class = BlScene
bl_delay_refresh = 1
bl_delay_apply = 1
bl_automatic_push = True
bl_icon = 'SCENE_DATA'
return bpy.data.scenes.get(self.data['name'])

View File

@ -6,6 +6,13 @@ from .bl_datablock import BlDatablock
class BlSpeaker(BlDatablock):
bl_id = "speakers"
bl_class = bpy.types.Speaker
bl_delay_refresh = 1
bl_delay_apply = 1
bl_automatic_push = True
bl_icon = 'SPEAKER'
def load(self, data, target):
utils.dump_anything.load(target, data)
@ -34,17 +41,6 @@ class BlSpeaker(BlDatablock):
return dumper.dump(pointer)
def resolve(self):
self.pointer = utils.find_from_attr('uuid', self.uuid, bpy.data.lattices)
def is_valid(self):
return bpy.data.lattices.get(self.data['name'])
bl_id = "speakers"
bl_class = bpy.types.Speaker
bl_rep_class = BlSpeaker
bl_delay_refresh = 1
bl_delay_apply = 1
bl_automatic_push = True
bl_icon = 'SPEAKER'

View File

@ -1,65 +0,0 @@
import bpy
import mathutils
import jsondiff
from .. import utils
from .. import presence
from .bl_datablock import BlDatablock
from ..libs.replication.replication.constants import UP
class BlUser(BlDatablock):
def construct(self, name):
return presence.User()
def load(self, data, target):
target.name = data['name']
target.location = data['location']
target.selected_objects = data['selected_objects']
utils.dump_anything.load(target, data)
def apply(self):
if self.pointer:
self.load(data=self.data, target=self.pointer)
presence.refresh_3d_view()
self.state = UP
def dump(self,pointer=None):
data = utils.dump_anything.dump(pointer)
data['location'] = pointer.location
data['color'] = pointer.color
data['selected_objects'] = pointer.selected_objects
data['view_matrix'] = pointer.view_matrix
return data
def update(self):
self.pointer.is_dirty = True
# def diff(self):
# if not self.pointer:
# return False
# if self.pointer.is_dirty:
# self.pointer.is_dirty = False
# return True
# for i,coord in enumerate(self.pointer.location):
# if coord != self.data['location'][i]:
# return True
# return False
def is_valid(self):
return True
bl_id = "users"
bl_class = presence.User
bl_rep_class = BlUser
bl_delay_refresh = .1
bl_delay_apply = .1
bl_automatic_push = True
bl_icon = 'CON_ARMATURE'

View File

@ -7,6 +7,13 @@ from .bl_material import load_link, load_node
class BlWorld(BlDatablock):
bl_id = "worlds"
bl_class = bpy.types.World
bl_delay_refresh = 4
bl_delay_apply = 4
bl_automatic_push = True
bl_icon = 'WORLD_DATA'
def construct(self, data):
return bpy.data.worlds.new(data["name"])
@ -26,7 +33,7 @@ class BlWorld(BlDatablock):
for link in data["node_tree"]["links"]:
load_link(target.node_tree, data["node_tree"]["links"][link])
def dump(self, pointer=None):
def dump_implementation(self, data, pointer=None):
assert(pointer)
world_dumper = utils.dump_anything.Dumper()
@ -83,9 +90,6 @@ class BlWorld(BlDatablock):
pointer.node_tree, ["links"], 3, data['node_tree'])
return data
def resolve(self):
self.pointer = utils.find_from_attr('uuid', self.uuid, bpy.data.worlds)
def resolve_dependencies(self):
deps = []
@ -100,11 +104,3 @@ class BlWorld(BlDatablock):
def is_valid(self):
return bpy.data.worlds.get(self.data['name'])
bl_id = "worlds"
bl_class = bpy.types.World
bl_rep_class = BlWorld
bl_delay_refresh = 4
bl_delay_apply = 4
bl_automatic_push = True
bl_icon = 'WORLD_DATA'

View File

@ -3,10 +3,10 @@ import logging
import bpy
from . import operators, presence, utils
from .bl_types.bl_user import BlUser
from .libs.replication.replication.constants import FETCHED, RP_COMMON
from .libs.replication.replication.constants import FETCHED, RP_COMMON, STATE_INITIAL,STATE_QUITTING, STATE_ACTIVE, STATE_SYNCING, STATE_SRV_SYNC
logger = logging.getLogger(__name__)
logger.setLevel(logging.WARNING)
class Delayable():
@ -64,84 +64,114 @@ class ApplyTimer(Timer):
super().__init__(timout)
def execute(self):
if operators.client:
nodes = operators.client.list(filter=self._type)
client = operators.client
if client and client.state['STATE'] == STATE_ACTIVE:
nodes = client.list(filter=self._type)
for node in nodes:
node_ref = operators.client.get(uuid=node)
node_ref = client.get(uuid=node)
if node_ref.state == FETCHED:
try:
operators.client.apply(node)
client.apply(node)
except Exception as e:
logger.error("fail to apply {}: {}".format(node_ref.uuid,e))
logger.error(
"fail to apply {}: {}".format(node_ref.uuid, e))
class DynamicRightSelectTimer(Timer):
def __init__(self, timout=.1):
super().__init__(timout)
self.last_selection = []
self._last_selection = []
self._user = None
self._right_strategy = RP_COMMON
def execute(self):
if operators.client:
users = operators.client.list(filter=BlUser)
session = operators.client
settings = bpy.context.window_manager.session
for user in users:
user_ref = operators.client.get(uuid=user)
settings = bpy.context.window_manager.session
if session and session.state['STATE'] == STATE_ACTIVE:
# Find user
if self._user is None:
self._user = session.online_users.get(settings.username)
# Local user
if user_ref.pointer:
current_selection = utils.get_selected_objects(
bpy.context.scene)
if current_selection != self.last_selection:
right_strategy = operators.client.get_config()[
'right_strategy']
if right_strategy == RP_COMMON:
obj_common = [
o for o in self.last_selection if o not in current_selection]
obj_ours = [
o for o in current_selection if o not in self.last_selection]
if self._right_strategy is None:
self._right_strategy = session.config[
'right_strategy']
# change new selection to our
for obj in obj_ours:
node = operators.client.get(uuid=obj)
if node and node.owner == RP_COMMON:
recursive = True
if node.data and 'instance_type' in node.data.keys():
recursive = node.data['instance_type'] != 'COLLECTION'
operators.client.change_owner(
node.uuid,
settings.username,
recursive=recursive)
else:
return
if self._user:
current_selection = utils.get_selected_objects(
bpy.context.scene,
bpy.data.window_managers['WinMan'].windows[0].view_layer
)
if current_selection != self._last_selection:
if self._right_strategy == RP_COMMON:
obj_common = [
o for o in self._last_selection if o not in current_selection]
obj_ours = [
o for o in current_selection if o not in self._last_selection]
self.last_selection = current_selection
user_ref.pointer.update_selected_objects(
bpy.context)
user_ref.update()
# change old selection right to common
for obj in obj_common:
node = session.get(uuid=obj)
# change old selection right to common
for obj in obj_common:
node = operators.client.get(uuid=obj)
if node and (node.owner == settings.username or node.owner == RP_COMMON):
recursive = True
if node.data and 'instance_type' in node.data.keys():
recursive = node.data['instance_type'] != 'COLLECTION'
operators.client.change_owner(
node.uuid,
RP_COMMON,
recursive=recursive)
else:
for obj in bpy.data.objects:
if obj.hide_select and obj.uuid not in user_ref.data['selected_objects']:
obj.hide_select = False
elif not obj.hide_select and obj.uuid in user_ref.data['selected_objects']:
obj.hide_select = True
if node and (node.owner == settings.username or node.owner == RP_COMMON):
recursive = True
if node.data and 'instance_type' in node.data.keys():
recursive = node.data['instance_type'] != 'COLLECTION'
session.change_owner(
node.uuid,
RP_COMMON,
recursive=recursive)
# change new selection to our
for obj in obj_ours:
node = session.get(uuid=obj)
if node and node.owner == RP_COMMON:
recursive = True
if node.data and 'instance_type' in node.data.keys():
recursive = node.data['instance_type'] != 'COLLECTION'
session.change_owner(
node.uuid,
settings.username,
recursive=recursive)
else:
return
self._last_selection = current_selection
user_metadata = {
'selected_objects': current_selection
}
session.update_user_metadata(user_metadata)
logger.info("Update selection")
# Fix deselection until right managment refactoring (with Roles concepts)
if len(current_selection) == 0 and self._right_strategy == RP_COMMON:
owned_keys = session.list(
filter_owner=settings.username)
for key in owned_keys:
node = session.get(uuid=key)
session.change_owner(
key,
RP_COMMON,
recursive=recursive)
for user, user_info in session.online_users.items():
if user != settings.username:
metadata = user_info.get('metadata')
if 'selected_objects' in metadata:
# Update selectionnable objects
for obj in bpy.data.objects:
if obj.hide_select and obj.uuid not in metadata['selected_objects']:
obj.hide_select = False
elif not obj.hide_select and obj.uuid in metadata['selected_objects']:
obj.hide_select = True
class Draw(Delayable):
@ -160,37 +190,103 @@ class Draw(Delayable):
bpy.types.SpaceView3D.draw_handler_remove(
self._handler, "WINDOW")
except:
logger.error("draw already unregistered")
pass
class DrawClient(Draw):
def execute(self):
repo = operators.client
if repo and presence.renderer:
session = getattr(operators, 'client', None)
renderer = getattr(presence, 'renderer', None)
if session and renderer and session.state['STATE'] == STATE_ACTIVE:
settings = bpy.context.window_manager.session
client_list = [key for key in repo.list(filter=BlUser) if
key != settings.user_uuid]
users = session.online_users
for cli in client_list:
cli_ref = repo.get(uuid=cli)
if cli_ref.data.get('name'):
if settings.presence_show_selected:
presence.renderer.draw_client_selection(
cli_ref.data['name'], cli_ref.data['color'], cli_ref.data['selected_objects'])
if settings.presence_show_user:
presence.renderer.draw_client_camera(
cli_ref.data['name'], cli_ref.data['location'], cli_ref.data['color'])
for user in users.values():
metadata = user.get('metadata')
if 'color' in metadata:
if settings.presence_show_selected and 'selected_objects' in metadata.keys():
renderer.draw_client_selection(
user['id'], metadata['color'], metadata['selected_objects'])
if settings.presence_show_user and 'view_corners' in metadata:
renderer.draw_client_camera(
user['id'], metadata['view_corners'], metadata['color'])
class ClientUpdate(Timer):
def __init__(self, timout=1, client_uuid=None):
assert(client_uuid)
self._client_uuid = client_uuid
def __init__(self, timout=.5):
super().__init__(timout)
self.handle_quit = False
def execute(self):
if self._client_uuid and operators.client:
client = operators.client.get(uuid=self._client_uuid)
settings = bpy.context.window_manager.session
session_info = bpy.context.window_manager.session
session = getattr(operators, 'client', None)
renderer = getattr(presence, 'renderer', None)
if session and renderer and session.state['STATE'] == STATE_ACTIVE:
# Check if session has been closes prematurely
if session.state['STATE'] == 0:
bpy.ops.session.stop()
if client:
client.pointer.update_location()
local_user = operators.client.online_users.get(
session_info.username)
if not local_user:
return
local_user_metadata = local_user.get('metadata')
current_view_corners = presence.get_view_corners()
if not local_user_metadata or 'color' not in local_user_metadata.keys():
metadata = {
'view_corners': current_view_corners,
'view_matrix': presence.get_view_matrix(),
'color': (settings.client_color.r,
settings.client_color.g,
settings.client_color.b,
1),
'frame_current':bpy.context.scene.frame_current
}
session.update_user_metadata(metadata)
elif current_view_corners != local_user_metadata['view_corners']:
logger.info('update user metadata')
local_user_metadata['view_corners'] = current_view_corners
local_user_metadata['view_matrix'] = presence.get_view_matrix()
session.update_user_metadata(local_user_metadata)
# sync online users
session_users = operators.client.online_users
ui_users = bpy.context.window_manager.online_users
for index, user in enumerate(ui_users):
if user.username not in session_users.keys():
ui_users.remove(index)
renderer.flush_selection()
renderer.flush_users()
break
for user in session_users:
if user not in ui_users:
new_key = ui_users.add()
new_key.name = user
new_key.username = user
# TODO: event drivent 3d view refresh
presence.refresh_3d_view()
elif session.state['STATE'] == STATE_QUITTING:
presence.refresh_3d_view()
self.handle_quit = True
elif session.state['STATE'] == STATE_INITIAL and self.handle_quit:
self.handle_quit = False
presence.refresh_3d_view()
operators.unregister_delayables()
presence.renderer.stop()
# # ui update
elif session:
presence.refresh_3d_view()

View File

@ -6,7 +6,7 @@ import sys
from pathlib import Path
logger = logging.getLogger(__name__)
logger.setLevel(logging.ERROR)
logger.setLevel(logging.WARNING)
CONFIG_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "config")
CONFIG = os.path.join(CONFIG_DIR, "app.yaml")
@ -48,12 +48,6 @@ def module_can_be_imported(name):
return False
def get_package_install_directory():
for path in sys.path:
if os.path.basename(path) in ("dist-packages", "site-packages"):
return path
def install_pip():
# pip can not necessarily be imported into Blender after this
get_pip_path = Path(__file__).parent / "libs" / "get-pip.py"
@ -61,10 +55,8 @@ def install_pip():
def install_package(name):
target = get_package_install_directory()
subprocess.run([str(PYTHON_PATH), "-m", "pip", "install",
name, '--target', target], cwd=SUBPROCESS_DIR)
name], cwd=SUBPROCESS_DIR)
def check_dir(dir):
if not os.path.exists(dir):

View File

@ -92,11 +92,13 @@ class Dumper:
def _build_inline_dump_functions(self):
self._dump_identity = (lambda x, depth: x, lambda x, depth: x)
self._dump_ref = (lambda x, depth: x.name, self._dump_object_as_branch)
self._dump_ID = (lambda x, depth: x.name, self._dump_default_as_branch)
self._dump_collection = (self._dump_default_as_leaf, self._dump_collection_as_branch)
self._dump_array = (self._dump_default_as_leaf, self._dump_array_as_branch)
self._dump_matrix = (self._dump_matrix_as_leaf, self._dump_matrix_as_leaf)
self._dump_vector = (self._dump_vector_as_leaf, self._dump_vector_as_leaf)
self._dump_quaternion = (self._dump_quaternion_as_leaf, self._dump_quaternion_as_leaf)
self._dump_default = (self._dump_default_as_leaf, self._dump_default_as_branch)
self._dump_color = (self._dump_color_as_leaf, self._dump_color_as_leaf)
@ -105,11 +107,14 @@ class Dumper:
self._match_type_int = (_dump_filter_type(int), self._dump_identity)
self._match_type_float = (_dump_filter_type(float), self._dump_identity)
self._match_type_string = (_dump_filter_type(str), self._dump_identity)
self._match_type_ref = (_dump_filter_type(T.Object), self._dump_ref)
self._match_type_ID = (_dump_filter_type(T.ID), self._dump_ID)
self._match_type_bpy_prop_collection = (_dump_filter_type(T.bpy_prop_collection), self._dump_collection)
self._match_type_array = (_dump_filter_array, self._dump_array)
self._match_type_matrix = (_dump_filter_type(mathutils.Matrix), self._dump_matrix)
self._match_type_vector = (_dump_filter_type(mathutils.Vector), self._dump_vector)
self._match_type_quaternion = (_dump_filter_type(mathutils.Quaternion), self._dump_quaternion)
self._match_type_euler = (_dump_filter_type(mathutils.Euler), self._dump_quaternion)
self._match_type_color = (_dump_filter_type_by_name("Color"), self._dump_color)
self._match_default = (_dump_filter_default, self._dump_default)
@ -135,10 +140,19 @@ class Dumper:
def _dump_vector_as_leaf(self, vector, depth):
return list(vector)
def _dump_quaternion_as_leaf(self, quaternion, depth):
return list(quaternion)
def _dump_color_as_leaf(self, color, depth):
return list(color)
def _dump_object_as_branch(self, default, depth):
if depth == 1:
return self._dump_default_as_branch(default, depth)
else:
return default.name
def _dump_default_as_branch(self, default, depth):
def is_valid_property(p):
try:
@ -173,12 +187,14 @@ class Dumper:
self._match_type_int,
self._match_type_float,
self._match_type_string,
self._match_type_ref,
self._match_type_ID,
self._match_type_bpy_prop_collection,
self._match_type_array,
self._match_type_matrix,
self._match_type_vector,
self._match_type_color,
self._match_type_quaternion,
self._match_type_euler,
self._match_type_color,
self._match_default
]
@ -306,6 +322,12 @@ class Loader:
def _load_vector(self, vector, dump):
vector.write(mathutils.Vector(dump))
def _load_quaternion(self, quaternion, dump):
quaternion.write(mathutils.Quaternion(dump))
def _load_euler(self, euler, dump):
euler.write(mathutils.Euler(dump))
def _ordered_keys(self, keys):
ordered_keys = []
@ -336,6 +358,8 @@ class Loader:
(_load_filter_type(T.IntProperty), self._load_identity),
(_load_filter_type(mathutils.Matrix, use_bl_rna=False), self._load_matrix), # before float because bl_rna type of matrix if FloatProperty
(_load_filter_type(mathutils.Vector, use_bl_rna=False), self._load_vector), # before float because bl_rna type of vector if FloatProperty
(_load_filter_type(mathutils.Quaternion, use_bl_rna=False), self._load_quaternion),
(_load_filter_type(mathutils.Euler, use_bl_rna=False), self._load_euler),
(_load_filter_type(T.FloatProperty), self._load_identity),
(_load_filter_type(T.StringProperty), self._load_identity),
(_load_filter_type(T.EnumProperty), self._load_identity),

View File

@ -4,53 +4,51 @@ import os
import queue
import random
import string
import subprocess
import time
from operator import itemgetter
from pathlib import Path
import msgpack
from subprocess import PIPE, Popen, TimeoutExpired
import bpy
import mathutils
from bpy.app.handlers import persistent
from bpy_extras.io_utils import ExportHelper
from . import bl_types, delayable, environment, presence, ui, utils
from .libs.replication.replication.constants import (FETCHED, STATE_ACTIVE,
STATE_INITIAL,
STATE_SYNCING)
from .libs.replication.replication.data import ReplicatedDataFactory
from .libs.replication.replication.exception import NonAuthorizedOperationError
from .libs.replication.replication.interface import Session
from .libs.replication.replication.constants import (
STATE_ACTIVE,
STATE_INITIAL,
STATE_SYNCING)
logger = logging.getLogger(__name__)
logger.setLevel(logging.ERROR)
logger.setLevel(logging.WARNING)
client = None
delayables = []
ui_context = None
stop_modal_executor = False
modal_executor_queue = None
server_process = None
def unregister_delayables():
global delayables, stop_modal_executor
def init_supported_datablocks(supported_types_id):
global client
for type_id in supported_types_id:
if hasattr(bpy.data, type_id):
for item in getattr(bpy.data, type_id):
if client.exist(uuid=item.uuid):
continue
else:
client.add(item)
for d in delayables:
try:
d.unregister()
except:
continue
stop_modal_executor = True
# OPERATORS
class SessionStartOperator(bpy.types.Operator):
bl_idname = "session.start"
bl_label = "start"
bl_description = "connect to a net server"
bl_options = {"REGISTER"}
host: bpy.props.BoolProperty(default=False)
@ -59,94 +57,92 @@ class SessionStartOperator(bpy.types.Operator):
return True
def execute(self, context):
global client, delayables
global client, delayables, ui_context, server_process
settings = context.window_manager.session
users = bpy.data.window_managers['WinMan'].online_users
# TODO: Sync server clients
users.clear()
delayables.clear()
# save config
settings.save(context)
bpy_factory = ReplicatedDataFactory()
supported_bl_types = []
ui_context = context.copy()
# init the factory with supported types
for type in bl_types.types_to_register():
_type = getattr(bl_types, type)
supported_bl_types.append(_type.bl_id)
type_module = getattr(bl_types, type)
type_impl_name = "Bl{}".format(type.split('_')[1].capitalize())
type_module_class = getattr(type_module, type_impl_name)
supported_bl_types.append(type_module_class.bl_id)
# Retreive local replicated types settings
type_local_config = settings.supported_datablock[_type.bl_rep_class.__name__]
type_local_config = settings.supported_datablock[type_impl_name]
bpy_factory.register_type(
_type.bl_class,
_type.bl_rep_class,
type_module_class.bl_class,
type_module_class,
timer=type_local_config.bl_delay_refresh,
automatic=type_local_config.auto_push)
if type_local_config.bl_delay_apply > 0:
delayables.append(delayable.ApplyTimer(
timout=type_local_config.bl_delay_apply,
target_type=_type.bl_rep_class))
target_type=type_module_class))
client = Session(factory=bpy_factory)
client = Session(
factory=bpy_factory,
python_path=bpy.app.binary_path_python,
default_strategy=settings.right_strategy)
# Host a session
if self.host:
# Scene setup
if settings.start_empty:
utils.clean_scene()
client.host(
id=settings.username,
address=settings.ip,
port=settings.port,
right_strategy=settings.right_strategy
)
settings.is_admin = True
try:
for scene in bpy.data.scenes:
scene_uuid = client.add(scene)
client.commit(scene_uuid)
client.host(
id=settings.username,
address=settings.ip,
port=settings.port,
ipc_port=settings.ipc_port)
except Exception as e:
self.report({'ERROR'}, repr(e))
logger.error(f"Error: {e}")
finally:
settings.is_admin = True
# Join a session
else:
utils.clean_scene()
client.connect(
id=settings.username,
address=settings.ip,
port=settings.port
)
try:
client.connect(
id=settings.username,
address=settings.ip,
port=settings.port,
ipc_port=settings.ipc_port
)
except Exception as e:
self.report({'ERROR'}, repr(e))
logger.error(f"Error: {e}")
finally:
settings.is_admin = False
time.sleep(1)
if client.state == 0:
settings.is_admin = False
self.report(
{'ERROR'},
"A session is already hosted on this address")
return {"CANCELLED"}
# Init user settings
usr = presence.User(
username=settings.username,
color=(settings.client_color.r,
settings.client_color.g,
settings.client_color.b,
1),
)
settings.user_uuid = client.add(usr,owner=settings.username)
client.commit(settings.user_uuid)
if settings.init_scene and self.host:
for scene in bpy.data.scenes:
scene_uuid = client.add(scene)
# for node in client.list():
client.commit(scene_uuid)
delayables.append(delayable.ClientUpdate(
client_uuid=settings.user_uuid))
# Background client updates service
#TODO: Refactoring
delayables.append(delayable.ClientUpdate())
delayables.append(delayable.DrawClient())
delayables.append(delayable.DynamicRightSelectTimer())
# Push all added values
client.push_all()
# Launch drawing module
if settings.enable_presence:
presence.renderer.run()
@ -155,6 +151,10 @@ class SessionStartOperator(bpy.types.Operator):
for d in delayables:
d.register()
global modal_executor_queue
modal_executor_queue = queue.Queue()
bpy.ops.session.apply_armature_operator()
self.report(
{'INFO'},
"connexion on tcp://{}:{}".format(settings.ip, settings.port))
@ -172,20 +172,13 @@ class SessionStopOperator(bpy.types.Operator):
return True
def execute(self, context):
global client, delayables
settings = context.window_manager.session
settings.is_admin = False
assert(client)
global client, delayables, stop_modal_executor
assert(client)
client.remove(settings.user_uuid)
client.disconnect()
for d in delayables:
try:
d.unregister()
except:
continue
presence.renderer.stop()
try:
client.disconnect()
except Exception as e:
self.report({'ERROR'}, repr(e))
return {"FINISHED"}
@ -264,6 +257,14 @@ class SessionSnapUserOperator(bpy.types.Operator):
def execute(self, context):
wm = context.window_manager
settings = context.window_manager.session
if settings.time_snap_running:
settings.time_snap_running = False
return {'CANCELLED'}
else:
settings.time_snap_running = True
self._timer = wm.event_timer_add(0.1, window=context.window)
wm.modal_handler_add(self)
return {'RUNNING_MODAL'}
@ -273,7 +274,9 @@ class SessionSnapUserOperator(bpy.types.Operator):
wm.event_timer_remove(self._timer)
def modal(self, context, event):
if event.type in {'RIGHTMOUSE', 'ESC'}:
is_running = context.window_manager.session.time_snap_running
if event.type in {'RIGHTMOUSE', 'ESC'} or not is_running:
self.cancel(context)
return {'CANCELLED'}
@ -281,9 +284,64 @@ class SessionSnapUserOperator(bpy.types.Operator):
area, region, rv3d = presence.view3d_find()
global client
target_client = client.get(uuid=self.target_client)
if target_client:
rv3d.view_matrix = mathutils.Matrix(target_client.data['view_matrix'])
if client:
target_ref = client.online_users.get(self.target_client)
if target_ref:
rv3d.view_matrix = mathutils.Matrix(
target_ref['metadata']['view_matrix'])
else:
return {"CANCELLED"}
return {'PASS_THROUGH'}
class SessionSnapTimeOperator(bpy.types.Operator):
bl_idname = "session.snaptime"
bl_label = "snap to user time"
bl_description = "Snap time to selected user time's"
bl_options = {"REGISTER"}
_timer = None
target_client: bpy.props.StringProperty(default="None")
@classmethod
def poll(cls, context):
return True
def execute(self, context):
settings = context.window_manager.session
if settings.user_snap_running:
settings.user_snap_running = False
return {'CANCELLED'}
else:
settings.user_snap_running = True
wm = context.window_manager
self._timer = wm.event_timer_add(0.05, window=context.window)
wm.modal_handler_add(self)
return {'RUNNING_MODAL'}
def cancel(self, context):
wm = context.window_manager
wm.event_timer_remove(self._timer)
def modal(self, context, event):
is_running = context.window_manager.session.user_snap_running
if event.type in {'RIGHTMOUSE', 'ESC'} or not is_running:
self.cancel(context)
return {'CANCELLED'}
if event.type == 'TIMER':
global client
if client:
target_ref = client.online_users.get(self.target_client)
if target_ref:
context.scene.frame_current = target_ref['metadata']['frame_current']
else:
return {"CANCELLED"}
@ -330,22 +388,129 @@ class SessionCommit(bpy.types.Operator):
return {"FINISHED"}
class ApplyArmatureOperator(bpy.types.Operator):
"""Operator which runs its self from a timer"""
bl_idname = "session.apply_armature_operator"
bl_label = "Modal Executor Operator"
_timer = None
def modal(self, context, event):
global stop_modal_executor, modal_executor_queue
if stop_modal_executor:
self.cancel(context)
return {'CANCELLED'}
if event.type == 'TIMER':
global client
if client and client.state['STATE'] == STATE_ACTIVE:
nodes = client.list(filter=bl_types.bl_armature.BlArmature)
for node in nodes:
node_ref = client.get(uuid=node)
if node_ref.state == FETCHED:
try:
client.apply(node)
except Exception as e:
logger.error(
"fail to apply {}: {}".format(node_ref.uuid, e))
return {'PASS_THROUGH'}
def execute(self, context):
wm = context.window_manager
self._timer = wm.event_timer_add(2, window=context.window)
wm.modal_handler_add(self)
return {'RUNNING_MODAL'}
def cancel(self, context):
global stop_modal_executor
wm = context.window_manager
wm.event_timer_remove(self._timer)
stop_modal_executor = False
classes = (
SessionStartOperator,
SessionStopOperator,
SessionPropertyRemoveOperator,
SessionSnapUserOperator,
SessionSnapTimeOperator,
SessionPropertyRightOperator,
SessionApply,
SessionCommit,
ApplyArmatureOperator,
)
@persistent
def load_pre_handler(dummy):
global client
if client and client.state in [STATE_ACTIVE, STATE_SYNCING]:
if client and client.state['STATE'] in [STATE_ACTIVE, STATE_SYNCING]:
bpy.ops.session.stop()
@persistent
def sanitize_deps_graph(dummy):
"""sanitize deps graph
Temporary solution to resolve each node pointers after a Undo.
A future solution should be to avoid storing dataclock reference...
"""
global client
if client and client.state['STATE'] in [STATE_ACTIVE]:
for node_key in client.list():
client.get(node_key).resolve()
@persistent
def update_client_frame(scene):
if client and client.state['STATE'] == STATE_ACTIVE:
client.update_user_metadata({
'frame_current': scene.frame_current
})
@persistent
def depsgraph_evaluation(scene):
if client and client.state['STATE'] == STATE_ACTIVE:
context = bpy.context
blender_depsgraph = bpy.context.view_layer.depsgraph
dependency_updates = [u for u in blender_depsgraph.updates]
session_infos = bpy.context.window_manager.session
# NOTE: maybe we don't need to check each update but only the first
for update in reversed(dependency_updates):
# Is the object tracked ?
if update.id.uuid:
# Retrieve local version
node = client.get(update.id.uuid)
# Check our right on this update:
# - if its ours or ( under common and diff), launch the
# update process
# - if its to someone else, ignore the update (go deeper ?)
if node.owner == session_infos.username:
# Avoid slow geometry update
if 'EDIT' in context.mode:
break
logger.error("UPDATE: MODIFIFY {}".format(type(update.id)))
# client.commit(node.uuid)
# client.push(node.uuid)
else:
# Distant update
continue
# else:
# # New items !
# logger.error("UPDATE: ADD")C.obj
def register():
from bpy.utils import register_class
@ -354,18 +519,34 @@ def register():
bpy.app.handlers.load_pre.append(load_pre_handler)
bpy.app.handlers.undo_post.append(sanitize_deps_graph)
bpy.app.handlers.redo_post.append(sanitize_deps_graph)
bpy.app.handlers.frame_change_pre.append(update_client_frame)
# bpy.app.handlers.depsgraph_update_post.append(depsgraph_evaluation)
def unregister():
global client
if client and client.state == 2:
if client and client.state['STATE'] == 2:
client.disconnect()
client = None
from bpy.utils import unregister_class
for cls in reversed(classes):
unregister_class(cls)
bpy.app.handlers.load_pre.remove(load_pre_handler)
bpy.app.handlers.load_pre.remove(load_pre_handler)
bpy.app.handlers.undo_post.remove(sanitize_deps_graph)
bpy.app.handlers.redo_post.remove(sanitize_deps_graph)
bpy.app.handlers.frame_change_pre.remove(update_client_frame)
# bpy.app.handlers.depsgraph_update_post.remove(depsgraph_evaluation)
if __name__ == "__main__":
register()

View File

@ -31,8 +31,8 @@ def view3d_find():
def refresh_3d_view():
area, region, rv3d = view3d_find()
area.tag_redraw()
if area and region and rv3d:
area.tag_redraw()
def get_target(region, rv3d, coord):
@ -69,7 +69,7 @@ def get_default_bbox(obj, radius):
return [(point.x, point.y, point.z)
for point in bbox_corners]
def get_client_cam_points():
def get_view_corners():
area, region, rv3d = view3d_find()
v1 = [0, 0, 0]
@ -78,6 +78,7 @@ def get_client_cam_points():
v4 = [0, 0, 0]
v5 = [0, 0, 0]
v6 = [0, 0, 0]
v7 = [0, 0, 0]
if area and region and rv3d:
width = region.width
@ -112,31 +113,14 @@ def get_bb_coords_from_obj(object, parent=None):
return [(point.x, point.y, point.z)
for point in bbox_corners]
class User():
def __init__(self, username=None, color=(0, 0, 0, 1)):
self.is_dirty = False
self.name = username
self.color = color
self.location = [[0, 0, 0], [0, 0, 0], [0, 0, 0], [0, 0, 0], [0, 0, 0]]
self.selected_objects = []
self.last_select_objects = []
self.view_matrix = None
def update_location(self):
current_coords = get_client_cam_points()
area, region, rv3d = view3d_find()
current_coords = list(get_client_cam_points())
if current_coords and self.location != current_coords:
self.location = current_coords
def get_view_matrix():
area, region, rv3d = view3d_find()
if area and region and rv3d:
matrix_dumper = utils.dump_anything.Dumper()
current_vm = matrix_dumper.dump(rv3d.view_matrix)
if self.view_matrix != current_vm:
self.view_matrix = current_vm
def update_selected_objects(self, context):
self.selected_objects = utils.get_selected_objects(context.scene)
return matrix_dumper.dump(rv3d.view_matrix)
def update_presence(self, context):
global renderer
@ -171,8 +155,12 @@ class DrawFactory(object):
self.register_handlers()
def stop(self):
self.flush_users()
self.flush_selection()
self.unregister_handlers()
refresh_3d_view()
def register_handlers(self):
self.draw3d_handle = bpy.types.SpaceView3D.draw_handler_add(
self.draw3d_callback, (), 'WINDOW', 'POST_VIEW')
@ -215,14 +203,14 @@ class DrawFactory(object):
self.d2d_items.clear()
def draw_client_selection(self, client_uuid, client_color, client_selection):
local_user = bpy.context.window_manager.session.user_uuid
def draw_client_selection(self, client_id, client_color, client_selection):
local_user = bpy.context.window_manager.session.username
if local_user != client_uuid:
self.flush_selection(client_uuid)
if local_user != client_id:
self.flush_selection(client_id)
for select_ob in client_selection:
drawable_key = "{}_select_{}".format(client_uuid, select_ob)
drawable_key = "{}_select_{}".format(client_id, select_ob)
ob = utils.find_from_attr("uuid", select_ob, bpy.data.objects)
if not ob:
@ -271,11 +259,11 @@ class DrawFactory(object):
self.d3d_items[key] = (shader, batch, color)
def draw_client_camera(self, client_uuid, client_location, client_color):
def draw_client_camera(self, client_id, client_location, client_color):
if client_location:
local_user = bpy.context.window_manager.session.user_uuid
local_user = bpy.context.window_manager.session.username
if local_user != client_uuid:
if local_user != client_id:
try:
indices = (
(1, 3), (2, 1), (3, 0),
@ -290,8 +278,8 @@ class DrawFactory(object):
batch = batch_for_shader(
shader, 'LINES', {"pos": position}, indices=indices)
self.d3d_items[client_uuid] = (shader, batch, color)
self.d2d_items[client_uuid] = (position[1], client_uuid, color)
self.d3d_items[client_id] = (shader, batch, color)
self.d2d_items[client_id] = (position[1], client_id, color)
except Exception as e:
logger.error("Draw client exception {}".format(e))

View File

@ -1,9 +1,12 @@
import bpy
from . import operators
from .bl_types.bl_user import BlUser
from .libs.replication.replication.constants import (ADDED, ERROR, FETCHED,
MODIFIED, RP_COMMON, UP)
MODIFIED, RP_COMMON, UP,
STATE_ACTIVE, STATE_AUTH,
STATE_CONFIG, STATE_SYNCING,
STATE_INITIAL, STATE_SRV_SYNC,
STATE_WAITING, STATE_QUITTING)
ICONS_PROP_STATES = ['TRIA_DOWN', # ADDED
'TRIA_UP', # COMMITED
@ -12,6 +15,44 @@ ICONS_PROP_STATES = ['TRIA_DOWN', # ADDED
'FILE_REFRESH', # UP
'TRIA_UP'] # CHANGED
def printProgressBar (iteration, total, prefix = '', suffix = '', decimals = 1, length = 100, fill = '', fill_empty=' '):
"""
Call in a loop to create terminal progress bar
@params:
iteration - Required : current iteration (Int)
total - Required : total iterations (Int)
prefix - Optional : prefix string (Str)
suffix - Optional : suffix string (Str)
decimals - Optional : positive number of decimals in percent complete (Int)
length - Optional : character length of bar (Int)
fill - Optional : bar fill character (Str)
From here:
https://gist.github.com/greenstick/b23e475d2bfdc3a82e34eaa1f6781ee4
"""
percent = ("{0:." + str(decimals) + "f}").format(100 * (iteration / float(total)))
filledLength = int(length * iteration // total)
bar = fill * filledLength + fill_empty * (length - filledLength)
return '{} |{}| {}/{}{}'.format(prefix, bar, iteration,total, suffix)
def get_state_str(state):
state_str = 'None'
if state == STATE_WAITING:
state_str = 'WARMING UP DATA'
elif state == STATE_SYNCING:
state_str = 'FETCHING FROM SERVER'
elif state == STATE_AUTH:
state_str = 'AUTHENTIFICATION'
elif state == STATE_CONFIG:
state_str = 'CONFIGURATION'
elif state == STATE_ACTIVE:
state_str = 'ONLINE'
elif state == STATE_SRV_SYNC:
state_str = 'PUSHING TO SERVER'
elif state == STATE_INITIAL:
state_str = 'INIT'
elif state == STATE_QUITTING:
state_str = 'QUITTING SESSION'
return state_str
class SESSION_PT_settings(bpy.types.Panel):
"""Settings panel"""
@ -32,22 +73,55 @@ class SESSION_PT_settings(bpy.types.Panel):
if hasattr(context.window_manager, 'session'):
# STATE INITIAL
if not operators.client \
or (operators.client and operators.client.state == 0):
or (operators.client and operators.client.state['STATE'] == STATE_INITIAL):
pass
else:
cli_state = operators.client.state
row.label(text=f"Status : {get_state_str(cli_state['STATE'])}")
row = layout.row()
current_state = cli_state['STATE']
# STATE ACTIVE
if operators.client.state == 2:
row = layout.row()
if current_state == STATE_ACTIVE:
row.operator("session.stop", icon='QUIT', text="Exit")
row = layout.row()
# STATE SYNCING
else:
status = "connecting..."
row.label(text=status)
# CONNECTION STATE
elif current_state in [
STATE_SRV_SYNC,
STATE_SYNCING,
STATE_AUTH,
STATE_CONFIG,
STATE_WAITING]:
if cli_state['STATE'] in [STATE_SYNCING,STATE_SRV_SYNC,STATE_WAITING]:
box = row.box()
box.label(text=printProgressBar(
cli_state['CURRENT'],
cli_state['TOTAL'],
length=16
))
row = layout.row()
row.operator("session.stop", icon='QUIT', text="CANCEL")
elif current_state == STATE_QUITTING:
row = layout.row()
box = row.box()
num_online_services = 0
for name, state in operators.client.services_state.items():
if state == STATE_ACTIVE:
num_online_services += 1
total_online_services = len(operators.client.services_state)
box.label(text=printProgressBar(
total_online_services-num_online_services,
total_online_services,
length=16
))
class SESSION_PT_settings_network(bpy.types.Panel):
bl_idname = "MULTIUSER_SETTINGS_NETWORK_PT_panel"
@ -60,7 +134,7 @@ class SESSION_PT_settings_network(bpy.types.Panel):
@classmethod
def poll(cls, context):
return not operators.client \
or (operators.client and operators.client.state == 0)
or (operators.client and operators.client.state['STATE'] == 0)
def draw(self, context):
layout = self.layout
@ -72,24 +146,24 @@ class SESSION_PT_settings_network(bpy.types.Panel):
row.prop(settings, "session_mode", expand=True)
row = layout.row()
box = row.box()
row = box.row()
row.prop(settings, "ip", text="IP")
row = box.row()
row.label(text="Port:")
row.prop(settings, "port", text="")
row = box.row()
row.label(text="IPC Port:")
row.prop(settings, "ipc_port", text="")
if settings.session_mode == 'HOST':
box = row.box()
row = box.row()
row.label(text="Start empty:")
row.prop(settings, "start_empty", text="")
row = box.row()
row.label(text="Port:")
row.prop(settings, "port", text="")
row = box.row()
row.operator("session.start", text="HOST").host = True
else:
box = row.box()
row = box.row()
row.prop(settings, "ip", text="IP")
row = box.row()
row.label(text="Port:")
row.prop(settings, "port", text="")
row = box.row()
row.operator("session.start", text="CONNECT").host = False
@ -105,7 +179,7 @@ class SESSION_PT_settings_user(bpy.types.Panel):
@classmethod
def poll(cls, context):
return not operators.client \
or (operators.client and operators.client.state == 0)
or (operators.client and operators.client.state['STATE'] == 0)
def draw(self, context):
layout = self.layout
@ -133,7 +207,7 @@ class SESSION_PT_settings_replication(bpy.types.Panel):
@classmethod
def poll(cls, context):
return not operators.client \
or (operators.client and operators.client.state == 0)
or (operators.client and operators.client.state['STATE'] == 0)
def draw(self, context):
layout = self.layout
@ -167,7 +241,7 @@ class SESSION_PT_settings_replication(bpy.types.Panel):
class SESSION_PT_user(bpy.types.Panel):
bl_idname = "MULTIUSER_USER_PT_panel"
bl_label = "Users"
bl_label = "Online users"
bl_space_type = 'VIEW_3D'
bl_region_type = 'UI'
bl_category = "Multiuser"
@ -175,49 +249,63 @@ class SESSION_PT_user(bpy.types.Panel):
@classmethod
def poll(cls, context):
return operators.client and operators.client.state == 2
return operators.client and operators.client.state['STATE'] == 2
def draw(self, context):
layout = self.layout
online_users = context.window_manager.online_users
selected_user = context.window_manager.user_index
settings = context.window_manager.session
active_user = online_users[selected_user] if len(online_users)-1>=selected_user else 0
# Create a simple row.
col = layout.column(align=True)
client_keys = operators.client.list(filter=BlUser)
if client_keys and len(client_keys) > 0:
for key in client_keys:
area_msg = col.row(align=True)
item_box = area_msg.box()
client = operators.client.get(uuid=key).data
info = ""
detail_item_row = item_box.row(align=True)
if client.get('name'):
username = client['name']
is_local_user = username == settings.username
if is_local_user:
info = "(self)"
detail_item_row.label(
text="{} {}".format(username, info))
if not is_local_user:
detail_item_row.operator(
"session.snapview",
text="",
icon='VIEW_CAMERA').target_client = key
row = layout.row()
else:
row.label(text="Empty")
row = layout.row()
box = row.box()
split = box.split(factor=0.5)
split.label(text="user")
split.label(text="frame")
split.label(text="ping")
row = layout.row()
layout.template_list("SESSION_UL_users", "", context.window_manager, "online_users", context.window_manager, "user_index")
if active_user != 0 and active_user.username != settings.username:
row = layout.row()
user_operations = row.split()
user_operations.alert = context.window_manager.session.time_snap_running
user_operations.operator(
"session.snapview",
text="",
icon='VIEW_CAMERA').target_client = active_user.username
user_operations.alert = context.window_manager.session.user_snap_running
user_operations.operator(
"session.snaptime",
text="",
icon='TIME').target_client = active_user.username
class SESSION_UL_users(bpy.types.UIList):
def draw_item(self, context, layout, data, item, icon, active_data, active_propname, index, flt_flag):
session = operators.client
settings = context.window_manager.session
is_local_user = item.username == settings.username
ping = '-'
frame_current = '-'
if session:
user = session.online_users.get(item.username)
if user:
ping = str(user['latency'])
metadata = user.get('metadata')
if metadata and 'frame_current' in metadata:
frame_current = str(metadata['frame_current'])
split = layout.split(factor=0.5)
split.label(text=item.username)
split.label(text=frame_current)
split.label(text=ping)
class SESSION_PT_presence(bpy.types.Panel):
bl_idname = "MULTIUSER_MODULE_PT_panel"
@ -230,7 +318,8 @@ class SESSION_PT_presence(bpy.types.Panel):
@classmethod
def poll(cls, context):
return True
return not operators.client \
or (operators.client and operators.client.state['STATE'] in [STATE_INITIAL, STATE_ACTIVE])
def draw_header(self, context):
self.layout.prop(context.window_manager.session, "enable_presence", text="")
@ -245,12 +334,40 @@ class SESSION_PT_presence(bpy.types.Panel):
col.prop(settings,"presence_show_user")
row = layout.row()
class SESSION_PT_services(bpy.types.Panel):
bl_idname = "MULTIUSER_SERVICE_PT_panel"
bl_label = "Services"
bl_space_type = 'VIEW_3D'
bl_region_type = 'UI'
bl_category = "Multiuser"
bl_parent_id = 'MULTIUSER_SETTINGS_PT_panel'
bl_options = {'DEFAULT_CLOSED'}
@classmethod
def poll(cls, context):
return operators.client and operators.client.state['STATE'] == 2
def draw(self, context):
layout = self.layout
online_users = context.window_manager.online_users
selected_user = context.window_manager.user_index
settings = context.window_manager.session
active_user = online_users[selected_user] if len(online_users)-1>=selected_user else 0
# Create a simple row.
for name, state in operators.client.services_state.items():
row = layout.row()
row.label(text=name)
row.label(text=get_state_str(state))
def draw_property(context, parent, property_uuid, level=0):
settings = context.window_manager.session
item = operators.client.get(uuid=property_uuid)
if item.str_type == 'BlUser' or item.state == ERROR:
if item.state == ERROR:
return
area_msg = parent.row(align=True)
@ -317,7 +434,7 @@ class SESSION_PT_outliner(bpy.types.Panel):
@classmethod
def poll(cls, context):
return operators.client and operators.client.state == 2
return operators.client and operators.client.state['STATE'] == 2
def draw_header(self, context):
self.layout.label(text="", icon='OUTLINER_OB_GROUP_INSTANCE')
@ -365,13 +482,15 @@ class SESSION_PT_outliner(bpy.types.Panel):
classes = (
SESSION_UL_users,
SESSION_PT_settings,
SESSION_PT_settings_user,
SESSION_PT_settings_network,
SESSION_PT_presence,
SESSION_PT_settings_replication,
SESSION_PT_user,
SESSION_PT_outliner
SESSION_PT_outliner,
SESSION_PT_services
)

View File

@ -5,6 +5,7 @@ import random
import string
import sys
from uuid import uuid4
from collections.abc import Iterable
import bpy
import mathutils
@ -13,7 +14,18 @@ from . import environment, presence
from .libs import dump_anything
logger = logging.getLogger(__name__)
logger.setLevel(logging.ERROR)
logger.setLevel(logging.WARNING)
def has_action(target):
return (hasattr(target, 'animation_data')
and target.animation_data
and target.animation_data.action)
def has_driver(target):
return (hasattr(target, 'animation_data')
and target.animation_data
and target.animation_data.drivers)
def find_from_attr(attr_name, attr_value, list):
@ -45,7 +57,7 @@ def get_datablock_users(datablock):
def random_string_digits(stringLength=6):
"""Generate a random string of letters and digits """
lettersAndDigits = string.ascii_letters + string.digits
return ''.join(random.choice(lettersAndDigits) for i in range(stringLength))
return ''.join(random.choices(lettersAndDigits, k=stringLength))
def clean_scene():
@ -92,8 +104,8 @@ def get_armature_edition_context(armature):
return override
def get_selected_objects(scene):
return [obj.uuid for obj in scene.objects if obj.select_get()]
def get_selected_objects(scene, active_view_layer):
return [obj.uuid for obj in scene.objects if obj.select_get(view_layer=active_view_layer)]
def load_dict(src_dict, target):
@ -139,3 +151,13 @@ def dump_datablock_attibutes(datablock=None, attributes=[], depth=1, dickt=None)
pass
return data
def resolve_from_id(id, optionnal_type=None):
for category in dir(bpy.data):
root = getattr(bpy.data, category)
if isinstance(root, Iterable):
if id in root and ((optionnal_type is None) or (optionnal_type.lower() in root[id].__class__.__name__.lower())):
return root[id]
return None