Merge branch 'export_replication_graph' into 'develop'
Export replication graph See merge request slumber/multi-user!85
This commit is contained in:
commit
66b6c06a2c
BIN
docs/getting_started/img/quickstart_cancel_save_session_data.png
Normal file
BIN
docs/getting_started/img/quickstart_cancel_save_session_data.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 14 KiB |
BIN
docs/getting_started/img/quickstart_import_session_data.png
Normal file
BIN
docs/getting_started/img/quickstart_import_session_data.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 106 KiB |
BIN
docs/getting_started/img/quickstart_save_session_data.png
Normal file
BIN
docs/getting_started/img/quickstart_save_session_data.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 17 KiB |
BIN
docs/getting_started/img/quickstart_save_session_data_cancel.png
Normal file
BIN
docs/getting_started/img/quickstart_save_session_data_cancel.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 14 KiB |
BIN
docs/getting_started/img/quickstart_save_session_data_dialog.png
Normal file
BIN
docs/getting_started/img/quickstart_save_session_data_dialog.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 80 KiB |
@ -292,7 +292,7 @@ a connected user or be under :ref:`common-right<**COMMON**>` rights.
|
||||
|
||||
The Repository panel (see image below) allows you to monitor, change datablock states and rights manually.
|
||||
|
||||
.. figure:: img/quickstart_properties.png
|
||||
.. figure:: img/quickstart_save_session_data.png
|
||||
:align: center
|
||||
|
||||
Repository panel
|
||||
@ -319,6 +319,40 @@ Here is a quick list of available actions:
|
||||
| .. image:: img/quickstart_remove.png | **Delete** | Remove the data-block from network replication |
|
||||
+---------------------------------------+-------------------+------------------------------------------------------------------------------------+
|
||||
|
||||
Save session data
|
||||
-----------------
|
||||
|
||||
.. danger::
|
||||
This is an experimental feature, until the stable release it is highly recommended to use regular .blend save.
|
||||
|
||||
The save session data allows you to create a backup of the session data.
|
||||
|
||||
When you hit the **save session data** button, the following popup dialog will appear.
|
||||
It allows you to choose the destination folder and if you want to run an auto-save.
|
||||
|
||||
.. figure:: img/quickstart_save_session_data_dialog.png
|
||||
:align: center
|
||||
|
||||
Save session data dialog.
|
||||
|
||||
If you enabled the auto-save option, you can cancel it from the **Cancel auto-save** button.
|
||||
|
||||
.. figure:: img/quickstart_save_session_data_cancel.png
|
||||
:align: center
|
||||
|
||||
Cancel session autosave.
|
||||
|
||||
|
||||
To import session data backups, use the following **Multiuser session snapshot** import dialog
|
||||
|
||||
.. figure:: img/quickstart_import_session_data.png
|
||||
:align: center
|
||||
|
||||
Import session data dialog.
|
||||
|
||||
.. note::
|
||||
It is not yet possible to start a session directly from a backup.
|
||||
|
||||
.. _advanced:
|
||||
|
||||
Advanced settings
|
||||
|
@ -89,6 +89,8 @@ def register():
|
||||
type=preferences.SessionUser
|
||||
)
|
||||
bpy.types.WindowManager.user_index = bpy.props.IntProperty()
|
||||
bpy.types.TOPBAR_MT_file_import.append(operators.menu_func_import)
|
||||
|
||||
|
||||
def unregister():
|
||||
from . import presence
|
||||
@ -97,6 +99,8 @@ def unregister():
|
||||
from . import preferences
|
||||
from . import addon_updater_ops
|
||||
|
||||
bpy.types.TOPBAR_MT_file_import.remove(operators.menu_func_import)
|
||||
|
||||
presence.unregister()
|
||||
addon_updater_ops.unregister()
|
||||
ui.unregister()
|
||||
|
@ -17,6 +17,8 @@
|
||||
|
||||
|
||||
import asyncio
|
||||
import copy
|
||||
import gzip
|
||||
import logging
|
||||
import os
|
||||
import queue
|
||||
@ -25,27 +27,35 @@ import shutil
|
||||
import string
|
||||
import sys
|
||||
import time
|
||||
from datetime import datetime
|
||||
from operator import itemgetter
|
||||
from pathlib import Path
|
||||
from queue import Queue
|
||||
from time import gmtime, strftime
|
||||
|
||||
try:
|
||||
import _pickle as pickle
|
||||
except ImportError:
|
||||
import pickle
|
||||
|
||||
import bpy
|
||||
import mathutils
|
||||
from bpy.app.handlers import persistent
|
||||
from replication.constants import (FETCHED, RP_COMMON, STATE_ACTIVE,
|
||||
from bpy_extras.io_utils import ExportHelper, ImportHelper
|
||||
from replication.constants import (COMMITED, FETCHED, RP_COMMON, STATE_ACTIVE,
|
||||
STATE_INITIAL, STATE_SYNCING, UP)
|
||||
from replication.data import ReplicatedDataFactory
|
||||
from replication.exception import NonAuthorizedOperationError
|
||||
from replication.interface import session
|
||||
|
||||
from . import bl_types, delayable, environment, ui, utils
|
||||
from . import bl_types, environment, timers, ui, utils
|
||||
from .presence import SessionStatusWidget, renderer, view3d_find
|
||||
from .timers import registry
|
||||
|
||||
background_execution_queue = Queue()
|
||||
deleyables = []
|
||||
stop_modal_executor = False
|
||||
|
||||
|
||||
def session_callback(name):
|
||||
""" Session callback wrapper
|
||||
|
||||
@ -193,8 +203,8 @@ class SessionStartOperator(bpy.types.Operator):
|
||||
if settings.update_method == 'DEFAULT':
|
||||
if type_local_config.bl_delay_apply > 0:
|
||||
deleyables.append(
|
||||
delayable.ApplyTimer(
|
||||
timout=type_local_config.bl_delay_apply,
|
||||
timers.ApplyTimer(
|
||||
timeout=type_local_config.bl_delay_apply,
|
||||
target_type=type_module_class))
|
||||
|
||||
if bpy.app.version[1] >= 91:
|
||||
@ -208,7 +218,7 @@ class SessionStartOperator(bpy.types.Operator):
|
||||
external_update_handling=use_extern_update)
|
||||
|
||||
if settings.update_method == 'DEPSGRAPH':
|
||||
deleyables.append(delayable.ApplyTimer(
|
||||
deleyables.append(timers.ApplyTimer(
|
||||
settings.depsgraph_update_rate/1000))
|
||||
|
||||
# Host a session
|
||||
@ -259,12 +269,12 @@ class SessionStartOperator(bpy.types.Operator):
|
||||
logging.error(str(e))
|
||||
|
||||
# Background client updates service
|
||||
deleyables.append(delayable.ClientUpdate())
|
||||
deleyables.append(delayable.DynamicRightSelectTimer())
|
||||
deleyables.append(timers.ClientUpdate())
|
||||
deleyables.append(timers.DynamicRightSelectTimer())
|
||||
|
||||
session_update = delayable.SessionStatusUpdate()
|
||||
session_user_sync = delayable.SessionUserSync()
|
||||
session_background_executor = delayable.MainThreadExecutor(
|
||||
session_update = timers.SessionStatusUpdate()
|
||||
session_user_sync = timers.SessionUserSync()
|
||||
session_background_executor = timers.MainThreadExecutor(
|
||||
execution_queue=background_execution_queue)
|
||||
|
||||
session_update.register()
|
||||
@ -712,6 +722,181 @@ class SessionNotifyOperator(bpy.types.Operator):
|
||||
return context.window_manager.invoke_props_dialog(self)
|
||||
|
||||
|
||||
def dump_db(filepath):
|
||||
# Replication graph
|
||||
nodes_ids = session.list()
|
||||
#TODO: add dump graph to replication
|
||||
|
||||
nodes =[]
|
||||
for n in nodes_ids:
|
||||
nd = session.get(uuid=n)
|
||||
nodes.append((
|
||||
n,
|
||||
{
|
||||
'owner': nd.owner,
|
||||
'str_type': nd.str_type,
|
||||
'data': nd.data,
|
||||
'dependencies': nd.dependencies,
|
||||
}
|
||||
))
|
||||
|
||||
db = dict()
|
||||
db['nodes'] = nodes
|
||||
db['users'] = copy.copy(session.online_users)
|
||||
|
||||
stime = datetime.now().strftime('%Y_%m_%d_%H-%M-%S')
|
||||
|
||||
filepath = Path(filepath)
|
||||
filepath = filepath.with_name(f"{filepath.stem}_{stime}{filepath.suffix}")
|
||||
with gzip.open(filepath, "wb") as f:
|
||||
logging.info(f"Writing session snapshot to {filepath}")
|
||||
pickle.dump(db, f, protocol=4)
|
||||
|
||||
|
||||
class SessionSaveBackupOperator(bpy.types.Operator, ExportHelper):
|
||||
bl_idname = "session.save"
|
||||
bl_label = "Save session data"
|
||||
bl_description = "Save a snapshot of the collaborative session"
|
||||
|
||||
# ExportHelper mixin class uses this
|
||||
filename_ext = ".db"
|
||||
|
||||
filter_glob: bpy.props.StringProperty(
|
||||
default="*.db",
|
||||
options={'HIDDEN'},
|
||||
maxlen=255, # Max internal buffer length, longer would be clamped.
|
||||
)
|
||||
|
||||
enable_autosave: bpy.props.BoolProperty(
|
||||
name="Auto-save",
|
||||
description="Enable session auto-save",
|
||||
default=True,
|
||||
)
|
||||
save_interval: bpy.props.FloatProperty(
|
||||
name="Auto save interval",
|
||||
description="auto-save interval (seconds)",
|
||||
default=10,
|
||||
)
|
||||
|
||||
def execute(self, context):
|
||||
if self.enable_autosave:
|
||||
recorder = timers.SessionBackupTimer(
|
||||
filepath=self.filepath,
|
||||
timeout=self.save_interval)
|
||||
recorder.register()
|
||||
deleyables.append(recorder)
|
||||
else:
|
||||
dump_db(self.filepath)
|
||||
|
||||
return {'FINISHED'}
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context):
|
||||
return session.state['STATE'] == STATE_ACTIVE
|
||||
|
||||
class SessionStopAutoSaveOperator(bpy.types.Operator):
|
||||
bl_idname = "session.cancel_autosave"
|
||||
bl_label = "Cancel auto-save"
|
||||
bl_description = "Cancel session auto-save"
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context):
|
||||
return (session.state['STATE'] == STATE_ACTIVE and 'SessionBackupTimer' in registry)
|
||||
|
||||
def execute(self, context):
|
||||
autosave_timer = registry.get('SessionBackupTimer')
|
||||
autosave_timer.unregister()
|
||||
|
||||
return {'FINISHED'}
|
||||
|
||||
|
||||
class SessionLoadSaveOperator(bpy.types.Operator, ImportHelper):
|
||||
bl_idname = "session.load"
|
||||
bl_label = "Load session save"
|
||||
bl_description = "Load a Multi-user session save"
|
||||
bl_options = {'REGISTER', 'UNDO'}
|
||||
|
||||
# ExportHelper mixin class uses this
|
||||
filename_ext = ".db"
|
||||
|
||||
filter_glob: bpy.props.StringProperty(
|
||||
default="*.db",
|
||||
options={'HIDDEN'},
|
||||
maxlen=255, # Max internal buffer length, longer would be clamped.
|
||||
)
|
||||
|
||||
def execute(self, context):
|
||||
from replication.graph import ReplicationGraph
|
||||
|
||||
# TODO: add filechecks
|
||||
|
||||
try:
|
||||
f = gzip.open(self.filepath, "rb")
|
||||
db = pickle.load(f)
|
||||
except OSError as e:
|
||||
f = open(self.filepath, "rb")
|
||||
db = pickle.load(f)
|
||||
|
||||
if db:
|
||||
logging.info(f"Reading {self.filepath}")
|
||||
nodes = db.get("nodes")
|
||||
|
||||
logging.info(f"{len(nodes)} Nodes to load")
|
||||
|
||||
|
||||
|
||||
# init the factory with supported types
|
||||
bpy_factory = ReplicatedDataFactory()
|
||||
for type in bl_types.types_to_register():
|
||||
type_module = getattr(bl_types, type)
|
||||
name = [e.capitalize() for e in type.split('_')[1:]]
|
||||
type_impl_name = 'Bl'+''.join(name)
|
||||
type_module_class = getattr(type_module, type_impl_name)
|
||||
|
||||
|
||||
bpy_factory.register_type(
|
||||
type_module_class.bl_class,
|
||||
type_module_class)
|
||||
|
||||
graph = ReplicationGraph()
|
||||
|
||||
for node, node_data in nodes:
|
||||
node_type = node_data.get('str_type')
|
||||
|
||||
impl = bpy_factory.get_implementation_from_net(node_type)
|
||||
|
||||
if impl:
|
||||
logging.info(f"Loading {node}")
|
||||
instance = impl(owner=node_data['owner'],
|
||||
uuid=node,
|
||||
dependencies=node_data['dependencies'],
|
||||
data=node_data['data'])
|
||||
instance.store(graph)
|
||||
instance.state = FETCHED
|
||||
|
||||
logging.info("Graph succefully loaded")
|
||||
|
||||
utils.clean_scene()
|
||||
|
||||
# Step 1: Construct nodes
|
||||
for node in graph.list_ordered():
|
||||
graph[node].resolve()
|
||||
|
||||
# Step 2: Load nodes
|
||||
for node in graph.list_ordered():
|
||||
graph[node].apply()
|
||||
|
||||
|
||||
return {'FINISHED'}
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context):
|
||||
return True
|
||||
|
||||
def menu_func_import(self, context):
|
||||
self.layout.operator(SessionLoadSaveOperator.bl_idname, text='Multi-user session snapshot (.db)')
|
||||
|
||||
|
||||
classes = (
|
||||
SessionStartOperator,
|
||||
SessionStopOperator,
|
||||
@ -726,6 +911,9 @@ classes = (
|
||||
SessionInitOperator,
|
||||
SessionClearCache,
|
||||
SessionNotifyOperator,
|
||||
SessionSaveBackupOperator,
|
||||
SessionLoadSaveOperator,
|
||||
SessionStopAutoSaveOperator,
|
||||
)
|
||||
|
||||
|
||||
|
@ -16,68 +16,48 @@
|
||||
# ##### END GPL LICENSE BLOCK #####
|
||||
|
||||
import logging
|
||||
import sys
|
||||
|
||||
import bpy
|
||||
|
||||
from . import utils
|
||||
from .presence import (renderer,
|
||||
UserFrustumWidget,
|
||||
UserNameWidget,
|
||||
UserSelectionWidget,
|
||||
refresh_3d_view,
|
||||
generate_user_camera,
|
||||
get_view_matrix,
|
||||
refresh_sidebar_view)
|
||||
from . import operators
|
||||
from replication.constants import (FETCHED,
|
||||
UP,
|
||||
RP_COMMON,
|
||||
STATE_INITIAL,
|
||||
STATE_QUITTING,
|
||||
STATE_ACTIVE,
|
||||
STATE_SYNCING,
|
||||
STATE_LOBBY,
|
||||
STATE_SRV_SYNC)
|
||||
|
||||
from replication.interface import session
|
||||
from replication.constants import (FETCHED, RP_COMMON, STATE_ACTIVE,
|
||||
STATE_INITIAL, STATE_LOBBY, STATE_QUITTING,
|
||||
STATE_SRV_SYNC, STATE_SYNCING, UP)
|
||||
from replication.exception import NonAuthorizedOperationError
|
||||
from replication.interface import session
|
||||
|
||||
from . import operators, utils
|
||||
from .presence import (UserFrustumWidget, UserNameWidget, UserSelectionWidget,
|
||||
generate_user_camera, get_view_matrix, refresh_3d_view,
|
||||
refresh_sidebar_view, renderer)
|
||||
|
||||
this = sys.modules[__name__]
|
||||
|
||||
# Registered timers
|
||||
this.registry = dict()
|
||||
|
||||
def is_annotating(context: bpy.types.Context):
|
||||
""" Check if the annotate mode is enabled
|
||||
"""
|
||||
return bpy.context.workspace.tools.from_space_view3d_mode('OBJECT', create=False).idname == 'builtin.annotate'
|
||||
|
||||
class Delayable():
|
||||
"""Delayable task interface
|
||||
"""
|
||||
|
||||
def register(self):
|
||||
raise NotImplementedError
|
||||
|
||||
def execute(self):
|
||||
raise NotImplementedError
|
||||
|
||||
def unregister(self):
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
class Timer(Delayable):
|
||||
class Timer(object):
|
||||
"""Timer binder interface for blender
|
||||
|
||||
Run a bpy.app.Timer in the background looping at the given rate
|
||||
"""
|
||||
|
||||
def __init__(self, duration=1):
|
||||
super().__init__()
|
||||
self._timeout = duration
|
||||
def __init__(self, timeout=10, id=None):
|
||||
self._timeout = timeout
|
||||
self.is_running = False
|
||||
self.id = id if id else self.__class__.__name__
|
||||
|
||||
def register(self):
|
||||
"""Register the timer into the blender timer system
|
||||
"""
|
||||
|
||||
if not self.is_running:
|
||||
this.registry[self.id] = self
|
||||
bpy.app.timers.register(self.main)
|
||||
self.is_running = True
|
||||
logging.debug(f"Register {self.__class__.__name__}")
|
||||
@ -105,15 +85,26 @@ class Timer(Delayable):
|
||||
"""Unnegister the timer of the blender timer system
|
||||
"""
|
||||
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
|
||||
|
||||
class SessionBackupTimer(Timer):
|
||||
def __init__(self, timeout=10, filepath=None):
|
||||
self._filepath = filepath
|
||||
super().__init__(timeout)
|
||||
|
||||
|
||||
def execute(self):
|
||||
operators.dump_db(self._filepath)
|
||||
|
||||
class ApplyTimer(Timer):
|
||||
def __init__(self, timout=1, target_type=None):
|
||||
def __init__(self, timeout=1, target_type=None):
|
||||
self._type = target_type
|
||||
super().__init__(timout)
|
||||
super().__init__(timeout)
|
||||
self.id = target_type.__name__
|
||||
|
||||
def execute(self):
|
||||
if session and session.state['STATE'] == STATE_ACTIVE:
|
||||
@ -140,8 +131,8 @@ class ApplyTimer(Timer):
|
||||
session.apply(n, force=True)
|
||||
|
||||
class DynamicRightSelectTimer(Timer):
|
||||
def __init__(self, timout=.1):
|
||||
super().__init__(timout)
|
||||
def __init__(self, timeout=.1):
|
||||
super().__init__(timeout)
|
||||
self._last_selection = []
|
||||
self._user = None
|
||||
self._annotating = False
|
||||
@ -262,8 +253,8 @@ class DynamicRightSelectTimer(Timer):
|
||||
|
||||
|
||||
class ClientUpdate(Timer):
|
||||
def __init__(self, timout=.1):
|
||||
super().__init__(timout)
|
||||
def __init__(self, timeout=.1):
|
||||
super().__init__(timeout)
|
||||
self.handle_quit = False
|
||||
self.users_metadata = {}
|
||||
|
||||
@ -325,16 +316,16 @@ class ClientUpdate(Timer):
|
||||
|
||||
|
||||
class SessionStatusUpdate(Timer):
|
||||
def __init__(self, timout=1):
|
||||
super().__init__(timout)
|
||||
def __init__(self, timeout=1):
|
||||
super().__init__(timeout)
|
||||
|
||||
def execute(self):
|
||||
refresh_sidebar_view()
|
||||
|
||||
|
||||
class SessionUserSync(Timer):
|
||||
def __init__(self, timout=1):
|
||||
super().__init__(timout)
|
||||
def __init__(self, timeout=1):
|
||||
super().__init__(timeout)
|
||||
self.settings = utils.get_preferences()
|
||||
|
||||
def execute(self):
|
||||
@ -367,8 +358,8 @@ class SessionUserSync(Timer):
|
||||
|
||||
|
||||
class MainThreadExecutor(Timer):
|
||||
def __init__(self, timout=1, execution_queue=None):
|
||||
super().__init__(timout)
|
||||
def __init__(self, timeout=1, execution_queue=None):
|
||||
super().__init__(timeout)
|
||||
self.execution_queue = execution_queue
|
||||
|
||||
def execute(self):
|
@ -29,6 +29,7 @@ from replication.constants import (ADDED, ERROR, FETCHED,
|
||||
STATE_LAUNCHING_SERVICES)
|
||||
from replication import __version__
|
||||
from replication.interface import session
|
||||
from .timers import registry
|
||||
|
||||
ICONS_PROP_STATES = ['TRIA_DOWN', # ADDED
|
||||
'TRIA_UP', # COMMITED
|
||||
@ -563,6 +564,13 @@ class SESSION_PT_repository(bpy.types.Panel):
|
||||
row = layout.row()
|
||||
|
||||
if session.state['STATE'] == STATE_ACTIVE:
|
||||
if 'SessionBackupTimer' in registry:
|
||||
row.alert = True
|
||||
row.operator('session.cancel_autosave', icon="CANCEL")
|
||||
row.alert = False
|
||||
else:
|
||||
row.operator('session.save', icon="FILE_TICK")
|
||||
|
||||
flow = layout.grid_flow(
|
||||
row_major=True,
|
||||
columns=0,
|
||||
|
Loading…
Reference in New Issue
Block a user