Compare commits

..

2 Commits

16 changed files with 212 additions and 130 deletions

View File

@ -1,7 +1,13 @@
stages:
- test
- build
- deploy
- doc
include:
- local: .gitlab/ci/test.gitlab-ci.yml
- local: .gitlab/ci/build.gitlab-ci.yml
- local: .gitlab/ci/deploy.gitlab-ci.yml
- local: .gitlab/ci/doc.gitlab-ci.yml

View File

@ -1,5 +1,6 @@
build:
stage: build
needs: ["test"]
image: debian:stable-slim
script:
- rm -rf tests .git .gitignore script

View File

@ -0,0 +1,7 @@
test:
stage: test
image: slumber/blender-addon-testing:latest
script:
- python3 scripts/test_addon.py
variables:
GIT_SUBMODULE_STRATEGY: recursive

View File

@ -264,10 +264,4 @@ All notable changes to this project will be documented in this file.
- Server crashing during snapshots
- Blender 3.1 numpy loading error during early connection process
- Server docker arguments
## [0.5.5] - 2022-06-12
### Fixed
- Numpy mesh serialization error
- Server docker arguments

View File

@ -16,12 +16,12 @@ import sys
# -- Project information -----------------------------------------------------
project = 'Multi-User 0.5.x Documentation'
project = 'Multi-User 0.5.0 Documentation'
copyright = '2020, Swann Martinez'
author = 'Swann Martinez, Poochy, Fabian'
# The full version, including alpha/beta/rc tags
version_release = '0.5.5'
version_release = '0.5.1-develop'
# -- General configuration ---------------------------------------------------

View File

