Compare commits

..

2 Commits

Author SHA1 Message Date
c06febed45 fix: compositor working 2021-06-18 17:20:12 +02:00
8c3e510231 feat: add bl_compositor + node_tree 2021-06-17 16:10:42 +02:00
58 changed files with 1377 additions and 2468 deletions

View File

@ -186,34 +186,4 @@ All notable changes to this project will be documented in this file.
- Exception access violation during Undo/Redo
- Sync missing armature bone Roll
- Sync missing driver data_path
- Constraint replication
## [0.4.0] - 2021-07-20
### Added
- Connection preset system (@Kysios)
- Display connected users active mode (users pannel and viewport) (@Kysios)
- Delta-based replication
- Sync timeline marker
- Sync images settings (@Kysios)
- Sync parent relation type (@Kysios)
- Sync uv project modifier
- Sync FCurves modifiers
### Changed
- User selection optimizations (draw and sync) (@Kysios)
- Improved shapekey syncing performances
- Improved gpencil syncing performances
- Integrate replication as a submodule
- The dependencies are now installed in a folder(blender addon folder) that no longer requires administrative rights
- Presence overlay UI optimization (@Kysios)
### Fixed
- User selection bounding box glitches for non-mesh objects (@Kysios)
- Transforms replication for animated objects
- GPencil fill stroke
- Sculpt and GPencil brushes deleted when joining a session (@Kysios)
- Auto-updater doesn't work for master and develop builds
- Constraint replication

View File

