mirror of
https://github.com/ynput/ayon-core.git
synced 2025-12-24 21:04:40 +01:00
Merge pull request #4168 from pypeclub/feature/3ds-max-support
3dsmax: integration
This commit is contained in:
commit
06d99c0bd9
21 changed files with 927 additions and 0 deletions
10
openpype/hosts/max/__init__.py
Normal file
10
openpype/hosts/max/__init__.py
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
from .addon import (
|
||||
MaxAddon,
|
||||
MAX_HOST_DIR,
|
||||
)
|
||||
|
||||
|
||||
__all__ = (
|
||||
"MaxAddon",
|
||||
"MAX_HOST_DIR",
|
||||
)
|
||||
16
openpype/hosts/max/addon.py
Normal file
16
openpype/hosts/max/addon.py
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
import os
|
||||
from openpype.modules import OpenPypeModule, IHostAddon
|
||||
|
||||
MAX_HOST_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
|
||||
|
||||
class MaxAddon(OpenPypeModule, IHostAddon):
|
||||
name = "max"
|
||||
host_name = "max"
|
||||
|
||||
def initialize(self, module_settings):
|
||||
self.enabled = True
|
||||
|
||||
def get_workfile_extensions(self):
|
||||
return [".max"]
|
||||
20
openpype/hosts/max/api/__init__.py
Normal file
20
openpype/hosts/max/api/__init__.py
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
"""Public API for 3dsmax"""
|
||||
|
||||
from .pipeline import (
|
||||
MaxHost,
|
||||
)
|
||||
|
||||
|
||||
from .lib import (
|
||||
maintained_selection,
|
||||
lsattr,
|
||||
get_all_children
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"MaxHost",
|
||||
"maintained_selection",
|
||||
"lsattr",
|
||||
"get_all_children"
|
||||
]
|
||||
122
openpype/hosts/max/api/lib.py
Normal file
122
openpype/hosts/max/api/lib.py
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
"""Library of functions useful for 3dsmax pipeline."""
|
||||
import json
|
||||
import six
|
||||
from pymxs import runtime as rt
|
||||
from typing import Union
|
||||
import contextlib
|
||||
|
||||
|
||||
JSON_PREFIX = "JSON::"
|
||||
|
||||
|
||||
def imprint(node_name: str, data: dict) -> bool:
|
||||
node = rt.getNodeByName(node_name)
|
||||
if not node:
|
||||
return False
|
||||
|
||||
for k, v in data.items():
|
||||
if isinstance(v, (dict, list)):
|
||||
rt.setUserProp(node, k, f'{JSON_PREFIX}{json.dumps(v)}')
|
||||
else:
|
||||
rt.setUserProp(node, k, v)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def lsattr(
|
||||
attr: str,
|
||||
value: Union[str, None] = None,
|
||||
root: Union[str, None] = None) -> list:
|
||||
"""List nodes having attribute with specified value.
|
||||
|
||||
Args:
|
||||
attr (str): Attribute name to match.
|
||||
value (str, Optional): Value to match, of omitted, all nodes
|
||||
with specified attribute are returned no matter of value.
|
||||
root (str, Optional): Root node name. If omitted, scene root is used.
|
||||
|
||||
Returns:
|
||||
list of nodes.
|
||||
"""
|
||||
root = rt.rootnode if root is None else rt.getNodeByName(root)
|
||||
|
||||
def output_node(node, nodes):
|
||||
nodes.append(node)
|
||||
for child in node.Children:
|
||||
output_node(child, nodes)
|
||||
|
||||
nodes = []
|
||||
output_node(root, nodes)
|
||||
return [
|
||||
n for n in nodes
|
||||
if rt.getUserProp(n, attr) == value
|
||||
] if value else [
|
||||
n for n in nodes
|
||||
if rt.getUserProp(n, attr)
|
||||
]
|
||||
|
||||
|
||||
def read(container) -> dict:
|
||||
data = {}
|
||||
props = rt.getUserPropBuffer(container)
|
||||
# this shouldn't happen but let's guard against it anyway
|
||||
if not props:
|
||||
return data
|
||||
|
||||
for line in props.split("\r\n"):
|
||||
try:
|
||||
key, value = line.split("=")
|
||||
except ValueError:
|
||||
# if the line cannot be split we can't really parse it
|
||||
continue
|
||||
|
||||
value = value.strip()
|
||||
if isinstance(value.strip(), six.string_types) and \
|
||||
value.startswith(JSON_PREFIX):
|
||||
try:
|
||||
value = json.loads(value[len(JSON_PREFIX):])
|
||||
except json.JSONDecodeError:
|
||||
# not a json
|
||||
pass
|
||||
|
||||
data[key.strip()] = value
|
||||
|
||||
data["instance_node"] = container.name
|
||||
|
||||
return data
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def maintained_selection():
|
||||
previous_selection = rt.getCurrentSelection()
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
if previous_selection:
|
||||
rt.select(previous_selection)
|
||||
else:
|
||||
rt.select()
|
||||
|
||||
|
||||
def get_all_children(parent, node_type=None):
|
||||
"""Handy function to get all the children of a given node
|
||||
|
||||
Args:
|
||||
parent (3dsmax Node1): Node to get all children of.
|
||||
node_type (None, runtime.class): give class to check for
|
||||
e.g. rt.FFDBox/rt.GeometryClass etc.
|
||||
|
||||
Returns:
|
||||
list: list of all children of the parent node
|
||||
"""
|
||||
def list_children(node):
|
||||
children = []
|
||||
for c in node.Children:
|
||||
children.append(c)
|
||||
children = children + list_children(c)
|
||||
return children
|
||||
child_list = list_children(parent)
|
||||
|
||||
return ([x for x in child_list if rt.superClassOf(x) == node_type]
|
||||
if node_type else child_list)
|
||||
130
openpype/hosts/max/api/menu.py
Normal file
130
openpype/hosts/max/api/menu.py
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
"""3dsmax menu definition of OpenPype."""
|
||||
from Qt import QtWidgets, QtCore
|
||||
from pymxs import runtime as rt
|
||||
|
||||
from openpype.tools.utils import host_tools
|
||||
|
||||
|
||||
class OpenPypeMenu(object):
|
||||
"""Object representing OpenPype menu.
|
||||
|
||||
This is using "hack" to inject itself before "Help" menu of 3dsmax.
|
||||
For some reason `postLoadingMenus` event doesn't fire, and main menu
|
||||
if probably re-initialized by menu templates, se we wait for at least
|
||||
1 event Qt event loop before trying to insert.
|
||||
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.main_widget = self.get_main_widget()
|
||||
self.menu = None
|
||||
|
||||
timer = QtCore.QTimer()
|
||||
# set number of event loops to wait.
|
||||
timer.setInterval(1)
|
||||
timer.timeout.connect(self._on_timer)
|
||||
timer.start()
|
||||
|
||||
self._timer = timer
|
||||
self._counter = 0
|
||||
|
||||
def _on_timer(self):
|
||||
if self._counter < 1:
|
||||
self._counter += 1
|
||||
return
|
||||
|
||||
self._counter = 0
|
||||
self._timer.stop()
|
||||
self.build_openpype_menu()
|
||||
|
||||
@staticmethod
|
||||
def get_main_widget():
|
||||
"""Get 3dsmax main window."""
|
||||
return QtWidgets.QWidget.find(rt.windows.getMAXHWND())
|
||||
|
||||
def get_main_menubar(self) -> QtWidgets.QMenuBar:
|
||||
"""Get main Menubar by 3dsmax main window."""
|
||||
return list(self.main_widget.findChildren(QtWidgets.QMenuBar))[0]
|
||||
|
||||
def get_or_create_openpype_menu(
|
||||
self, name: str = "&OpenPype",
|
||||
before: str = "&Help") -> QtWidgets.QAction:
|
||||
"""Create OpenPype menu.
|
||||
|
||||
Args:
|
||||
name (str, Optional): OpenPypep menu name.
|
||||
before (str, Optional): Name of the 3dsmax main menu item to
|
||||
add OpenPype menu before.
|
||||
|
||||
Returns:
|
||||
QtWidgets.QAction: OpenPype menu action.
|
||||
|
||||
"""
|
||||
if self.menu is not None:
|
||||
return self.menu
|
||||
|
||||
menu_bar = self.get_main_menubar()
|
||||
menu_items = menu_bar.findChildren(
|
||||
QtWidgets.QMenu, options=QtCore.Qt.FindDirectChildrenOnly)
|
||||
help_action = None
|
||||
for item in menu_items:
|
||||
if name in item.title():
|
||||
# we already have OpenPype menu
|
||||
return item
|
||||
|
||||
if before in item.title():
|
||||
help_action = item.menuAction()
|
||||
|
||||
op_menu = QtWidgets.QMenu("&OpenPype")
|
||||
menu_bar.insertMenu(help_action, op_menu)
|
||||
|
||||
self.menu = op_menu
|
||||
return op_menu
|
||||
|
||||
def build_openpype_menu(self) -> QtWidgets.QAction:
|
||||
"""Build items in OpenPype menu."""
|
||||
openpype_menu = self.get_or_create_openpype_menu()
|
||||
load_action = QtWidgets.QAction("Load...", openpype_menu)
|
||||
load_action.triggered.connect(self.load_callback)
|
||||
openpype_menu.addAction(load_action)
|
||||
|
||||
publish_action = QtWidgets.QAction("Publish...", openpype_menu)
|
||||
publish_action.triggered.connect(self.publish_callback)
|
||||
openpype_menu.addAction(publish_action)
|
||||
|
||||
manage_action = QtWidgets.QAction("Manage...", openpype_menu)
|
||||
manage_action.triggered.connect(self.manage_callback)
|
||||
openpype_menu.addAction(manage_action)
|
||||
|
||||
library_action = QtWidgets.QAction("Library...", openpype_menu)
|
||||
library_action.triggered.connect(self.library_callback)
|
||||
openpype_menu.addAction(library_action)
|
||||
|
||||
openpype_menu.addSeparator()
|
||||
|
||||
workfiles_action = QtWidgets.QAction("Work Files...", openpype_menu)
|
||||
workfiles_action.triggered.connect(self.workfiles_callback)
|
||||
openpype_menu.addAction(workfiles_action)
|
||||
return openpype_menu
|
||||
|
||||
def load_callback(self):
|
||||
"""Callback to show Loader tool."""
|
||||
host_tools.show_loader(parent=self.main_widget)
|
||||
|
||||
def publish_callback(self):
|
||||
"""Callback to show Publisher tool."""
|
||||
host_tools.show_publisher(parent=self.main_widget)
|
||||
|
||||
def manage_callback(self):
|
||||
"""Callback to show Scene Manager/Inventory tool."""
|
||||
host_tools.show_subset_manager(parent=self.main_widget)
|
||||
|
||||
def library_callback(self):
|
||||
"""Callback to show Library Loader tool."""
|
||||
host_tools.show_library_loader(parent=self.main_widget)
|
||||
|
||||
def workfiles_callback(self):
|
||||
"""Callback to show Workfiles tool."""
|
||||
host_tools.show_workfiles(parent=self.main_widget)
|
||||
145
openpype/hosts/max/api/pipeline.py
Normal file
145
openpype/hosts/max/api/pipeline.py
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
"""Pipeline tools for OpenPype Houdini integration."""
|
||||
import os
|
||||
import logging
|
||||
|
||||
import json
|
||||
|
||||
from openpype.host import HostBase, IWorkfileHost, ILoadHost, INewPublisher
|
||||
import pyblish.api
|
||||
from openpype.pipeline import (
|
||||
register_creator_plugin_path,
|
||||
register_loader_plugin_path,
|
||||
AVALON_CONTAINER_ID,
|
||||
)
|
||||
from openpype.hosts.max.api.menu import OpenPypeMenu
|
||||
from openpype.hosts.max.api import lib
|
||||
from openpype.hosts.max import MAX_HOST_DIR
|
||||
|
||||
from pymxs import runtime as rt # noqa
|
||||
|
||||
log = logging.getLogger("openpype.hosts.max")
|
||||
|
||||
PLUGINS_DIR = os.path.join(MAX_HOST_DIR, "plugins")
|
||||
PUBLISH_PATH = os.path.join(PLUGINS_DIR, "publish")
|
||||
LOAD_PATH = os.path.join(PLUGINS_DIR, "load")
|
||||
CREATE_PATH = os.path.join(PLUGINS_DIR, "create")
|
||||
INVENTORY_PATH = os.path.join(PLUGINS_DIR, "inventory")
|
||||
|
||||
|
||||
class MaxHost(HostBase, IWorkfileHost, ILoadHost, INewPublisher):
|
||||
|
||||
name = "max"
|
||||
menu = None
|
||||
|
||||
def __init__(self):
|
||||
super(MaxHost, self).__init__()
|
||||
self._op_events = {}
|
||||
self._has_been_setup = False
|
||||
|
||||
def install(self):
|
||||
pyblish.api.register_host("max")
|
||||
|
||||
pyblish.api.register_plugin_path(PUBLISH_PATH)
|
||||
register_loader_plugin_path(LOAD_PATH)
|
||||
register_creator_plugin_path(CREATE_PATH)
|
||||
|
||||
# self._register_callbacks()
|
||||
self.menu = OpenPypeMenu()
|
||||
|
||||
self._has_been_setup = True
|
||||
|
||||
def has_unsaved_changes(self):
|
||||
# TODO: how to get it from 3dsmax?
|
||||
return True
|
||||
|
||||
def get_workfile_extensions(self):
|
||||
return [".max"]
|
||||
|
||||
def save_workfile(self, dst_path=None):
|
||||
rt.saveMaxFile(dst_path)
|
||||
return dst_path
|
||||
|
||||
def open_workfile(self, filepath):
|
||||
rt.checkForSave()
|
||||
rt.loadMaxFile(filepath)
|
||||
return filepath
|
||||
|
||||
def get_current_workfile(self):
|
||||
return os.path.join(rt.maxFilePath, rt.maxFileName)
|
||||
|
||||
def get_containers(self):
|
||||
return ls()
|
||||
|
||||
def _register_callbacks(self):
|
||||
rt.callbacks.removeScripts(id=rt.name("OpenPypeCallbacks"))
|
||||
|
||||
rt.callbacks.addScript(
|
||||
rt.Name("postLoadingMenus"),
|
||||
self._deferred_menu_creation, id=rt.Name('OpenPypeCallbacks'))
|
||||
|
||||
def _deferred_menu_creation(self):
|
||||
self.log.info("Building menu ...")
|
||||
self.menu = OpenPypeMenu()
|
||||
|
||||
@staticmethod
|
||||
def create_context_node():
|
||||
"""Helper for creating context holding node."""
|
||||
|
||||
root_scene = rt.rootScene
|
||||
|
||||
create_attr_script = ("""
|
||||
attributes "OpenPypeContext"
|
||||
(
|
||||
parameters main rollout:params
|
||||
(
|
||||
context type: #string
|
||||
)
|
||||
|
||||
rollout params "OpenPype Parameters"
|
||||
(
|
||||
editText editTextContext "Context" type: #string
|
||||
)
|
||||
)
|
||||
""")
|
||||
|
||||
attr = rt.execute(create_attr_script)
|
||||
rt.custAttributes.add(root_scene, attr)
|
||||
|
||||
return root_scene.OpenPypeContext.context
|
||||
|
||||
def update_context_data(self, data, changes):
|
||||
try:
|
||||
_ = rt.rootScene.OpenPypeContext.context
|
||||
except AttributeError:
|
||||
# context node doesn't exists
|
||||
self.create_context_node()
|
||||
|
||||
rt.rootScene.OpenPypeContext.context = json.dumps(data)
|
||||
|
||||
def get_context_data(self):
|
||||
try:
|
||||
context = rt.rootScene.OpenPypeContext.context
|
||||
except AttributeError:
|
||||
# context node doesn't exists
|
||||
context = self.create_context_node()
|
||||
if not context:
|
||||
context = "{}"
|
||||
return json.loads(context)
|
||||
|
||||
def save_file(self, dst_path=None):
|
||||
# Force forwards slashes to avoid segfault
|
||||
dst_path = dst_path.replace("\\", "/")
|
||||
rt.saveMaxFile(dst_path)
|
||||
|
||||
|
||||
def ls() -> list:
|
||||
"""Get all OpenPype instances."""
|
||||
objs = rt.objects
|
||||
containers = [
|
||||
obj for obj in objs
|
||||
if rt.getUserProp(obj, "id") == AVALON_CONTAINER_ID
|
||||
]
|
||||
|
||||
for container in sorted(containers, key=lambda name: container.name):
|
||||
yield lib.read(container)
|
||||
111
openpype/hosts/max/api/plugin.py
Normal file
111
openpype/hosts/max/api/plugin.py
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
"""3dsmax specific Avalon/Pyblish plugin definitions."""
|
||||
from pymxs import runtime as rt
|
||||
import six
|
||||
from abc import ABCMeta
|
||||
from openpype.pipeline import (
|
||||
CreatorError,
|
||||
Creator,
|
||||
CreatedInstance
|
||||
)
|
||||
from openpype.lib import BoolDef
|
||||
from .lib import imprint, read, lsattr
|
||||
|
||||
|
||||
class OpenPypeCreatorError(CreatorError):
|
||||
pass
|
||||
|
||||
|
||||
class MaxCreatorBase(object):
|
||||
|
||||
@staticmethod
|
||||
def cache_subsets(shared_data):
|
||||
if shared_data.get("max_cached_subsets") is None:
|
||||
shared_data["max_cached_subsets"] = {}
|
||||
cached_instances = lsattr("id", "pyblish.avalon.instance")
|
||||
for i in cached_instances:
|
||||
creator_id = rt.getUserProp(i, "creator_identifier")
|
||||
if creator_id not in shared_data["max_cached_subsets"]:
|
||||
shared_data["max_cached_subsets"][creator_id] = [i.name]
|
||||
else:
|
||||
shared_data[
|
||||
"max_cached_subsets"][creator_id].append(i.name) # noqa
|
||||
return shared_data
|
||||
|
||||
@staticmethod
|
||||
def create_instance_node(node_name: str, parent: str = ""):
|
||||
parent_node = rt.getNodeByName(parent) if parent else rt.rootScene
|
||||
if not parent_node:
|
||||
raise OpenPypeCreatorError(f"Specified parent {parent} not found")
|
||||
|
||||
container = rt.container(name=node_name)
|
||||
container.Parent = parent_node
|
||||
|
||||
return container
|
||||
|
||||
|
||||
@six.add_metaclass(ABCMeta)
|
||||
class MaxCreator(Creator, MaxCreatorBase):
|
||||
selected_nodes = []
|
||||
|
||||
def create(self, subset_name, instance_data, pre_create_data):
|
||||
if pre_create_data.get("use_selection"):
|
||||
self.selected_nodes = rt.getCurrentSelection()
|
||||
|
||||
instance_node = self.create_instance_node(subset_name)
|
||||
instance_data["instance_node"] = instance_node.name
|
||||
instance = CreatedInstance(
|
||||
self.family,
|
||||
subset_name,
|
||||
instance_data,
|
||||
self
|
||||
)
|
||||
for node in self.selected_nodes:
|
||||
node.Parent = instance_node
|
||||
|
||||
self._add_instance_to_context(instance)
|
||||
imprint(instance_node.name, instance.data_to_store())
|
||||
|
||||
return instance
|
||||
|
||||
def collect_instances(self):
|
||||
self.cache_subsets(self.collection_shared_data)
|
||||
for instance in self.collection_shared_data[
|
||||
"max_cached_subsets"].get(self.identifier, []):
|
||||
created_instance = CreatedInstance.from_existing(
|
||||
read(rt.getNodeByName(instance)), self
|
||||
)
|
||||
self._add_instance_to_context(created_instance)
|
||||
|
||||
def update_instances(self, update_list):
|
||||
for created_inst, _changes in update_list:
|
||||
instance_node = created_inst.get("instance_node")
|
||||
|
||||
new_values = {
|
||||
key: new_value
|
||||
for key, (_old_value, new_value) in _changes.items()
|
||||
}
|
||||
imprint(
|
||||
instance_node,
|
||||
new_values,
|
||||
)
|
||||
|
||||
def remove_instances(self, instances):
|
||||
"""Remove specified instance from the scene.
|
||||
|
||||
This is only removing `id` parameter so instance is no longer
|
||||
instance, because it might contain valuable data for artist.
|
||||
|
||||
"""
|
||||
for instance in instances:
|
||||
instance_node = rt.getNodeByName(
|
||||
instance.data.get("instance_node"))
|
||||
if instance_node:
|
||||
rt.delete(rt.getNodeByName(instance_node))
|
||||
|
||||
self._remove_instance_from_context(instance)
|
||||
|
||||
def get_pre_create_attr_defs(self):
|
||||
return [
|
||||
BoolDef("use_selection", label="Use selection")
|
||||
]
|
||||
17
openpype/hosts/max/hooks/set_paths.py
Normal file
17
openpype/hosts/max/hooks/set_paths.py
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
from openpype.lib import PreLaunchHook
|
||||
|
||||
|
||||
class SetPath(PreLaunchHook):
|
||||
"""Set current dir to workdir.
|
||||
|
||||
Hook `GlobalHostDataHook` must be executed before this hook.
|
||||
"""
|
||||
app_groups = ["max"]
|
||||
|
||||
def execute(self):
|
||||
workdir = self.launch_context.env.get("AVALON_WORKDIR", "")
|
||||
if not workdir:
|
||||
self.log.warning("BUG: Workdir is not filled.")
|
||||
return
|
||||
|
||||
self.launch_context.kwargs["cwd"] = workdir
|
||||
0
openpype/hosts/max/plugins/__init__.py
Normal file
0
openpype/hosts/max/plugins/__init__.py
Normal file
22
openpype/hosts/max/plugins/create/create_pointcache.py
Normal file
22
openpype/hosts/max/plugins/create/create_pointcache.py
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
"""Creator plugin for creating pointcache alembics."""
|
||||
from openpype.hosts.max.api import plugin
|
||||
from openpype.pipeline import CreatedInstance
|
||||
|
||||
|
||||
class CreatePointCache(plugin.MaxCreator):
|
||||
identifier = "io.openpype.creators.max.pointcache"
|
||||
label = "Point Cache"
|
||||
family = "pointcache"
|
||||
icon = "gear"
|
||||
|
||||
def create(self, subset_name, instance_data, pre_create_data):
|
||||
# from pymxs import runtime as rt
|
||||
|
||||
_ = super(CreatePointCache, self).create(
|
||||
subset_name,
|
||||
instance_data,
|
||||
pre_create_data) # type: CreatedInstance
|
||||
|
||||
# for additional work on the node:
|
||||
# instance_node = rt.getNodeByName(instance.get("instance_node"))
|
||||
65
openpype/hosts/max/plugins/load/load_pointcache.py
Normal file
65
openpype/hosts/max/plugins/load/load_pointcache.py
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
"""Simple alembic loader for 3dsmax.
|
||||
|
||||
Because of limited api, alembics can be only loaded, but not easily updated.
|
||||
|
||||
"""
|
||||
import os
|
||||
from openpype.pipeline import (
|
||||
load
|
||||
)
|
||||
|
||||
|
||||
class AbcLoader(load.LoaderPlugin):
|
||||
"""Alembic loader."""
|
||||
|
||||
families = ["model", "animation", "pointcache"]
|
||||
label = "Load Alembic"
|
||||
representations = ["abc"]
|
||||
order = -10
|
||||
icon = "code-fork"
|
||||
color = "orange"
|
||||
|
||||
def load(self, context, name=None, namespace=None, data=None):
|
||||
from pymxs import runtime as rt
|
||||
|
||||
file_path = os.path.normpath(self.fname)
|
||||
|
||||
abc_before = {
|
||||
c for c in rt.rootNode.Children
|
||||
if rt.classOf(c) == rt.AlembicContainer
|
||||
}
|
||||
|
||||
abc_export_cmd = (f"""
|
||||
AlembicImport.ImportToRoot = false
|
||||
|
||||
importFile @"{file_path}" #noPrompt
|
||||
""")
|
||||
|
||||
self.log.debug(f"Executing command: {abc_export_cmd}")
|
||||
rt.execute(abc_export_cmd)
|
||||
|
||||
abc_after = {
|
||||
c for c in rt.rootNode.Children
|
||||
if rt.classOf(c) == rt.AlembicContainer
|
||||
}
|
||||
|
||||
# This should yield new AlembicContainer node
|
||||
abc_containers = abc_after.difference(abc_before)
|
||||
|
||||
if len(abc_containers) != 1:
|
||||
self.log.error("Something failed when loading.")
|
||||
|
||||
abc_container = abc_containers.pop()
|
||||
|
||||
container_name = f"{name}_CON"
|
||||
container = rt.container(name=container_name)
|
||||
abc_container.Parent = container
|
||||
|
||||
return container
|
||||
|
||||
def remove(self, container):
|
||||
from pymxs import runtime as rt
|
||||
|
||||
node = container["node"]
|
||||
rt.delete(node)
|
||||
63
openpype/hosts/max/plugins/publish/collect_workfile.py
Normal file
63
openpype/hosts/max/plugins/publish/collect_workfile.py
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
"""Collect current work file."""
|
||||
import os
|
||||
import pyblish.api
|
||||
|
||||
from pymxs import runtime as rt
|
||||
from openpype.pipeline import legacy_io
|
||||
|
||||
|
||||
class CollectWorkfile(pyblish.api.ContextPlugin):
|
||||
"""Inject the current working file into context"""
|
||||
|
||||
order = pyblish.api.CollectorOrder - 0.01
|
||||
label = "Collect 3dsmax Workfile"
|
||||
hosts = ['max']
|
||||
|
||||
def process(self, context):
|
||||
"""Inject the current working file."""
|
||||
folder = rt.maxFilePath
|
||||
file = rt.maxFileName
|
||||
if not folder or not file:
|
||||
self.log.error("Scene is not saved.")
|
||||
current_file = os.path.join(folder, file)
|
||||
|
||||
context.data['currentFile'] = current_file
|
||||
|
||||
filename, ext = os.path.splitext(file)
|
||||
|
||||
task = legacy_io.Session["AVALON_TASK"]
|
||||
|
||||
data = {}
|
||||
|
||||
# create instance
|
||||
instance = context.create_instance(name=filename)
|
||||
subset = 'workfile' + task.capitalize()
|
||||
|
||||
data.update({
|
||||
"subset": subset,
|
||||
"asset": os.getenv("AVALON_ASSET", None),
|
||||
"label": subset,
|
||||
"publish": True,
|
||||
"family": 'workfile',
|
||||
"families": ['workfile'],
|
||||
"setMembers": [current_file],
|
||||
"frameStart": context.data['frameStart'],
|
||||
"frameEnd": context.data['frameEnd'],
|
||||
"handleStart": context.data['handleStart'],
|
||||
"handleEnd": context.data['handleEnd']
|
||||
})
|
||||
|
||||
data['representations'] = [{
|
||||
'name': ext.lstrip("."),
|
||||
'ext': ext.lstrip("."),
|
||||
'files': file,
|
||||
"stagingDir": folder,
|
||||
}]
|
||||
|
||||
instance.data.update(data)
|
||||
|
||||
self.log.info('Collected instance: {}'.format(file))
|
||||
self.log.info('Scene path: {}'.format(current_file))
|
||||
self.log.info('staging Dir: {}'.format(folder))
|
||||
self.log.info('subset: {}'.format(subset))
|
||||
100
openpype/hosts/max/plugins/publish/extract_pointcache.py
Normal file
100
openpype/hosts/max/plugins/publish/extract_pointcache.py
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Export alembic file.
|
||||
|
||||
Note:
|
||||
Parameters on AlembicExport (AlembicExport.Parameter):
|
||||
|
||||
ParticleAsMesh (bool): Sets whether particle shapes are exported
|
||||
as meshes.
|
||||
AnimTimeRange (enum): How animation is saved:
|
||||
#CurrentFrame: saves current frame
|
||||
#TimeSlider: saves the active time segments on time slider (default)
|
||||
#StartEnd: saves a range specified by the Step
|
||||
StartFrame (int)
|
||||
EnFrame (int)
|
||||
ShapeSuffix (bool): When set to true, appends the string "Shape" to the
|
||||
name of each exported mesh. This property is set to false by default.
|
||||
SamplesPerFrame (int): Sets the number of animation samples per frame.
|
||||
Hidden (bool): When true, export hidden geometry.
|
||||
UVs (bool): When true, export the mesh UV map channel.
|
||||
Normals (bool): When true, export the mesh normals.
|
||||
VertexColors (bool): When true, export the mesh vertex color map 0 and the
|
||||
current vertex color display data when it differs
|
||||
ExtraChannels (bool): When true, export the mesh extra map channels
|
||||
(map channels greater than channel 1)
|
||||
Velocity (bool): When true, export the meh vertex and particle velocity
|
||||
data.
|
||||
MaterialIDs (bool): When true, export the mesh material ID as
|
||||
Alembic face sets.
|
||||
Visibility (bool): When true, export the node visibility data.
|
||||
LayerName (bool): When true, export the node layer name as an Alembic
|
||||
object property.
|
||||
MaterialName (bool): When true, export the geometry node material name as
|
||||
an Alembic object property
|
||||
ObjectID (bool): When true, export the geometry node g-buffer object ID as
|
||||
an Alembic object property.
|
||||
CustomAttributes (bool): When true, export the node and its modifiers
|
||||
custom attributes into an Alembic object compound property.
|
||||
"""
|
||||
import os
|
||||
import pyblish.api
|
||||
from openpype.pipeline import publish
|
||||
from pymxs import runtime as rt
|
||||
from openpype.hosts.max.api import (
|
||||
maintained_selection,
|
||||
get_all_children
|
||||
)
|
||||
|
||||
|
||||
class ExtractAlembic(publish.Extractor):
|
||||
order = pyblish.api.ExtractorOrder
|
||||
label = "Extract Pointcache"
|
||||
hosts = ["max"]
|
||||
families = ["pointcache", "camera"]
|
||||
|
||||
def process(self, instance):
|
||||
start = float(instance.data.get("frameStartHandle", 1))
|
||||
end = float(instance.data.get("frameEndHandle", 1))
|
||||
|
||||
container = instance.data["instance_node"]
|
||||
|
||||
self.log.info("Extracting pointcache ...")
|
||||
|
||||
parent_dir = self.staging_dir(instance)
|
||||
file_name = "{name}.abc".format(**instance.data)
|
||||
path = os.path.join(parent_dir, file_name)
|
||||
|
||||
# We run the render
|
||||
self.log.info("Writing alembic '%s' to '%s'" % (file_name,
|
||||
parent_dir))
|
||||
|
||||
abc_export_cmd = (
|
||||
f"""
|
||||
AlembicExport.ArchiveType = #ogawa
|
||||
AlembicExport.CoordinateSystem = #maya
|
||||
AlembicExport.StartFrame = {start}
|
||||
AlembicExport.EndFrame = {end}
|
||||
|
||||
exportFile @"{path}" #noPrompt selectedOnly:on using:AlembicExport
|
||||
|
||||
""")
|
||||
|
||||
self.log.debug(f"Executing command: {abc_export_cmd}")
|
||||
|
||||
with maintained_selection():
|
||||
# select and export
|
||||
|
||||
rt.select(get_all_children(rt.getNodeByName(container)))
|
||||
rt.execute(abc_export_cmd)
|
||||
|
||||
if "representations" not in instance.data:
|
||||
instance.data["representations"] = []
|
||||
|
||||
representation = {
|
||||
'name': 'abc',
|
||||
'ext': 'abc',
|
||||
'files': file_name,
|
||||
"stagingDir": parent_dir,
|
||||
}
|
||||
instance.data["representations"].append(representation)
|
||||
18
openpype/hosts/max/plugins/publish/validate_scene_saved.py
Normal file
18
openpype/hosts/max/plugins/publish/validate_scene_saved.py
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
import pyblish.api
|
||||
from openpype.pipeline import PublishValidationError
|
||||
from pymxs import runtime as rt
|
||||
|
||||
|
||||
class ValidateSceneSaved(pyblish.api.InstancePlugin):
|
||||
"""Validate that workfile was saved."""
|
||||
|
||||
order = pyblish.api.ValidatorOrder
|
||||
families = ["workfile"]
|
||||
hosts = ["max"]
|
||||
label = "Validate Workfile is saved"
|
||||
|
||||
def process(self, instance):
|
||||
if not rt.maxFilePath or not rt.maxFileName:
|
||||
raise PublishValidationError(
|
||||
"Workfile is not saved", title=self.label)
|
||||
9
openpype/hosts/max/startup/startup.ms
Normal file
9
openpype/hosts/max/startup/startup.ms
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
-- OpenPype Init Script
|
||||
(
|
||||
local sysPath = dotNetClass "System.IO.Path"
|
||||
local sysDir = dotNetClass "System.IO.Directory"
|
||||
local localScript = getThisScriptFilename()
|
||||
local startup = sysPath.Combine (sysPath.GetDirectoryName localScript) "startup.py"
|
||||
|
||||
python.ExecuteFile startup
|
||||
)
|
||||
6
openpype/hosts/max/startup/startup.py
Normal file
6
openpype/hosts/max/startup/startup.py
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
from openpype.hosts.max.api import MaxHost
|
||||
from openpype.pipeline import install_host
|
||||
|
||||
host = MaxHost()
|
||||
install_host(host)
|
||||
BIN
openpype/resources/app_icons/3dsmax.png
Normal file
BIN
openpype/resources/app_icons/3dsmax.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 12 KiB |
|
|
@ -114,6 +114,35 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"3dsmax": {
|
||||
"enabled": true,
|
||||
"label": "3ds max",
|
||||
"icon": "{}/app_icons/3dsmax.png",
|
||||
"host_name": "max",
|
||||
"environment": {
|
||||
"ADSK_3DSMAX_STARTUPSCRIPTS_ADDON_DIR": "{OPENPYPE_ROOT}\\openpype\\hosts\\max\\startup"
|
||||
},
|
||||
"variants": {
|
||||
"2023": {
|
||||
"use_python_2": false,
|
||||
"executables": {
|
||||
"windows": [
|
||||
"C:\\Program Files\\Autodesk\\3ds Max 2023\\3dsmax.exe"
|
||||
],
|
||||
"darwin": [],
|
||||
"linux": []
|
||||
},
|
||||
"arguments": {
|
||||
"windows": [],
|
||||
"darwin": [],
|
||||
"linux": []
|
||||
},
|
||||
"environment": {
|
||||
"3DSMAX_VERSION": "2023"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"flame": {
|
||||
"enabled": true,
|
||||
"label": "Flame",
|
||||
|
|
|
|||
|
|
@ -152,6 +152,7 @@ class HostsEnumEntity(BaseEnumEntity):
|
|||
|
||||
schema_types = ["hosts-enum"]
|
||||
all_host_names = [
|
||||
"max",
|
||||
"aftereffects",
|
||||
"blender",
|
||||
"celaction",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,39 @@
|
|||
{
|
||||
"type": "dict",
|
||||
"key": "3dsmax",
|
||||
"label": "Autodesk 3ds Max",
|
||||
"collapsible": true,
|
||||
"checkbox_key": "enabled",
|
||||
"children": [
|
||||
{
|
||||
"type": "boolean",
|
||||
"key": "enabled",
|
||||
"label": "Enabled"
|
||||
},
|
||||
{
|
||||
"type": "schema_template",
|
||||
"name": "template_host_unchangables"
|
||||
},
|
||||
{
|
||||
"key": "environment",
|
||||
"label": "Environment",
|
||||
"type": "raw-json"
|
||||
},
|
||||
{
|
||||
"type": "dict-modifiable",
|
||||
"key": "variants",
|
||||
"collapsible_key": true,
|
||||
"use_label_wrap": false,
|
||||
"object_type": {
|
||||
"type": "dict",
|
||||
"collapsible": true,
|
||||
"children": [
|
||||
{
|
||||
"type": "schema_template",
|
||||
"name": "template_host_variant_items"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -9,6 +9,10 @@
|
|||
"type": "schema",
|
||||
"name": "schema_maya"
|
||||
},
|
||||
{
|
||||
"type": "schema",
|
||||
"name": "schema_3dsmax"
|
||||
},
|
||||
{
|
||||
"type": "schema",
|
||||
"name": "schema_flame"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue