diff --git a/multi_user/operators.py b/multi_user/operators.py index 981c7df..0be114f 100644 --- a/multi_user/operators.py +++ b/multi_user/operators.py @@ -32,6 +32,7 @@ from operator import itemgetter from pathlib import Path from queue import Queue from time import gmtime, strftime +from numpy import interp try: import _pickle as pickle @@ -39,6 +40,7 @@ except ImportError: import pickle import bpy +import bmesh import mathutils from bpy.app.handlers import persistent from bpy_extras.io_utils import ExportHelper, ImportHelper @@ -56,6 +58,226 @@ background_execution_queue = Queue() deleyables = [] stop_modal_executor = False +CLEARED_DATABLOCKS = ['actions', 'armatures', 'cache_files', 'cameras', + 'collections', 'curves', 'filepath', 'fonts', + 'grease_pencils', 'images', 'lattices', 'libraries', + 'lightprobes', 'lights', 'linestyles', 'masks', + 'materials', 'meshes', 'metaballs', 'movieclips', + 'node_groups', 'objects', 'paint_curves', 'particles', + 'scenes', 'shape_keys', 'sounds', 'speakers', 'texts', + 'textures', 'volumes', 'worlds'] + +PERSISTENT_DATABLOCKS = ['LineStyle', 'Dots Stroke', 'replay_action'] + +def clean_scene(ignored_datablocks: list = None): + """ + Delete all datablock of the scene except PERSISTENT_DATABLOCKS and ignored + ones in ignored_datablocks. + """ + PERSISTENT_DATABLOCKS.extend(ignored_datablocks) + # Avoid to trigger a runtime error by keeping the last scene + PERSISTENT_DATABLOCKS.append(bpy.data.scenes[0].name) + + for type_name in CLEARED_DATABLOCKS: + type_collection = getattr(bpy.data, type_name) + for datablock in type_collection: + if datablock.name in PERSISTENT_DATABLOCKS: + logging.debug(f"Skipping {datablock.name}") + continue + else: + logging.debug(f"Removing {datablock.name}") + type_collection.remove(datablock) + + # Clear sequencer + bpy.context.scene.sequence_editor_clear() + +def get_bb_coords_from_obj(object: bpy.types.Object, instance: bpy.types.Object = None) -> list: + """ Generate bounding box in world coordinate from object bound box + + :param object: target object + :type object: bpy.types.Object + :param instance: optionnal instance + :type instance: bpy.types.Object + :return: list of 8 points [(x,y,z),...] + """ + base = object.matrix_world + + if instance: + scale = mathutils.Matrix.Diagonal(object.matrix_world.to_scale()) + base = instance.matrix_world @ scale.to_4x4() + + bbox_corners = [base @ mathutils.Vector( + corner) for corner in object.bound_box] + + + return [(point.x, point.y, point.z) for point in bbox_corners] + + +def bbox_from_obj(obj: bpy.types.Object, index: int = 1) -> 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),...] + """ + 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), + (-radius, -radius, +radius), (+radius, -radius, +radius), + (-radius, +radius, +radius), (+radius, +radius, +radius)] + + 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 vertex_pos, vertex_indices + +def draw_user(username, metadata, radius=0.01, intensity=10.0): + """ + Generate a mesh representation of a given user frustum and + sight of view. + """ + view_corners = metadata.get('view_corners') + color = metadata.get('color', (1,1,1,0)) + objects = metadata.get('selected_objects', None) + scene = metadata.get('scene_current', bpy.context.scene.name) + + user_collection = bpy.data.collections.new(username) + + # User Color + user_mat = bpy.data.materials.new(username) + user_mat.use_nodes = True + nodes = user_mat.node_tree.nodes + nodes.remove(nodes['Principled BSDF']) + emission_node = nodes.new('ShaderNodeEmission') + emission_node.inputs['Color'].default_value = color + emission_node.inputs['Strength'].default_value = intensity + + output_node = nodes['Material Output'] + user_mat.node_tree.links.new( + emission_node.outputs['Emission'], output_node.inputs['Surface']) + + # Generate camera mesh + camera_vertices = view_corners[:4] + camera_vertices.append(view_corners[6]) + camera_mesh = bpy.data.meshes.new(f"{username}_camera") + camera_obj = bpy.data.objects.new(f"{username}_camera", camera_mesh) + frustum_bm = bmesh.new() + frustum_bm.from_mesh(camera_mesh) + + for p in camera_vertices: + frustum_bm.verts.new(p) + frustum_bm.verts.ensure_lookup_table() + + frustum_bm.edges.new((frustum_bm.verts[0], frustum_bm.verts[2])) + frustum_bm.edges.new((frustum_bm.verts[2], frustum_bm.verts[1])) + frustum_bm.edges.new((frustum_bm.verts[1], frustum_bm.verts[3])) + frustum_bm.edges.new((frustum_bm.verts[3], frustum_bm.verts[0])) + + frustum_bm.edges.new((frustum_bm.verts[0], frustum_bm.verts[4])) + frustum_bm.edges.new((frustum_bm.verts[2], frustum_bm.verts[4])) + frustum_bm.edges.new((frustum_bm.verts[1], frustum_bm.verts[4])) + frustum_bm.edges.new((frustum_bm.verts[3], frustum_bm.verts[4])) + frustum_bm.edges.ensure_lookup_table() + + frustum_bm.to_mesh(camera_mesh) + frustum_bm.free() # free and prevent further access + + camera_obj.modifiers.new("wireframe", "SKIN") + camera_obj.data.skin_vertices[0].data[0].use_root = True + for v in camera_mesh.skin_vertices[0].data: + v.radius = [radius, radius] + + camera_mesh.materials.append(user_mat) + user_collection.objects.link(camera_obj) + + # Generate sight mesh + sight_mesh = bpy.data.meshes.new(f"{username}_sight") + sight_obj = bpy.data.objects.new(f"{username}_sight", sight_mesh) + sight_verts = view_corners[4:6] + sight_bm = bmesh.new() + sight_bm.from_mesh(sight_mesh) + + for p in sight_verts: + sight_bm.verts.new(p) + sight_bm.verts.ensure_lookup_table() + + sight_bm.edges.new((sight_bm.verts[0], sight_bm.verts[1])) + sight_bm.edges.ensure_lookup_table() + sight_bm.to_mesh(sight_mesh) + sight_bm.free() + + sight_obj.modifiers.new("wireframe", "SKIN") + sight_obj.data.skin_vertices[0].data[0].use_root = True + for v in sight_mesh.skin_vertices[0].data: + v.radius = [radius, radius] + + sight_mesh.materials.append(user_mat) + user_collection.objects.link(sight_obj) + + # Draw selected objects + if objects: + for o in list(objects): + instance = bl_types.bl_datablock.get_datablock_from_uuid(o, None) + if instance: + bbox_mesh = bpy.data.meshes.new(f"{instance.name}_bbox") + bbox_obj = bpy.data.objects.new( + f"{instance.name}_bbox", bbox_mesh) + bbox_verts, bbox_ind = bbox_from_obj(instance, index=0) + bbox_bm = bmesh.new() + bbox_bm.from_mesh(bbox_mesh) + + for p in bbox_verts: + bbox_bm.verts.new(p) + bbox_bm.verts.ensure_lookup_table() + + for e in bbox_ind: + bbox_bm.edges.new( + (bbox_bm.verts[e[0]], bbox_bm.verts[e[1]])) + + bbox_bm.to_mesh(bbox_mesh) + bbox_bm.free() + bpy.data.collections[username].objects.link(bbox_obj) + + bbox_obj.modifiers.new("wireframe", "SKIN") + bbox_obj.data.skin_vertices[0].data[0].use_root = True + for v in bbox_mesh.skin_vertices[0].data: + v.radius = [radius, radius] + + bbox_mesh.materials.append(user_mat) + + bpy.data.scenes[scene].collection.children.link(user_collection) + + def session_callback(name): """ Session callback wrapper @@ -827,9 +1049,42 @@ class SessionLoadSaveOperator(bpy.types.Operator, ImportHelper): maxlen=255, # Max internal buffer length, longer would be clamped. ) + draw_users: bpy.props.BoolProperty( + name="Load users", + description="Draw users in the scene", + default=False, + ) + + replay: bpy.props.BoolProperty( + name="Replay mode", + description="Enable replay functions", + default=False, + ) + + user_skin_radius: bpy.props.FloatProperty( + name="Wireframe radius", + description="Wireframe radius", + default=0.005, + ) + user_color_intensity: bpy.props.FloatProperty( + name="Shading intensity", + description="Shading intensity", + default=1.0, + ) + + files: bpy.props.CollectionProperty( + name='File paths', + type=bpy.types.OperatorFileListElement + ) + + def draw(self, context): + pass + def execute(self, context): from replication.graph import ReplicationGraph + runtime_settings = context.window_manager.session + # TODO: add filechecks try: @@ -878,7 +1133,16 @@ class SessionLoadSaveOperator(bpy.types.Operator, ImportHelper): logging.info("Graph succefully loaded") - utils.clean_scene() + # Persitstent collection + ignored_datablocks = [] + + persistent_collection = bpy.data.collections.get("multiuser_timelapse") + if self.replay and \ + runtime_settings.replay_persistent_collection and \ + persistent_collection: + ignored_datablocks = ['multiuser_timelapse','multiuser_timelapse_cam','multiuser_timelapse_cam_obj','multiuser_timelapse_path','multiuser_timelapse_path_obj', 'multiuser_timelapse_pathAction'] + + clean_scene(ignored_datablocks=ignored_datablocks) # Step 1: Construct nodes for node in graph.list_ordered(): @@ -888,6 +1152,68 @@ class SessionLoadSaveOperator(bpy.types.Operator, ImportHelper): for node in graph.list_ordered(): graph[node].apply() + if len(self.files) > 1: + runtime_settings.replay_files.clear() + context.scene.active_replay_file = len(self.files)-1 + directory = Path(self.filepath).parent + file_list = [f['name'] for f in self.files] + file_list.sort() + for f in file_list: + snap = runtime_settings.replay_files.add() + snap.name = str(Path(directory, f)) + print(f) + + if runtime_settings.replay_mode == 'TIMELINE': + replay_action = bpy.data.actions.get('replay_action', bpy.data.actions.new('replay_action')) + + bpy.context.scene.animation_data_create() + bpy.context.scene.animation_data.action = replay_action + if len(replay_action.fcurves) > 0 and replay_action.fcurves[0].data_path == 'active_replay_file': + replay_fcurve = replay_action.fcurves[0] + else: + replay_fcurve = replay_action.fcurves.new('active_replay_file') + + for p in reversed(replay_fcurve.keyframe_points): + replay_fcurve.keyframe_points.remove(p, fast=True) + + duration = runtime_settings.replay_duration + file_count = len(self.files)-1 + for index in range(0, file_count): + frame = interp(index, [0, file_count], [bpy.context.scene.frame_start, duration]) + replay_fcurve.keyframe_points.insert(frame, index) + + if self.draw_users: + f = gzip.open(self.filepath, "rb") + db = pickle.load(f) + + users = db.get("users") + + for username, user_data in users.items(): + metadata = user_data['metadata'] + + if metadata: + draw_user(username, metadata, radius=self.user_skin_radius, intensity=self.user_color_intensity) + + + # Relink the persistent collection + if self.replay and persistent_collection: + logging.info(f"Relinking {persistent_collection.name}") + bpy.context.scene.collection.children.link(persistent_collection) + + # Reasign scene action + if self.replay and \ + runtime_settings.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') + bpy.context.scene.frame_end = runtime_settings.replay_duration + + # Reasign the scene camera + if self.replay and \ + runtime_settings.replay_persistent_collection and \ + runtime_settings.replay_camera: + bpy.context.scene.camera = runtime_settings.replay_camera + return {'FINISHED'} @@ -967,6 +1293,16 @@ def load_pre_handler(dummy): @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'] == STATE_ACTIVE: session.update_user_metadata({ 'frame_current': scene.frame_current diff --git a/multi_user/preferences.py b/multi_user/preferences.py index 790af67..935f7d6 100644 --- a/multi_user/preferences.py +++ b/multi_user/preferences.py @@ -28,7 +28,7 @@ from . import bl_types, environment, addon_updater_ops, presence, ui from .utils import get_preferences, get_expanded_icon from replication.constants import RP_COMMON from replication.interface import session - +from numpy import interp # From https://stackoverflow.com/a/106223 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])$") @@ -93,6 +93,88 @@ def set_log_level(self, value): def get_log_level(self): return logging.getLogger().level +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') + cam = bpy.data.cameras.get('multiuser_timelapse_cam', bpy.data.cameras.new('multiuser_timelapse_cam')) + cam_obj = bpy.data.objects.get('multiuser_timelapse_cam_obj', bpy.data.objects.new('multiuser_timelapse_cam_obj', cam)) + curve = bpy.data.curves.get('multiuser_timelapse_path', bpy.data.curves.new('multiuser_timelapse_path', 'CURVE')) + curve_obj = bpy.data.objects.get('multiuser_timelapse_path_obj', bpy.data.objects.new('multiuser_timelapse_path_obj', curve)) + + if cam_obj.name not in collection.objects: + collection.objects.link(cam_obj) + if curve_obj.name not in collection.objects: + collection.objects.link(curve_obj) + + 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) + class ReplicatedDatablock(bpy.types.PropertyGroup): type_name: bpy.props.StringProperty() @@ -530,6 +612,37 @@ 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 = ( @@ -552,9 +665,20 @@ def register(): logging.debug('Generating bl_types preferences') prefs.generate_supported_types() + 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'} + ) def unregister(): from bpy.utils import unregister_class for cls in reversed(classes): unregister_class(cls) + + del bpy.types.Scene.active_replay_file diff --git a/multi_user/ui.py b/multi_user/ui.py index b8f423b..22f9208 100644 --- a/multi_user/ui.py +++ b/multi_user/ui.py @@ -615,6 +615,39 @@ class VIEW3D_PT_overlay_session(bpy.types.Panel): row.active = settings.presence_show_user row.prop(settings, "presence_show_far_user") +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') + + classes = ( SESSION_UL_users, SESSION_PT_settings, @@ -624,6 +657,7 @@ classes = ( SESSION_PT_advanced_settings, SESSION_PT_user, SESSION_PT_repository, + SESSION_PT_replay, VIEW3D_PT_overlay_session, ) diff --git a/multi_user/utils.py b/multi_user/utils.py index 25444f9..af49d27 100644 --- a/multi_user/utils.py +++ b/multi_user/utils.py @@ -100,18 +100,6 @@ def get_state_str(state): return state_str -def clean_scene(): - for type_name in dir(bpy.data): - try: - type_collection = getattr(bpy.data, type_name) - for item in type_collection: - type_collection.remove(item) - 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)]