@ -11,8 +11,9 @@ This tool aims to allow multiple users to work on the same scene over the networ
## Quick installation
1. Download [latest build](https://gitlab.com/slumber/multi-user/-/jobs/artifacts/develop/download?job=build) or [stable build](https://gitlab.com/slumber/multi-user/-/jobs/artifacts/master/download?job=build).
2. Install last_version.zip from your addon preferences.
1. Download latest release [multi_user.zip](https://gitlab.com/slumber/multi-user/-/jobs/artifacts/master/download?job=build).
2. Run blender as administrator (dependencies installation).
3. Install last_version.zip from your addon preferences.
[Dependencies](#dependencies) will be automatically added to your blender python during installation.
@ -28,35 +29,35 @@ See the [troubleshooting guide](https://slumber.gitlab.io/multi-user/getting_sta
Currently, not all data-block are supported for replication over the wire. The following list summarizes the status for each ones.
| Name | Status | Comment |
| -------------- | :----: | :---------------------------------------------------------------------: |
| action | ✔️ | |
| camera | ✔️ | |
| collection | ✔️ | |
| gpencil | ✔️ | |
| image | ✔️ | |
| mesh | ✔️ | |
| material | ✔️ | |
| node_groups | ✔️ | Material & Geometry only |
| geometry nodes | ✔️ | |
| metaball | ✔️ | |
| object | ✔️ | |
| texts | ✔️ | |
| scene | ✔️ | |
| world | ✔️ | |
| volumes | ✔️ | |
| lightprobes | ✔️ | |
| physics | ✔️ | |
| textures | ✔️ | |
| curve | ❗ | Nurbs surfaces not supported |
| armature | ❗ | Only for Mesh. [Planned for GPencil](https://gitlab.com/slumber/multi-user/-/issues/161). Not stable yet |
| particles | ❗ | The cache isn't syncing. |
| speakers | ❗ | [Partial](https://gitlab.com/slumber/multi-user/-/issues/65) |
| vse | ❗ | Mask and Clip not supported yet |
| libraries | | |
| nla | ❌ | |
| texts | ❌ | [Planned for v0.5.0](https://gitlab.com/slumber/multi-user/-/issues/81) |
| compositing | ❌ | [Planned for v0.5.0](https://gitlab.com/slumber/multi-user/-/issues/46) |
| Name | Status | Comment |
| -------------- | :----: | :----------------------------------------------------------: |
| action | ✔️ | |
| camera | ✔️ | |
| collection | ✔️ | |
| gpencil | ✔️ | |
| image | ✔️ | |
| mesh | ✔️ | |
| material | ✔️ | |
| node_groups | ✔️ | Material & Geometry only |
| geometry nodes | ✔️ | |
| metaball | ✔️ | |
| object | ✔️ | |
| texts | ✔️ | |
| scene | ✔️ | |
| world | ✔️ | |
| 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. |
| speakers | ❗ | [Partial](https://gitlab.com/slumber/multi-user/-/issues/65) |
| vse | ❗ | Mask and Clip not supported yet |
| 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

@ -19,10 +19,10 @@ import sys
project = 'multi-user'
copyright = '2020, Swann Martinez'
author = 'Swann Martinez, Poochy, Fabian'
author = 'Swann Martinez, with contributions from Poochy'
# The full version, including alpha/beta/rc tags
release = '0.5.0-develop'
release = '0.2.0'
# -- General configuration ---------------------------------------------------

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 365 KiB

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 18 KiB

View File

@ -215,10 +215,8 @@ One of the most vital tools is the **Online user panel**. It lists all connected
users' information including your own:
* **Role** : if a user is an admin or a regular user.
* **Username** : Name of the user.
* **Mode** : User's active editing mode (edit_mesh, paint,etc.).
* **Frame**: When (on which frame) the user is working.
* **Location**: Where the user is actually working.
* **Frame**: When (on which frame) the user is working.
* **Ping**: user's connection delay in milliseconds
.. figure:: img/quickstart_users.png
@ -275,7 +273,6 @@ it draw users' related information in your viewport such as:
* Username
* User point of view
* User active mode
* User selection
.. figure:: img/quickstart_presence.png

View File

@ -212,14 +212,14 @@ You can run the dedicated server on any platform by following these steps:
.. code-block:: bash
replication.serve
replication.server
.. hint::
You can also specify a custom **port** (-p), **timeout** (-t), **admin password** (-pwd), **log level (ERROR, WARNING, INFO or DEBUG)** (-l) and **log file** (-lf) with the following optional arguments
.. code-block:: bash
replication.serve -p 5555 -pwd admin -t 5000 -l INFO -lf server.log
replication.server -p 5555 -pwd admin -t 5000 -l INFO -lf server.log
Here, for example, a server is instantiated on port 5555, with password 'admin', a 5 second timeout, and logging enabled.
@ -562,7 +562,7 @@ The default Docker image essentially runs the equivalent of:
.. code-block:: bash
replication.serve -pwd admin -p 5555 -t 5000 -l DEBUG -lf multiuser_server.log
replication.server -pwd admin -p 5555 -t 5000 -l DEBUG -lf multiuser_server.log
This means the server will be launched with 'admin' as the administrator password, run on ports 5555:5558, use a timeout of 5 seconds, verbose 'DEBUG' log level, and with log files written to 'multiuser_server.log'. See :ref:`cmd-line` for a description of optional parameters.
@ -572,7 +572,7 @@ For example, I would like to launch my server with a different administrator pas
.. code-block:: bash
replication.serve -pwd supersecretpassword -p 5555 -t 3000 -l DEBUG -lf logname.log
python3 -m replication.server -pwd supersecretpassword -p 5555 -t 3000 -l DEBUG -lf logname.log
Now, my configuration should look like this:
@ -691,7 +691,7 @@ We're finally ready to launch the server. Simply run:
.. code-block:: bash
replication.serve -p 5555 -pwd admin -t 5000 -l INFO -lf server.log
python3 -m replication.server -p 5555 -pwd admin -t 5000 -l INFO -lf server.log
See :ref:`cmd-line` for a description of optional parameters

View File

@ -19,9 +19,9 @@
bl_info = {
"name": "Multi-User",
"author": "Swann Martinez",
"version": (0, 4, 0),
"version": (0, 5, 0),
"description": "Enable real-time collaborative workflow inside blender",
"blender": (2, 82, 0),
"blender": (2, 93, 0),
"location": "3D View > Sidebar > Multi-User tab",
"warning": "Unstable addon, use it at your own risks",
"category": "Collaboration",
@ -59,9 +59,7 @@ def register():
from . import presence
from . import operators
from . import handlers
from . import ui
from . import icons
from . import preferences
from . import addon_updater_ops
@ -69,9 +67,7 @@ def register():
addon_updater_ops.register(bl_info)
presence.register()
operators.register()
handlers.register()
ui.register()
icons.register()
except ModuleNotFoundError as e:
raise Exception(module_error_msg)
logging.error(module_error_msg)
@ -85,28 +81,21 @@ def register():
type=preferences.SessionUser
)
bpy.types.WindowManager.user_index = bpy.props.IntProperty()
bpy.types.WindowManager.server_index = bpy.props.IntProperty()
bpy.types.TOPBAR_MT_file_import.append(operators.menu_func_import)
bpy.types.TOPBAR_MT_file_export.append(operators.menu_func_export)
def unregister():
from . import presence
from . import operators
from . import handlers
from . import ui
from . import icons
from . import preferences
from . import addon_updater_ops
bpy.types.TOPBAR_MT_file_import.remove(operators.menu_func_import)
bpy.types.TOPBAR_MT_file_export.remove(operators.menu_func_export)
presence.unregister()
addon_updater_ops.unregister()
ui.unregister()
icons.unregister()
handlers.unregister()
operators.unregister()
preferences.unregister()
@ -114,6 +103,5 @@ def unregister():
del bpy.types.ID.uuid
del bpy.types.WindowManager.online_users
del bpy.types.WindowManager.user_index
del bpy.types.WindowManager.server_index
environment.unregister()

View File

@ -41,9 +41,10 @@ __all__ = [
'bl_node_group',
'bl_texture',
"bl_particle",
# 'bl_compositor',
] # Order here defines execution order
if bpy.app.version >= (2,91,0):
if bpy.app.version[1] >= 91:
__all__.append('bl_volume')
from . import *

View File

@ -219,7 +219,7 @@ def load_fcurve(fcurve_data, fcurve):
def dump_animation_data(datablock):
animation_data = {}
if has_action(datablock):
animation_data['action'] = datablock.animation_data.action.uuid
animation_data['action'] = datablock.animation_data.action.name
if has_driver(datablock):
animation_data['drivers'] = []
for driver in datablock.animation_data.drivers:
@ -241,10 +241,8 @@ def load_animation_data(animation_data, datablock):
for driver in animation_data['drivers']:
load_driver(datablock, driver)
action = animation_data.get('action')
if action:
action = resolve_datablock_from_uuid(action, bpy.data.actions)
datablock.animation_data.action = action
if 'action' in animation_data:
datablock.animation_data.action = bpy.data.actions[animation_data['action']]
elif datablock.animation_data.action:
datablock.animation_data.action = None
@ -261,8 +259,6 @@ def resolve_animation_dependencies(datablock):
class BlAction(ReplicatedDatablock):
use_delta = True
bl_id = "actions"
bl_class = bpy.types.Action
bl_check_common = False

View File

@ -37,8 +37,6 @@ def get_roll(bone: bpy.types.Bone) -> float:
class BlArmature(ReplicatedDatablock):
use_delta = True
bl_id = "armatures"
bl_class = bpy.types.Armature
bl_check_common = False

View File

@ -26,8 +26,6 @@ from .bl_action import dump_animation_data, load_animation_data, resolve_animati
class BlCamera(ReplicatedDatablock):
use_delta = True
bl_id = "cameras"
bl_class = bpy.types.Camera
bl_check_common = False
@ -56,7 +54,7 @@ class BlCamera(ReplicatedDatablock):
background_images = data.get('background_images')
datablock.background_images.clear()
# TODO: Use image uuid
if background_images:
for img_name, img_data in background_images.items():
img_id = img_data.get('image')

View File

@ -0,0 +1,81 @@
# ##### 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
import logging
import re
from uuid import uuid4
from .dump_anything import Loader, Dumper
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
from .node_tree import load_node_tree, dump_node_tree, get_node_tree_dependencies
class BlCompositor(ReplicatedDatablock):
bl_id = "compositor"
bl_class = bpy.types.CompositorNodeTree
bl_check_common = True
bl_icon = 'COMPOSITOR_NODE'
bl_reload_parent = False
@staticmethod
def construct(data: dict) -> object:
return bpy.data.scenes["Scene"].node_tree # TODO: resolve_datablock_from_uuid for multiple scenes
@staticmethod
def load(data: dict, datablock: object):
load_animation_data(data.get('animation_data'), datablock)
loader = Loader()
loader.load(datablock, data)
load_node_tree(data['node_tree'], datablock)
@staticmethod
def dump(datablock: object) -> dict:
comp_dumper = Dumper()
comp_dumper.depth = 1
comp_dumper.include_filter = [
'use_nodes',
'name',
]
data = comp_dumper.dump(datablock)
data['node_tree'] = dump_node_tree(datablock)
data['animation_data'] = dump_animation_data(datablock)
return data
@staticmethod
def resolve(data: dict) -> object:
uuid = data.get('uuid')
return resolve_datablock_from_uuid(uuid, bpy.data.scenes["Scene"].node_tree)
@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.CompositorNodeTree
_class = BlCompositor

View File

@ -137,8 +137,6 @@ SPLINE_METADATA = [
class BlCurve(ReplicatedDatablock):
use_delta = True
bl_id = "curves"
bl_class = bpy.types.Curve
bl_check_common = False

View File

@ -28,8 +28,7 @@ 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
from ..timers import is_annotating
from .bl_material import load_materials_slots, dump_materials_slots
STROKE_POINT = [
'co',
@ -53,12 +52,12 @@ STROKE = [
"uv_translation",
"vertex_color_fill",
]
if bpy.app.version >= (2,91,0):
if bpy.app.version[1] >= 91:
STROKE.append('use_cyclic')
else:
STROKE.append('draw_cyclic')
if bpy.app.version >= (2,83,0):
if bpy.app.version[1] >= 83:
STROKE_POINT.append('vertex_color')
def dump_stroke(stroke):
@ -66,9 +65,36 @@ def dump_stroke(stroke):
:param stroke: target grease pencil stroke
:type stroke: bpy.types.GPencilStroke
:return: (p_count, p_data)
:return: dict
"""
return (len(stroke.points), np_dump_collection(stroke.points, STROKE_POINT))
assert(stroke)
dumper = Dumper()
dumper.include_filter = [
"aspect",
"display_mode",
"draw_cyclic",
"end_cap_mode",
"hardeness",
"line_width",
"material_index",
"start_cap_mode",
"uv_rotation",
"uv_scale",
"uv_translation",
"vertex_color_fill",
]
dumped_stroke = dumper.dump(stroke)
# Stoke points
p_count = len(stroke.points)
dumped_stroke['p_count'] = p_count
dumped_stroke['points'] = np_dump_collection(stroke.points, STROKE_POINT)
# TODO: uv_factor, uv_rotation
return dumped_stroke
def load_stroke(stroke_data, stroke):
@ -81,12 +107,12 @@ def load_stroke(stroke_data, stroke):
"""
assert(stroke and stroke_data)
stroke.points.add(stroke_data[0])
np_load_collection(stroke_data[1], stroke.points, STROKE_POINT)
stroke.points.add(stroke_data["p_count"])
np_load_collection(stroke_data['points'], stroke.points, STROKE_POINT)
# HACK: Temporary fix to trigger a BKE_gpencil_stroke_geometry_update to
# fix fill issues
stroke.uv_scale = 1.0
stroke.uv_scale = stroke_data["uv_scale"]
def dump_frame(frame):
@ -121,12 +147,10 @@ def load_frame(frame_data, frame):
assert(frame and frame_data)
# Load stroke points
for stroke_data in frame_data['strokes_points']:
target_stroke = frame.strokes.new()
load_stroke(stroke_data, target_stroke)
# Load stroke metadata
np_load_collection(frame_data['strokes'], frame.strokes, STROKE)
@ -146,6 +170,7 @@ def dump_layer(layer):
'opacity',
'channel_color',
'color',
# 'thickness', #TODO: enabling only for annotation
'tint_color',
'tint_factor',
'vertex_paint_opacity',
@ -162,7 +187,7 @@ def dump_layer(layer):
'hide',
'annotation_hide',
'lock',
'lock_frame',
# 'lock_frame',
# 'lock_material',
# 'use_mask_layer',
'use_lights',
@ -170,13 +195,12 @@ def dump_layer(layer):
'select',
'show_points',
'show_in_front',
# 'thickness'
# 'parent',
# 'parent_type',
# 'parent_bone',
# 'matrix_inverse',
]
if layer.thickness != 0:
if layer.id_data.is_annotation:
dumper.include_filter.append('thickness')
dumped_layer = dumper.dump(layer)
@ -231,10 +255,10 @@ class BlGpencil(ReplicatedDatablock):
@staticmethod
def load(data: dict, datablock: object):
# MATERIAL SLOTS
src_materials = data.get('materials', None)
if src_materials:
load_materials_slots(src_materials, datablock.materials)
datablock.materials.clear()
if "materials" in data.keys():
for mat in data['materials']:
datablock.materials.append(bpy.data.materials[mat])
loader = Loader()
loader.load(datablock, data)
@ -262,6 +286,7 @@ class BlGpencil(ReplicatedDatablock):
dumper = Dumper()
dumper.depth = 2
dumper.include_filter = [
'materials',
'name',
'zdepth_offset',
'stroke_thickness_space',
@ -269,7 +294,7 @@ class BlGpencil(ReplicatedDatablock):
'stroke_depth_order'
]
data = dumper.dump(datablock)
data['materials'] = dump_materials_slots(datablock.materials)
data['layers'] = {}
for layer in datablock.layers:
@ -298,8 +323,7 @@ class BlGpencil(ReplicatedDatablock):
return bpy.context.mode == 'OBJECT' \
or layer_changed(datablock, data) \
or frame_changed(data) \
or get_preferences().sync_flags.sync_during_editmode \
or is_annotating(bpy.context)
or get_preferences().sync_flags.sync_during_editmode
_type = bpy.types.GreasePencil
_class = BlGpencil

View File

@ -69,12 +69,11 @@ class BlImage(ReplicatedDatablock):
@staticmethod
def load(data: dict, datablock: object):
loader = Loader()
loader.load(datablock, data)
loader.load(data, datablock)
# datablock.name = data.get('name')
datablock.source = 'FILE'
datablock.filepath_raw = get_filepath(data['filename'])
color_space_name = data.get("colorspace")
color_space_name = data["colorspace_settings"]["name"]
if color_space_name:
datablock.colorspace_settings.name = color_space_name
@ -93,10 +92,12 @@ class BlImage(ReplicatedDatablock):
"name",
# 'source',
'size',
'alpha_mode']
'height',
'alpha',
'float_buffer',
'alpha_mode',
'colorspace_settings']
data.update(dumper.dump(datablock))
data['colorspace'] = datablock.colorspace_settings.name
return data
@staticmethod
@ -131,7 +132,10 @@ class BlImage(ReplicatedDatablock):
if datablock.is_dirty:
datablock.save()
return True
if not data or (datablock and (datablock.name != data.get('name'))):
return True
else:
return False
_type = bpy.types.Image
_class = BlImage

View File

@ -29,8 +29,6 @@ POINT = ['co', 'weight_softbody', 'co_deform']
class BlLattice(ReplicatedDatablock):
use_delta = True
bl_id = "lattices"
bl_class = bpy.types.Lattice
bl_check_common = False

View File

@ -26,8 +26,6 @@ from .bl_action import dump_animation_data, load_animation_data, resolve_animati
class BlLight(ReplicatedDatablock):
use_delta = True
bl_id = "lights"
bl_class = bpy.types.Light
bl_check_common = False

View File

@ -25,8 +25,6 @@ from replication.protocol import ReplicatedDatablock
from .bl_datablock import resolve_datablock_from_uuid
class BlLightprobe(ReplicatedDatablock):
use_delta = True
bl_id = "lightprobes"
bl_class = bpy.types.LightProbe
bl_check_common = False
@ -37,7 +35,7 @@ class BlLightprobe(ReplicatedDatablock):
def construct(data: dict) -> object:
type = 'CUBE' if data['type'] == 'CUBEMAP' else data['type']
# See https://developer.blender.org/D6396
if bpy.app.version >= (2,83,0):
if bpy.app.version[1] >= 83:
return bpy.data.lightprobes.new(data["name"], type)
else:
logging.warning("Lightprobe replication only supported since 2.83. See https://developer.blender.org/D6396")
@ -49,7 +47,7 @@ class BlLightprobe(ReplicatedDatablock):
@staticmethod
def dump(datablock: object) -> dict:
if bpy.app.version < (2,83,0):
if bpy.app.version[1] < 83:
logging.warning("Lightprobe replication only supported since 2.83. See https://developer.blender.org/D6396")
dumper = Dumper()

View File

@ -28,341 +28,7 @@ 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*)\]')
IGNORED_SOCKETS = ['GEOMETRY', 'SHADER', 'CUSTOM']
def load_node(node_data: dict, node_tree: bpy.types.ShaderNodeTree):
""" Load a node into a node_tree from a dict
:arg node_data: dumped node data
:type node_data: dict
:arg node_tree: target node_tree
:type node_tree: bpy.types.NodeTree
"""
loader = Loader()
target_node = node_tree.nodes.new(type=node_data["bl_idname"])
target_node.select = False
loader.load(target_node, node_data)
image_uuid = node_data.get('image_uuid', None)
node_tree_uuid = node_data.get('node_tree_uuid', None)
if image_uuid and not target_node.image:
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:
target_node.node_tree = get_datablock_from_uuid(node_tree_uuid, None)
inputs_data = node_data.get('inputs')
if inputs_data:
inputs = [i for i in target_node.inputs if i.type not in IGNORED_SOCKETS]
for idx, inpt in enumerate(inputs):
if idx < len(inputs_data) and hasattr(inpt, "default_value"):
loaded_input = inputs_data[idx]
try:
if inpt.type in ['OBJECT', 'COLLECTION']:
inpt.default_value = get_datablock_from_uuid(loaded_input, None)
else:
inpt.default_value = loaded_input
except Exception as e:
logging.warning(f"Node {target_node.name} input {inpt.name} parameter not supported, skipping ({e})")
else:
logging.warning(f"Node {target_node.name} input length mismatch.")
outputs_data = node_data.get('outputs')
if outputs_data:
outputs = [o for o in target_node.outputs if o.type not in IGNORED_SOCKETS]
for idx, output in enumerate(outputs):
if idx < len(outputs_data) and hasattr(output, "default_value"):
loaded_output = outputs_data[idx]
try:
if output.type in ['OBJECT', 'COLLECTION']:
output.default_value = get_datablock_from_uuid(loaded_output, None)
else:
output.default_value = loaded_output
except Exception as e:
logging.warning(
f"Node {target_node.name} output {output.name} parameter not supported, skipping ({e})")
else:
logging.warning(
f"Node {target_node.name} output length mismatch.")
def dump_node(node: bpy.types.ShaderNode) -> dict:
""" Dump a single node to a dict
:arg node: target node
:type node: bpy.types.Node
:retrun: dict
"""
node_dumper = Dumper()
node_dumper.depth = 1
node_dumper.exclude_filter = [
"dimensions",
"show_expanded",
"name_full",
"select",
"bl_label",
"bl_height_min",
"bl_height_max",
"bl_height_default",
"bl_width_min",
"bl_width_max",
"type",
"bl_icon",
"bl_width_default",
"bl_static_type",
"show_tetxure",
"is_active_output",
"hide",
"show_options",
"show_preview",
"show_texture",
"outputs",
"width_hidden"
]
dumped_node = node_dumper.dump(node)
if node.parent:
dumped_node['parent'] = node.parent.name
dump_io_needed = (node.type not in ['REROUTE', 'OUTPUT_MATERIAL'])
if dump_io_needed:
io_dumper = Dumper()
io_dumper.depth = 2
io_dumper.include_filter = ["default_value"]
if hasattr(node, 'inputs'):
dumped_node['inputs'] = []
inputs = [i for i in node.inputs if i.type not in IGNORED_SOCKETS]
for idx, inpt in enumerate(inputs):
if hasattr(inpt, 'default_value'):
if isinstance(inpt.default_value, bpy.types.ID):
dumped_input = inpt.default_value.uuid
else:
dumped_input = io_dumper.dump(inpt.default_value)
dumped_node['inputs'].append(dumped_input)
if hasattr(node, 'outputs'):
dumped_node['outputs'] = []
for idx, output in enumerate(node.outputs):
if output.type not in IGNORED_SOCKETS:
if hasattr(output, 'default_value'):
dumped_node['outputs'].append(
io_dumper.dump(output.default_value))
if hasattr(node, 'color_ramp'):
ramp_dumper = Dumper()
ramp_dumper.depth = 4
ramp_dumper.include_filter = [
'elements',
'alpha',
'color',
'position',
'interpolation',
'hue_interpolation',
'color_mode'
]
dumped_node['color_ramp'] = ramp_dumper.dump(node.color_ramp)
if hasattr(node, 'mapping'):
curve_dumper = Dumper()
curve_dumper.depth = 5
curve_dumper.include_filter = [
'curves',
'points',
'location'
]
dumped_node['mapping'] = curve_dumper.dump(node.mapping)
if hasattr(node, 'image') and getattr(node, 'image'):
dumped_node['image_uuid'] = node.image.uuid
if hasattr(node, 'node_tree') and getattr(node, 'node_tree'):
dumped_node['node_tree_uuid'] = node.node_tree.uuid
return dumped_node
def load_links(links_data, node_tree):
""" Load node_tree links from a list
:arg links_data: dumped node links
:type links_data: list
:arg node_tree: node links collection
:type node_tree: bpy.types.NodeTree
"""
for link in links_data:
input_socket = node_tree.nodes[link['to_node']
].inputs[int(link['to_socket'])]
output_socket = node_tree.nodes[link['from_node']].outputs[int(
link['from_socket'])]
node_tree.links.new(input_socket, output_socket)
def dump_links(links):
""" Dump node_tree links collection to a list
:arg links: node links collection
:type links: bpy.types.NodeLinks
:retrun: list
"""
links_data = []
for link in links:
to_socket = NODE_SOCKET_INDEX.search(
link.to_socket.path_from_id()).group(1)
from_socket = NODE_SOCKET_INDEX.search(
link.from_socket.path_from_id()).group(1)
links_data.append({
'to_node': link.to_node.name,
'to_socket': to_socket,
'from_node': link.from_node.name,
'from_socket': from_socket,
})
return links_data
def dump_node_tree(node_tree: bpy.types.ShaderNodeTree) -> dict:
""" Dump a shader node_tree to a dict including links and nodes
:arg node_tree: dumped shader node tree
:type node_tree: bpy.types.ShaderNodeTree
:return: dict
"""
node_tree_data = {
'nodes': {node.name: dump_node(node) for node in node_tree.nodes},
'links': dump_links(node_tree.links),
'name': node_tree.name,
'type': type(node_tree).__name__
}
for socket_id in ['inputs', 'outputs']:
socket_collection = getattr(node_tree, socket_id)
node_tree_data[socket_id] = dump_node_tree_sockets(socket_collection)
return node_tree_data
def dump_node_tree_sockets(sockets: bpy.types.Collection) -> dict:
""" dump sockets of a shader_node_tree
:arg target_node_tree: target node_tree
:type target_node_tree: bpy.types.NodeTree
:arg socket_id: socket identifer
:type socket_id: str
:return: dict
"""
sockets_data = []
for socket in sockets:
try:
socket_uuid = socket['uuid']
except Exception:
socket_uuid = str(uuid4())
socket['uuid'] = socket_uuid
sockets_data.append((socket.name, socket.bl_socket_idname, socket_uuid))
return sockets_data
def load_node_tree_sockets(sockets: bpy.types.Collection,
sockets_data: dict):
""" load sockets of a shader_node_tree
:arg target_node_tree: target node_tree
:type target_node_tree: bpy.types.NodeTree
:arg socket_id: socket identifer
:type socket_id: str
:arg socket_data: dumped socket data
:type socket_data: dict
"""
# Check for removed sockets
for socket in sockets:
if not [s for s in sockets_data if 'uuid' in socket and socket['uuid'] == s[2]]:
sockets.remove(socket)
# Check for new sockets
for idx, socket_data in enumerate(sockets_data):
try:
checked_socket = sockets[idx]
if checked_socket.name != socket_data[0]:
checked_socket.name = socket_data[0]
except Exception:
s = sockets.new(socket_data[1], socket_data[0])
s['uuid'] = socket_data[2]
def load_node_tree(node_tree_data: dict, target_node_tree: bpy.types.ShaderNodeTree) -> dict:
"""Load a shader node_tree from dumped data
:arg node_tree_data: dumped node data
:type node_tree_data: dict
:arg target_node_tree: target node_tree
:type target_node_tree: bpy.types.NodeTree
"""
# TODO: load only required nodes
target_node_tree.nodes.clear()
if not target_node_tree.is_property_readonly('name'):
target_node_tree.name = node_tree_data['name']
if 'inputs' in node_tree_data:
socket_collection = getattr(target_node_tree, 'inputs')
load_node_tree_sockets(socket_collection, node_tree_data['inputs'])
if 'outputs' in node_tree_data:
socket_collection = getattr(target_node_tree, 'outputs')
load_node_tree_sockets(socket_collection, node_tree_data['outputs'])
# Load nodes
for node in node_tree_data["nodes"]:
load_node(node_tree_data["nodes"][node], target_node_tree)
for node_id, node_data in node_tree_data["nodes"].items():
target_node = target_node_tree.nodes.get(node_id, None)
if target_node is None:
continue
elif 'parent' in node_data:
target_node.parent = target_node_tree.nodes[node_data['parent']]
else:
target_node.parent = None
# TODO: load only required nodes links
# Load nodes links
target_node_tree.links.clear()
load_links(node_tree_data["links"], target_node_tree)
def get_node_tree_dependencies(node_tree: bpy.types.NodeTree) -> list:
def has_image(node): return (
node.type in ['TEX_IMAGE', 'TEX_ENVIRONMENT'] and node.image)
def has_node_group(node): return (
hasattr(node, 'node_tree') and node.node_tree)
def has_texture(node): return (
node.type in ['ATTRIBUTE_SAMPLE_TEXTURE','TEXTURE'] and node.texture)
deps = []
for node in node_tree.nodes:
if has_image(node):
deps.append(node.image)
elif has_node_group(node):
deps.append(node.node_tree)
elif has_texture(node):
deps.append(node.texture)
return deps
from .node_tree import load_node_tree, dump_node_tree, get_node_tree_dependencies
def dump_materials_slots(materials: bpy.types.bpy_prop_collection) -> list:
""" Dump material slots collection
@ -387,22 +53,20 @@ def load_materials_slots(src_materials: list, dst_materials: bpy.types.bpy_prop_
for mat_uuid, mat_name in src_materials:
mat_ref = None
if mat_uuid:
if mat_uuid is not None:
mat_ref = get_datablock_from_uuid(mat_uuid, None)
else:
mat_ref = bpy.data.materials[mat_name]
dst_materials.append(mat_ref)
class BlMaterial(ReplicatedDatablock):
use_delta = True
bl_id = "materials"
bl_class = bpy.types.Material
bl_check_common = False
bl_icon = 'MATERIAL_DATA'
bl_reload_parent = False
bl_reload_child = True
@staticmethod
def construct(data: dict) -> object:
@ -410,6 +74,8 @@ class BlMaterial(ReplicatedDatablock):
@staticmethod
def load(data: dict, datablock: object):
load_animation_data(data.get('animation_data'), datablock)
loader = Loader()
is_grease_pencil = data.get('is_grease_pencil')
@ -426,8 +92,6 @@ class BlMaterial(ReplicatedDatablock):
datablock.use_nodes = True
load_node_tree(data['node_tree'], datablock.node_tree)
load_animation_data(data.get('nodes_animation_data'), datablock.node_tree)
load_animation_data(data.get('animation_data'), datablock)
@staticmethod
def dump(datablock: object) -> dict:
@ -495,10 +159,8 @@ class BlMaterial(ReplicatedDatablock):
data['grease_pencil'] = gp_mat_dumper.dump(datablock.grease_pencil)
elif datablock.use_nodes:
data['node_tree'] = dump_node_tree(datablock.node_tree)
data['nodes_animation_data'] = dump_animation_data(datablock.node_tree)
data['animation_data'] = dump_animation_data(datablock)
return data
@staticmethod
@ -512,7 +174,7 @@ class BlMaterial(ReplicatedDatablock):
if datablock.use_nodes:
deps.extend(get_node_tree_dependencies(datablock.node_tree))
deps.extend(resolve_animation_dependencies(datablock.node_tree))
deps.extend(resolve_animation_dependencies(datablock))
return deps

View File

@ -55,8 +55,6 @@ POLYGON = [
]
class BlMesh(ReplicatedDatablock):
use_delta = True
bl_id = "meshes"
bl_class = bpy.types.Mesh
bl_check_common = False

View File

@ -65,8 +65,6 @@ def load_metaball_elements(elements_data, elements):
class BlMetaball(ReplicatedDatablock):
use_delta = True
bl_id = "metaballs"
bl_class = bpy.types.MetaBall
bl_check_common = False

View File

@ -28,8 +28,6 @@ from .bl_datablock import resolve_datablock_from_uuid
from .bl_action import dump_animation_data, load_animation_data, resolve_animation_dependencies
class BlNodeGroup(ReplicatedDatablock):
use_delta = True
bl_id = "node_groups"
bl_class = bpy.types.NodeTree
bl_check_common = False

View File

@ -24,7 +24,7 @@ from replication.exception import ContextError
from replication.protocol import ReplicatedDatablock
from .bl_datablock import get_datablock_from_uuid, resolve_datablock_from_uuid
from .bl_material import IGNORED_SOCKETS
from .node_tree import IGNORED_SOCKETS
from ..utils import get_preferences
from .bl_action import dump_animation_data, load_animation_data, resolve_animation_dependencies
from .dump_anything import (
@ -48,7 +48,7 @@ SHAPEKEY_BLOCK_ATTR = [
]
if bpy.app.version >= (2,93,0):
if bpy.app.version[1] >= 93:
SUPPORTED_GEOMETRY_NODE_PARAMETERS = (int, str, float)
else:
SUPPORTED_GEOMETRY_NODE_PARAMETERS = (int, str)
@ -56,24 +56,14 @@ else:
blender 2.92.")
def get_node_group_properties_identifiers(node_group):
props_ids = []
# Inputs
def get_node_group_inputs(node_group):
inputs = []
for inpt in node_group.inputs:
if inpt.type in IGNORED_SOCKETS:
continue
else:
props_ids.append((inpt.identifier, inpt.type))
if inpt.type in ['INT', 'VALUE', 'BOOLEAN', 'RGBA', 'VECTOR']:
props_ids.append((f"{inpt.identifier}_attribute_name",'STR'))
props_ids.append((f"{inpt.identifier}_use_attribute", 'BOOL'))
for outpt in node_group.outputs:
if outpt.type not in IGNORED_SOCKETS and outpt.type in ['INT', 'VALUE', 'BOOLEAN', 'RGBA', 'VECTOR']:
props_ids.append((f"{outpt.identifier}_attribute_name", 'STR'))
return props_ids
inputs.append(inpt)
return inputs
# return [inpt.identifer for inpt in node_group.inputs if inpt.type not in IGNORED_SOCKETS]
@ -132,35 +122,29 @@ def load_physics(dumped_settings: dict, target: bpy.types.Object):
bpy.ops.rigidbody.constraint_remove({"object": target})
def dump_modifier_geometry_node_props(modifier: bpy.types.Modifier) -> list:
def dump_modifier_geometry_node_inputs(modifier: bpy.types.Modifier) -> list:
""" Dump geometry node modifier input properties
:arg modifier: geometry node modifier to dump
:type modifier: bpy.type.Modifier
"""
dumped_props = []
for prop_value, prop_type in get_node_group_properties_identifiers(modifier.node_group):
try:
prop_value = modifier[prop_value]
except KeyError as e:
logging.error(f"fail to dump geomety node modifier property : {prop_value} ({e})")
else:
dump = None
if isinstance(prop_value, bpy.types.ID):
dump = prop_value.uuid
elif isinstance(prop_value, SUPPORTED_GEOMETRY_NODE_PARAMETERS):
dump = prop_value
elif hasattr(prop_value, 'to_list'):
dump = prop_value.to_list()
dumped_inputs = []
for inpt in get_node_group_inputs(modifier.node_group):
input_value = modifier[inpt.identifier]
dumped_props.append((dump, prop_type))
# logging.info(prop_value)
dumped_input = None
if isinstance(input_value, bpy.types.ID):
dumped_input = input_value.uuid
elif isinstance(input_value, SUPPORTED_GEOMETRY_NODE_PARAMETERS):
dumped_input = input_value
elif hasattr(input_value, 'to_list'):
dumped_input = input_value.to_list()
dumped_inputs.append(dumped_input)
return dumped_props
return dumped_inputs
def load_modifier_geometry_node_props(dumped_modifier: dict, target_modifier: bpy.types.Modifier):
def load_modifier_geometry_node_inputs(dumped_modifier: dict, target_modifier: bpy.types.Modifier):
""" Load geometry node modifier inputs
:arg dumped_modifier: source dumped modifier to load
@ -169,17 +153,17 @@ def load_modifier_geometry_node_props(dumped_modifier: dict, target_modifier: bp
:type target_modifier: bpy.type.Modifier
"""
for input_index, inpt in enumerate(get_node_group_properties_identifiers(target_modifier.node_group)):
dumped_value, dumped_type = dumped_modifier['props'][input_index]
input_value = target_modifier[inpt[0]]
if dumped_type in ['INT', 'VALUE', 'STR']:
logging.info(f"{inpt[0]}/{dumped_value}")
target_modifier[inpt[0]] = dumped_value
elif dumped_type in ['RGBA', 'VECTOR']:
for input_index, inpt in enumerate(get_node_group_inputs(target_modifier.node_group)):
dumped_value = dumped_modifier['inputs'][input_index]
input_value = target_modifier[inpt.identifier]
if isinstance(input_value, SUPPORTED_GEOMETRY_NODE_PARAMETERS):
target_modifier[inpt.identifier] = dumped_value
elif hasattr(input_value, 'to_list'):
for index in range(len(input_value)):
input_value[index] = dumped_value[index]
elif dumped_type in ['COLLECTION', 'OBJECT', 'IMAGE', 'TEXTURE', 'MATERIAL']:
target_modifier[inpt[0]] = get_datablock_from_uuid(dumped_value, None)
elif inpt.type in ['COLLECTION', 'OBJECT']:
target_modifier[inpt.identifier] = get_datablock_from_uuid(
dumped_value, None)
def load_pose(target_bone, data):
@ -214,12 +198,12 @@ def find_data_from_name(name=None):
instance = bpy.data.speakers[name]
elif name in bpy.data.lightprobes.keys():
# Only supported since 2.83
if bpy.app.version >= (2,83,0):
if bpy.app.version[1] >= 83:
instance = bpy.data.lightprobes[name]
else:
logging.warning(
"Lightprobe replication only supported since 2.83. See https://developer.blender.org/D6396")
elif bpy.app.version >= (2,91,0) and name in bpy.data.volumes.keys():
elif bpy.app.version[1] >= 91 and name in bpy.data.volumes.keys():
# Only supported since 2.91
instance = bpy.data.volumes[name]
return instance
@ -266,11 +250,10 @@ def find_geometry_nodes_dependencies(modifiers: bpy.types.bpy_prop_collection) -
for mod in modifiers:
if mod.type == 'NODES' and mod.node_group:
dependencies.append(mod.node_group)
for inpt, inpt_type in get_node_group_properties_identifiers(mod.node_group):
inpt_value = mod.get(inpt)
# Avoid to handle 'COLLECTION', 'OBJECT' to avoid circular dependencies
if inpt_type in ['IMAGE', 'TEXTURE', 'MATERIAL'] and inpt_value:
dependencies.append(inpt_value)
# for inpt in get_node_group_inputs(mod.node_group):
# parameter = mod.get(inpt.identifier)
# if parameter and isinstance(parameter, bpy.types.ID):
# dependencies.append(parameter)
return dependencies
@ -404,7 +387,10 @@ def dump_modifiers(modifiers: bpy.types.bpy_prop_collection)->dict:
dumped_modifier = dumper.dump(modifier)
# hack to dump geometry nodes inputs
if modifier.type == 'NODES':
dumped_modifier['props'] = dump_modifier_geometry_node_props(modifier)
dumped_inputs = dump_modifier_geometry_node_inputs(
modifier)
dumped_modifier['inputs'] = dumped_inputs
elif modifier.type == 'PARTICLE_SYSTEM':
dumper.exclude_filter = [
"is_edited",
@ -469,7 +455,7 @@ def load_modifiers(dumped_modifiers: list, modifiers: bpy.types.bpy_prop_collect
loader.load(loaded_modifier, dumped_modifier)
if loaded_modifier.type == 'NODES':
load_modifier_geometry_node_props(dumped_modifier, loaded_modifier)
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']
@ -507,8 +493,6 @@ def load_modifiers_custom_data(dumped_modifiers: dict, modifiers: bpy.types.bpy_
class BlObject(ReplicatedDatablock):
use_delta = True
bl_id = "objects"
bl_class = bpy.types.Object
bl_check_common = False
@ -634,8 +618,10 @@ class BlObject(ReplicatedDatablock):
transform = data.get('transforms', None)
if transform:
datablock.matrix_parent_inverse = mathutils.Matrix(transform['matrix_parent_inverse'])
datablock.matrix_parent_inverse = mathutils.Matrix(
transform['matrix_parent_inverse'])
datablock.matrix_basis = mathutils.Matrix(transform['matrix_basis'])
datablock.matrix_local = mathutils.Matrix(transform['matrix_local'])
@staticmethod

View File

@ -3,8 +3,7 @@ import mathutils
from . import dump_anything
from replication.protocol import ReplicatedDatablock
from .bl_datablock import get_datablock_from_uuid
from .bl_datablock import resolve_datablock_from_uuid
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
@ -41,8 +40,6 @@ IGNORED_ATTR = [
]
class BlParticle(ReplicatedDatablock):
use_delta = True
bl_id = "particles"
bl_class = bpy.types.ParticleSettings
bl_icon = "PARTICLES"

View File

@ -19,6 +19,7 @@
import logging
from pathlib import Path
from uuid import uuid4
import re
import bpy
import mathutils
@ -29,10 +30,12 @@ from replication.protocol import ReplicatedDatablock
from ..utils import flush_history, get_preferences
from .bl_action import (dump_animation_data, load_animation_data,
resolve_animation_dependencies)
from .node_tree import (get_node_tree_dependencies, load_node_tree,
dump_node_tree)
from .bl_collection import (dump_collection_children, dump_collection_objects,
load_collection_childrens, load_collection_objects,
resolve_collection_dependencies)
from .bl_datablock import resolve_datablock_from_uuid
from .bl_datablock import get_datablock_from_uuid, resolve_datablock_from_uuid
from .bl_file import get_filepath
from .dump_anything import Dumper, Loader
@ -303,7 +306,6 @@ def dump_sequence(sequence: bpy.types.Sequence) -> dict:
return data
def load_sequence(sequence_data: dict,
sequence_editor: bpy.types.SequenceEditor):
""" Load sequence from dumped data
@ -370,7 +372,6 @@ def load_sequence(sequence_data: dict,
loader.load(sequence, sequence_data)
sequence.select = False
class BlScene(ReplicatedDatablock):
is_root = True
use_delta = True
@ -403,9 +404,8 @@ class BlScene(ReplicatedDatablock):
datablock.world = bpy.data.worlds[data['world']]
# Annotation
gpencil_uid = data.get('grease_pencil')
if gpencil_uid:
datablock.grease_pencil = resolve_datablock_from_uuid(gpencil_uid, bpy.data.grease_pencils)
if 'grease_pencil' in data.keys():
datablock.grease_pencil = bpy.data.grease_pencils[data['grease_pencil']]
if get_preferences().sync_flags.sync_render_settings:
if 'eevee' in data.keys():
@ -440,23 +440,22 @@ class BlScene(ReplicatedDatablock):
if seq.name not in sequences:
vse.sequences.remove(seq)
# Load existing sequences
for seq_data in sequences.values():
for seq_data in sequences.value():
load_sequence(seq_data, vse)
# If the sequence is no longer used, clear it
elif datablock.sequence_editor and not sequences:
datablock.sequence_editor_clear()
# Timeline markers
markers = data.get('timeline_markers')
if markers:
datablock.timeline_markers.clear()
for name, frame, camera in markers:
marker = datablock.timeline_markers.new(name, frame=frame)
if camera:
marker.camera = resolve_datablock_from_uuid(camera, bpy.data.objects)
marker.select = False
# FIXME: Find a better way after the replication big refacotoring
# Keep other user from deleting collection object by flushing their history
# Compositor
if data["use_nodes"]:
if datablock.node_tree is None:
datablock.use_nodes = True
load_node_tree(data['node_tree'], datablock.node_tree)
flush_history()
@staticmethod
@ -468,9 +467,11 @@ class BlScene(ReplicatedDatablock):
scene_dumper = Dumper()
scene_dumper.depth = 1
scene_dumper.include_filter = [
'use_nodes',
'name',
'world',
'id',
'grease_pencil',
'frame_start',
'frame_end',
'frame_step',
@ -525,14 +526,12 @@ class BlScene(ReplicatedDatablock):
for seq in vse.sequences_all:
dumped_sequences[seq.name] = dump_sequence(seq)
data['sequences'] = dumped_sequences
# Compositor
if datablock.use_nodes:
data['node_tree'] = dump_node_tree(datablock.node_tree)
data['animation_data'] = dump_animation_data(datablock)
# Timeline markers
if datablock.timeline_markers:
data['timeline_markers'] = [(m.name, m.frame, getattr(m.camera, 'uuid', None)) for m in datablock.timeline_markers]
if datablock.grease_pencil:
data['grease_pencil'] = datablock.grease_pencil.uuid
return data
@staticmethod
@ -566,6 +565,12 @@ class BlScene(ReplicatedDatablock):
Path(bpy.path.abspath(sequence.directory),
elem.filename))
# Compositor
if datablock.use_nodes:
deps.extend(get_node_tree_dependencies(datablock.node_tree))
deps.extend(resolve_animation_dependencies(datablock))
return deps
@staticmethod

View File

@ -25,8 +25,6 @@ from .bl_datablock import resolve_datablock_from_uuid
from .bl_action import dump_animation_data, load_animation_data, resolve_animation_dependencies
class BlSpeaker(ReplicatedDatablock):
use_delta = True
bl_id = "speakers"
bl_class = bpy.types.Speaker
bl_check_common = False

View File

@ -26,8 +26,6 @@ from .bl_action import dump_animation_data, load_animation_data, resolve_animati
import bpy.types as T
class BlTexture(ReplicatedDatablock):
use_delta = True
bl_id = "textures"
bl_class = bpy.types.Texture
bl_check_common = False

View File

@ -27,8 +27,6 @@ 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(ReplicatedDatablock):
use_delta = True
bl_id = "volumes"
bl_class = bpy.types.Volume
bl_check_common = False

View File

@ -21,7 +21,7 @@ import mathutils
from .dump_anything import Loader, Dumper
from replication.protocol import ReplicatedDatablock
from .bl_material import (load_node_tree,
from .node_tree import (load_node_tree,
dump_node_tree,
get_node_tree_dependencies)
@ -30,8 +30,6 @@ from .bl_action import dump_animation_data, load_animation_data, resolve_animati
class BlWorld(ReplicatedDatablock):
use_delta = True
bl_id = "worlds"
bl_class = bpy.types.World
bl_check_common = True

View File

@ -0,0 +1,362 @@
# ##### 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
import logging
import re
from uuid import uuid4
from .dump_anything import Loader, Dumper
from .bl_datablock import get_datablock_from_uuid, resolve_datablock_from_uuid
IGNORED_SOCKETS = ['GEOMETRY', 'SHADER', 'CUSTOM']
NODE_SOCKET_INDEX = re.compile('\[(\d*)\]')
def load_node(node_data: dict, node_tree: bpy.types.NodeTree):
""" Load a node into a node_tree from a dict
:arg node_data: dumped node data
:type node_data: dict
:arg node_tree: target node_tree
:type node_tree: bpy.types.NodeTree
"""
loader = Loader()
target_node = node_tree.nodes.new(type=node_data["bl_idname"])
target_node.select = False
loader.load(target_node, node_data)
image_uuid = node_data.get('image_uuid', None)
node_tree_uuid = node_data.get('node_tree_uuid', None)
if image_uuid and not target_node.image:
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:
target_node.node_tree = get_datablock_from_uuid(node_tree_uuid, None)
inputs_data = node_data.get('inputs')
if inputs_data:
inputs = [i for i in target_node.inputs if i.type not in IGNORED_SOCKETS]
for idx, inpt in enumerate(inputs):
if idx < len(inputs_data) and hasattr(inpt, "default_value"):
loaded_input = inputs_data[idx]
try:
if inpt.type in ['OBJECT', 'COLLECTION']:
inpt.default_value = get_datablock_from_uuid(loaded_input, None)
else:
inpt.default_value = loaded_input
except Exception as e:
logging.warning(f"Node {target_node.name} input {inpt.name} parameter not supported, skipping ({e})")
else:
logging.warning(f"Node {target_node.name} input length mismatch.")
outputs_data = node_data.get('outputs')
if outputs_data:
outputs = [o for o in target_node.outputs if o.type not in IGNORED_SOCKETS]
for idx, output in enumerate(outputs):
if idx < len(outputs_data) and hasattr(output, "default_value"):
loaded_output = outputs_data[idx]
try:
if output.type in ['OBJECT', 'COLLECTION']:
output.default_value = get_datablock_from_uuid(loaded_output, None)
else:
output.default_value = loaded_output
except Exception as e:
logging.warning(
f"Node {target_node.name} output {output.name} parameter not supported, skipping ({e})")
else:
logging.warning(
f"Node {target_node.name} output length mismatch.")
def dump_node(node: bpy.types.Node) -> dict:
""" Dump a single node to a dict
:arg node: target node
:type node: bpy.types.Node
:retrun: dict
"""
node_dumper = Dumper()
node_dumper.depth = 1
node_dumper.exclude_filter = [
"dimensions",
"show_expanded",
"name_full",
"select",
"bl_label",
"bl_height_min",
"bl_height_max",
"bl_height_default",
"bl_width_min",
"bl_width_max",
"type",
"bl_icon",
"bl_width_default",
"bl_static_type",
"show_tetxure",
"is_active_output",
"hide",
"show_options",
"show_preview",
"show_texture",
"outputs",
"width_hidden",
"image"
]
dumped_node = node_dumper.dump(node)
if node.parent:
dumped_node['parent'] = node.parent.name
dump_io_needed = (node.type not in ['REROUTE', 'OUTPUT_MATERIAL'])
if dump_io_needed:
io_dumper = Dumper()
io_dumper.depth = 2
io_dumper.include_filter = ["default_value"]
if hasattr(node, 'inputs'):
dumped_node['inputs'] = []
inputs = [i for i in node.inputs if i.type not in IGNORED_SOCKETS]
for idx, inpt in enumerate(inputs):
if hasattr(inpt, 'default_value'):
if isinstance(inpt.default_value, bpy.types.ID):
dumped_input = inpt.default_value.uuid
else:
dumped_input = io_dumper.dump(inpt.default_value)
dumped_node['inputs'].append(dumped_input)
if hasattr(node, 'outputs'):
dumped_node['outputs'] = []
for idx, output in enumerate(node.outputs):
if output.type not in IGNORED_SOCKETS:
if hasattr(output, 'default_value'):
dumped_node['outputs'].append(
io_dumper.dump(output.default_value))
if hasattr(node, 'color_ramp'):
ramp_dumper = Dumper()
ramp_dumper.depth = 4
ramp_dumper.include_filter = [
'elements',
'alpha',
'color',
'position',
'interpolation',
'hue_interpolation',
'color_mode'
]
dumped_node['color_ramp'] = ramp_dumper.dump(node.color_ramp)
if hasattr(node, 'mapping'):
curve_dumper = Dumper()
curve_dumper.depth = 5
curve_dumper.include_filter = [
'curves',
'points',
'location'
]
dumped_node['mapping'] = curve_dumper.dump(node.mapping)
if hasattr(node, 'image') and getattr(node, 'image'):
dumped_node['image_uuid'] = node.image.uuid
if hasattr(node, 'node_tree') and getattr(node, 'node_tree'):
dumped_node['node_tree_uuid'] = node.node_tree.uuid
return dumped_node
def load_links(links_data, node_tree):
""" Load node_tree links from a list
:arg links_data: dumped node links
:type links_data: list
:arg node_tree: node links collection
:type node_tree: bpy.types.NodeTree
"""
for link in links_data:
input_socket = node_tree.nodes[link['to_node']
].inputs[int(link['to_socket'])]
output_socket = node_tree.nodes[link['from_node']].outputs[int(
link['from_socket'])]
node_tree.links.new(input_socket, output_socket)
def dump_links(links):
""" Dump node_tree links collection to a list
:arg links: node links collection
:type links: bpy.types.NodeLinks
:retrun: list
"""
links_data = []
for link in links:
to_socket = NODE_SOCKET_INDEX.search(
link.to_socket.path_from_id()).group(1)
from_socket = NODE_SOCKET_INDEX.search(
link.from_socket.path_from_id()).group(1)
links_data.append({
'to_node': link.to_node.name,
'to_socket': to_socket,
'from_node': link.from_node.name,
'from_socket': from_socket,
})
return links_data
def dump_node_tree(node_tree: bpy.types.NodeTree) -> dict:
""" Dump a node_tree to a dict including links and nodes
:arg node_tree: dumped node tree
:type node_tree: bpy.types.NodeTree
:return: dict
"""
node_tree_data = {
'nodes': {node.name: dump_node(node) for node in node_tree.nodes},
'links': dump_links(node_tree.links),
'name': node_tree.name,
'type': type(node_tree).__name__
}
for socket_id in ['inputs', 'outputs']:
socket_collection = getattr(node_tree, socket_id)
node_tree_data[socket_id] = dump_node_tree_sockets(socket_collection)
return node_tree_data
def dump_node_tree_sockets(sockets: bpy.types.Collection) -> dict:
""" dump sockets of a shader_node_tree
:arg target_node_tree: target node_tree
:type target_node_tree: bpy.types.NodeTree
:arg socket_id: socket identifer
:type socket_id: str
:return: dict
"""
sockets_data = []
for socket in sockets:
try:
socket_uuid = socket['uuid']
except Exception:
socket_uuid = str(uuid4())
socket['uuid'] = socket_uuid
sockets_data.append((socket.name, socket.bl_socket_idname, socket_uuid))
return sockets_data
def load_node_tree_sockets(sockets: bpy.types.Collection,
sockets_data: dict):
""" load sockets of a shader_node_tree
:arg target_node_tree: target node_tree
:type target_node_tree: bpy.types.NodeTree
:arg socket_id: socket identifer
:type socket_id: str
:arg socket_data: dumped socket data
:type socket_data: dict
"""
# Check for removed sockets
for socket in sockets:
if not [s for s in sockets_data if 'uuid' in socket and socket['uuid'] == s[2]]:
sockets.remove(socket)
# Check for new sockets
for idx, socket_data in enumerate(sockets_data):
try:
checked_socket = sockets[idx]
if checked_socket.name != socket_data[0]:
checked_socket.name = socket_data[0]
except Exception:
s = sockets.new(socket_data[1], socket_data[0])
s['uuid'] = socket_data[2]
def load_node_tree(node_tree_data: dict, target_node_tree: bpy.types.NodeTree) -> dict:
"""Load a shader node_tree from dumped data
:arg node_tree_data: dumped node data
:type node_tree_data: dict
:arg target_node_tree: target node_tree
:type target_node_tree: bpy.types.NodeTree
"""
# TODO: load only required nodes
target_node_tree.nodes.clear()
if not target_node_tree.is_property_readonly('name'):
target_node_tree.name = node_tree_data['name']
if 'inputs' in node_tree_data:
socket_collection = getattr(target_node_tree, 'inputs')
load_node_tree_sockets(socket_collection, node_tree_data['inputs'])
if 'outputs' in node_tree_data:
socket_collection = getattr(target_node_tree, 'outputs')
load_node_tree_sockets(socket_collection, node_tree_data['outputs'])
# Load nodes
for node in node_tree_data["nodes"]:
load_node(node_tree_data["nodes"][node], target_node_tree)
for node_id, node_data in node_tree_data["nodes"].items():
target_node = target_node_tree.nodes.get(node_id, None)
if target_node is None:
continue
elif 'parent' in node_data:
target_node.parent = target_node_tree.nodes[node_data['parent']]
else:
target_node.parent = None
# TODO: load only required nodes links
# Load nodes links
target_node_tree.links.clear()
load_links(node_tree_data["links"], target_node_tree)
def get_node_tree_dependencies(node_tree: bpy.types.NodeTree) -> list:
def has_image(node): return (
node.type in ['TEX_IMAGE', 'TEX_ENVIRONMENT','IMAGE','R_LAYER'] and node.image)
def has_node_group(node): return (
hasattr(node, 'node_tree') and node.node_tree)
def has_texture(node): return (
node.type in ['ATTRIBUTE_SAMPLE_TEXTURE','TEXTURE'] and node.texture)
deps = []
for node in node_tree.nodes:
if has_image(node):
deps.append(node.image)
elif has_node_group(node):
deps.append(node.node_tree)
elif has_texture(node):
deps.append(node.texture)
return deps

View File

@ -134,7 +134,7 @@ def install_modules(dependencies: list, python_path: str, install_dir: str):
module_can_be_imported(package_name)
def register():
if bpy.app.version >= (2,91,0):
if bpy.app.version[1] >= 91:
python_binary_path = sys.executable
else:
python_binary_path = bpy.app.binary_path_python

View File

@ -1,170 +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 logging
import bpy
from bpy.app.handlers import persistent
from replication import porcelain
from replication.constants import RP_COMMON, STATE_ACTIVE, STATE_SYNCING, UP
from replication.exception import ContextError, NonAuthorizedOperationError
from replication.interface import session
from . import shared_data, utils
def sanitize_deps_graph(remove_nodes: bool = False):
""" Cleanup the replication graph
"""
if session and session.state == STATE_ACTIVE:
start = utils.current_milli_time()
rm_cpt = 0
for node in session.repository.graph.values():
node.instance = session.repository.rdp.resolve(node.data)
if node is None \
or (node.state == UP and not node.instance):
if remove_nodes:
try:
porcelain.rm(session.repository,
node.uuid,
remove_dependencies=False)
logging.info(f"Removing {node.uuid}")
rm_cpt += 1
except NonAuthorizedOperationError:
continue
logging.info(f"Sanitize took { utils.current_milli_time()-start} ms, removed {rm_cpt} nodes")
def update_external_dependencies():
"""Force external dependencies(files such as images) evaluation
"""
external_types = ['WindowsPath', 'PosixPath', 'Image']
nodes_ids = [n.uuid for n in session.repository.graph.values() if n.data['type_id'] in external_types]
for node_id in nodes_ids:
node = session.repository.graph.get(node_id)
if node and node.owner in [session.repository.username, RP_COMMON]:
porcelain.commit(session.repository, node_id)
porcelain.push(session.repository, 'origin', node_id)
@persistent
def on_scene_update(scene):
"""Forward blender depsgraph update to replication
"""
if session and session.state == STATE_ACTIVE:
context = bpy.context
blender_depsgraph = bpy.context.view_layer.depsgraph
dependency_updates = [u for u in blender_depsgraph.updates]
settings = utils.get_preferences()
incoming_updates = shared_data.session.applied_updates
distant_update = [getattr(u.id, 'uuid', None) for u in dependency_updates if getattr(u.id, 'uuid', None) in incoming_updates]
if distant_update:
for u in distant_update:
shared_data.session.applied_updates.remove(u)
logging.debug(f"Ignoring distant update of {dependency_updates[0].id.name}")
return
# NOTE: maybe we don't need to check each update but only the first
for update in reversed(dependency_updates):
update_uuid = getattr(update.id, 'uuid', None)
if update_uuid:
node = session.repository.graph.get(update.id.uuid)
check_common = session.repository.rdp.get_implementation(update.id).bl_check_common
if node and (node.owner == session.repository.username or check_common):
logging.debug(f"Evaluate {update.id.name}")
if node.state == UP:
try:
porcelain.commit(session.repository, node.uuid)
porcelain.push(session.repository,
'origin', node.uuid)
except ReferenceError:
logging.debug(f"Reference error {node.uuid}")
except ContextError as e:
logging.debug(e)
except Exception as e:
logging.error(e)
else:
continue
elif isinstance(update.id, bpy.types.Scene):
scene = bpy.data.scenes.get(update.id.name)
scn_uuid = porcelain.add(session.repository, scene)
porcelain.commit(session.repository, scn_uuid)
porcelain.push(session.repository, 'origin', scn_uuid)
scene_graph_changed = [u for u in reversed(dependency_updates) if getattr(u.id, 'uuid', None) and isinstance(u.id,(bpy.types.Scene,bpy.types.Collection))]
if scene_graph_changed:
porcelain.purge_orphan_nodes(session.repository)
update_external_dependencies()
@persistent
def resolve_deps_graph(dummy):
"""Resolve deps graph
Temporary solution to resolve each node pointers after a Undo.
A future solution should be to avoid storing dataclock reference...
"""
if session and session.state == STATE_ACTIVE:
sanitize_deps_graph(remove_nodes=True)
@persistent
def load_pre_handler(dummy):
if session and session.state in [STATE_ACTIVE, STATE_SYNCING]:
bpy.ops.session.stop()
@persistent
def update_client_frame(scene):
setting = bpy.context.window_manager.session
if setting.replay_mode == 'TIMELINE' and \
setting.replay_files and \
scene.active_replay_file != setting.replay_frame_current :
index = bpy.context.scene.active_replay_file
bpy.ops.session.load(filepath=bpy.context.window_manager.session.replay_files[index].name,
draw_users=True,
replay=True)
setting.replay_frame_current = index
if session and session.state == STATE_ACTIVE:
porcelain.update_user_metadata(session.repository, {
'frame_current': scene.frame_current
})
@persistent
def post_frame_update(scene):
if bpy.context.window_manager.session.replay_mode == 'TIMELINE' and \
not bpy.context.scene.animation_data:
bpy.context.scene.animation_data_create()
bpy.context.scene.animation_data.action = bpy.data.actions.get('replay_action')
def register():
bpy.app.handlers.undo_post.append(resolve_deps_graph)
bpy.app.handlers.redo_post.append(resolve_deps_graph)
bpy.app.handlers.load_pre.append(load_pre_handler)
bpy.app.handlers.frame_change_pre.append(update_client_frame)
def unregister():
bpy.app.handlers.undo_post.remove(resolve_deps_graph)
bpy.app.handlers.redo_post.remove(resolve_deps_graph)
bpy.app.handlers.load_pre.remove(load_pre_handler)
bpy.app.handlers.frame_change_pre.remove(update_client_frame)

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 os
from pathlib import Path
import bpy.utils.previews
def register():
global icons_col
pcoll = bpy.utils.previews.new()
icons_dir = os.path.join(os.path.dirname(__file__), ".")
for png in Path(icons_dir).rglob("*.png"):
pcoll.load(png.stem, str(png), "IMAGE")
icons_col = pcoll
def unregister():
global icons_col
try:
bpy.utils.previews.remove(icons_col)
except Exception:
pass
icons_col = None

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

File diff suppressed because it is too large Load Diff

View File

@ -17,17 +17,15 @@
import random
import logging
from uuid import uuid4
import bpy
import string
import re
import os
from numpy import interp
from pathlib import Path
from . import bl_types, environment, addon_updater_ops, presence, ui
from .utils import get_preferences, get_expanded_icon, get_folder_size
from .utils import get_preferences, get_expanded_icon
from replication.constants import RP_COMMON
from replication.interface import session
@ -35,21 +33,15 @@ from replication.interface import session
IP_REGEX = re.compile("^(([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\.){3}([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])$")
HOSTNAME_REGEX = re.compile("^(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\-]*[a-zA-Z0-9])\.)*([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9\-]*[A-Za-z0-9])$")
#SERVER PRESETS AT LAUNCH
DEFAULT_PRESETS = {
"localhost" : {
"server_name": "localhost",
"ip": "localhost",
"port": 5555,
"use_admin_password": True,
"admin_password": "admin",
"server_password": ""
"server_ip": "localhost",
"server_port": 5555,
"server_password": "admin"
},
"public session" : {
"server_name": "public session",
"ip": "51.75.71.183",
"port": 5555,
"admin_password": "",
"server_ip": "51.75.71.183",
"server_port": 5555,
"server_password": ""
},
}
@ -86,6 +78,11 @@ def update_ip(self, context):
logging.error("Wrong IP format")
self['ip'] = "127.0.0.1"
def update_server_preset_interface(self, context):
self.server_name = self.server_preset.get(self.server_preset_interface).name
self.ip = self.server_preset.get(self.server_preset_interface).server_ip
self.port = self.server_preset.get(self.server_preset_interface).server_port
self.password = self.server_preset.get(self.server_preset_interface).server_password
def update_directory(self, context):
new_dir = Path(self.cache_directory)
@ -100,77 +97,6 @@ def update_directory(self, context):
def set_log_level(self, value):
logging.getLogger().setLevel(value)
def set_active_replay(self, value):
files_count = len(bpy.context.window_manager.session.replay_files)
if files_count == 0:
return
max_index = files_count-1
if value > max_index:
value = max_index
if hasattr(self, 'active_replay_file'):
self["active_replay_file"] = value
else:
self.active_replay_file = value
if bpy.context.window_manager.session.replay_mode == 'MANUAL':
bpy.ops.session.load(
filepath=bpy.context.window_manager.session.replay_files[value].name,
draw_users=True,
replay=True)
def get_active_replay(self):
return self.get('active_replay_file', 0)
def set_replay_persistent_collection(self, value):
if hasattr(self, 'replay_persistent_collection'):
self["replay_persistent_collection"] = value
else:
self.replay_persistent_collection = value
collection = bpy.data.collections.get("multiuser_timelapse", None)
if collection is None and value:
collection = bpy.data.collections.new('multiuser_timelapse')
bpy.context.scene.collection.children.link(collection)
elif collection and not value:
for o in collection.objects:
bpy.data.objects.remove(o)
bpy.data.collections.remove(collection)
def get_replay_persistent_collection(self):
return self.get('replay_persistent_collection', False)
def set_replay_duration(self, value):
if hasattr(self, 'replay_duration'):
self["replay_duration"] = value
else:
self.replay_duration = value
# Update the animation fcurve
replay_action = bpy.data.actions.get('replay_action')
replay_fcurve = None
for fcurve in replay_action.fcurves:
if fcurve.data_path == 'active_replay_file':
replay_fcurve = fcurve
if replay_fcurve:
for p in reversed(replay_fcurve.keyframe_points):
replay_fcurve.keyframe_points.remove(p, fast=True)
bpy.context.scene.frame_end = value
files_count = len(bpy.context.window_manager.session.replay_files)-1
for index in range(0, files_count):
frame = interp(index,[0, files_count],[bpy.context.scene.frame_start, value])
replay_fcurve.keyframe_points.insert(frame, index)
def get_replay_duration(self):
return self.get('replay_duration', 10)
def get_log_level(self):
return logging.getLogger().level
@ -184,15 +110,9 @@ class ReplicatedDatablock(bpy.types.PropertyGroup):
icon: bpy.props.StringProperty()
class ServerPreset(bpy.types.PropertyGroup):
server_name: bpy.props.StringProperty(default="")
ip: bpy.props.StringProperty(default="127.0.0.1", update=update_ip)
port: bpy.props.IntProperty(default=5555)
use_server_password: bpy.props.BoolProperty(default=False)
server_password: bpy.props.StringProperty(default="", subtype = "PASSWORD")
use_admin_password: bpy.props.BoolProperty(default=False)
admin_password: bpy.props.StringProperty(default="", subtype = "PASSWORD")
is_online: bpy.props.BoolProperty(default=False)
is_private: bpy.props.BoolProperty(default=False)
server_ip: bpy.props.StringProperty()
server_port: bpy.props.IntProperty(default=5555)
server_password: bpy.props.StringProperty(default="admin", subtype = "PASSWORD")
def set_sync_render_settings(self, value):
self['sync_render_settings'] = value
@ -242,60 +162,35 @@ class ReplicationFlags(bpy.types.PropertyGroup):
class SessionPrefs(bpy.types.AddonPreferences):
bl_idname = __package__
# User settings
ip: bpy.props.StringProperty(
name="ip",
description='Distant host ip',
default="localhost",
update=update_ip)
username: bpy.props.StringProperty(
name="Username",
default=f"user_{random_string_digits()}"
)
client_color: bpy.props.FloatVectorProperty(
name="client_instance_color",
description='User color',
subtype='COLOR',
default=randomColor()
default=randomColor())
port: bpy.props.IntProperty(
name="port",
description='Distant host port',
default=5555
)
# Current server settings
server_name: bpy.props.StringProperty(
name="server_name",
description="Custom name of the server",
default='localhost',
)
server_index: bpy.props.IntProperty(
name="server_index",
description="index of the server",
)
# User host session settings
host_port: bpy.props.IntProperty(
name="host_port",
description='Distant host port',
default=5555
)
host_use_server_password: bpy.props.BoolProperty(
name="use_server_password",
description='Use session password',
default=False
)
host_server_password: bpy.props.StringProperty(
name="server_password",
password: bpy.props.StringProperty(
name="password",
default=random_string_digits(),
description='Session password',
subtype='PASSWORD'
)
host_use_admin_password: bpy.props.BoolProperty(
name="use_admin_password",
description='Use admin password',
default=True
)
host_admin_password: bpy.props.StringProperty(
name="admin_password",
description='Admin password',
subtype='PASSWORD',
default='admin'
)
# Other
is_first_launch: bpy.props.BoolProperty(
name="is_fnirst_launch",
description="First time lauching the addon",
default=True
)
sync_flags: bpy.props.PointerProperty(
type=ReplicationFlags
)
@ -319,11 +214,6 @@ class SessionPrefs(bpy.types.AddonPreferences):
description='connection timeout before disconnection',
default=5000
)
ping_timeout: bpy.props.IntProperty(
name='ping timeout',
description='check if servers are online',
default=500
)
# Replication update settings
depsgraph_update_rate: bpy.props.FloatProperty(
name='depsgraph update rate (s)',
@ -335,12 +225,11 @@ class SessionPrefs(bpy.types.AddonPreferences):
description="Remove filecache from memory",
default=False
)
# For UI
# for UI
category: bpy.props.EnumProperty(
name="Category",
description="Preferences Category",
items=[
('PREF', "Preferences", "Preferences of this add-on"),
('CONFIG', "Configuration", "Configuration of this add-on"),
('UPDATE', "Update", "Update this add-on"),
],
@ -384,58 +273,31 @@ class SessionPrefs(bpy.types.AddonPreferences):
step=1,
subtype='PERCENTAGE',
)
presence_text_distance: bpy.props.FloatProperty(
name="Distance text visibilty",
description="Adjust the distance visibilty of user's mode/name",
min=0.1,
max=10000,
default=100,
)
conf_session_identity_expanded: bpy.props.BoolProperty(
name="Identity",
description="Identity",
default=False
default=True
)
conf_session_net_expanded: bpy.props.BoolProperty(
name="Net",
description="net",
default=False
default=True
)
conf_session_hosting_expanded: bpy.props.BoolProperty(
name="Rights",
description="Rights",
default=False
)
conf_session_rep_expanded: bpy.props.BoolProperty(
name="Replication",
description="Replication",
default=False
)
conf_session_cache_expanded: bpy.props.BoolProperty(
name="Cache",
description="cache",
default=False
)
conf_session_log_expanded: bpy.props.BoolProperty(
name="conf_session_log_expanded",
description="conf_session_log_expanded",
default=False
)
conf_session_ui_expanded: bpy.props.BoolProperty(
name="Interface",
description="Interface",
default=False
)
sidebar_repository_shown: bpy.props.BoolProperty(
name="sidebar_repository_shown",
description="sidebar_repository_shown",
default=False
)
sidebar_advanced_shown: bpy.props.BoolProperty(
name="sidebar_advanced_shown",
description="sidebar_advanced_shown",
default=False
)
sidebar_advanced_rep_expanded: bpy.props.BoolProperty(
name="sidebar_advanced_rep_expanded",
description="sidebar_advanced_rep_expanded",
@ -446,11 +308,6 @@ class SessionPrefs(bpy.types.AddonPreferences):
description="sidebar_advanced_log_expanded",
default=False
)
sidebar_advanced_uinfo_expanded: bpy.props.BoolProperty(
name="sidebar_advanced_uinfo_expanded",
description="sidebar_advanced_uinfo_expanded",
default=False
)
sidebar_advanced_net_expanded: bpy.props.BoolProperty(
name="sidebar_advanced_net_expanded",
description="sidebar_advanced_net_expanded",
@ -507,6 +364,12 @@ class SessionPrefs(bpy.types.AddonPreferences):
name="server preset",
type=ServerPreset,
)
server_preset_interface: bpy.props.EnumProperty(
name="servers",
description="servers enum",
items=server_list_callback,
update=update_server_preset_interface,
)
# Custom panel
panel_category: bpy.props.StringProperty(
@ -516,28 +379,38 @@ class SessionPrefs(bpy.types.AddonPreferences):
def draw(self, context):
layout = self.layout
layout.row().prop(self, "category", expand=True)
if self.category == 'PREF':
grid = layout.column()
box = grid.box()
row = box.row()
# USER SETTINGS
split = row.split(factor=0.7, align=True)
split.prop(self, "username", text="User")
split.prop(self, "client_color", text="")
row = box.row()
row.label(text="Hide settings:")
row = box.row()
row.prop(self, "sidebar_advanced_shown", text="Hide “Advanced” settings in side pannel (Not in session)")
row = box.row()
row.prop(self, "sidebar_repository_shown", text="Hide “Repository” settings in side pannel (In session)")
if self.category == 'CONFIG':
grid = layout.column()
# USER INFORMATIONS
box = grid.box()
box.prop(
self, "conf_session_identity_expanded", text="User information",
icon=get_expanded_icon(self.conf_session_identity_expanded),
emboss=False)
if self.conf_session_identity_expanded:
box.row().prop(self, "username", text="name")
box.row().prop(self, "client_color", text="color")
# NETWORK SETTINGS
box = grid.box()
box.prop(
self, "conf_session_net_expanded", text="Networking",
icon=get_expanded_icon(self.conf_session_net_expanded),
emboss=False)
if self.conf_session_net_expanded:
box.row().prop(self, "ip", text="Address")
row = box.row()
row.label(text="Port:")
row.prop(self, "port", text="")
row = box.row()
row.label(text="Init the session from:")
row.prop(self, "init_method", text="")
# HOST SETTINGS
box = grid.box()
box.prop(
@ -545,57 +418,9 @@ class SessionPrefs(bpy.types.AddonPreferences):
icon=get_expanded_icon(self.conf_session_hosting_expanded),
emboss=False)
if self.conf_session_hosting_expanded:
row = box.row()
row.prop(self, "host_port", text="Port: ")
row = box.row()
row.label(text="Init the session from:")
row.prop(self, "init_method", text="")
row = box.row()
col = row.column()
col.prop(self, "host_use_server_password", text="Server password:")
col = row.column()
col.enabled = True if self.host_use_server_password else False
col.prop(self, "host_server_password", text="")
row = box.row()
col = row.column()
col.prop(self, "host_use_admin_password", text="Admin password:")
col = row.column()
col.enabled = True if self.host_use_admin_password else False
col.prop(self, "host_admin_password", text="")
# NETWORKING
box = grid.box()
box.prop(
self, "conf_session_net_expanded", text="Network",
icon=get_expanded_icon(self.conf_session_net_expanded),
emboss=False)
if self.conf_session_net_expanded:
row = box.row()
row.label(text="Timeout (ms):")
row.prop(self, "connection_timeout", text="")
row = box.row()
row.label(text="Server ping (ms):")
row.prop(self, "ping_timeout", text="")
# REPLICATION
box = grid.box()
box.prop(
self, "conf_session_rep_expanded", text="Replication",
icon=get_expanded_icon(self.conf_session_rep_expanded),
emboss=False)
if self.conf_session_rep_expanded:
row = box.row()
row.prop(self.sync_flags, "sync_render_settings")
row = box.row()
row.prop(self.sync_flags, "sync_active_camera")
row = box.row()
row.prop(self.sync_flags, "sync_during_editmode")
row = box.row()
if self.sync_flags.sync_during_editmode:
warning = row.box()
warning.label(text="Don't use this with heavy meshes !", icon='ERROR')
row = box.row()
row.prop(self, "depsgraph_update_rate", text="Apply delay")
# CACHE SETTINGS
box = grid.box()
@ -606,18 +431,24 @@ class SessionPrefs(bpy.types.AddonPreferences):
if self.conf_session_cache_expanded:
box.row().prop(self, "cache_directory", text="Cache directory")
box.row().prop(self, "clear_memory_filecache", text="Clear memory filecache")
box.row().operator('session.clear_cache', text=f"Clear cache ({get_folder_size(self.cache_directory)})")
# LOGGING
# INTERFACE SETTINGS
box = grid.box()
box.prop(
self, "conf_session_log_expanded", text="Logging",
icon=get_expanded_icon(self.conf_session_log_expanded),
self, "conf_session_ui_expanded", text="Interface",
icon=get_expanded_icon(self.conf_session_ui_expanded),
emboss=False)
if self.conf_session_log_expanded:
if self.conf_session_ui_expanded:
box.row().prop(self, "panel_category", text="Panel category", expand=True)
row = box.row()
row.label(text="Log level:")
row.prop(self, 'logging_level', text="")
row.label(text="Session widget:")
col = box.column(align=True)
col.prop(self, "presence_hud_scale", expand=True)
col.prop(self, "presence_hud_hpos", expand=True)
col.prop(self, "presence_hud_vpos", expand=True)
if self.category == 'UPDATE':
from . import addon_updater_ops
@ -638,31 +469,18 @@ class SessionPrefs(bpy.types.AddonPreferences):
new_db.icon = impl.bl_icon
new_db.bl_name = impl.bl_id
# Get a server preset through its name
def get_server_preset(self, name):
existing_preset = None
for server_preset in self.server_preset :
if server_preset.server_name == name :
existing_preset = server_preset
return existing_preset
# Custom at launch server preset
# custom at launch server preset
def generate_default_presets(self):
for preset_name, preset_data in DEFAULT_PRESETS.items():
existing_preset = self.get_server_preset(preset_name)
existing_preset = self.server_preset.get(preset_name)
if existing_preset :
continue
new_server = self.server_preset.add()
new_server.name = str(uuid4())
new_server.server_name = preset_data.get('server_name')
new_server.ip = preset_data.get('ip')
new_server.port = preset_data.get('port')
new_server.use_server_password = preset_data.get('use_server_password',False)
new_server.name = preset_name
new_server.server_ip = preset_data.get('server_ip')
new_server.server_port = preset_data.get('server_port')
new_server.server_password = preset_data.get('server_password',None)
new_server.use_admin_password = preset_data.get('use_admin_password',False)
new_server.admin_password = preset_data.get('admin_password',None)
def client_list_callback(scene, context):
@ -720,11 +538,6 @@ class SessionProps(bpy.types.PropertyGroup):
description='Enable user overlay ',
default=True,
)
presence_show_mode: bpy.props.BoolProperty(
name="Show users current mode",
description='Enable user mode overlay ',
default=False,
)
presence_show_far_user: bpy.props.BoolProperty(
name="Show users on different scenes",
description="Show user on different scenes",
@ -750,6 +563,11 @@ class SessionProps(bpy.types.PropertyGroup):
description='Connect as admin',
default=False
)
internet_ip: bpy.props.StringProperty(
name="internet ip",
default="no found",
description='Internet interface ip',
)
user_snap_running: bpy.props.BoolProperty(
default=False
)
@ -759,37 +577,6 @@ class SessionProps(bpy.types.PropertyGroup):
is_host: bpy.props.BoolProperty(
default=False
)
replay_files: bpy.props.CollectionProperty(
name='File paths',
type=bpy.types.OperatorFileListElement
)
replay_persistent_collection: bpy.props.BoolProperty(
name="replay_persistent_collection",
description='Enable a collection that persist accross frames loading',
get=get_replay_persistent_collection,
set=set_replay_persistent_collection,
)
replay_mode: bpy.props.EnumProperty(
name='replay method',
description='Replay in keyframe (timeline) or manually',
items={
('TIMELINE', 'TIMELINE', 'Replay from the timeline.'),
('MANUAL', 'MANUAL', 'Replay manually, from the replay frame widget.')},
default='TIMELINE')
replay_duration: bpy.props.IntProperty(
name='replay interval',
default=250,
min=10,
set=set_replay_duration,
get=get_replay_duration,
)
replay_frame_current: bpy.props.IntProperty(
name='replay_frame_current',
)
replay_camera: bpy.props.PointerProperty(
name='Replay camera',
type=bpy.types.Object
)
classes = (
@ -815,16 +602,6 @@ def register():
# at launch server presets
prefs.generate_default_presets()
bpy.types.Scene.active_replay_file = bpy.props.IntProperty(
name="active_replay_file",
default=0,
min=0,
description='Active snapshot',
set=set_active_replay,
get=get_active_replay,
options={'ANIMATABLE'}
)
@ -833,5 +610,3 @@ def unregister():
for cls in reversed(classes):
unregister_class(cls)
del bpy.types.Scene.active_replay_file

View File

@ -94,41 +94,15 @@ def project_to_viewport(region: bpy.types.Region, rv3d: bpy.types.RegionView3D,
return [target.x, target.y, target.z]
def bbox_from_obj(obj: bpy.types.Object, index: int = 1) -> list:
def bbox_from_obj(obj: bpy.types.Object, radius: float) -> list:
""" Generate a bounding box for a given object by using its world matrix
:param obj: target object
:type obj: bpy.types.Object
:param index: indice offset
:type index: int
:return: list of 8 points [(x,y,z),...], list of 12 link between these points [(1,2),...]
:param radius: bounding box radius
:type radius: float
:return: list of 8 points [(x,y,z),...]
"""
radius = 1.0 # Radius of the bounding box
index = 8*index
vertex_indices = (
(0+index, 1+index), (0+index, 2+index), (1+index, 3+index), (2+index, 3+index),
(4+index, 5+index), (4+index, 6+index), (5+index, 7+index), (6+index, 7+index),
(0+index, 4+index), (1+index, 5+index), (2+index, 6+index), (3+index, 7+index))
if obj.type == 'EMPTY':
radius = obj.empty_display_size
elif obj.type == 'LIGHT':
radius = obj.data.shadow_soft_size
elif obj.type == 'LIGHT_PROBE':
radius = obj.data.influence_distance
elif obj.type == 'CAMERA':
radius = obj.data.display_size
elif hasattr(obj, 'bound_box'):
vertex_indices = (
(0+index, 1+index), (1+index, 2+index),
(2+index, 3+index), (0+index, 3+index),
(4+index, 5+index), (5+index, 6+index),
(6+index, 7+index), (4+index, 7+index),
(0+index, 4+index), (1+index, 5+index),
(2+index, 6+index), (3+index, 7+index))
vertex_pos = get_bb_coords_from_obj(obj)
return vertex_pos, vertex_indices
coords = [
(-radius, -radius, -radius), (+radius, -radius, -radius),
(-radius, +radius, -radius), (+radius, +radius, -radius),
@ -138,32 +112,9 @@ def bbox_from_obj(obj: bpy.types.Object, index: int = 1) -> list:
base = obj.matrix_world
bbox_corners = [base @ mathutils.Vector(corner) for corner in coords]
vertex_pos = [(point.x, point.y, point.z) for point in bbox_corners]
return [(point.x, point.y, point.z)
for point in bbox_corners]
return vertex_pos, vertex_indices
def bbox_from_instance_collection(ic: bpy.types.Object, index: int = 0) -> list:
""" Generate a bounding box for a given instance collection by using its objects
:param ic: target instance collection
:type ic: bpy.types.Object
:param index: indice offset
:type index: int
:return: list of 8*objs points [(x,y,z),...], tuple of 12*objs link between these points [(1,2),...]
"""
vertex_pos = []
vertex_indices = ()
for obj_index, obj in enumerate(ic.instance_collection.objects):
vertex_pos_temp, vertex_indices_temp = bbox_from_obj(obj, index=index+obj_index)
vertex_pos += vertex_pos_temp
vertex_indices += vertex_indices_temp
bbox_corners = [ic.matrix_world @ mathutils.Vector(vertex) for vertex in vertex_pos]
vertex_pos = [(point.x, point.y, point.z) for point in bbox_corners]
return vertex_pos, vertex_indices
def generate_user_camera() -> list:
""" Generate a basic camera represention of the user point of view
@ -224,7 +175,7 @@ def get_bb_coords_from_obj(object: bpy.types.Object, instance: bpy.types.Object
bbox_corners = [base @ mathutils.Vector(
corner) for corner in object.bound_box]
return [(point.x, point.y, point.z) for point in bbox_corners]
@ -252,13 +203,6 @@ class Widget(object):
"""
return True
def configure_bgl(self):
bgl.glLineWidth(2.)
bgl.glEnable(bgl.GL_DEPTH_TEST)
bgl.glEnable(bgl.GL_BLEND)
bgl.glEnable(bgl.GL_LINE_SMOOTH)
def draw(self):
"""How to draw the widget
"""
@ -312,6 +256,11 @@ class UserFrustumWidget(Widget):
{"pos": positions},
indices=self.indices)
bgl.glLineWidth(2.)
bgl.glEnable(bgl.GL_DEPTH_TEST)
bgl.glEnable(bgl.GL_BLEND)
bgl.glEnable(bgl.GL_LINE_SMOOTH)
shader.bind()
shader.uniform_float("color", self.data.get('color'))
batch.draw(shader)
@ -323,8 +272,6 @@ class UserSelectionWidget(Widget):
username):
self.username = username
self.settings = bpy.context.window_manager.session
self.current_selection_ids = []
self.current_selected_objects = []
@property
def data(self):
@ -334,15 +281,6 @@ class UserSelectionWidget(Widget):
else:
return None
@property
def selected_objects(self):
user_selection = self.data.get('selected_objects')
if self.current_selection_ids != user_selection:
self.current_selected_objects = [find_from_attr("uuid", uid, bpy.data.objects) for uid in user_selection]
self.current_selection_ids = user_selection
return self.current_selected_objects
def poll(self):
if self.data is None:
return False
@ -357,31 +295,49 @@ class UserSelectionWidget(Widget):
self.settings.enable_presence
def draw(self):
vertex_pos = []
vertex_ind = []
collection_offset = 0
for obj_index, obj in enumerate(self.selected_objects):
if obj is None:
continue
obj_index+=collection_offset
if hasattr(obj, 'instance_collection') and obj.instance_collection:
bbox_pos, bbox_ind = bbox_from_instance_collection(obj, index=obj_index)
collection_offset+=len(obj.instance_collection.objects)-1
else :
bbox_pos, bbox_ind = bbox_from_obj(obj, index=obj_index)
vertex_pos += bbox_pos
vertex_ind += bbox_ind
user_selection = self.data.get('selected_objects')
for select_ob in user_selection:
ob = find_from_attr("uuid", select_ob, bpy.data.objects)
if not ob:
return
shader = gpu.shader.from_builtin('3D_UNIFORM_COLOR')
batch = batch_for_shader(
shader,
'LINES',
{"pos": vertex_pos},
indices=vertex_ind)
vertex_pos = bbox_from_obj(ob, 1.0)
vertex_indices = (
(0, 1), (1, 2), (2, 3), (0, 3),
(4, 5), (5, 6), (6, 7), (4, 7),
(0, 4), (1, 5), (2, 6), (3, 7))
if ob.instance_collection:
for obj in ob.instance_collection.objects:
if obj.type == 'MESH' and hasattr(obj, 'bound_box'):
vertex_pos = get_bb_coords_from_obj(obj, instance=ob)
break
elif ob.type == 'EMPTY':
vertex_pos = bbox_from_obj(ob, ob.empty_display_size)
elif ob.type == 'LIGHT':
vertex_pos = bbox_from_obj(ob, ob.data.shadow_soft_size)
elif ob.type == 'LIGHT_PROBE':
vertex_pos = bbox_from_obj(ob, ob.data.influence_distance)
elif ob.type == 'CAMERA':
vertex_pos = bbox_from_obj(ob, ob.data.display_size)
elif hasattr(ob, 'bound_box'):
vertex_indices = (
(0, 1), (1, 2), (2, 3), (0, 3),
(4, 5), (5, 6), (6, 7), (4, 7),
(0, 4), (1, 5), (2, 6), (3, 7))
vertex_pos = get_bb_coords_from_obj(ob)
shader = gpu.shader.from_builtin('3D_UNIFORM_COLOR')
batch = batch_for_shader(
shader,
'LINES',
{"pos": vertex_pos},
indices=vertex_indices)
shader.bind()
shader.uniform_float("color", self.data.get('color'))
batch.draw(shader)
shader.bind()
shader.uniform_float("color", self.data.get('color'))
batch.draw(shader)
class UserNameWidget(Widget):
draw_type = 'POST_PIXEL'
@ -425,62 +381,6 @@ class UserNameWidget(Widget):
blf.color(0, color[0], color[1], color[2], color[3])
blf.draw(0, self.username)
class UserModeWidget(Widget):
draw_type = 'POST_PIXEL'
def __init__(
self,
username):
self.username = username
self.settings = bpy.context.window_manager.session
self.preferences = get_preferences()
@property
def data(self):
user = session.online_users.get(self.username)
if user:
return user.get('metadata')
else:
return None
def poll(self):
if self.data is None:
return False
scene_current = self.data.get('scene_current')
mode_current = self.data.get('mode_current')
user_selection = self.data.get('selected_objects')
return (scene_current == bpy.context.scene.name or
mode_current == bpy.context.mode or
self.settings.presence_show_far_user) and \
user_selection and \
self.settings.presence_show_mode and \
self.settings.enable_presence
def draw(self):
user_selection = self.data.get('selected_objects')
area, region, rv3d = view3d_find()
viewport_coord = project_to_viewport(region, rv3d, (0, 0))
obj = find_from_attr("uuid", user_selection[0], bpy.data.objects)
if not obj:
return
mode_current = self.data.get('mode_current')
color = self.data.get('color')
origin_coord = project_to_screen(obj.location)
distance_viewport_object = math.sqrt((viewport_coord[0]-obj.location[0])**2+(viewport_coord[1]-obj.location[1])**2+(viewport_coord[2]-obj.location[2])**2)
if distance_viewport_object > self.preferences.presence_mode_distance :
return
if origin_coord :
blf.position(0, origin_coord[0]+8, origin_coord[1]-15, 0)
blf.size(0, 16, 72)
blf.color(0, color[0], color[1], color[2], color[3])
blf.draw(0, mode_current)
class SessionStatusWidget(Widget):
draw_type = 'POST_PIXEL'
@ -563,7 +463,6 @@ class DrawFactory(object):
try:
for widget in self.widgets.values():
if widget.draw_type == 'POST_VIEW' and widget.poll():
widget.configure_bgl()
widget.draw()
except Exception as e:
logging.error(
@ -573,7 +472,6 @@ class DrawFactory(object):
try:
for widget in self.widgets.values():
if widget.draw_type == 'POST_PIXEL' and widget.poll():
widget.configure_bgl()
widget.draw()
except Exception as e:
logging.error(
@ -586,7 +484,6 @@ this.renderer = DrawFactory()
def register():
this.renderer.register_handlers()
this.renderer.add_widget("session_status", SessionStatusWidget())

View File

@ -1,48 +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 #####
from replication.constants import STATE_INITIAL
class SessionData():
""" A structure to share easily the current session data across the addon
modules.
This object will completely replace the Singleton lying in replication
interface module.
"""
def __init__(self):
self.repository = None # The current repository
self.remote = None # The active remote
self.server = None
self.applied_updates = []
@property
def state(self):
if self.remote is None:
return STATE_INITIAL
else:
return self.remote.connection_status
def clear(self):
self.remote = None
self.repository = None
self.server = None
self.applied_updates = []
session = SessionData()

View File

@ -27,12 +27,10 @@ from replication.interface import session
from replication import porcelain
from . import operators, utils
from .presence import (UserFrustumWidget, UserNameWidget, UserModeWidget, UserSelectionWidget,
from .presence import (UserFrustumWidget, UserNameWidget, UserSelectionWidget,
generate_user_camera, get_view_matrix, refresh_3d_view,
refresh_sidebar_view, renderer)
from . import shared_data
this = sys.modules[__name__]
# Registered timers
@ -41,8 +39,7 @@ this.registry = dict()
def is_annotating(context: bpy.types.Context):
""" Check if the annotate mode is enabled
"""
active_tool = bpy.context.workspace.tools.from_space_view3d_mode('OBJECT', create=False)
return (active_tool and active_tool.idname == 'builtin.annotate')
return bpy.context.workspace.tools.from_space_view3d_mode('OBJECT', create=False).idname == 'builtin.annotate'
class Timer(object):
@ -92,7 +89,7 @@ class Timer(object):
if bpy.app.timers.is_registered(self.main):
logging.info(f"Unregistering {self.id}")
bpy.app.timers.unregister(self.main)
del this.registry[self.id]
self.is_running = False
@ -117,7 +114,6 @@ class ApplyTimer(Timer):
if node_ref.state == FETCHED:
try:
shared_data.session.applied_updates.append(node)
porcelain.apply(session.repository, node)
except Exception as e:
logging.error(f"Fail to apply {node_ref.uuid}")
@ -130,58 +126,14 @@ class ApplyTimer(Timer):
porcelain.apply(session.repository,
parent.uuid,
force=True)
if hasattr(impl, 'bl_reload_child') and impl.bl_reload_child:
for dep in node_ref.dependencies:
porcelain.apply(session.repository,
dep,
force=True)
class AnnotationUpdates(Timer):
def __init__(self, timeout=1):
self._annotating = False
self._settings = utils.get_preferences()
super().__init__(timeout)
def execute(self):
if session and session.state == STATE_ACTIVE:
ctx = bpy.context
annotation_gp = ctx.scene.grease_pencil
if annotation_gp and not annotation_gp.uuid:
ctx.scene.update_tag()
# if an annotation exist and is tracked
if annotation_gp and annotation_gp.uuid:
registered_gp = session.repository.graph.get(annotation_gp.uuid)
if is_annotating(bpy.context):
# try to get the right on it
if registered_gp.owner == RP_COMMON:
self._annotating = True
logging.debug(
"Getting the right on the annotation GP")
porcelain.lock(session.repository,
[registered_gp.uuid],
ignore_warnings=True,
affect_dependencies=False)
if registered_gp.owner == self._settings.username:
porcelain.commit(session.repository, annotation_gp.uuid)
porcelain.push(session.repository, 'origin', annotation_gp.uuid)
elif self._annotating:
porcelain.unlock(session.repository,
[registered_gp.uuid],
ignore_warnings=True,
affect_dependencies=False)
self._annotating = False
class DynamicRightSelectTimer(Timer):
def __init__(self, timeout=.1):
super().__init__(timeout)
self._last_selection = set()
self._last_selection = []
self._user = None
self._annotating = False
def execute(self):
settings = utils.get_preferences()
@ -192,47 +144,83 @@ class DynamicRightSelectTimer(Timer):
self._user = session.online_users.get(settings.username)
if self._user:
current_selection = set(utils.get_selected_objects(
bpy.context.scene,
bpy.data.window_managers['WinMan'].windows[0].view_layer
))
if current_selection != self._last_selection:
to_lock = list(current_selection.difference(self._last_selection))
to_release = list(self._last_selection.difference(current_selection))
instances_to_lock = list()
ctx = bpy.context
annotation_gp = ctx.scene.grease_pencil
for node_id in to_lock:
node = session.repository.graph.get(node_id)
if node and hasattr(node,'data'):
instance_mode = node.data.get('instance_type')
if instance_mode and instance_mode == 'COLLECTION':
to_lock.remove(node_id)
instances_to_lock.append(node_id)
if instances_to_lock:
try:
if annotation_gp and not annotation_gp.uuid:
ctx.scene.update_tag()
# if an annotation exist and is tracked
if annotation_gp and annotation_gp.uuid:
registered_gp = session.repository.graph.get(annotation_gp.uuid)
if is_annotating(bpy.context):
# try to get the right on it
if registered_gp.owner == RP_COMMON:
self._annotating = True
logging.debug(
"Getting the right on the annotation GP")
porcelain.lock(session.repository,
instances_to_lock,
registered_gp.uuid,
ignore_warnings=True,
affect_dependencies=False)
except NonAuthorizedOperationError as e:
logging.warning(e)
if registered_gp.owner == settings.username:
gp_node = session.repository.graph.get(annotation_gp.uuid)
porcelain.commit(session.repository, gp_node.uuid)
porcelain.push(session.repository, 'origin', gp_node.uuid)
if to_release:
try:
porcelain.unlock(session.repository,
to_release,
ignore_warnings=True,
affect_dependencies=True)
except NonAuthorizedOperationError as e:
logging.warning(e)
if to_lock:
try:
porcelain.lock(session.repository,
to_lock,
ignore_warnings=True,
affect_dependencies=True)
except NonAuthorizedOperationError as e:
logging.warning(e)
elif self._annotating:
porcelain.unlock(session.repository,
registered_gp.uuid,
ignore_warnings=True,
affect_dependencies=False)
current_selection = utils.get_selected_objects(
bpy.context.scene,
bpy.data.window_managers['WinMan'].windows[0].view_layer
)
if current_selection != self._last_selection:
obj_common = [
o for o in self._last_selection if o not in current_selection]
obj_ours = [
o for o in current_selection if o not in self._last_selection]
# change old selection right to common
for obj in obj_common:
node = session.repository.graph.get(obj)
if node and (node.owner == settings.username or node.owner == RP_COMMON):
recursive = True
if node.data and 'instance_type' in node.data.keys():
recursive = node.data['instance_type'] != 'COLLECTION'
try:
porcelain.unlock(session.repository,
node.uuid,
ignore_warnings=True,
affect_dependencies=recursive)
except NonAuthorizedOperationError:
logging.warning(
f"Not authorized to change {node} owner")
# change new selection to our
for obj in obj_ours:
node = session.repository.graph.get(obj)
if node and node.owner == RP_COMMON:
recursive = True
if node.data and 'instance_type' in node.data.keys():
recursive = node.data['instance_type'] != 'COLLECTION'
try:
porcelain.lock(session.repository,
node.uuid,
ignore_warnings=True,
affect_dependencies=recursive)
except NonAuthorizedOperationError:
logging.warning(
f"Not authorized to change {node} owner")
else:
return
self._last_selection = current_selection
@ -246,23 +234,23 @@ class DynamicRightSelectTimer(Timer):
# Fix deselection until right managment refactoring (with Roles concepts)
if len(current_selection) == 0 :
owned_keys = [k for k, v in session.repository.graph.items() if v.owner==settings.username]
if owned_keys:
for key in owned_keys:
node = session.repository.graph.get(key)
try:
porcelain.unlock(session.repository,
owned_keys,
key,
ignore_warnings=True,
affect_dependencies=True)
except NonAuthorizedOperationError as e:
logging.warning(e)
except NonAuthorizedOperationError:
logging.warning(
f"Not authorized to change {key} owner")
# Objects selectability
for obj in bpy.data.objects:
object_uuid = getattr(obj, 'uuid', None)
if object_uuid:
is_selectable = not session.repository.is_node_readonly(object_uuid)
if obj.hide_select != is_selectable:
obj.hide_select = is_selectable
shared_data.session.applied_updates.append(object_uuid)
class ClientUpdate(Timer):
@ -312,8 +300,7 @@ class ClientUpdate(Timer):
settings.client_color.b,
1),
'frame_current': bpy.context.scene.frame_current,
'scene_current': scene_current,
'mode_current': bpy.context.mode
'scene_current': scene_current
}
porcelain.update_user_metadata(session.repository, metadata)
@ -327,9 +314,6 @@ class ClientUpdate(Timer):
local_user_metadata['view_matrix'] = get_view_matrix(
)
porcelain.update_user_metadata(session.repository, local_user_metadata)
elif bpy.context.mode != local_user_metadata['mode_current']:
local_user_metadata['mode_current'] = bpy.context.mode
porcelain.update_user_metadata(session.repository, local_user_metadata)
class SessionStatusUpdate(Timer):
@ -357,7 +341,6 @@ class SessionUserSync(Timer):
renderer.remove_widget(f"{user.username}_cam")
renderer.remove_widget(f"{user.username}_select")
renderer.remove_widget(f"{user.username}_name")
renderer.remove_widget(f"{user.username}_mode")
ui_users.remove(index)
break
@ -373,8 +356,6 @@ class SessionUserSync(Timer):
f"{user}_select", UserSelectionWidget(user))
renderer.add_widget(
f"{user}_name", UserNameWidget(user))
renderer.add_widget(
f"{user}_mode", UserModeWidget(user))
class MainThreadExecutor(Timer):

View File

@ -16,9 +16,7 @@
# ##### END GPL LICENSE BLOCK #####
from logging import log
import bpy
import bpy.utils.previews
from .utils import get_preferences, get_expanded_icon, get_folder_size, get_state_str
from replication.constants import (ADDED, ERROR, FETCHED,
@ -73,180 +71,162 @@ class SESSION_PT_settings(bpy.types.Panel):
def draw_header(self, context):
layout = self.layout
settings = get_preferences()
from multi_user import icons
offline_icon = icons.icons_col["session_status_offline"]
waiting_icon = icons.icons_col["session_status_waiting"]
online_icon = icons.icons_col["session_status_online"]
if session and session.state != STATE_INITIAL:
cli_state = session.state
state = session.state
connection_icon = offline_icon
connection_icon = "KEYTYPE_MOVING_HOLD_VEC"
if state == STATE_ACTIVE:
connection_icon = online_icon
connection_icon = 'PROP_ON'
else:
connection_icon = waiting_icon
connection_icon = 'PROP_CON'
layout.label(text=f"{str(settings.server_name)} - {get_state_str(cli_state)}", icon_value=connection_icon.icon_id)
layout.label(text=f"Session - {get_state_str(cli_state)}", icon=connection_icon)
else:
layout.label(text=f"Multi-user - v{__version__}", icon="ANTIALIASED")
layout.label(text=f"Session - v{__version__}",icon="PROP_OFF")
def draw(self, context):
layout = self.layout
row = layout.row()
runtime_settings = context.window_manager.session
settings = get_preferences()
if settings.is_first_launch:
# USER SETTINGS
row = layout.row()
row.label(text="1. Enter your username and color:")
row = layout.row()
split = row.split(factor=0.7, align=True)
split.prop(settings, "username", text="")
split.prop(settings, "client_color", text="")
if hasattr(context.window_manager, 'session'):
# STATE INITIAL
if not session \
or (session and session.state == STATE_INITIAL):
pass
else:
progress = session.state_progress
row = layout.row()
# DOC
row = layout.row()
row.label(text="2. New here ? See the doc:")
row = layout.row()
row.operator("doc.get", text="Documentation", icon="HELP")
# START
row = layout.row()
row.label(text="3: Start the Multi-user:")
row = layout.row()
row.scale_y = 2
row.operator("firstlaunch.verify", text="Continue")
if not settings.is_first_launch:
if hasattr(context.window_manager, 'session'):
# STATE INITIAL
if not session \
or (session and session.state == STATE_INITIAL):
layout = self.layout
settings = get_preferences()
server_preset = settings.server_preset
selected_server = context.window_manager.server_index if context.window_manager.server_index<=len(server_preset)-1 else 0
active_server_name = server_preset[selected_server].name if len(server_preset)>=1 else ""
is_server_selected = True if active_server_name else False
current_state = session.state
info_msg = None
# SERVER LIST
row = layout.row()
box = row.box()
box.scale_y = 0.7
split = box.split(factor=0.7)
split.label(text="Server")
split.label(text="Online")
if current_state in [STATE_ACTIVE]:
row = row.grid_flow(row_major=True, columns=0, even_columns=True, even_rows=False, align=True)
row.prop(settings.sync_flags, "sync_render_settings",text="",icon_only=True, icon='SCENE')
row.prop(settings.sync_flags, "sync_during_editmode", text="",icon_only=True, icon='EDITMODE_HLT')
row.prop(settings.sync_flags, "sync_active_camera", text="",icon_only=True, icon='OBJECT_DATAMODE')
col = row.column(align=True)
col.operator("session.get_info", icon="FILE_REFRESH", text="")
row= layout.row()
row = layout.row()
col = row.column(align=True)
col.template_list("SESSION_UL_network", "", settings, "server_preset", context.window_manager, "server_index")
col.separator()
connectOp = col.row()
connectOp.enabled =is_server_selected
connectOp.operator("session.connect", text="Connect")
if current_state in [STATE_ACTIVE] and runtime_settings.is_host:
info_msg = f"LAN: {runtime_settings.internet_ip}"
if current_state == STATE_LOBBY:
info_msg = "Waiting for the session to start."
col = row.column(align=True)
col.operator("session.preset_server_add", icon="ADD", text="") # TODO : add conditions (need a name, etc..)
row_visible = col.row(align=True)
col_visible = row_visible.column(align=True)
col_visible.enabled = is_server_selected
col_visible.operator("session.preset_server_remove", icon="REMOVE", text="").target_server_name = active_server_name
col_visible.separator()
col_visible.operator("session.preset_server_edit", icon="GREASEPENCIL", text="").target_server_name = active_server_name
if info_msg:
info_box = row.box()
info_box.row().label(text=info_msg,icon='INFO')
else:
exitbutton = layout.row()
exitbutton.scale_y = 1.5
exitbutton.operator("session.stop", icon='QUIT', text="Disconnect")
# Progress bar
if current_state in [STATE_SYNCING, STATE_SRV_SYNC, STATE_WAITING]:
info_box = row.box()
info_box.row().label(text=printProgressBar(
progress['current'],
progress['total'],
length=16
))
progress = session.state_progress
current_state = session.state
info_msg = None
if current_state == STATE_LOBBY:
usr = session.online_users.get(settings.username)
row= layout.row()
info_msg = "Waiting for the session to start."
if usr and usr['admin']:
info_msg = "Init the session to start."
info_box = layout.row()
info_box.label(text=info_msg,icon='INFO')
init_row = layout.row()
init_row.operator("session.init", icon='TOOL_SETTINGS', text="Init")
else:
info_box = layout.row()
info_box.row().label(text=info_msg,icon='INFO')
layout.row().operator("session.stop", icon='QUIT', text="Exit")
# PROGRESS BAR
if current_state in [STATE_SYNCING, STATE_SRV_SYNC, STATE_WAITING]:
row= layout.row()
row.label(text=f"Status: {get_state_str(current_state)}")
row= layout.row()
info_box = row.box()
info_box.label(text=printProgressBar(
progress['current'],
progress['total'],
length=16
))
class SESSION_PT_host_settings(bpy.types.Panel):
bl_idname = "MULTIUSER_SETTINGS_HOST_PT_panel"
bl_label = "Hosting"
class SESSION_PT_settings_network(bpy.types.Panel):
bl_idname = "MULTIUSER_SETTINGS_NETWORK_PT_panel"
bl_label = "Network"
bl_space_type = 'VIEW_3D'
bl_region_type = 'UI'
bl_parent_id = 'MULTIUSER_SETTINGS_PT_panel'
bl_options = {'DEFAULT_CLOSED'}
@classmethod
def poll(cls, context):
settings = get_preferences()
return not session \
or (session and session.state == 0) \
and not settings.sidebar_advanced_shown \
and not settings.is_first_launch
or (session and session.state == 0)
def draw_header(self, context):
self.layout.label(text="", icon='NETWORK_DRIVE')
self.layout.label(text="", icon='URL')
def draw(self, context):
layout = self.layout
runtime_settings = context.window_manager.session
settings = get_preferences()
#HOST
host_selection = layout.row().box()
host_selection_row = host_selection.row()
host_selection_row.label(text="Init the session from:")
host_selection_row.prop(settings, "init_method", text="")
host_selection_row = host_selection.row()
host_selection_row.label(text="Port:")
host_selection_row.prop(settings, "host_port", text="")
host_selection_row = host_selection.row()
host_selection_col = host_selection_row.column()
host_selection_col.prop(settings, "host_use_server_password", text="Server password:")
host_selection_col = host_selection_row.column()
host_selection_col.enabled = True if settings.host_use_server_password else False
host_selection_col.prop(settings, "host_server_password", text="")
host_selection_row = host_selection.row()
host_selection_col = host_selection_row.column()
host_selection_col.prop(settings, "host_use_admin_password", text="Admin password:")
host_selection_col = host_selection_row.column()
host_selection_col.enabled = True if settings.host_use_admin_password else False
host_selection_col.prop(settings, "host_admin_password", text="")
# USER SETTINGS
row = layout.row()
row.prop(runtime_settings, "session_mode", expand=True)
row = layout.row()
col = row.row(align=True)
col.prop(settings, "server_preset_interface", text="")
col.operator("session.preset_server_add", icon='ADD', text="")
col.operator("session.preset_server_remove", icon='REMOVE', text="")
host_selection = layout.column()
host_selection.operator("session.host", text="Host")
row = layout.row()
box = row.box()
if runtime_settings.session_mode == 'HOST':
row = box.row()
row.label(text="Port:")
row.prop(settings, "port", text="")
row = box.row()
row.label(text="Start from:")
row.prop(settings, "init_method", text="")
row = box.row()
row.label(text="Admin password:")
row.prop(settings, "password", text="")
row = box.row()
row.operator("session.start", text="HOST").host = True
else:
row = box.row()
row.prop(settings, "ip", text="IP")
row = box.row()
row.label(text="Port:")
row.prop(settings, "port", text="")
row = box.row()
row.prop(runtime_settings, "admin", text='Connect as admin', icon='DISCLOSURE_TRI_DOWN' if runtime_settings.admin
else 'DISCLOSURE_TRI_RIGHT')
if runtime_settings.admin:
row = box.row()
row.label(text="Password:")
row.prop(settings, "password", text="")
row = box.row()
row.operator("session.start", text="CONNECT").host = False
class SESSION_PT_settings_user(bpy.types.Panel):
bl_idname = "MULTIUSER_SETTINGS_USER_PT_panel"
bl_label = "User info"
bl_space_type = 'VIEW_3D'
bl_region_type = 'UI'
bl_parent_id = 'MULTIUSER_SETTINGS_PT_panel'
@classmethod
def poll(cls, context):
return not session \
or (session and session.state == 0)
def draw_header(self, context):
self.layout.label(text="", icon='USER')
def draw(self, context):
layout = self.layout
runtime_settings = context.window_manager.session
settings = get_preferences()
row = layout.row()
# USER SETTINGS
row.prop(settings, "username", text="name")
row = layout.row()
row.prop(settings, "client_color", text="color")
row = layout.row()
class SESSION_PT_advanced_settings(bpy.types.Panel):
bl_idname = "MULTIUSER_SETTINGS_REPLICATION_PT_panel"
bl_label = "General Settings"
bl_label = "Advanced"
bl_space_type = 'VIEW_3D'
bl_region_type = 'UI'
bl_parent_id = 'MULTIUSER_SETTINGS_PT_panel'
@ -254,34 +234,19 @@ class SESSION_PT_advanced_settings(bpy.types.Panel):
@classmethod
def poll(cls, context):
settings = get_preferences()
return not session \
or (session and session.state == 0) \
and not settings.sidebar_advanced_shown \
and not settings.is_first_launch
or (session and session.state == 0)
def draw_header(self, context):
self.layout.label(text="", icon='PREFERENCES')
def draw(self, context):
layout = self.layout
runtime_settings = context.window_manager.session
settings = get_preferences()
#ADVANCED USER INFO
uinfo_section = layout.row().box()
uinfo_section.prop(
settings,
"sidebar_advanced_uinfo_expanded",
text="User Info",
icon=get_expanded_icon(settings.sidebar_advanced_uinfo_expanded),
emboss=False)
if settings.sidebar_advanced_uinfo_expanded:
uinfo_section_row = uinfo_section.row()
uinfo_section_split = uinfo_section_row.split(factor=0.7, align=True)
uinfo_section_split.prop(settings, "username", text="")
uinfo_section_split.prop(settings, "client_color", text="")
#ADVANCED NET
net_section = layout.row().box()
net_section.prop(
settings,
@ -289,15 +254,12 @@ class SESSION_PT_advanced_settings(bpy.types.Panel):
text="Network",
icon=get_expanded_icon(settings.sidebar_advanced_net_expanded),
emboss=False)
if settings.sidebar_advanced_net_expanded:
net_section_row = net_section.row()
net_section_row.label(text="Timeout (ms):")
net_section_row.prop(settings, "connection_timeout", text="")
net_section_row = net_section.row()
net_section_row.label(text="Server ping (ms):")
net_section_row.prop(settings, "ping_timeout", text="")
#ADVANCED REPLICATION
replication_section = layout.row().box()
replication_section.prop(
settings,
@ -305,12 +267,16 @@ class SESSION_PT_advanced_settings(bpy.types.Panel):
text="Replication",
icon=get_expanded_icon(settings.sidebar_advanced_rep_expanded),
emboss=False)
if settings.sidebar_advanced_rep_expanded:
replication_section_row = replication_section.row()
replication_section_row = replication_section.row()
replication_section_row.prop(settings.sync_flags, "sync_render_settings")
replication_section_row = replication_section.row()
replication_section_row.prop(settings.sync_flags, "sync_active_camera")
replication_section_row = replication_section.row()
replication_section_row.prop(settings.sync_flags, "sync_during_editmode")
replication_section_row = replication_section.row()
if settings.sync_flags.sync_during_editmode:
@ -319,7 +285,7 @@ class SESSION_PT_advanced_settings(bpy.types.Panel):
replication_section_row = replication_section.row()
replication_section_row.prop(settings, "depsgraph_update_rate", text="Apply delay")
#ADVANCED CACHE
cache_section = layout.row().box()
cache_section.prop(
settings,
@ -337,8 +303,6 @@ class SESSION_PT_advanced_settings(bpy.types.Panel):
cache_section_row.prop(settings, "clear_memory_filecache", text="")
cache_section_row = cache_section.row()
cache_section_row.operator('session.clear_cache', text=f"Clear cache ({get_folder_size(settings.cache_directory)})")
#ADVANCED LOG
log_section = layout.row().box()
log_section.prop(
settings,
@ -346,11 +310,11 @@ class SESSION_PT_advanced_settings(bpy.types.Panel):
text="Logging",
icon=get_expanded_icon(settings.sidebar_advanced_log_expanded),
emboss=False)
if settings.sidebar_advanced_log_expanded:
log_section_row = log_section.row()
log_section_row.label(text="Log level:")
log_section_row.prop(settings, 'logging_level', text="")
class SESSION_PT_user(bpy.types.Panel):
bl_idname = "MULTIUSER_USER_PT_panel"
bl_label = "Online users"
@ -360,8 +324,7 @@ class SESSION_PT_user(bpy.types.Panel):
@classmethod
def poll(cls, context):
return session \
and session.state in [STATE_ACTIVE, STATE_LOBBY]
return session and session.state in [STATE_ACTIVE, STATE_LOBBY]
def draw_header(self, context):
self.layout.label(text="", icon='USER')
@ -373,23 +336,22 @@ class SESSION_PT_user(bpy.types.Panel):
settings = get_preferences()
active_user = online_users[selected_user] if len(
online_users)-1 >= selected_user else 0
runtime_settings = context.window_manager.session
#USER LIST
# Create a simple row.
row = layout.row()
box = row.box()
split = box.split(factor=0.35)
split.label(text="user")
split = split.split(factor=0.3)
split.label(text="mode")
split.label(text="frame")
split = split.split(factor=0.5)
split.label(text="location")
split.label(text="frame")
split.label(text="ping")
row = layout.row()
layout.template_list("SESSION_UL_users", "", context.window_manager,
"online_users", context.window_manager, "user_index")
#OPERATOR ON USER
if active_user != 0 and active_user.username != settings.username:
row = layout.row()
user_operations = row.split()
@ -421,8 +383,6 @@ class SESSION_UL_users(bpy.types.UIList):
ping = '-'
frame_current = '-'
scene_current = '-'
mode_current = '-'
mode_icon = 'BLANK1'
status_icon = 'BLANK1'
if session:
user = session.online_users.get(item.username)
@ -432,49 +392,57 @@ class SESSION_UL_users(bpy.types.UIList):
if metadata and 'frame_current' in metadata:
frame_current = str(metadata.get('frame_current','-'))
scene_current = metadata.get('scene_current','-')
mode_current = metadata.get('mode_current','-')
if mode_current == "OBJECT" :
mode_icon = "OBJECT_DATAMODE"
elif mode_current == "EDIT_MESH" :
mode_icon = "EDITMODE_HLT"
elif mode_current == 'EDIT_CURVE':
mode_icon = "CURVE_DATA"
elif mode_current == 'EDIT_SURFACE':
mode_icon = "SURFACE_DATA"
elif mode_current == 'EDIT_TEXT':
mode_icon = "FILE_FONT"
elif mode_current == 'EDIT_ARMATURE':
mode_icon = "ARMATURE_DATA"
elif mode_current == 'EDIT_METABALL':
mode_icon = "META_BALL"
elif mode_current == 'EDIT_LATTICE':
mode_icon = "LATTICE_DATA"
elif mode_current == 'POSE':
mode_icon = "POSE_HLT"
elif mode_current == 'SCULPT':
mode_icon = "SCULPTMODE_HLT"
elif mode_current == 'PAINT_WEIGHT':
mode_icon = "WPAINT_HLT"
elif mode_current == 'PAINT_VERTEX':
mode_icon = "VPAINT_HLT"
elif mode_current == 'PAINT_TEXTURE':
mode_icon = "TPAINT_HLT"
elif mode_current == 'PARTICLE':
mode_icon = "PARTICLES"
elif mode_current == 'PAINT_GPENCIL' or mode_current =='EDIT_GPENCIL' or mode_current =='SCULPT_GPENCIL' or mode_current =='WEIGHT_GPENCIL' or mode_current =='VERTEX_GPENCIL':
mode_icon = "GREASEPENCIL"
if user['admin']:
status_icon = 'FAKE_USER_ON'
split = layout.split(factor=0.35)
split.label(text=item.username, icon=status_icon)
split = split.split(factor=0.3)
split.label(icon=mode_icon)
split.label(text=frame_current)
split = split.split(factor=0.5)
split.label(text=scene_current)
split.label(text=frame_current)
split.label(text=ping)
class SESSION_PT_presence(bpy.types.Panel):
bl_idname = "MULTIUSER_MODULE_PT_panel"
bl_label = "Presence overlay"
bl_space_type = 'VIEW_3D'
bl_region_type = 'UI'
bl_parent_id = 'MULTIUSER_SETTINGS_PT_panel'
bl_options = {'DEFAULT_CLOSED'}
@classmethod
def poll(cls, context):
return not session \
or (session and session.state in [STATE_INITIAL, STATE_ACTIVE])
def draw_header(self, context):
self.layout.prop(context.window_manager.session,
"enable_presence", text="",icon='OVERLAY')
def draw(self, context):
layout = self.layout
settings = context.window_manager.session
pref = get_preferences()
layout.active = settings.enable_presence
col = layout.column()
col.prop(settings, "presence_show_session_status")
row = col.column()
row.active = settings.presence_show_session_status
row.prop(pref, "presence_hud_scale", expand=True)
row = col.column(align=True)
row.active = settings.presence_show_session_status
row.prop(pref, "presence_hud_hpos", expand=True)
row.prop(pref, "presence_hud_vpos", expand=True)
col.prop(settings, "presence_show_selected")
col.prop(settings, "presence_show_user")
row = layout.column()
row.active = settings.presence_show_user
row.prop(settings, "presence_show_far_user")
def draw_property(context, parent, property_uuid, level=0):
settings = get_preferences()
runtime_settings = context.window_manager.session
item = session.repository.graph.get(property_uuid)
type_id = item.data.get('type_id')
area_msg = parent.row(align=True)
@ -494,18 +462,15 @@ def draw_property(context, parent, property_uuid, level=0):
detail_item_box.label(text=f"{name}")
# Operations
have_right_to_modify = (item.owner == settings.username or \
item.owner == RP_COMMON) and item.state != ERROR
from multi_user import icons
sync_status = icons.icons_col["repository_push"] #TODO: Link all icons to the right sync (push/merge/issue). For issue use "UNLINKED" for icon
# sync_status = icons.icons_col["repository_merge"]
if have_right_to_modify:
detail_item_box.operator(
"session.commit",
text="",
icon_value=sync_status.icon_id).target = item.uuid
icon='TRIA_UP').target = item.uuid
detail_item_box.separator()
if item.state in [FETCHED, UP]:
@ -537,72 +502,12 @@ def draw_property(context, parent, property_uuid, level=0):
else:
detail_item_box.label(text="", icon="DECORATE_LOCKED")
class SESSION_PT_sync(bpy.types.Panel):
bl_idname = "MULTIUSER_SYNC_PT_panel"
bl_label = "Synchronize"
bl_space_type = 'VIEW_3D'
bl_region_type = 'UI'
bl_parent_id = 'MULTIUSER_SETTINGS_PT_panel'
bl_options = {'DEFAULT_CLOSED'}
@classmethod
def poll(cls, context):
return session \
and session.state in [STATE_ACTIVE]
def draw_header(self, context):
self.layout.label(text="", icon='UV_SYNC_SELECT')
def draw(self, context):
layout = self.layout
settings = get_preferences()
row= layout.row()
row = row.grid_flow(row_major=True, columns=0, even_columns=True, even_rows=False, align=True)
row.prop(settings.sync_flags, "sync_render_settings",text="",icon_only=True, icon='SCENE')
row.prop(settings.sync_flags, "sync_during_editmode", text="",icon_only=True, icon='EDITMODE_HLT')
row.prop(settings.sync_flags, "sync_active_camera", text="",icon_only=True, icon='VIEW_CAMERA')
class SESSION_PT_replay(bpy.types.Panel):
bl_idname = "MULTIUSER_REPLAY_PT_panel"
bl_label = "Replay"
bl_space_type = 'VIEW_3D'
bl_region_type = 'UI'
bl_parent_id = 'MULTIUSER_SETTINGS_PT_panel'
bl_options = {'DEFAULT_CLOSED'}
@classmethod
def poll(cls, context):
return context.window_manager.session.replay_files
def draw_header(self, context):
self.layout.label(text="", icon='RECOVER_LAST')
def draw(self, context):
layout = self.layout
settings = context.window_manager.session
row= layout.row()
row.prop(settings,'replay_mode', toggle=True, expand=True)
row= layout.row()
if settings.replay_mode == 'MANUAL':
row.prop(bpy.context.scene, 'active_replay_file', text="Snapshot index")
else:
row.prop(settings, 'replay_duration', text="Replay Duration")
row= layout.row()
row.prop(settings, 'replay_persistent_collection', text="persistent collection", toggle=True, icon='OUTLINER_COLLECTION')
if settings.replay_persistent_collection:
row= layout.row()
row.prop(settings, 'replay_camera', text="", icon='VIEW_CAMERA')
class SESSION_PT_repository(bpy.types.Panel):
bl_idname = "MULTIUSER_PROPERTIES_PT_panel"
bl_label = "Repository"
bl_space_type = 'VIEW_3D'
bl_space_type = 'VIEW_3D'
bl_region_type = 'UI'
bl_parent_id = 'MULTIUSER_SETTINGS_PT_panel'
bl_options = {'DEFAULT_CLOSED'}
@classmethod
def poll(cls, context):
@ -615,8 +520,8 @@ class SESSION_PT_repository(bpy.types.Panel):
admin = usr['admin']
return hasattr(context.window_manager, 'session') and \
session and \
session.state == STATE_ACTIVE and \
not settings.sidebar_repository_shown
(session.state == STATE_ACTIVE or \
session.state == STATE_LOBBY and admin)
def draw_header(self, context):
self.layout.label(text="", icon='OUTLINER_OB_GROUP_INSTANCE')
@ -630,18 +535,19 @@ class SESSION_PT_repository(bpy.types.Panel):
usr = session.online_users.get(settings.username)
row = layout.row()
if session.state == STATE_ACTIVE:
if 'SessionBackupTimer' in registry:
row = layout.row()
row.alert = True
row.operator('session.cancel_autosave', icon="CANCEL")
row.alert = False
# else:
# row.operator('session.save', icon="FILE_TICK")
else:
row.operator('session.save', icon="FILE_TICK")
box = layout.box()
row = box.row()
row.prop(runtime_settings, "filter_owned", text="Only show owned data blocks", icon_only=True, icon="DECORATE_UNLOCKED")
row.prop(runtime_settings, "filter_owned", text="Show only owned Nodes", icon_only=True, icon="DECORATE_UNLOCKED")
row = box.row()
row.prop(runtime_settings, "filter_name", text="Filter")
row = box.row()
@ -649,18 +555,27 @@ class SESSION_PT_repository(bpy.types.Panel):
# Properties
owned_nodes = [k for k, v in session.repository.graph.items() if v.owner==settings.username]
filtered_node = owned_nodes if runtime_settings.filter_owned else list(session.repository.graph.keys())
filtered_node = owned_nodes if runtime_settings.filter_owned else session.repository.graph.keys()
if runtime_settings.filter_name:
filtered_node = [n for n in filtered_node if runtime_settings.filter_name.lower() in session.repository.graph.get(n).data.get('name').lower()]
for node_id in filtered_node:
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 filtered_node:
col = layout.column(align=True)
for key in filtered_node:
draw_property(context, col, key)
else:
layout.row().label(text="Empty")
elif session.state == STATE_LOBBY and usr and usr['admin']:
row.operator("session.init", icon='TOOL_SETTINGS', text="Init")
else:
row.label(text="Waiting to start")
class VIEW3D_PT_overlay_session(bpy.types.Panel):
bl_space_type = 'VIEW_3D'
@ -675,75 +590,37 @@ class VIEW3D_PT_overlay_session(bpy.types.Panel):
def draw(self, context):
layout = self.layout
settings = context.window_manager.session
pref = get_preferences()
layout.active = settings.enable_presence
row = layout.row()
row.prop(settings, "enable_presence",text="Presence Overlay")
row = layout.row()
row.prop(settings, "presence_show_selected",text="Selected Objects")
row = layout.row(align=True)
row.prop(settings, "presence_show_user", text="Users camera")
row.prop(settings, "presence_show_mode", text="Users mode")
view = context.space_data
overlay = view.overlay
display_all = overlay.show_overlays
col = layout.column()
if settings.presence_show_mode or settings.presence_show_user:
row = col.column()
row.prop(pref, "presence_text_distance", expand=True)
row = col.column()
row.prop(settings, "presence_show_far_user", text="Users on different scenes")
row = col.row(align=True)
settings = context.window_manager.session
layout.active = settings.enable_presence
col = layout.column()
col.prop(settings, "presence_show_session_status")
if settings.presence_show_session_status :
split = layout.split()
text_pos = split.column(align=True)
text_pos.active = settings.presence_show_session_status
text_pos.prop(pref, "presence_hud_hpos", expand=True)
text_pos.prop(pref, "presence_hud_vpos", expand=True)
text_scale = split.column()
text_scale.active = settings.presence_show_session_status
text_scale.prop(pref, "presence_hud_scale", expand=True)
col.prop(settings, "presence_show_selected")
col.prop(settings, "presence_show_user")
class SESSION_UL_network(bpy.types.UIList):
def draw_item(self, context, layout, data, item, icon, active_data, active_propname, index, flt_flag):
settings = get_preferences()
server_name = '-'
server_status = 'BLANK1'
server_private = 'BLANK1'
server_name = item.server_name
split = layout.split(factor=0.7)
if item.is_private:
server_private = 'LOCKED'
split.label(text=server_name, icon=server_private)
else:
split.label(text=server_name)
from multi_user import icons
server_status = icons.icons_col["server_offline"]
if item.is_online:
server_status = icons.icons_col["server_online"]
split.label(icon_value=server_status.icon_id)
row = layout.column()
row.active = settings.presence_show_user
row.prop(settings, "presence_show_far_user")
classes = (
SESSION_UL_users,
SESSION_UL_network,
SESSION_PT_settings,
SESSION_PT_host_settings,
SESSION_PT_settings_user,
SESSION_PT_settings_network,
SESSION_PT_presence,
SESSION_PT_advanced_settings,
SESSION_PT_user,
SESSION_PT_sync,
SESSION_PT_repository,
VIEW3D_PT_overlay_session,
SESSION_PT_replay,
)
register, unregister = bpy.utils.register_classes_factory(classes)
if __name__ == "__main__":

View File

@ -38,6 +38,7 @@ from replication.constants import (STATE_ACTIVE, STATE_AUTH,
STATE_LOBBY,
CONNECTING)
def find_from_attr(attr_name, attr_value, list):
for item in list:
if getattr(item, attr_name, None) == attr_value:
@ -99,6 +100,24 @@ def get_state_str(state):
return state_str
def clean_scene():
to_delete = [f for f in dir(bpy.data) if f not in ['brushes', 'palettes']]
for type_name in to_delete:
try:
sub_collection_to_avoid = [bpy.data.linestyles['LineStyle'], bpy.data.materials['Dots Stroke']]
type_collection = getattr(bpy.data, type_name)
items_to_remove = [i for i in type_collection if i not in sub_collection_to_avoid]
for item in items_to_remove:
try:
type_collection.remove(item)
except:
continue
except:
continue
# Clear sequencer
bpy.context.scene.sequence_editor_clear()
def get_selected_objects(scene, active_view_layer):
return [obj.uuid for obj in scene.objects if obj.select_get(view_layer=active_view_layer)]

View File

@ -1,7 +1,7 @@
# Download base image debian jessie
FROM python:slim
ARG replication_version=0.9.1
ARG replication_version=0.1.13
ARG version=0.1.1
# Infos
@ -22,4 +22,4 @@ RUN pip install replication==$replication_version
# Run the server with parameters
ENTRYPOINT ["/bin/sh", "-c"]
CMD ["replication.serve -apwd ${password} -spwd '' -p ${port} -t ${timeout} -l ${log_level} -lf ${log_file}"]
CMD ["python3 -m replication.server -pwd ${password} -p ${port} -t ${timeout} -l ${log_level} -lf ${log_file}"]

View File

@ -7,7 +7,7 @@ import bpy
from multi_user.bl_types.bl_lightprobe import BlLightprobe
@pytest.mark.skipif(bpy.app.version < (2,83,0), reason="requires blender 2.83 or higher")
@pytest.mark.skipif(bpy.app.version[1] < 83, reason="requires blender 2.83 or higher")
@pytest.mark.parametrize('lightprobe_type', ['PLANAR','GRID','CUBEMAP'])
def test_lightprobes(clear_blend, lightprobe_type):
bpy.ops.object.lightprobe_add(type=lightprobe_type)

View File

@ -8,7 +8,7 @@ from multi_user.bl_types.bl_material import BlMaterial
def test_material_nodes(clear_blend):
nodes_types = [node.bl_rna.identifier for node in bpy.types.ShaderNode.__subclasses__()]
nodes_types = [node.bl_rna.identifier for node in bpy.types.ShaderNode.__subclasses__()] # Faire un peu comme ici
datablock = bpy.data.materials.new("test")
datablock.use_nodes = True

View File

@ -11,9 +11,8 @@ from multi_user.utils import get_preferences
def test_scene(clear_blend):
get_preferences().sync_flags.sync_render_settings = True
datablock = bpy.data.scenes.new("toto")
datablock.timeline_markers.new('toto', frame=10)
datablock.timeline_markers.new('tata', frame=1)
# datablock = bpy.data.scenes.new("toto") # TODO: trouver datablock -> active compositing 'Use nodes'
datablock = bpy.data.scenes["Scene"].use_nodes
datablock.view_settings.use_curve_mapping = True
# Test
implementation = BlScene()

20
tests/test_operators.py Normal file
View File

@ -0,0 +1,20 @@
import os
import pytest
from deepdiff import DeepDiff
import bpy
import random
def test_start_session():
result = bpy.ops.session.start()
assert 'FINISHED' in result
def test_stop_session():
result = bpy.ops.session.stop()
assert 'FINISHED' in result