mirror of
https://github.com/ynput/ayon-core.git
synced 2025-12-26 13:52:15 +01:00
Merge branch 'develop' into enhancement/houdini_extract_active_view
This commit is contained in:
commit
bc5caf974c
25 changed files with 426 additions and 380 deletions
|
|
@ -2,6 +2,8 @@
|
|||
|
||||
from .cache import CacheItem, NestedCacheItem
|
||||
from .projects import (
|
||||
StatusItem,
|
||||
StatusStates,
|
||||
ProjectItem,
|
||||
ProjectsModel,
|
||||
PROJECTS_MODEL_SENDER,
|
||||
|
|
@ -21,6 +23,8 @@ __all__ = (
|
|||
"CacheItem",
|
||||
"NestedCacheItem",
|
||||
|
||||
"StatusItem",
|
||||
"StatusStates",
|
||||
"ProjectItem",
|
||||
"ProjectsModel",
|
||||
"PROJECTS_MODEL_SENDER",
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
import contextlib
|
||||
from abc import ABCMeta, abstractmethod
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Dict, Any
|
||||
|
||||
import ayon_api
|
||||
import six
|
||||
|
||||
from ayon_core.style import get_default_entity_icon_color
|
||||
from ayon_core.lib import CacheItem, NestedCacheItem
|
||||
|
|
@ -10,8 +10,14 @@ from ayon_core.lib import CacheItem, NestedCacheItem
|
|||
PROJECTS_MODEL_SENDER = "projects.model"
|
||||
|
||||
|
||||
@six.add_metaclass(ABCMeta)
|
||||
class AbstractHierarchyController:
|
||||
class StatusStates:
|
||||
not_started = "not_started"
|
||||
in_progress = "in_progress"
|
||||
done = "done"
|
||||
blocked = "blocked"
|
||||
|
||||
|
||||
class AbstractHierarchyController(ABC):
|
||||
@abstractmethod
|
||||
def emit_event(self, topic, data, source):
|
||||
pass
|
||||
|
|
@ -25,18 +31,24 @@ class StatusItem:
|
|||
color (str): Status color in hex ("#434a56").
|
||||
short (str): Short status name ("NRD").
|
||||
icon (str): Icon name in MaterialIcons ("fiber_new").
|
||||
state (Literal["not_started", "in_progress", "done", "blocked"]):
|
||||
Status state.
|
||||
state (str): Status state.
|
||||
|
||||
"""
|
||||
def __init__(self, name, color, short, icon, state):
|
||||
self.name = name
|
||||
self.color = color
|
||||
self.short = short
|
||||
self.icon = icon
|
||||
self.state = state
|
||||
def __init__(
|
||||
self,
|
||||
name: str,
|
||||
color: str,
|
||||
short: str,
|
||||
icon: str,
|
||||
state: str
|
||||
):
|
||||
self.name: str = name
|
||||
self.color: str = color
|
||||
self.short: str = short
|
||||
self.icon: str = icon
|
||||
self.state: str = state
|
||||
|
||||
def to_data(self):
|
||||
def to_data(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"name": self.name,
|
||||
"color": self.color,
|
||||
|
|
|
|||
|
|
@ -217,7 +217,9 @@ class InventoryModel(QtGui.QStandardItemModel):
|
|||
version_label = format_version(version_item.version)
|
||||
is_hero = version_item.version < 0
|
||||
is_latest = version_item.is_latest
|
||||
if not is_latest:
|
||||
# TODO maybe use different colors for last approved and last
|
||||
# version? Or don't care about color at all?
|
||||
if not is_latest and not version_item.is_last_approved:
|
||||
version_color = self.OUTDATED_COLOR
|
||||
status_name = version_item.status
|
||||
|
||||
|
|
|
|||
|
|
@ -3,7 +3,9 @@ import collections
|
|||
|
||||
import ayon_api
|
||||
from ayon_api.graphql import GraphQlQuery
|
||||
|
||||
from ayon_core.host import ILoadHost
|
||||
from ayon_core.tools.common_models.projects import StatusStates
|
||||
|
||||
|
||||
# --- Implementation that should be in ayon-python-api ---
|
||||
|
|
@ -149,26 +151,35 @@ class RepresentationInfo:
|
|||
|
||||
|
||||
class VersionItem:
|
||||
def __init__(self, version_id, product_id, version, status, is_latest):
|
||||
self.version = version
|
||||
self.version_id = version_id
|
||||
self.product_id = product_id
|
||||
self.version = version
|
||||
self.status = status
|
||||
self.is_latest = is_latest
|
||||
def __init__(
|
||||
self,
|
||||
version_id: str,
|
||||
product_id: str,
|
||||
version: int,
|
||||
status: str,
|
||||
is_latest: bool,
|
||||
is_last_approved: bool,
|
||||
):
|
||||
self.version_id: str = version_id
|
||||
self.product_id: str = product_id
|
||||
self.version: int = version
|
||||
self.status: str = status
|
||||
self.is_latest: bool = is_latest
|
||||
self.is_last_approved: bool = is_last_approved
|
||||
|
||||
@property
|
||||
def is_hero(self):
|
||||
return self.version < 0
|
||||
|
||||
@classmethod
|
||||
def from_entity(cls, version_entity, is_latest):
|
||||
def from_entity(cls, version_entity, is_latest, is_last_approved):
|
||||
return cls(
|
||||
version_id=version_entity["id"],
|
||||
product_id=version_entity["productId"],
|
||||
version=version_entity["version"],
|
||||
status=version_entity["status"],
|
||||
is_latest=is_latest,
|
||||
is_last_approved=is_last_approved,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -275,6 +286,11 @@ class ContainersModel:
|
|||
if product_id not in self._version_items_by_product_id
|
||||
}
|
||||
if missing_ids:
|
||||
status_items_by_name = {
|
||||
status_item.name: status_item
|
||||
for status_item in self._controller.get_project_status_items()
|
||||
}
|
||||
|
||||
def version_sorted(entity):
|
||||
return entity["version"]
|
||||
|
||||
|
|
@ -300,9 +316,21 @@ class ContainersModel:
|
|||
version_entities_by_product_id.items()
|
||||
):
|
||||
last_version = abs(version_entities[-1]["version"])
|
||||
last_approved_id = None
|
||||
for version_entity in version_entities:
|
||||
status_item = status_items_by_name.get(
|
||||
version_entity["status"]
|
||||
)
|
||||
if status_item is None:
|
||||
continue
|
||||
if status_item.state == StatusStates.done:
|
||||
last_approved_id = version_entity["id"]
|
||||
|
||||
version_items_by_id = {
|
||||
entity["id"]: VersionItem.from_entity(
|
||||
entity, abs(entity["version"]) == last_version
|
||||
entity,
|
||||
abs(entity["version"]) == last_version,
|
||||
entity["id"] == last_approved_id
|
||||
)
|
||||
for entity in version_entities
|
||||
}
|
||||
|
|
|
|||
|
|
@ -233,19 +233,38 @@ class SceneInventoryView(QtWidgets.QTreeView):
|
|||
has_outdated = False
|
||||
has_loaded_hero_versions = False
|
||||
has_available_hero_version = False
|
||||
for version_items_by_id in version_items_by_product_id.values():
|
||||
has_outdated_approved = False
|
||||
last_version_by_product_id = {}
|
||||
for product_id, version_items_by_id in (
|
||||
version_items_by_product_id.items()
|
||||
):
|
||||
_has_outdated_approved = False
|
||||
_last_approved_version_item = None
|
||||
for version_item in version_items_by_id.values():
|
||||
if version_item.is_hero:
|
||||
has_available_hero_version = True
|
||||
|
||||
elif version_item.is_last_approved:
|
||||
_last_approved_version_item = version_item
|
||||
_has_outdated_approved = True
|
||||
|
||||
if version_item.version_id not in version_ids:
|
||||
continue
|
||||
|
||||
if version_item.is_hero:
|
||||
has_loaded_hero_versions = True
|
||||
|
||||
elif not version_item.is_latest:
|
||||
has_outdated = True
|
||||
|
||||
if (
|
||||
_has_outdated_approved
|
||||
and _last_approved_version_item is not None
|
||||
):
|
||||
last_version_by_product_id[product_id] = (
|
||||
_last_approved_version_item
|
||||
)
|
||||
has_outdated_approved = True
|
||||
|
||||
switch_to_versioned = None
|
||||
if has_loaded_hero_versions:
|
||||
update_icon = qtawesome.icon(
|
||||
|
|
@ -261,6 +280,42 @@ class SceneInventoryView(QtWidgets.QTreeView):
|
|||
lambda: self._on_switch_to_versioned(item_ids)
|
||||
)
|
||||
|
||||
update_to_last_approved_action = None
|
||||
approved_version_by_item_id = {}
|
||||
if has_outdated_approved:
|
||||
for container_item in container_items_by_id.values():
|
||||
repre_id = container_item.representation_id
|
||||
repre_info = repre_info_by_id.get(repre_id)
|
||||
if not repre_info or not repre_info.is_valid:
|
||||
continue
|
||||
version_item = last_version_by_product_id.get(
|
||||
repre_info.product_id
|
||||
)
|
||||
if (
|
||||
version_item is None
|
||||
or version_item.version_id == repre_info.version_id
|
||||
):
|
||||
continue
|
||||
approved_version_by_item_id[container_item.item_id] = (
|
||||
version_item.version
|
||||
)
|
||||
|
||||
if approved_version_by_item_id:
|
||||
update_icon = qtawesome.icon(
|
||||
"fa.angle-double-up",
|
||||
color="#00f0b4"
|
||||
)
|
||||
update_to_last_approved_action = QtWidgets.QAction(
|
||||
update_icon,
|
||||
"Update to last approved",
|
||||
menu
|
||||
)
|
||||
update_to_last_approved_action.triggered.connect(
|
||||
lambda: self._update_containers_to_approved_versions(
|
||||
approved_version_by_item_id
|
||||
)
|
||||
)
|
||||
|
||||
update_to_latest_action = None
|
||||
if has_outdated or has_loaded_hero_versions:
|
||||
update_icon = qtawesome.icon(
|
||||
|
|
@ -299,7 +354,9 @@ class SceneInventoryView(QtWidgets.QTreeView):
|
|||
# set version
|
||||
set_version_action = None
|
||||
if active_repre_id is not None:
|
||||
set_version_icon = qtawesome.icon("fa.hashtag", color=DEFAULT_COLOR)
|
||||
set_version_icon = qtawesome.icon(
|
||||
"fa.hashtag", color=DEFAULT_COLOR
|
||||
)
|
||||
set_version_action = QtWidgets.QAction(
|
||||
set_version_icon,
|
||||
"Set version",
|
||||
|
|
@ -323,6 +380,9 @@ class SceneInventoryView(QtWidgets.QTreeView):
|
|||
if switch_to_versioned:
|
||||
menu.addAction(switch_to_versioned)
|
||||
|
||||
if update_to_last_approved_action:
|
||||
menu.addAction(update_to_last_approved_action)
|
||||
|
||||
if update_to_latest_action:
|
||||
menu.addAction(update_to_latest_action)
|
||||
|
||||
|
|
@ -970,3 +1030,24 @@ class SceneInventoryView(QtWidgets.QTreeView):
|
|||
"""
|
||||
versions = [version for _ in range(len(item_ids))]
|
||||
self._update_containers(item_ids, versions)
|
||||
|
||||
def _update_containers_to_approved_versions(
|
||||
self, approved_version_by_item_id
|
||||
):
|
||||
"""Helper to update items to given version (or version per item)
|
||||
|
||||
If at least one item is specified this will always try to refresh
|
||||
the inventory even if errors occurred on any of the items.
|
||||
|
||||
Arguments:
|
||||
approved_version_by_item_id (Dict[str, int]): Version to set by
|
||||
item id.
|
||||
|
||||
"""
|
||||
versions = []
|
||||
item_ids = []
|
||||
for item_id, version in approved_version_by_item_id.items():
|
||||
item_ids.append(item_id)
|
||||
versions.append(version)
|
||||
|
||||
self._update_containers(item_ids, versions)
|
||||
|
|
|
|||
|
|
@ -448,6 +448,17 @@ DEFAULT_TOOLS_VALUES = {
|
|||
"task_types": [],
|
||||
"tasks": [],
|
||||
"template": "SK_{folder[name]}{variant}"
|
||||
},
|
||||
{
|
||||
"product_types": [
|
||||
"hda"
|
||||
],
|
||||
"hosts": [
|
||||
"houdini"
|
||||
],
|
||||
"task_types": [],
|
||||
"tasks": [],
|
||||
"template": "{folder[name]}_{variant}"
|
||||
}
|
||||
],
|
||||
"filter_creator_profiles": []
|
||||
|
|
|
|||
|
|
@ -92,7 +92,7 @@ class AEPlaceholderPlugin(PlaceholderPlugin):
|
|||
return None, None
|
||||
|
||||
def _collect_scene_placeholders(self):
|
||||
"""" Cache placeholder data to shared data.
|
||||
"""Cache placeholder data to shared data.
|
||||
Returns:
|
||||
(list) of dicts
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -83,7 +83,7 @@ class ExtractThumbnail(plugin.BlenderExtractor):
|
|||
instance.data["representations"].append(representation)
|
||||
|
||||
def _fix_output_path(self, filepath):
|
||||
""""Workaround to return correct filepath.
|
||||
"""Workaround to return correct filepath.
|
||||
|
||||
To workaround this we just glob.glob() for any file extensions and
|
||||
assume the latest modified file is the correct file and return it.
|
||||
|
|
|
|||
|
|
@ -4,10 +4,6 @@ from .pipeline import (
|
|||
containerise
|
||||
)
|
||||
|
||||
from .plugin import (
|
||||
Creator,
|
||||
)
|
||||
|
||||
from .lib import (
|
||||
lsattr,
|
||||
lsattrs,
|
||||
|
|
@ -23,8 +19,6 @@ __all__ = [
|
|||
"ls",
|
||||
"containerise",
|
||||
|
||||
"Creator",
|
||||
|
||||
# Utility functions
|
||||
"lsattr",
|
||||
"lsattrs",
|
||||
|
|
|
|||
|
|
@ -148,89 +148,6 @@ def validate_fps():
|
|||
return True
|
||||
|
||||
|
||||
def create_remote_publish_node(force=True):
|
||||
"""Function to create a remote publish node in /out
|
||||
|
||||
This is a hacked "Shell" node that does *nothing* except for triggering
|
||||
`colorbleed.lib.publish_remote()` as pre-render script.
|
||||
|
||||
All default attributes of the Shell node are hidden to the Artist to
|
||||
avoid confusion.
|
||||
|
||||
Additionally some custom attributes are added that can be collected
|
||||
by a Collector to set specific settings for the publish, e.g. whether
|
||||
to separate the jobs per instance or process in one single job.
|
||||
|
||||
"""
|
||||
|
||||
cmd = "import colorbleed.lib; colorbleed.lib.publish_remote()"
|
||||
|
||||
existing = hou.node("/out/REMOTE_PUBLISH")
|
||||
if existing:
|
||||
if force:
|
||||
log.warning("Removing existing '/out/REMOTE_PUBLISH' node..")
|
||||
existing.destroy()
|
||||
else:
|
||||
raise RuntimeError("Node already exists /out/REMOTE_PUBLISH. "
|
||||
"Please remove manually or set `force` to "
|
||||
"True.")
|
||||
|
||||
# Create the shell node
|
||||
out = hou.node("/out")
|
||||
node = out.createNode("shell", node_name="REMOTE_PUBLISH")
|
||||
node.moveToGoodPosition()
|
||||
|
||||
# Set color make it stand out (avalon/pyblish color)
|
||||
node.setColor(hou.Color(0.439, 0.709, 0.933))
|
||||
|
||||
# Set the pre-render script
|
||||
node.setParms({
|
||||
"prerender": cmd,
|
||||
"lprerender": "python" # command language
|
||||
})
|
||||
|
||||
# Lock the attributes to ensure artists won't easily mess things up.
|
||||
node.parm("prerender").lock(True)
|
||||
node.parm("lprerender").lock(True)
|
||||
|
||||
# Lock up the actual shell command
|
||||
command_parm = node.parm("command")
|
||||
command_parm.set("")
|
||||
command_parm.lock(True)
|
||||
shellexec_parm = node.parm("shellexec")
|
||||
shellexec_parm.set(False)
|
||||
shellexec_parm.lock(True)
|
||||
|
||||
# Get the node's parm template group so we can customize it
|
||||
template = node.parmTemplateGroup()
|
||||
|
||||
# Hide default tabs
|
||||
template.hideFolder("Shell", True)
|
||||
template.hideFolder("Scripts", True)
|
||||
|
||||
# Hide default settings
|
||||
template.hide("execute", True)
|
||||
template.hide("renderdialog", True)
|
||||
template.hide("trange", True)
|
||||
template.hide("f", True)
|
||||
template.hide("take", True)
|
||||
|
||||
# Add custom settings to this node.
|
||||
parm_folder = hou.FolderParmTemplate("folder", "Submission Settings")
|
||||
|
||||
# Separate Jobs per Instance
|
||||
parm = hou.ToggleParmTemplate(name="separateJobPerInstance",
|
||||
label="Separate Job per Instance",
|
||||
default_value=False)
|
||||
parm_folder.addParmTemplate(parm)
|
||||
|
||||
# Add our custom Submission Settings folder
|
||||
template.append(parm_folder)
|
||||
|
||||
# Apply template back to the node
|
||||
node.setParmTemplateGroup(template)
|
||||
|
||||
|
||||
def render_rop(ropnode):
|
||||
"""Render ROP node utility for Publishing.
|
||||
|
||||
|
|
|
|||
|
|
@ -221,12 +221,8 @@ def containerise(name,
|
|||
|
||||
"""
|
||||
|
||||
# Ensure AVALON_CONTAINERS subnet exists
|
||||
subnet = hou.node(AVALON_CONTAINERS)
|
||||
if subnet is None:
|
||||
obj_network = hou.node("/obj")
|
||||
subnet = obj_network.createNode("subnet",
|
||||
node_name="AVALON_CONTAINERS")
|
||||
# Get AVALON_CONTAINERS subnet
|
||||
subnet = get_or_create_avalon_container()
|
||||
|
||||
# Create proper container name
|
||||
container_name = "{}_{}".format(name, suffix or "CON")
|
||||
|
|
@ -401,6 +397,18 @@ def on_new():
|
|||
_enforce_start_frame()
|
||||
|
||||
|
||||
def get_or_create_avalon_container() -> "hou.OpNode":
|
||||
avalon_container = hou.node(AVALON_CONTAINERS)
|
||||
if avalon_container:
|
||||
return avalon_container
|
||||
|
||||
parent_path, name = AVALON_CONTAINERS.rsplit("/", 1)
|
||||
parent = hou.node(parent_path)
|
||||
return parent.createNode(
|
||||
"subnet", node_name=name
|
||||
)
|
||||
|
||||
|
||||
def _set_context_settings():
|
||||
"""Apply the project settings from the project definition
|
||||
|
||||
|
|
|
|||
|
|
@ -10,8 +10,7 @@ import hou
|
|||
import pyblish.api
|
||||
from ayon_core.pipeline import (
|
||||
CreatorError,
|
||||
LegacyCreator,
|
||||
Creator as NewCreator,
|
||||
Creator,
|
||||
CreatedInstance,
|
||||
AYON_INSTANCE_ID,
|
||||
AVALON_INSTANCE_ID,
|
||||
|
|
@ -26,80 +25,6 @@ from .lib import imprint, read, lsattr, add_self_publish_button
|
|||
SETTINGS_CATEGORY = "houdini"
|
||||
|
||||
|
||||
class Creator(LegacyCreator):
|
||||
"""Creator plugin to create instances in Houdini
|
||||
|
||||
To support the wide range of node types for render output (Alembic, VDB,
|
||||
Mantra) the Creator needs a node type to create the correct instance
|
||||
|
||||
By default, if none is given, is `geometry`. An example of accepted node
|
||||
types: geometry, alembic, ifd (mantra)
|
||||
|
||||
Please check the Houdini documentation for more node types.
|
||||
|
||||
Tip: to find the exact node type to create press the `i` left of the node
|
||||
when hovering over a node. The information is visible under the name of
|
||||
the node.
|
||||
|
||||
Deprecated:
|
||||
This creator is deprecated and will be removed in future version.
|
||||
|
||||
"""
|
||||
defaults = ['Main']
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super(Creator, self).__init__(*args, **kwargs)
|
||||
self.nodes = []
|
||||
|
||||
def process(self):
|
||||
"""This is the base functionality to create instances in Houdini
|
||||
|
||||
The selected nodes are stored in self to be used in an override method.
|
||||
This is currently necessary in order to support the multiple output
|
||||
types in Houdini which can only be rendered through their own node.
|
||||
|
||||
Default node type if none is given is `geometry`
|
||||
|
||||
It also makes it easier to apply custom settings per instance type
|
||||
|
||||
Example of override method for Alembic:
|
||||
|
||||
def process(self):
|
||||
instance = super(CreateEpicNode, self, process()
|
||||
# Set parameters for Alembic node
|
||||
instance.setParms(
|
||||
{"sop_path": "$HIP/%s.abc" % self.nodes[0]}
|
||||
)
|
||||
|
||||
Returns:
|
||||
hou.Node
|
||||
|
||||
"""
|
||||
try:
|
||||
if (self.options or {}).get("useSelection"):
|
||||
self.nodes = hou.selectedNodes()
|
||||
|
||||
# Get the node type and remove it from the data, not needed
|
||||
node_type = self.data.pop("node_type", None)
|
||||
if node_type is None:
|
||||
node_type = "geometry"
|
||||
|
||||
# Get out node
|
||||
out = hou.node("/out")
|
||||
instance = out.createNode(node_type, node_name=self.name)
|
||||
instance.moveToGoodPosition()
|
||||
|
||||
imprint(instance, self.data)
|
||||
|
||||
self._process(instance)
|
||||
|
||||
except hou.Error as er:
|
||||
six.reraise(
|
||||
CreatorError,
|
||||
CreatorError("Creator error: {}".format(er)),
|
||||
sys.exc_info()[2])
|
||||
|
||||
|
||||
class HoudiniCreatorBase(object):
|
||||
@staticmethod
|
||||
def cache_instance_data(shared_data):
|
||||
|
|
@ -148,7 +73,11 @@ class HoudiniCreatorBase(object):
|
|||
|
||||
@staticmethod
|
||||
def create_instance_node(
|
||||
folder_path, node_name, parent, node_type="geometry"
|
||||
folder_path,
|
||||
node_name,
|
||||
parent,
|
||||
node_type="geometry",
|
||||
pre_create_data=None
|
||||
):
|
||||
"""Create node representing instance.
|
||||
|
||||
|
|
@ -157,6 +86,7 @@ class HoudiniCreatorBase(object):
|
|||
node_name (str): Name of the new node.
|
||||
parent (str): Name of the parent node.
|
||||
node_type (str, optional): Type of the node.
|
||||
pre_create_data (Optional[Dict]): Pre create data.
|
||||
|
||||
Returns:
|
||||
hou.Node: Newly created instance node.
|
||||
|
|
@ -170,7 +100,7 @@ class HoudiniCreatorBase(object):
|
|||
|
||||
|
||||
@six.add_metaclass(ABCMeta)
|
||||
class HoudiniCreator(NewCreator, HoudiniCreatorBase):
|
||||
class HoudiniCreator(Creator, HoudiniCreatorBase):
|
||||
"""Base class for most of the Houdini creator plugins."""
|
||||
selected_nodes = []
|
||||
settings_name = None
|
||||
|
|
@ -193,7 +123,12 @@ class HoudiniCreator(NewCreator, HoudiniCreatorBase):
|
|||
folder_path = instance_data["folderPath"]
|
||||
|
||||
instance_node = self.create_instance_node(
|
||||
folder_path, product_name, "/out", node_type)
|
||||
folder_path,
|
||||
product_name,
|
||||
"/out",
|
||||
node_type,
|
||||
pre_create_data
|
||||
)
|
||||
|
||||
self.customize_node_look(instance_node)
|
||||
|
||||
|
|
|
|||
|
|
@ -1,13 +1,19 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
"""Creator plugin for creating publishable Houdini Digital Assets."""
|
||||
import ayon_api
|
||||
import hou
|
||||
from assettools import setToolSubmenu
|
||||
|
||||
import ayon_api
|
||||
from ayon_core.pipeline import (
|
||||
CreatorError,
|
||||
get_current_project_name
|
||||
)
|
||||
from ayon_core.lib import (
|
||||
get_ayon_username,
|
||||
BoolDef
|
||||
)
|
||||
|
||||
from ayon_houdini.api import plugin
|
||||
import hou
|
||||
|
||||
|
||||
class CreateHDA(plugin.HoudiniCreator):
|
||||
|
|
@ -37,19 +43,38 @@ class CreateHDA(plugin.HoudiniCreator):
|
|||
return product_name.lower() in existing_product_names_low
|
||||
|
||||
def create_instance_node(
|
||||
self, folder_path, node_name, parent, node_type="geometry"
|
||||
self,
|
||||
folder_path,
|
||||
node_name,
|
||||
parent,
|
||||
node_type="geometry",
|
||||
pre_create_data=None
|
||||
):
|
||||
if pre_create_data is None:
|
||||
pre_create_data = {}
|
||||
|
||||
parent_node = hou.node("/obj")
|
||||
if self.selected_nodes:
|
||||
# if we have `use selection` enabled, and we have some
|
||||
# selected nodes ...
|
||||
subnet = parent_node.collapseIntoSubnet(
|
||||
self.selected_nodes,
|
||||
subnet_name="{}_subnet".format(node_name))
|
||||
subnet.moveToGoodPosition()
|
||||
to_hda = subnet
|
||||
if self.selected_nodes[0].type().name() == "subnet":
|
||||
to_hda = self.selected_nodes[0]
|
||||
to_hda.setName("{}_subnet".format(node_name), unique_name=True)
|
||||
else:
|
||||
parent_node = self.selected_nodes[0].parent()
|
||||
subnet = parent_node.collapseIntoSubnet(
|
||||
self.selected_nodes,
|
||||
subnet_name="{}_subnet".format(node_name))
|
||||
subnet.moveToGoodPosition()
|
||||
to_hda = subnet
|
||||
else:
|
||||
# Use Obj as the default path
|
||||
parent_node = hou.node("/obj")
|
||||
# Find and return the NetworkEditor pane tab with the minimum index
|
||||
pane = hou.ui.paneTabOfType(hou.paneTabType.NetworkEditor)
|
||||
if isinstance(pane, hou.NetworkEditor):
|
||||
# Use the NetworkEditor pane path as the parent path.
|
||||
parent_node = pane.pwd()
|
||||
|
||||
to_hda = parent_node.createNode(
|
||||
"subnet", node_name="{}_subnet".format(node_name))
|
||||
if not to_hda.type().definition():
|
||||
|
|
@ -71,7 +96,8 @@ class CreateHDA(plugin.HoudiniCreator):
|
|||
hda_node = to_hda.createDigitalAsset(
|
||||
name=type_name,
|
||||
description=node_name,
|
||||
hda_file_name="$HIP/{}.hda".format(node_name)
|
||||
hda_file_name="$HIP/{}.hda".format(node_name),
|
||||
ignore_external_references=True
|
||||
)
|
||||
hda_node.layoutChildren()
|
||||
elif self._check_existing(folder_path, node_name):
|
||||
|
|
@ -81,21 +107,92 @@ class CreateHDA(plugin.HoudiniCreator):
|
|||
else:
|
||||
hda_node = to_hda
|
||||
|
||||
hda_node.setName(node_name)
|
||||
# If user tries to create the same HDA instance more than
|
||||
# once, then all of them will have the same product name and
|
||||
# point to the same hda_file_name. But, their node names will
|
||||
# be incremented.
|
||||
hda_node.setName(node_name, unique_name=True)
|
||||
self.customize_node_look(hda_node)
|
||||
|
||||
# Set Custom settings.
|
||||
hda_def = hda_node.type().definition()
|
||||
|
||||
if pre_create_data.get("set_user"):
|
||||
hda_def.setUserInfo(get_ayon_username())
|
||||
|
||||
if pre_create_data.get("use_project"):
|
||||
setToolSubmenu(hda_def, "AYON/{}".format(self.project_name))
|
||||
|
||||
return hda_node
|
||||
|
||||
def create(self, product_name, instance_data, pre_create_data):
|
||||
instance_data.pop("active", None)
|
||||
|
||||
instance = super(CreateHDA, self).create(
|
||||
return super(CreateHDA, self).create(
|
||||
product_name,
|
||||
instance_data,
|
||||
pre_create_data)
|
||||
|
||||
return instance
|
||||
|
||||
def get_network_categories(self):
|
||||
# Houdini allows creating sub-network nodes inside
|
||||
# these categories.
|
||||
# Therefore this plugin can work in these categories.
|
||||
return [
|
||||
hou.objNodeTypeCategory()
|
||||
hou.chopNodeTypeCategory(),
|
||||
hou.cop2NodeTypeCategory(),
|
||||
hou.dopNodeTypeCategory(),
|
||||
hou.ropNodeTypeCategory(),
|
||||
hou.lopNodeTypeCategory(),
|
||||
hou.objNodeTypeCategory(),
|
||||
hou.sopNodeTypeCategory(),
|
||||
hou.topNodeTypeCategory(),
|
||||
hou.vopNodeTypeCategory()
|
||||
]
|
||||
|
||||
def get_pre_create_attr_defs(self):
|
||||
attrs = super(CreateHDA, self).get_pre_create_attr_defs()
|
||||
return attrs + [
|
||||
BoolDef("set_user",
|
||||
tooltip="Set current user as the author of the HDA",
|
||||
default=False,
|
||||
label="Set Current User"),
|
||||
BoolDef("use_project",
|
||||
tooltip="Use project name as tab submenu path.\n"
|
||||
"The location in TAB Menu will be\n"
|
||||
"'AYON/project_name/your_HDA_name'",
|
||||
default=True,
|
||||
label="Use Project as menu entry"),
|
||||
]
|
||||
|
||||
def get_dynamic_data(
|
||||
self,
|
||||
project_name,
|
||||
folder_entity,
|
||||
task_entity,
|
||||
variant,
|
||||
host_name,
|
||||
instance
|
||||
):
|
||||
"""
|
||||
Pass product name from product name templates as dynamic data.
|
||||
"""
|
||||
dynamic_data = super(CreateHDA, self).get_dynamic_data(
|
||||
project_name,
|
||||
folder_entity,
|
||||
task_entity,
|
||||
variant,
|
||||
host_name,
|
||||
instance
|
||||
)
|
||||
|
||||
dynamic_data.update(
|
||||
{
|
||||
"asset": folder_entity["name"],
|
||||
"folder": {
|
||||
"label": folder_entity["label"],
|
||||
"name": folder_entity["name"]
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
return dynamic_data
|
||||
|
|
|
|||
|
|
@ -1,8 +1,13 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
import os
|
||||
from ayon_core.pipeline import get_representation_path
|
||||
import hou
|
||||
from ayon_core.pipeline import (
|
||||
get_representation_path,
|
||||
AVALON_CONTAINER_ID
|
||||
)
|
||||
from ayon_core.pipeline.load import LoadError
|
||||
from ayon_houdini.api import (
|
||||
lib,
|
||||
pipeline,
|
||||
plugin
|
||||
)
|
||||
|
|
@ -19,42 +24,43 @@ class HdaLoader(plugin.HoudiniLoader):
|
|||
color = "orange"
|
||||
|
||||
def load(self, context, name=None, namespace=None, data=None):
|
||||
import hou
|
||||
|
||||
# Format file name, Houdini only wants forward slashes
|
||||
file_path = self.filepath_from_context(context)
|
||||
file_path = os.path.normpath(file_path)
|
||||
file_path = file_path.replace("\\", "/")
|
||||
|
||||
# Get the root node
|
||||
obj = hou.node("/obj")
|
||||
|
||||
namespace = namespace or context["folder"]["name"]
|
||||
node_name = "{}_{}".format(namespace, name) if namespace else name
|
||||
|
||||
hou.hda.installFile(file_path)
|
||||
|
||||
# Get the type name from the HDA definition.
|
||||
hda_defs = hou.hda.definitionsInFile(file_path)
|
||||
if not hda_defs:
|
||||
raise LoadError(f"No HDA definitions found in file: {file_path}")
|
||||
|
||||
type_name = hda_defs[0].nodeTypeName()
|
||||
hda_node = obj.createNode(type_name, node_name)
|
||||
parent_node = self._create_dedicated_parent_node(hda_defs[-1])
|
||||
|
||||
self[:] = [hda_node]
|
||||
# Get the type name from the HDA definition.
|
||||
type_name = hda_defs[-1].nodeTypeName()
|
||||
hda_node = parent_node.createNode(type_name, node_name)
|
||||
hda_node.moveToGoodPosition()
|
||||
|
||||
return pipeline.containerise(
|
||||
node_name,
|
||||
namespace,
|
||||
[hda_node],
|
||||
context,
|
||||
self.__class__.__name__,
|
||||
suffix="",
|
||||
)
|
||||
# Imprint it manually
|
||||
data = {
|
||||
"schema": "openpype:container-2.0",
|
||||
"id": AVALON_CONTAINER_ID,
|
||||
"name": node_name,
|
||||
"namespace": namespace,
|
||||
"loader": self.__class__.__name__,
|
||||
"representation": context["representation"]["id"],
|
||||
}
|
||||
|
||||
lib.imprint(hda_node, data)
|
||||
|
||||
return hda_node
|
||||
|
||||
def update(self, container, context):
|
||||
import hou
|
||||
|
||||
repre_entity = context["representation"]
|
||||
hda_node = container["node"]
|
||||
|
|
@ -71,4 +77,45 @@ class HdaLoader(plugin.HoudiniLoader):
|
|||
|
||||
def remove(self, container):
|
||||
node = container["node"]
|
||||
parent = node.parent()
|
||||
node.destroy()
|
||||
|
||||
if parent.path() == pipeline.AVALON_CONTAINERS:
|
||||
return
|
||||
|
||||
# Remove parent if empty.
|
||||
if not parent.children():
|
||||
parent.destroy()
|
||||
|
||||
def _create_dedicated_parent_node(self, hda_def):
|
||||
|
||||
# Get the root node
|
||||
parent_node = pipeline.get_or_create_avalon_container()
|
||||
node = None
|
||||
node_type = None
|
||||
if hda_def.nodeTypeCategory() == hou.objNodeTypeCategory():
|
||||
return parent_node
|
||||
elif hda_def.nodeTypeCategory() == hou.chopNodeTypeCategory():
|
||||
node_type, node_name = "chopnet", "MOTION"
|
||||
elif hda_def.nodeTypeCategory() == hou.cop2NodeTypeCategory():
|
||||
node_type, node_name = "cop2net", "IMAGES"
|
||||
elif hda_def.nodeTypeCategory() == hou.dopNodeTypeCategory():
|
||||
node_type, node_name = "dopnet", "DOPS"
|
||||
elif hda_def.nodeTypeCategory() == hou.ropNodeTypeCategory():
|
||||
node_type, node_name = "ropnet", "ROPS"
|
||||
elif hda_def.nodeTypeCategory() == hou.lopNodeTypeCategory():
|
||||
node_type, node_name = "lopnet", "LOPS"
|
||||
elif hda_def.nodeTypeCategory() == hou.sopNodeTypeCategory():
|
||||
node_type, node_name = "geo", "SOPS"
|
||||
elif hda_def.nodeTypeCategory() == hou.topNodeTypeCategory():
|
||||
node_type, node_name = "topnet", "TOPS"
|
||||
# TODO: Create a dedicated parent node based on Vop Node vex context.
|
||||
elif hda_def.nodeTypeCategory() == hou.vopNodeTypeCategory():
|
||||
node_type, node_name = "matnet", "MATSandVOPS"
|
||||
|
||||
node = parent_node.node(node_name)
|
||||
if not node:
|
||||
node = parent_node.createNode(node_type, node_name)
|
||||
|
||||
node.moveToGoodPosition()
|
||||
return node
|
||||
|
|
|
|||
|
|
@ -1,29 +0,0 @@
|
|||
import hou
|
||||
import pyblish.api
|
||||
|
||||
from ayon_core.pipeline.publish import RepairAction
|
||||
from ayon_houdini.api import lib, plugin
|
||||
|
||||
|
||||
class CollectRemotePublishSettings(plugin.HoudiniContextPlugin):
|
||||
"""Collect custom settings of the Remote Publish node."""
|
||||
|
||||
order = pyblish.api.CollectorOrder
|
||||
families = ["*"]
|
||||
targets = ["deadline"]
|
||||
label = "Remote Publish Submission Settings"
|
||||
actions = [RepairAction]
|
||||
|
||||
def process(self, context):
|
||||
|
||||
node = hou.node("/out/REMOTE_PUBLISH")
|
||||
if not node:
|
||||
return
|
||||
|
||||
attributes = lib.read(node)
|
||||
|
||||
# Debug the settings we have collected
|
||||
for key, value in sorted(attributes.items()):
|
||||
self.log.debug("Collected %s: %s" % (key, value))
|
||||
|
||||
context.data.update(attributes)
|
||||
|
|
@ -1,50 +0,0 @@
|
|||
# -*-coding: utf-8 -*-
|
||||
import hou
|
||||
|
||||
import pyblish.api
|
||||
from ayon_core.pipeline.publish import RepairContextAction
|
||||
from ayon_core.pipeline import PublishValidationError
|
||||
|
||||
from ayon_houdini.api import lib, plugin
|
||||
|
||||
|
||||
class ValidateRemotePublishOutNode(plugin.HoudiniContextPlugin):
|
||||
"""Validate the remote publish out node exists for Deadline to trigger."""
|
||||
|
||||
order = pyblish.api.ValidatorOrder - 0.4
|
||||
families = ["*"]
|
||||
targets = ["deadline"]
|
||||
label = "Remote Publish ROP node"
|
||||
actions = [RepairContextAction]
|
||||
|
||||
def process(self, context):
|
||||
|
||||
cmd = "import colorbleed.lib; colorbleed.lib.publish_remote()"
|
||||
|
||||
node = hou.node("/out/REMOTE_PUBLISH")
|
||||
if not node:
|
||||
raise RuntimeError("Missing REMOTE_PUBLISH node.")
|
||||
|
||||
# We ensure it's a shell node and that it has the pre-render script
|
||||
# set correctly. Plus the shell script it will trigger should be
|
||||
# completely empty (doing nothing)
|
||||
if node.type().name() != "shell":
|
||||
self.raise_error("Must be shell ROP node")
|
||||
if node.parm("command").eval() != "":
|
||||
self.raise_error("Must have no command")
|
||||
if node.parm("shellexec").eval():
|
||||
self.raise_error("Must not execute in shell")
|
||||
if node.parm("prerender").eval() != cmd:
|
||||
self.raise_error("REMOTE_PUBLISH node does not have "
|
||||
"correct prerender script.")
|
||||
if node.parm("lprerender").eval() != "python":
|
||||
self.raise_error("REMOTE_PUBLISH node prerender script "
|
||||
"type not set to 'python'")
|
||||
|
||||
@classmethod
|
||||
def repair(cls, context):
|
||||
"""(Re)create the node if it fails to pass validation."""
|
||||
lib.create_remote_publish_node(force=True)
|
||||
|
||||
def raise_error(self, message):
|
||||
raise PublishValidationError(message)
|
||||
|
|
@ -1,41 +0,0 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
import hou
|
||||
|
||||
import pyblish.api
|
||||
from ayon_core.pipeline.publish import RepairContextAction
|
||||
from ayon_core.pipeline import PublishValidationError
|
||||
|
||||
from ayon_houdini.api import plugin
|
||||
|
||||
|
||||
class ValidateRemotePublishEnabled(plugin.HoudiniContextPlugin):
|
||||
"""Validate the remote publish node is *not* bypassed."""
|
||||
|
||||
order = pyblish.api.ValidatorOrder - 0.39
|
||||
families = ["*"]
|
||||
targets = ["deadline"]
|
||||
label = "Remote Publish ROP enabled"
|
||||
actions = [RepairContextAction]
|
||||
|
||||
def process(self, context):
|
||||
|
||||
node = hou.node("/out/REMOTE_PUBLISH")
|
||||
if not node:
|
||||
raise PublishValidationError(
|
||||
"Missing REMOTE_PUBLISH node.", title=self.label)
|
||||
|
||||
if node.isBypassed():
|
||||
raise PublishValidationError(
|
||||
"REMOTE_PUBLISH must not be bypassed.", title=self.label)
|
||||
|
||||
@classmethod
|
||||
def repair(cls, context):
|
||||
"""(Re)create the node if it fails to pass validation."""
|
||||
|
||||
node = hou.node("/out/REMOTE_PUBLISH")
|
||||
if not node:
|
||||
raise PublishValidationError(
|
||||
"Missing REMOTE_PUBLISH node.", title=cls.label)
|
||||
|
||||
cls.log.info("Disabling bypass on /out/REMOTE_PUBLISH")
|
||||
node.bypass(False)
|
||||
|
|
@ -10,10 +10,9 @@ from ayon_core.pipeline.publish import (
|
|||
ValidateContentsOrder,
|
||||
RepairAction,
|
||||
)
|
||||
|
||||
from ayon_core.pipeline.create import get_product_name
|
||||
from ayon_houdini.api import plugin
|
||||
from ayon_houdini.api.action import SelectInvalidAction
|
||||
from ayon_core.pipeline.create import get_product_name
|
||||
|
||||
|
||||
class FixProductNameAction(RepairAction):
|
||||
|
|
@ -26,7 +25,7 @@ class ValidateSubsetName(plugin.HoudiniInstancePlugin,
|
|||
|
||||
"""
|
||||
|
||||
families = ["staticMesh"]
|
||||
families = ["staticMesh", "hda"]
|
||||
label = "Validate Product Name"
|
||||
order = ValidateContentsOrder + 0.1
|
||||
actions = [FixProductNameAction, SelectInvalidAction]
|
||||
|
|
@ -67,7 +66,13 @@ class ValidateSubsetName(plugin.HoudiniInstancePlugin,
|
|||
instance.context.data["hostName"],
|
||||
instance.data["productType"],
|
||||
variant=instance.data["variant"],
|
||||
dynamic_data={"asset": folder_entity["name"]}
|
||||
dynamic_data={
|
||||
"asset": folder_entity["name"],
|
||||
"folder": {
|
||||
"label": folder_entity["label"],
|
||||
"name": folder_entity["name"]
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
if instance.data.get("productName") != product_name:
|
||||
|
|
@ -97,7 +102,13 @@ class ValidateSubsetName(plugin.HoudiniInstancePlugin,
|
|||
instance.context.data["hostName"],
|
||||
instance.data["productType"],
|
||||
variant=instance.data["variant"],
|
||||
dynamic_data={"asset": folder_entity["name"]}
|
||||
dynamic_data={
|
||||
"asset": folder_entity["name"],
|
||||
"folder": {
|
||||
"label": folder_entity["label"],
|
||||
"name": folder_entity["name"]
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
instance.data["productName"] = product_name
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
"""Package declaring AYON addon 'houdini' version."""
|
||||
__version__ = "0.3.4"
|
||||
__version__ = "0.3.6"
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
name = "houdini"
|
||||
title = "Houdini"
|
||||
version = "0.3.4"
|
||||
version = "0.3.6"
|
||||
|
||||
client_dir = "ayon_houdini"
|
||||
|
||||
|
|
|
|||
|
|
@ -9,11 +9,16 @@ class CreateSetDress(plugin.MayaCreator):
|
|||
label = "Set Dress"
|
||||
product_type = "setdress"
|
||||
icon = "cubes"
|
||||
exactSetMembersOnly = True
|
||||
shader = True
|
||||
default_variants = ["Main", "Anim"]
|
||||
|
||||
def get_instance_attr_defs(self):
|
||||
return [
|
||||
BoolDef("exactSetMembersOnly",
|
||||
label="Exact Set Members Only",
|
||||
default=True)
|
||||
default=self.exactSetMembersOnly),
|
||||
BoolDef("shader",
|
||||
label="Include shader",
|
||||
default=self.shader)
|
||||
]
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
"""Extract data as Maya scene (raw)."""
|
||||
import os
|
||||
|
||||
import contextlib
|
||||
from ayon_core.lib import BoolDef
|
||||
from ayon_core.pipeline import AVALON_CONTAINER_ID, AYON_CONTAINER_ID
|
||||
from ayon_core.pipeline.publish import AYONPyblishPluginMixin
|
||||
from ayon_maya.api.lib import maintained_selection
|
||||
from ayon_maya.api.lib import maintained_selection, shader
|
||||
from ayon_maya.api import plugin
|
||||
from maya import cmds
|
||||
|
||||
|
|
@ -88,17 +88,21 @@ class ExtractMayaSceneRaw(plugin.MayaExtractorPlugin, AYONPyblishPluginMixin):
|
|||
)
|
||||
with maintained_selection():
|
||||
cmds.select(selection, noExpand=True)
|
||||
cmds.file(path,
|
||||
force=True,
|
||||
typ="mayaAscii" if self.scene_type == "ma" else "mayaBinary", # noqa: E501
|
||||
exportSelected=True,
|
||||
preserveReferences=attribute_values[
|
||||
"preserve_references"
|
||||
],
|
||||
constructionHistory=True,
|
||||
shader=True,
|
||||
constraints=True,
|
||||
expressions=True)
|
||||
with contextlib.ExitStack() as stack:
|
||||
if not instance.data.get("shader", True):
|
||||
# Fix bug where export without shader may import the geometry 'green'
|
||||
# due to the lack of any shader on import.
|
||||
stack.enter_context(shader(selection, shadingEngine="initialShadingGroup"))
|
||||
|
||||
cmds.file(path,
|
||||
force=True,
|
||||
typ="mayaAscii" if self.scene_type == "ma" else "mayaBinary",
|
||||
exportSelected=True,
|
||||
preserveReferences=attribute_values["preserve_references"],
|
||||
constructionHistory=True,
|
||||
shader=instance.data.get("shader", True),
|
||||
constraints=True,
|
||||
expressions=True)
|
||||
|
||||
if "representations" not in instance.data:
|
||||
instance.data["representations"] = []
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
"""Package declaring AYON addon 'maya' version."""
|
||||
__version__ = "0.2.6"
|
||||
__version__ = "0.2.7"
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
name = "maya"
|
||||
title = "Maya"
|
||||
version = "0.2.6"
|
||||
version = "0.2.7"
|
||||
client_dir = "ayon_maya"
|
||||
|
||||
ayon_required_addons = {
|
||||
|
|
|
|||
|
|
@ -124,6 +124,14 @@ class CreateVrayProxyModel(BaseSettingsModel):
|
|||
default_factory=list, title="Default Products")
|
||||
|
||||
|
||||
class CreateSetDressModel(BaseSettingsModel):
|
||||
enabled: bool = SettingsField(True)
|
||||
exactSetMembersOnly: bool = SettingsField(title="Exact Set Members Only")
|
||||
shader: bool = SettingsField(title="Include shader")
|
||||
default_variants: list[str] = SettingsField(
|
||||
default_factory=list, title="Default Products")
|
||||
|
||||
|
||||
class CreateMultishotLayout(BasicCreatorModel):
|
||||
shotParent: str = SettingsField(title="Shot Parent Folder")
|
||||
groupLoadedAssets: bool = SettingsField(title="Group Loaded Assets")
|
||||
|
|
@ -217,8 +225,8 @@ class CreatorsModel(BaseSettingsModel):
|
|||
default_factory=BasicCreatorModel,
|
||||
title="Create Rig"
|
||||
)
|
||||
CreateSetDress: BasicCreatorModel = SettingsField(
|
||||
default_factory=BasicCreatorModel,
|
||||
CreateSetDress: CreateSetDressModel = SettingsField(
|
||||
default_factory=CreateSetDressModel,
|
||||
title="Create Set Dress"
|
||||
)
|
||||
CreateVrayProxy: CreateVrayProxyModel = SettingsField(
|
||||
|
|
@ -396,6 +404,8 @@ DEFAULT_CREATORS_SETTINGS = {
|
|||
},
|
||||
"CreateSetDress": {
|
||||
"enabled": True,
|
||||
"exactSetMembersOnly": True,
|
||||
"shader": True,
|
||||
"default_variants": [
|
||||
"Main",
|
||||
"Anim"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue