Compare commits

..

84 Commits

Author SHA1 Message Date
5830fe1abb fix: add items_to_remove 2021-06-16 14:28:26 +02:00
c609f72080 fix: All brushes 2021-06-16 12:29:56 +02:00
a28a6f91bd feat: move testing to blender 2.93 2021-06-15 16:27:49 +02:00
a996f39d3b Merge branch '195-auto-updater-install-a-broken-version-of-the-addon' into 'develop'
Resolve "Auto updater install a broken version of the addon"

See merge request slumber/multi-user!123
2021-06-15 12:54:49 +00:00
7790a16034 fix: download the build artifact instead of the repository default zip
Related to #195
2021-06-15 14:51:37 +02:00
836fdd02b8 Merge branch '192-parent-type-isn-t-synced' into 'develop'
Resolve "Parent type isn't synced"

See merge request slumber/multi-user!122
2021-06-15 09:22:13 +00:00
7cb3482353 fix: parent type and parent bone 2021-06-15 11:20:31 +02:00
041022056c Merge branch 'develop' of gitlab.com:slumber/multi-user into develop 2021-06-14 17:32:50 +02:00
05f3eb1445 fix: update readme 2021-06-14 17:32:05 +02:00
17193bde3a fix: doc server .png names 2021-06-14 14:29:45 +00:00
a14b4313f5 feat: update to develop 2021-06-14 16:12:47 +02:00
b203d9dffd Merge branch '188-intgrate-replication-as-a-submodule' into develop 2021-06-14 16:10:15 +02:00
f64db2155e Merge branch '49-connection-preset-system' into 'develop'
Connection-preset-system

See merge request slumber/multi-user!121
2021-06-14 13:50:58 +00:00
699cf578e2 feat: prevent updates in sclupt mode 2021-06-11 16:42:23 +02:00
e9b4afb440 refactor: enable partial delta based replication 2021-06-11 15:28:37 +02:00
1fc25412ac fix: constraint differential update support 2021-06-10 15:21:25 +02:00
b5405553dc refactor: install replication dependencies in libs 2021-06-09 18:16:43 +02:00
c616054878 tour du python blender 2021-06-07 17:06:41 +02:00
5c08493774 fix 'GraphObjectStore' object has no attribute 'object_store' 2021-06-04 18:30:54 +02:00
af8a138b4f fix: modifier order 2021-06-04 17:17:30 +02:00
6d9216f14a refactor: cleanup repository 2021-06-04 16:07:02 +02:00
fc4fb088bb refactor: repository api clean 2021-06-04 14:02:09 +02:00
98553ba00c refactor: remove get_nodes 2021-06-04 12:13:53 +02:00
1e15a12b10 refactor: remove list 2021-06-04 12:07:54 +02:00
569543650f feat: skip external updates 2021-06-03 15:43:47 +02:00
07358802f7 refactor: fix scene item removal 2021-06-03 15:03:09 +02:00
a059fafe12 feat: add mutate to scene delta 2021-06-03 11:43:24 +02:00
297f68ccfe refactor: only apply node when it is necessary (skip for host) 2021-06-03 11:41:25 +02:00
c9c70d1e08 refactor: stamp datablock during apply 2021-06-03 11:20:54 +02:00
e7b7f38991 fix: change rights 2021-06-02 17:49:22 +02:00
392e0aaaa3 refactor: remove missing parameter 2021-06-02 15:45:11 +02:00
4c774d5d53 refactor: move update user metadata to porcelain 2021-06-02 12:59:53 +02:00
4c4cf8a970 refactor: move rm to porcelain 2021-06-02 11:47:41 +02:00
211d0848c2 fix: replication version 2021-06-02 11:39:37 +02:00
c9665c4719 refactor: move unlock/lock/kick to porcelain 2021-06-02 11:31:23 +02:00
431fe0d840 refactor: move lock/unock to porcelain 2021-06-02 10:22:37 +02:00
df7ca66ad8 fix: repo dumps api 2021-06-02 09:35:55 +02:00
c2d2db78e6 refactor: temporary remove name resolution 2021-06-01 15:47:05 +02:00
ad89a4e389 fix: disable mutable delta for scene 2021-06-01 14:53:17 +02:00
6ca6d4443d refactor: move load/dumps to repository 2021-05-31 11:39:54 +02:00
81c9b5fc06 fix: animation loading 2021-05-21 23:02:42 +02:00
9fddfe084c fix: annotation 2021-05-21 17:29:22 +02:00
ca40523393 fix: apply and resolve 2021-05-21 17:14:28 +02:00
76e28ced21 refactor: remove legacy data 2021-05-21 15:40:45 +02:00
55c6002b28 feat: update version 2021-05-20 17:22:00 +02:00
8d5c8aded3 refacor: code formating 2021-05-20 09:57:44 +02:00
8ebba80b97 refactor: add diff back 2021-05-19 17:44:42 +02:00
50d6c6b3c8 fix: filter 2021-05-19 15:59:36 +02:00
f0b03c50f2 refactor: fix tests 2021-05-19 15:12:11 +02:00
28e83a38e6 refactor: add back armature lightprobes, sound and speaker 2021-05-19 15:05:54 +02:00
2e261cd66b refactor: add particle and lattive back 2021-05-19 14:40:13 +02:00
3f6e4f7333 refactor: add texts back 2021-05-19 14:23:56 +02:00
49fadf084a refactor: add gpencil back 2021-05-19 13:56:42 +02:00
e2e0dc31c1 refactor: add volume and world support 2021-05-19 13:42:34 +02:00
389bbd97d5 refactor: add image and file back 2021-05-19 13:31:57 +02:00
19602691d3 feat: texture 2021-05-19 11:43:01 +02:00
2e2ff5d4bf refactor: add material nodegroup back 2021-05-19 11:25:56 +02:00
fef6559ce0 refactor: add light and camera support back 2021-05-19 10:52:04 +02:00
5f669fd49a refactor: add camera back 2021-05-19 09:55:07 +02:00
330ff08fd3 refactor: add collection back 2021-05-19 09:47:01 +02:00
f3be8f9623 feat: bring back icons 2021-05-19 09:37:50 +02:00
ffb70ab74c refactor: protocol refactoring part 1 (mesh, object, action, scene) 2021-05-18 23:14:09 +02:00
26140eefb2 refactor: clear replicated datablock init states 2021-05-18 18:23:28 +02:00
cdf0433e8a refactor: move fetch to repository 2021-05-18 17:17:10 +02:00
acd70f73bf refactor: add remote
refactor: move push to porcelain
2021-05-18 16:54:07 +02:00
36c3a9ab0b refactor: remove sanitize 2021-05-18 11:01:55 +02:00
cfb1afdd72 Revert "feat: node sanitize on collection and scene update"
This reverts commit fb1c985f31.
2021-05-18 11:00:05 +02:00
4eeb80350e fix: layer info missing 2021-05-18 10:54:13 +02:00
fb1c985f31 feat: node sanitize on collection and scene update 2021-05-17 17:35:34 +02:00
689c2473d6 fix: commit 2021-05-17 17:18:17 +02:00
41620fce90 fix: commit 2021-05-17 17:04:43 +02:00
249bcf827b fix: collection instance bounding box selection 2021-05-17 16:03:01 +02:00
d47eab4f26 refactor: move commit to porcelain 2021-05-17 11:12:18 +02:00
f011089d82 refactor: removed apply from replicated datablock 2021-05-17 10:52:28 +02:00
acc58a1c9f fix: tcp keepalive IDLE time 2021-05-16 22:26:53 +02:00
24d850de9f refactor: get metadata updates optimization back 2021-05-11 11:41:43 +02:00
b045911a59 refactor: get diff back for testing 2021-05-10 12:04:45 +02:00
a67be76422 feat: delta commit 2021-05-09 17:42:56 +02:00
32033c743c feat: update repllication version 2021-05-07 17:10:23 +02:00
5da8650611 fix: get replication version 2021-05-07 16:56:00 +02:00
aec5096f87 feat: update submodule url 2021-05-07 16:12:04 +02:00
fba39b9980 fix: ci with submodules 2021-05-07 15:47:53 +02:00
6af3e4b777 refactor: add threaded data handling back on server side 2021-05-04 16:25:36 +02:00
58d639e9d8 feat: add replication as a submoduke 2021-05-04 14:56:50 +02:00
68 changed files with 1570 additions and 1328 deletions

3
.gitignore vendored
View File

@ -13,4 +13,5 @@ multi_user_updater/
_build _build
# ignore generated zip generated from blender_addon_tester # ignore generated zip generated from blender_addon_tester
*.zip *.zip
libs

View File

@ -8,3 +8,5 @@ build:
name: multi_user name: multi_user
paths: paths:
- multi_user - multi_user
variables:
GIT_SUBMODULE_STRATEGY: recursive

View File

@ -5,6 +5,7 @@ deploy:
variables: variables:
DOCKER_DRIVER: overlay2 DOCKER_DRIVER: overlay2
DOCKER_TLS_CERTDIR: "/certs" DOCKER_TLS_CERTDIR: "/certs"
GIT_SUBMODULE_STRATEGY: recursive
services: services:
- docker:19.03.12-dind - docker:19.03.12-dind

View File

@ -3,3 +3,5 @@ test:
image: slumber/blender-addon-testing:latest image: slumber/blender-addon-testing:latest
script: script:
- python3 scripts/test_addon.py - python3 scripts/test_addon.py
variables:
GIT_SUBMODULE_STRATEGY: recursive

3
.gitmodules vendored
View File

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

View File

