Compare commits
51 Commits
doc-testin
...
travel
Author | SHA1 | Date | |
---|---|---|---|
d78c42b02f | |||
8cb40b2d60 | |||
57fdd492ef | |||
e538752fbc | |||
53eaaa2fcd | |||
a7e9108bff | |||
570909a7c4 | |||
736c3df7c4 | |||
8e606068f3 | |||
eb631e2d4b | |||
70641435cc | |||
552c649d34 | |||
d9d5a34653 | |||
12acd22660 | |||
826a59085e | |||
5ee4988aca | |||
cb85a1db4c | |||
5e30e215ab | |||
9f167256d0 | |||
4e19c169b2 | |||
9c633c35ec | |||
9610b50a49 | |||
67d18f08e2 | |||
9d0d684589 | |||
2446df4fe3 | |||
07862f1cf0 | |||
480818fe85 | |||
b965c80ba5 | |||
b66d0dd4ce | |||
9487753307 | |||
df1257ca4c | |||
cc5a87adb8 | |||
19c56e590b | |||
d0e80da945 | |||
0ccd0563ea | |||
1c3394ce56 | |||
d2b63df68e | |||
3d9c78c2f9 | |||
4726a90a4a | |||
73b763d85f | |||
5e29c6fe26 | |||
113ab81cbf | |||
d2215b662c | |||
238a34d023 | |||
55ca8a7b84 | |||
7049c1723d | |||
ffe419a46e | |||
bed33ca6ba | |||
56ea93508c | |||
5f95eadc1d | |||
40ad96b0af |
@ -2,9 +2,12 @@ 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
|
||||
|
@ -1,5 +1,6 @@
|
||||
build:
|
||||
stage: build
|
||||
needs: ["test"]
|
||||
image: debian:stable-slim
|
||||
script:
|
||||
- rm -rf tests .git .gitignore script
|
||||
|
@ -1,5 +1,6 @@
|
||||
deploy:
|
||||
stage: deploy
|
||||
needs: ["build"]
|
||||
image: slumber/docker-python
|
||||
variables:
|
||||
DOCKER_DRIVER: overlay2
|
||||
|
16
.gitlab/ci/doc.gitlab-ci.yml
Normal file
16
.gitlab/ci/doc.gitlab-ci.yml
Normal file
@ -0,0 +1,16 @@
|
||||
pages:
|
||||
stage: doc
|
||||
needs: ["deploy"]
|
||||
image: python
|
||||
script:
|
||||
- pip install -U sphinx sphinx_rtd_theme sphinx-material
|
||||
- sphinx-build -b html ./docs public
|
||||
artifacts:
|
||||
paths:
|
||||
- public
|
||||
only:
|
||||
refs:
|
||||
- master
|
||||
- develop
|
||||
|
||||
|
31
CHANGELOG.md
31
CHANGELOG.md
@ -157,4 +157,33 @@ All notable changes to this project will be documented in this file.
|
||||
- Empty and Light object selection highlights
|
||||
- Material renaming
|
||||
- Default material nodes input parameters
|
||||
- blender 2.91 python api compatibility
|
||||
- blender 2.91 python api compatibility
|
||||
|
||||
## [0.3.0] - 2021-04-14
|
||||
|
||||
### Added
|
||||
|
||||
- Curve material support
|
||||
- Cycle visibility settings
|
||||
- Session save/load operator
|
||||
- Add new scene support
|
||||
- Physic initial support
|
||||
- Geometry node initial support
|
||||
- Blender 2.93 compatibility
|
||||
### Changed
|
||||
|
||||
- Host documentation on Gitlab Page
|
||||
- Event driven update (from the blender deps graph)
|
||||
|
||||
### Fixed
|
||||
|
||||
- Vertex group assignation
|
||||
- Parent relation can't be removed
|
||||
- Separate object
|
||||
- Delete animation
|
||||
- Sync missing holdout option for grease pencil material
|
||||
- Sync missing `skin_vertices`
|
||||
- Exception access violation during Undo/Redo
|
||||
- Sync missing armature bone Roll
|
||||
- Sync missing driver data_path
|
||||
- Constraint replication
|
64
README.md
64
README.md
@ -19,44 +19,46 @@ This tool aims to allow multiple users to work on the same scene over the networ
|
||||
|
||||
## Usage
|
||||
|
||||
See the [documentation](https://multi-user.readthedocs.io/en/latest/) for details.
|
||||
See the [documentation](https://slumber.gitlab.io/multi-user/index.html) for details.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
See the [troubleshooting guide](https://multi-user.readthedocs.io/en/latest/getting_started/troubleshooting.html) for tips on the most common issues.
|
||||
See the [troubleshooting guide](https://slumber.gitlab.io/multi-user/getting_started/troubleshooting.html) for tips on the most common issues.
|
||||
|
||||
## Current development status
|
||||
|
||||
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 | ✔️ | |
|
||||
| armature | ❗ | Not stable |
|
||||
| camera | ✔️ | |
|
||||
| collection | ✔️ | |
|
||||
| curve | ❗ | Nurbs not supported |
|
||||
| gpencil | ✔️ | [Airbrush not supported](https://gitlab.com/slumber/multi-user/-/issues/123) |
|
||||
| image | ✔️ | |
|
||||
| mesh | ✔️ | |
|
||||
| material | ✔️ | |
|
||||
| node_groups | ❗ | Material only |
|
||||
| metaball | ✔️ | |
|
||||
| object | ✔️ | |
|
||||
| textures | ❗ | Supported for modifiers only |
|
||||
| texts | ✔️ | |
|
||||
| scene | ✔️ | |
|
||||
| world | ✔️ | |
|
||||
| lightprobes | ✔️ | |
|
||||
| compositing | ❌ | [Planned](https://gitlab.com/slumber/multi-user/-/issues/46) |
|
||||
| texts | ❌ | [Planned](https://gitlab.com/slumber/multi-user/-/issues/81) |
|
||||
| nla | ❌ | |
|
||||
| volumes | ✔️ | |
|
||||
| particles | ❌ | [On-going](https://gitlab.com/slumber/multi-user/-/issues/24) |
|
||||
| speakers | ❗ | [Partial](https://gitlab.com/slumber/multi-user/-/issues/65) |
|
||||
| vse | ❗ | Mask and Clip not supported yet |
|
||||
| physics | ❌ | [Planned](https://gitlab.com/slumber/multi-user/-/issues/45) |
|
||||
| libraries | ❗ | Partial |
|
||||
| Name | Status | Comment |
|
||||
| -------------- | :----: | :----------------------------------------------------------: |
|
||||
| action | ✔️ | |
|
||||
| armature | ❗ | Not stable |
|
||||
| camera | ✔️ | |
|
||||
| collection | ✔️ | |
|
||||
| curve | ❗ | Nurbs surfaces not supported |
|
||||
| gpencil | ✔️ | |
|
||||
| image | ✔️ | |
|
||||
| mesh | ✔️ | |
|
||||
| material | ✔️ | |
|
||||
| node_groups | ❗ | Material & Geometry only |
|
||||
| geometry nodes | ✔️ | |
|
||||
| metaball | ✔️ | |
|
||||
| object | ✔️ | |
|
||||
| textures | ❗ | Supported for modifiers/materials/geo nodes only |
|
||||
| texts | ✔️ | |
|
||||
| scene | ✔️ | |
|
||||
| world | ✔️ | |
|
||||
| lightprobes | ✔️ | |
|
||||
| compositing | ❌ | [Planned](https://gitlab.com/slumber/multi-user/-/issues/46) |
|
||||
| texts | ❌ | [Planned](https://gitlab.com/slumber/multi-user/-/issues/81) |
|
||||
| nla | ❌ | |
|
||||
| volumes | ✔️ | |
|
||||
| particles | ❗ | The cache isn't syncing. |
|
||||
| speakers | ❗ | [Partial](https://gitlab.com/slumber/multi-user/-/issues/65) |
|
||||
| vse | ❗ | Mask and Clip not supported yet |
|
||||
| physics | ❌ | [Planned](https://gitlab.com/slumber/multi-user/-/issues/45) |
|
||||
| libraries | ❗ | Partial |
|
||||
|
||||
|
||||
|
||||
### Performance issues
|
||||
@ -74,7 +76,7 @@ I'm working on it.
|
||||
|
||||
## Contributing
|
||||
|
||||
See [contributing section](https://multi-user.readthedocs.io/en/latest/ways_to_contribute.html) of the documentation.
|
||||
See [contributing section](https://slumber.gitlab.io/multi-user/ways_to_contribute.html) of the documentation.
|
||||
|
||||
Feel free to [join the discord server](https://discord.gg/aBPvGws) to chat, seek help and contribute.
|
||||
|
||||
|
@ -122,13 +122,13 @@ class addon_updater_install_popup(bpy.types.Operator):
|
||||
# if true, run clean install - ie remove all files before adding new
|
||||
# equivalent to deleting the addon and reinstalling, except the
|
||||
# updater folder/backup folder remains
|
||||
clean_install = bpy.props.BoolProperty(
|
||||
clean_install: bpy.props.BoolProperty(
|
||||
name="Clean install",
|
||||
description="If enabled, completely clear the addon's folder before installing new update, creating a fresh install",
|
||||
default=False,
|
||||
options={'HIDDEN'}
|
||||
)
|
||||
ignore_enum = bpy.props.EnumProperty(
|
||||
ignore_enum: bpy.props.EnumProperty(
|
||||
name="Process update",
|
||||
description="Decide to install, ignore, or defer new addon update",
|
||||
items=[
|
||||
@ -264,7 +264,7 @@ class addon_updater_update_now(bpy.types.Operator):
|
||||
# if true, run clean install - ie remove all files before adding new
|
||||
# equivalent to deleting the addon and reinstalling, except the
|
||||
# updater folder/backup folder remains
|
||||
clean_install = bpy.props.BoolProperty(
|
||||
clean_install: bpy.props.BoolProperty(
|
||||
name="Clean install",
|
||||
description="If enabled, completely clear the addon's folder before installing new update, creating a fresh install",
|
||||
default=False,
|
||||
@ -332,7 +332,7 @@ class addon_updater_update_target(bpy.types.Operator):
|
||||
i+=1
|
||||
return ret
|
||||
|
||||
target = bpy.props.EnumProperty(
|
||||
target: bpy.props.EnumProperty(
|
||||
name="Target version to install",
|
||||
description="Select the version to install",
|
||||
items=target_version
|
||||
@ -341,7 +341,7 @@ class addon_updater_update_target(bpy.types.Operator):
|
||||
# if true, run clean install - ie remove all files before adding new
|
||||
# equivalent to deleting the addon and reinstalling, except the
|
||||
# updater folder/backup folder remains
|
||||
clean_install = bpy.props.BoolProperty(
|
||||
clean_install: bpy.props.BoolProperty(
|
||||
name="Clean install",
|
||||
description="If enabled, completely clear the addon's folder before installing new update, creating a fresh install",
|
||||
default=False,
|
||||
@ -399,7 +399,7 @@ class addon_updater_install_manually(bpy.types.Operator):
|
||||
bl_description = "Proceed to manually install update"
|
||||
bl_options = {'REGISTER', 'INTERNAL'}
|
||||
|
||||
error = bpy.props.StringProperty(
|
||||
error: bpy.props.StringProperty(
|
||||
name="Error Occurred",
|
||||
default="",
|
||||
options={'HIDDEN'}
|
||||
@ -461,7 +461,7 @@ class addon_updater_updated_successful(bpy.types.Operator):
|
||||
bl_description = "Update installation response"
|
||||
bl_options = {'REGISTER', 'INTERNAL', 'UNDO'}
|
||||
|
||||
error = bpy.props.StringProperty(
|
||||
error: bpy.props.StringProperty(
|
||||
name="Error Occurred",
|
||||
default="",
|
||||
options={'HIDDEN'}
|
||||
|
@ -42,6 +42,7 @@ __all__ = [
|
||||
# 'bl_sequencer',
|
||||
'bl_node_group',
|
||||
'bl_texture',
|
||||
"bl_particle",
|
||||
] # Order here defines execution order
|
||||
|
||||
if bpy.app.version[1] >= 91:
|
||||
|
@ -27,7 +27,7 @@ from .dump_anything import Loader, Dumper
|
||||
from .bl_datablock import BlDatablock, get_datablock_from_uuid
|
||||
|
||||
NODE_SOCKET_INDEX = re.compile('\[(\d*)\]')
|
||||
IGNORED_SOCKETS = ['GEOMETRY']
|
||||
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
|
||||
@ -53,31 +53,136 @@ def load_node(node_data: dict, node_tree: bpy.types.ShaderNodeTree):
|
||||
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_data):
|
||||
if idx < len(inputs) and hasattr(inputs[idx], "default_value"):
|
||||
for idx, inpt in enumerate(inputs):
|
||||
if idx < len(inputs_data) and hasattr(inpt, "default_value"):
|
||||
loaded_input = inputs_data[idx]
|
||||
try:
|
||||
inputs[idx].default_value = inpt
|
||||
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 {inputs[idx].name} parameter not supported, skipping ({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_data):
|
||||
if idx < len(outputs) and hasattr(outputs[idx], "default_value"):
|
||||
for idx, output in enumerate(outputs):
|
||||
if idx < len(outputs_data) and hasattr(output, "default_value"):
|
||||
loaded_output = outputs_data[idx]
|
||||
try:
|
||||
outputs[idx].default_value = output
|
||||
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 {outputs[idx].name} parameter not supported, skipping ({e})")
|
||||
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",
|
||||
"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
|
||||
|
||||
@ -120,93 +225,6 @@ def dump_links(links):
|
||||
return links_data
|
||||
|
||||
|
||||
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",
|
||||
"image"
|
||||
]
|
||||
|
||||
dumped_node = node_dumper.dump(node)
|
||||
|
||||
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'] = []
|
||||
for idx, inpt in enumerate(node.inputs):
|
||||
if hasattr(inpt, 'default_value'):
|
||||
dumped_node['inputs'].append(
|
||||
io_dumper.dump(inpt.default_value))
|
||||
|
||||
if hasattr(node, 'outputs'):
|
||||
dumped_node['outputs'] = []
|
||||
for idx, output in enumerate(node.outputs):
|
||||
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',
|
||||
'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 dump_node_tree(node_tree: bpy.types.ShaderNodeTree) -> dict:
|
||||
""" Dump a shader node_tree to a dict including links and nodes
|
||||
|
||||
@ -263,7 +281,7 @@ def load_node_tree_sockets(sockets: bpy.types.Collection,
|
||||
"""
|
||||
# Check for removed sockets
|
||||
for socket in sockets:
|
||||
if not [s for s in sockets_data if socket['uuid'] == s[2]]:
|
||||
if not [s for s in sockets_data if 'uuid' in socket and socket['uuid'] == s[2]]:
|
||||
sockets.remove(socket)
|
||||
|
||||
# Check for new sockets
|
||||
@ -303,6 +321,14 @@ def load_node_tree(node_tree_data: dict, target_node_tree: bpy.types.ShaderNodeT
|
||||
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()
|
||||
@ -317,6 +343,8 @@ def get_node_tree_dependencies(node_tree: bpy.types.NodeTree) -> list:
|
||||
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:
|
||||
@ -324,6 +352,8 @@ def get_node_tree_dependencies(node_tree: bpy.types.NodeTree) -> list:
|
||||
deps.append(node.image)
|
||||
elif has_node_group(node):
|
||||
deps.append(node.node_tree)
|
||||
elif has_texture(node):
|
||||
deps.append(node.texture)
|
||||
|
||||
return deps
|
||||
|
||||
@ -354,10 +384,7 @@ def load_materials_slots(src_materials: list, dst_materials: bpy.types.bpy_prop_
|
||||
if mat_uuid is not None:
|
||||
mat_ref = get_datablock_from_uuid(mat_uuid, None)
|
||||
else:
|
||||
mat_ref = bpy.data.materials.get(mat_name, None)
|
||||
|
||||
if mat_ref is None:
|
||||
raise Exception(f"Material {mat_name} doesn't exist")
|
||||
mat_ref = bpy.data.materials[mat_name]
|
||||
|
||||
dst_materials.append(mat_ref)
|
||||
|
||||
|
@ -23,6 +23,7 @@ import mathutils
|
||||
from replication.exception import ContextError
|
||||
|
||||
from .bl_datablock import BlDatablock, get_datablock_from_uuid
|
||||
from .bl_material import IGNORED_SOCKETS
|
||||
from .dump_anything import (
|
||||
Dumper,
|
||||
Loader,
|
||||
@ -30,32 +31,97 @@ from .dump_anything import (
|
||||
np_dump_collection)
|
||||
|
||||
|
||||
|
||||
SKIN_DATA = [
|
||||
'radius',
|
||||
'use_loose',
|
||||
'use_root'
|
||||
]
|
||||
|
||||
def get_input_index(e):
|
||||
return int(re.findall('[0-9]+', e)[0])
|
||||
if bpy.app.version[1] >= 93:
|
||||
SUPPORTED_GEOMETRY_NODE_PARAMETERS = (int, str, float)
|
||||
else:
|
||||
SUPPORTED_GEOMETRY_NODE_PARAMETERS = (int, str)
|
||||
logging.warning("Geometry node Float parameter not supported in \
|
||||
blender 2.92.")
|
||||
|
||||
def get_node_group_inputs(node_group):
|
||||
inputs = []
|
||||
for inpt in node_group.inputs:
|
||||
if inpt.type in IGNORED_SOCKETS:
|
||||
continue
|
||||
else:
|
||||
inputs.append(inpt)
|
||||
return inputs
|
||||
# return [inpt.identifer for inpt in node_group.inputs if inpt.type not in IGNORED_SOCKETS]
|
||||
|
||||
|
||||
def dump_physics(target: bpy.types.Object)->dict:
|
||||
"""
|
||||
Dump all physics settings from a given object excluding modifier
|
||||
related physics settings (such as softbody, cloth, dynapaint and fluid)
|
||||
"""
|
||||
dumper = Dumper()
|
||||
dumper.depth = 1
|
||||
physics_data = {}
|
||||
|
||||
# Collisions (collision)
|
||||
if target.collision and target.collision.use:
|
||||
physics_data['collision'] = dumper.dump(target.collision)
|
||||
|
||||
# Field (field)
|
||||
if target.field and target.field.type != "NONE":
|
||||
physics_data['field'] = dumper.dump(target.field)
|
||||
|
||||
# Rigid Body (rigid_body)
|
||||
if target.rigid_body:
|
||||
physics_data['rigid_body'] = dumper.dump(target.rigid_body)
|
||||
|
||||
# Rigid Body constraint (rigid_body_constraint)
|
||||
if target.rigid_body_constraint:
|
||||
physics_data['rigid_body_constraint'] = dumper.dump(target.rigid_body_constraint)
|
||||
|
||||
return physics_data
|
||||
|
||||
def load_physics(dumped_settings: dict, target: bpy.types.Object):
|
||||
""" Load all physics settings from a given object excluding modifier
|
||||
related physics settings (such as softbody, cloth, dynapaint and fluid)
|
||||
"""
|
||||
loader = Loader()
|
||||
|
||||
if 'collision' in dumped_settings:
|
||||
loader.load(target.collision, dumped_settings['collision'])
|
||||
|
||||
if 'field' in dumped_settings:
|
||||
loader.load(target.field, dumped_settings['field'])
|
||||
|
||||
if 'rigid_body' in dumped_settings:
|
||||
if not target.rigid_body:
|
||||
bpy.ops.rigidbody.object_add({"object": target})
|
||||
loader.load(target.rigid_body, dumped_settings['rigid_body'])
|
||||
elif target.rigid_body:
|
||||
bpy.ops.rigidbody.object_remove({"object": target})
|
||||
|
||||
if 'rigid_body_constraint' in dumped_settings:
|
||||
if not target.rigid_body_constraint:
|
||||
bpy.ops.rigidbody.constraint_add({"object": target})
|
||||
loader.load(target.rigid_body_constraint, dumped_settings['rigid_body_constraint'])
|
||||
elif target.rigid_body_constraint:
|
||||
bpy.ops.rigidbody.constraint_remove({"object": target})
|
||||
|
||||
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
|
||||
"""
|
||||
inputs_name = [p for p in dir(modifier) if "Input_" in p]
|
||||
inputs_name.sort(key=get_input_index)
|
||||
dumped_inputs = []
|
||||
for inputs_index, input_name in enumerate(inputs_name):
|
||||
input_value = modifier[input_name]
|
||||
for inpt in get_node_group_inputs(modifier.node_group):
|
||||
input_value = modifier[inpt.identifier]
|
||||
|
||||
dumped_input = None
|
||||
if isinstance(input_value, bpy.types.ID):
|
||||
dumped_input = input_value.uuid
|
||||
elif type(input_value) in [int, str, float]:
|
||||
elif isinstance(input_value, SUPPORTED_GEOMETRY_NODE_PARAMETERS):
|
||||
dumped_input = input_value
|
||||
elif hasattr(input_value, 'to_list'):
|
||||
dumped_input = input_value.to_list()
|
||||
@ -73,18 +139,16 @@ def load_modifier_geometry_node_inputs(dumped_modifier: dict, target_modifier: b
|
||||
:type target_modifier: bpy.type.Modifier
|
||||
"""
|
||||
|
||||
inputs_name = [p for p in dir(target_modifier) if "Input_" in p]
|
||||
inputs_name.sort(key=get_input_index)
|
||||
for input_index, input_name in enumerate(inputs_name):
|
||||
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[input_name]
|
||||
if type(input_value) in [int, str, float]:
|
||||
input_value = dumped_value
|
||||
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]
|
||||
else:
|
||||
target_modifier[input_name] = get_datablock_from_uuid(
|
||||
elif inpt.type in ['COLLECTION', 'OBJECT']:
|
||||
target_modifier[inpt.identifier] = get_datablock_from_uuid(
|
||||
dumped_value, None)
|
||||
|
||||
|
||||
@ -161,19 +225,24 @@ def find_textures_dependencies(modifiers: bpy.types.bpy_prop_collection) -> [bpy
|
||||
return textures
|
||||
|
||||
|
||||
def find_geometry_nodes(modifiers: bpy.types.bpy_prop_collection) -> [bpy.types.NodeTree]:
|
||||
""" Find geometry nodes group from a modifier stack
|
||||
def find_geometry_nodes_dependencies(modifiers: bpy.types.bpy_prop_collection) -> [bpy.types.NodeTree]:
|
||||
""" Find geometry nodes dependencies from a modifier stack
|
||||
|
||||
:arg modifiers: modifiers collection
|
||||
:type modifiers: bpy.types.bpy_prop_collection
|
||||
:return: list of bpy.types.NodeTree pointers
|
||||
"""
|
||||
nodes_groups = []
|
||||
for item in modifiers:
|
||||
if item.type == 'NODES' and item.node_group:
|
||||
nodes_groups.append(item.node_group)
|
||||
dependencies = []
|
||||
for mod in modifiers:
|
||||
if mod.type == 'NODES' and mod.node_group:
|
||||
dependencies.append(mod.node_group)
|
||||
# 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
|
||||
|
||||
return nodes_groups
|
||||
|
||||
def dump_vertex_groups(src_object: bpy.types.Object) -> dict:
|
||||
""" Dump object's vertex groups
|
||||
@ -219,6 +288,7 @@ def load_vertex_groups(dumped_vertex_groups: dict, target_object: bpy.types.Obje
|
||||
for index, weight in vg['vertices']:
|
||||
vertex_group.add([index], weight, 'REPLACE')
|
||||
|
||||
|
||||
class BlObject(BlDatablock):
|
||||
bl_id = "objects"
|
||||
bl_class = bpy.types.Object
|
||||
@ -296,9 +366,20 @@ class BlObject(BlDatablock):
|
||||
# Load transformation data
|
||||
loader.load(target, data)
|
||||
|
||||
# Object display fields
|
||||
if 'display' in data:
|
||||
loader.load(target.display, data['display'])
|
||||
|
||||
# Parenting
|
||||
parent_id = data.get('parent_uid')
|
||||
if parent_id:
|
||||
parent = get_datablock_from_uuid(parent_id[0], bpy.data.objects[parent_id[1]])
|
||||
# Avoid reloading
|
||||
if target.parent != parent and parent is not None:
|
||||
target.parent = parent
|
||||
elif target.parent:
|
||||
target.parent = None
|
||||
|
||||
# Pose
|
||||
if 'pose' in data:
|
||||
if not target.pose:
|
||||
@ -343,14 +424,48 @@ class BlObject(BlDatablock):
|
||||
SKIN_DATA)
|
||||
|
||||
if hasattr(target, 'cycles_visibility') \
|
||||
and 'cycles_visibility' in data:
|
||||
and 'cycles_visibility' in data:
|
||||
loader.load(target.cycles_visibility, data['cycles_visibility'])
|
||||
|
||||
# TODO: handle geometry nodes input from dump_anything
|
||||
if hasattr(target, 'modifiers'):
|
||||
nodes_modifiers = [mod for mod in target.modifiers if mod.type == 'NODES']
|
||||
nodes_modifiers = [
|
||||
mod for mod in target.modifiers if mod.type == 'NODES']
|
||||
for modifier in nodes_modifiers:
|
||||
load_modifier_geometry_node_inputs(data['modifiers'][modifier.name], modifier)
|
||||
load_modifier_geometry_node_inputs(
|
||||
data['modifiers'][modifier.name], modifier)
|
||||
|
||||
particles_modifiers = [
|
||||
mod for mod in target.modifiers if mod.type == 'PARTICLE_SYSTEM']
|
||||
|
||||
for mod in particles_modifiers:
|
||||
default = mod.particle_system.settings
|
||||
dumped_particles = data['modifiers'][mod.name]['particle_system']
|
||||
loader.load(mod.particle_system, dumped_particles)
|
||||
|
||||
settings = get_datablock_from_uuid(dumped_particles['settings_uuid'], None)
|
||||
if settings:
|
||||
mod.particle_system.settings = settings
|
||||
# Hack to remove the default generated particle settings
|
||||
if not default.uuid:
|
||||
bpy.data.particles.remove(default)
|
||||
|
||||
phys_modifiers = [
|
||||
mod for mod in target.modifiers if mod.type in ['SOFT_BODY', 'CLOTH']]
|
||||
|
||||
for mod in phys_modifiers:
|
||||
loader.load(mod.settings, data['modifiers'][mod.name]['settings'])
|
||||
|
||||
# PHYSICS
|
||||
load_physics(data, target)
|
||||
|
||||
transform = data.get('transforms', None)
|
||||
if transform:
|
||||
target.matrix_parent_inverse = mathutils.Matrix(
|
||||
transform['matrix_parent_inverse'])
|
||||
target.matrix_basis = mathutils.Matrix(transform['matrix_basis'])
|
||||
target.matrix_local = mathutils.Matrix(transform['matrix_local'])
|
||||
|
||||
|
||||
def _dump_implementation(self, data, instance=None):
|
||||
assert(instance)
|
||||
@ -366,7 +481,6 @@ class BlObject(BlDatablock):
|
||||
dumper.include_filter = [
|
||||
"name",
|
||||
"rotation_mode",
|
||||
"parent",
|
||||
"data",
|
||||
"library",
|
||||
"empty_display_type",
|
||||
@ -381,8 +495,6 @@ class BlObject(BlDatablock):
|
||||
"color",
|
||||
"instance_collection",
|
||||
"instance_type",
|
||||
"location",
|
||||
"scale",
|
||||
'lock_location',
|
||||
'lock_rotation',
|
||||
'lock_scale',
|
||||
@ -396,12 +508,16 @@ class BlObject(BlDatablock):
|
||||
'show_all_edges',
|
||||
'show_texture_space',
|
||||
'show_in_front',
|
||||
'type',
|
||||
'rotation_quaternion' if instance.rotation_mode == 'QUATERNION' else 'rotation_euler',
|
||||
'type'
|
||||
]
|
||||
|
||||
data = dumper.dump(instance)
|
||||
|
||||
dumper.include_filter = [
|
||||
'matrix_parent_inverse',
|
||||
'matrix_local',
|
||||
'matrix_basis']
|
||||
data['transforms'] = dumper.dump(instance)
|
||||
dumper.include_filter = [
|
||||
'show_shadows',
|
||||
]
|
||||
@ -411,6 +527,10 @@ class BlObject(BlDatablock):
|
||||
if self.is_library:
|
||||
return data
|
||||
|
||||
# PARENTING
|
||||
if instance.parent:
|
||||
data['parent_uid'] = (instance.parent.uuid, instance.parent.name)
|
||||
|
||||
# MODIFIERS
|
||||
if hasattr(instance, 'modifiers'):
|
||||
data["modifiers"] = {}
|
||||
@ -418,12 +538,29 @@ class BlObject(BlDatablock):
|
||||
if modifiers:
|
||||
dumper.include_filter = None
|
||||
dumper.depth = 1
|
||||
dumper.exclude_filter = ['is_active']
|
||||
for index, modifier in enumerate(modifiers):
|
||||
data["modifiers"][modifier.name] = dumper.dump(modifier)
|
||||
dumped_modifier = dumper.dump(modifier)
|
||||
# hack to dump geometry nodes inputs
|
||||
if modifier.type == 'NODES':
|
||||
dumped_inputs = dump_modifier_geometry_node_inputs(modifier)
|
||||
data["modifiers"][modifier.name]['inputs'] = dumped_inputs
|
||||
dumped_inputs = dump_modifier_geometry_node_inputs(
|
||||
modifier)
|
||||
dumped_modifier['inputs'] = dumped_inputs
|
||||
|
||||
elif modifier.type == 'PARTICLE_SYSTEM':
|
||||
dumper.exclude_filter = [
|
||||
"is_edited",
|
||||
"is_editable",
|
||||
"is_global_hair"
|
||||
]
|
||||
dumped_modifier['particle_system'] = dumper.dump(modifier.particle_system)
|
||||
dumped_modifier['particle_system']['settings_uuid'] = modifier.particle_system.settings.uuid
|
||||
|
||||
elif modifier.type in ['SOFT_BODY', 'CLOTH']:
|
||||
dumped_modifier['settings'] = dumper.dump(modifier.settings)
|
||||
|
||||
data["modifiers"][modifier.name] = dumped_modifier
|
||||
|
||||
gp_modifiers = getattr(instance, 'grease_pencil_modifiers', None)
|
||||
|
||||
if gp_modifiers:
|
||||
@ -445,6 +582,7 @@ class BlObject(BlDatablock):
|
||||
'location']
|
||||
gp_mod_data['curve'] = curve_dumper.dump(modifier.curve)
|
||||
|
||||
|
||||
# CONSTRAINTS
|
||||
if hasattr(instance, 'constraints'):
|
||||
dumper.include_filter = None
|
||||
@ -489,7 +627,6 @@ class BlObject(BlDatablock):
|
||||
bone_groups[group.name] = dumper.dump(group)
|
||||
data['pose']['bone_groups'] = bone_groups
|
||||
|
||||
|
||||
# VERTEx GROUP
|
||||
if len(instance.vertex_groups) > 0:
|
||||
data['vertex_groups'] = dump_vertex_groups(instance)
|
||||
@ -526,7 +663,8 @@ class BlObject(BlDatablock):
|
||||
if hasattr(object_data, 'skin_vertices') and object_data.skin_vertices:
|
||||
skin_vertices = list()
|
||||
for skin_data in object_data.skin_vertices:
|
||||
skin_vertices.append(np_dump_collection(skin_data.data, SKIN_DATA))
|
||||
skin_vertices.append(
|
||||
np_dump_collection(skin_data.data, SKIN_DATA))
|
||||
data['skin_vertices'] = skin_vertices
|
||||
|
||||
# CYCLE SETTINGS
|
||||
@ -541,6 +679,9 @@ class BlObject(BlDatablock):
|
||||
]
|
||||
data['cycles_visibility'] = dumper.dump(instance.cycles_visibility)
|
||||
|
||||
# PHYSICS
|
||||
data.update(dump_physics(instance))
|
||||
|
||||
return data
|
||||
|
||||
def _resolve_deps_implementation(self):
|
||||
@ -549,18 +690,23 @@ class BlObject(BlDatablock):
|
||||
# Avoid Empty case
|
||||
if self.instance.data:
|
||||
deps.append(self.instance.data)
|
||||
if self.instance.parent :
|
||||
deps.append(self.instance.parent)
|
||||
|
||||
# Particle systems
|
||||
for particle_slot in self.instance.particle_systems:
|
||||
deps.append(particle_slot.settings)
|
||||
|
||||
if self.is_library:
|
||||
deps.append(self.instance.library)
|
||||
|
||||
if self.instance.parent:
|
||||
deps.append(self.instance.parent)
|
||||
|
||||
if self.instance.instance_type == 'COLLECTION':
|
||||
# TODO: uuid based
|
||||
deps.append(self.instance.instance_collection)
|
||||
|
||||
if self.instance.modifiers:
|
||||
deps.extend(find_textures_dependencies(self.instance.modifiers))
|
||||
deps.extend(find_geometry_nodes(self.instance.modifiers))
|
||||
deps.extend(find_geometry_nodes_dependencies(self.instance.modifiers))
|
||||
|
||||
return deps
|
||||
|
90
multi_user/bl_types/bl_particle.py
Normal file
90
multi_user/bl_types/bl_particle.py
Normal file
@ -0,0 +1,90 @@
|
||||
import bpy
|
||||
import mathutils
|
||||
|
||||
from . import dump_anything
|
||||
from .bl_datablock import BlDatablock, get_datablock_from_uuid
|
||||
|
||||
|
||||
def dump_textures_slots(texture_slots: bpy.types.bpy_prop_collection) -> list:
|
||||
""" Dump every texture slot collection as the form:
|
||||
[(index, slot_texture_uuid, slot_texture_name), (), ...]
|
||||
"""
|
||||
dumped_slots = []
|
||||
for index, slot in enumerate(texture_slots):
|
||||
if slot and slot.texture:
|
||||
dumped_slots.append((index, slot.texture.uuid, slot.texture.name))
|
||||
|
||||
return dumped_slots
|
||||
|
||||
|
||||
def load_texture_slots(dumped_slots: list, target_slots: bpy.types.bpy_prop_collection):
|
||||
"""
|
||||
"""
|
||||
for index, slot in enumerate(target_slots):
|
||||
if slot:
|
||||
target_slots.clear(index)
|
||||
|
||||
for index, slot_uuid, slot_name in dumped_slots:
|
||||
target_slots.create(index).texture = get_datablock_from_uuid(
|
||||
slot_uuid, slot_name
|
||||
)
|
||||
|
||||
IGNORED_ATTR = [
|
||||
"is_embedded_data",
|
||||
"is_evaluated",
|
||||
"is_fluid",
|
||||
"is_library_indirect",
|
||||
"users"
|
||||
]
|
||||
|
||||
class BlParticle(BlDatablock):
|
||||
bl_id = "particles"
|
||||
bl_class = bpy.types.ParticleSettings
|
||||
bl_icon = "PARTICLES"
|
||||
bl_check_common = False
|
||||
bl_reload_parent = False
|
||||
|
||||
def _construct(self, data):
|
||||
instance = bpy.data.particles.new(data["name"])
|
||||
instance.uuid = self.uuid
|
||||
return instance
|
||||
|
||||
def _load_implementation(self, data, target):
|
||||
dump_anything.load(target, data)
|
||||
|
||||
dump_anything.load(target.effector_weights, data["effector_weights"])
|
||||
|
||||
# Force field
|
||||
force_field_1 = data.get("force_field_1", None)
|
||||
if force_field_1:
|
||||
dump_anything.load(target.force_field_1, force_field_1)
|
||||
|
||||
force_field_2 = data.get("force_field_2", None)
|
||||
if force_field_2:
|
||||
dump_anything.load(target.force_field_2, force_field_2)
|
||||
|
||||
# Texture slots
|
||||
load_texture_slots(data["texture_slots"], target.texture_slots)
|
||||
|
||||
def _dump_implementation(self, data, instance=None):
|
||||
assert instance
|
||||
|
||||
dumper = dump_anything.Dumper()
|
||||
dumper.depth = 1
|
||||
dumper.exclude_filter = IGNORED_ATTR
|
||||
data = dumper.dump(instance)
|
||||
|
||||
# Particle effectors
|
||||
data["effector_weights"] = dumper.dump(instance.effector_weights)
|
||||
if instance.force_field_1:
|
||||
data["force_field_1"] = dumper.dump(instance.force_field_1)
|
||||
if instance.force_field_2:
|
||||
data["force_field_2"] = dumper.dump(instance.force_field_2)
|
||||
|
||||
# Texture slots
|
||||
data["texture_slots"] = dump_textures_slots(instance.texture_slots)
|
||||
|
||||
return data
|
||||
|
||||
def _resolve_deps_implementation(self):
|
||||
return [t.texture for t in self.instance.texture_slots if t and t.texture]
|
@ -610,6 +610,8 @@ class Loader:
|
||||
instance.write(bpy.data.fonts.get(dump))
|
||||
elif isinstance(rna_property_type, T.Sound):
|
||||
instance.write(bpy.data.sounds.get(dump))
|
||||
# elif isinstance(rna_property_type, T.ParticleSettings):
|
||||
# instance.write(bpy.data.particles.get(dump))
|
||||
|
||||
def _load_matrix(self, matrix, dump):
|
||||
matrix.write(mathutils.Matrix(dump))
|
||||
|
@ -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', '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
|
||||
|
||||
@ -210,8 +432,6 @@ class SessionStartOperator(bpy.types.Operator):
|
||||
type_module_class,
|
||||
check_common=type_module_class.bl_check_common)
|
||||
|
||||
deleyables.append(timers.ApplyTimer(timeout=settings.depsgraph_update_rate))
|
||||
|
||||
if bpy.app.version[1] >= 91:
|
||||
python_binary_path = sys.executable
|
||||
else:
|
||||
@ -272,6 +492,7 @@ class SessionStartOperator(bpy.types.Operator):
|
||||
# Background client updates service
|
||||
deleyables.append(timers.ClientUpdate())
|
||||
deleyables.append(timers.DynamicRightSelectTimer())
|
||||
deleyables.append(timers.ApplyTimer(timeout=settings.depsgraph_update_rate))
|
||||
# deleyables.append(timers.PushTimer(
|
||||
# queue=stagging,
|
||||
# timeout=settings.depsgraph_update_rate
|
||||
@ -812,6 +1033,26 @@ class SessionStopAutoSaveOperator(bpy.types.Operator):
|
||||
|
||||
return {'FINISHED'}
|
||||
|
||||
class SessionRenderReplay(bpy.types.Operator):
|
||||
bl_idname = "session.render_replay"
|
||||
bl_label = "Render Replay"
|
||||
bl_description = "Render Replay"
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context):
|
||||
return context.window_manager.session.replay_files
|
||||
|
||||
def execute(self, context):
|
||||
base_path = str(context.scene.render.filepath)
|
||||
for frame in range(0,context.scene.frame_end):
|
||||
logging.info(f"Rendering frame {frame} to {base_path}_{frame}.png")
|
||||
context.scene.frame_current = frame
|
||||
filename = Path(bpy.context.window_manager.session.replay_files[context.scene.active_replay_file].name)
|
||||
context.scene.render.filepath = f"{base_path}{frame}_{filename.stem}"
|
||||
bpy.ops.render.render(write_still=True)
|
||||
|
||||
context.scene.render.filepath = base_path
|
||||
return {'FINISHED'}
|
||||
|
||||
class SessionLoadSaveOperator(bpy.types.Operator, ImportHelper):
|
||||
bl_idname = "session.load"
|
||||
@ -828,9 +1069,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:
|
||||
@ -879,7 +1153,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():
|
||||
@ -889,6 +1172,69 @@ 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
|
||||
|
||||
bpy.context.scene.eevee.use_bloom = False
|
||||
|
||||
return {'FINISHED'}
|
||||
|
||||
@ -918,6 +1264,7 @@ classes = (
|
||||
SessionLoadSaveOperator,
|
||||
SessionStopAutoSaveOperator,
|
||||
SessionPurgeOperator,
|
||||
SessionRenderReplay
|
||||
)
|
||||
|
||||
def update_external_dependencies():
|
||||
@ -968,6 +1315,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
|
||||
@ -995,7 +1352,7 @@ def depsgraph_evaluation(scene):
|
||||
# - if its ours or ( under common and diff), launch the
|
||||
# update process
|
||||
# - if its to someone else, ignore the update
|
||||
if node and node.owner in [session.id, RP_COMMON]:
|
||||
if node and (node.owner == session.id or node.bl_check_common):
|
||||
if node.state == UP:
|
||||
try:
|
||||
if node.has_changed():
|
||||
|
@ -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()
|
||||
@ -195,7 +277,7 @@ class SessionPrefs(bpy.types.AddonPreferences):
|
||||
connection_timeout: bpy.props.IntProperty(
|
||||
name='connection timeout',
|
||||
description='connection timeout before disconnection',
|
||||
default=1000
|
||||
default=5000
|
||||
)
|
||||
# Replication update settings
|
||||
depsgraph_update_rate: bpy.props.FloatProperty(
|
||||
@ -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
|
||||
|
@ -17,7 +17,7 @@
|
||||
|
||||
import logging
|
||||
import sys
|
||||
|
||||
import traceback
|
||||
import bpy
|
||||
from replication.constants import (FETCHED, RP_COMMON, STATE_ACTIVE,
|
||||
STATE_INITIAL, STATE_LOBBY, STATE_QUITTING,
|
||||
@ -112,7 +112,8 @@ class ApplyTimer(Timer):
|
||||
try:
|
||||
session.apply(node)
|
||||
except Exception as e:
|
||||
logging.error(f"Fail to apply {node_ref.uuid}: {e}")
|
||||
logging.error(f"Fail to apply {node_ref.uuid}")
|
||||
traceback.print_exc()
|
||||
else:
|
||||
if node_ref.bl_reload_parent:
|
||||
for parent in session._graph.find_parents(node):
|
||||
|
@ -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,
|
||||
)
|
||||
|
||||
|
@ -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)]
|
||||
|
||||
|
@ -13,7 +13,7 @@ def main():
|
||||
if len(sys.argv) > 2:
|
||||
blender_rev = sys.argv[2]
|
||||
else:
|
||||
blender_rev = "2.91.0"
|
||||
blender_rev = "2.92.0"
|
||||
|
||||
try:
|
||||
exit_val = BAT.test_blender_addon(addon_path=addon, blender_revision=blender_rev)
|
||||
|
@ -7,7 +7,7 @@ import bpy
|
||||
import random
|
||||
from multi_user.bl_types.bl_object import BlObject
|
||||
|
||||
# Removed 'BUILD' modifier because the seed doesn't seems to be
|
||||
# Removed 'BUILD', 'SOFT_BODY' modifier because the seed doesn't seems to be
|
||||
# correctly initialized (#TODO: report the bug)
|
||||
MOFIFIERS_TYPES = [
|
||||
'DATA_TRANSFER', 'MESH_CACHE', 'MESH_SEQUENCE_CACHE',
|
||||
@ -22,8 +22,7 @@ MOFIFIERS_TYPES = [
|
||||
'MESH_DEFORM', 'SHRINKWRAP', 'SIMPLE_DEFORM', 'SMOOTH',
|
||||
'CORRECTIVE_SMOOTH', 'LAPLACIANSMOOTH', 'SURFACE_DEFORM',
|
||||
'WARP', 'WAVE', 'CLOTH', 'COLLISION', 'DYNAMIC_PAINT',
|
||||
'EXPLODE', 'FLUID', 'OCEAN', 'PARTICLE_INSTANCE',
|
||||
'SOFT_BODY', 'SURFACE']
|
||||
'EXPLODE', 'FLUID', 'OCEAN', 'PARTICLE_INSTANCE', 'SURFACE']
|
||||
|
||||
GP_MODIFIERS_TYPE = [
|
||||
'GP_ARRAY', 'GP_BUILD', 'GP_MIRROR', 'GP_MULTIPLY',
|
||||
@ -72,5 +71,5 @@ def test_object(clear_blend):
|
||||
test = implementation._construct(expected)
|
||||
implementation._load(expected, test)
|
||||
result = implementation._dump(test)
|
||||
|
||||
print(DeepDiff(expected, result))
|
||||
assert not DeepDiff(expected, result)
|
||||
|
Reference in New Issue
Block a user