@ -19,7 +19,7 @@
bl_info = {
"name": "Multi-User",
"author": "Swann Martinez",
"version": (0, 6, 0),
"version": (0, 5, 4),
"description": "Enable real-time collaborative workflow inside blender",
"blender": (2, 82, 0),
"location": "3D View > Sidebar > Multi-User tab",

View File

@ -245,8 +245,9 @@ def dump_node_tree(node_tree: bpy.types.ShaderNodeTree) -> dict:
'type': type(node_tree).__name__
}
sockets = [item for item in node_tree.interface.items_tree if item.item_type == 'SOCKET']
node_tree_data['interface'] = dump_node_tree_sockets(sockets)
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
@ -262,18 +263,18 @@ def dump_node_tree_sockets(sockets: bpy.types.Collection) -> dict:
"""
sockets_data = []
for socket in sockets:
sockets_data.append(
(
socket.name,
socket.socket_type,
socket.in_out
)
)
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(interface: bpy.types.NodeTreeInterface,
def load_node_tree_sockets(sockets: bpy.types.Collection,
sockets_data: dict):
""" load sockets of a shader_node_tree
@ -284,17 +285,20 @@ def load_node_tree_sockets(interface: bpy.types.NodeTreeInterface,
:arg socket_data: dumped socket data
:type socket_data: dict
"""
# Remove old sockets
interface.clear()
# 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 name, socket_type, in_out in sockets_data:
socket = interface.new_socket(
name,
in_out=in_out,
socket_type=socket_type
)
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:
@ -311,8 +315,13 @@ def load_node_tree(node_tree_data: dict, target_node_tree: bpy.types.ShaderNodeT
if not target_node_tree.is_property_readonly('name'):
target_node_tree.name = node_tree_data['name']
if 'interface' in node_tree_data:
load_node_tree_sockets(target_node_tree.interface, node_tree_data['interface'])
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"]:

View File

@ -37,8 +37,8 @@ VERTICE = ['co']
EDGE = [
'vertices',
# 'crease',
# 'bevel_weight',
'crease',
'bevel_weight',
'use_seam',
'use_edge_sharp',
]

View File

@ -26,8 +26,8 @@ import numpy as np
BPY_TO_NUMPY_TYPES = {
'FLOAT': np.float32,
'INT': np.int32,
'BOOL': bool,
'BOOLEAN': bool}
'BOOL': np.bool,
'BOOLEAN': np.bool}
PRIMITIVE_TYPES = ['FLOAT', 'INT', 'BOOLEAN']

View File

@ -36,6 +36,8 @@ REPLICATION_DEPENDENCIES = {
LIBS = os.path.join(os.path.dirname(os.path.abspath(__file__)), "libs")
REPLICATION = os.path.join(LIBS,"replication")
PYTHON_PATH = None
SUBPROCESS_DIR = None
rtypes = []
@ -48,13 +50,13 @@ def module_can_be_imported(name: str) -> bool:
return False
def install_pip(python_path):
def install_pip():
# pip can not necessarily be imported into Blender after this
subprocess.run([str(python_path), "-m", "ensurepip"])
subprocess.run([str(PYTHON_PATH), "-m", "ensurepip"])
def install_requirements(python_path:str, module_requirement: str, install_dir: str):
logging.info(f"Installing {module_requirement} dependencies in {install_dir}")
def install_package(name: str, install_dir: str):
logging.info(f"installing {name} version...")
env = os.environ
if "PIP_REQUIRE_VIRTUALENV" in env:
# PIP_REQUIRE_VIRTUALENV is an env var to ensure pip cannot install packages outside a virtual env
@ -63,7 +65,23 @@ def install_requirements(python_path:str, module_requirement: str, install_dir:
# env var for the subprocess.
env = os.environ.copy()
del env["PIP_REQUIRE_VIRTUALENV"]
subprocess.run([str(python_path), "-m", "pip", "install", "-r", f"{install_dir}/{module_requirement}/requirements.txt", "-t", install_dir], env=env)
subprocess.run([str(PYTHON_PATH), "-m", "pip", "install", f"{name}", "-t", install_dir], env=env)
if name in sys.modules:
del sys.modules[name]
def check_package_version(name: str, required_version: str):
logging.info(f"Checking {name} version...")
out = subprocess.run([str(PYTHON_PATH), "-m", "pip", "show", name], capture_output=True)
version = VERSION_EXPR.search(out.stdout.decode())
if version and version.group() == required_version:
logging.info(f"{name} is up to date")
return True
else:
logging.info(f"{name} need an update")
return False
def get_ip():
@ -99,7 +117,21 @@ def remove_paths(paths: list):
if path in sys.path:
logging.debug(f"Removing {path} dir from the path.")
sys.path.remove(path)
def install_modules(dependencies: list, python_path: str, install_dir: str):
global PYTHON_PATH, SUBPROCESS_DIR
PYTHON_PATH = Path(python_path)
SUBPROCESS_DIR = PYTHON_PATH.parent
if not module_can_be_imported("pip"):
install_pip()
for package_name in dependencies:
if not module_can_be_imported(package_name):
install_package(package_name, install_dir=install_dir)
module_can_be_imported(package_name)
def register():
if bpy.app.version >= (2,91,0):
@ -107,21 +139,12 @@ def register():
else:
python_binary_path = bpy.app.binary_path_python
python_path = Path(python_binary_path)
for module_name in list(sys.modules.keys()):
if 'replication' in module_name:
del sys.modules[module_name]
setup_paths([LIBS, REPLICATION])
if not module_can_be_imported("pip"):
install_pip(python_path)
deps_not_installed = [package_name for package_name in REPLICATION_DEPENDENCIES if not module_can_be_imported(package_name)]
if any(deps_not_installed):
install_requirements(python_path, module_requirement='replication', install_dir=LIBS)
install_modules(REPLICATION_DEPENDENCIES, python_binary_path, install_dir=LIBS)
def unregister():
remove_paths([REPLICATION, LIBS])

View File

@ -23,6 +23,7 @@ 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 .timers import XrUserUpdate
from . import shared_data, utils
@ -46,14 +47,16 @@ def sanitize_deps_graph(remove_nodes: bool = False):
rm_cpt += 1
except NonAuthorizedOperationError:
continue
logging.info(f"Sanitize took { utils.current_milli_time()-start} ms, removed {rm_cpt} nodes")
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]
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]:
@ -72,11 +75,13 @@ def on_scene_update(scene):
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]
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}")
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
@ -84,7 +89,8 @@ def on_scene_update(scene):
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
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}")
@ -107,12 +113,14 @@ def on_scene_update(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))]
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
@ -138,6 +146,13 @@ def update_client_frame(scene):
'frame_current': scene.frame_current
})
@persistent
def xr_user_update(scene):
if session and session.state == STATE_ACTIVE:
xr_timer = XrUserUpdate()
xr_timer.register()
logging.info("XR Session timer started")
def register():
bpy.app.handlers.undo_post.append(resolve_deps_graph)
@ -145,6 +160,8 @@ def register():
bpy.app.handlers.load_pre.append(load_pre_handler)
bpy.app.handlers.frame_change_pre.append(update_client_frame)
bpy.app.handlers.xr_session_start_pre.append(xr_user_update)
def unregister():
@ -153,3 +170,5 @@ def unregister():
bpy.app.handlers.load_pre.remove(load_pre_handler)
bpy.app.handlers.frame_change_pre.remove(update_client_frame)
bpy.app.handlers.xr_session_start_pre.remove(xr_user_update)

View File

@ -68,6 +68,7 @@ stop_modal_executor = False
def draw_user(username, metadata, radius=0.01, intensity=10.0):
# TODO: Draw camera model from viewmatrix
view_corners = metadata.get('view_corners')
color = metadata.get('color', (1,1,1,0))
objects = metadata.get('selected_objects', None)

View File

@ -44,6 +44,13 @@ DEFAULT_PRESETS = {
"admin_password": "admin",
"server_password": ""
},
"public session" : {
"server_name": "public session",
"ip": "51.75.71.183",
"port": 5555,
"admin_password": "",
"server_password": ""
},
}
def randomColor():

View File

@ -26,7 +26,7 @@ import bgl
import blf
import bpy
import gpu
import mathutils
from mathutils import Vector, Matrix, Quaternion
from bpy_extras import view3d_utils
from gpu_extras.batch import batch_for_shader
from replication.constants import (STATE_ACTIVE, STATE_AUTH, STATE_CONFIG,
@ -136,7 +136,7 @@ def bbox_from_obj(obj: bpy.types.Object, index: int = 1) -> list:
(-radius, +radius, +radius), (+radius, +radius, +radius)]
base = obj.matrix_world
bbox_corners = [base @ mathutils.Vector(corner) for corner in coords]
bbox_corners = [base @ Vector(corner) for corner in coords]
vertex_pos = [(point.x, point.y, point.z) for point in bbox_corners]
@ -159,39 +159,12 @@ def bbox_from_instance_collection(ic: bpy.types.Object, index: int = 0) -> list:
vertex_pos += vertex_pos_temp
vertex_indices += vertex_indices_temp
bbox_corners = [ic.matrix_world @ mathutils.Vector(vertex) for vertex in vertex_pos]
bbox_corners = [ic.matrix_world @ 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
:return: list of 7 points
"""
area, region, rv3d = view3d_find()
v1 = v2 = v3 = v4 = v5 = v6 = v7 = [0, 0, 0]
if area and region and rv3d:
width = region.width
height = region.height
v1 = project_to_viewport(region, rv3d, (0, 0))
v3 = project_to_viewport(region, rv3d, (0, height))
v2 = project_to_viewport(region, rv3d, (width, height))
v4 = project_to_viewport(region, rv3d, (width, 0))
v5 = project_to_viewport(region, rv3d, (width/2, height/2))
v6 = list(rv3d.view_location)
v7 = project_to_viewport(
region, rv3d, (width/2, height/2), distance=-.8)
coords = [v1, v2, v3, v4, v5, v6, v7]
return coords
def project_to_screen(coords: list) -> list:
""" Project 3D coordinate to 2D screen coordinates
@ -219,10 +192,10 @@ def get_bb_coords_from_obj(object: bpy.types.Object, instance: bpy.types.Object
base = object.matrix_world
if instance:
scale = mathutils.Matrix.Diagonal(object.matrix_world.to_scale())
scale = Matrix.Diagonal(object.matrix_world.to_scale())
base = instance.matrix_world @ scale.to_4x4()
bbox_corners = [base @ mathutils.Vector(
bbox_corners = [base @ Vector(
corner) for corner in object.bound_box]
@ -253,9 +226,11 @@ class Widget(object):
return True
def configure_bgl(self):
gpu.state.blend_set('ALPHA')
gpu.state.depth_test_set('LESS_EQUAL')
gpu.state.line_width_set(2.0)
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
@ -265,9 +240,14 @@ class Widget(object):
class UserFrustumWidget(Widget):
# Camera widget indices
indices = ((1, 3), (2, 1), (3, 0),
(2, 0), (4, 5), (1, 6),
(2, 6), (3, 6), (0, 6))
camera_vertex = ((0, 0, 1),
(-1, -0.5, -1), (1, -0.5, -1), (1, 0.5, -1), (-1, 0.5, -1),
(0, 1, -1),
(-0.5, 0.6, -1), (0.5, 0.6, -1))
camera_indices = ((0, 1), (0, 2), (0, 3), (0, 4),
(1, 2), (2, 3), (3, 4), (4, 1),
(5, 6), (6, 7), (7, 5))
def __init__(
self,
@ -288,27 +268,33 @@ class UserFrustumWidget(Widget):
return False
scene_current = self.data.get('scene_current')
view_corners = self.data.get('view_corners')
view_matrix = self.data.get('view_matrix')
return (scene_current == bpy.context.scene.name or
self.settings.presence_show_far_user) and \
view_corners and \
view_matrix and \
self.settings.presence_show_user and \
self.settings.enable_presence
def draw(self):
location = self.data.get('view_corners')
shader = gpu.shader.from_builtin('UNIFORM_COLOR')
positions = [tuple(coord) for coord in location]
if len(positions) != 7:
return
shader = gpu.shader.from_builtin('3D_UNIFORM_COLOR')
xr_state = self.data.get('xr')
transformation = Matrix()
if xr_state:
loc = Vector(xr_state.get('viewer_pose_location'))
rot = Quaternion(xr_state.get('viewer_pose_rotation'))
scale = Vector((1,1,1))
transformation = Matrix.LocRotScale(loc, rot, scale)
else:
transformation = Matrix(self.data.get('view_matrix')).inverted()
coords = [transformation @ Vector(vertex) for vertex in self.camera_vertex]
batch = batch_for_shader(
shader,
'LINES',
{"pos": positions},
indices=self.indices)
{"pos": coords},
indices=self.camera_indices)
shader.bind()
shader.uniform_float("color", self.data.get('color'))
@ -370,7 +356,7 @@ class UserSelectionWidget(Widget):
vertex_pos += bbox_pos
vertex_ind += bbox_ind
shader = gpu.shader.from_builtin('UNIFORM_COLOR')
shader = gpu.shader.from_builtin('3D_UNIFORM_COLOR')
batch = batch_for_shader(
shader,
'LINES',
@ -403,23 +389,28 @@ class UserNameWidget(Widget):
return False
scene_current = self.data.get('scene_current')
view_corners = self.data.get('view_corners')
view_matrix = self.data.get('view_matrix')
return (scene_current == bpy.context.scene.name or
self.settings.presence_show_far_user) and \
view_corners and \
view_matrix and \
self.settings.presence_show_user and \
self.settings.enable_presence
def draw(self):
view_corners = self.data.get('view_corners')
xr_state = self.data.get('xr')
if xr_state:
position = xr_state.get('viewer_pose_location', [0,0,0])
else:
position = Matrix(self.data.get('view_matrix')).inverted().to_translation()
color = self.data.get('color')
position = [tuple(coord) for coord in view_corners]
coords = project_to_screen(position[1])
coords = project_to_screen(position)
if coords:
blf.position(0, coords[0], coords[1]+10, 0)
blf.size(0, 16)
blf.size(0, 16, 72)
blf.color(0, color[0], color[1], color[2], color[3])
blf.draw(0, self.username)
@ -509,7 +500,7 @@ class SessionStatusWidget(Widget):
vpos = (self.preferences.presence_hud_vpos*bpy.context.area.height)/100
blf.position(0, hpos, vpos, 0)
blf.size(0, int(text_scale*ui_scale))
blf.size(0, int(text_scale*ui_scale), 72)
blf.color(0, color[0], color[1], color[2], color[3])
blf.draw(0, state_str)

View File

@ -28,8 +28,7 @@ from replication import porcelain
from . import operators, utils
from .presence import (UserFrustumWidget, UserNameWidget, UserModeWidget, UserSelectionWidget,
generate_user_camera, get_view_matrix, refresh_3d_view,
refresh_sidebar_view, renderer)
get_view_matrix, refresh_3d_view, refresh_sidebar_view, renderer)
from . import shared_data
@ -276,21 +275,24 @@ class ClientUpdate(Timer):
if session and renderer:
if session.state in [STATE_ACTIVE, STATE_LOBBY]:
local_user = session.online_users.get(
settings.username)
local_user = session.online_users.get(settings.username)
xr_session_state = bpy.context.window_manager.xr_session_state
if not local_user:
return
else:
for username, user_data in session.online_users.items():
if username != settings.username:
cached_user_data = self.users_metadata.get(
username)
cached_user_data = self.users_metadata.get(username)
new_user_data = session.online_users[username]['metadata']
if cached_user_data is None:
self.users_metadata[username] = user_data['metadata']
elif 'view_matrix' in cached_user_data and 'view_matrix' in new_user_data and cached_user_data['view_matrix'] != new_user_data['view_matrix']:
elif 'view_matrix' in cached_user_data and \
'view_matrix' in new_user_data and \
cached_user_data['view_matrix'] != new_user_data['view_matrix'] or \
'xr' in cached_user_data and \
'xr' in new_user_data and \
cached_user_data['xr']['viewer_pose_location'] != new_user_data['xr']['viewer_pose_location']:
refresh_3d_view()
self.users_metadata[username] = user_data['metadata']
break
@ -300,13 +302,12 @@ class ClientUpdate(Timer):
local_user_metadata = local_user.get('metadata')
scene_current = bpy.context.scene.name
local_user = session.online_users.get(settings.username)
current_view_corners = generate_user_camera()
current_view_matrix = get_view_matrix()
# Init client metadata
if not local_user_metadata or 'color' not in local_user_metadata.keys():
metadata = {
'view_corners': get_view_matrix(),
'view_matrix': get_view_matrix(),
'view_matrix': current_view_matrix,
'color': (settings.client_color.r,
settings.client_color.g,
settings.client_color.b,
@ -322,10 +323,8 @@ class ClientUpdate(Timer):
elif scene_current != local_user_metadata['scene_current']:
local_user_metadata['scene_current'] = scene_current
porcelain.update_user_metadata(session.repository, local_user_metadata)
elif 'view_corners' in local_user_metadata and current_view_corners != local_user_metadata['view_corners']:
local_user_metadata['view_corners'] = current_view_corners
local_user_metadata['view_matrix'] = get_view_matrix(
)
elif 'view_matrix' in local_user_metadata and current_view_matrix != local_user_metadata['view_matrix']:
local_user_metadata['view_matrix'] = current_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
@ -387,3 +386,28 @@ class MainThreadExecutor(Timer):
function, kwargs = self.execution_queue.get()
logging.debug(f"Executing {function.__name__}")
function(**kwargs)
class XrUserUpdate(Timer):
def __init__(self, timeout=.01):
# TODO: Add user refresh rate settings
super().__init__(timeout)
def execute(self):
xr_session_state = bpy.context.window_manager.xr_session_state
if xr_session_state and xr_session_state.is_running:
# Update user state
porcelain.update_user_metadata(
session.repository,
{'xr': {
'viewer_pose_location': list(xr_session_state.viewer_pose_location),
'viewer_pose_rotation': list(xr_session_state.viewer_pose_rotation),
'controller_0_location': list(xr_session_state.controller_grip_location_get(bpy.context, 0)),
'controller_0_rotation': list(xr_session_state.controller_grip_rotation_get(bpy.context, 0)),
'controller_1_location': list(xr_session_state.controller_grip_location_get(bpy.context, 1)),
'controller_1_rotation': list(xr_session_state.controller_grip_rotation_get(bpy.context, 1))}
})
else:
logging.info("XR Session ended, stopping user update")
self.unregister()