@ -32,32 +32,32 @@ Currently, not all data-block are supported for replication over the wire. The f
| Name | Status | Comment | | Name | Status | Comment |
| -------------- | :----: | :----------------------------------------------------------: | | -------------- | :----: | :----------------------------------------------------------: |
| action | ✔️ | | | action | ✔️ | |
| armature | ❗ | Not stable |
| camera | ✔️ | | | camera | ✔️ | |
| collection | ✔️ | | | collection | ✔️ | |
| curve | ❗ | Nurbs surfaces not supported |
| gpencil | ✔️ | | | gpencil | ✔️ | |
| image | ✔️ | | | image | ✔️ | |
| mesh | ✔️ | | | mesh | ✔️ | |
| material | ✔️ | | | material | ✔️ | |
| node_groups | | Material & Geometry only | | node_groups | ✔️ | Material & Geometry only |
| geometry nodes | ✔️ | | | geometry nodes | ✔️ | |
| metaball | ✔️ | | | metaball | ✔️ | |
| object | ✔️ | | | object | ✔️ | |
| textures | ❗ | Supported for modifiers/materials/geo nodes only |
| texts | ✔️ | | | texts | ✔️ | |
| scene | ✔️ | | | scene | ✔️ | |
| world | ✔️ | | | world | ✔️ | |
| lightprobes | ✔️ | |
| compositing | ❌ | [Planned](https://gitlab.com/slumber/multi-user/-/issues/46) |
| texts | ❌ | [Planned](https://gitlab.com/slumber/multi-user/-/issues/81) |
| nla | ❌ | |
| volumes | ✔️ | | | volumes | ✔️ | |
| lightprobes | ✔️ | |
| physics | ✔️ | |
| curve | ❗ | Nurbs surfaces not supported |
| textures | ❗ | Supported for modifiers/materials/geo nodes only |
| armature | ❗ | Not stable |
| particles | ❗ | The cache isn't syncing. | | particles | ❗ | The cache isn't syncing. |
| speakers | ❗ | [Partial](https://gitlab.com/slumber/multi-user/-/issues/65) | | speakers | ❗ | [Partial](https://gitlab.com/slumber/multi-user/-/issues/65) |
| vse | ❗ | Mask and Clip not supported yet | | vse | ❗ | Mask and Clip not supported yet |
| physics | ❌ | [Planned](https://gitlab.com/slumber/multi-user/-/issues/45) |
| libraries | ❗ | Partial | | libraries | ❗ | Partial |
| nla | ❌ | |
| texts | ❌ | [Planned](https://gitlab.com/slumber/multi-user/-/issues/81) |
| compositing | ❌ | [Planned](https://gitlab.com/slumber/multi-user/-/issues/46) |

View File

Before

Width:  |  Height:  |  Size: 7.3 KiB

After

Width:  |  Height:  |  Size: 7.3 KiB

View File

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 4.2 KiB

View File

Before

Width:  |  Height:  |  Size: 9.0 KiB

After

Width:  |  Height:  |  Size: 9.0 KiB

View File

Before

Width:  |  Height:  |  Size: 3.2 KiB

After

Width:  |  Height:  |  Size: 3.2 KiB

View File

@ -19,7 +19,7 @@
bl_info = { bl_info = {
"name": "Multi-User", "name": "Multi-User",
"author": "Swann Martinez", "author": "Swann Martinez",
"version": (0, 4, 0), "version": (0, 5, 0),
"description": "Enable real-time collaborative workflow inside blender", "description": "Enable real-time collaborative workflow inside blender",
"blender": (2, 82, 0), "blender": (2, 82, 0),
"location": "3D View > Sidebar > Multi-User tab", "location": "3D View > Sidebar > Multi-User tab",
@ -43,13 +43,10 @@ from bpy.app.handlers import persistent
from . import environment from . import environment
DEPENDENCIES = {
("replication", '0.1.36'),
}
module_error_msg = "Insufficient rights to install the multi-user \ module_error_msg = "Insufficient rights to install the multi-user \
dependencies, aunch blender with administrator rights." dependencies, aunch blender with administrator rights."
def register(): def register():
# Setup logging policy # Setup logging policy
logging.basicConfig( logging.basicConfig(
@ -58,12 +55,7 @@ def register():
level=logging.INFO) level=logging.INFO)
try: try:
if bpy.app.version[1] >= 91: environment.register()
python_binary_path = sys.executable
else:
python_binary_path = bpy.app.binary_path_python
environment.setup(DEPENDENCIES, python_binary_path)
from . import presence from . import presence
from . import operators from . import operators
@ -111,3 +103,5 @@ def unregister():
del bpy.types.ID.uuid del bpy.types.ID.uuid
del bpy.types.WindowManager.online_users del bpy.types.WindowManager.online_users
del bpy.types.WindowManager.user_index del bpy.types.WindowManager.user_index
environment.unregister()

View File

@ -1688,10 +1688,7 @@ class GitlabEngine(object):
# Could clash with tag names and if it does, it will # Could clash with tag names and if it does, it will
# download TAG zip instead of branch zip to get # download TAG zip instead of branch zip to get
# direct path, would need. # direct path, would need.
return "{}{}{}".format( return f"https://gitlab.com/slumber/multi-user/-/jobs/artifacts/{branch}/download?job=build"
self.form_repo_url(updater),
"/repository/archive.zip?sha=",
branch)
def get_zip_url(self, sha, updater): def get_zip_url(self, sha, updater):
return "{base}/repository/archive.zip?sha={sha}".format( return "{base}/repository/archive.zip?sha={sha}".format(

View File

@ -28,7 +28,6 @@ __all__ = [
'bl_light', 'bl_light',
'bl_scene', 'bl_scene',
'bl_material', 'bl_material',
'bl_library',
'bl_armature', 'bl_armature',
'bl_action', 'bl_action',
'bl_world', 'bl_world',
@ -39,7 +38,6 @@ __all__ = [
'bl_font', 'bl_font',
'bl_sound', 'bl_sound',
'bl_file', 'bl_file',
# 'bl_sequencer',
'bl_node_group', 'bl_node_group',
'bl_texture', 'bl_texture',
"bl_particle", "bl_particle",
@ -49,8 +47,18 @@ if bpy.app.version[1] >= 91:
__all__.append('bl_volume') __all__.append('bl_volume')
from . import * from . import *
from replication.data import DataTranslationProtocol
def types_to_register(): def types_to_register():
return __all__ return __all__
from replication.protocol import DataTranslationProtocol
def get_data_translation_protocol()-> DataTranslationProtocol:
""" Return a data translation protocol from implemented bpy types
"""
bpy_protocol = DataTranslationProtocol()
for module_name in __all__:
impl = globals().get(module_name)
if impl and hasattr(impl, "_type") and hasattr(impl, "_type"):
bpy_protocol.register_implementation(impl._type, impl._class)
return bpy_protocol

View File

@ -25,8 +25,8 @@ from enum import Enum
from .. import utils from .. import utils
from .dump_anything import ( from .dump_anything import (
Dumper, Loader, np_dump_collection, np_load_collection, remove_items_from_dict) Dumper, Loader, np_dump_collection, np_load_collection, remove_items_from_dict)
from .bl_datablock import BlDatablock, has_action, has_driver, dump_driver, load_driver from replication.protocol import ReplicatedDatablock
from .bl_datablock import resolve_datablock_from_uuid
KEYFRAME = [ KEYFRAME = [
'amplitude', 'amplitude',
@ -41,6 +41,66 @@ KEYFRAME = [
'interpolation', 'interpolation',
] ]
def has_action(datablock):
""" Check if the datablock datablock has actions
"""
return (hasattr(datablock, 'animation_data')
and datablock.animation_data
and datablock.animation_data.action)
def has_driver(datablock):
""" Check if the datablock datablock is driven
"""
return (hasattr(datablock, 'animation_data')
and datablock.animation_data
and datablock.animation_data.drivers)
def dump_driver(driver):
dumper = Dumper()
dumper.depth = 6
data = dumper.dump(driver)
return data
def load_driver(target_datablock, src_driver):
loader = Loader()
drivers = target_datablock.animation_data.drivers
src_driver_data = src_driver['driver']
new_driver = drivers.new(src_driver['data_path'], index=src_driver['array_index'])
# Settings
new_driver.driver.type = src_driver_data['type']
new_driver.driver.expression = src_driver_data['expression']
loader.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]
src_id = src_target_data.get('id')
if src_id:
new_var.targets[src_target].id = utils.resolve_from_id(src_target_data['id'], src_target_data['id_type'])
loader.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]
loader.load(new_point, src_driver['keyframe_points'][src_point])
def dump_fcurve(fcurve: bpy.types.FCurve, use_numpy: bool = True) -> dict: def dump_fcurve(fcurve: bpy.types.FCurve, use_numpy: bool = True) -> dict:
""" Dump a sigle curve to a dict """ Dump a sigle curve to a dict
@ -198,26 +258,28 @@ def resolve_animation_dependencies(datablock):
return [] return []
class BlAction(BlDatablock): class BlAction(ReplicatedDatablock):
bl_id = "actions" bl_id = "actions"
bl_class = bpy.types.Action bl_class = bpy.types.Action
bl_check_common = False bl_check_common = False
bl_icon = 'ACTION_TWEAK' bl_icon = 'ACTION_TWEAK'
bl_reload_parent = False bl_reload_parent = False
def _construct(self, data): @staticmethod
def construct(data: dict) -> object:
return bpy.data.actions.new(data["name"]) return bpy.data.actions.new(data["name"])
def _load_implementation(self, data, target): @staticmethod
def load(data: dict, datablock: object):
for dumped_fcurve in data["fcurves"]: for dumped_fcurve in data["fcurves"]:
dumped_data_path = dumped_fcurve["data_path"] dumped_data_path = dumped_fcurve["data_path"]
dumped_array_index = dumped_fcurve["dumped_array_index"] dumped_array_index = dumped_fcurve["dumped_array_index"]
# create fcurve if needed # create fcurve if needed
fcurve = target.fcurves.find( fcurve = datablock.fcurves.find(
dumped_data_path, index=dumped_array_index) dumped_data_path, index=dumped_array_index)
if fcurve is None: if fcurve is None:
fcurve = target.fcurves.new( fcurve = datablock.fcurves.new(
dumped_data_path, index=dumped_array_index) dumped_data_path, index=dumped_array_index)
load_fcurve(dumped_fcurve, fcurve) load_fcurve(dumped_fcurve, fcurve)
@ -225,9 +287,10 @@ class BlAction(BlDatablock):
id_root = data.get('id_root') id_root = data.get('id_root')
if id_root: if id_root:
target.id_root = id_root datablock.id_root = id_root
def _dump_implementation(self, data, instance=None): @staticmethod
def dump(datablock: object) -> dict:
dumper = Dumper() dumper = Dumper()
dumper.exclude_filter = [ dumper.exclude_filter = [
'name_full', 'name_full',
@ -242,11 +305,23 @@ class BlAction(BlDatablock):
'users' 'users'
] ]
dumper.depth = 1 dumper.depth = 1
data = dumper.dump(instance) data = dumper.dump(datablock)
data["fcurves"] = [] data["fcurves"] = []
for fcurve in instance.fcurves: for fcurve in datablock.fcurves:
data["fcurves"].append(dump_fcurve(fcurve, use_numpy=True)) data["fcurves"].append(dump_fcurve(fcurve, use_numpy=True))
return data return data
@staticmethod
def resolve(data: dict) -> object:
uuid = data.get('uuid')
return resolve_datablock_from_uuid(uuid, bpy.data.actions)
@staticmethod
def resolve_deps(datablock: object) -> [object]:
return []
_type = bpy.types.Action
_class = BlAction

View File

@ -22,8 +22,9 @@ import mathutils
from .dump_anything import Loader, Dumper from .dump_anything import Loader, Dumper
from .. import presence, operators, utils from .. import presence, operators, utils
from .bl_datablock import BlDatablock from replication.protocol import ReplicatedDatablock
from .bl_datablock import resolve_datablock_from_uuid
from .bl_action import dump_animation_data, load_animation_data, resolve_animation_dependencies
def get_roll(bone: bpy.types.Bone) -> float: def get_roll(bone: bpy.types.Bone) -> float:
""" Compute the actuall roll of a pose bone """ Compute the actuall roll of a pose bone
@ -35,17 +36,19 @@ def get_roll(bone: bpy.types.Bone) -> float:
return bone.AxisRollFromMatrix(bone.matrix_local.to_3x3())[1] return bone.AxisRollFromMatrix(bone.matrix_local.to_3x3())[1]
class BlArmature(BlDatablock): class BlArmature(ReplicatedDatablock):
bl_id = "armatures" bl_id = "armatures"
bl_class = bpy.types.Armature bl_class = bpy.types.Armature
bl_check_common = False bl_check_common = False
bl_icon = 'ARMATURE_DATA' bl_icon = 'ARMATURE_DATA'
bl_reload_parent = False bl_reload_parent = False
def _construct(self, data): @staticmethod
def construct(data: dict) -> object:
return bpy.data.armatures.new(data["name"]) return bpy.data.armatures.new(data["name"])
def _load_implementation(self, data, target): @staticmethod
def load(data: dict, datablock: object):
# Load parent object # Load parent object
parent_object = utils.find_from_attr( parent_object = utils.find_from_attr(
'uuid', 'uuid',
@ -55,7 +58,7 @@ class BlArmature(BlDatablock):
if parent_object is None: if parent_object is None:
parent_object = bpy.data.objects.new( parent_object = bpy.data.objects.new(
data['user_name'], target) data['user_name'], datablock)
parent_object.uuid = data['user'] parent_object.uuid = data['user']
is_object_in_master = ( is_object_in_master = (
@ -90,10 +93,10 @@ class BlArmature(BlDatablock):
bpy.ops.object.mode_set(mode='EDIT') bpy.ops.object.mode_set(mode='EDIT')
for bone in data['bones']: for bone in data['bones']:
if bone not in target.edit_bones: if bone not in datablock.edit_bones:
new_bone = target.edit_bones.new(bone) new_bone = datablock.edit_bones.new(bone)
else: else:
new_bone = target.edit_bones[bone] new_bone = datablock.edit_bones[bone]
bone_data = data['bones'].get(bone) bone_data = data['bones'].get(bone)
@ -104,7 +107,7 @@ class BlArmature(BlDatablock):
new_bone.roll = bone_data['roll'] new_bone.roll = bone_data['roll']
if 'parent' in bone_data: if 'parent' in bone_data:
new_bone.parent = target.edit_bones[data['bones'] new_bone.parent = datablock.edit_bones[data['bones']
[bone]['parent']] [bone]['parent']]
new_bone.use_connect = bone_data['use_connect'] new_bone.use_connect = bone_data['use_connect']
@ -119,9 +122,10 @@ class BlArmature(BlDatablock):
if 'EDIT' in current_mode: if 'EDIT' in current_mode:
bpy.ops.object.mode_set(mode='EDIT') bpy.ops.object.mode_set(mode='EDIT')
def _dump_implementation(self, data, instance=None): load_animation_data(data.get('animation_data'), datablock)
assert(instance)
@staticmethod
def dump(datablock: object) -> dict:
dumper = Dumper() dumper = Dumper()
dumper.depth = 4 dumper.depth = 4
dumper.include_filter = [ dumper.include_filter = [
@ -135,14 +139,14 @@ class BlArmature(BlDatablock):
'name', 'name',
'layers', 'layers',
] ]
data = dumper.dump(instance) data = dumper.dump(datablock)
for bone in instance.bones: for bone in datablock.bones:
if bone.parent: if bone.parent:
data['bones'][bone.name]['parent'] = bone.parent.name data['bones'][bone.name]['parent'] = bone.parent.name
# get the parent Object # get the parent Object
# TODO: Use id_data instead # TODO: Use id_data instead
object_users = utils.get_datablock_users(instance)[0] object_users = utils.get_datablock_users(datablock)[0]
data['user'] = object_users.uuid data['user'] = object_users.uuid
data['user_name'] = object_users.name data['user_name'] = object_users.name
@ -153,7 +157,25 @@ class BlArmature(BlDatablock):
data['user_scene'] = [ data['user_scene'] = [
item.name for item in container_users if isinstance(item, bpy.types.Scene)] item.name for item in container_users if isinstance(item, bpy.types.Scene)]
for bone in instance.bones: for bone in datablock.bones:
data['bones'][bone.name]['roll'] = get_roll(bone) data['bones'][bone.name]['roll'] = get_roll(bone)
data['animation_data'] = dump_animation_data(datablock)
return data return data
@staticmethod
def resolve(data: dict) -> object:
uuid = data.get('uuid')
name = data.get('name')
datablock = resolve_datablock_from_uuid(uuid, bpy.data.armatures)
if datablock is None:
datablock = bpy.data.armatures.get(name)
return datablock
@staticmethod
def resolve_deps(datablock: object) -> [object]:
return resolve_animation_dependencies(datablock)
_type = bpy.types.Armature
_class = BlArmature

View File

@ -20,39 +20,46 @@ import bpy
import mathutils import mathutils
from .dump_anything import Loader, Dumper from .dump_anything import Loader, Dumper
from .bl_datablock import BlDatablock from replication.protocol import ReplicatedDatablock
from .bl_datablock import resolve_datablock_from_uuid
from .bl_action import dump_animation_data, load_animation_data, resolve_animation_dependencies
class BlCamera(BlDatablock): class BlCamera(ReplicatedDatablock):
bl_id = "cameras" bl_id = "cameras"
bl_class = bpy.types.Camera bl_class = bpy.types.Camera
bl_check_common = False bl_check_common = False
bl_icon = 'CAMERA_DATA' bl_icon = 'CAMERA_DATA'
bl_reload_parent = False bl_reload_parent = False
def _construct(self, data):
@staticmethod
def construct(data: dict) -> object:
return bpy.data.cameras.new(data["name"]) return bpy.data.cameras.new(data["name"])
def _load_implementation(self, data, target): @staticmethod
def load(data: dict, datablock: object):
loader = Loader() loader = Loader()
loader.load(target, data) loader.load(datablock, data)
dof_settings = data.get('dof') dof_settings = data.get('dof')
load_animation_data(data.get('animation_data'), datablock)
# DOF settings # DOF settings
if dof_settings: if dof_settings:
loader.load(target.dof, dof_settings) loader.load(datablock.dof, dof_settings)
background_images = data.get('background_images') background_images = data.get('background_images')
target.background_images.clear() datablock.background_images.clear()
if background_images: if background_images:
for img_name, img_data in background_images.items(): for img_name, img_data in background_images.items():
img_id = img_data.get('image') img_id = img_data.get('image')
if img_id: if img_id:
target_img = target.background_images.new() target_img = datablock.background_images.new()
target_img.image = bpy.data.images[img_id] target_img.image = bpy.data.images[img_id]
loader.load(target_img, img_data) loader.load(target_img, img_data)
@ -61,11 +68,8 @@ class BlCamera(BlDatablock):
loader.load(target_img.image_user, img_user) loader.load(target_img.image_user, img_user)
def _dump_implementation(self, data, instance=None): @staticmethod
assert(instance) def dump(datablock: object) -> dict:
# TODO: background image support
dumper = Dumper() dumper = Dumper()
dumper.depth = 3 dumper.depth = 3
dumper.include_filter = [ dumper.include_filter = [
@ -114,15 +118,29 @@ class BlCamera(BlDatablock):
'use_cyclic', 'use_cyclic',
'use_auto_refresh' 'use_auto_refresh'
] ]
data = dumper.dump(instance) data = dumper.dump(datablock)
for index, image in enumerate(instance.background_images): data['animation_data'] = dump_animation_data(datablock)
for index, image in enumerate(datablock.background_images):
if image.image_user: if image.image_user:
data['background_images'][index]['image_user'] = dumper.dump(image.image_user) data['background_images'][index]['image_user'] = dumper.dump(image.image_user)
return data return data
def _resolve_deps_implementation(self):
@staticmethod
def resolve(data: dict) -> object:
uuid = data.get('uuid')
return resolve_datablock_from_uuid(uuid, bpy.data.cameras)
@staticmethod
def resolve_deps(datablock: object) -> [object]:
deps = [] deps = []
for background in self.instance.background_images: for background in datablock.background_images:
if background.image: if background.image:
deps.append(background.image) deps.append(background.image)
deps.extend(resolve_animation_dependencies(datablock))
return deps return deps
_type = bpy.types.Camera
_class = BlCamera

View File

@ -19,10 +19,12 @@
import bpy import bpy
import mathutils import mathutils
from .. import utils from deepdiff import DeepDiff, Delta
from .bl_datablock import BlDatablock
from .dump_anything import Loader, Dumper
from .. import utils
from replication.protocol import ReplicatedDatablock
from .dump_anything import Loader, Dumper
from .bl_datablock import resolve_datablock_from_uuid
def dump_collection_children(collection): def dump_collection_children(collection):
collection_children = [] collection_children = []
@ -81,58 +83,82 @@ def resolve_collection_dependencies(collection):
return deps return deps
class BlCollection(BlDatablock): class BlCollection(ReplicatedDatablock):
bl_id = "collections" bl_id = "collections"
bl_icon = 'FILE_FOLDER' bl_icon = 'FILE_FOLDER'
bl_class = bpy.types.Collection bl_class = bpy.types.Collection
bl_check_common = True bl_check_common = True
bl_reload_parent = False bl_reload_parent = False
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):
targetData.collections = [
name for name in sourceData.collections if name == self.data['name']]
instance = bpy.data.collections[self.data['name']] use_delta = True
return instance
@staticmethod
def construct(data: dict) -> object:
instance = bpy.data.collections.new(data["name"]) instance = bpy.data.collections.new(data["name"])
return instance return instance
def _load_implementation(self, data, target):
@staticmethod
def load(data: dict, datablock: object):
loader = Loader() loader = Loader()
loader.load(target, data) loader.load(datablock, data)
# Objects # Objects
load_collection_objects(data['objects'], target) load_collection_objects(data['objects'], datablock)
# Link childrens # Link childrens
load_collection_childrens(data['children'], target) load_collection_childrens(data['children'], datablock)
# FIXME: Find a better way after the replication big refacotoring # FIXME: Find a better way after the replication big refacotoring
# Keep other user from deleting collection object by flushing their history # Keep other user from deleting collection object by flushing their history
utils.flush_history() utils.flush_history()
def _dump_implementation(self, data, instance=None):
assert(instance)
@staticmethod
def dump(datablock: object) -> dict:
dumper = Dumper() dumper = Dumper()
dumper.depth = 1 dumper.depth = 1
dumper.include_filter = [ dumper.include_filter = [
"name", "name",
"instance_offset" "instance_offset"
] ]
data = dumper.dump(instance) data = dumper.dump(datablock)
# dump objects # dump objects
data['objects'] = dump_collection_objects(instance) data['objects'] = dump_collection_objects(datablock)
# dump children collections # dump children collections
data['children'] = dump_collection_children(instance) data['children'] = dump_collection_children(datablock)
return data return data
def _resolve_deps_implementation(self):
return resolve_collection_dependencies(self.instance) @staticmethod
def resolve(data: dict) -> object:
uuid = data.get('uuid')
return resolve_datablock_from_uuid(uuid, bpy.data.collections)
@staticmethod
def resolve_deps(datablock: object) -> [object]:
return resolve_collection_dependencies(datablock)
@staticmethod
def compute_delta(last_data: dict, current_data: dict) -> Delta:
diff_params = {
'ignore_order': True,
'report_repetition': True
}
delta_params = {
# 'mutate': True
}
return Delta(
DeepDiff(last_data,
current_data,
cache_size=5000,
**diff_params),
**delta_params)
_type = bpy.types.Collection
_class = BlCollection

View File

@ -21,13 +21,15 @@ import bpy.types as T
import mathutils import mathutils
import logging import logging
from .. import utils from ..utils import get_preferences
from .bl_datablock import BlDatablock from replication.protocol import ReplicatedDatablock
from .dump_anything import (Dumper, Loader, from .dump_anything import (Dumper, Loader,
np_load_collection, np_load_collection,
np_dump_collection) np_dump_collection)
from .bl_datablock import get_datablock_from_uuid
from .bl_material import dump_materials_slots, load_materials_slots from .bl_material import dump_materials_slots, load_materials_slots
from .bl_datablock import resolve_datablock_from_uuid
from .bl_action import dump_animation_data, load_animation_data, resolve_animation_dependencies
SPLINE_BEZIER_POINT = [ SPLINE_BEZIER_POINT = [
# "handle_left_type", # "handle_left_type",
@ -134,25 +136,29 @@ SPLINE_METADATA = [
] ]
class BlCurve(BlDatablock): class BlCurve(ReplicatedDatablock):
bl_id = "curves" bl_id = "curves"
bl_class = bpy.types.Curve bl_class = bpy.types.Curve
bl_check_common = False bl_check_common = False
bl_icon = 'CURVE_DATA' bl_icon = 'CURVE_DATA'
bl_reload_parent = False bl_reload_parent = False
def _construct(self, data): @staticmethod
def construct(data: dict) -> object:
return bpy.data.curves.new(data["name"], data["type"]) return bpy.data.curves.new(data["name"], data["type"])
def _load_implementation(self, data, target): @staticmethod
loader = Loader() def load(data: dict, datablock: object):
loader.load(target, data) load_animation_data(data.get('animation_data'), datablock)
target.splines.clear() loader = Loader()
loader.load(datablock, data)
datablock.splines.clear()
# load splines # load splines
for spline in data['splines'].values(): for spline in data['splines'].values():
new_spline = target.splines.new(spline['type']) new_spline = datablock.splines.new(spline['type'])
# Load curve geometry data # Load curve geometry data
if new_spline.type == 'BEZIER': if new_spline.type == 'BEZIER':
@ -173,15 +179,14 @@ class BlCurve(BlDatablock):
# MATERIAL SLOTS # MATERIAL SLOTS
src_materials = data.get('materials', None) src_materials = data.get('materials', None)
if src_materials: if src_materials:
load_materials_slots(src_materials, target.materials) load_materials_slots(src_materials, datablock.materials)
def _dump_implementation(self, data, instance=None): @staticmethod
assert(instance) def dump(datablock: object) -> dict:
dumper = Dumper() dumper = Dumper()
# Conflicting attributes # Conflicting attributes
# TODO: remove them with the NURBS support # TODO: remove them with the NURBS support
dumper.include_filter = CURVE_METADATA dumper.include_filter = CURVE_METADATA
dumper.exclude_filter = [ dumper.exclude_filter = [
'users', 'users',
'order_u', 'order_u',
@ -190,14 +195,16 @@ class BlCurve(BlDatablock):
'point_count_u', 'point_count_u',
'active_textbox' 'active_textbox'
] ]
if instance.use_auto_texspace: if datablock.use_auto_texspace:
dumper.exclude_filter.extend([ dumper.exclude_filter.extend([
'texspace_location', 'texspace_location',
'texspace_size']) 'texspace_size'])
data = dumper.dump(instance) data = dumper.dump(datablock)
data['animation_data'] = dump_animation_data(datablock)
data['splines'] = {} data['splines'] = {}
for index, spline in enumerate(instance.splines): for index, spline in enumerate(datablock.splines):
dumper.depth = 2 dumper.depth = 2
dumper.include_filter = SPLINE_METADATA dumper.include_filter = SPLINE_METADATA
spline_data = dumper.dump(spline) spline_data = dumper.dump(spline)
@ -211,21 +218,27 @@ class BlCurve(BlDatablock):
spline.bezier_points, SPLINE_BEZIER_POINT) spline.bezier_points, SPLINE_BEZIER_POINT)
data['splines'][index] = spline_data data['splines'][index] = spline_data
if isinstance(instance, T.SurfaceCurve): if isinstance(datablock, T.SurfaceCurve):
data['type'] = 'SURFACE' data['type'] = 'SURFACE'
elif isinstance(instance, T.TextCurve): elif isinstance(datablock, T.TextCurve):
data['type'] = 'FONT' data['type'] = 'FONT'
elif isinstance(instance, T.Curve): elif isinstance(datablock, T.Curve):
data['type'] = 'CURVE' data['type'] = 'CURVE'
data['materials'] = dump_materials_slots(instance.materials) data['materials'] = dump_materials_slots(datablock.materials)
return data return data
def _resolve_deps_implementation(self): @staticmethod
def resolve(data: dict) -> object:
uuid = data.get('uuid')
return resolve_datablock_from_uuid(uuid, bpy.data.curves)
@staticmethod
def resolve_deps(datablock: object) -> [object]:
# TODO: resolve material # TODO: resolve material
deps = [] deps = []
curve = self.instance curve = datablock
if isinstance(curve, T.TextCurve): if isinstance(curve, T.TextCurve):
deps.extend([ deps.extend([
@ -234,15 +247,19 @@ class BlCurve(BlDatablock):
curve.font_bold_italic, curve.font_bold_italic,
curve.font_italic]) curve.font_italic])
for material in self.instance.materials: for material in datablock.materials:
if material: if material:
deps.append(material) deps.append(material)
deps.extend(resolve_animation_dependencies(datablock))
return deps return deps
def diff(self): @staticmethod
if 'EDIT' in bpy.context.mode \ def needs_update(datablock: object, data: dict) -> bool:
and not self.preferences.sync_flags.sync_during_editmode: return 'EDIT' not in bpy.context.mode \
return False or get_preferences().sync_flags.sync_during_editmode
else:
return super().diff()
_type = [bpy.types.Curve, bpy.types.TextCurve]
_class = BlCurve

View File

@ -22,73 +22,11 @@ from collections.abc import Iterable
import bpy import bpy
import mathutils import mathutils
from replication.constants import DIFF_BINARY, DIFF_JSON, UP from replication.constants import DIFF_BINARY, DIFF_JSON, UP
from replication.data import ReplicatedDatablock from replication.protocol import ReplicatedDatablock
from .. import utils from .. import utils
from .dump_anything import Dumper, Loader from .dump_anything import Dumper, Loader
def has_action(target):
""" Check if the target datablock has actions
"""
return (hasattr(target, 'animation_data')
and target.animation_data
and target.animation_data.action)
def has_driver(target):
""" Check if the target datablock is driven
"""
return (hasattr(target, 'animation_data')
and target.animation_data
and target.animation_data.drivers)
def dump_driver(driver):
dumper = Dumper()
dumper.depth = 6
data = dumper.dump(driver)
return data
def load_driver(target_datablock, src_driver):
loader = Loader()
drivers = target_datablock.animation_data.drivers
src_driver_data = src_driver['driver']
new_driver = drivers.new(src_driver['data_path'], index=src_driver['array_index'])
# Settings
new_driver.driver.type = src_driver_data['type']
new_driver.driver.expression = src_driver_data['expression']
loader.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]
src_id = src_target_data.get('id')
if src_id:
new_var.targets[src_target].id = utils.resolve_from_id(src_target_data['id'], src_target_data['id_type'])
loader.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]
loader.load(new_point, src_driver['keyframe_points'][src_point])
def get_datablock_from_uuid(uuid, default, ignore=[]): def get_datablock_from_uuid(uuid, default, ignore=[]):
if not uuid: if not uuid:
return default return default
@ -100,133 +38,8 @@ def get_datablock_from_uuid(uuid, default, ignore=[]):
return item return item
return default return default
def resolve_datablock_from_uuid(uuid, bpy_collection):
class BlDatablock(ReplicatedDatablock): for item in bpy_collection:
"""BlDatablock if getattr(item, 'uuid', None) == uuid:
return item
bl_id : blender internal storage identifier return None
bl_class : blender internal type
bl_icon : type icon (blender icon name)
bl_check_common: enable check even in common rights
bl_reload_parent: reload parent
"""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
instance = kwargs.get('instance', None)
self.preferences = utils.get_preferences()
# TODO: use is_library_indirect
self.is_library = (instance and hasattr(instance, 'library') and
instance.library) or \
(hasattr(self,'data') and self.data and 'library' in self.data)
if instance and hasattr(instance, 'uuid'):
instance.uuid = self.uuid
def resolve(self, construct = True):
datablock_root = getattr(bpy.data, self.bl_id)
datablock_ref = utils.find_from_attr('uuid', self.uuid, datablock_root)
if not datablock_ref:
try:
datablock_ref = datablock_root[self.data['name']]
except Exception:
pass
if construct and not datablock_ref:
name = self.data.get('name')
logging.debug(f"Constructing {name}")
datablock_ref = self._construct(data=self.data)
if datablock_ref is not None:
setattr(datablock_ref, 'uuid', self.uuid)
self.instance = datablock_ref
return True
else:
return False
def remove_instance(self):
"""
Remove instance from blender data
"""
assert(self.instance)
datablock_root = getattr(bpy.data, self.bl_id)
datablock_root.remove(self.instance)
def _dump(self, instance=None):
dumper = Dumper()
data = {}
animation_data = {}
# Dump animation data
if has_action(instance):
animation_data['action'] = instance.animation_data.action.name
if has_driver(instance):
animation_data['drivers'] = []
for driver in instance.animation_data.drivers:
animation_data['drivers'].append(dump_driver(driver))
if animation_data:
data['animation_data'] = animation_data
if self.is_library:
data.update(dumper.dump(instance))
else:
data.update(self._dump_implementation(data, instance=instance))
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']]
elif target.animation_data.action:
target.animation_data.action = None
# Remove existing animation data if there is not more to load
elif hasattr(target, 'animation_data') and target.animation_data:
target.animation_data_clear()
if self.is_library:
return
else:
self._load_implementation(data, target)
def _load_implementation(self, data, target):
raise NotImplementedError
def resolve_deps(self):
dependencies = []
if has_action(self.instance):
dependencies.append(self.instance.animation_data.action)
if not self.is_library:
dependencies.extend(self._resolve_deps_implementation())
logging.debug(f"{self.instance} dependencies: {dependencies}")
return dependencies
def _resolve_deps_implementation(self):
return []
def is_valid(self):
return getattr(bpy.data, self.bl_id).get(self.data['name'])

View File

@ -19,14 +19,15 @@
import logging import logging
import os import os
import sys import sys
from pathlib import Path from pathlib import Path, WindowsPath, PosixPath
import bpy import bpy
import mathutils import mathutils
from replication.constants import DIFF_BINARY, UP from replication.constants import DIFF_BINARY, UP
from replication.data import ReplicatedDatablock from replication.protocol import ReplicatedDatablock
from .. import utils from .. import utils
from ..utils import get_preferences
from .dump_anything import Dumper, Loader from .dump_anything import Dumper, Loader
@ -58,33 +59,16 @@ class BlFile(ReplicatedDatablock):
bl_icon = 'FILE' bl_icon = 'FILE'
bl_reload_parent = True bl_reload_parent = True
def __init__(self, *args, **kwargs): @staticmethod
super().__init__(*args, **kwargs) def construct(data: dict) -> object:
self.instance = kwargs.get('instance', None) return Path(get_filepath(data['name']))
if self.instance and not self.instance.exists():
raise FileNotFoundError(str(self.instance))
self.preferences = utils.get_preferences()
def resolve(self, construct = True): @staticmethod
self.instance = Path(get_filepath(self.data['name'])) def resolve(data: dict) -> object:
return Path(get_filepath(data['name']))
file_exists = self.instance.exists()
if not file_exists:
logging.debug("File don't exist, loading it.")
self._load(self.data, self.instance)
return file_exists
@staticmethod
def push(self, socket, identity=None, check_data=False): def dump(datablock: object) -> dict:
super().push(socket, identity=None, check_data=False)
if self.preferences.clear_memory_filecache:
del self.data['file']
def _dump(self, instance=None):
""" """
Read the file and return a dict as: Read the file and return a dict as:
{ {
@ -96,46 +80,62 @@ class BlFile(ReplicatedDatablock):
logging.info(f"Extracting file metadata") logging.info(f"Extracting file metadata")
data = { data = {
'name': self.instance.name, 'name': datablock.name,
} }
logging.info( logging.info(f"Reading {datablock.name} content: {datablock.stat().st_size} bytes")
f"Reading {self.instance.name} content: {self.instance.stat().st_size} bytes")
try: try:
file = open(self.instance, "rb") file = open(datablock, "rb")
data['file'] = file.read() data['file'] = file.read()
file.close() file.close()
except IOError: except IOError:
logging.warning(f"{self.instance} doesn't exist, skipping") logging.warning(f"{datablock} doesn't exist, skipping")
else: else:
file.close() file.close()
return data return data
def _load(self, data, target): @staticmethod
def load(data: dict, datablock: object):
""" """
Writing the file Writing the file
""" """
try: try:
file = open(target, "wb") file = open(datablock, "wb")
file.write(data['file']) file.write(data['file'])
if self.preferences.clear_memory_filecache: if get_preferences().clear_memory_filecache:
del self.data['file'] del data['file']
except IOError: except IOError:
logging.warning(f"{target} doesn't exist, skipping") logging.warning(f"{datablock} doesn't exist, skipping")
else: else:
file.close() file.close()
def diff(self): @staticmethod
if self.preferences.clear_memory_filecache: def resolve_deps(datablock: object) -> [object]:
return []
@staticmethod
def needs_update(datablock: object, data:dict)-> bool:
if get_preferences().clear_memory_filecache:
return False return False
else: else:
if not self.instance: if not datablock:
return None
if not data:
return True
memory_size = sys.getsizeof(data['file'])-33
disk_size = datablock.stat().st_size
if memory_size != disk_size:
return True
else:
return False return False
memory_size = sys.getsizeof(self.data['file'])-33
disk_size = self.instance.stat().st_size _type = [WindowsPath, PosixPath]
return memory_size != disk_size _class = BlFile

View File

@ -22,19 +22,20 @@ from pathlib import Path
import bpy import bpy
from .bl_datablock import BlDatablock from replication.protocol import ReplicatedDatablock
from .bl_file import get_filepath, ensure_unpacked from .bl_file import get_filepath, ensure_unpacked
from .dump_anything import Dumper, Loader from .dump_anything import Dumper, Loader
from .bl_datablock import resolve_datablock_from_uuid
class BlFont(ReplicatedDatablock):
class BlFont(BlDatablock):
bl_id = "fonts" bl_id = "fonts"
bl_class = bpy.types.VectorFont bl_class = bpy.types.VectorFont
bl_check_common = False bl_check_common = False
bl_icon = 'FILE_FONT' bl_icon = 'FILE_FONT'
bl_reload_parent = False bl_reload_parent = False
def _construct(self, data): @staticmethod
def construct(data: dict) -> object:
filename = data.get('filename') filename = data.get('filename')
if filename == '<builtin>': if filename == '<builtin>':
@ -42,31 +43,43 @@ class BlFont(BlDatablock):
else: else:
return bpy.data.fonts.load(get_filepath(filename)) return bpy.data.fonts.load(get_filepath(filename))
def _load(self, data, target): @staticmethod
def load(data: dict, datablock: object):
pass pass
def _dump(self, instance=None): @staticmethod
if instance.filepath == '<builtin>': def dump(datablock: object) -> dict:
if datablock.filepath == '<builtin>':
filename = '<builtin>' filename = '<builtin>'
else: else:
filename = Path(instance.filepath).name filename = Path(datablock.filepath).name
if not filename: if not filename:
raise FileExistsError(instance.filepath) raise FileExistsError(datablock.filepath)
return { return {
'filename': filename, 'filename': filename,
'name': instance.name 'name': datablock.name
} }
def diff(self): @staticmethod
return False def resolve(data: dict) -> object:
uuid = data.get('uuid')
return resolve_datablock_from_uuid(uuid, bpy.data.fonts)
def _resolve_deps_implementation(self): @staticmethod
def resolve_deps(datablock: object) -> [object]:
deps = [] deps = []
if self.instance.filepath and self.instance.filepath != '<builtin>': if datablock.filepath and datablock.filepath != '<builtin>':
ensure_unpacked(self.instance) ensure_unpacked(datablock)
deps.append(Path(bpy.path.abspath(self.instance.filepath))) deps.append(Path(bpy.path.abspath(datablock.filepath)))
return deps return deps
@staticmethod
def needs_update(datablock: object, data:dict)-> bool:
return False
_type = bpy.types.VectorFont
_class = BlFont

View File

@ -24,10 +24,11 @@ from .dump_anything import (Dumper,
Loader, Loader,
np_dump_collection, np_dump_collection,
np_load_collection) np_load_collection)
from .bl_datablock import BlDatablock from replication.protocol import ReplicatedDatablock
from .bl_datablock import resolve_datablock_from_uuid
from .bl_action import dump_animation_data, load_animation_data, resolve_animation_dependencies
from ..utils import get_preferences
# GPencil data api is structured as it follow:
# GP-Object --> GP-Layers --> GP-Frames --> GP-Strokes --> GP-Stroke-Points
STROKE_POINT = [ STROKE_POINT = [
'co', 'co',
@ -113,6 +114,7 @@ def load_stroke(stroke_data, stroke):
# fix fill issues # fix fill issues
stroke.uv_scale = stroke_data["uv_scale"] stroke.uv_scale = stroke_data["uv_scale"]
def dump_frame(frame): def dump_frame(frame):
""" Dump a grease pencil frame to a dict """ Dump a grease pencil frame to a dict
@ -151,6 +153,7 @@ def load_frame(frame_data, frame):
np_load_collection(frame_data['strokes'], frame.strokes, STROKE) np_load_collection(frame_data['strokes'], frame.strokes, STROKE)
def dump_layer(layer): def dump_layer(layer):
""" Dump a grease pencil layer """ Dump a grease pencil layer
@ -228,47 +231,58 @@ def load_layer(layer_data, layer):
load_frame(frame_data, target_frame) load_frame(frame_data, target_frame)
class BlGpencil(BlDatablock): def layer_changed(datablock: object, data: dict) -> bool:
if datablock.layers.active and \
datablock.layers.active.info != data["active_layers"]:
return True
else:
return False
def frame_changed(data: dict) -> bool:
return bpy.context.scene.frame_current != data["eval_frame"]
class BlGpencil(ReplicatedDatablock):
bl_id = "grease_pencils" bl_id = "grease_pencils"
bl_class = bpy.types.GreasePencil bl_class = bpy.types.GreasePencil
bl_check_common = False bl_check_common = False
bl_icon = 'GREASEPENCIL' bl_icon = 'GREASEPENCIL'
bl_reload_parent = False bl_reload_parent = False
def _construct(self, data): @staticmethod
def construct(data: dict) -> object:
return bpy.data.grease_pencils.new(data["name"]) return bpy.data.grease_pencils.new(data["name"])
def _load_implementation(self, data, target): @staticmethod
target.materials.clear() def load(data: dict, datablock: object):
datablock.materials.clear()
if "materials" in data.keys(): if "materials" in data.keys():
for mat in data['materials']: for mat in data['materials']:
target.materials.append(bpy.data.materials[mat]) datablock.materials.append(bpy.data.materials[mat])
loader = Loader() loader = Loader()
loader.load(target, data) loader.load(datablock, data)
# TODO: reuse existing layer # TODO: reuse existing layer
for layer in target.layers: for layer in datablock.layers:
target.layers.remove(layer) datablock.layers.remove(layer)
if "layers" in data.keys(): if "layers" in data.keys():
for layer in data["layers"]: for layer in data["layers"]:
layer_data = data["layers"].get(layer) layer_data = data["layers"].get(layer)
# if layer not in target.layers.keys(): # if layer not in datablock.layers.keys():
target_layer = target.layers.new(data["layers"][layer]["info"]) target_layer = datablock.layers.new(data["layers"][layer]["info"])
# else: # else:
# target_layer = target.layers[layer] # target_layer = target.layers[layer]
# target_layer.clear() # target_layer.clear()
load_layer(layer_data, target_layer) load_layer(layer_data, target_layer)
target.layers.update() datablock.layers.update()
@staticmethod
def dump(datablock: object) -> dict:
def _dump_implementation(self, data, instance=None):
assert(instance)
dumper = Dumper() dumper = Dumper()
dumper.depth = 2 dumper.depth = 2
dumper.include_filter = [ dumper.include_filter = [
@ -279,36 +293,37 @@ class BlGpencil(BlDatablock):
'pixel_factor', 'pixel_factor',
'stroke_depth_order' 'stroke_depth_order'
] ]
data = dumper.dump(instance) data = dumper.dump(datablock)
data['layers'] = {} data['layers'] = {}
for layer in instance.layers: for layer in datablock.layers:
data['layers'][layer.info] = dump_layer(layer) data['layers'][layer.info] = dump_layer(layer)
data["active_layers"] = instance.layers.active.info data["active_layers"] = datablock.layers.active.info if datablock.layers.active else "None"
data["eval_frame"] = bpy.context.scene.frame_current data["eval_frame"] = bpy.context.scene.frame_current
return data return data
def _resolve_deps_implementation(self): @staticmethod
def resolve(data: dict) -> object:
uuid = data.get('uuid')
return resolve_datablock_from_uuid(uuid, bpy.data.grease_pencils)
@staticmethod
def resolve_deps(datablock: object) -> [object]:
deps = [] deps = []
for material in self.instance.materials: for material in datablock.materials:
deps.append(material) deps.append(material)
return deps return deps
def layer_changed(self): @staticmethod
return self.instance.layers.active.info != self.data["active_layers"] def needs_update(datablock: object, data: dict) -> bool:
return bpy.context.mode == 'OBJECT' \
or layer_changed(datablock, data) \
or frame_changed(data) \
or get_preferences().sync_flags.sync_during_editmode
def frame_changed(self): _type = bpy.types.GreasePencil
return bpy.context.scene.frame_current != self.data["eval_frame"] _class = BlGpencil
def diff(self):
if self.layer_changed() \
or self.frame_changed() \
or bpy.context.mode == 'OBJECT' \
or self.preferences.sync_flags.sync_during_editmode:
return super().diff()
else:
return False

View File

@ -24,9 +24,12 @@ import bpy
import mathutils import mathutils
from .. import utils from .. import utils
from .bl_datablock import BlDatablock from replication.protocol import ReplicatedDatablock
from .dump_anything import Dumper, Loader from .dump_anything import Dumper, Loader
from .bl_file import get_filepath, ensure_unpacked from .bl_file import get_filepath, ensure_unpacked
from .bl_datablock import resolve_datablock_from_uuid
from .bl_action import dump_animation_data, load_animation_data, resolve_animation_dependencies
format_to_ext = { format_to_ext = {
'BMP': 'bmp', 'BMP': 'bmp',
@ -48,35 +51,36 @@ format_to_ext = {
} }
class BlImage(BlDatablock): class BlImage(ReplicatedDatablock):
bl_id = "images" bl_id = "images"
bl_class = bpy.types.Image bl_class = bpy.types.Image
bl_check_common = False bl_check_common = False
bl_icon = 'IMAGE_DATA' bl_icon = 'IMAGE_DATA'
bl_reload_parent = False bl_reload_parent = False
def _construct(self, data): @staticmethod
def construct(data: dict) -> object:
return bpy.data.images.new( return bpy.data.images.new(
name=data['name'], name=data['name'],
width=data['size'][0], width=data['size'][0],
height=data['size'][1] height=data['size'][1]
) )
def _load(self, data, target): @staticmethod
def load(data: dict, datablock: object):
loader = Loader() loader = Loader()
loader.load(data, target) loader.load(data, datablock)
target.source = 'FILE' datablock.source = 'FILE'
target.filepath_raw = get_filepath(data['filename']) datablock.filepath_raw = get_filepath(data['filename'])
color_space_name = data["colorspace_settings"]["name"] color_space_name = data["colorspace_settings"]["name"]
if color_space_name: if color_space_name:
target.colorspace_settings.name = color_space_name datablock.colorspace_settings.name = color_space_name
def _dump(self, instance=None): @staticmethod
assert(instance) def dump(datablock: object) -> dict:
filename = Path(datablock.filepath).name
filename = Path(instance.filepath).name
data = { data = {
"filename": filename "filename": filename
@ -93,35 +97,45 @@ class BlImage(BlDatablock):
'float_buffer', 'float_buffer',
'alpha_mode', 'alpha_mode',
'colorspace_settings'] 'colorspace_settings']
data.update(dumper.dump(instance)) data.update(dumper.dump(datablock))
return data return data
def diff(self): @staticmethod
if self.instance.is_dirty: def resolve(data: dict) -> object:
self.instance.save() uuid = data.get('uuid')
return resolve_datablock_from_uuid(uuid, bpy.data.images)
if self.instance and (self.instance.name != self.data['name']): @staticmethod
def resolve_deps(datablock: object) -> [object]:
deps = []
if datablock.packed_file:
filename = Path(bpy.path.abspath(datablock.filepath)).name
datablock.filepath_raw = get_filepath(filename)
datablock.save()
# An image can't be unpacked to the modified path
# TODO: make a bug report
datablock.unpack(method="REMOVE")
elif datablock.source == "GENERATED":
filename = f"{datablock.name}.png"
datablock.filepath = get_filepath(filename)
datablock.save()
if datablock.filepath:
deps.append(Path(bpy.path.abspath(datablock.filepath)))
return deps
@staticmethod
def needs_update(datablock: object, data:dict)-> bool:
if datablock.is_dirty:
datablock.save()
if not data or (datablock and (datablock.name != data.get('name'))):
return True return True
else: else:
return False return False
def _resolve_deps_implementation(self): _type = bpy.types.Image
deps = [] _class = BlImage
if self.instance.packed_file:
filename = Path(bpy.path.abspath(self.instance.filepath)).name
self.instance.filepath_raw = get_filepath(filename)
self.instance.save()
# An image can't be unpacked to the modified path
# TODO: make a bug report
self.instance.unpack(method="REMOVE")
elif self.instance.source == "GENERATED":
filename = f"{self.instance.name}.png"
self.instance.filepath = get_filepath(filename)
self.instance.save()
if self.instance.filepath:
deps.append(Path(bpy.path.abspath(self.instance.filepath)))
return deps

View File

@ -20,33 +20,39 @@ import bpy
import mathutils import mathutils
from .dump_anything import Dumper, Loader, np_dump_collection, np_load_collection from .dump_anything import Dumper, Loader, np_dump_collection, np_load_collection
from .bl_datablock import BlDatablock from replication.protocol import ReplicatedDatablock
from replication.exception import ContextError from replication.exception import ContextError
from .bl_datablock import resolve_datablock_from_uuid
from .bl_action import dump_animation_data, load_animation_data, resolve_animation_dependencies
POINT = ['co', 'weight_softbody', 'co_deform'] POINT = ['co', 'weight_softbody', 'co_deform']
class BlLattice(BlDatablock): class BlLattice(ReplicatedDatablock):
bl_id = "lattices" bl_id = "lattices"
bl_class = bpy.types.Lattice bl_class = bpy.types.Lattice
bl_check_common = False bl_check_common = False
bl_icon = 'LATTICE_DATA' bl_icon = 'LATTICE_DATA'
bl_reload_parent = False bl_reload_parent = False
def _construct(self, data): @staticmethod
def construct(data: dict) -> object:
return bpy.data.lattices.new(data["name"]) return bpy.data.lattices.new(data["name"])
def _load_implementation(self, data, target): @staticmethod
if target.is_editmode: def load(data: dict, datablock: object):
load_animation_data(data.get('animation_data'), datablock)
if datablock.is_editmode:
raise ContextError("lattice is in edit mode") raise ContextError("lattice is in edit mode")
loader = Loader() loader = Loader()
loader.load(target, data) loader.load(datablock, data)
np_load_collection(data['points'], target.points, POINT) np_load_collection(data['points'], datablock.points, POINT)
def _dump_implementation(self, data, instance=None): @staticmethod
if instance.is_editmode: def dump(datablock: object) -> dict:
if datablock.is_editmode:
raise ContextError("lattice is in edit mode") raise ContextError("lattice is in edit mode")
dumper = Dumper() dumper = Dumper()
@ -62,9 +68,20 @@ class BlLattice(BlDatablock):
'interpolation_type_w', 'interpolation_type_w',
'use_outside' 'use_outside'
] ]
data = dumper.dump(instance) data = dumper.dump(datablock)
data['points'] = np_dump_collection(instance.points, POINT)
data['points'] = np_dump_collection(datablock.points, POINT)
data['animation_data'] = dump_animation_data(datablock)
return data return data
@staticmethod
def resolve(data: dict) -> object:
uuid = data.get('uuid')
return resolve_datablock_from_uuid(uuid, bpy.data.lattices)
@staticmethod
def resolve_deps(datablock: object) -> [object]:
return resolve_animation_dependencies(datablock)
_type = bpy.types.Lattice
_class = BlLattice

View File

@ -1,45 +0,0 @@
# ##### BEGIN GPL LICENSE BLOCK #####
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
#
# ##### END GPL LICENSE BLOCK #####
import bpy
import mathutils
from .dump_anything import Loader, Dumper
from .bl_datablock import BlDatablock
class BlLibrary(BlDatablock):
bl_id = "libraries"
bl_class = bpy.types.Library
bl_check_common = False
bl_icon = 'LIBRARY_DATA_DIRECT'
bl_reload_parent = False
def _construct(self, data):
with bpy.data.libraries.load(filepath=data["filepath"], link=True) as (sourceData, targetData):
targetData = sourceData
return sourceData
def _load(self, data, target):
pass
def _dump(self, instance=None):
assert(instance)
dumper = Dumper()
return dumper.dump(instance)

View File

@ -20,25 +20,32 @@ import bpy
import mathutils import mathutils
from .dump_anything import Loader, Dumper from .dump_anything import Loader, Dumper
from .bl_datablock import BlDatablock from replication.protocol import ReplicatedDatablock
from .bl_datablock import resolve_datablock_from_uuid
from .bl_action import dump_animation_data, load_animation_data, resolve_animation_dependencies
class BlLight(BlDatablock): class BlLight(ReplicatedDatablock):
bl_id = "lights" bl_id = "lights"
bl_class = bpy.types.Light bl_class = bpy.types.Light
bl_check_common = False bl_check_common = False
bl_icon = 'LIGHT_DATA' bl_icon = 'LIGHT_DATA'
bl_reload_parent = False bl_reload_parent = False
def _construct(self, data): @staticmethod
return bpy.data.lights.new(data["name"], data["type"]) def construct(data: dict) -> object:
instance = bpy.data.lights.new(data["name"], data["type"])
instance.uuid = data.get("uuid")
return instance
def _load_implementation(self, data, target): @staticmethod
def load(data: dict, datablock: object):
loader = Loader() loader = Loader()
loader.load(target, data) loader.load(datablock, data)
load_animation_data(data.get('animation_data'), datablock)
def _dump_implementation(self, data, instance=None): @staticmethod
assert(instance) def dump(datablock: object) -> dict:
dumper = Dumper() dumper = Dumper()
dumper.depth = 3 dumper.depth = 3
dumper.include_filter = [ dumper.include_filter = [
@ -67,9 +74,23 @@ class BlLight(BlDatablock):
'spot_size', 'spot_size',
'spot_blend' 'spot_blend'
] ]
data = dumper.dump(instance) data = dumper.dump(datablock)
data['animation_data'] = dump_animation_data(datablock)
return data return data
@staticmethod
def resolve(data: dict) -> object:
uuid = data.get('uuid')
return resolve_datablock_from_uuid(uuid, bpy.data.lights)
@staticmethod
def resolve_deps(datablock: object) -> [object]:
deps = []
deps.extend(resolve_animation_dependencies(datablock))
return deps
_type = [bpy.types.SpotLight, bpy.types.PointLight, bpy.types.AreaLight, bpy.types.SunLight]
_class = BlLight

View File

@ -21,17 +21,18 @@ import mathutils
import logging import logging
from .dump_anything import Loader, Dumper from .dump_anything import Loader, Dumper
from .bl_datablock import BlDatablock from replication.protocol import ReplicatedDatablock
from .bl_datablock import resolve_datablock_from_uuid
class BlLightprobe(ReplicatedDatablock):
class BlLightprobe(BlDatablock):
bl_id = "lightprobes" bl_id = "lightprobes"
bl_class = bpy.types.LightProbe bl_class = bpy.types.LightProbe
bl_check_common = False bl_check_common = False
bl_icon = 'LIGHTPROBE_GRID' bl_icon = 'LIGHTPROBE_GRID'
bl_reload_parent = False bl_reload_parent = False
def _construct(self, data): @staticmethod
def construct(data: dict) -> object:
type = 'CUBE' if data['type'] == 'CUBEMAP' else data['type'] type = 'CUBE' if data['type'] == 'CUBEMAP' else data['type']
# See https://developer.blender.org/D6396 # See https://developer.blender.org/D6396
if bpy.app.version[1] >= 83: if bpy.app.version[1] >= 83:
@ -39,12 +40,13 @@ class BlLightprobe(BlDatablock):
else: else:
logging.warning("Lightprobe replication only supported since 2.83. See https://developer.blender.org/D6396") logging.warning("Lightprobe replication only supported since 2.83. See https://developer.blender.org/D6396")
def _load_implementation(self, data, target): @staticmethod
def load(data: dict, datablock: object):
loader = Loader() loader = Loader()
loader.load(target, data) loader.load(datablock, data)
def _dump_implementation(self, data, instance=None): @staticmethod
assert(instance) def dump(datablock: object) -> dict:
if bpy.app.version[1] < 83: if bpy.app.version[1] < 83:
logging.warning("Lightprobe replication only supported since 2.83. See https://developer.blender.org/D6396") logging.warning("Lightprobe replication only supported since 2.83. See https://developer.blender.org/D6396")
@ -71,7 +73,16 @@ class BlLightprobe(BlDatablock):
'visibility_blur' 'visibility_blur'
] ]
return dumper.dump(instance) return dumper.dump(datablock)
@staticmethod
def resolve(data: dict) -> object:
uuid = data.get('uuid')
return resolve_datablock_from_uuid(uuid, bpy.data.lightprobes)
@staticmethod
def resolve_deps(datablock: object) -> [object]:
return []
_type = bpy.types.LightProbe
_class = BlLightprobe

View File

@ -24,7 +24,10 @@ import re
from uuid import uuid4 from uuid import uuid4
from .dump_anything import Loader, Dumper from .dump_anything import Loader, Dumper
from .bl_datablock import BlDatablock, get_datablock_from_uuid from replication.protocol import ReplicatedDatablock
from .bl_datablock import get_datablock_from_uuid, resolve_datablock_from_uuid
from .bl_action import dump_animation_data, load_animation_data, resolve_animation_dependencies
NODE_SOCKET_INDEX = re.compile('\[(\d*)\]') NODE_SOCKET_INDEX = re.compile('\[(\d*)\]')
IGNORED_SOCKETS = ['GEOMETRY', 'SHADER', 'CUSTOM'] IGNORED_SOCKETS = ['GEOMETRY', 'SHADER', 'CUSTOM']
@ -45,7 +48,11 @@ def load_node(node_data: dict, node_tree: bpy.types.ShaderNodeTree):
node_tree_uuid = node_data.get('node_tree_uuid', None) node_tree_uuid = node_data.get('node_tree_uuid', None)
if image_uuid and not target_node.image: if image_uuid and not target_node.image:
target_node.image = get_datablock_from_uuid(image_uuid, None) image = resolve_datablock_from_uuid(image_uuid, bpy.data.images)
if image is None:
logging.error(f"Fail to find material image from uuid {image_uuid}")
else:
target_node.image = image
if node_tree_uuid: if node_tree_uuid:
target_node.node_tree = get_datablock_from_uuid(node_tree_uuid, None) target_node.node_tree = get_datablock_from_uuid(node_tree_uuid, None)
@ -389,36 +396,40 @@ def load_materials_slots(src_materials: list, dst_materials: bpy.types.bpy_prop_
dst_materials.append(mat_ref) dst_materials.append(mat_ref)
class BlMaterial(BlDatablock): class BlMaterial(ReplicatedDatablock):
bl_id = "materials" bl_id = "materials"
bl_class = bpy.types.Material bl_class = bpy.types.Material
bl_check_common = False bl_check_common = False
bl_icon = 'MATERIAL_DATA' bl_icon = 'MATERIAL_DATA'
bl_reload_parent = False bl_reload_parent = False
def _construct(self, data): @staticmethod
def construct(data: dict) -> object:
return bpy.data.materials.new(data["name"]) return bpy.data.materials.new(data["name"])
def _load_implementation(self, data, target): @staticmethod
def load(data: dict, datablock: object):
load_animation_data(data.get('animation_data'), datablock)
loader = Loader() loader = Loader()
is_grease_pencil = data.get('is_grease_pencil') is_grease_pencil = data.get('is_grease_pencil')
use_nodes = data.get('use_nodes') use_nodes = data.get('use_nodes')
loader.load(target, data) loader.load(datablock, data)
if is_grease_pencil: if is_grease_pencil:
if not target.is_grease_pencil: if not datablock.is_grease_pencil:
bpy.data.materials.create_gpencil_data(target) bpy.data.materials.create_gpencil_data(datablock)
loader.load(target.grease_pencil, data['grease_pencil']) loader.load(datablock.grease_pencil, data['grease_pencil'])
elif use_nodes: elif use_nodes:
if target.node_tree is None: if datablock.node_tree is None:
target.use_nodes = True datablock.use_nodes = True
load_node_tree(data['node_tree'], target.node_tree) load_node_tree(data['node_tree'], datablock.node_tree)
def _dump_implementation(self, data, instance=None): @staticmethod
assert(instance) def dump(datablock: object) -> dict:
mat_dumper = Dumper() mat_dumper = Dumper()
mat_dumper.depth = 2 mat_dumper.depth = 2
mat_dumper.include_filter = [ mat_dumper.include_filter = [
@ -444,9 +455,9 @@ class BlMaterial(BlDatablock):
'line_priority', 'line_priority',
'is_grease_pencil' 'is_grease_pencil'
] ]
data = mat_dumper.dump(instance) data = mat_dumper.dump(datablock)
if instance.is_grease_pencil: if datablock.is_grease_pencil:
gp_mat_dumper = Dumper() gp_mat_dumper = Dumper()
gp_mat_dumper.depth = 3 gp_mat_dumper.depth = 3
@ -480,19 +491,28 @@ class BlMaterial(BlDatablock):
'use_overlap_strokes', 'use_overlap_strokes',
'use_fill_holdout', 'use_fill_holdout',
] ]
data['grease_pencil'] = gp_mat_dumper.dump(instance.grease_pencil) data['grease_pencil'] = gp_mat_dumper.dump(datablock.grease_pencil)
elif instance.use_nodes: elif datablock.use_nodes:
data['node_tree'] = dump_node_tree(instance.node_tree) data['node_tree'] = dump_node_tree(datablock.node_tree)
data['animation_data'] = dump_animation_data(datablock)
return data return data
def _resolve_deps_implementation(self): @staticmethod
# TODO: resolve node group deps def resolve(data: dict) -> object:
uuid = data.get('uuid')
return resolve_datablock_from_uuid(uuid, bpy.data.materials)
@staticmethod
def resolve_deps(datablock: object) -> [object]:
deps = [] deps = []
if self.instance.use_nodes: if datablock.use_nodes:
deps.extend(get_node_tree_dependencies(self.instance.node_tree)) deps.extend(get_node_tree_dependencies(datablock.node_tree))
if self.is_library:
deps.append(self.instance.library) deps.extend(resolve_animation_dependencies(datablock))
return deps return deps
_type = bpy.types.Material
_class = BlMaterial

View File

@ -25,8 +25,13 @@ import numpy as np
from .dump_anything import Dumper, Loader, np_load_collection_primitives, np_dump_collection_primitive, np_load_collection, np_dump_collection from .dump_anything import Dumper, Loader, np_load_collection_primitives, np_dump_collection_primitive, np_load_collection, np_dump_collection
from replication.constants import DIFF_BINARY from replication.constants import DIFF_BINARY
from replication.exception import ContextError from replication.exception import ContextError
from .bl_datablock import BlDatablock, get_datablock_from_uuid from replication.protocol import ReplicatedDatablock
from .bl_datablock import get_datablock_from_uuid
from .bl_material import dump_materials_slots, load_materials_slots from .bl_material import dump_materials_slots, load_materials_slots
from ..utils import get_preferences
from .bl_datablock import resolve_datablock_from_uuid
from .bl_action import dump_animation_data, load_animation_data, resolve_animation_dependencies
VERTICE = ['co'] VERTICE = ['co']
@ -49,76 +54,77 @@ POLYGON = [
'material_index', 'material_index',
] ]
class BlMesh(BlDatablock): class BlMesh(ReplicatedDatablock):
bl_id = "meshes" bl_id = "meshes"
bl_class = bpy.types.Mesh bl_class = bpy.types.Mesh
bl_check_common = False bl_check_common = False
bl_icon = 'MESH_DATA' bl_icon = 'MESH_DATA'
bl_reload_parent = True bl_reload_parent = True
def _construct(self, data): @staticmethod
instance = bpy.data.meshes.new(data["name"]) def construct(data: dict) -> object:
instance.uuid = self.uuid return bpy.data.meshes.new(data.get("name"))
return instance
def _load_implementation(self, data, target): @staticmethod
if not target or target.is_editmode: def load(data: dict, datablock: object):
if not datablock or datablock.is_editmode:
raise ContextError raise ContextError
else: else:
load_animation_data(data.get('animation_data'), datablock)
loader = Loader() loader = Loader()
loader.load(target, data) loader.load(datablock, data)
# MATERIAL SLOTS # MATERIAL SLOTS
src_materials = data.get('materials', None) src_materials = data.get('materials', None)
if src_materials: if src_materials:
load_materials_slots(src_materials, target.materials) load_materials_slots(src_materials, datablock.materials)
# CLEAR GEOMETRY # CLEAR GEOMETRY
if target.vertices: if datablock.vertices:
target.clear_geometry() datablock.clear_geometry()
target.vertices.add(data["vertex_count"]) datablock.vertices.add(data["vertex_count"])
target.edges.add(data["egdes_count"]) datablock.edges.add(data["egdes_count"])
target.loops.add(data["loop_count"]) datablock.loops.add(data["loop_count"])
target.polygons.add(data["poly_count"]) datablock.polygons.add(data["poly_count"])
# LOADING # LOADING
np_load_collection(data['vertices'], target.vertices, VERTICE) np_load_collection(data['vertices'], datablock.vertices, VERTICE)
np_load_collection(data['edges'], target.edges, EDGE) np_load_collection(data['edges'], datablock.edges, EDGE)
np_load_collection(data['loops'], target.loops, LOOP) np_load_collection(data['loops'], datablock.loops, LOOP)
np_load_collection(data["polygons"],target.polygons, POLYGON) np_load_collection(data["polygons"],datablock.polygons, POLYGON)
# UV Layers # UV Layers
if 'uv_layers' in data.keys(): if 'uv_layers' in data.keys():
for layer in data['uv_layers']: for layer in data['uv_layers']:
if layer not in target.uv_layers: if layer not in datablock.uv_layers:
target.uv_layers.new(name=layer) datablock.uv_layers.new(name=layer)
np_load_collection_primitives( np_load_collection_primitives(
target.uv_layers[layer].data, datablock.uv_layers[layer].data,
'uv', 'uv',
data["uv_layers"][layer]['data']) data["uv_layers"][layer]['data'])
# Vertex color # Vertex color
if 'vertex_colors' in data.keys(): if 'vertex_colors' in data.keys():
for color_layer in data['vertex_colors']: for color_layer in data['vertex_colors']:
if color_layer not in target.vertex_colors: if color_layer not in datablock.vertex_colors:
target.vertex_colors.new(name=color_layer) datablock.vertex_colors.new(name=color_layer)
np_load_collection_primitives( np_load_collection_primitives(
target.vertex_colors[color_layer].data, datablock.vertex_colors[color_layer].data,
'color', 'color',
data["vertex_colors"][color_layer]['data']) data["vertex_colors"][color_layer]['data'])
target.validate() datablock.validate()
target.update() datablock.update()
def _dump_implementation(self, data, instance=None): @staticmethod
assert(instance) def dump(datablock: object) -> dict:
if (datablock.is_editmode or bpy.context.mode == "SCULPT") and not get_preferences().sync_flags.sync_during_editmode:
if (instance.is_editmode or bpy.context.mode == "SCULPT") and not self.preferences.sync_flags.sync_during_editmode:
raise ContextError("Mesh is in edit mode") raise ContextError("Mesh is in edit mode")
mesh = instance mesh = datablock
dumper = Dumper() dumper = Dumper()
dumper.depth = 1 dumper.depth = 1
@ -132,6 +138,8 @@ class BlMesh(BlDatablock):
data = dumper.dump(mesh) data = dumper.dump(mesh)
data['animation_data'] = dump_animation_data(datablock)
# VERTICES # VERTICES
data["vertex_count"] = len(mesh.vertices) data["vertex_count"] = len(mesh.vertices)
data["vertices"] = np_dump_collection(mesh.vertices, VERTICE) data["vertices"] = np_dump_collection(mesh.vertices, VERTICE)
@ -163,21 +171,30 @@ class BlMesh(BlDatablock):
data['vertex_colors'][color_map.name]['data'] = np_dump_collection_primitive(color_map.data, 'color') data['vertex_colors'][color_map.name]['data'] = np_dump_collection_primitive(color_map.data, 'color')
# Materials # Materials
data['materials'] = dump_materials_slots(instance.materials) data['materials'] = dump_materials_slots(datablock.materials)
return data return data
def _resolve_deps_implementation(self): @staticmethod
def resolve_deps(datablock: object) -> [object]:
deps = [] deps = []
for material in self.instance.materials: for material in datablock.materials:
if material: if material:
deps.append(material) deps.append(material)
deps.extend(resolve_animation_dependencies(datablock))
return deps return deps
def diff(self): @staticmethod
if 'EDIT' in bpy.context.mode \ def resolve(data: dict) -> object:
and not self.preferences.sync_flags.sync_during_editmode: uuid = data.get('uuid')
return False return resolve_datablock_from_uuid(uuid, bpy.data.meshes)
else:
return super().diff() @staticmethod
def needs_update(datablock: object, data: dict) -> bool:
return ('EDIT' not in bpy.context.mode and bpy.context.mode != 'SCULPT') \
or get_preferences().sync_flags.sync_during_editmode
_type = bpy.types.Mesh
_class = BlMesh

View File

@ -23,7 +23,9 @@ from .dump_anything import (
Dumper, Loader, np_dump_collection_primitive, np_load_collection_primitives, Dumper, Loader, np_dump_collection_primitive, np_load_collection_primitives,
np_dump_collection, np_load_collection) np_dump_collection, np_load_collection)
from .bl_datablock import BlDatablock from replication.protocol import ReplicatedDatablock
from .bl_datablock import resolve_datablock_from_uuid
from .bl_action import dump_animation_data, load_animation_data, resolve_animation_dependencies
ELEMENT = [ ELEMENT = [
@ -62,29 +64,33 @@ def load_metaball_elements(elements_data, elements):
np_load_collection(elements_data, elements, ELEMENT) np_load_collection(elements_data, elements, ELEMENT)
class BlMetaball(BlDatablock): class BlMetaball(ReplicatedDatablock):
bl_id = "metaballs" bl_id = "metaballs"
bl_class = bpy.types.MetaBall bl_class = bpy.types.MetaBall
bl_check_common = False bl_check_common = False
bl_icon = 'META_BALL' bl_icon = 'META_BALL'
bl_reload_parent = False bl_reload_parent = False
def _construct(self, data): @staticmethod
def construct(data: dict) -> object:
return bpy.data.metaballs.new(data["name"]) return bpy.data.metaballs.new(data["name"])
def _load_implementation(self, data, target): @staticmethod
loader = Loader() def load(data: dict, datablock: object):
loader.load(target, data) load_animation_data(data.get('animation_data'), datablock)
target.elements.clear() loader = Loader()
loader.load(datablock, data)
datablock.elements.clear()
for mtype in data["elements"]['type']: for mtype in data["elements"]['type']:
new_element = target.elements.new() new_element = datablock.elements.new()
load_metaball_elements(data['elements'], target.elements) load_metaball_elements(data['elements'], datablock.elements)
def _dump_implementation(self, data, instance=None): @staticmethod
assert(instance) def dump(datablock: object) -> dict:
dumper = Dumper() dumper = Dumper()
dumper.depth = 1 dumper.depth = 1
dumper.include_filter = [ dumper.include_filter = [
@ -98,7 +104,24 @@ class BlMetaball(BlDatablock):
'texspace_size' 'texspace_size'
] ]
data = dumper.dump(instance) data = dumper.dump(datablock)
data['elements'] = dump_metaball_elements(instance.elements) data['animation_data'] = dump_animation_data(datablock)
data['elements'] = dump_metaball_elements(datablock.elements)
return data return data
@staticmethod
def resolve(data: dict) -> object:
uuid = data.get('uuid')
return resolve_datablock_from_uuid(uuid, bpy.data.metaballs)
@staticmethod
def resolve_deps(datablock: object) -> [object]:
deps = []
deps.extend(resolve_animation_dependencies(datablock))
return deps
_type = bpy.types.MetaBall
_class = BlMetaball

View File

@ -20,26 +20,43 @@ import bpy
import mathutils import mathutils
from .dump_anything import Dumper, Loader, np_dump_collection, np_load_collection from .dump_anything import Dumper, Loader, np_dump_collection, np_load_collection
from .bl_datablock import BlDatablock from replication.protocol import ReplicatedDatablock
from .bl_material import (dump_node_tree, from .bl_material import (dump_node_tree,
load_node_tree, load_node_tree,
get_node_tree_dependencies) get_node_tree_dependencies)
from .bl_datablock import resolve_datablock_from_uuid
from .bl_action import dump_animation_data, load_animation_data, resolve_animation_dependencies
class BlNodeGroup(BlDatablock): class BlNodeGroup(ReplicatedDatablock):
bl_id = "node_groups" bl_id = "node_groups"
bl_class = bpy.types.NodeTree bl_class = bpy.types.NodeTree
bl_check_common = False bl_check_common = False
bl_icon = 'NODETREE' bl_icon = 'NODETREE'
bl_reload_parent = False bl_reload_parent = False
def _construct(self, data): @staticmethod
def construct(data: dict) -> object:
return bpy.data.node_groups.new(data["name"], data["type"]) return bpy.data.node_groups.new(data["name"], data["type"])
def _load_implementation(self, data, target): @staticmethod
load_node_tree(data, target) def load(data: dict, datablock: object):
load_node_tree(data, datablock)
def _dump_implementation(self, data, instance=None): @staticmethod
return dump_node_tree(instance) def dump(datablock: object) -> dict:
return dump_node_tree(datablock)
def _resolve_deps_implementation(self): @staticmethod
return get_node_tree_dependencies(self.instance) def resolve(data: dict) -> object:
uuid = data.get('uuid')
return resolve_datablock_from_uuid(uuid, bpy.data.node_groups)
@staticmethod
def resolve_deps(datablock: object) -> [object]:
deps = []
deps.extend(get_node_tree_dependencies(datablock))
deps.extend(resolve_animation_dependencies(datablock))
return deps
_type = [bpy.types.ShaderNodeTree, bpy.types.GeometryNodeTree]
_class = BlNodeGroup

View File

@ -22,8 +22,10 @@ import bpy
import mathutils import mathutils
from replication.exception import ContextError from replication.exception import ContextError
from .bl_datablock import BlDatablock, get_datablock_from_uuid from replication.protocol import ReplicatedDatablock
from .bl_datablock import get_datablock_from_uuid, resolve_datablock_from_uuid
from .bl_material import IGNORED_SOCKETS from .bl_material import IGNORED_SOCKETS
from ..utils import get_preferences
from .bl_action import dump_animation_data, load_animation_data, resolve_animation_dependencies from .bl_action import dump_animation_data, load_animation_data, resolve_animation_dependencies
from .dump_anything import ( from .dump_anything import (
Dumper, Dumper,
@ -44,6 +46,8 @@ SHAPEKEY_BLOCK_ATTR = [
'slider_min', 'slider_min',
'slider_max', 'slider_max',
] ]
if bpy.app.version[1] >= 93: if bpy.app.version[1] >= 93:
SUPPORTED_GEOMETRY_NODE_PARAMETERS = (int, str, float) SUPPORTED_GEOMETRY_NODE_PARAMETERS = (int, str, float)
else: else:
@ -51,6 +55,7 @@ else:
logging.warning("Geometry node Float parameter not supported in \ logging.warning("Geometry node Float parameter not supported in \
blender 2.92.") blender 2.92.")
def get_node_group_inputs(node_group): def get_node_group_inputs(node_group):
inputs = [] inputs = []
for inpt in node_group.inputs: for inpt in node_group.inputs:
@ -89,6 +94,7 @@ def dump_physics(target: bpy.types.Object)->dict:
return physics_data return physics_data
def load_physics(dumped_settings: dict, target: bpy.types.Object): def load_physics(dumped_settings: dict, target: bpy.types.Object):
""" Load all physics settings from a given object excluding modifier """ Load all physics settings from a given object excluding modifier
related physics settings (such as softbody, cloth, dynapaint and fluid) related physics settings (such as softbody, cloth, dynapaint and fluid)
@ -114,7 +120,8 @@ def load_physics(dumped_settings: dict, target: bpy.types.Object):
loader.load(target.rigid_body_constraint, dumped_settings['rigid_body_constraint']) loader.load(target.rigid_body_constraint, dumped_settings['rigid_body_constraint'])
elif target.rigid_body_constraint: elif target.rigid_body_constraint:
bpy.ops.rigidbody.constraint_remove({"object": target}) bpy.ops.rigidbody.constraint_remove({"object": target})
def dump_modifier_geometry_node_inputs(modifier: bpy.types.Modifier) -> list: def dump_modifier_geometry_node_inputs(modifier: bpy.types.Modifier) -> list:
""" Dump geometry node modifier input properties """ Dump geometry node modifier input properties
@ -295,6 +302,7 @@ def load_vertex_groups(dumped_vertex_groups: dict, target_object: bpy.types.Obje
for index, weight in vg['vertices']: for index, weight in vg['vertices']:
vertex_group.add([index], weight, 'REPLACE') vertex_group.add([index], weight, 'REPLACE')
def dump_shape_keys(target_key: bpy.types.Key)->dict: def dump_shape_keys(target_key: bpy.types.Key)->dict:
""" Dump the target shape_keys datablock to a dict using numpy """ Dump the target shape_keys datablock to a dict using numpy
@ -370,12 +378,12 @@ def dump_modifiers(modifiers: bpy.types.bpy_prop_collection)->dict:
:type modifiers: bpy.types.bpy_prop_collection :type modifiers: bpy.types.bpy_prop_collection
:return: dict :return: dict
""" """
dumped_modifiers = {} dumped_modifiers = []
dumper = Dumper() dumper = Dumper()
dumper.depth = 1 dumper.depth = 1
dumper.exclude_filter = ['is_active'] dumper.exclude_filter = ['is_active']
for index, modifier in enumerate(modifiers): for modifier in modifiers:
dumped_modifier = dumper.dump(modifier) dumped_modifier = dumper.dump(modifier)
# hack to dump geometry nodes inputs # hack to dump geometry nodes inputs
if modifier.type == 'NODES': if modifier.type == 'NODES':
@ -397,9 +405,78 @@ def dump_modifiers(modifiers: bpy.types.bpy_prop_collection)->dict:
elif modifier.type == 'UV_PROJECT': elif modifier.type == 'UV_PROJECT':
dumped_modifier['projectors'] =[p.object.name for p in modifier.projectors if p and p.object] dumped_modifier['projectors'] =[p.object.name for p in modifier.projectors if p and p.object]
dumped_modifiers[modifier.name] = dumped_modifier dumped_modifiers.append(dumped_modifier)
return dumped_modifiers return dumped_modifiers
def dump_constraints(constraints: bpy.types.bpy_prop_collection)->list:
"""Dump all constraints to a list
:param constraints: constraints
:type constraints: bpy.types.bpy_prop_collection
:return: dict
"""
dumper = Dumper()
dumper.depth = 2
dumper.include_filter = None
dumped_constraints = []
for constraint in constraints:
dumped_constraints.append(dumper.dump(constraint))
return dumped_constraints
def load_constraints(dumped_constraints: list, constraints: bpy.types.bpy_prop_collection):
""" Load dumped constraints
:param dumped_constraints: list of constraints to load
:type dumped_constraints: list
:param constraints: constraints
:type constraints: bpy.types.bpy_prop_collection
"""
loader = Loader()
constraints.clear()
for dumped_constraint in dumped_constraints:
constraint_type = dumped_constraint.get('type')
new_constraint = constraints.new(constraint_type)
loader.load(new_constraint, dumped_constraint)
def load_modifiers(dumped_modifiers: list, modifiers: bpy.types.bpy_prop_collection):
""" Dump all modifiers of a modifier collection into a dict
:param dumped_modifiers: list of modifiers to load
:type dumped_modifiers: list
:param modifiers: modifiers
:type modifiers: bpy.types.bpy_prop_collection
"""
loader = Loader()
modifiers.clear()
for dumped_modifier in dumped_modifiers:
name = dumped_modifier.get('name')
mtype = dumped_modifier.get('type')
loaded_modifier = modifiers.new(name, mtype)
loader.load(loaded_modifier, dumped_modifier)
if loaded_modifier.type == 'NODES':
load_modifier_geometry_node_inputs(dumped_modifier, loaded_modifier)
elif loaded_modifier.type == 'PARTICLE_SYSTEM':
default = loaded_modifier.particle_system.settings
dumped_particles = dumped_modifier['particle_system']
loader.load(loaded_modifier.particle_system, dumped_particles)
settings = get_datablock_from_uuid(dumped_particles['settings_uuid'], None)
if settings:
loaded_modifier.particle_system.settings = settings
# Hack to remove the default generated particle settings
if not default.uuid:
bpy.data.particles.remove(default)
elif loaded_modifier.type in ['SOFT_BODY', 'CLOTH']:
loader.load(loaded_modifier.settings, dumped_modifier['settings'])
elif loaded_modifier.type == 'UV_PROJECT':
for projector_index, projector_object in enumerate(dumped_modifier['projectors']):
target_object = bpy.data.objects.get(projector_object)
if target_object:
loaded_modifier.projectors[projector_index].object = target_object
else:
logging.error("Could't load projector target object {projector_object}")
def load_modifiers_custom_data(dumped_modifiers: dict, modifiers: bpy.types.bpy_prop_collection): def load_modifiers_custom_data(dumped_modifiers: dict, modifiers: bpy.types.bpy_prop_collection):
""" Load modifiers custom data not managed by the dump_anything loader """ Load modifiers custom data not managed by the dump_anything loader
@ -413,48 +490,19 @@ def load_modifiers_custom_data(dumped_modifiers: dict, modifiers: bpy.types.bpy_
for modifier in modifiers: for modifier in modifiers:
dumped_modifier = dumped_modifiers.get(modifier.name) dumped_modifier = dumped_modifiers.get(modifier.name)
if modifier.type == 'NODES':
load_modifier_geometry_node_inputs(dumped_modifier, modifier)
elif modifier.type == 'PARTICLE_SYSTEM':
default = modifier.particle_system.settings
dumped_particles = dumped_modifier['particle_system']
loader.load(modifier.particle_system, dumped_particles)
settings = get_datablock_from_uuid(dumped_particles['settings_uuid'], None)
if settings:
modifier.particle_system.settings = settings
# Hack to remove the default generated particle settings
if not default.uuid:
bpy.data.particles.remove(default)
elif modifier.type in ['SOFT_BODY', 'CLOTH']:
loader.load(modifier.settings, dumped_modifier['settings'])
elif modifier.type == 'UV_PROJECT':
for projector_index, projector_object in enumerate(dumped_modifier['projectors']):
target_object = bpy.data.objects.get(projector_object)
if target_object:
modifier.projectors[projector_index].object = target_object
else:
logging.error("Could't load projector target object {projector_object}")
class BlObject(BlDatablock): class BlObject(ReplicatedDatablock):
bl_id = "objects" bl_id = "objects"
bl_class = bpy.types.Object bl_class = bpy.types.Object
bl_check_common = False bl_check_common = False
bl_icon = 'OBJECT_DATA' bl_icon = 'OBJECT_DATA'
bl_reload_parent = False bl_reload_parent = False
def _construct(self, data): @staticmethod
def construct(data: dict) -> object:
instance = None instance = None
if self.is_library:
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
# TODO: refactoring # TODO: refactoring
object_name = data.get("name") object_name = data.get("name")
data_uuid = data.get("data_uuid") data_uuid = data.get("data_uuid")
@ -467,70 +515,68 @@ class BlObject(BlDatablock):
ignore=['images']) # TODO: use resolve_from_id ignore=['images']) # TODO: use resolve_from_id
if data_type != 'EMPTY' and object_data is None: if data_type != 'EMPTY' and object_data is None:
raise Exception(f"Fail to load object {data['name']}({self.uuid})") raise Exception(f"Fail to load object {data['name']})")
instance = bpy.data.objects.new(object_name, object_data) return bpy.data.objects.new(object_name, object_data)
instance.uuid = self.uuid
return instance @staticmethod
def load(data: dict, datablock: object):
def _load_implementation(self, data, target):
loader = Loader() loader = Loader()
load_animation_data(data.get('animation_data'), datablock)
data_uuid = data.get("data_uuid") data_uuid = data.get("data_uuid")
data_id = data.get("data") data_id = data.get("data")
if target.data and (target.data.name != data_id): if datablock.data and (datablock.data.name != data_id):
target.data = get_datablock_from_uuid( datablock.data = get_datablock_from_uuid(
data_uuid, find_data_from_name(data_id), ignore=['images']) data_uuid, find_data_from_name(data_id), ignore=['images'])
# vertex groups # vertex groups
vertex_groups = data.get('vertex_groups', None) vertex_groups = data.get('vertex_groups', None)
if vertex_groups: if vertex_groups:
load_vertex_groups(vertex_groups, target) load_vertex_groups(vertex_groups, datablock)
object_data = target.data object_data = datablock.data
# SHAPE KEYS # SHAPE KEYS
shape_keys = data.get('shape_keys') shape_keys = data.get('shape_keys')
if shape_keys: if shape_keys:
load_shape_keys(shape_keys, target) load_shape_keys(shape_keys, datablock)
# Load transformation data # Load transformation data
loader.load(target, data) loader.load(datablock, data)
# Object display fields # Object display fields
if 'display' in data: if 'display' in data:
loader.load(target.display, data['display']) loader.load(datablock.display, data['display'])
# Parenting # Parenting
parent_id = data.get('parent_uid') parent_id = data.get('parent_uid')
if parent_id: if parent_id:
parent = get_datablock_from_uuid(parent_id[0], bpy.data.objects[parent_id[1]]) parent = get_datablock_from_uuid(parent_id[0], bpy.data.objects[parent_id[1]])
# Avoid reloading # Avoid reloading
if target.parent != parent and parent is not None: if datablock.parent != parent and parent is not None:
target.parent = parent datablock.parent = parent
elif target.parent: elif datablock.parent:
target.parent = None datablock.parent = None
# Pose # Pose
if 'pose' in data: if 'pose' in data:
if not target.pose: if not datablock.pose:
raise Exception('No pose data yet (Fixed in a near futur)') raise Exception('No pose data yet (Fixed in a near futur)')
# Bone groups # Bone groups
for bg_name in data['pose']['bone_groups']: for bg_name in data['pose']['bone_groups']:
bg_data = data['pose']['bone_groups'].get(bg_name) bg_data = data['pose']['bone_groups'].get(bg_name)
bg_target = target.pose.bone_groups.get(bg_name) bg_target = datablock.pose.bone_groups.get(bg_name)
if not bg_target: if not bg_target:
bg_target = target.pose.bone_groups.new(name=bg_name) bg_target = datablock.pose.bone_groups.new(name=bg_name)
loader.load(bg_target, bg_data) loader.load(bg_target, bg_data)
# target.pose.bone_groups.get # datablock.pose.bone_groups.get
# Bones # Bones
for bone in data['pose']['bones']: for bone in data['pose']['bones']:
target_bone = target.pose.bones.get(bone) target_bone = datablock.pose.bones.get(bone)
bone_data = data['pose']['bones'].get(bone) bone_data = data['pose']['bones'].get(bone)
if 'constraints' in bone_data.keys(): if 'constraints' in bone_data.keys():
@ -539,13 +585,13 @@ class BlObject(BlDatablock):
load_pose(target_bone, bone_data) load_pose(target_bone, bone_data)
if 'bone_index' in bone_data.keys(): if 'bone_index' in bone_data.keys():
target_bone.bone_group = target.pose.bone_group[bone_data['bone_group_index']] target_bone.bone_group = datablock.pose.bone_group[bone_data['bone_group_index']]
# TODO: find another way... # TODO: find another way...
if target.empty_display_type == "IMAGE": if datablock.empty_display_type == "IMAGE":
img_uuid = data.get('data_uuid') img_uuid = data.get('data_uuid')
if target.data is None and img_uuid: if datablock.data is None and img_uuid:
target.data = get_datablock_from_uuid(img_uuid, None) datablock.data = get_datablock_from_uuid(img_uuid, None)
if hasattr(object_data, 'skin_vertices') \ if hasattr(object_data, 'skin_vertices') \
and object_data.skin_vertices\ and object_data.skin_vertices\
@ -556,30 +602,33 @@ class BlObject(BlDatablock):
skin_data.data, skin_data.data,
SKIN_DATA) SKIN_DATA)
if hasattr(target, 'cycles_visibility') \ if hasattr(datablock, 'cycles_visibility') \
and 'cycles_visibility' in data: and 'cycles_visibility' in data:
loader.load(target.cycles_visibility, data['cycles_visibility']) loader.load(datablock.cycles_visibility, data['cycles_visibility'])
if hasattr(target, 'modifiers'): if hasattr(datablock, 'modifiers'):
load_modifiers_custom_data(data['modifiers'], target.modifiers) load_modifiers(data['modifiers'], datablock.modifiers)
constraints = data.get('constraints')
if constraints:
load_constraints(constraints, datablock.constraints)
# PHYSICS # PHYSICS
load_physics(data, target) load_physics(data, datablock)
transform = data.get('transforms', None) transform = data.get('transforms', None)
if transform: if transform:
target.matrix_parent_inverse = mathutils.Matrix( datablock.matrix_parent_inverse = mathutils.Matrix(
transform['matrix_parent_inverse']) transform['matrix_parent_inverse'])
target.matrix_basis = mathutils.Matrix(transform['matrix_basis']) datablock.matrix_basis = mathutils.Matrix(transform['matrix_basis'])
target.matrix_local = mathutils.Matrix(transform['matrix_local']) datablock.matrix_local = mathutils.Matrix(transform['matrix_local'])
def _dump_implementation(self, data, instance=None): @staticmethod
assert(instance) def dump(datablock: object) -> dict:
if _is_editmode(datablock):
if _is_editmode(instance): if get_preferences().sync_flags.sync_during_editmode:
if self.preferences.sync_flags.sync_during_editmode: datablock.update_from_editmode()
instance.update_from_editmode()
else: else:
raise ContextError("Object is in edit-mode.") raise ContextError("Object is in edit-mode.")
@ -615,35 +664,37 @@ class BlObject(BlDatablock):
'show_all_edges', 'show_all_edges',
'show_texture_space', 'show_texture_space',
'show_in_front', 'show_in_front',
'type' 'type',
'parent_type',
'parent_bone',
'track_axis',
'up_axis',
] ]
data = dumper.dump(instance) data = dumper.dump(datablock)
data['animation_data'] = dump_animation_data(datablock)
dumper.include_filter = [ dumper.include_filter = [
'matrix_parent_inverse', 'matrix_parent_inverse',
'matrix_local', 'matrix_local',
'matrix_basis'] 'matrix_basis']
data['transforms'] = dumper.dump(instance) data['transforms'] = dumper.dump(datablock)
dumper.include_filter = [ dumper.include_filter = [
'show_shadows', 'show_shadows',
] ]
data['display'] = dumper.dump(instance.display) data['display'] = dumper.dump(datablock.display)
data['data_uuid'] = getattr(instance.data, 'uuid', None) data['data_uuid'] = getattr(datablock.data, 'uuid', None)
if self.is_library:
return data
# PARENTING # PARENTING
if instance.parent: if datablock.parent:
data['parent_uid'] = (instance.parent.uuid, instance.parent.name) data['parent_uid'] = (datablock.parent.uuid, datablock.parent.name)
# MODIFIERS # MODIFIERS
modifiers = getattr(instance, 'modifiers', None) modifiers = getattr(datablock, 'modifiers', None)
if hasattr(instance, 'modifiers'): if hasattr(datablock, 'modifiers'):
data['modifiers'] = dump_modifiers(modifiers) data['modifiers'] = dump_modifiers(modifiers)
gp_modifiers = getattr(instance, 'grease_pencil_modifiers', None) gp_modifiers = getattr(datablock, 'grease_pencil_modifiers', None)
if gp_modifiers: if gp_modifiers:
dumper.include_filter = None dumper.include_filter = None
@ -666,16 +717,14 @@ class BlObject(BlDatablock):
# CONSTRAINTS # CONSTRAINTS
if hasattr(instance, 'constraints'): if hasattr(datablock, 'constraints'):
dumper.include_filter = None data["constraints"] = dump_constraints(datablock.constraints)
dumper.depth = 3
data["constraints"] = dumper.dump(instance.constraints)
# POSE # POSE
if hasattr(instance, 'pose') and instance.pose: if hasattr(datablock, 'pose') and datablock.pose:
# BONES # BONES
bones = {} bones = {}
for bone in instance.pose.bones: for bone in datablock.pose.bones:
bones[bone.name] = {} bones[bone.name] = {}
dumper.depth = 1 dumper.depth = 1
rotation = 'rotation_quaternion' if bone.rotation_mode == 'QUATERNION' else 'rotation_euler' rotation = 'rotation_quaternion' if bone.rotation_mode == 'QUATERNION' else 'rotation_euler'
@ -700,7 +749,7 @@ class BlObject(BlDatablock):
# GROUPS # GROUPS
bone_groups = {} bone_groups = {}
for group in instance.pose.bone_groups: for group in datablock.pose.bone_groups:
dumper.depth = 3 dumper.depth = 3
dumper.include_filter = [ dumper.include_filter = [
'name', 'name',
@ -710,11 +759,11 @@ class BlObject(BlDatablock):
data['pose']['bone_groups'] = bone_groups data['pose']['bone_groups'] = bone_groups
# VERTEx GROUP # VERTEx GROUP
if len(instance.vertex_groups) > 0: if len(datablock.vertex_groups) > 0:
data['vertex_groups'] = dump_vertex_groups(instance) data['vertex_groups'] = dump_vertex_groups(datablock)
# SHAPE KEYS # SHAPE KEYS
object_data = instance.data object_data = datablock.data
if hasattr(object_data, 'shape_keys') and object_data.shape_keys: if hasattr(object_data, 'shape_keys') and object_data.shape_keys:
data['shape_keys'] = dump_shape_keys(object_data.shape_keys) data['shape_keys'] = dump_shape_keys(object_data.shape_keys)
@ -727,7 +776,7 @@ class BlObject(BlDatablock):
data['skin_vertices'] = skin_vertices data['skin_vertices'] = skin_vertices
# CYCLE SETTINGS # CYCLE SETTINGS
if hasattr(instance, 'cycles_visibility'): if hasattr(datablock, 'cycles_visibility'):
dumper.include_filter = [ dumper.include_filter = [
'camera', 'camera',
'diffuse', 'diffuse',
@ -736,38 +785,48 @@ class BlObject(BlDatablock):
'scatter', 'scatter',
'shadow', 'shadow',
] ]
data['cycles_visibility'] = dumper.dump(instance.cycles_visibility) data['cycles_visibility'] = dumper.dump(datablock.cycles_visibility)
# PHYSICS # PHYSICS
data.update(dump_physics(instance)) data.update(dump_physics(datablock))
return data return data
def _resolve_deps_implementation(self): @staticmethod
def resolve_deps(datablock: object) -> [object]:
deps = [] deps = []
# Avoid Empty case # Avoid Empty case
if self.instance.data: if datablock.data:
deps.append(self.instance.data) deps.append(datablock.data)
# Particle systems # Particle systems
for particle_slot in self.instance.particle_systems: for particle_slot in datablock.particle_systems:
deps.append(particle_slot.settings) deps.append(particle_slot.settings)
if self.is_library: if datablock.parent:
deps.append(self.instance.library) deps.append(datablock.parent)
if self.instance.parent: if datablock.instance_type == 'COLLECTION':
deps.append(self.instance.parent)
if self.instance.instance_type == 'COLLECTION':
# TODO: uuid based # TODO: uuid based
deps.append(self.instance.instance_collection) deps.append(datablock.instance_collection)
if self.instance.modifiers: if datablock.modifiers:
deps.extend(find_textures_dependencies(self.instance.modifiers)) deps.extend(find_textures_dependencies(datablock.modifiers))
deps.extend(find_geometry_nodes_dependencies(self.instance.modifiers)) deps.extend(find_geometry_nodes_dependencies(datablock.modifiers))
if hasattr(datablock.data, 'shape_keys') and datablock.data.shape_keys:
deps.extend(resolve_animation_dependencies(datablock.data.shape_keys))
deps.extend(resolve_animation_dependencies(datablock))
if hasattr(self.instance.data, 'shape_keys') and self.instance.data.shape_keys:
deps.extend(resolve_animation_dependencies(self.instance.data.shape_keys))
return deps return deps
@staticmethod
def resolve(data: dict) -> object:
uuid = data.get('uuid')
return resolve_datablock_from_uuid(uuid, bpy.data.objects)
_type = bpy.types.Object
_class = BlObject

View File

@ -2,7 +2,10 @@ import bpy
import mathutils import mathutils
from . import dump_anything from . import dump_anything
from .bl_datablock import BlDatablock, get_datablock_from_uuid from replication.protocol import ReplicatedDatablock
from .bl_datablock import get_datablock_from_uuid
from .bl_datablock import resolve_datablock_from_uuid
from .bl_action import dump_animation_data, load_animation_data, resolve_animation_dependencies
def dump_textures_slots(texture_slots: bpy.types.bpy_prop_collection) -> list: def dump_textures_slots(texture_slots: bpy.types.bpy_prop_collection) -> list:
@ -37,54 +40,65 @@ IGNORED_ATTR = [
"users" "users"
] ]
class BlParticle(BlDatablock): class BlParticle(ReplicatedDatablock):
bl_id = "particles" bl_id = "particles"
bl_class = bpy.types.ParticleSettings bl_class = bpy.types.ParticleSettings
bl_icon = "PARTICLES" bl_icon = "PARTICLES"
bl_check_common = False bl_check_common = False
bl_reload_parent = False bl_reload_parent = False
def _construct(self, data): @staticmethod
instance = bpy.data.particles.new(data["name"]) def construct(data: dict) -> object:
instance.uuid = self.uuid return bpy.data.particles.new(data["name"])
return instance
def _load_implementation(self, data, target): @staticmethod
dump_anything.load(target, data) def load(data: dict, datablock: object):
load_animation_data(data.get('animation_data'), datablock)
dump_anything.load(datablock, data)
dump_anything.load(target.effector_weights, data["effector_weights"]) dump_anything.load(datablock.effector_weights, data["effector_weights"])
# Force field # Force field
force_field_1 = data.get("force_field_1", None) force_field_1 = data.get("force_field_1", None)
if force_field_1: if force_field_1:
dump_anything.load(target.force_field_1, force_field_1) dump_anything.load(datablock.force_field_1, force_field_1)
force_field_2 = data.get("force_field_2", None) force_field_2 = data.get("force_field_2", None)
if force_field_2: if force_field_2:
dump_anything.load(target.force_field_2, force_field_2) dump_anything.load(datablock.force_field_2, force_field_2)
# Texture slots # Texture slots
load_texture_slots(data["texture_slots"], target.texture_slots) load_texture_slots(data["texture_slots"], datablock.texture_slots)
def _dump_implementation(self, data, instance=None):
assert instance
@staticmethod
def dump(datablock: object) -> dict:
dumper = dump_anything.Dumper() dumper = dump_anything.Dumper()
dumper.depth = 1 dumper.depth = 1
dumper.exclude_filter = IGNORED_ATTR dumper.exclude_filter = IGNORED_ATTR
data = dumper.dump(instance) data = dumper.dump(datablock)
# Particle effectors # Particle effectors
data["effector_weights"] = dumper.dump(instance.effector_weights) data["effector_weights"] = dumper.dump(datablock.effector_weights)
if instance.force_field_1: if datablock.force_field_1:
data["force_field_1"] = dumper.dump(instance.force_field_1) data["force_field_1"] = dumper.dump(datablock.force_field_1)
if instance.force_field_2: if datablock.force_field_2:
data["force_field_2"] = dumper.dump(instance.force_field_2) data["force_field_2"] = dumper.dump(datablock.force_field_2)
# Texture slots # Texture slots
data["texture_slots"] = dump_textures_slots(instance.texture_slots) data["texture_slots"] = dump_textures_slots(datablock.texture_slots)
data['animation_data'] = dump_animation_data(datablock)
return data return data
def _resolve_deps_implementation(self): @staticmethod
return [t.texture for t in self.instance.texture_slots if t and t.texture] def resolve(data: dict) -> object:
uuid = data.get('uuid')
return resolve_datablock_from_uuid(uuid, bpy.data.particles)
@staticmethod
def resolve_deps(datablock: object) -> [object]:
deps = [t.texture for t in datablock.texture_slots if t and t.texture]
deps.extend(resolve_animation_dependencies(datablock))
return deps
_type = bpy.types.ParticleSettings
_class = BlParticle

View File

@ -18,17 +18,21 @@
import logging import logging
from pathlib import Path from pathlib import Path
from uuid import uuid4
import bpy import bpy
import mathutils import mathutils
from deepdiff import DeepDiff from deepdiff import DeepDiff, Delta
from replication.constants import DIFF_JSON, MODIFIED from replication.constants import DIFF_JSON, MODIFIED
from replication.protocol import ReplicatedDatablock
from ..utils import flush_history from ..utils import flush_history, get_preferences
from .bl_action import (dump_animation_data, load_animation_data,
resolve_animation_dependencies)
from .bl_collection import (dump_collection_children, dump_collection_objects, from .bl_collection import (dump_collection_children, dump_collection_objects,
load_collection_childrens, load_collection_objects, load_collection_childrens, load_collection_objects,
resolve_collection_dependencies) resolve_collection_dependencies)
from .bl_datablock import BlDatablock from .bl_datablock import resolve_datablock_from_uuid
from .bl_file import get_filepath from .bl_file import get_filepath
from .dump_anything import Dumper, Loader from .dump_anything import Dumper, Loader
@ -286,12 +290,10 @@ def dump_sequence(sequence: bpy.types.Sequence) -> dict:
dumper.depth = 1 dumper.depth = 1
data = dumper.dump(sequence) data = dumper.dump(sequence)
# TODO: Support multiple images # TODO: Support multiple images
if sequence.type == 'IMAGE': if sequence.type == 'IMAGE':
data['filenames'] = [e.filename for e in sequence.elements] data['filenames'] = [e.filename for e in sequence.elements]
# Effect strip inputs # Effect strip inputs
input_count = getattr(sequence, 'input_count', None) input_count = getattr(sequence, 'input_count', None)
if input_count: if input_count:
@ -302,7 +304,8 @@ def dump_sequence(sequence: bpy.types.Sequence) -> dict:
return data return data
def load_sequence(sequence_data: dict, sequence_editor: bpy.types.SequenceEditor): def load_sequence(sequence_data: dict,
sequence_editor: bpy.types.SequenceEditor):
""" Load sequence from dumped data """ Load sequence from dumped data
:arg sequence_data: sequence to dump :arg sequence_data: sequence to dump
@ -321,54 +324,56 @@ def load_sequence(sequence_data: dict, sequence_editor: bpy.types.SequenceEditor
if strip_type == 'SCENE': if strip_type == 'SCENE':
strip_scene = bpy.data.scenes.get(sequence_data.get('scene')) strip_scene = bpy.data.scenes.get(sequence_data.get('scene'))
sequence = sequence_editor.sequences.new_scene(strip_name, sequence = sequence_editor.sequences.new_scene(strip_name,
strip_scene, strip_scene,
strip_channel, strip_channel,
strip_frame_start) strip_frame_start)
elif strip_type == 'MOVIE': elif strip_type == 'MOVIE':
filepath = get_filepath(Path(sequence_data['filepath']).name) filepath = get_filepath(Path(sequence_data['filepath']).name)
sequence = sequence_editor.sequences.new_movie(strip_name, sequence = sequence_editor.sequences.new_movie(strip_name,
filepath, filepath,
strip_channel, strip_channel,
strip_frame_start) strip_frame_start)
elif strip_type == 'SOUND': elif strip_type == 'SOUND':
filepath = bpy.data.sounds[sequence_data['sound']].filepath filepath = bpy.data.sounds[sequence_data['sound']].filepath
sequence = sequence_editor.sequences.new_sound(strip_name, sequence = sequence_editor.sequences.new_sound(strip_name,
filepath, filepath,
strip_channel, strip_channel,
strip_frame_start) strip_frame_start)
elif strip_type == 'IMAGE': elif strip_type == 'IMAGE':
images_name = sequence_data.get('filenames') images_name = sequence_data.get('filenames')
filepath = get_filepath(images_name[0]) filepath = get_filepath(images_name[0])
sequence = sequence_editor.sequences.new_image(strip_name, sequence = sequence_editor.sequences.new_image(strip_name,
filepath, filepath,
strip_channel, strip_channel,
strip_frame_start) strip_frame_start)
# load other images # load other images
if len(images_name)>1: if len(images_name) > 1:
for img_idx in range(1,len(images_name)): for img_idx in range(1, len(images_name)):
sequence.elements.append((images_name[img_idx])) sequence.elements.append((images_name[img_idx]))
else: else:
seq = {} seq = {}
for i in range(sequence_data['input_count']): for i in range(sequence_data['input_count']):
seq[f"seq{i+1}"] = sequence_editor.sequences_all.get(sequence_data.get(f"input_{i+1}", None)) seq[f"seq{i+1}"] = sequence_editor.sequences_all.get(
sequence_data.get(f"input_{i+1}", None))
sequence = sequence_editor.sequences.new_effect(name=strip_name, sequence = sequence_editor.sequences.new_effect(name=strip_name,
type=strip_type, type=strip_type,
channel=strip_channel, channel=strip_channel,
frame_start=strip_frame_start, frame_start=strip_frame_start,
frame_end=sequence_data['frame_final_end'], frame_end=sequence_data['frame_final_end'],
**seq) **seq)
loader = Loader() loader = Loader()
# TODO: Support filepath updates
loader.exclure_filter = ['filepath', 'sound', 'filenames','fps'] loader.exclure_filter = ['filepath', 'sound', 'filenames', 'fps']
loader.load(sequence, sequence_data) loader.load(sequence, sequence_data)
sequence.select = False sequence.select = False
class BlScene(BlDatablock): class BlScene(ReplicatedDatablock):
is_root = True is_root = True
use_delta = True
bl_id = "scenes" bl_id = "scenes"
bl_class = bpy.types.Scene bl_class = bpy.types.Scene
@ -376,76 +381,78 @@ class BlScene(BlDatablock):
bl_icon = 'SCENE_DATA' bl_icon = 'SCENE_DATA'
bl_reload_parent = False bl_reload_parent = False
def _construct(self, data): @staticmethod
instance = bpy.data.scenes.new(data["name"]) def construct(data: dict) -> object:
instance.uuid = self.uuid return bpy.data.scenes.new(data["name"])
return instance @staticmethod
def load(data: dict, datablock: object):
load_animation_data(data.get('animation_data'), datablock)
def _load_implementation(self, data, target):
# Load other meshes metadata # Load other meshes metadata
loader = Loader() loader = Loader()
loader.load(target, data) loader.load(datablock, data)
# Load master collection # Load master collection
load_collection_objects( load_collection_objects(
data['collection']['objects'], target.collection) data['collection']['objects'], datablock.collection)
load_collection_childrens( load_collection_childrens(
data['collection']['children'], target.collection) data['collection']['children'], datablock.collection)
if 'world' in data.keys(): if 'world' in data.keys():
target.world = bpy.data.worlds[data['world']] datablock.world = bpy.data.worlds[data['world']]
# Annotation # Annotation
if 'grease_pencil' in data.keys(): if 'grease_pencil' in data.keys():
target.grease_pencil = bpy.data.grease_pencils[data['grease_pencil']] datablock.grease_pencil = bpy.data.grease_pencils[data['grease_pencil']]
if self.preferences.sync_flags.sync_render_settings: if get_preferences().sync_flags.sync_render_settings:
if 'eevee' in data.keys(): if 'eevee' in data.keys():
loader.load(target.eevee, data['eevee']) loader.load(datablock.eevee, data['eevee'])
if 'cycles' in data.keys(): if 'cycles' in data.keys():
loader.load(target.cycles, data['cycles']) loader.load(datablock.cycles, data['cycles'])
if 'render' in data.keys(): if 'render' in data.keys():
loader.load(target.render, data['render']) loader.load(datablock.render, data['render'])
if 'view_settings' in data.keys(): view_settings = data.get('view_settings')
loader.load(target.view_settings, data['view_settings']) if view_settings:
if target.view_settings.use_curve_mapping and \ loader.load(datablock.view_settings, view_settings)
'curve_mapping' in data['view_settings']: if datablock.view_settings.use_curve_mapping and \
'curve_mapping' in view_settings:
# TODO: change this ugly fix # TODO: change this ugly fix
target.view_settings.curve_mapping.white_level = data[ datablock.view_settings.curve_mapping.white_level = view_settings['curve_mapping']['white_level']
'view_settings']['curve_mapping']['white_level'] datablock.view_settings.curve_mapping.black_level = view_settings['curve_mapping']['black_level']
target.view_settings.curve_mapping.black_level = data[ datablock.view_settings.curve_mapping.update()
'view_settings']['curve_mapping']['black_level']
target.view_settings.curve_mapping.update()
# Sequencer # Sequencer
sequences = data.get('sequences') sequences = data.get('sequences')
if sequences: if sequences:
# Create sequencer data # Create sequencer data
target.sequence_editor_create() datablock.sequence_editor_create()
vse = target.sequence_editor vse = datablock.sequence_editor
# Clear removed sequences # Clear removed sequences
for seq in vse.sequences_all: for seq in vse.sequences_all:
if seq.name not in sequences: if seq.name not in sequences:
vse.sequences.remove(seq) vse.sequences.remove(seq)
# Load existing sequences # Load existing sequences
for seq_name, seq_data in sequences.items(): for seq_data in sequences.value():
load_sequence(seq_data, vse) load_sequence(seq_data, vse)
# If the sequence is no longer used, clear it # If the sequence is no longer used, clear it
elif target.sequence_editor and not sequences: elif datablock.sequence_editor and not sequences:
target.sequence_editor_clear() datablock.sequence_editor_clear()
# FIXME: Find a better way after the replication big refacotoring # FIXME: Find a better way after the replication big refacotoring
# Keep other user from deleting collection object by flushing their history # Keep other user from deleting collection object by flushing their history
flush_history() flush_history()
def _dump_implementation(self, data, instance=None): @staticmethod
assert(instance) def dump(datablock: object) -> dict:
data = {}
data['animation_data'] = dump_animation_data(datablock)
# Metadata # Metadata
scene_dumper = Dumper() scene_dumper = Dumper()
@ -459,40 +466,40 @@ class BlScene(BlDatablock):
'frame_end', 'frame_end',
'frame_step', 'frame_step',
] ]
if self.preferences.sync_flags.sync_active_camera: if get_preferences().sync_flags.sync_active_camera:
scene_dumper.include_filter.append('camera') scene_dumper.include_filter.append('camera')
data.update(scene_dumper.dump(instance)) data.update(scene_dumper.dump(datablock))
# Master collection # Master collection
data['collection'] = {} data['collection'] = {}
data['collection']['children'] = dump_collection_children( data['collection']['children'] = dump_collection_children(
instance.collection) datablock.collection)
data['collection']['objects'] = dump_collection_objects( data['collection']['objects'] = dump_collection_objects(
instance.collection) datablock.collection)
scene_dumper.depth = 1 scene_dumper.depth = 1
scene_dumper.include_filter = None scene_dumper.include_filter = None
# Render settings # Render settings
if self.preferences.sync_flags.sync_render_settings: if get_preferences().sync_flags.sync_render_settings:
scene_dumper.include_filter = RENDER_SETTINGS scene_dumper.include_filter = RENDER_SETTINGS
data['render'] = scene_dumper.dump(instance.render) data['render'] = scene_dumper.dump(datablock.render)
if instance.render.engine == 'BLENDER_EEVEE': if datablock.render.engine == 'BLENDER_EEVEE':
scene_dumper.include_filter = EVEE_SETTINGS scene_dumper.include_filter = EVEE_SETTINGS
data['eevee'] = scene_dumper.dump(instance.eevee) data['eevee'] = scene_dumper.dump(datablock.eevee)
elif instance.render.engine == 'CYCLES': elif datablock.render.engine == 'CYCLES':
scene_dumper.include_filter = CYCLES_SETTINGS scene_dumper.include_filter = CYCLES_SETTINGS
data['cycles'] = scene_dumper.dump(instance.cycles) data['cycles'] = scene_dumper.dump(datablock.cycles)
scene_dumper.include_filter = VIEW_SETTINGS scene_dumper.include_filter = VIEW_SETTINGS
data['view_settings'] = scene_dumper.dump(instance.view_settings) data['view_settings'] = scene_dumper.dump(datablock.view_settings)
if instance.view_settings.use_curve_mapping: if datablock.view_settings.use_curve_mapping:
data['view_settings']['curve_mapping'] = scene_dumper.dump( data['view_settings']['curve_mapping'] = scene_dumper.dump(
instance.view_settings.curve_mapping) datablock.view_settings.curve_mapping)
scene_dumper.depth = 5 scene_dumper.depth = 5
scene_dumper.include_filter = [ scene_dumper.include_filter = [
'curves', 'curves',
@ -500,35 +507,37 @@ class BlScene(BlDatablock):
'location', 'location',
] ]
data['view_settings']['curve_mapping']['curves'] = scene_dumper.dump( data['view_settings']['curve_mapping']['curves'] = scene_dumper.dump(
instance.view_settings.curve_mapping.curves) datablock.view_settings.curve_mapping.curves)
# Sequence # Sequence
vse = instance.sequence_editor vse = datablock.sequence_editor
if vse: if vse:
dumped_sequences = {} dumped_sequences = {}
for seq in vse.sequences_all: for seq in vse.sequences_all:
dumped_sequences[seq.name] = dump_sequence(seq) dumped_sequences[seq.name] = dump_sequence(seq)
data['sequences'] = dumped_sequences data['sequences'] = dumped_sequences
return data return data
def _resolve_deps_implementation(self): @staticmethod
def resolve_deps(datablock: object) -> [object]:
deps = [] deps = []
# Master Collection # Master Collection
deps.extend(resolve_collection_dependencies(self.instance.collection)) deps.extend(resolve_collection_dependencies(datablock.collection))
# world # world
if self.instance.world: if datablock.world:
deps.append(self.instance.world) deps.append(datablock.world)
# annotations # annotations
if self.instance.grease_pencil: if datablock.grease_pencil:
deps.append(self.instance.grease_pencil) deps.append(datablock.grease_pencil)
deps.extend(resolve_animation_dependencies(datablock))
# Sequences # Sequences
vse = self.instance.sequence_editor vse = datablock.sequence_editor
if vse: if vse:
for sequence in vse.sequences_all: for sequence in vse.sequences_all:
if sequence.type == 'MOVIE' and sequence.filepath: if sequence.type == 'MOVIE' and sequence.filepath:
@ -543,16 +552,45 @@ class BlScene(BlDatablock):
return deps return deps
def diff(self): @staticmethod
def resolve(data: dict) -> object:
uuid = data.get('uuid')
name = data.get('name')
datablock = resolve_datablock_from_uuid(uuid, bpy.data.scenes)
if datablock is None:
datablock = bpy.data.scenes.get(name)
return datablock
@staticmethod
def compute_delta(last_data: dict, current_data: dict) -> Delta:
exclude_path = [] exclude_path = []
if not self.preferences.sync_flags.sync_render_settings: if not get_preferences().sync_flags.sync_render_settings:
exclude_path.append("root['eevee']") exclude_path.append("root['eevee']")
exclude_path.append("root['cycles']") exclude_path.append("root['cycles']")
exclude_path.append("root['view_settings']") exclude_path.append("root['view_settings']")
exclude_path.append("root['render']") exclude_path.append("root['render']")
if not self.preferences.sync_flags.sync_active_camera: if not get_preferences().sync_flags.sync_active_camera:
exclude_path.append("root['camera']") exclude_path.append("root['camera']")
return DeepDiff(self.data, self._dump(instance=self.instance), exclude_paths=exclude_path) diff_params = {
'exclude_paths': exclude_path,
'ignore_order': True,
'report_repetition': True
}
delta_params = {
# 'mutate': True
}
return Delta(
DeepDiff(last_data,
current_data,
cache_size=5000,
**diff_params),
**delta_params)
_type = bpy.types.Scene
_class = BlScene

View File

@ -23,45 +23,59 @@ from pathlib import Path
import bpy import bpy
from .bl_file import get_filepath, ensure_unpacked from .bl_file import get_filepath, ensure_unpacked
from .bl_datablock import BlDatablock from replication.protocol import ReplicatedDatablock
from .dump_anything import Dumper, Loader from .dump_anything import Dumper, Loader
from .bl_datablock import resolve_datablock_from_uuid
class BlSound(BlDatablock): class BlSound(ReplicatedDatablock):
bl_id = "sounds" bl_id = "sounds"
bl_class = bpy.types.Sound bl_class = bpy.types.Sound
bl_check_common = False bl_check_common = False
bl_icon = 'SOUND' bl_icon = 'SOUND'
bl_reload_parent = False bl_reload_parent = False
def _construct(self, data): @staticmethod
def construct(data: dict) -> object:
filename = data.get('filename') filename = data.get('filename')
return bpy.data.sounds.load(get_filepath(filename)) return bpy.data.sounds.load(get_filepath(filename))
def _load(self, data, target): @staticmethod
def load(data: dict, datablock: object):
loader = Loader() loader = Loader()
loader.load(target, data) loader.load(datablock, data)
def diff(self): @staticmethod
return False def dump(datablock: object) -> dict:
filename = Path(datablock.filepath).name
def _dump(self, instance=None):
filename = Path(instance.filepath).name
if not filename: if not filename:
raise FileExistsError(instance.filepath) raise FileExistsError(datablock.filepath)
return { return {
'filename': filename, 'filename': filename,
'name': instance.name 'name': datablock.name
} }
def _resolve_deps_implementation(self): @staticmethod
def resolve_deps(datablock: object) -> [object]:
deps = [] deps = []
if self.instance.filepath and self.instance.filepath != '<builtin>': if datablock.filepath and datablock.filepath != '<builtin>':
ensure_unpacked(self.instance) ensure_unpacked(datablock)
deps.append(Path(bpy.path.abspath(self.instance.filepath))) deps.append(Path(bpy.path.abspath(datablock.filepath)))
return deps return deps
@staticmethod
def resolve(data: dict) -> object:
uuid = data.get('uuid')
return resolve_datablock_from_uuid(uuid, bpy.data.sounds)
@staticmethod
def needs_update(datablock: object, data:dict)-> bool:
return False
_type = bpy.types.Sound
_class = BlSound

View File

@ -20,26 +20,29 @@ import bpy
import mathutils import mathutils
from .dump_anything import Loader, Dumper from .dump_anything import Loader, Dumper
from .bl_datablock import BlDatablock from replication.protocol import ReplicatedDatablock
from .bl_datablock import resolve_datablock_from_uuid
from .bl_action import dump_animation_data, load_animation_data, resolve_animation_dependencies
class BlSpeaker(ReplicatedDatablock):
class BlSpeaker(BlDatablock):
bl_id = "speakers" bl_id = "speakers"
bl_class = bpy.types.Speaker bl_class = bpy.types.Speaker
bl_check_common = False bl_check_common = False
bl_icon = 'SPEAKER' bl_icon = 'SPEAKER'
bl_reload_parent = False bl_reload_parent = False
def _load_implementation(self, data, target): @staticmethod
def load(data: dict, datablock: object):
loader = Loader() loader = Loader()
loader.load(target, data) loader.load(datablock, data)
load_animation_data(data.get('animation_data'), datablock)
def _construct(self, data): @staticmethod
def construct(data: dict) -> object:
return bpy.data.speakers.new(data["name"]) return bpy.data.speakers.new(data["name"])
def _dump_implementation(self, data, instance=None): @staticmethod
assert(instance) def dump(datablock: object) -> dict:
dumper = Dumper() dumper = Dumper()
dumper.depth = 1 dumper.depth = 1
dumper.include_filter = [ dumper.include_filter = [
@ -58,17 +61,27 @@ class BlSpeaker(BlDatablock):
'cone_volume_outer' 'cone_volume_outer'
] ]
return dumper.dump(instance) data = dumper.dump(datablock)
data['animation_data'] = dump_animation_data(datablock)
return data
def _resolve_deps_implementation(self): @staticmethod
def resolve(data: dict) -> object:
uuid = data.get('uuid')
return resolve_datablock_from_uuid(uuid, bpy.data.speakers)
@staticmethod
def resolve_deps(datablock: object) -> [object]:
# TODO: resolve material # TODO: resolve material
deps = [] deps = []
sound = self.instance.sound sound = datablock.sound
if sound: if sound:
deps.append(sound) deps.append(sound)
deps.extend(resolve_animation_dependencies(datablock))
return deps return deps
_type = bpy.types.Speaker
_class = BlSpeaker

View File

@ -20,25 +20,30 @@ import bpy
import mathutils import mathutils
from .dump_anything import Loader, Dumper from .dump_anything import Loader, Dumper
from .bl_datablock import BlDatablock from replication.protocol import ReplicatedDatablock
from .bl_datablock import resolve_datablock_from_uuid
from .bl_action import dump_animation_data, load_animation_data, resolve_animation_dependencies
import bpy.types as T
class BlTexture(ReplicatedDatablock):
class BlTexture(BlDatablock):
bl_id = "textures" bl_id = "textures"
bl_class = bpy.types.Texture bl_class = bpy.types.Texture
bl_check_common = False bl_check_common = False
bl_icon = 'TEXTURE' bl_icon = 'TEXTURE'
bl_reload_parent = False bl_reload_parent = False
def _load_implementation(self, data, target): @staticmethod
def load(data: dict, datablock: object):
loader = Loader() loader = Loader()
loader.load(target, data) loader.load(datablock, data)
load_animation_data(data.get('animation_data'), datablock)
def _construct(self, data): @staticmethod
def construct(data: dict) -> object:
return bpy.data.textures.new(data["name"], data["type"]) return bpy.data.textures.new(data["name"], data["type"])
def _dump_implementation(self, data, instance=None): @staticmethod
assert(instance) def dump(datablock: object) -> dict:
dumper = Dumper() dumper = Dumper()
dumper.depth = 1 dumper.depth = 1
@ -52,24 +57,39 @@ class BlTexture(BlDatablock):
'name_full' 'name_full'
] ]
data = dumper.dump(instance) data = dumper.dump(datablock)
color_ramp = getattr(instance, 'color_ramp', None)
color_ramp = getattr(datablock, 'color_ramp', None)
if color_ramp: if color_ramp:
dumper.depth = 4 dumper.depth = 4
data['color_ramp'] = dumper.dump(color_ramp) data['color_ramp'] = dumper.dump(color_ramp)
data['animation_data'] = dump_animation_data(datablock)
return data return data
def _resolve_deps_implementation(self): @staticmethod
# TODO: resolve material def resolve(data: dict) -> object:
uuid = data.get('uuid')
return resolve_datablock_from_uuid(uuid, bpy.data.textures)
@staticmethod
def resolve_deps(datablock: object) -> [object]:
deps = [] deps = []
image = getattr(self.instance,"image", None) image = getattr(datablock,"image", None)
if image: if image:
deps.append(image) deps.append(image)
deps.extend(resolve_animation_dependencies(datablock))
return deps return deps
_type = [T.WoodTexture, T.VoronoiTexture,
T.StucciTexture, T.NoiseTexture,
T.MusgraveTexture, T.MarbleTexture,
T.MagicTexture, T.ImageTexture,
T.DistortedNoiseTexture, T.CloudsTexture,
T.BlendTexture]
_class = BlTexture

View File

@ -21,32 +21,24 @@ import mathutils
from pathlib import Path from pathlib import Path
from .dump_anything import Loader, Dumper from .dump_anything import Loader, Dumper
from .bl_datablock import BlDatablock, get_datablock_from_uuid from replication.protocol import ReplicatedDatablock
from .bl_datablock import get_datablock_from_uuid, resolve_datablock_from_uuid
from .bl_material import dump_materials_slots, load_materials_slots from .bl_material import dump_materials_slots, load_materials_slots
from .bl_action import dump_animation_data, load_animation_data, resolve_animation_dependencies
class BlVolume(BlDatablock): class BlVolume(ReplicatedDatablock):
bl_id = "volumes" bl_id = "volumes"
bl_class = bpy.types.Volume bl_class = bpy.types.Volume
bl_check_common = False bl_check_common = False
bl_icon = 'VOLUME_DATA' bl_icon = 'VOLUME_DATA'
bl_reload_parent = False bl_reload_parent = False
def _load_implementation(self, data, target): @staticmethod
loader = Loader() def construct(data: dict) -> object:
loader.load(target, data)
loader.load(target.display, data['display'])
# MATERIAL SLOTS
src_materials = data.get('materials', None)
if src_materials:
load_materials_slots(src_materials, target.materials)
def _construct(self, data):
return bpy.data.volumes.new(data["name"]) return bpy.data.volumes.new(data["name"])
def _dump_implementation(self, data, instance=None): @staticmethod
assert(instance) def dump(datablock: object) -> dict:
dumper = Dumper() dumper = Dumper()
dumper.depth = 1 dumper.depth = 1
dumper.exclude_filter = [ dumper.exclude_filter = [
@ -60,27 +52,48 @@ class BlVolume(BlDatablock):
'use_fake_user' 'use_fake_user'
] ]
data = dumper.dump(instance) data = dumper.dump(datablock)
data['display'] = dumper.dump(instance.display) data['display'] = dumper.dump(datablock.display)
# Fix material index # Fix material index
data['materials'] = dump_materials_slots(instance.materials) data['materials'] = dump_materials_slots(datablock.materials)
data['animation_data'] = dump_animation_data(datablock)
return data return data
def _resolve_deps_implementation(self): @staticmethod
def load(data: dict, datablock: object):
load_animation_data(data.get('animation_data'), datablock)
loader = Loader()
loader.load(datablock, data)
loader.load(datablock.display, data['display'])
# MATERIAL SLOTS
src_materials = data.get('materials', None)
if src_materials:
load_materials_slots(src_materials, datablock.materials)
@staticmethod
def resolve(data: dict) -> object:
uuid = data.get('uuid')
return resolve_datablock_from_uuid(uuid, bpy.data.volumes)
@staticmethod
def resolve_deps(datablock: object) -> [object]:
# TODO: resolve material # TODO: resolve material
deps = [] deps = []
external_vdb = Path(bpy.path.abspath(self.instance.filepath)) external_vdb = Path(bpy.path.abspath(datablock.filepath))
if external_vdb.exists() and not external_vdb.is_dir(): if external_vdb.exists() and not external_vdb.is_dir():
deps.append(external_vdb) deps.append(external_vdb)
for material in self.instance.materials: for material in datablock.materials:
if material: if material:
deps.append(material) deps.append(material)
deps.extend(resolve_animation_dependencies(datablock))
return deps return deps
_type = bpy.types.Volume
_class = BlVolume

View File

@ -20,35 +20,40 @@ import bpy
import mathutils import mathutils
from .dump_anything import Loader, Dumper from .dump_anything import Loader, Dumper
from .bl_datablock import BlDatablock from replication.protocol import ReplicatedDatablock
from .bl_material import (load_node_tree, from .bl_material import (load_node_tree,
dump_node_tree, dump_node_tree,
get_node_tree_dependencies) get_node_tree_dependencies)
from .bl_datablock import resolve_datablock_from_uuid
from .bl_action import dump_animation_data, load_animation_data, resolve_animation_dependencies
class BlWorld(BlDatablock):
class BlWorld(ReplicatedDatablock):
bl_id = "worlds" bl_id = "worlds"
bl_class = bpy.types.World bl_class = bpy.types.World
bl_check_common = True bl_check_common = True
bl_icon = 'WORLD_DATA' bl_icon = 'WORLD_DATA'
bl_reload_parent = False bl_reload_parent = False
def _construct(self, data): @staticmethod
def construct(data: dict) -> object:
return bpy.data.worlds.new(data["name"]) return bpy.data.worlds.new(data["name"])
def _load_implementation(self, data, target): @staticmethod
def load(data: dict, datablock: object):
load_animation_data(data.get('animation_data'), datablock)
loader = Loader() loader = Loader()
loader.load(target, data) loader.load(datablock, data)
if data["use_nodes"]: if data["use_nodes"]:
if target.node_tree is None: if datablock.node_tree is None:
target.use_nodes = True datablock.use_nodes = True
load_node_tree(data['node_tree'], target.node_tree) load_node_tree(data['node_tree'], datablock.node_tree)
def _dump_implementation(self, data, instance=None):
assert(instance)
@staticmethod
def dump(datablock: object) -> dict:
world_dumper = Dumper() world_dumper = Dumper()
world_dumper.depth = 1 world_dumper.depth = 1
world_dumper.include_filter = [ world_dumper.include_filter = [
@ -56,17 +61,27 @@ class BlWorld(BlDatablock):
"name", "name",
"color" "color"
] ]
data = world_dumper.dump(instance) data = world_dumper.dump(datablock)
if instance.use_nodes: if datablock.use_nodes:
data['node_tree'] = dump_node_tree(instance.node_tree) data['node_tree'] = dump_node_tree(datablock.node_tree)
data['animation_data'] = dump_animation_data(datablock)
return data return data
@staticmethod
def resolve(data: dict) -> object:
uuid = data.get('uuid')
return resolve_datablock_from_uuid(uuid, bpy.data.worlds)
def _resolve_deps_implementation(self): @staticmethod
def resolve_deps(datablock: object) -> [object]:
deps = [] deps = []
if self.instance.use_nodes: if datablock.use_nodes:
deps.extend(get_node_tree_dependencies(self.instance.node_tree)) deps.extend(get_node_tree_dependencies(datablock.node_tree))
if self.is_library:
deps.append(self.instance.library) deps.extend(resolve_animation_dependencies(datablock))
return deps return deps
_type = bpy.types.World
_class = BlWorld

View File

@ -507,16 +507,12 @@ class Loader:
_constructors = { _constructors = {
T.ColorRampElement: (CONSTRUCTOR_NEW, ["position"]), T.ColorRampElement: (CONSTRUCTOR_NEW, ["position"]),
T.ParticleSettingsTextureSlot: (CONSTRUCTOR_ADD, []), T.ParticleSettingsTextureSlot: (CONSTRUCTOR_ADD, []),
T.Modifier: (CONSTRUCTOR_NEW, ["name", "type"]),
T.GpencilModifier: (CONSTRUCTOR_NEW, ["name", "type"]), T.GpencilModifier: (CONSTRUCTOR_NEW, ["name", "type"]),
T.Constraint: (CONSTRUCTOR_NEW, ["type"]),
} }
destructors = { destructors = {
T.ColorRampElement: DESTRUCTOR_REMOVE, T.ColorRampElement: DESTRUCTOR_REMOVE,
T.Modifier: DESTRUCTOR_CLEAR,
T.GpencilModifier: DESTRUCTOR_CLEAR, T.GpencilModifier: DESTRUCTOR_CLEAR,
T.Constraint: DESTRUCTOR_REMOVE,
} }
element_type = element.bl_rna_property.fixed_type element_type = element.bl_rna_property.fixed_type

View File

@ -24,20 +24,25 @@ import sys
from pathlib import Path from pathlib import Path
import socket import socket
import re import re
import bpy
VERSION_EXPR = re.compile('\d+.\d+.\d+') VERSION_EXPR = re.compile('\d+.\d+.\d+')
THIRD_PARTY = os.path.join(os.path.dirname(os.path.abspath(__file__)), "libs")
DEFAULT_CACHE_DIR = os.path.join( DEFAULT_CACHE_DIR = os.path.join(
os.path.dirname(os.path.abspath(__file__)), "cache") os.path.dirname(os.path.abspath(__file__)), "cache")
REPLICATION_DEPENDENCIES = {
"zmq",
"deepdiff"
}
LIBS = os.path.join(os.path.dirname(os.path.abspath(__file__)), "libs")
REPLICATION = os.path.join(LIBS,"replication")
PYTHON_PATH = None PYTHON_PATH = None
SUBPROCESS_DIR = None SUBPROCESS_DIR = None
rtypes = [] rtypes = []
def module_can_be_imported(name): def module_can_be_imported(name: str) -> bool:
try: try:
__import__(name) __import__(name)
return True return True
@ -50,7 +55,7 @@ def install_pip():
subprocess.run([str(PYTHON_PATH), "-m", "ensurepip"]) subprocess.run([str(PYTHON_PATH), "-m", "ensurepip"])
def install_package(name, version): def install_package(name: str, install_dir: str):
logging.info(f"installing {name} version...") logging.info(f"installing {name} version...")
env = os.environ env = os.environ
if "PIP_REQUIRE_VIRTUALENV" in env: if "PIP_REQUIRE_VIRTUALENV" in env:
@ -60,12 +65,13 @@ def install_package(name, version):
# env var for the subprocess. # env var for the subprocess.
env = os.environ.copy() env = os.environ.copy()
del env["PIP_REQUIRE_VIRTUALENV"] del env["PIP_REQUIRE_VIRTUALENV"]
subprocess.run([str(PYTHON_PATH), "-m", "pip", "install", f"{name}=={version}"], env=env) subprocess.run([str(PYTHON_PATH), "-m", "pip", "install", f"{name}", "-t", install_dir], env=env)
if name in sys.modules: if name in sys.modules:
del sys.modules[name] del sys.modules[name]
def check_package_version(name, required_version):
def check_package_version(name: str, required_version: str):
logging.info(f"Checking {name} version...") logging.info(f"Checking {name} version...")
out = subprocess.run([str(PYTHON_PATH), "-m", "pip", "show", name], capture_output=True) out = subprocess.run([str(PYTHON_PATH), "-m", "pip", "show", name], capture_output=True)
@ -77,6 +83,7 @@ def check_package_version(name, required_version):
logging.info(f"{name} need an update") logging.info(f"{name} need an update")
return False return False
def get_ip(): def get_ip():
""" """
Retrieve the main network interface IP. Retrieve the main network interface IP.
@ -94,7 +101,25 @@ def check_dir(dir):
os.makedirs(dir) os.makedirs(dir)
def setup(dependencies, python_path): def setup_paths(paths: list):
""" Add missing path to sys.path
"""
for path in paths:
if path not in sys.path:
logging.debug(f"Adding {path} dir to the path.")
sys.path.insert(0, path)
def remove_paths(paths: list):
""" Remove list of path from sys.path
"""
for path in paths:
if path in sys.path:
logging.debug(f"Removing {path} dir from the path.")
sys.path.remove(path)
def install_modules(dependencies: list, python_path: str, install_dir: str):
global PYTHON_PATH, SUBPROCESS_DIR global PYTHON_PATH, SUBPROCESS_DIR
PYTHON_PATH = Path(python_path) PYTHON_PATH = Path(python_path)
@ -103,9 +128,23 @@ def setup(dependencies, python_path):
if not module_can_be_imported("pip"): if not module_can_be_imported("pip"):
install_pip() install_pip()
for package_name, package_version in dependencies: for package_name in dependencies:
if not module_can_be_imported(package_name): if not module_can_be_imported(package_name):
install_package(package_name, package_version) install_package(package_name, install_dir=install_dir)
module_can_be_imported(package_name) module_can_be_imported(package_name)
elif not check_package_version(package_name, package_version):
install_package(package_name, package_version) def register():
if bpy.app.version[1] >= 91:
python_binary_path = sys.executable
else:
python_binary_path = bpy.app.binary_path_python
for module_name in list(sys.modules.keys()):
if 'replication' in module_name:
del sys.modules[module_name]
setup_paths([LIBS, REPLICATION])
install_modules(REPLICATION_DEPENDENCIES, python_binary_path, install_dir=LIBS)
def unregister():
remove_paths([REPLICATION, LIBS])

View File

@ -47,11 +47,12 @@ from bpy.app.handlers import persistent
from bpy_extras.io_utils import ExportHelper, ImportHelper from bpy_extras.io_utils import ExportHelper, ImportHelper
from replication.constants import (COMMITED, FETCHED, RP_COMMON, STATE_ACTIVE, from replication.constants import (COMMITED, FETCHED, RP_COMMON, STATE_ACTIVE,
STATE_INITIAL, STATE_SYNCING, UP) STATE_INITIAL, STATE_SYNCING, UP)
from replication.data import DataTranslationProtocol from replication.protocol import DataTranslationProtocol
from replication.exception import ContextError, NonAuthorizedOperationError from replication.exception import ContextError, NonAuthorizedOperationError
from replication.interface import session from replication.interface import session
from replication.porcelain import add, apply from replication import porcelain
from replication.repository import Repository from replication.repository import Repository
from replication.objects import Node
from . import bl_types, environment, timers, ui, utils from . import bl_types, environment, timers, ui, utils
from .presence import SessionStatusWidget, renderer, view3d_find from .presence import SessionStatusWidget, renderer, view3d_find
@ -79,35 +80,33 @@ def session_callback(name):
def initialize_session(): def initialize_session():
"""Session connection init hander """Session connection init hander
""" """
logging.info("Intializing the scene")
settings = utils.get_preferences() settings = utils.get_preferences()
runtime_settings = bpy.context.window_manager.session runtime_settings = bpy.context.window_manager.session
# Step 1: Constrect nodes if not runtime_settings.is_host:
logging.info("Constructing nodes") logging.info("Intializing the scene")
for node in session.repository.list_ordered(): # Step 1: Constrect nodes
node_ref = session.repository.get_node(node) logging.info("Instantiating nodes")
if node_ref is None: for node in session.repository.index_sorted:
logging.error(f"Can't construct node {node}") node_ref = session.repository.graph.get(node)
elif node_ref.state == FETCHED: if node_ref is None:
node_ref.resolve() logging.error(f"Can't construct node {node}")
elif node_ref.state == FETCHED:
# Step 2: Load nodes node_ref.instance = session.repository.rdp.resolve(node_ref.data)
logging.info("Loading nodes") if node_ref.instance is None:
for node in session.repository.list_ordered(): node_ref.instance = session.repository.rdp.construct(node_ref.data)
node_ref = session.repository.get_node(node) node_ref.instance.uuid = node_ref.uuid
if node_ref is None: # Step 2: Load nodes
logging.error(f"Can't load node {node}") logging.info("Applying nodes")
elif node_ref.state == FETCHED: for node in session.repository.index_sorted:
node_ref.apply() porcelain.apply(session.repository, node)
logging.info("Registering timers") logging.info("Registering timers")
# Step 4: Register blender timers # Step 4: Register blender timers
for d in deleyables: for d in deleyables:
d.register() d.register()
bpy.ops.session.apply_armature_operator('INVOKE_DEFAULT')
# Step 5: Clearing history # Step 5: Clearing history
utils.flush_history() utils.flush_history()
@ -191,37 +190,26 @@ class SessionStartOperator(bpy.types.Operator):
handler.setFormatter(formatter) handler.setFormatter(formatter)
bpy_protocol = DataTranslationProtocol() bpy_protocol = bl_types.get_data_translation_protocol()
supported_bl_types = []
# init the factory with supported types # Check if supported_datablocks are up to date before starting the
for type in bl_types.types_to_register(): # the session
type_module = getattr(bl_types, type) for dcc_type_id in bpy_protocol.implementations.keys():
name = [e.capitalize() for e in type.split('_')[1:]] if dcc_type_id not in settings.supported_datablocks:
type_impl_name = 'Bl'+''.join(name) logging.info(f"{dcc_type_id} not found, \
type_module_class = getattr(type_module, type_impl_name)
supported_bl_types.append(type_module_class.bl_id)
if type_impl_name not in settings.supported_datablocks:
logging.info(f"{type_impl_name} not found, \
regenerate type settings...") regenerate type settings...")
settings.generate_supported_types() settings.generate_supported_types()
type_local_config = settings.supported_datablocks[type_impl_name]
bpy_protocol.register_type(
type_module_class.bl_class,
type_module_class,
check_common=type_module_class.bl_check_common)
if bpy.app.version[1] >= 91: if bpy.app.version[1] >= 91:
python_binary_path = sys.executable python_binary_path = sys.executable
else: else:
python_binary_path = bpy.app.binary_path_python python_binary_path = bpy.app.binary_path_python
repo = Repository(data_protocol=bpy_protocol) repo = Repository(
rdp=bpy_protocol,
username=settings.username)
# Host a session # Host a session
if self.host: if self.host:
if settings.init_method == 'EMPTY': if settings.init_method == 'EMPTY':
@ -233,12 +221,17 @@ class SessionStartOperator(bpy.types.Operator):
try: try:
# Init repository # Init repository
for scene in bpy.data.scenes: for scene in bpy.data.scenes:
add(repo, scene) porcelain.add(repo, scene)
porcelain.remote_add(
repo,
'origin',
'127.0.0.1',
settings.port,
admin_password=admin_pass)
session.host( session.host(
repository= repo, repository= repo,
id=settings.username, remote='origin',
port=settings.port,
timeout=settings.connection_timeout, timeout=settings.connection_timeout,
password=admin_pass, password=admin_pass,
cache_directory=settings.cache_directory, cache_directory=settings.cache_directory,
@ -257,11 +250,14 @@ class SessionStartOperator(bpy.types.Operator):
admin_pass = None admin_pass = None
try: try:
porcelain.remote_add(
repo,
'origin',
settings.ip,
settings.port,
admin_password=admin_pass)
session.connect( session.connect(
repository= repo, repository= repo,
id=settings.username,
address=settings.ip,
port=settings.port,
timeout=settings.connection_timeout, timeout=settings.connection_timeout,
password=admin_pass password=admin_pass
) )
@ -273,10 +269,7 @@ class SessionStartOperator(bpy.types.Operator):
deleyables.append(timers.ClientUpdate()) deleyables.append(timers.ClientUpdate())
deleyables.append(timers.DynamicRightSelectTimer()) deleyables.append(timers.DynamicRightSelectTimer())
deleyables.append(timers.ApplyTimer(timeout=settings.depsgraph_update_rate)) deleyables.append(timers.ApplyTimer(timeout=settings.depsgraph_update_rate))
# deleyables.append(timers.PushTimer(
# queue=stagging,
# timeout=settings.depsgraph_update_rate
# ))
session_update = timers.SessionStatusUpdate() session_update = timers.SessionStatusUpdate()
session_user_sync = timers.SessionUserSync() session_user_sync = timers.SessionUserSync()
session_background_executor = timers.MainThreadExecutor( session_background_executor = timers.MainThreadExecutor(
@ -292,11 +285,7 @@ class SessionStartOperator(bpy.types.Operator):
deleyables.append(session_update) deleyables.append(session_update)
deleyables.append(session_user_sync) deleyables.append(session_user_sync)
deleyables.append(session_listen) deleyables.append(session_listen)
self.report(
{'INFO'},
f"connecting to tcp://{settings.ip}:{settings.port}")
return {"FINISHED"} return {"FINISHED"}
@ -332,9 +321,10 @@ class SessionInitOperator(bpy.types.Operator):
utils.clean_scene() utils.clean_scene()
for scene in bpy.data.scenes: for scene in bpy.data.scenes:
add(session.repository, scene) porcelain.add(session.repository, scene)
session.init() session.init()
context.window_manager.session.is_host = True
return {"FINISHED"} return {"FINISHED"}
@ -381,7 +371,7 @@ class SessionKickOperator(bpy.types.Operator):
assert(session) assert(session)
try: try:
session.kick(self.user) porcelain.kick(session.repository, self.user)
except Exception as e: except Exception as e:
self.report({'ERROR'}, repr(e)) self.report({'ERROR'}, repr(e))
@ -410,7 +400,7 @@ class SessionPropertyRemoveOperator(bpy.types.Operator):
def execute(self, context): def execute(self, context):
try: try:
session.remove(self.property_path) porcelain.rm(session.repository, self.property_path)
return {"FINISHED"} return {"FINISHED"}
except: # NonAuthorizedOperationError: except: # NonAuthorizedOperationError:
@ -452,10 +442,17 @@ class SessionPropertyRightOperator(bpy.types.Operator):
runtime_settings = context.window_manager.session runtime_settings = context.window_manager.session
if session: if session:
session.change_owner(self.key, if runtime_settings.clients == RP_COMMON:
runtime_settings.clients, porcelain.unlock(session.repository,
self.key,
ignore_warnings=True, ignore_warnings=True,
affect_dependencies=self.recursive) affect_dependencies=self.recursive)
else:
porcelain.lock(session.repository,
self.key,
runtime_settings.clients,
ignore_warnings=True,
affect_dependencies=self.recursive)
return {"FINISHED"} return {"FINISHED"}
@ -570,7 +567,7 @@ class SessionSnapTimeOperator(bpy.types.Operator):
def modal(self, context, event): def modal(self, context, event):
is_running = context.window_manager.session.user_snap_running is_running = context.window_manager.session.user_snap_running
if event.type in {'RIGHTMOUSE', 'ESC'} or not is_running: if not is_running:
self.cancel(context) self.cancel(context)
return {'CANCELLED'} return {'CANCELLED'}
@ -603,18 +600,19 @@ class SessionApply(bpy.types.Operator):
def execute(self, context): def execute(self, context):
logging.debug(f"Running apply on {self.target}") logging.debug(f"Running apply on {self.target}")
try: try:
node_ref = session.repository.get_node(self.target) node_ref = session.repository.graph.get(self.target)
apply(session.repository, porcelain.apply(session.repository,
self.target, self.target,
force=True, force=True,
force_dependencies=self.reset_dependencies) force_dependencies=self.reset_dependencies)
if node_ref.bl_reload_parent: impl = session.repository.rdp.get_implementation(node_ref.instance)
for parent in session.repository.get_parents(self.target): if impl.bl_reload_parent:
for parent in session.repository.graph.get_parents(self.target):
logging.debug(f"Refresh parent {parent}") logging.debug(f"Refresh parent {parent}")
apply(session.repository, porcelain.apply(session.repository,
parent.uuid, parent.uuid,
force=True) force=True)
except Exception as e: except Exception as e:
self.report({'ERROR'}, repr(e)) self.report({'ERROR'}, repr(e))
traceback.print_exc() traceback.print_exc()
@ -637,54 +635,12 @@ class SessionCommit(bpy.types.Operator):
def execute(self, context): def execute(self, context):
try: try:
session.commit(uuid=self.target) porcelain.commit(session.repository, self.target)
session.push(self.target) porcelain.push(session.repository, 'origin', self.target)
return {"FINISHED"} return {"FINISHED"}
except Exception as e: except Exception as e:
self.report({'ERROR'}, repr(e)) self.report({'ERROR'}, repr(e))
return {"CANCELED"} return {"CANCELLED"}
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':
if session and session.state == STATE_ACTIVE:
nodes = session.list(filter=bl_types.bl_armature.BlArmature)
for node in nodes:
node_ref = session.repository.get_node(node)
if node_ref.state == FETCHED:
try:
apply(session.repository, node)
except Exception as e:
logging.error("Fail to apply armature: {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
class SessionClearCache(bpy.types.Operator): class SessionClearCache(bpy.types.Operator):
@ -715,6 +671,7 @@ class SessionClearCache(bpy.types.Operator):
row = self.layout row = self.layout
row.label(text=f" Do you really want to remove local cache ? ") row.label(text=f" Do you really want to remove local cache ? ")
class SessionPurgeOperator(bpy.types.Operator): class SessionPurgeOperator(bpy.types.Operator):
"Remove node with lost references" "Remove node with lost references"
bl_idname = "session.purge" bl_idname = "session.purge"
@ -797,7 +754,7 @@ class SessionSaveBackupOperator(bpy.types.Operator, ExportHelper):
recorder.register() recorder.register()
deleyables.append(recorder) deleyables.append(recorder)
else: else:
session.save(self.filepath) session.repository.dumps(self.filepath)
return {'FINISHED'} return {'FINISHED'}
@ -805,6 +762,7 @@ class SessionSaveBackupOperator(bpy.types.Operator, ExportHelper):
def poll(cls, context): def poll(cls, context):
return session.state == STATE_ACTIVE return session.state == STATE_ACTIVE
class SessionStopAutoSaveOperator(bpy.types.Operator): class SessionStopAutoSaveOperator(bpy.types.Operator):
bl_idname = "session.cancel_autosave" bl_idname = "session.cancel_autosave"
bl_label = "Cancel auto-save" bl_label = "Cancel auto-save"
@ -839,63 +797,24 @@ class SessionLoadSaveOperator(bpy.types.Operator, ImportHelper):
def execute(self, context): def execute(self, context):
from replication.repository import Repository from replication.repository import Repository
# TODO: add filechecks # init the factory with supported types
bpy_protocol = bl_types.get_data_translation_protocol()
repo = Repository(bpy_protocol)
repo.loads(self.filepath)
utils.clean_scene()
try: nodes = [repo.graph.get(n) for n in repo.index_sorted]
f = gzip.open(self.filepath, "rb")
db = pickle.load(f)
except OSError as e:
f = open(self.filepath, "rb")
db = pickle.load(f)
if db:
logging.info(f"Reading {self.filepath}")
nodes = db.get("nodes")
logging.info(f"{len(nodes)} Nodes to load") # Step 1: Construct nodes
for node in nodes:
node.instance = bpy_protocol.resolve(node.data)
if node.instance is None:
node.instance = bpy_protocol.construct(node.data)
node.instance.uuid = node.uuid
# Step 2: Load nodes
for node in nodes:
# init the factory with supported types porcelain.apply(repo, node.uuid)
bpy_protocol = DataTranslationProtocol()
for type in bl_types.types_to_register():
type_module = getattr(bl_types, type)
name = [e.capitalize() for e in type.split('_')[1:]]
type_impl_name = 'Bl'+''.join(name)
type_module_class = getattr(type_module, type_impl_name)
bpy_protocol.register_type(
type_module_class.bl_class,
type_module_class)
graph = Repository()
for node, node_data in nodes:
node_type = node_data.get('str_type')
impl = bpy_protocol.get_implementation_from_net(node_type)
if impl:
logging.info(f"Loading {node}")
instance = impl(owner=node_data['owner'],
uuid=node,
dependencies=node_data['dependencies'],
data=node_data['data'])
graph.do_commit(instance)
instance.state = FETCHED
logging.info("Graph succefully loaded")
utils.clean_scene()
# Step 1: Construct nodes
for node in graph.list_ordered():
graph[node].resolve()
# Step 2: Load nodes
for node in graph.list_ordered():
graph[node].apply()
return {'FINISHED'} return {'FINISHED'}
@ -987,7 +906,6 @@ classes = (
SessionPropertyRightOperator, SessionPropertyRightOperator,
SessionApply, SessionApply,
SessionCommit, SessionCommit,
ApplyArmatureOperator,
SessionKickOperator, SessionKickOperator,
SessionInitOperator, SessionInitOperator,
SessionClearCache, SessionClearCache,
@ -1000,14 +918,15 @@ classes = (
SessionPresetServerRemove, SessionPresetServerRemove,
) )
def update_external_dependencies(): def update_external_dependencies():
nodes_ids = session.list(filter=bl_types.bl_file.BlFile) nodes_ids = [n.uuid for n in session.repository.graph.values() if n.data['type_id'] in ['WindowsPath', 'PosixPath']]
for node_id in nodes_ids: for node_id in nodes_ids:
node = session.repository.get_node(node_id) node = session.repository.graph.get(node_id)
if node and node.owner in [session.id, RP_COMMON] \ if node and node.owner in [session.repository.username, RP_COMMON]:
and node.has_changed(): porcelain.commit(session.repository, node_id)
session.commit(node_id) porcelain.push(session.repository,'origin', node_id)
session.push(node_id, check_data=False)
def sanitize_deps_graph(remove_nodes: bool = False): def sanitize_deps_graph(remove_nodes: bool = False):
""" Cleanup the replication graph """ Cleanup the replication graph
@ -1015,18 +934,20 @@ def sanitize_deps_graph(remove_nodes: bool = False):
if session and session.state == STATE_ACTIVE: if session and session.state == STATE_ACTIVE:
start = utils.current_milli_time() start = utils.current_milli_time()
rm_cpt = 0 rm_cpt = 0
for node_key in session.list(): for node in session.repository.graph.values():
node = session.repository.get_node(node_key) node.instance = session.repository.rdp.resolve(node.data)
if node is None \ if node is None \
or (node.state == UP and not node.resolve(construct=False)): or (node.state == UP and not node.instance):
if remove_nodes: if remove_nodes:
try: try:
session.remove(node.uuid, remove_dependencies=False) porcelain.rm(session.repository,
node.uuid,
remove_dependencies=False)
logging.info(f"Removing {node.uuid}") logging.info(f"Removing {node.uuid}")
rm_cpt += 1 rm_cpt += 1
except NonAuthorizedOperationError: except NonAuthorizedOperationError:
continue continue
logging.info(f"Sanitize took { utils.current_milli_time()-start} ms") logging.info(f"Sanitize took { utils.current_milli_time()-start} ms, removed {rm_cpt} nodes")
@persistent @persistent
@ -1040,6 +961,7 @@ def resolve_deps_graph(dummy):
if session and session.state == STATE_ACTIVE: if session and session.state == STATE_ACTIVE:
sanitize_deps_graph(remove_nodes=True) sanitize_deps_graph(remove_nodes=True)
@persistent @persistent
def load_pre_handler(dummy): def load_pre_handler(dummy):
if session and session.state in [STATE_ACTIVE, STATE_SYNCING]: if session and session.state in [STATE_ACTIVE, STATE_SYNCING]:
@ -1049,7 +971,7 @@ def load_pre_handler(dummy):
@persistent @persistent
def update_client_frame(scene): def update_client_frame(scene):
if session and session.state == STATE_ACTIVE: if session and session.state == STATE_ACTIVE:
session.update_user_metadata({ porcelain.update_user_metadata(session.repository, {
'frame_current': scene.frame_current 'frame_current': scene.frame_current
}) })
@ -1064,27 +986,28 @@ def depsgraph_evaluation(scene):
update_external_dependencies() update_external_dependencies()
is_internal = [u for u in dependency_updates if u.is_updated_geometry or u.is_updated_shading or u.is_updated_transform]
# NOTE: maybe we don't need to check each update but only the first # NOTE: maybe we don't need to check each update but only the first
if not is_internal:
return
for update in reversed(dependency_updates): for update in reversed(dependency_updates):
# Is the object tracked ? # Is the object tracked ?
if update.id.uuid: if update.id.uuid:
# Retrieve local version # Retrieve local version
node = session.repository.get_node(update.id.uuid) node = session.repository.graph.get(update.id.uuid)
check_common = session.repository.rdp.get_implementation(update.id).bl_check_common
# Check our right on this update: # Check our right on this update:
# - if its ours or ( under common and diff), launch the # - if its ours or ( under common and diff), launch the
# update process # update process
# - if its to someone else, ignore the update # - if its to someone else, ignore the update
if node and (node.owner == session.id or node.bl_check_common): if node and (node.owner == session.repository.username or check_common):
if node.state == UP: if node.state == UP:
try: try:
if node.has_changed(): porcelain.commit(session.repository, node.uuid)
session.commit(node.uuid) porcelain.push(session.repository, 'origin', node.uuid)
session.push(node.uuid, check_data=False)
except ReferenceError: except ReferenceError:
logging.debug(f"Reference error {node.uuid}") logging.debug(f"Reference error {node.uuid}")
if not node.is_valid():
session.remove(node.uuid)
except ContextError as e: except ContextError as e:
logging.debug(e) logging.debug(e)
except Exception as e: except Exception as e:
@ -1095,11 +1018,11 @@ def depsgraph_evaluation(scene):
elif isinstance(update.id, bpy.types.Scene): elif isinstance(update.id, bpy.types.Scene):
ref = session.repository.get_node_by_datablock(update.id) ref = session.repository.get_node_by_datablock(update.id)
if ref: if ref:
ref.resolve() pass
else: else:
scn_uuid = add(session.repository, update.id) scn_uuid = porcelain.add(session.repository, update.id)
session.commit(scn_uuid) porcelain.commit(session.node_id, scn_uuid)
session.push(scn_uuid, check_data=False) porcelain.push(session.repository,'origin', scn_uuid)
def register(): def register():
from bpy.utils import register_class from bpy.utils import register_class

View File

@ -457,18 +457,18 @@ class SessionPrefs(bpy.types.AddonPreferences):
def generate_supported_types(self): def generate_supported_types(self):
self.supported_datablocks.clear() self.supported_datablocks.clear()
for type in bl_types.types_to_register(): bpy_protocol = bl_types.get_data_translation_protocol()
# init the factory with supported types
for dcc_type_id, impl in bpy_protocol.implementations.items():
new_db = self.supported_datablocks.add() new_db = self.supported_datablocks.add()
type_module = getattr(bl_types, type) new_db.name = dcc_type_id
name = [e.capitalize() for e in type.split('_')[1:]] new_db.type_name = dcc_type_id
type_impl_name = 'Bl'+''.join(name)
type_module_class = getattr(type_module, type_impl_name)
new_db.name = type_impl_name
new_db.type_name = type_impl_name
new_db.use_as_filter = True new_db.use_as_filter = True
new_db.icon = type_module_class.bl_icon new_db.icon = impl.bl_icon
new_db.bl_name = type_module_class.bl_id new_db.bl_name = impl.bl_id
# custom at launch server preset # custom at launch server preset
def generate_default_presets(self): def generate_default_presets(self):
@ -553,6 +553,11 @@ class SessionProps(bpy.types.PropertyGroup):
description='Show only owned datablocks', description='Show only owned datablocks',
default=True default=True
) )
filter_name: bpy.props.StringProperty(
name="filter_name",
default="",
description='Node name filter',
)
admin: bpy.props.BoolProperty( admin: bpy.props.BoolProperty(
name="admin", name="admin",
description='Connect as admin', description='Connect as admin',

View File

@ -302,9 +302,10 @@ class UserSelectionWidget(Widget):
return return
vertex_pos = bbox_from_obj(ob, 1.0) vertex_pos = bbox_from_obj(ob, 1.0)
vertex_indices = ((0, 1), (0, 2), (1, 3), (2, 3), vertex_indices = (
(4, 5), (4, 6), (5, 7), (6, 7), (0, 1), (1, 2), (2, 3), (0, 3),
(0, 4), (1, 5), (2, 6), (3, 7)) (4, 5), (5, 6), (6, 7), (4, 7),
(0, 4), (1, 5), (2, 6), (3, 7))
if ob.instance_collection: if ob.instance_collection:
for obj in ob.instance_collection.objects: for obj in ob.instance_collection.objects:

View File

@ -24,7 +24,7 @@ from replication.constants import (FETCHED, RP_COMMON, STATE_ACTIVE,
STATE_SRV_SYNC, STATE_SYNCING, UP) STATE_SRV_SYNC, STATE_SYNCING, UP)
from replication.exception import NonAuthorizedOperationError, ContextError from replication.exception import NonAuthorizedOperationError, ContextError
from replication.interface import session from replication.interface import session
from replication.porcelain import apply, add from replication import porcelain
from . import operators, utils from . import operators, utils
from .presence import (UserFrustumWidget, UserNameWidget, UserSelectionWidget, from .presence import (UserFrustumWidget, UserNameWidget, UserSelectionWidget,
@ -72,6 +72,7 @@ class Timer(object):
except Exception as e: except Exception as e:
logging.error(e) logging.error(e)
self.unregister() self.unregister()
traceback.print_exc()
session.disconnect(reason=f"Error during timer {self.id} execution") session.disconnect(reason=f"Error during timer {self.id} execution")
else: else:
if self.is_running: if self.is_running:
@ -99,7 +100,7 @@ class SessionBackupTimer(Timer):
def execute(self): def execute(self):
session.save(self._filepath) session.repository.dumps(self._filepath)
class SessionListenTimer(Timer): class SessionListenTimer(Timer):
def execute(self): def execute(self):
@ -108,22 +109,21 @@ class SessionListenTimer(Timer):
class ApplyTimer(Timer): class ApplyTimer(Timer):
def execute(self): def execute(self):
if session and session.state == STATE_ACTIVE: if session and session.state == STATE_ACTIVE:
nodes = session.list() for node in session.repository.graph.keys():
node_ref = session.repository.graph.get(node)
for node in nodes:
node_ref = session.repository.get_node(node)
if node_ref.state == FETCHED: if node_ref.state == FETCHED:
try: try:
apply(session.repository, node) porcelain.apply(session.repository, node)
except Exception as e: except Exception as e:
logging.error(f"Fail to apply {node_ref.uuid}") logging.error(f"Fail to apply {node_ref.uuid}")
traceback.print_exc() traceback.print_exc()
else: else:
if node_ref.bl_reload_parent: impl = session.repository.rdp.get_implementation(node_ref.instance)
for parent in session.repository.get_parents(node): if impl.bl_reload_parent:
for parent in session.repository.graph.get_parents(node):
logging.debug("Refresh parent {node}") logging.debug("Refresh parent {node}")
apply(session.repository, porcelain.apply(session.repository,
parent.uuid, parent.uuid,
force=True) force=True)
@ -152,31 +152,28 @@ class DynamicRightSelectTimer(Timer):
# if an annotation exist and is tracked # if an annotation exist and is tracked
if annotation_gp and annotation_gp.uuid: if annotation_gp and annotation_gp.uuid:
registered_gp = session.repository.get_node(annotation_gp.uuid) registered_gp = session.repository.graph.get(annotation_gp.uuid)
if is_annotating(bpy.context): if is_annotating(bpy.context):
# try to get the right on it # try to get the right on it
if registered_gp.owner == RP_COMMON: if registered_gp.owner == RP_COMMON:
self._annotating = True self._annotating = True
logging.debug( logging.debug(
"Getting the right on the annotation GP") "Getting the right on the annotation GP")
session.change_owner( porcelain.lock(session.repository,
registered_gp.uuid, registered_gp.uuid,
settings.username, ignore_warnings=True,
ignore_warnings=True, affect_dependencies=False)
affect_dependencies=False)
if registered_gp.owner == settings.username: if registered_gp.owner == settings.username:
gp_node = session.repository.get_node(annotation_gp.uuid) gp_node = session.repository.graph.get(annotation_gp.uuid)
if gp_node.has_changed(): porcelain.commit(session.repository, gp_node.uuid)
session.commit(gp_node.uuid) porcelain.push(session.repository, 'origin', gp_node.uuid)
session.push(gp_node.uuid, check_data=False)
elif self._annotating: elif self._annotating:
session.change_owner( porcelain.unlock(session.repository,
registered_gp.uuid, registered_gp.uuid,
RP_COMMON, ignore_warnings=True,
ignore_warnings=True, affect_dependencies=False)
affect_dependencies=False)
current_selection = utils.get_selected_objects( current_selection = utils.get_selected_objects(
bpy.context.scene, bpy.context.scene,
@ -190,25 +187,24 @@ class DynamicRightSelectTimer(Timer):
# change old selection right to common # change old selection right to common
for obj in obj_common: for obj in obj_common:
node = session.repository.get_node(obj) node = session.repository.graph.get(obj)
if node and (node.owner == settings.username or node.owner == RP_COMMON): if node and (node.owner == settings.username or node.owner == RP_COMMON):
recursive = True recursive = True
if node.data and 'instance_type' in node.data.keys(): if node.data and 'instance_type' in node.data.keys():
recursive = node.data['instance_type'] != 'COLLECTION' recursive = node.data['instance_type'] != 'COLLECTION'
try: try:
session.change_owner( porcelain.unlock(session.repository,
node.uuid, node.uuid,
RP_COMMON, ignore_warnings=True,
ignore_warnings=True, affect_dependencies=recursive)
affect_dependencies=recursive)
except NonAuthorizedOperationError: except NonAuthorizedOperationError:
logging.warning( logging.warning(
f"Not authorized to change {node} owner") f"Not authorized to change {node} owner")
# change new selection to our # change new selection to our
for obj in obj_ours: for obj in obj_ours:
node = session.repository.get_node(obj) node = session.repository.graph.get(obj)
if node and node.owner == RP_COMMON: if node and node.owner == RP_COMMON:
recursive = True recursive = True
@ -216,11 +212,10 @@ class DynamicRightSelectTimer(Timer):
recursive = node.data['instance_type'] != 'COLLECTION' recursive = node.data['instance_type'] != 'COLLECTION'
try: try:
session.change_owner( porcelain.lock(session.repository,
node.uuid, node.uuid,
settings.username, ignore_warnings=True,
ignore_warnings=True, affect_dependencies=recursive)
affect_dependencies=recursive)
except NonAuthorizedOperationError: except NonAuthorizedOperationError:
logging.warning( logging.warning(
f"Not authorized to change {node} owner") f"Not authorized to change {node} owner")
@ -233,21 +228,19 @@ class DynamicRightSelectTimer(Timer):
'selected_objects': current_selection 'selected_objects': current_selection
} }
session.update_user_metadata(user_metadata) porcelain.update_user_metadata(session.repository, user_metadata)
logging.debug("Update selection") logging.debug("Update selection")
# Fix deselection until right managment refactoring (with Roles concepts) # Fix deselection until right managment refactoring (with Roles concepts)
if len(current_selection) == 0 : if len(current_selection) == 0 :
owned_keys = session.list( owned_keys = [k for k, v in session.repository.graph.items() if v.owner==settings.username]
filter_owner=settings.username)
for key in owned_keys: for key in owned_keys:
node = session.repository.get_node(key) node = session.repository.graph.get(key)
try: try:
session.change_owner( porcelain.unlock(session.repository,
key, key,
RP_COMMON, ignore_warnings=True,
ignore_warnings=True, affect_dependencies=True)
affect_dependencies=recursive)
except NonAuthorizedOperationError: except NonAuthorizedOperationError:
logging.warning( logging.warning(
f"Not authorized to change {key} owner") f"Not authorized to change {key} owner")
@ -255,7 +248,7 @@ class DynamicRightSelectTimer(Timer):
for obj in bpy.data.objects: for obj in bpy.data.objects:
object_uuid = getattr(obj, 'uuid', None) object_uuid = getattr(obj, 'uuid', None)
if object_uuid: if object_uuid:
is_selectable = not session.is_readonly(object_uuid) is_selectable = not session.repository.is_node_readonly(object_uuid)
if obj.hide_select != is_selectable: if obj.hide_select != is_selectable:
obj.hide_select = is_selectable obj.hide_select = is_selectable
@ -309,18 +302,18 @@ class ClientUpdate(Timer):
'frame_current': bpy.context.scene.frame_current, 'frame_current': bpy.context.scene.frame_current,
'scene_current': scene_current 'scene_current': scene_current
} }
session.update_user_metadata(metadata) porcelain.update_user_metadata(session.repository, metadata)
# Update client representation # Update client representation
# Update client current scene # Update client current scene
elif scene_current != local_user_metadata['scene_current']: elif scene_current != local_user_metadata['scene_current']:
local_user_metadata['scene_current'] = scene_current local_user_metadata['scene_current'] = scene_current
session.update_user_metadata(local_user_metadata) porcelain.update_user_metadata(session.repository, local_user_metadata)
elif 'view_corners' in local_user_metadata and current_view_corners != local_user_metadata['view_corners']: elif 'view_corners' in local_user_metadata and current_view_corners != local_user_metadata['view_corners']:
local_user_metadata['view_corners'] = current_view_corners local_user_metadata['view_corners'] = current_view_corners
local_user_metadata['view_matrix'] = get_view_matrix( local_user_metadata['view_matrix'] = get_view_matrix(
) )
session.update_user_metadata(local_user_metadata) porcelain.update_user_metadata(session.repository, local_user_metadata)
class SessionStatusUpdate(Timer): class SessionStatusUpdate(Timer):

View File

@ -443,8 +443,8 @@ class SESSION_PT_presence(bpy.types.Panel):
def draw_property(context, parent, property_uuid, level=0): def draw_property(context, parent, property_uuid, level=0):
settings = get_preferences() settings = get_preferences()
runtime_settings = context.window_manager.session runtime_settings = context.window_manager.session
item = session.repository.get_node(property_uuid) item = session.repository.graph.get(property_uuid)
type_id = item.data.get('type_id')
area_msg = parent.row(align=True) area_msg = parent.row(align=True)
if item.state == ERROR: if item.state == ERROR:
@ -455,11 +455,10 @@ def draw_property(context, parent, property_uuid, level=0):
line = area_msg.box() line = area_msg.box()
name = item.data['name'] if item.data else item.uuid name = item.data['name'] if item.data else item.uuid
icon = settings.supported_datablocks[type_id].icon if type_id else 'ERROR'
detail_item_box = line.row(align=True) detail_item_box = line.row(align=True)
detail_item_box.label(text="", detail_item_box.label(text="", icon=icon)
icon=settings.supported_datablocks[item.str_type].icon)
detail_item_box.label(text=f"{name}") detail_item_box.label(text=f"{name}")
# Operations # Operations
@ -546,40 +545,32 @@ class SESSION_PT_repository(bpy.types.Panel):
else: else:
row.operator('session.save', icon="FILE_TICK") row.operator('session.save', icon="FILE_TICK")
flow = layout.grid_flow( box = layout.box()
row_major=True, row = box.row()
columns=0, row.prop(runtime_settings, "filter_owned", text="Show only owned Nodes", icon_only=True, icon="DECORATE_UNLOCKED")
even_columns=True, row = box.row()
even_rows=False, row.prop(runtime_settings, "filter_name", text="Filter")
align=True) row = box.row()
for item in settings.supported_datablocks:
col = flow.column(align=True)
col.prop(item, "use_as_filter", text="", icon=item.icon)
row = layout.row(align=True)
row.prop(runtime_settings, "filter_owned", text="Show only owned")
row = layout.row(align=True)
# Properties # Properties
types_filter = [t.type_name for t in settings.supported_datablocks owned_nodes = [k for k, v in session.repository.graph.items() if v.owner==settings.username]
if t.use_as_filter]
key_to_filter = session.list( filtered_node = owned_nodes if runtime_settings.filter_owned else session.repository.graph.keys()
filter_owner=settings.username) if runtime_settings.filter_owned else session.list()
client_keys = [key for key in key_to_filter if runtime_settings.filter_name:
if session.repository.get_node(key).str_type for node_id in filtered_node:
in types_filter] node_instance = session.repository.graph.get(node_id)
name = node_instance.data.get('name')
if runtime_settings.filter_name not in name:
filtered_node.remove(node_id)
if client_keys: if filtered_node:
col = layout.column(align=True) col = layout.column(align=True)
for key in client_keys: for key in filtered_node:
draw_property(context, col, key) draw_property(context, col, key)
else: else:
row.label(text="Empty") layout.row().label(text="Empty")
elif session.state == STATE_LOBBY and usr and usr['admin']: elif session.state == STATE_LOBBY and usr and usr['admin']:
row.operator("session.init", icon='TOOL_SETTINGS', text="Init") row.operator("session.init", icon='TOOL_SETTINGS', text="Init")

View File

@ -101,11 +101,17 @@ def get_state_str(state):
def clean_scene(): def clean_scene():
for type_name in dir(bpy.data): to_delete = [f for f in dir(bpy.data) if f not in ['brushes', 'palettes']]
for type_name in to_delete:
try: try:
sub_collection_to_avoid = [bpy.data.linestyles['LineStyle'], bpy.data.materials['Dots Stroke']]
type_collection = getattr(bpy.data, type_name) type_collection = getattr(bpy.data, type_name)
for item in type_collection: items_to_remove = [i for i in type_collection if i not in sub_collection_to_avoid]
type_collection.remove(item) for item in items_to_remove:
try:
type_collection.remove(item)
except:
continue
except: except:
continue continue

View File

@ -1,4 +1,4 @@
import re import re
init_py = open("multi_user/__init__.py").read() init_py = open("multi_user/libs/replication/replication/__init__.py").read()
print(re.search("\d+\.\d+\.\d+\w\d+|\d+\.\d+\.\d+", init_py).group(0)) print(re.search("\d+\.\d+\.\d+\w\d+|\d+\.\d+\.\d+", init_py).group(0))

View File

@ -13,7 +13,7 @@ def main():
if len(sys.argv) > 2: if len(sys.argv) > 2:
blender_rev = sys.argv[2] blender_rev = sys.argv[2]
else: else:
blender_rev = "2.92.0" blender_rev = "2.93.0"
try: try:
exit_val = BAT.test_blender_addon(addon_path=addon, blender_revision=blender_rev) exit_val = BAT.test_blender_addon(addon_path=addon, blender_revision=blender_rev)

View File

@ -32,11 +32,11 @@ def test_action(clear_blend):
# Test # Test
implementation = BlAction() implementation = BlAction()
expected = implementation._dump(datablock) expected = implementation.dump(datablock)
bpy.data.actions.remove(datablock) bpy.data.actions.remove(datablock)
test = implementation._construct(expected) test = implementation.construct(expected)
implementation._load(expected, test) implementation.load(expected, test)
result = implementation._dump(test) result = implementation.dump(test)
assert not DeepDiff(expected, result) assert not DeepDiff(expected, result)

View File

@ -12,11 +12,11 @@ def test_armature(clear_blend):
datablock = bpy.data.armatures[0] datablock = bpy.data.armatures[0]
implementation = BlArmature() implementation = BlArmature()
expected = implementation._dump(datablock) expected = implementation.dump(datablock)
bpy.data.armatures.remove(datablock) bpy.data.armatures.remove(datablock)
test = implementation._construct(expected) test = implementation.construct(expected)
implementation._load(expected, test) implementation.load(expected, test)
result = implementation._dump(test) result = implementation.dump(test)
assert not DeepDiff(expected, result) assert not DeepDiff(expected, result)

View File

@ -15,11 +15,11 @@ def test_camera(clear_blend, camera_type):
datablock.type = camera_type datablock.type = camera_type
camera_dumper = BlCamera() camera_dumper = BlCamera()
expected = camera_dumper._dump(datablock) expected = camera_dumper.dump(datablock)
bpy.data.cameras.remove(datablock) bpy.data.cameras.remove(datablock)
test = camera_dumper._construct(expected) test = camera_dumper.construct(expected)
camera_dumper._load(expected, test) camera_dumper.load(expected, test)
result = camera_dumper._dump(test) result = camera_dumper.dump(test)
assert not DeepDiff(expected, result) assert not DeepDiff(expected, result)

View File

@ -23,11 +23,11 @@ def test_collection(clear_blend):
# Test # Test
implementation = BlCollection() implementation = BlCollection()
expected = implementation._dump(datablock) expected = implementation.dump(datablock)
bpy.data.collections.remove(datablock) bpy.data.collections.remove(datablock)
test = implementation._construct(expected) test = implementation.construct(expected)
implementation._load(expected, test) implementation.load(expected, test)
result = implementation._dump(test) result = implementation.dump(test)
assert not DeepDiff(expected, result) assert not DeepDiff(expected, result)

View File

@ -19,11 +19,11 @@ def test_curve(clear_blend, curve_type):
datablock = bpy.data.curves[0] datablock = bpy.data.curves[0]
implementation = BlCurve() implementation = BlCurve()
expected = implementation._dump(datablock) expected = implementation.dump(datablock)
bpy.data.curves.remove(datablock) bpy.data.curves.remove(datablock)
test = implementation._construct(expected) test = implementation.construct(expected)
implementation._load(expected, test) implementation.load(expected, test)
result = implementation._dump(test) result = implementation.dump(test)
assert not DeepDiff(expected, result) assert not DeepDiff(expected, result)

View File

@ -13,11 +13,11 @@ def test_gpencil(clear_blend):
datablock = bpy.data.grease_pencils[0] datablock = bpy.data.grease_pencils[0]
implementation = BlGpencil() implementation = BlGpencil()
expected = implementation._dump(datablock) expected = implementation.dump(datablock)
bpy.data.grease_pencils.remove(datablock) bpy.data.grease_pencils.remove(datablock)
test = implementation._construct(expected) test = implementation.construct(expected)
implementation._load(expected, test) implementation.load(expected, test)
result = implementation._dump(test) result = implementation.dump(test)
assert not DeepDiff(expected, result) assert not DeepDiff(expected, result)

View File

@ -13,11 +13,11 @@ def test_lattice(clear_blend):
datablock = bpy.data.lattices[0] datablock = bpy.data.lattices[0]
implementation = BlLattice() implementation = BlLattice()
expected = implementation._dump(datablock) expected = implementation.dump(datablock)
bpy.data.lattices.remove(datablock) bpy.data.lattices.remove(datablock)
test = implementation._construct(expected) test = implementation.construct(expected)
implementation._load(expected, test) implementation.load(expected, test)
result = implementation._dump(test) result = implementation.dump(test)
assert not DeepDiff(expected, result) assert not DeepDiff(expected, result)

View File

@ -14,11 +14,11 @@ def test_lightprobes(clear_blend, lightprobe_type):
blender_light = bpy.data.lightprobes[0] blender_light = bpy.data.lightprobes[0]
lightprobe_dumper = BlLightprobe() lightprobe_dumper = BlLightprobe()
expected = lightprobe_dumper._dump(blender_light) expected = lightprobe_dumper.dump(blender_light)
bpy.data.lightprobes.remove(blender_light) bpy.data.lightprobes.remove(blender_light)
test = lightprobe_dumper._construct(expected) test = lightprobe_dumper.construct(expected)
lightprobe_dumper._load(expected, test) lightprobe_dumper.load(expected, test)
result = lightprobe_dumper._dump(test) result = lightprobe_dumper.dump(test)
assert not DeepDiff(expected, result) assert not DeepDiff(expected, result)

View File

@ -13,11 +13,11 @@ def test_light(clear_blend, light_type):
blender_light = bpy.data.lights[0] blender_light = bpy.data.lights[0]
light_dumper = BlLight() light_dumper = BlLight()
expected = light_dumper._dump(blender_light) expected = light_dumper.dump(blender_light)
bpy.data.lights.remove(blender_light) bpy.data.lights.remove(blender_light)
test = light_dumper._construct(expected) test = light_dumper.construct(expected)
light_dumper._load(expected, test) light_dumper.load(expected, test)
result = light_dumper._dump(test) result = light_dumper.dump(test)
assert not DeepDiff(expected, result) assert not DeepDiff(expected, result)

View File

@ -17,12 +17,12 @@ def test_material_nodes(clear_blend):
datablock.node_tree.nodes.new(ntype) datablock.node_tree.nodes.new(ntype)
implementation = BlMaterial() implementation = BlMaterial()
expected = implementation._dump(datablock) expected = implementation.dump(datablock)
bpy.data.materials.remove(datablock) bpy.data.materials.remove(datablock)
test = implementation._construct(expected) test = implementation.construct(expected)
implementation._load(expected, test) implementation.load(expected, test)
result = implementation._dump(test) result = implementation.dump(test)
assert not DeepDiff(expected, result) assert not DeepDiff(expected, result)
@ -32,11 +32,11 @@ def test_material_gpencil(clear_blend):
bpy.data.materials.create_gpencil_data(datablock) bpy.data.materials.create_gpencil_data(datablock)
implementation = BlMaterial() implementation = BlMaterial()
expected = implementation._dump(datablock) expected = implementation.dump(datablock)
bpy.data.materials.remove(datablock) bpy.data.materials.remove(datablock)
test = implementation._construct(expected) test = implementation.construct(expected)
implementation._load(expected, test) implementation.load(expected, test)
result = implementation._dump(test) result = implementation.dump(test)
assert not DeepDiff(expected, result) assert not DeepDiff(expected, result)

View File

@ -18,11 +18,11 @@ def test_mesh(clear_blend, mesh_type):
# Test # Test
implementation = BlMesh() implementation = BlMesh()
expected = implementation._dump(datablock) expected = implementation.dump(datablock)
bpy.data.meshes.remove(datablock) bpy.data.meshes.remove(datablock)
test = implementation._construct(expected) test = implementation.construct(expected)
implementation._load(expected, test) implementation.load(expected, test)
result = implementation._dump(test) result = implementation.dump(test)
assert not DeepDiff(expected, result) assert not DeepDiff(expected, result)

View File

@ -13,11 +13,11 @@ def test_metaball(clear_blend, metaballs_type):
datablock = bpy.data.metaballs[0] datablock = bpy.data.metaballs[0]
dumper = BlMetaball() dumper = BlMetaball()
expected = dumper._dump(datablock) expected = dumper.dump(datablock)
bpy.data.metaballs.remove(datablock) bpy.data.metaballs.remove(datablock)
test = dumper._construct(expected) test = dumper.construct(expected)
dumper._load(expected, test) dumper.load(expected, test)
result = dumper._dump(test) result = dumper.dump(test)
assert not DeepDiff(expected, result) assert not DeepDiff(expected, result)

View File

@ -65,11 +65,11 @@ def test_object(clear_blend):
datablock.shape_key_add(name='shape2') datablock.shape_key_add(name='shape2')
implementation = BlObject() implementation = BlObject()
expected = implementation._dump(datablock) expected = implementation.dump(datablock)
bpy.data.objects.remove(datablock) bpy.data.objects.remove(datablock)
test = implementation._construct(expected) test = implementation.construct(expected)
implementation._load(expected, test) implementation.load(expected, test)
result = implementation._dump(test) result = implementation.dump(test)
print(DeepDiff(expected, result)) print(DeepDiff(expected, result))
assert not DeepDiff(expected, result) assert not DeepDiff(expected, result)

View File

@ -15,11 +15,11 @@ def test_scene(clear_blend):
datablock.view_settings.use_curve_mapping = True datablock.view_settings.use_curve_mapping = True
# Test # Test
implementation = BlScene() implementation = BlScene()
expected = implementation._dump(datablock) expected = implementation.dump(datablock)
bpy.data.scenes.remove(datablock) bpy.data.scenes.remove(datablock)
test = implementation._construct(expected) test = implementation.construct(expected)
implementation._load(expected, test) implementation.load(expected, test)
result = implementation._dump(test) result = implementation.dump(test)
assert not DeepDiff(expected, result) assert not DeepDiff(expected, result)

View File

@ -12,11 +12,11 @@ def test_speaker(clear_blend):
datablock = bpy.data.speakers[0] datablock = bpy.data.speakers[0]
implementation = BlSpeaker() implementation = BlSpeaker()
expected = implementation._dump(datablock) expected = implementation.dump(datablock)
bpy.data.speakers.remove(datablock) bpy.data.speakers.remove(datablock)
test = implementation._construct(expected) test = implementation.construct(expected)
implementation._load(expected, test) implementation.load(expected, test)
result = implementation._dump(test) result = implementation.dump(test)
assert not DeepDiff(expected, result) assert not DeepDiff(expected, result)

View File

@ -14,11 +14,11 @@ def test_texture(clear_blend, texture_type):
datablock = bpy.data.textures.new('test', texture_type) datablock = bpy.data.textures.new('test', texture_type)
implementation = BlTexture() implementation = BlTexture()
expected = implementation._dump(datablock) expected = implementation.dump(datablock)
bpy.data.textures.remove(datablock) bpy.data.textures.remove(datablock)
test = implementation._construct(expected) test = implementation.construct(expected)
implementation._load(expected, test) implementation.load(expected, test)
result = implementation._dump(test) result = implementation.dump(test)
assert not DeepDiff(expected, result) assert not DeepDiff(expected, result)

View File

@ -11,11 +11,11 @@ def test_volume(clear_blend):
datablock = bpy.data.volumes.new("Test") datablock = bpy.data.volumes.new("Test")
implementation = BlVolume() implementation = BlVolume()
expected = implementation._dump(datablock) expected = implementation.dump(datablock)
bpy.data.volumes.remove(datablock) bpy.data.volumes.remove(datablock)
test = implementation._construct(expected) test = implementation.construct(expected)
implementation._load(expected, test) implementation.load(expected, test)
result = implementation._dump(test) result = implementation.dump(test)
assert not DeepDiff(expected, result) assert not DeepDiff(expected, result)

View File

@ -12,11 +12,11 @@ def test_world(clear_blend):
datablock.use_nodes = True datablock.use_nodes = True
implementation = BlWorld() implementation = BlWorld()
expected = implementation._dump(datablock) expected = implementation.dump(datablock)
bpy.data.worlds.remove(datablock) bpy.data.worlds.remove(datablock)
test = implementation._construct(expected) test = implementation.construct(expected)
implementation._load(expected, test) implementation.load(expected, test)
result = implementation._dump(test) result = implementation.dump(test)
assert not DeepDiff(expected, result) assert not DeepDiff(expected, result)