Merge pull request #4168 from pypeclub/feature/3ds-max-support

3dsmax: integration
This commit is contained in:
Ondřej Samohel 2022-12-07 14:53:11 +01:00 committed by GitHub
commit 06d99c0bd9
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
21 changed files with 927 additions and 0 deletions

View file

@ -0,0 +1,10 @@
from .addon import (
MaxAddon,
MAX_HOST_DIR,
)
__all__ = (
"MaxAddon",
"MAX_HOST_DIR",
)

View 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"]

View 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"
]

View 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)

View 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)

View 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)

View 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")
]

View 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

View file

View 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"))

View 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)

View 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))

View 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)

View 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)

View 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
)

View 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)

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

View file

@ -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",

View file

@ -152,6 +152,7 @@ class HostsEnumEntity(BaseEnumEntity):
schema_types = ["hosts-enum"]
all_host_names = [
"max",
"aftereffects",
"blender",
"celaction",

View file

@ -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"
}
]
}
}
]
}

View file

@ -9,6 +9,10 @@
"type": "schema",
"name": "schema_maya"
},
{
"type": "schema",
"name": "schema_3dsmax"
},
{
"type": "schema",
"name": "schema_flame"