diff --git a/dwpicker/capture.py b/dwpicker/capture.py new file mode 100644 index 0000000..1ff3544 --- /dev/null +++ b/dwpicker/capture.py @@ -0,0 +1,873 @@ +"""Maya Capture + +Playblasting with independent viewport, camera and display options + +Code from https://github.com/abstractfactory/maya-capture +Thanks for this ! +""" + +import re +import sys +import contextlib +from collections import defaultdict + +from maya import cmds +from maya import mel + +try: + from PySide6 import QtGui, QtWidgets +except ImportError: + try: + from PySide2 import QtGui, QtWidgets + except ImportError: + from PySide import QtGui + QtWidgets = QtGui + +version_info = (2, 6, 0) + +__version__ = "%s.%s.%s" % version_info +__license__ = "MIT" + + +def capture(camera=None, + width=None, + height=None, + filename=None, + start_frame=None, + end_frame=None, + frame=None, + format='qt', + compression='H.264', + quality=100, + off_screen=False, + viewer=True, + show_ornaments=True, + sound=None, + isolate=None, + maintain_aspect_ratio=True, + overwrite=False, + frame_padding=4, + raw_frame_numbers=False, + use_camera_sequencer=False, + camera_options=None, + display_options=None, + viewport_options=None, + viewport2_options=None, + complete_filename=None): + """Playblast in an independent panel + + Arguments: + camera (str, optional): Name of camera, defaults to "persp" + width (int, optional): Width of output in pixels + height (int, optional): Height of output in pixels + filename (str, optional): Name of output file. If + none is specified, no files are saved. + start_frame (float, optional): Defaults to current start frame. + end_frame (float, optional): Defaults to current end frame. + frame (float or tuple, optional): A single frame or list of frames. + Use this to capture a single frame or an arbitrary sequence of + frames. + format (str, optional): Name of format, defaults to "qt". + compression (str, optional): Name of compression, defaults to "H.264" + quality (int, optional): The quality of the output, defaults to 100 + off_screen (bool, optional): Whether or not to playblast off screen + viewer (bool, optional): Display results in native player + show_ornaments (bool, optional): Whether or not model view ornaments + (e.g. axis icon, grid and HUD) should be displayed. + sound (str, optional): Specify the sound node to be used during + playblast. When None (default) no sound will be used. + isolate (list): List of nodes to isolate upon capturing + maintain_aspect_ratio (bool, optional): Modify height in order to + maintain aspect ratio. + overwrite (bool, optional): Whether or not to overwrite if file + already exists. If disabled and file exists and error will be + raised. + frame_padding (bool, optional): Number of zeros used to pad file name + for image sequences. + raw_frame_numbers (bool, optional): Whether or not to use the exact + frame numbers from the scene or capture to a sequence starting at + zero. Defaults to False. When set to True `viewer` can't be used + and will be forced to False. + use_camera_sequencer (bool, optional): Whether or not to playblast + using the camera sequencer. Defaults to False. When set to True the + value of `camera` will be ignored and the cameras from the + sequencer will be used instead. Additionally, the `start_frame` and + `end_frame` values will be in sequence time instead of scene frame + numbers. + camera_options (dict, optional): Supplied camera options, + using `CameraOptions` + display_options (dict, optional): Supplied display + options, using `DisplayOptions` + viewport_options (dict, optional): Supplied viewport + options, using `ViewportOptions` + viewport2_options (dict, optional): Supplied display + options, using `Viewport2Options` + complete_filename (str, optional): Exact name of output file. Use this + to override the output of `filename` so it excludes frame padding. + + Example: + >>> # Launch default capture + >>> capture() + >>> # Launch capture with custom viewport settings + >>> capture('persp', 800, 600, + ... viewport_options={ + ... "displayAppearance": "wireframe", + ... "grid": False, + ... "polymeshes": True, + ... }, + ... camera_options={ + ... "displayResolution": True + ... } + ... ) + + + """ + + camera = camera or "persp" + + # Ensure camera exists + if not cmds.objExists(camera): + raise RuntimeError("Camera does not exist: {0}".format(camera)) + + width = width or cmds.getAttr("defaultResolution.width") + height = height or cmds.getAttr("defaultResolution.height") + if maintain_aspect_ratio: + ratio = cmds.getAttr("defaultResolution.deviceAspectRatio") + height = round(width / ratio) + + # Set frame range if no custom frame range specified + if use_camera_sequencer: + # Get frames from the Camera Sequencer + sequencer = cmds.sequenceManager(query=True, writableSequencer=True) + + if start_frame is None: + start_frame = cmds.getAttr(sequencer + ".minFrame") + if end_frame is None: + end_frame = cmds.getAttr(sequencer + ".maxFrame") + else: + # Get frames from the timeline + if start_frame is None: + start_frame = cmds.playbackOptions(minTime=True, query=True) + if end_frame is None: + end_frame = cmds.playbackOptions(maxTime=True, query=True) + + # (#74) Bugfix: `maya.cmds.playblast` will raise an error when playblasting + # with `rawFrameNumbers` set to True but no explicit `frames` provided. + # Since we always know what frames will be included we can provide it + # explicitly + if raw_frame_numbers and frame is None: + frame = range(int(start_frame), int(end_frame) + 1) + + # We need to wrap `completeFilename`, otherwise even when None is provided + # it will use filename as the exact name. Only when lacking as argument + # does it function correctly. + playblast_kwargs = dict() + if complete_filename: + playblast_kwargs['completeFilename'] = complete_filename + if frame is not None: + playblast_kwargs['frame'] = frame + if sound is not None: + playblast_kwargs['sound'] = sound + + # We need to raise an error when the user gives a custom frame range with + # negative frames in combination with raw frame numbers. This will result + # in a minimal integer frame number : filename.-2147483648.png for any + # negative rendered frame + if frame and raw_frame_numbers: + check = frame if isinstance(frame, (list, tuple, range)) else [frame] + if any(f < 0 for f in check): + raise RuntimeError("Negative frames are not supported with " + "raw frame numbers and explicit frame numbers") + + # (#21) Bugfix: `maya.cmds.playblast` suffers from undo bug where it + # always sets the currentTime to frame 1. By setting currentTime before + # the playblast call it'll undo correctly. + cmds.currentTime(cmds.currentTime(query=True)) + + padding = 10 # Extend panel to accommodate for OS window manager + with _independent_panel(width=width + padding, + height=height + padding, + off_screen=off_screen) as panel: + cmds.setFocus(panel) + + with _disabled_inview_messages(),\ + _maintain_camera(panel, camera),\ + _applied_viewport_options(viewport_options, panel),\ + _applied_camera_options(camera_options, panel, use_camera_sequencer),\ + _applied_display_options(display_options),\ + _applied_viewport2_options(viewport2_options),\ + _isolated_nodes(isolate, panel),\ + _maintained_time(): + + output = cmds.playblast( + compression=compression, + format=format, + percent=100, + quality=quality, + viewer=viewer, + startTime=start_frame, + endTime=end_frame, + offScreen=off_screen, + showOrnaments=show_ornaments, + forceOverwrite=overwrite, + filename=filename, + widthHeight=[width, height], + rawFrameNumbers=raw_frame_numbers, + framePadding=frame_padding, + sequenceTime=use_camera_sequencer, + **playblast_kwargs) + + return output + + +def snap(*args, **kwargs): + """Single frame playblast in an independent panel. + + The arguments of `capture` are all valid here as well, except for + `start_frame` and `end_frame`. + + Arguments: + frame (float, optional): The frame to snap. If not provided current + frame is used. + clipboard (bool, optional): Whether to add the output image to the + global clipboard. This allows to easily paste the snapped image + into another application, eg. into Photoshop. + + Keywords: + See `capture`. + + """ + + # capture single frame + frame = kwargs.pop('frame', cmds.currentTime(q=1)) + kwargs['start_frame'] = frame + kwargs['end_frame'] = frame + kwargs['frame'] = frame + + if not isinstance(frame, (int, float)): + raise TypeError("frame must be a single frame (integer or float). " + "Use `capture()` for sequences.") + + # override capture defaults + format = kwargs.pop('format', "image") + compression = kwargs.pop('compression', "png") + viewer = kwargs.pop('viewer', False) + raw_frame_numbers = kwargs.pop('raw_frame_numbers', True) + kwargs['compression'] = compression + kwargs['format'] = format + kwargs['viewer'] = viewer + kwargs['raw_frame_numbers'] = raw_frame_numbers + + # pop snap only keyword arguments + clipboard = kwargs.pop('clipboard', False) + + # perform capture + output = capture(*args, **kwargs) + + def replace(m): + """Substitute # with frame number""" + return str(int(frame)).zfill(len(m.group())) + + output = re.sub("#+", replace, output) + + # add image to clipboard + if clipboard: + _image_to_clipboard(output) + + return output + + +CameraOptions = { + "displayGateMask": False, + "displayResolution": False, + "displayFilmGate": False, + "displayFieldChart": False, + "displaySafeAction": False, + "displaySafeTitle": False, + "displayFilmPivot": False, + "displayFilmOrigin": False, + "overscan": 1.0, + "depthOfField": False, +} + +DisplayOptions = { + "displayGradient": True, + "background": (0.631, 0.631, 0.631), + "backgroundTop": (0.535, 0.617, 0.702), + "backgroundBottom": (0.052, 0.052, 0.052), +} + +# These display options require a different command to be queried and set +_DisplayOptionsRGB = set(["background", "backgroundTop", "backgroundBottom"]) + +ViewportOptions = { + # renderer + "rendererName": "vp2Renderer", + "fogging": False, + "fogMode": "linear", + "fogDensity": 1, + "fogStart": 1, + "fogEnd": 1, + "fogColor": (0, 0, 0, 0), + "shadows": False, + "displayTextures": True, + "displayLights": "default", + "useDefaultMaterial": False, + "wireframeOnShaded": False, + "displayAppearance": 'smoothShaded', + "selectionHiliteDisplay": False, + "headsUpDisplay": True, + # object display + "imagePlane": True, + "nurbsCurves": False, + "nurbsSurfaces": False, + "polymeshes": True, + "subdivSurfaces": False, + "planes": True, + "cameras": False, + "controlVertices": True, + "lights": False, + "grid": False, + "hulls": True, + "joints": False, + "ikHandles": False, + "deformers": False, + "dynamics": False, + "fluids": False, + "hairSystems": False, + "follicles": False, + "nCloths": False, + "nParticles": False, + "nRigids": False, + "dynamicConstraints": False, + "locators": False, + "manipulators": False, + "dimensions": False, + "handles": False, + "pivots": False, + "textures": False, + "strokes": False +} + +Viewport2Options = { + "consolidateWorld": True, + "enableTextureMaxRes": False, + "bumpBakeResolution": 64, + "colorBakeResolution": 64, + "floatingPointRTEnable": True, + "floatingPointRTFormat": 1, + "gammaCorrectionEnable": False, + "gammaValue": 2.2, + "lineAAEnable": False, + "maxHardwareLights": 8, + "motionBlurEnable": False, + "motionBlurSampleCount": 8, + "motionBlurShutterOpenFraction": 0.2, + "motionBlurType": 0, + "multiSampleCount": 8, + "multiSampleEnable": False, + "singleSidedLighting": False, + "ssaoEnable": False, + "ssaoAmount": 1.0, + "ssaoFilterRadius": 16, + "ssaoRadius": 16, + "ssaoSamples": 16, + "textureMaxResolution": 4096, + "threadDGEvaluation": False, + "transparencyAlgorithm": 1, + "transparencyQuality": 0.33, + "useMaximumHardwareLights": True, + "vertexAnimationCache": 0 +} + + +def apply_view(panel, **options): + """Apply options to panel""" + + camera = cmds.modelPanel(panel, camera=True, query=True) + + # Display options + display_options = options.get("display_options", {}) + for key, value in display_options.items(): + if key in _DisplayOptionsRGB: + cmds.displayRGBColor(key, *value) + else: + cmds.displayPref(**{key: value}) + + # Camera options + camera_options = options.get("camera_options", {}) + for key, value in camera_options.items(): + cmds.setAttr("{0}.{1}".format(camera, key), value) + + # Viewport options + viewport_options = options.get("viewport_options", {}) + for key, value in viewport_options.items(): + cmds.modelEditor(panel, edit=True, **{key: value}) + + viewport2_options = options.get("viewport2_options", {}) + for key, value in viewport2_options.items(): + attr = "hardwareRenderingGlobals.{0}".format(key) + cmds.setAttr(attr, value) + + +def parse_active_panel(): + """Parse the active modelPanel. + + Raises + RuntimeError: When no active modelPanel an error is raised. + + Returns: + str: Name of modelPanel + + """ + + panel = cmds.getPanel(withFocus=True) + + # This happens when last focus was on panel + # that got deleted (e.g. `capture()` then `parse_active_view()`) + if not panel or "modelPanel" not in panel: + raise RuntimeError("No active model panel found") + + return panel + + +def parse_active_view(): + """Parse the current settings from the active view""" + panel = parse_active_panel() + return parse_view(panel) + + +def parse_view(panel): + """Parse the scene, panel and camera for their current settings + + Example: + >>> parse_view("modelPanel1") + + Arguments: + panel (str): Name of modelPanel + + """ + + camera = cmds.modelPanel(panel, query=True, camera=True) + + # Display options + display_options = {} + for key in DisplayOptions: + if key in _DisplayOptionsRGB: + display_options[key] = cmds.displayRGBColor(key, query=True) + else: + display_options[key] = cmds.displayPref(query=True, **{key: True}) + + # Camera options + camera_options = {} + for key in CameraOptions: + camera_options[key] = cmds.getAttr("{0}.{1}".format(camera, key)) + + # Viewport options + viewport_options = {} + + # capture plugin display filters first to ensure we never override + # built-in arguments if ever possible a plugin has similarly named + # plugin display filters (which it shouldn't!) + plugins = cmds.pluginDisplayFilter(query=True, listFilters=True) + for plugin in plugins: + plugin = str(plugin) # unicode->str for simplicity of the dict + state = cmds.modelEditor(panel, query=True, queryPluginObjects=plugin) + viewport_options[plugin] = state + + for key in ViewportOptions: + viewport_options[key] = cmds.modelEditor( + panel, query=True, **{key: True}) + + viewport2_options = {} + for key in Viewport2Options.keys(): + attr = "hardwareRenderingGlobals.{0}".format(key) + try: + viewport2_options[key] = cmds.getAttr(attr) + except ValueError: + continue + + return { + "camera": camera, + "display_options": display_options, + "camera_options": camera_options, + "viewport_options": viewport_options, + "viewport2_options": viewport2_options + } + + +def parse_active_scene(): + """Parse active scene for arguments for capture() + + *Resolution taken from render settings. + + """ + + time_control = mel.eval("$gPlayBackSlider = $gPlayBackSlider") + + return { + "start_frame": cmds.playbackOptions(minTime=True, query=True), + "end_frame": cmds.playbackOptions(maxTime=True, query=True), + "width": cmds.getAttr("defaultResolution.width"), + "height": cmds.getAttr("defaultResolution.height"), + "compression": cmds.optionVar(query="playblastCompression"), + "filename": (cmds.optionVar(query="playblastFile") + if cmds.optionVar(query="playblastSaveToFile") else None), + "format": cmds.optionVar(query="playblastFormat"), + "off_screen": (True if cmds.optionVar(query="playblastOffscreen") + else False), + "show_ornaments": (True if cmds.optionVar(query="playblastShowOrnaments") + else False), + "quality": cmds.optionVar(query="playblastQuality"), + "sound": cmds.timeControl(time_control, q=True, sound=True) or None + } + + +def apply_scene(**options): + """Apply options from scene + + Example: + >>> apply_scene({"start_frame": 1009}) + + Arguments: + options (dict): Scene options + + """ + + if "start_frame" in options: + cmds.playbackOptions(minTime=options["start_frame"]) + + if "end_frame" in options: + cmds.playbackOptions(maxTime=options["end_frame"]) + + if "width" in options: + cmds.setAttr("defaultResolution.width", options["width"]) + + if "height" in options: + cmds.setAttr("defaultResolution.height", options["height"]) + + if "compression" in options: + cmds.optionVar( + stringValue=["playblastCompression", options["compression"]]) + + if "filename" in options: + cmds.optionVar( + stringValue=["playblastFile", options["filename"]]) + + if "format" in options: + cmds.optionVar( + stringValue=["playblastFormat", options["format"]]) + + if "off_screen" in options: + cmds.optionVar( + intValue=["playblastFormat", options["off_screen"]]) + + if "show_ornaments" in options: + cmds.optionVar( + intValue=["show_ornaments", options["show_ornaments"]]) + + if "quality" in options: + cmds.optionVar( + floatValue=["playblastQuality", options["quality"]]) + + +@contextlib.contextmanager +def _applied_view(panel, **options): + """Apply options to panel""" + + original = parse_view(panel) + apply_view(panel, **options) + + try: + yield + finally: + apply_view(panel, **original) + + +@contextlib.contextmanager +def _independent_panel(width, height, off_screen=False): + """Create capture-window context without decorations + + Arguments: + width (int): Width of panel + height (int): Height of panel + + Example: + >>> with _independent_panel(800, 600): + ... cmds.capture() + + """ + + # center panel on screen + screen_width, screen_height = _get_screen_size() + topLeft = [int((screen_height-height)/2.0), + int((screen_width-width)/2.0)] + + window = cmds.window(width=width, + height=height, + topLeftCorner=topLeft, + menuBarVisible=False, + titleBar=False, + visible=not off_screen) + cmds.paneLayout() + panel = cmds.modelPanel(menuBarVisible=False, + label='CapturePanel') + + # Hide icons under panel menus + bar_layout = cmds.modelPanel(panel, q=True, barLayout=True) + cmds.frameLayout(bar_layout, edit=True, collapse=True) + + if not off_screen: + cmds.showWindow(window) + + # Set the modelEditor of the modelPanel as the active view so it takes + # the playback focus. Does seem redundant with the `refresh` added in. + editor = cmds.modelPanel(panel, query=True, modelEditor=True) + cmds.modelEditor(editor, edit=True, activeView=True) + + # Force a draw refresh of Maya so it keeps focus on the new panel + # This focus is required to force preview playback in the independent panel + cmds.refresh(force=True) + + try: + yield panel + finally: + # Delete the panel to fix memory leak (about 5 mb per capture) + cmds.deleteUI(panel, panel=True) + cmds.deleteUI(window) + + +@contextlib.contextmanager +def _applied_camera_options(options, panel, use_camera_sequencer=False): + """Context manager for applying `options` to the cameras used""" + + if use_camera_sequencer: + cameras = [ + cmds.shot(shot, query=True, currentCamera=True) + for shot in cmds.sequenceManager(listShots=True) + if not cmds.shot(shot, query=True, mute=True) + ] + else: + cameras = [cmds.modelPanel(panel, query=True, camera=True)] + + options = dict(CameraOptions, **(options or {})) + + old_options = defaultdict(dict) + for camera in cameras: + cam_options = options.copy() + for opt in cam_options: + try: + old_options[camera][opt] = cmds.getAttr(camera + "." + opt) + except: + sys.stderr.write("Could not get camera attribute " + "for capture: %s.%s" % (camera, opt)) + cam_options.pop(opt) + + for opt, value in cam_options.items(): + cmds.setAttr(camera + "." + opt, value) + + try: + yield + finally: + for camera, orig_options in old_options.items(): + if orig_options: + for opt, value in orig_options.items(): + cmds.setAttr(camera + "." + opt, value) + + +@contextlib.contextmanager +def _applied_display_options(options): + """Context manager for setting background color display options.""" + + options = dict(DisplayOptions, **(options or {})) + + colors = ['background', 'backgroundTop', 'backgroundBottom'] + preferences = ['displayGradient'] + + # Store current settings + original = {} + for color in colors: + original[color] = cmds.displayRGBColor(color, query=True) or [] + + for preference in preferences: + original[preference] = cmds.displayPref( + query=True, **{preference: True}) + + # Apply settings + for color in colors: + value = options[color] + cmds.displayRGBColor(color, *value) + + for preference in preferences: + value = options[preference] + cmds.displayPref(**{preference: value}) + + try: + yield + + finally: + # Restore original settings + for color in colors: + cmds.displayRGBColor(color, *original[color]) + for preference in preferences: + cmds.displayPref(**{preference: original[preference]}) + + +@contextlib.contextmanager +def _applied_viewport_options(options, panel): + """Context manager for applying `options` to `panel`""" + + options = dict(ViewportOptions, **(options or {})) + + # separate the plugin display filter options since they need to + # be set differently (see #55) + plugins = cmds.pluginDisplayFilter(query=True, listFilters=True) + plugin_options = dict() + for plugin in plugins: + if plugin in options: + plugin_options[plugin] = options.pop(plugin) + + # default options + cmds.modelEditor(panel, edit=True, **options) + + # plugin display filter options + for plugin, state in plugin_options.items(): + cmds.modelEditor(panel, edit=True, pluginObjects=(plugin, state)) + + yield + + +@contextlib.contextmanager +def _applied_viewport2_options(options): + """Context manager for setting viewport 2.0 options. + + These options are applied by setting attributes on the + "hardwareRenderingGlobals" node. + + """ + + options = dict(Viewport2Options, **(options or {})) + + # Store current settings + original = {} + for opt in options.copy(): + try: + original[opt] = cmds.getAttr("hardwareRenderingGlobals." + opt) + except ValueError: + options.pop(opt) + + # Apply settings + for opt, value in options.items(): + cmds.setAttr("hardwareRenderingGlobals." + opt, value) + + try: + yield + finally: + # Restore previous settings + for opt, value in original.items(): + cmds.setAttr("hardwareRenderingGlobals." + opt, value) + + +@contextlib.contextmanager +def _isolated_nodes(nodes, panel): + """Context manager for isolating `nodes` in `panel`""" + + if nodes is not None: + cmds.isolateSelect(panel, state=True) + for obj in nodes: + cmds.isolateSelect(panel, addDagObject=obj) + yield + + +@contextlib.contextmanager +def _maintained_time(): + """Context manager for preserving (resetting) the time after the context""" + + current_time = cmds.currentTime(query=1) + try: + yield + finally: + cmds.currentTime(current_time) + + +@contextlib.contextmanager +def _maintain_camera(panel, camera): + state = {} + + if not _in_standalone(): + cmds.lookThru(panel, camera) + else: + state = dict((camera, cmds.getAttr(camera + ".rnd")) + for camera in cmds.ls(type="camera")) + cmds.setAttr(camera + ".rnd", True) + + try: + yield + finally: + for camera, renderable in state.items(): + cmds.setAttr(camera + ".rnd", renderable) + + +@contextlib.contextmanager +def _disabled_inview_messages(): + """Disable in-view help messages during the context""" + original = cmds.optionVar(q="inViewMessageEnable") + cmds.optionVar(iv=("inViewMessageEnable", 0)) + try: + yield + finally: + cmds.optionVar(iv=("inViewMessageEnable", original)) + + +def _image_to_clipboard(path): + """Copies the image at path to the system's global clipboard.""" + if _in_standalone(): + raise Exception("Cannot copy to clipboard from Maya Standalone") + + image = QtGui.QImage(path) + clipboard = QtWidgets.QApplication.clipboard() + clipboard.setImage(image, mode=QtGui.QClipboard.Clipboard) + + +def _get_screen_size(): + """Return available screen size without space occupied by taskbar""" + if _in_standalone(): + return [0, 0] + + try: + rect = QtWidgets.QDesktopWidget().screenGeometry(-1) + except AttributeError: + # in Qt6 it is a different call + rect = QtWidgets.QApplication.primaryScreen().availableGeometry() + + return [rect.width(), rect.height()] + + +def _in_standalone(): + return not hasattr(cmds, "about") or cmds.about(batch=True) + + +# -------------------------------- +# +# Apply version specific settings +# +# -------------------------------- + +version = mel.eval("getApplicationVersionAsFloat") +if version > 2015: + Viewport2Options.update({ + "hwFogAlpha": 1.0, + "hwFogFalloff": 0, + "hwFogDensity": 0.1, + "hwFogEnable": False, + "holdOutDetailMode": 1, + "hwFogEnd": 100.0, + "holdOutMode": True, + "hwFogColorR": 0.5, + "hwFogColorG": 0.5, + "hwFogColorB": 0.5, + "hwFogStart": 0.0, + }) + ViewportOptions.update({ + "motionTrails": False + }) diff --git a/dwpicker/designer/editor.py b/dwpicker/designer/editor.py index a16e847..d8f849f 100644 --- a/dwpicker/designer/editor.py +++ b/dwpicker/designer/editor.py @@ -26,6 +26,7 @@ from dwpicker.designer.display import DisplayOptions from dwpicker.designer.menu import MenuWidget from dwpicker.designer.attributes import AttributeEditor +from dwpicker.designer.viewportwidget import ViewportWidget DIRECTION_OFFSETS = { @@ -39,6 +40,9 @@ def __init__(self, document, parent=None): title = "Picker editor - " + document.data['general']['name'] self.setWindowTitle(title) + self.splitter_layout = QtWidgets.QSplitter() + self.splitter_layout.setObjectName("SplitterLayout") + self.document = document self.document.shapes_changed.connect(self.update) self.document.general_option_changed.connect(self.generals_modified) @@ -47,6 +51,9 @@ def __init__(self, document, parent=None): self.display_options = DisplayOptions() + self.viewport_widget = ViewportWidget() + self.viewport_widget.addSnapshotRequested.connect(self.capture_snapshot) + self.shape_canvas = ShapeEditCanvas( self.document, self.display_options) self.shape_canvas.callContextMenu.connect(self.call_context_menu) @@ -67,6 +74,7 @@ def __init__(self, document, parent=None): self.menu.snapValuesChanged.connect(self.snap_value_changed) self.menu.buttonLibraryRequested.connect(self.call_library) self.menu.useSnapToggled.connect(self.use_snap) + self.menu.viewportToggled.connect(self.toggle_viewport) method = self.shape_canvas.set_lock_background_shape self.menu.lockBackgroundShapeToggled.connect(method) self.menu.undoRequested.connect(self.document.undo) @@ -115,18 +123,34 @@ def __init__(self, document, parent=None): self.attribute_editor.panelDoubleClicked.connect( self.shape_canvas.select_panel_shapes) + self.splitter_layout.addWidget(self.viewport_widget) + self.splitter_layout.addWidget(self.shape_canvas) + self.splitter_layout.setSizes([0, 1]) + self.hlayout = QtWidgets.QHBoxLayout() self.hlayout.setSizeConstraint(QtWidgets.QLayout.SetMaximumSize) self.hlayout.setContentsMargins(0, 0, 0, 0) - self.hlayout.addWidget(self.shape_canvas) + self.hlayout.addWidget(self.splitter_layout) self.hlayout.addWidget(self.attribute_editor) self.vlayout = QtWidgets.QVBoxLayout(self) + self.vlayout.setObjectName("VerticalLayout") self.vlayout.setContentsMargins(0, 0, 0, 0) self.vlayout.setSpacing(0) self.vlayout.addWidget(self.menu) self.vlayout.addLayout(self.hlayout) + def toggle_viewport(self): + """Collapse or expand the left widget""" + sizes = self.splitter_layout.sizes() + if sizes[0] > 0: # If left widget is visible + self.splitter_layout.setSizes([0, sizes[1]]) + else: + self.splitter_layout.setSizes([sizes[1] // 2, sizes[1] // 2]) + + def capture_snapshot(self, file=None): + self.create_shape(BACKGROUND, before=True, image=True, filepath=file) + def call_library(self, point): self.shape_library_menu.move(point) self.shape_library_menu.show() @@ -284,13 +308,16 @@ def create_library_shape(self, path): def create_shape( self, template, before=False, position=None, targets=None, - image=False): + image=False, filepath=None): options = deepcopy(template) panel = self.shape_canvas.display_options.current_panel options['panel'] = max((panel, 0)) if image: - filename = get_image_path(self, "Select background image.") + if filepath: + filename = filepath + else: + filename = get_image_path(self, "Select background image.") if filename: filename = format_path(filename) options['image.path'] = filename diff --git a/dwpicker/designer/menu.py b/dwpicker/designer/menu.py index 22fdfcf..2daaa3e 100644 --- a/dwpicker/designer/menu.py +++ b/dwpicker/designer/menu.py @@ -33,6 +33,7 @@ class MenuWidget(QtWidgets.QWidget): symmetryRequested = QtCore.Signal(bool) undoRequested = QtCore.Signal() useSnapToggled = QtCore.Signal(bool) + viewportToggled = QtCore.Signal() def __init__(self, display_options, parent=None): super(MenuWidget, self).__init__(parent=parent) @@ -89,6 +90,10 @@ def __init__(self, display_options, parent=None): self.hierarchy.setChecked(state) self.hierarchy.toggled.connect(self.toggle_hierarchy_display) + self.viewport = QtWidgets.QAction(icon('3d_axis.png'), '', self) + self.viewport.triggered.connect(self.viewportToggled.emit) + self.viewport.setToolTip('Toggle viewport') + self.snap = QtWidgets.QAction(icon('snap.png'), '', self) self.snap.setToolTip('Snap grid enable') self.snap.setCheckable(True) @@ -202,6 +207,7 @@ def __init__(self, display_options, parent=None): self.toolbar.addAction(self.lock_bg) self.toolbar.addAction(self.isolate) self.toolbar.addAction(self.hierarchy) + self.toolbar.addAction(self.viewport) self.toolbar.addSeparator() self.toolbar.addAction(self.snap) self.toolbar.addWidget(self.snapx) diff --git a/dwpicker/designer/viewportwidget.py b/dwpicker/designer/viewportwidget.py new file mode 100644 index 0000000..0662c41 --- /dev/null +++ b/dwpicker/designer/viewportwidget.py @@ -0,0 +1,336 @@ +import sys +import uuid + +import maya.OpenMayaUI as omui +from maya import cmds +from maya import mel + +from dwpicker.capture import snap + +from dwpicker.path import get_filename +from dwpicker.pyside import QtWidgets, QtCore, QtGui +from dwpicker.pyside import shiboken2 +from dwpicker.qtutils import icon + +if sys.version_info[0] == 3: + long = int + +IMAGE_SIZE_PRESETS = { + "Default": {"width": 960, "height": 540}, + "1080x1080": {"width": 1080, "height": 1080}, + "720x1000": {"width": 720, "height": 1000}, + "720x1280": {"width": 720, "height": 1280}, + "1080x1350": {"width": 1080, "height": 1350}, + "1080x1920": {"width": 1080, "height": 1920}, + "1280x720": {"width": 1280, "height": 720}, + "1920x1080": {"width": 1920, "height": 1080}, + "Custom": {"width": None, "height": None} +} + + +class ViewportWidget(QtWidgets.QWidget): + addSnapshotRequested = QtCore.Signal(str) + + def __init__(self, parent=None): + super(ViewportWidget, self).__init__(parent) + + self.notification = None + + self.setObjectName("ViewportWidget") + self.resize(320, 620) + + self.main_layout = QtWidgets.QVBoxLayout(self) + self.main_layout.setContentsMargins(5, 0, 0, 0) + self.main_layout.setObjectName("ViewportPickerLayout") + + layout = omui.MQtUtil.fullName(long(shiboken2.getCppPointer( + self.main_layout)[0])) + cmds.setParent(layout) + panel_layout_name = cmds.paneLayout() + + ptr = omui.MQtUtil.findControl(panel_layout_name) + self.panel_layout = shiboken2.wrapInstance(long(ptr), + QtWidgets.QWidget) + + cameras = cmds.ls(type="camera") + cameras = [cmds.listRelatives(cam, parent=True)[0] for cam in + cameras] + + picker_model_name = "PickerModelPanel" + str(uuid.uuid4())[:4] + self.camera_name = "front" + + if not self.camera_name in cameras: + camera_ = cmds.camera(n=self.camera_name)[0] + cmds.rename(camera_, self.camera_name) + + self.model_panel_name = cmds.modelPanel( + picker_model_name, menuBarVisible=False) + cmds.modelEditor(self.model_panel_name, + edit=True, + displayAppearance='smoothShaded', + camera=self.camera_name, + grid=True) + + ptr = omui.MQtUtil.findControl(self.model_panel_name) + self.model_panel_widget = shiboken2.wrapInstance(long(ptr), + QtWidgets.QWidget) + + self.cam_label = QtWidgets.QLabel("Cam") + + camera_combo_box = QtWidgets.QComboBox() + camera_combo_box.setToolTip("Cameras") + camera_combo_box.addItems(cameras) + camera_combo_box.currentTextChanged.connect( + lambda: self.update_camera_viewport(camera_combo_box, + picker_model_name)) + + self.lock_toggle = QtWidgets.QAction(icon('lock.png'), '', self) + self.lock_toggle.setToolTip("Toggle lock camera") + self.lock_toggle.triggered.connect( + lambda: toggle_camera_settings(picker_model_name, + self.camera_name, "lock_camera")) + + self.camera_toggle = QtWidgets.QAction(icon('camera.png'), '', self) + self.camera_toggle.setToolTip("Toggle orthographic view") + self.camera_toggle.triggered.connect( + lambda: toggle_camera_view(self.camera_name)) + + self.grid_toggle = QtWidgets.QAction(icon('grid.png'), '', self) + self.grid_toggle.setToolTip("Toggle grid view") + self.grid_toggle.triggered.connect( + lambda: toggle_grid_view(picker_model_name)) + + self.field_toggle = QtWidgets.QAction(icon('fieldChart.png'), '', self) + self.field_toggle.setToolTip("Toggle field chart") + self.field_toggle.triggered.connect( + lambda: toggle_camera_settings(picker_model_name, + self.camera_name, "field_chart")) + + self.resolution_toggle = QtWidgets.QAction(icon('resolutionGate.png'), + '', self) + self.resolution_toggle.setToolTip("Toggle resolution") + self.resolution_toggle.triggered.connect( + lambda: toggle_camera_settings(picker_model_name, + self.camera_name, "resolution")) + + image_size_combo_box = QtWidgets.QComboBox(self) + image_size_combo_box.setToolTip("Image sizes") + image_size_combo_box.setMaximumWidth(85) + for resolution in IMAGE_SIZE_PRESETS: + image_size_combo_box.addItem(resolution) + image_size_combo_box.currentTextChanged.connect( + lambda: self.update_resolution_settings(image_size_combo_box, + IMAGE_SIZE_PRESETS)) + + self.width_input = QtWidgets.QLineEdit() + self.width_input.setMaximumWidth(35) + self.width_input.setValidator(QtGui.QIntValidator(self)) + self.width_input.setVisible(False) + self.height_input = QtWidgets.QLineEdit() + self.height_input.setMaximumWidth(35) + self.height_input.setValidator(QtGui.QIntValidator(self)) + self.height_input.setVisible(False) + self.width_input.editingFinished.connect( + lambda: self.update_resolution_settings(image_size_combo_box, + IMAGE_SIZE_PRESETS, + self.width_input, + self.height_input)) + self.height_input.editingFinished.connect( + lambda: self.update_resolution_settings(image_size_combo_box, + IMAGE_SIZE_PRESETS, + self.width_input, + self.height_input)) + + self.snapshot = QtWidgets.QAction(icon('snapshot.png'), '', self) + self.snapshot.setToolTip("Capture snapshot") + self.snapshot.triggered.connect( + lambda: self.capture_snapshot(camera_combo_box)) + + self.toolbar = QtWidgets.QToolBar() + self.toolbar.setIconSize(QtCore.QSize(14, 14)) + toolbar_layout = self.toolbar.layout() + toolbar_layout.setSpacing(0) + self.toolbar.addWidget(self.cam_label) + self.toolbar.addWidget(camera_combo_box) + self.toolbar.addAction(self.lock_toggle) + self.toolbar.addAction(self.camera_toggle) + self.toolbar.addAction(self.grid_toggle) + self.toolbar.addAction(self.field_toggle) + self.toolbar.addAction(self.resolution_toggle) + self.toolbar.addWidget(image_size_combo_box) + self.width_action = self.toolbar.addWidget(self.width_input) + self.height_action = self.toolbar.addWidget(self.height_input) + self.toolbar.addAction(self.snapshot) + + self.main_layout.setSpacing(0) + self.main_layout.addWidget(self.toolbar) + self.main_layout.addWidget(self.panel_layout) + + def showEvent(self, event): + super(ViewportWidget, self).showEvent(event) + self.model_panel_widget.update() + + def update_camera_viewport(self, combo_box, panel): + """ + Update the camera in the active panel when a new camera is selected from the combo box. + """ + active_camera = combo_box.currentText() + cmds.modelPanel(panel, edit=True, camera=active_camera) + self.camera_name = active_camera + + def update_resolution_settings( + self, combobox, image_size, custom_width=None, custom_height=None): + resolution = combobox.currentText() + + if resolution == "Custom": + self.width_action.setVisible(True) + self.height_action.setVisible(True) + else: + self.width_action.setVisible(False) + self.height_action.setVisible(False) + + width = image_size[resolution]["width"] + height = image_size[resolution]["height"] + + if not (width and height): + if not (custom_width and custom_height): + return + if not custom_width.text(): + return + if not custom_height.text(): + return + + width = int(custom_width.text()) + height = int(custom_height.text()) + + try: + device_aspect_ratio = round(width / height, 3) + except ZeroDivisionError: + raise ZeroDivisionError("Height cannot be Zero") + + cmds.setAttr("defaultResolution.width", width) + cmds.setAttr("defaultResolution.height", height) + cmds.setAttr("defaultResolution.deviceAspectRatio", + device_aspect_ratio) + cmds.setAttr("defaultResolution.pixelAspect", 1) + + def capture_snapshot(self, combo_box): + active_camera = combo_box.currentText() + + filename = get_filename() + + snap(active_camera, + off_screen=True, + filename=filename, + frame_padding=0, + show_ornaments=False, + # clipboard=True, + maintain_aspect_ratio=True, + camera_options={"displayFieldChart": False}) + + # Instantiating the NotificationWidget during initialization can result in + # an incorrect notification position, as the parent widget is not + # fully created yet. + self.notification = NotificationWidget(self) + self.notification.show_notification("Snapshot done!") + + self.addSnapshotRequested.emit(filename + ".0.png") + + +class NotificationWidget(QtWidgets.QLabel): + def __init__(self, parent=None): + super(NotificationWidget, self).__init__(parent) + self.setStyleSheet(""" + background-color: grey; + color: white; + border-radius: 4px; + font-size: 14px; + font-weight: bold; + """) + self.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter) + self.setFixedSize(140, 30) + + parent_rect = parent.rect() + self.move((parent_rect.width() - self.width()) // 2, 140) + + self.timer = QtCore.QTimer(self) + self.timer.setSingleShot(True) + self.timer.timeout.connect(self.deleteLater) + + def show_notification(self, message="", duration=2000): + self.setText(message) + self.show() + self.timer.start(duration) + + +def ui_delete_callback(): + panels = cmds.getPanel(type="modelPanel") + for panel in panels: + if panel.startswith('PickerModelPanel') and cmds.modelPanel( + panel, + exists=True): + cmds.deleteUI(panel, panel=True) + + +def toggle_grid_view(panel): + current_grid_state = cmds.modelEditor(panel, query=True, grid=True) + set_state = not current_grid_state + cmds.modelEditor(panel, edit=True, grid=set_state) + + +def toggle_camera_view(camera_name): + """ + Adjusts the camera view based on its type (perspective or orthographic). + + Args: + camera_name (str): The name of the camera to adjust. + """ + is_perspective = not cmds.camera(camera_name, query=True, + orthographic=True) + up_dir = cmds.camera(camera_name, query=True, worldUp=True) + + camera_view = {"ortho": True} if is_perspective else {"perspective": True} + cmds.viewPlace( + camera_name, up=(up_dir[0], up_dir[1], up_dir[2]), **camera_view) + + +def toggle_camera_settings(panel, camera_name="persp", option="resolution"): + """ + Toggles the camera settings based on the option specified. + + Args: + panel (str): The panel's name used on the viewport. + camera_name (str): The name of the camera. Defaults to "persp". + option (str): The setting to toggle. Options include: + - 'lock_camera': Locks or unlocks the camera. + - 'resolution': Toggles the camera's resolution settings (displayResolution and overscan). + - 'field_chart': Toggles the display of the field chart. + """ + + if option == "lock_camera": + mel.eval("changeCameraLockStatus" + " %s;" % panel) + + if option == "resolution": + display_resolution = cmds.camera( + camera_name, query=True, displayResolution=True) + overscan_value = cmds.camera(camera_name, query=True, overscan=True) + + if display_resolution and overscan_value == 1.3: + cmds.camera( + camera_name, edit=True, displayFilmGate=False, + displayResolution=False, overscan=1.0) + return + cmds.camera( + camera_name, edit=True, displayFilmGate=False, + displayResolution=True, overscan=1.3) + + elif option == "field_chart": + field_chart_display = cmds.camera( + camera_name, query=True, displayFieldChart=True) + if field_chart_display: + cmds.camera(camera_name, edit=True, displayFieldChart=False) + return + cmds.camera(camera_name, edit=True, displayFieldChart=True) + + + diff --git a/dwpicker/icons/3d_axis.png b/dwpicker/icons/3d_axis.png new file mode 100644 index 0000000..180fd53 Binary files /dev/null and b/dwpicker/icons/3d_axis.png differ diff --git a/dwpicker/icons/camera.png b/dwpicker/icons/camera.png new file mode 100644 index 0000000..a3296e7 Binary files /dev/null and b/dwpicker/icons/camera.png differ diff --git a/dwpicker/icons/fieldChart.png b/dwpicker/icons/fieldChart.png new file mode 100644 index 0000000..e8c6c37 Binary files /dev/null and b/dwpicker/icons/fieldChart.png differ diff --git a/dwpicker/icons/grid.png b/dwpicker/icons/grid.png new file mode 100644 index 0000000..6bf9bbe Binary files /dev/null and b/dwpicker/icons/grid.png differ diff --git a/dwpicker/icons/lock.png b/dwpicker/icons/lock.png new file mode 100644 index 0000000..7f2bc36 Binary files /dev/null and b/dwpicker/icons/lock.png differ diff --git a/dwpicker/icons/resolutionGate.png b/dwpicker/icons/resolutionGate.png new file mode 100644 index 0000000..21282b8 Binary files /dev/null and b/dwpicker/icons/resolutionGate.png differ diff --git a/dwpicker/icons/snapshot.png b/dwpicker/icons/snapshot.png new file mode 100644 index 0000000..7b5da18 Binary files /dev/null and b/dwpicker/icons/snapshot.png differ diff --git a/dwpicker/main.py b/dwpicker/main.py index b6ff293..33c0d4f 100644 --- a/dwpicker/main.py +++ b/dwpicker/main.py @@ -40,6 +40,7 @@ load_local_picker_data, store_local_picker_data, clean_stray_picker_holder_nodes) from dwpicker.templates import PICKER, BACKGROUND +from dwpicker.designer.viewportwidget import ui_delete_callback ABOUT = """\ @@ -237,6 +238,7 @@ def show(self, *args, **kwargs): self.register_callbacks() def close_event(self): + ui_delete_callback() self.preferences_window.close() def list_scene_namespaces(self): diff --git a/dwpicker/path.py b/dwpicker/path.py index e7ff6f2..86a36db 100644 --- a/dwpicker/path.py +++ b/dwpicker/path.py @@ -1,11 +1,15 @@ import os +import uuid from maya import cmds +from dwpicker.pyside import QtWidgets from dwpicker.optionvar import ( AUTO_COLLAPSE_IMG_PATH_FROM_ENV, CUSTOM_PROD_PICKER_DIRECTORY, LAST_IMPORT_DIRECTORY, LAST_IMAGE_DIRECTORY_USED, LAST_OPEN_DIRECTORY, - OVERRIDE_PROD_PICKER_DIRECTORY_ENV, USE_PROD_PICKER_DIR_AS_DEFAULT) + OVERRIDE_PROD_PICKER_DIRECTORY_ENV, USE_PROD_PICKER_DIR_AS_DEFAULT, + save_optionvar) + def unix_path(path, isroot=False): @@ -78,3 +82,18 @@ def get_image_directory(): if directory: return directory return cmds.optionVar(query=LAST_IMAGE_DIRECTORY_USED) + +def get_filename(): + folder_path = get_picker_project_directory() + if not folder_path: + folder_path = QtWidgets.QFileDialog.getExistingDirectory(None, + "Select Directory", + "") + if folder_path: + save_optionvar(LAST_IMAGE_DIRECTORY_USED, folder_path) + else: + folder_path = cmds.optionVar(query=LAST_IMAGE_DIRECTORY_USED) + + filename = os.path.join(folder_path, "dwpicker-" + str(uuid.uuid4())) + + return filename \ No newline at end of file